函数调用开销 - 为什么内置的Python函数似乎比我的自定义函数更快?

5

我一直对开销感兴趣,所以我写了一个最小的C扩展程序,导出了两个函数nopstarnop,它们几乎什么都不做,只是通过它们的输入(这两个相关的函数位于顶部,其余的只是乏味的样板代码):

amanmodule.c:

#include <Python.h>

static PyObject* aman_nop(PyObject *self, PyObject *args)
{
  PyObject *obj;

  if (!PyArg_UnpackTuple(args, "arg", 1, 1, &obj))
    return NULL;
  Py_INCREF(obj);
  return obj;
}

static PyObject* aman_starnop(PyObject *self, PyObject *args)
{
  Py_INCREF(args);
  return args;
}

static PyMethodDef AmanMethods[] = {
  {"nop",  (PyCFunction)aman_nop, METH_VARARGS,
   PyDoc_STR("nop(arg) -> arg\n\nReturn arg unchanged.")},
  {"starnop", (PyCFunction)aman_starnop, METH_VARARGS,
   PyDoc_STR("starnop(*args) -> args\n\nReturn tuple of args unchanged")},
  {NULL, NULL}
};

static struct PyModuleDef amanmodule = {
    PyModuleDef_HEAD_INIT,
    "aman",
    "aman - a module about nothing.\n\n"
    "Provides functions 'nop' and 'starnop' which do nothing:\n"
    "nop(arg) -> arg; starnop(*args) -> args\n",
    -1,
    AmanMethods
};

PyMODINIT_FUNC
PyInit_aman(void)
{
    return PyModule_Create(&amanmodule);
}

setup.py:

from setuptools import setup, extension

setup(name='aman', version='1.0',
      ext_modules=[extension.Extension('aman', ['amanmodule.c'])],
      author='n.n.',
      description="""aman - a module about nothing

      Provides functions 'nop' and 'starnop' which do nothing:
      nop(arg) -> arg; starnop(*args) -> args
      """,
      license='public domain',
      keywords='nop pass-through identity')

接下来,我会将它们与纯Python实现以及一些几乎什么都不做的内置函数进行时间比较:
import numpy as np
from aman import nop, starnop
from timeit import timeit

def mnsd(x): return '{:8.6f} \u00b1 {:8.6f} \u00b5s'.format(np.mean(x), np.std(x))

def pnp(x): x

globals={}
for globals['nop'] in (int, bool, (0).__add__, hash, starnop, nop, pnp, lambda x: x):
    print('{:60s}'.format(repr(globals['nop'])),
          mnsd([timeit('nop(1)', globals=globals) for i in range(10)]),
          '  ',
          mnsd([timeit('nop(True)',globals=globals) for i in range(10)]))

第一个问题,我的方法论没有什么愚蠢的地方吧?

每个1,000,000次调用的10个块的结果:

<class 'int'>                                                0.099754 ± 0.003917 µs    0.103933 ± 0.000585 µs
<class 'bool'>                                               0.097711 ± 0.000661 µs    0.094412 ± 0.000612 µs
<method-wrapper '__add__' of int object at 0x8c7000>         0.065146 ± 0.000728 µs    0.064976 ± 0.000605 µs
<built-in function hash>                                     0.039546 ± 0.000671 µs    0.039566 ± 0.000452 µs
<built-in function starnop>                                  0.056490 ± 0.000873 µs    0.056234 ± 0.000181 µs
<built-in function nop>                                      0.060094 ± 0.000799 µs    0.059959 ± 0.000170 µs
<function pnp at 0x7fa31c0512f0>                             0.090452 ± 0.001077 µs    0.098479 ± 0.003314 µs
<function <lambda> at 0x7fa31c051378>                        0.086387 ± 0.000817 µs    0.086536 ± 0.000714 µs

现在我的实际问题是:即使我的nops是用C编写的并且什么也不做(starnop甚至不解析它的参数),内置函数hash仍然更快。我知道在Python中,整数是它们自己的哈希值,所以hash在这里也是一个nop,但它并不比我的nops更慢,那么为什么会有速度差异呢?
更新:完全忘记了:我在一个相当标准的x86_64机器上,linux gcc4.8.5。我使用python3 setup.py install --user安装扩展程序。

你是如何编译你的C代码的?你还没有告诉我们这个至关重要的信息。另外,Python是如何被编译的?在Python中是否启用了任何优化,而你的代码中没有? - autistic
你知道吗?在提出这个问题之前,通过做一些研究,你可以节省很多打字的时间。开始研究的明智之处是阅读编译器手册页面,它会告诉你关于许多微妙的优化和其他有用信息,这些信息你以后可能会问到。 - autistic
你编译 C 代码的方式不正确。请展示一下你是如何编译 C 代码的... - autistic
1
@Sebivor 放心,正如我所解释的,并且即将被接受的答案证实的那样,编译器问题是一个理论上可能但不太可能的解释。Python构建系统和setuptools非常复杂,在像这样的简单情况下,setuptools会为您完成整个构建过程,使用与构建Python时相同的编译器设置 - 它为什么要做任何不同的事情呢?我展示的那一行文字就是我需要做的唯一的事情。 - Paul Panzer
在阅读手册时放松是很好的练习。你最不想要的是在脑海中听到一个愤怒、滥用的语气来阅读手册;你可能会错误地认为编写手册的人因为感到恼怒而变得啰嗦。 - autistic
显示剩余2条评论
1个回答

