Pesquisa de técnicas de criação de perfil em C ++
Nesta resposta, usarei várias ferramentas diferentes para analisar alguns programas de teste muito simples, a fim de comparar concretamente como essas ferramentas funcionam.
O seguinte programa de teste é muito simples e faz o seguinte:
main
chamadas fast
e maybe_slow
3 vezes, uma das maybe_slow
chamadas que estão sendo lenta
A chamada lenta de maybe_slow
é 10x mais longa e domina o tempo de execução se considerarmos as chamadas para a função filho common
. Idealmente, a ferramenta de criação de perfil poderá apontar para a chamada lenta específica.
both fast
e maybe_slow
call common
, que representam a maior parte da execução do programa
A interface do programa é:
./main.out [n [seed]]
e o programa faz O(n^2)
loops no total. seed
é apenas obter uma saída diferente sem afetar o tempo de execução.
main.c
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
uint64_t __attribute__ ((noinline)) common(uint64_t n, uint64_t seed) {
for (uint64_t i = 0; i < n; ++i) {
seed = (seed * seed) - (3 * seed) + 1;
}
return seed;
}
uint64_t __attribute__ ((noinline)) fast(uint64_t n, uint64_t seed) {
uint64_t max = (n / 10) + 1;
for (uint64_t i = 0; i < max; ++i) {
seed = common(n, (seed * seed) - (3 * seed) + 1);
}
return seed;
}
uint64_t __attribute__ ((noinline)) maybe_slow(uint64_t n, uint64_t seed, int is_slow) {
uint64_t max = n;
if (is_slow) {
max *= 10;
}
for (uint64_t i = 0; i < max; ++i) {
seed = common(n, (seed * seed) - (3 * seed) + 1);
}
return seed;
}
int main(int argc, char **argv) {
uint64_t n, seed;
if (argc > 1) {
n = strtoll(argv[1], NULL, 0);
} else {
n = 1;
}
if (argc > 2) {
seed = strtoll(argv[2], NULL, 0);
} else {
seed = 0;
}
seed += maybe_slow(n, seed, 0);
seed += fast(n, seed);
seed += maybe_slow(n, seed, 1);
seed += fast(n, seed);
seed += maybe_slow(n, seed, 0);
seed += fast(n, seed);
printf("%" PRIX64 "\n", seed);
return EXIT_SUCCESS;
}
gprof
O gprof requer recompilar o software com a instrumentação e também usa uma abordagem de amostragem junto com essa instrumentação. Portanto, ele encontra um equilíbrio entre precisão (a amostragem nem sempre é totalmente precisa e pode pular funções) e desaceleração da execução (instrumentação e amostragem são técnicas relativamente rápidas que não diminuem muito a execução).
O gprof é incorporado ao GCC / binutils, então tudo o que precisamos fazer é compilar com a -pg
opção de ativar o gprof. Em seguida, executamos o programa normalmente com um parâmetro CLI de tamanho que produz uma duração razoável de alguns segundos ( 10000
):
gcc -pg -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
time ./main.out 10000
Por motivos educacionais, também faremos uma corrida sem as otimizações ativadas. Observe que isso é inútil na prática, pois você normalmente se preocupa apenas em otimizar o desempenho do programa otimizado:
gcc -pg -ggdb3 -O0 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
./main.out 10000
Primeiro, time
nos diz que o tempo de execução com e sem -pg
o mesmo foi, o que é ótimo: sem lentidão! No entanto, vi relatos de desacelerações 2x - 3x em software complexo, por exemplo, como mostrado neste tíquete .
Como compilamos -pg
, a execução do programa produz um gmon.out
arquivo contendo os dados de criação de perfil.
Podemos observar graficamente esse arquivo, gprof2dot
conforme solicitado em: É possível obter uma representação gráfica dos resultados do gprof?
sudo apt install graphviz
python3 -m pip install --user gprof2dot
gprof main.out > main.gprof
gprof2dot < main.gprof | dot -Tsvg -o output.svg
Aqui, a gprof
ferramenta lê as gmon.out
informações de rastreamento e gera um relatório legível por humanos main.gprof
, que é gprof2dot
lido para gerar um gráfico.
A fonte do gprof2dot está em: https://github.com/jrfonseca/gprof2dot
Observamos o seguinte para a -O0
execução:
e para a -O3
corrida:
A -O0
saída é praticamente auto-explicativa. Por exemplo, mostra que as 3 maybe_slow
chamadas e as chamadas filho ocupam 97,56% do tempo de execução total, embora a execução de maybe_slow
si mesma sem filhos represente 0,00% do tempo total de execução, ou seja, quase todo o tempo gasto nessa função foi gasto em chamadas de criança.
TODO: por que está main
faltando na -O3
saída, mesmo que eu possa vê-la em um bt
no GDB? Função ausente da saída do GProf Eu acho que é porque o gprof também está baseado em amostragem, além de sua instrumentação compilada, e -O3
main
é muito rápido e não tem amostras.
Eu escolho a saída SVG em vez de PNG porque o SVG é pesquisável com Ctrl + F e o tamanho do arquivo pode ser cerca de 10x menor. Além disso, a largura e a altura da imagem gerada podem ser enormes, com dezenas de milhares de pixels para softwares complexos, e o GNOME eog
3.28.1 aparece nesse caso para PNGs, enquanto os SVGs são abertos automaticamente pelo meu navegador. O gimp 2.8 funcionou bem, veja também:
mas, mesmo assim, você arrastará muito a imagem para encontrar o que deseja; veja, por exemplo, esta imagem de um exemplo de software "real" extraído deste ticket :
Você consegue encontrar facilmente a pilha de chamadas mais crítica com todas essas minúsculas linhas de espaguete sem classificação passando uma sobre a outra? Pode haver dot
opções melhores , tenho certeza, mas não quero ir para lá agora. O que realmente precisamos é de um visualizador dedicado adequado, mas ainda não encontrei um:
No entanto, você pode usar o mapa de cores para atenuar um pouco esses problemas. Por exemplo, na enorme imagem anterior, finalmente consegui encontrar o caminho crítico à esquerda quando fiz a dedução brilhante de que o verde vem depois do vermelho, seguido finalmente do azul mais escuro e mais escuro.
Como alternativa, também podemos observar a saída de texto da gprof
ferramenta binutils incorporada, que salvamos anteriormente em:
cat main.gprof
Por padrão, isso produz uma saída extremamente detalhada que explica o que os dados de saída significam. Como não posso explicar melhor do que isso, vou deixar você ler você mesmo.
Depois de entender o formato de saída de dados, você pode reduzir a verbosidade para mostrar apenas os dados sem o tutorial com a -b
opção:
gprof -b main.out
No nosso exemplo, os resultados foram para -O0
:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls s/call s/call name
100.35 3.67 3.67 123003 0.00 0.00 common
0.00 3.67 0.00 3 0.00 0.03 fast
0.00 3.67 0.00 3 0.00 1.19 maybe_slow
Call graph
granularity: each sample hit covers 2 byte(s) for 0.27% of 3.67 seconds
index % time self children called name
0.09 0.00 3003/123003 fast [4]
3.58 0.00 120000/123003 maybe_slow [3]
[1] 100.0 3.67 0.00 123003 common [1]
-----------------------------------------------
<spontaneous>
[2] 100.0 0.00 3.67 main [2]
0.00 3.58 3/3 maybe_slow [3]
0.00 0.09 3/3 fast [4]
-----------------------------------------------
0.00 3.58 3/3 main [2]
[3] 97.6 0.00 3.58 3 maybe_slow [3]
3.58 0.00 120000/123003 common [1]
-----------------------------------------------
0.00 0.09 3/3 main [2]
[4] 2.4 0.00 0.09 3 fast [4]
0.09 0.00 3003/123003 common [1]
-----------------------------------------------
Index by function name
[1] common [4] fast [3] maybe_slow
e para -O3
:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls us/call us/call name
100.52 1.84 1.84 123003 14.96 14.96 common
Call graph
granularity: each sample hit covers 2 byte(s) for 0.54% of 1.84 seconds
index % time self children called name
0.04 0.00 3003/123003 fast [3]
1.79 0.00 120000/123003 maybe_slow [2]
[1] 100.0 1.84 0.00 123003 common [1]
-----------------------------------------------
<spontaneous>
[2] 97.6 0.00 1.79 maybe_slow [2]
1.79 0.00 120000/123003 common [1]
-----------------------------------------------
<spontaneous>
[3] 2.4 0.00 0.04 fast [3]
0.04 0.00 3003/123003 common [1]
-----------------------------------------------
Index by function name
[1] common
Como um resumo muito rápido para cada seção, por exemplo:
0.00 3.58 3/3 main [2]
[3] 97.6 0.00 3.58 3 maybe_slow [3]
3.58 0.00 120000/123003 common [1]
centra-se na função que é deixada recuada ( maybe_flow
). [3]
é o ID dessa função. Acima da função, estão os chamadores e, abaixo, os callees.
Pois -O3
, veja aqui como na saída gráfica que maybe_slow
e fast
não possui um pai conhecido, que é o que a documentação diz que <spontaneous>
significa.
Não tenho certeza se existe uma boa maneira de criar perfil linha por linha com o gprof: tempo `gprof` gasto em linhas de código específicas
valgrind callgrind
O valgrind executa o programa através da máquina virtual valgrind. Isso torna a criação de perfil muito precisa, mas também produz uma desaceleração muito grande do programa. Também mencionei o kcachegrind anteriormente em: Ferramentas para obter um gráfico de chamada de função pictórica do código
callgrind é a ferramenta do valgrind para criar um perfil do código e o kcachegrind é um programa do KDE que pode visualizar a saída do cachegrind.
Primeiro, precisamos remover o -pg
sinalizador para voltar à compilação normal, caso contrário, a execução falhará comProfiling timer expired
, sim, isso é tão comum que eu fiz e houve uma pergunta de estouro de pilha.
Então, compilamos e executamos como:
sudo apt install kcachegrind valgrind
gcc -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
time valgrind --tool=callgrind valgrind --dump-instr=yes \
--collect-jumps=yes ./main.out 10000
Eu habilito --dump-instr=yes --collect-jumps=yes
porque isso também despeja informações que nos permitem exibir uma quebra de desempenho por linha de montagem, a um custo adicional relativamente pequeno.
Logo de cara, time
diz-nos que o programa levou 29,5 segundos para ser executado; portanto, tivemos um abrandamento de cerca de 15x neste exemplo. Claramente, essa desaceleração será uma limitação séria para cargas de trabalho maiores. No "exemplo de software do mundo real" mencionado aqui , observei uma desaceleração de 80x.
A execução gera um arquivo de dados de perfil chamado, callgrind.out.<pid>
por exemplo, callgrind.out.8554
no meu caso. Vemos esse arquivo com:
kcachegrind callgrind.out.8554
que mostra uma GUI que contém dados semelhantes à saída textual do gprof:
Além disso, se formos na parte inferior direita da guia "Call Graph", vemos um gráfico de chamada que podemos exportar clicando com o botão direito do mouse para obter a seguinte imagem com quantidades irracionais de borda branca :-)
Acho que fast
não está aparecendo nesse gráfico porque o kcachegrind deve ter simplificado a visualização, porque essa chamada leva muito pouco tempo; esse provavelmente será o comportamento que você deseja em um programa real. O menu do botão direito do mouse possui algumas configurações para controlar quando selecionar esses nós, mas não consegui mostrar uma ligação tão curta depois de uma tentativa rápida. Se eu clicar na fast
janela esquerda, ele mostra um gráfico de chamada com fast
, de modo que a pilha foi realmente capturada. Ninguém ainda havia encontrado uma maneira de mostrar o gráfico de chamada do gráfico completo: Faça com que o callgrind mostre todas as chamadas de função no gráfico de chamada kcachegrind
TODO em software C ++ complexo, vejo algumas entradas do tipo <cycle N>
, por exemplo, <cycle 11>
onde eu esperaria nomes de funções, o que isso significa? Percebi que existe um botão "Detecção de ciclo" para ativar e desativar esse recurso, mas o que isso significa?
perf
de linux-tools
perf
parece usar exclusivamente mecanismos de amostragem de kernel do Linux. Isso facilita a configuração, mas também não é totalmente preciso.
sudo apt install linux-tools
time perf record -g ./main.out 10000
Isso adicionou 0,2s à execução, por isso estamos bem no tempo, mas ainda não vejo muito interesse depois de expandir o common
nó com a seta à direita do teclado:
Samples: 7K of event 'cycles:uppp', Event count (approx.): 6228527608
Children Self Command Shared Object Symbol
- 99.98% 99.88% main.out main.out [.] common
common
0.11% 0.11% main.out [kernel] [k] 0xffffffff8a6009e7
0.01% 0.01% main.out [kernel] [k] 0xffffffff8a600158
0.01% 0.00% main.out [unknown] [k] 0x0000000000000040
0.01% 0.00% main.out ld-2.27.so [.] _dl_sysdep_start
0.01% 0.00% main.out ld-2.27.so [.] dl_main
0.01% 0.00% main.out ld-2.27.so [.] mprotect
0.01% 0.00% main.out ld-2.27.so [.] _dl_map_object
0.01% 0.00% main.out ld-2.27.so [.] _xstat
0.00% 0.00% main.out ld-2.27.so [.] __GI___tunables_init
0.00% 0.00% main.out [unknown] [.] 0x2f3d4f4944555453
0.00% 0.00% main.out [unknown] [.] 0x00007fff3cfc57ac
0.00% 0.00% main.out ld-2.27.so [.] _start
Então, eu tento avaliar o -O0
programa para ver se isso mostra alguma coisa, e só agora, finalmente, vejo um gráfico de chamada:
Samples: 15K of event 'cycles:uppp', Event count (approx.): 12438962281
Children Self Command Shared Object Symbol
+ 99.99% 0.00% main.out [unknown] [.] 0x04be258d4c544155
+ 99.99% 0.00% main.out libc-2.27.so [.] __libc_start_main
- 99.99% 0.00% main.out main.out [.] main
- main
- 97.54% maybe_slow
common
- 2.45% fast
common
+ 99.96% 99.85% main.out main.out [.] common
+ 97.54% 0.03% main.out main.out [.] maybe_slow
+ 2.45% 0.00% main.out main.out [.] fast
0.11% 0.11% main.out [kernel] [k] 0xffffffff8a6009e7
0.00% 0.00% main.out [unknown] [k] 0x0000000000000040
0.00% 0.00% main.out ld-2.27.so [.] _dl_sysdep_start
0.00% 0.00% main.out ld-2.27.so [.] dl_main
0.00% 0.00% main.out ld-2.27.so [.] _dl_lookup_symbol_x
0.00% 0.00% main.out [kernel] [k] 0xffffffff8a600158
0.00% 0.00% main.out ld-2.27.so [.] mmap64
0.00% 0.00% main.out ld-2.27.so [.] _dl_map_object
0.00% 0.00% main.out ld-2.27.so [.] __GI___tunables_init
0.00% 0.00% main.out [unknown] [.] 0x552e53555f6e653d
0.00% 0.00% main.out [unknown] [.] 0x00007ffe1cf20fdb
0.00% 0.00% main.out ld-2.27.so [.] _start
TODO: o que aconteceu na -O3
execução? É simplesmente isso maybe_slow
e fast
foi rápido demais e não obteve nenhuma amostra? Funciona bem -O3
em programas maiores que demoram mais para serem executados? Perdi alguma opção de CLI? Eu descobri que estava prestes -F
a controlar a frequência da amostra no Hertz, mas -F 39500
aumentei para o máximo permitido por padrão de (poderia ser aumentado comsudo
) e ainda não vejo chamadas claras.
Uma coisa interessante perf
é a ferramenta FlameGraph de Brendan Gregg, que exibe os tempos da pilha de chamadas de uma maneira bem organizada que permite ver rapidamente as grandes chamadas. A ferramenta está disponível em: https://github.com/brendangregg/FlameGraph e também é mencionado em sua perf tutorial em: http://www.brendangregg.com/perf.html#FlameGraphs Quando eu corri perf
sem sudo
cheguei ERROR: No stack counts found
então para agora eu vou fazer isso com sudo
:
git clone https://github.com/brendangregg/FlameGraph
sudo perf record -F 99 -g -o perf_with_stack.data ./main.out 10000
sudo perf script -i perf_with_stack.data | FlameGraph/stackcollapse-perf.pl | FlameGraph/flamegraph.pl > flamegraph.svg
mas em um programa tão simples a saída não é muito fácil de entender, pois não podemos ver nem maybe_slow
nem facilmente fast
esse gráfico:
No exemplo mais complexo, fica claro o significado do gráfico:
TODO, há um log de [unknown]
funções nesse exemplo, por que isso?
Outras interfaces GU perf que podem valer a pena incluem:
Plug-in do Eclipse Trace Compass: https://www.eclipse.org/tracecompass/
Mas isso tem a desvantagem de que você precisa primeiro converter os dados para o Common Trace Format, o que pode ser feito perf data --to-ctf
, mas precisa ser ativado no tempo de compilação / ter perf
novo o suficiente, um dos quais não é o caso do perf Ubuntu 18.04
https://github.com/KDAB/hotspot
A desvantagem disso é que parece não haver um pacote Ubuntu, e sua construção requer o Qt 5.10 enquanto o Ubuntu 18.04 está no Qt 5.9.
gperftools
Anteriormente chamado de "Ferramentas de desempenho do Google", fonte: https://github.com/gperftools/gperftools Baseado em amostra.
Primeiro instale o gperftools com:
sudo apt install google-perftools
Em seguida, podemos ativar o perfilador de CPU gperftools de duas maneiras: em tempo de execução ou em tempo de construção.
Em tempo de execução, precisamos passar o conjunto LD_PRELOAD
para apontar para o libprofiler.so
qual você pode encontrar locate libprofiler.so
, por exemplo, no meu sistema:
gcc -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libprofiler.so \
CPUPROFILE=prof.out ./main.out 10000
Como alternativa, podemos construir a biblioteca no tempo do link, dispensando a passagem LD_PRELOAD
no tempo de execução:
gcc -Wl,--no-as-needed,-lprofiler,--as-needed -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
CPUPROFILE=prof.out ./main.out 10000
Veja também: gperftools - arquivo de perfil não despejado
A maneira mais agradável de visualizar esses dados que encontrei até agora é tornar a saída do pprof no mesmo formato que o kcachegrind usa como entrada (sim, a ferramenta Valgrind-project-viewer-tool) e usar o kcachegrind para visualizar o seguinte:
google-pprof --callgrind main.out prof.out > callgrind.out
kcachegrind callgrind.out
Após executar com qualquer um desses métodos, obtemos um prof.out
arquivo de dados de perfil como saída. Podemos visualizar esse arquivo graficamente como um SVG com:
google-pprof --web main.out prof.out
que fornece um gráfico de chamada familiar como outras ferramentas, mas com a unidade desajeitada do número de amostras em vez de segundos.
Como alternativa, também podemos obter alguns dados textuais com:
google-pprof --text main.out prof.out
que dá:
Using local file main.out.
Using local file prof.out.
Total: 187 samples
187 100.0% 100.0% 187 100.0% common
0 0.0% 100.0% 187 100.0% __libc_start_main
0 0.0% 100.0% 187 100.0% _start
0 0.0% 100.0% 4 2.1% fast
0 0.0% 100.0% 187 100.0% main
0 0.0% 100.0% 183 97.9% maybe_slow
Veja também: Como usar as ferramentas do google perf
Testado no Ubuntu 18.04, gprof2dot 2019.11.30, valgrind 3.13.0, perf 4.15.18, kernel Linux 4.15.0, FLameGraph 1a0dc6985aad06e76857cf2a354bd5ba0c9ce96b, gperftools 2.5-2.