Suporte do Python ao perfilador perf do Linux

autor:

Pablo Galindo

O perfilador perf do Linux é uma ferramenta muito poderosa que permite criar perfis e obter informações sobre o desempenho da sua aplicação. perf também possui um ecossistema muito vibrante de ferramentas que auxiliam na análise dos dados que produz.

O principal problema de usar o perfilador perf com aplicações Python é que perf apenas obtém informações sobre símbolos nativos, ou seja, os nomes de funções e procedimentos escritos em C. Isso significa que os nomes de funções Python e seus nomes de arquivos em seu código não aparecerão na saída de perf.

Desde o Python 3.12, o interpretador pode ser executado em um modo especial que permite que funções do Python apareçam na saída do criador de perfilador perf. Quando este modo está habilitado, o interpretador interporá um pequeno pedaço de código compilado instantaneamente antes da execução de cada função Python e ensinará perf a relação entre este pedaço de código e a função Python associada usando arquivos de mapa perf.

Nota

O suporte para o perfilador perf está atualmente disponível apenas para Linux em arquiteturas selecionadas. Verifique a saída da etapa de construção configure ou verifique a saída de python -m sysconfig | grep HAVE_PERF_TRAMPOLINE para ver se o seu sistema é compatível.

Por exemplo, considere o seguinte script:

def foo(n):
    result = 0
    for _ in range(n):
        result += 1
    return result

def bar(n):
    foo(n)

def baz(n):
    bar(n)

if __name__ == "__main__":
    baz(1000000)

Podemos executar perf para obter amostras de rastreamentos de pilha da CPU em 9999 hertz:

$ perf record -F 9999 -g -o perf.data python my_script.py

Então podemos usar perf report para analisar os dados:

$ perf report --stdio -n -g

# Children      Self       Samples  Command     Shared Object       Symbol
# ........  ........  ............  ..........  ..................  ..........................................
#
    91.08%     0.00%             0  python.exe  python.exe          [.] _start
            |
            ---_start
            |
                --90.71%--__libc_start_main
                        Py_BytesMain
                        |
                        |--56.88%--pymain_run_python.constprop.0
                        |          |
                        |          |--56.13%--_PyRun_AnyFileObject
                        |          |          _PyRun_SimpleFileObject
                        |          |          |
                        |          |          |--55.02%--run_mod
                        |          |          |          |
                        |          |          |           --54.65%--PyEval_EvalCode
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     |
                        |          |          |                     |--51.67%--_PyEval_EvalFrameDefault
                        |          |          |                     |          |
                        |          |          |                     |          |--11.52%--_PyLong_Add
                        |          |          |                     |          |          |
                        |          |          |                     |          |          |--2.97%--_PyObject_Malloc
...

Como você pode ver, as funções Python não são mostradas na saída, apenas _PyEval_EvalFrameDefault (a função que avalia o bytecode Python) aparece. Infelizmente isso não é muito útil porque todas as funções Python usam a mesma função C para avaliar bytecode, portanto não podemos saber qual função Python corresponde a qual função de avaliação de bytecode.

Em vez disso, se executarmos o mesmo experimento com o suporte perf ativado, obteremos:

$ perf report --stdio -n -g

# Children      Self       Samples  Command     Shared Object       Symbol
# ........  ........  ............  ..........  ..................  .....................................................................
#
    90.58%     0.36%             1  python.exe  python.exe          [.] _start
            |
            ---_start
            |
                --89.86%--__libc_start_main
                        Py_BytesMain
                        |
                        |--55.43%--pymain_run_python.constprop.0
                        |          |
                        |          |--54.71%--_PyRun_AnyFileObject
                        |          |          _PyRun_SimpleFileObject
                        |          |          |
                        |          |          |--53.62%--run_mod
                        |          |          |          |
                        |          |          |           --53.26%--PyEval_EvalCode
                        |          |          |                     py::<module>:/src/script.py
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     py::baz:/src/script.py
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     py::bar:/src/script.py
                        |          |          |                     _PyEval_EvalFrameDefault
                        |          |          |                     PyObject_Vectorcall
                        |          |          |                     _PyEval_Vector
                        |          |          |                     py::foo:/src/script.py
                        |          |          |                     |
                        |          |          |                     |--51.81%--_PyEval_EvalFrameDefault
                        |          |          |                     |          |
                        |          |          |                     |          |--13.77%--_PyLong_Add
                        |          |          |                     |          |          |
                        |          |          |                     |          |          |--3.26%--_PyObject_Malloc

Como habilitar o suporte a perfilação com perf

