ThreadMXBean.getCurrentThreadUserTime() do OpenJDK foi substituído de parsing de arquivo em /proc por chamada a clock_gettime(), alcançando até 400x de ganho de desempenho
- A implementação anterior passava por um caminho complexo de I/O, abrindo, lendo e fazendo parsing do arquivo
/proc/self/task/<tid>/stat
- A nova implementação aproveita a codificação em bits de
clockid_t do kernel Linux para ajustar os bits inferiores do ID obtido por pthread_getcpuclockid(), permitindo consultar diretamente apenas o tempo de usuário
- Nos benchmarks, o tempo médio por chamada caiu de 11μs para 279ns e, depois, com a aplicação do fast-path do kernel, houve cerca de 13% de melhoria adicional
- É um caso que mostra como é possível otimizar indo além das restrições do POSIX, por meio da compreensão da ABI interna do Linux
Problemas da implementação anterior
getCurrentThreadUserTime() abria o arquivo /proc/self/task/<tid>/stat e fazia o parsing do 13º e 14º campos para calcular o tempo de CPU em modo usuário
- Era necessário passar por múltiplas etapas, como criar o caminho do arquivo, abrir o arquivo, ler o buffer, fazer parsing da string e chamar
sscanf()
- Como o nome do comando pode conter parênteses, havia também uma lógica complexa com
strrchr() para localizar o último )
- Em contrapartida,
getCurrentThreadCpuTime() fazia apenas uma única chamada a clock_gettime(CLOCK_THREAD_CPUTIME_ID)
- Segundo o bug report de 2018 (JDK-8210452), a diferença de velocidade entre os dois métodos chegava a 30x a 400x
Comparação entre o caminho de acesso via /proc e o caminho com clock_gettime()
- O método via
/proc incluía várias chamadas de sistema e geração de strings dentro do kernel, como open(), read(), sscanf() e close()
- O método com
clock_gettime() lia diretamente o valor de tempo da estrutura sched_entity com uma única chamada de sistema
- Sob carga paralela, o acesso a
/proc sofria atrasos maiores por causa da contenção de locks no kernel
Nova forma de implementação
- O padrão POSIX define que
CLOCK_THREAD_CPUTIME_ID deve retornar o tempo de usuário + sistema
- O kernel Linux codifica o tipo de relógio nos bits inferiores de
clockid_t
00=PROF, 01=VIRT (apenas usuário), 10=SCHED (usuário + sistema)
- Ao trocar os bits inferiores do
clockid obtido por pthread_getcpuclockid() para 01, é possível convertê-lo em um relógio exclusivo de tempo de usuário
- No novo código, o I/O de arquivo e o parsing foram removidos, e o tempo de usuário passa a ser retornado apenas com uma chamada a
clock_gettime()
Resultados de medição de desempenho
- Antes da correção, o tempo médio por chamada era de 11,186μs; depois da correção, caiu para 0,279μs, uma melhora de cerca de 40x
- Medido em um ambiente com 16 threads, em linha com a faixa originalmente reportada de 30x a 400x
- No perfil de CPU, desaparecem as chamadas de sistema relacionadas a abertura e fechamento de arquivos, restando apenas uma única chamada a
clock_gettime()
Otimização adicional com o fast-path do kernel
- O kernel oferece um fast-path que acessa diretamente a thread atual quando o
clockid contém PID=0 codificado
- Se a JVM montar diretamente esse
clockid em vez de usar pthread_getcpuclockid(), é possível pular a busca na radix tree
- Com
clockid montado manualmente, o tempo médio caiu de 81,7ns para 70,8ns, cerca de 13% de melhoria adicional
- No entanto, como isso depende de detalhes internos da implementação do kernel, como o tamanho de
clockid_t, há preocupação com perda de legibilidade e compatibilidade
Conclusão e lições
- A remoção de 40 linhas eliminou uma diferença de desempenho de 400x, sem novos recursos do kernel, apenas aproveitando detalhes da ABI existente
- Reforça o valor de estudar o código-fonte do kernel: o POSIX garante portabilidade, mas o código do kernel mostra os limites do que é possível
- Destaca também a importância de revisar premissas antigas: fazer parsing de
/proc fazia sentido no passado, mas hoje é ineficiente
- Essa mudança será incluída no JDK 26 (previsto para março de 2026), trazendo ganho automático de desempenho nas chamadas de
ThreadMXBean.getCurrentThreadUserTime()
5 comentários
Impressionante.
Acho que não é totalmente falso, mas, quando envolve o kernel, imagino que perceber que estava lento já deve ter sido muito difícil.
Como será que dá para descobrir esse tipo de coisa em um projeto? Acho que seria difícil perceber só rodando IA...
Quando vejo casos assim, penso que eu também quero aprender e viver esse tipo de experiência com certeza.
Na verdade, já é difícil conseguir uma melhora de 2 a 3 vezes refazendo o código inteiro, então obter até 400 vezes de ganho de desempenho simplesmente mudando algumas linhas é realmente impressionante.
Comentários do Hacker News
Descobri que a pergunta “quanto tempo de CPU esta thread usou?” é uma operação muito cara mais do que eu imaginava
Sem uma referência no nível de um relógio atômico, acho difícil fazer afirmações sobre valores absolutos
clock_gettime()evita troca de contexto via vDSO. Por isso esse rastro também aparece no flamegraphCLOCK_VIRTouCLOCK_SCHED, ainda é necessário fazer chamada de syscallCLOCK_THREAD_CPUTIME_IDacaba indo para o kernel de qualquer forma, porque precisa consultar a task structVeja o código do kernel relacionado em posix-cpu-timers.c,
cputime.c,
gettimeofday.c
PERF_COUNT_SW_TASK_CLOCK, também dá para medir algo em torno de 8 nsA abordagem é ler da página compartilhada por meio de
perf_event_mmap_pagee calcular o delta com uma chamadardtscIsso é pouco documentado e quase não há implementações open source
perf_evente os requisitos de permissão são grandes, parece mais adequado para threads de longa duraçãoseqlocké necessário. Seria para evitar que ocorra uma troca de contexto entre os valores da página e ordtsc?Provavelmente a estrutura é verificar o valor da página de novo depois do
rdtsce, se tiver mudado, tentar novamenteComo referência,
clock_gettimetambém é uma syscall virtual baseada em vdsoclock_gettimenão usa syscall, e sim vdsoOlhando só o código, tudo parece ok, mas quando se vê o flamegraph muitas vezes a reação é “que diabos é isso?!”
Já encontrei vários problemas assim, como inicialização que não era estática e uma chamada de logger de uma linha causando serialização cara
Normalmente uso o gerador HTML do async-profiler, mas desta vez usei a ferramenta do Brendan para obter um único SVG
/proc, profiling com eBPF e a história de uma ABI de user space pouco documentadaOrganizei mais detalhes neste post do meu blog
Tweet de origem
O blog do Jaromir também foi realmente ótimo