4
很多Python函数调用的开销都在于创建args元组。参数解析也会增加一些开销。
使用METH_VARARGS调用约定定义的函数需要创建一个元组来存储所有参数。如果只需要单个参数,可以使用METH_O调用约定。使用METH_O时,不会创建元组,而是直接传递单个参数。我已经在你的示例中添加了一个使用METH_Onop1
可以使用METH_NOARGS定义不需要参数的函数,以获得最小的开销。请参见nop2
在使用METH_VARARGS时,可以通过直接解析args元组而不是调用PyArg_UnpackTuple或相关的PyArg_函数来稍微减少开销。这样会稍微快一些。请参见nop3
内置的hash()函数使用METH_O调用约定。
修改后的amanmodule.c
#include <Python.h>

static PyObject* aman_nop(PyObject *self, PyObject *args)
{
  PyObject *obj;

  if (!PyArg_UnpackTuple(args, "arg", 1, 1, &obj))
    return NULL;
  Py_INCREF(obj);
  return obj;
}

static PyObject* aman_nop1(PyObject *self, PyObject *other)
{
  Py_INCREF(other);
  return other;
}

static PyObject* aman_nop2(PyObject *self)
{
  Py_RETURN_NONE;
}

static PyObject* aman_nop3(PyObject *self, PyObject *args)
{
  PyObject *obj;

  if (PyTuple_GET_SIZE(args) == 1) {
    obj = PyTuple_GET_ITEM(args, 0);
    Py_INCREF(obj);
    return obj;
  }
  else {
    PyErr_SetString(PyExc_TypeError, "nop3 requires 1 argument");
    return NULL;
  }
}

static PyObject* aman_starnop(PyObject *self, PyObject *args)
{
  Py_INCREF(args);
  return args;
}

static PyMethodDef AmanMethods[] = {
  {"nop",  (PyCFunction)aman_nop, METH_VARARGS,
   PyDoc_STR("nop(arg) -> arg\n\nReturn arg unchanged.")},
  {"nop1",  (PyCFunction)aman_nop1, METH_O,
   PyDoc_STR("nop(arg) -> arg\n\nReturn arg unchanged.")},
  {"nop2",  (PyCFunction)aman_nop2, METH_NOARGS,
   PyDoc_STR("nop(arg) -> arg\n\nReturn arg unchanged.")},
  {"nop3",  (PyCFunction)aman_nop3, METH_VARARGS,
   PyDoc_STR("nop(arg) -> arg\n\nReturn arg unchanged.")},
  {"starnop", (PyCFunction)aman_starnop, METH_VARARGS,
   PyDoc_STR("starnop(*args) -> args\n\nReturn tuple of args unchanged")},
  {NULL, NULL}
};

static struct PyModuleDef amanmodule = {
    PyModuleDef_HEAD_INIT,
    "aman",
    "aman - a module about nothing.\n\n"
    "Provides functions 'nop' and 'starnop' which do nothing:\n"
    "nop(arg) -> arg; starnop(*args) -> args\n",
    -1,
    AmanMethods
};

PyMODINIT_FUNC
PyInit_aman(void)
{
    return PyModule_Create(&amanmodule);
}

修改后的 test.py 文件

import numpy as np
from aman import nop, nop1, nop2, nop3, starnop
from timeit import timeit

def mnsd(x): return '{:8.6f} \u00b1 {:8.6f} \u00b5s'.format(np.mean(x), np.std(x))

def pnp(x): x

globals={}
for globals['nop'] in (int, bool, (0).__add__, hash, starnop, nop, nop1, nop3, pnp, lambda x: x):
    print('{:60s}'.format(repr(globals['nop'])),
          mnsd([timeit('nop(1)', globals=globals) for i in range(10)]),
          '  ',
          mnsd([timeit('nop(True)',globals=globals) for i in range(10)]))

# To test with no arguments
for globals['nop'] in (nop2,):
    print('{:60s}'.format(repr(globals['nop'])),
          mnsd([timeit('nop()', globals=globals) for i in range(10)]),
          '  ',
          mnsd([timeit('nop()',globals=globals) for i in range(10)]))

结果

$ python3 test.py  
<class 'int'>                                                0.080414 ± 0.004360 µs    0.086166 ± 0.003216 µs
<class 'bool'>                                               0.080501 ± 0.008929 µs    0.075601 ± 0.000598 µs
<method-wrapper '__add__' of int object at 0xa6dca0>         0.045652 ± 0.004229 µs    0.044146 ± 0.000114 µs
<built-in function hash>                                     0.035122 ± 0.003317 µs    0.033419 ± 0.000136 µs
<built-in function starnop>                                  0.044056 ± 0.001300 µs    0.044280 ± 0.001629 µs
<built-in function nop>                                      0.047297 ± 0.000777 µs    0.049536 ± 0.007577 µs
<built-in function nop1>                                     0.030402 ± 0.001423 µs    0.031249 ± 0.002352 µs
<built-in function nop3>                                     0.044673 ± 0.004041 µs    0.042936 ± 0.000177 µs
<function pnp at 0x7f946342d840>                             0.071846 ± 0.005377 µs    0.071085 ± 0.003314 µs
<function <lambda> at 0x7f946342d8c8>                        0.066621 ± 0.001499 µs    0.067163 ± 0.002962 µs
<built-in function nop2>                                     0.027736 ± 0.001487 µs    0.027035 ± 0.000397 µs

哇,非常感谢!我会再等一下,但我有点怀疑是否会有更好的答案出现。 - Paul Panzer
1
再次感谢您提供完整、详尽且愉悦的答案。同时,我的“nop”速度也有了相当不错的提升。 - Paul Panzer

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接