O suporte à perfilação com perf pode ser habilitado desde o início usando a variável de ambiente PYTHONPERFSUPPORT ou a opção -X perf, ou dinamicamente usando sys.activate_stack_trampoline() e sys.deactivate_stack_trampoline().

As funções sys têm precedência sobre a opção -X, a opção -X tem precedência sobre a variável de ambiente.

Exemplo usando a variável de ambiente:

$ PYTHONPERFSUPPORT=1 perf record -F 9999 -g -o perf.data python script.py
$ perf report -g -i perf.data

Exemplo usando a opção -X:

$ perf record -F 9999 -g -o perf.data python -X perf script.py
$ perf report -g -i perf.data

Exemplo usando as APIs de sys em example.py:

import sys

sys.activate_stack_trampoline("perf")
do_profiled_stuff()
sys.deactivate_stack_trampoline()

non_profiled_stuff()

… então:

$ perf record -F 9999 -g -o perf.data python ./example.py
$ perf report -g -i perf.data

Como obter os melhores resultados

Para melhores resultados, Python deve ser compilado com CFLAGS="-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer", pois isso permite que os perfiladores façam o desenrolamento de pilha (ou stack unwinding) usando apenas o ponteiro de quadro e não no DWARF informações de depuração. Isso ocorre porque como o código interposto para permitir o suporte perf é gerado dinamicamente, ele não possui nenhuma informação de depuração DWARF disponível.

Você pode verificar se o seu sistema foi compilado com este sinalizador executando:

$ python -m sysconfig | grep 'no-omit-frame-pointer'

Se você não vir nenhuma saída, significa que seu interpretador não foi compilado com ponteiros de quadro e, portanto, pode não ser capaz de mostrar funções Python na saída de perf.

Como trabalhar sem ponteiros de quadro

Se você estiver trabalhando com um interpretador Python que foi compilado sem ponteiros de quadro, você ainda pode usar o perfilador perf, mas a sobrecarga será um pouco maior porque o Python precisa gerar informações de desenrolamento para cada chamada de função Python em tempo real. Além disso, perf levará mais tempo para processar os dados porque precisará usar as informações de depuração DWARF para desenrolar a pilha e este é um processo lento.

Para habilitar esse modo, você pode usar a variável de ambiente PYTHON_PERF_JIT_SUPPORT ou a opção -X perf_jit, que habilitará o modo JIT para o perfilador perf.

Nota

Devido a um bug na ferramenta perf, apenas versões perf superiores à v6.8 funcionarão com o modo JIT. A correção também foi portada para a versão v6.7.2 da ferramenta.

Note que ao verificar a versão da ferramenta perf (o que pode ser feito executando perf version) você deve levar em conta que algumas distros adicionam alguns números de versão personalizados, incluindo um caractere -. Isso significa que perf 6.7-3 não é necessariamente perf 6.7.3.

Ao usar o modo JIT do perf, você precisa de uma etapa extra antes de poder executar perf report. Você precisa chamar o comando perf inject para injetar as informações JIT no arquivo perf.data.:

$ perf record -F 9999 -g --call-graph dwarf -o perf.data python -Xperf_jit my_script.py
$ perf inject -i perf.data --jit --output perf.jit.data
$ perf report -g -i perf.jit.data

ou usando a variável de ambiente:

$ PYTHON_PERF_JIT_SUPPORT=1 perf record -F 9999 -g --call-graph dwarf -o perf.data python my_script.py
$ perf inject -i perf.data --jit --output perf.jit.data
$ perf report -g -i perf.jit.data

O comando perf inject --jit lerá perf.data, pegará automaticamente o arquivo de dump perf que o Python cria (em /tmp/perf-$PID.dump) e, em seguida, criará perf.jit.data que mescla todas as informações JIT. Ele também deve criar muitos arquivos jitted-XXXX-N.so no diretório atual, que são imagens ELF para todos os trampolins JIT que foram criados pelo Python.

Aviso

Observe que ao usar --call-graph dwarf, a ferramenta perf fará snapshots da pilha do processo que está sendo perfilado e salvará as informações no arquivo perf.data. Por padrão, o tamanho do dump da pilha é 8192 bytes, mas o usuário pode alterar o tamanho passando o tamanho após a vírgula como --call-graph dwarf,4096. O tamanho do dump da pilha é importante porque se o tamanho for muito pequeno, perf não conseguirá desenrolar a pilha e a saída ficará incompleta. Por outro lado, se o tamanho for muito grande, então perf não conseguirá amostrar o processo com a frequência que gostaria, pois a sobrecarga será maior.