cProfile добавляет значительные накладные расходы при вызове функций numba jit

Сравните чистую функцию Python no-op с функцией no-op, украшенной @numba.jit, то есть:

import numba

@numba.njit
def boring_numba():
    pass

def call_numba(x):
    for t in range(x):
        boring_numba()

def boring_normal():
    pass

def call_normal(x):
    for t in range(x):
        boring_normal()

Если мы время это с %timeitмы получаем следующее:

%timeit call_numba(int(1e7))
792 ms ± 5.51 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit call_normal(int(1e7))
737 ms ± 2.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Все совершенно разумно; есть небольшая нагрузка на функцию нумба, но не очень.

Однако, если мы используем cProfile чтобы профилировать этот код, мы получаем следующее:

cProfile.run('call_numba(int(1e7)); call_normal(int(1e7))', sort='cumulative')

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     76/1    0.003    0.000    8.670    8.670 {built-in method builtins.exec}
        1    6.613    6.613    7.127    7.127 experiments.py:10(call_numba)
        1    1.111    1.111    1.543    1.543 experiments.py:17(call_normal)
 10000000    0.432    0.000    0.432    0.000 experiments.py:14(boring_normal)
 10000000    0.428    0.000    0.428    0.000 experiments.py:6(boring_numba)
        1    0.000    0.000    0.086    0.086 dispatcher.py:72(compile)

cProfile считает, что вызов функции numba требует больших затрат. Это распространяется на "реальный" код: у меня была функция, которая просто называла мои дорогостоящие вычисления (вычисление компилируется с помощью numba-JIT), и cProfile сообщил, что функция оболочки занимает около трети от общего времени.

Я не против cProfile добавив немного накладных расходов, но если это в значительной степени противоречиво относительно того, где они добавляют эти накладные расходы, это не очень полезно. Кто-нибудь знает, почему это происходит, есть ли что-нибудь, что можно с этим сделать, и / или есть ли альтернативные инструменты профилирования, которые плохо взаимодействуют с numba?

1 ответ

Решение

Когда вы создаете функцию Numba, вы фактически создаете Numba Dispatcher объект. Этот объект "перенаправляет" вызов boring_numba к правильной (насколько это касается типов) внутренней "сшитой" функции. Таким образом, даже если вы создали функцию под названием boring_numba - эта функция не вызывается, то, что вызывается, является скомпилированной функцией, основанной на вашей функции.

Просто чтобы вы могли видеть, что функция boring_numba называется (хотя это не так, то, что называется CPUDispatcher.__call__) во время профилирования Dispatcher объект должен подключиться к текущему состоянию потока и проверить, работает ли профилировщик / трассировщик, и если "да", то он выглядит boring_numba Это последний шаг, который несет накладные расходы, потому что он должен подделать "кадр стека Python" для boring_numba,

Немного более технический:

Когда вы вызываете функцию Numba boring_numba это на самом деле вызывает Dispatcher_Call который является оберткой вокруг call_cfunc и вот основное отличие: когда у вас есть профилировщик, выполняющий код, работающий с профилировщиком, составляет большую часть вызова функции (просто сравните if (tstate->use_tracing && tstate->c_profilefunc) ветвь с else ветка, которая работает, если нет профилировщика / трассировщика):

static PyObject *
call_cfunc(DispatcherObject *self, PyObject *cfunc, PyObject *args, PyObject *kws, PyObject *locals)
{
    PyCFunctionWithKeywords fn;
    PyThreadState *tstate;
    assert(PyCFunction_Check(cfunc));
    assert(PyCFunction_GET_FLAGS(cfunc) == METH_VARARGS | METH_KEYWORDS);
    fn = (PyCFunctionWithKeywords) PyCFunction_GET_FUNCTION(cfunc);
    tstate = PyThreadState_GET();
    if (tstate->use_tracing && tstate->c_profilefunc)
    {
        /*
         * The following code requires some explaining:
         *
         * We want the jit-compiled function to be visible to the profiler, so we
         * need to synthesize a frame for it.
         * The PyFrame_New() constructor doesn't do anything with the 'locals' value if the 'code's
         * 'CO_NEWLOCALS' flag is set (which is always the case nowadays).
         * So, to get local variables into the frame, we have to manually set the 'f_locals'
         * member, then call `PyFrame_LocalsToFast`, where a subsequent call to the `frame.f_locals`
         * property (by virtue of the `frame_getlocals` function in frameobject.c) will find them.
         */
        PyCodeObject *code = (PyCodeObject*)PyObject_GetAttrString((PyObject*)self, "__code__");
        PyObject *globals = PyDict_New();
        PyObject *builtins = PyEval_GetBuiltins();
        PyFrameObject *frame = NULL;
        PyObject *result = NULL;

        if (!code) {
            PyErr_Format(PyExc_RuntimeError, "No __code__ attribute found.");
            goto error;
        }
        /* Populate builtins, which is required by some JITted functions */
        if (PyDict_SetItemString(globals, "__builtins__", builtins)) {
            goto error;
        }
        frame = PyFrame_New(tstate, code, globals, NULL);
        if (frame == NULL) {
            goto error;
        }
        /* Populate the 'fast locals' in `frame` */
        Py_XDECREF(frame->f_locals);
        frame->f_locals = locals;
        Py_XINCREF(frame->f_locals);
        PyFrame_LocalsToFast(frame, 0);
        tstate->frame = frame;
        C_TRACE(result, fn(PyCFunction_GET_SELF(cfunc), args, kws));
        tstate->frame = frame->f_back;

    error:
        Py_XDECREF(frame);
        Py_XDECREF(globals);
        Py_XDECREF(code);
        return result;
    }
    else
        return fn(PyCFunction_GET_SELF(cfunc), args, kws);
}

Я предполагаю, что этот дополнительный код (в случае, если работает профилировщик) замедляет функцию, когда вы cProfile-In.

Немного прискорбно, что функция numba добавляет столько накладных расходов, когда вы запускаете профилировщик, но замедление будет практически незначительным, если вы сделаете что-то существенное в функции numba. Если бы вы также переместить for цикл в функции Numba, то еще больше.

Если вы заметили, что функция numba (с запущенным или без использования профилировщика) занимает слишком много времени, вы, вероятно, вызываете ее слишком часто. Затем вы должны проверить, действительно ли вы можете переместить цикл внутри функции numba или обернуть код, содержащий цикл, в другую функцию numba.

Примечание: все это (немного) предположение, на самом деле я не собирал numba с символами отладки и не профилировал C-код на случай, если профилировщик работает. Однако количество операций, выполняемых профилировщиком, делает это весьма правдоподобным. И все это предполагает numba 0.39, не уверенный, относится ли это и к прошлым версиям.

Другие вопросы по тегам