为什么 float() 比 int() 更快?

33

经过一些代码实验和微基准测试,我刚刚发现在一个包含整数的字符串上使用float函数比使用相同字符串上的int函数快2倍。

>>> python -m timeit int('1')
1000000 loops, best of 3: 0.548 usec per loop

>>> python -m timeit float('1')
1000000 loops, best of 3: 0.273 usec per loop

当测试 int(float('1')) 时,运行时间甚至比裸的 int('1') 还要短,这更加奇怪。

>>> python -m timeit int(float('1'))
1000000 loops, best of 3: 0.457 usec per loop

我测试了在运行cPython 2.7.6的Windows 7和Linux Mint 16上的代码。

我必须补充一点,只有Python 2受到影响,Python 3在运行时差异较小(不显著)。

我知道通过这种微基准测试获得的信息很容易被滥用,但我很好奇为什么函数的运行时间会有这样的差异。

我试图找到intfloat的实现,但在源代码中找不到。


1
无法复制差异 jakob@devbox:~$ python -m timeit "int("1")" 10000000次循环,3次中的最佳结果:每个循环0.104微秒 jakob@devbox:~$ python -m timeit "float("1")" 10000000次循环,3次中的最佳结果:每个循环0.106微秒 - Jakob Bowyer
7
因为这是学习新事物的绝佳机会,也许我们所获得的知识太狭窄以至于无法应用于其他领域,但这并不会阻止好奇心。 - loopbackbee
4
关于源代码:请在源代码树的“Objects/”子目录中查找。当前2.7分支的mercurial链接: floatobject.cintobject.c。寻找 float_newint_new 函数。 - Martijn Pieters
6
如果使用你的命令行参数,我可以重现你的计时结果,但是你的命令行参数有错误。你没有转义引号,因此解析的是一个字面上的整数值1,而不是字符串! - Martijn Pieters
1
记录一下,我可以在2.7.5上重现你的发现,但在3.3.2上,我发现裸整型比其他方式快近两倍。 - wim
显示剩余14条评论
3个回答

18

int 有很多进制。

*, 0*, 0x*, 0b*, 0o*,它可以是 long,需要时间来确定基数和其他事情。

如果设置了基数,就可以节省很多时间。

python -m timeit "int('1',10)"       
1000000 loops, best of 3: 0.252 usec per loop

python -m timeit "int('1')"   
1000000 loops, best of 3: 0.594 usec per loop

正如@Martijn Pieters所提到的,代码中涉及了Object/intobject.c(int_new)Object/floatobject.c(float_new)


3
这并不解释为什么在Python 3中int('1')float('1')的速度一样快。 - Martijn Pieters
5
基本的判断只是扫描第一个字符;如果它不是0,那么base被设为10。这几乎不会让它的使用时间增加一倍。Python 3 也有完全相同的测试,因此这不可能是原因。 - Martijn Pieters
在Python 3.3中,所有整数似乎都是长整型(不存在'intobject.c')。请参阅http://docs.python.org/3.3/c-api/long.html和http://docs.python.org/2.7/c-api/long.html以及http://docs.python.org/2.7/c-api/int.html。 - xbello
我使用了分析器。找出要使用的基数并不是花费时间的原因。@MartijnPieters是正确的,PyInt_FromString + PyOS_strtoul仅占整个int版本总时间的5%。可能巨大的额外解释器开销来自于确定是调用一参数还是二参数版本的int?发现设置显式基数可以加快速度,+1,但猜测实际上扫描字符串以查找基数需要时间是错误的,-1。我可以重现这种效果。 - Peter Cordes
3
明确声明一下:这个答案完全是错误的。 int(“1”)从不寻找基数,它总是使用基数10。你需要明确设置第二个参数为0,以便Python甚至查找输入字符串中的基数字符,这种情况比int('1')的基本情况更快。而且没有支持解析带有“L”的int字面值,也没有“long”解析(str(1L)生成'1',而不是'1L',因此不需要反转)。 - Martijn Pieters
显示剩余5条评论

9

int() 需要考虑的转换类型比 float() 要多。当您将单个对象传递给 int() 且它不是整数时,会进行各种测试:

  1. 如果它已经是一个整数,则直接使用它
  2. 如果该对象实现了 __int__ 方法, 则调用它并使用结果
  3. 如果该对象是继承自 C 的 int 子类,则到内部提取结构中的 C 整数值转换为 int() 对象。
  4. 如果该对象实现了 __trunc__ 方法, 则调用它并使用结果
  5. 如果该对象是字符串,则将其转换为基数设置为 10 的整数。
当您传递基数参数时,这些测试都不会执行,代码直接跳转到使用所选基数将字符串转换为整数。这是因为没有其他可接受的类型,当有给定基数时。因此,当您传递基数时,从字符串创建整数会变得更快。
$ bin/python -m timeit "int('1')"
1000000 loops, best of 3: 0.469 usec per loop
$ bin/python -m timeit "int('1', 10)"
1000000 loops, best of 3: 0.277 usec per loop
$ bin/python -m timeit "float('1')"
1000000 loops, best of 3: 0.206 usec per loop

当你将一个字符串传递给 float() 时,首先要进行的测试是查看参数是否为字符串对象(而不是子类),此时它正在被解析。没有必要测试其他类型。
因此,int('1') 调用比 int('1', 10)float('1') 进行了更多的测试。这些测试中,测试1、2和3非常快;它们只是指针检查。但第四个测试使用了 C 中等效的 getattr(obj, '__trunc__'),这是相对昂贵的。这必须测试实例和字符串的完整 MRO,并且没有缓存,最终会引发一个 AttributeError(),格式化一个错误消息,没有人会看到这个消息。所有这些工作在这里都是无用的。
在Python 3中,getattr()调用已被替换为更快的代码。这是因为在Python 3中,不需要考虑旧式类,因此可以直接在实例的类型(类、type(instance)的结果)上查找属性,并且跨MRO的类属性查找此时已被缓存。不需要创建任何异常。 float()对象实现了__int__方法,这就是为什么int(float('1'))更快的原因;在第4步中,你永远不会触发__trunc__属性测试,因为第2步已经产生了结果。
如果你想查看Python 2的C代码,请首先查看int_new()方法。解析参数后,代码基本上执行以下操作:
if (base == -909)  // no base argument given, the default is -909
    return PyNumber_Int(x);  // parse an integer from x, an arbitrary type. 
if (PyString_Check(x)) {
    // do some error handling; there is a base, so parse the string with the base
    return PyInt_FromString(string, NULL, base);
}

没有基础的情况下调用PyNumber_Int()函数,该函数执行以下操作:

if (PyInt_CheckExact(o)) {
    // 1. it's an integer already
    // ...
}
m = o->ob_type->tp_as_number;
if (m && m->nb_int) { /* This should include subclasses of int */
    // 2. it has an __int__ method, return the result
    // ...
}
if (PyInt_Check(o)) { /* An int subclass without nb_int */
    // 3. it's an int subclass, extract the value
    // ...
}
trunc_func = PyObject_GetAttr(o, trunc_name);
if (trunc_func) {
    // 4. it has a __trunc__ method, call it and process the result
    // ...
}
if (PyString_Check(o))
    // 5. it's a string, lets parse!
    return int_from_string(PyString_AS_STRING(o),
                           PyString_GET_SIZE(o));

在这里,int_from_string() 实际上是对 PyInt_FromString(string, length, 10) 的封装,所以使用基数 10 来解析字符串。

在 Python 3 中,intobject 被移除,只剩下被重命名为 int()longobject。同样地,unicode 已经替代了 str。因此现在我们看到 long_new(),并且使用 PyUnicode_Check() 而不是 PyString_Check() 来检测字符串。

if (obase == NULL)
    return PyNumber_Long(x);

// bounds checks on the obase argument, storing a conversion in base

if (PyUnicode_Check(x))
    return PyLong_FromUnicodeObject(x, (int)base);

因此,当没有设置基础时,我们需要查看{{link1:PyNumber_Long()}},它会执行:

if (PyLong_CheckExact(o)) {
    // 1. it's an integer already
    // ...
}
m = o->ob_type->tp_as_number;
if (m && m->nb_int) { /* This should include subclasses of int */
    // 2. it has an __int__ method
    // ...
}
trunc_func = _PyObject_LookupSpecial(o, &PyId___trunc__);
if (trunc_func) {
    // 3. it has a __trunc__ method
    // ...
}
if (PyUnicode_Check(o))
    // 5. it's a string
    return PyLong_FromUnicodeObject(o, 10);

请注意_PyObject_LookupSpecial()调用,这是特殊方法查找的实现;它最终使用_PyType_Lookup(),该函数使用缓存;由于没有str.__trunc__方法,该缓存将在第一次MRO扫描后永远返回空值。此方法也不会引发异常,它只会返回所请求的方法或空值。 float()处理字符串的方式在Python 2和3之间没有变化,因此您只需要查看Python 2 float_new()函数,对于字符串来说非常简单:
// test for subclass and retrieve the single x argument
/* If it's a string, but not a string subclass, use
   PyFloat_FromString. */
if (PyString_CheckExact(x))
    return PyFloat_FromString(x, NULL);
return PyNumber_Float(x);

对于字符串对象,我们直接转换为解析,否则使用PyNumber_Float()查找实际的float对象,或具有__float__方法的对象,或者字符串子类。

这揭示了一种可能的优化方式:如果int()首先测试PyString_CheckExact()是否符合所有其他类型测试,那么在处理字符串时它将与float()一样快。 PyString_CheckExact()排除了具有__int____trunc__方法的字符串子类,因此是一个很好的第一次测试。


为了回应其他答案将此归咎于基本解析(因此寻找 0b0o00x 前缀,不区分大小写),默认的 int() 调用使用单个字符串参数确实会寻找一个基数,该基数硬编码为 10。在这种情况下传入带有前缀的字符串是错误的。
>>> int('0x1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '0x1'

只有在将第二个参数显式设置为0时,才会执行基本前缀解析:
>>> int('0x1', 0)
1

由于没有对__trunc__进行测试,因此base=0前缀解析情况的速度与显式设置base为任何其他支持的值一样快:
$ python2.7 -m timeit "int('1')"
1000000 loops, best of 3: 0.472 usec per loop
$ python2.7 -m timeit "int('1', 10)"
1000000 loops, best of 3: 0.268 usec per loop
$ python2.7 bin/python -m timeit "int('1', 0)"
1000000 loops, best of 3: 0.271 usec per loop
$ python2.7 bin/python -m timeit "int('0x1', 0)"
1000000 loops, best of 3: 0.261 usec per loop

2
这只是一些数据和观察结果,不是完整的答案。
在运行Linux 4.15.8-1-ARCH的3.9GHz Skylake i7-6700k上,使用Python 2.7.14进行性能分析。 float:每个循环使用0.0854微秒。 int:每个循环使用0.196微秒。(所以大约相差2倍)

float

$ perf record python2.7 -m timeit 'float("1")'
10000000 loops, best of 3: 0.0854 usec per loop

Samples: 14K of event 'cycles:uppp', Event count (approx.): 13685905532
Overhead  Command    Shared Object        Symbol
  29.73%  python2.7  libpython2.7.so.1.0  [.] PyEval_EvalFrameEx
   8.54%  python2.7  libpython2.7.so.1.0  [.] _Py_dg_strtod
   8.30%  python2.7  libpython2.7.so.1.0  [.] vgetargskeywords
   5.81%  python2.7  libpython2.7.so.1.0  [.] lookdict_string.lto_priv.1492
   4.79%  python2.7  libpython2.7.so.1.0  [.] PyFloat_FromString
   4.67%  python2.7  libpython2.7.so.1.0  [.] tupledealloc.lto_priv.335
   4.16%  python2.7  libpython2.7.so.1.0  [.] float_new.lto_priv.219
   3.93%  python2.7  libpython2.7.so.1.0  [.] _PyOS_ascii_strtod
   3.54%  python2.7  libc-2.26.so         [.] __strchr_avx2
   3.34%  python2.7  libpython2.7.so.1.0  [.] PyOS_string_to_double
   3.21%  python2.7  libpython2.7.so.1.0  [.] PyTuple_New
   3.05%  python2.7  libpython2.7.so.1.0  [.] type_call.lto_priv.51
   2.69%  python2.7  libpython2.7.so.1.0  [.] PyObject_Call
   2.15%  python2.7  libpython2.7.so.1.0  [.] PyArg_ParseTupleAndKeywords
   1.88%  python2.7  itertools.so         [.] _init
   1.78%  python2.7  libpython2.7.so.1.0  [.] _Py_set_387controlword
   1.19%  python2.7  libpython2.7.so.1.0  [.] _Py_get_387controlword
   1.10%  python2.7  libpython2.7.so.1.0  [.] vgetargskeywords.cold.59
   1.07%  python2.7  libpython2.7.so.1.0  [.] PyType_IsSubtype
   1.07%  python2.7  libc-2.26.so         [.] __memset_avx2_unaligned_erms
   ...

我不知道为什么Python会与x87控制字搞在一起,但是确实小型函数_Py_get_387controlword运行了fnstcw WORD PTR [rsp+0x6],然后将其重新加载到eax中作为整数返回值,使用movzx,但可能更多的时间用于写入和检查来自-fstack-protector-strong的栈canary。
这很奇怪,因为_Py_dg_strtod使用SSE2(cvtsi2sd xmm1,rsi)进行FP数学运算,而不是x87。(这个输入的热点部分主要是整数,但其中有mulsddivsd。) x86-64代码通常只使用x87进行long double(80位浮点数)操作。dg_strtod代表David Gay的字符串转换为双精度浮点数。有关它在底层如何工作的有趣博客文章
请注意,此函数仅占总运行时间的9%。相比于调用strtod并且丢弃结果的C循环,其余部分基本上是解释器开销。

int

$ perf record python2.7 -m timeit 'int("1")'
10000000 loops, best of 3: 0.196 usec per loop

$ perf report -Mintel
Samples: 32K of event 'cycles:uppp', Event count (approx.): 31257616633
Overhead  Command    Shared Object        Symbol
  29.00%  python2.7  libpython2.7.so.1.0  [.] PyString_FromFormatV
  13.11%  python2.7  libpython2.7.so.1.0  [.] PyEval_EvalFrameEx
   5.49%  python2.7  libc-2.26.so         [.] __strlen_avx2
   3.87%  python2.7  libpython2.7.so.1.0  [.] vgetargskeywords
   3.68%  python2.7  libpython2.7.so.1.0  [.] PyNumber_Int
   3.10%  python2.7  libpython2.7.so.1.0  [.] PyInt_FromString
   2.75%  python2.7  libpython2.7.so.1.0  [.] PyErr_Restore
   2.68%  python2.7  libc-2.26.so         [.] __strchr_avx2
   2.41%  python2.7  libpython2.7.so.1.0  [.] tupledealloc.lto_priv.335
   2.10%  python2.7  libpython2.7.so.1.0  [.] PyObject_Call
   2.00%  python2.7  libpython2.7.so.1.0  [.] PyOS_strtoul
   1.93%  python2.7  libpython2.7.so.1.0  [.] lookdict_string.lto_priv.1492
   1.87%  python2.7  libpython2.7.so.1.0  [.] _PyObject_GenericGetAttrWithDict
   1.73%  python2.7  libpython2.7.so.1.0  [.] PyString_FromStringAndSize
   1.71%  python2.7  libc-2.26.so         [.] __memmove_avx_unaligned_erms
   1.67%  python2.7  libpython2.7.so.1.0  [.] PyTuple_New
   1.63%  python2.7  libpython2.7.so.1.0  [.] PyObject_Malloc
   1.48%  python2.7  libpython2.7.so.1.0  [.] int_new.lto_priv.68
   1.45%  python2.7  libpython2.7.so.1.0  [.] PyErr_Format
   1.45%  python2.7  libpython2.7.so.1.0  [.] PyObject_Realloc
   1.37%  python2.7  libpython2.7.so.1.0  [.] type_call.lto_priv.51
   1.30%  python2.7  libpython2.7.so.1.0  [.] PyOS_strtol
   1.23%  python2.7  libpython2.7.so.1.0  [.] _PyString_Resize
   1.16%  python2.7  libc-2.26.so         [.] __ctype_b_loc
   1.11%  python2.7  libpython2.7.so.1.0  [.] _PyType_Lookup
   1.06%  python2.7  libpython2.7.so.1.0  [.] PyString_AsString
   1.04%  python2.7  libpython2.7.so.1.0  [.] PyArg_ParseTupleAndKeywords
   1.02%  python2.7  libpython2.7.so.1.0  [.] PyObject_Free
   0.93%  python2.7  libpython2.7.so.1.0  [.] PyInt_FromLong
   0.90%  python2.7  libpython2.7.so.1.0  [.] PyObject_GetAttr
   0.52%  python2.7  libc-2.26.so         [.] __memset_avx2_unaligned_erms
   0.52%  python2.7  libpython2.7.so.1.0  [.] vgetargskeywords.cold.59
   0.48%  python2.7  itertools.so         [.] _init
   ...

请注意, PyEval_EvalFrameEx 在 int 的总时间中占据13%,而在 float 的总时间中占据30%。 这大约是相同的绝对时间,而 PyString_FromFormatV 花费的时间是它的两倍。 再加上更多函数需要更多小块时间。

我还没有弄清楚 PyInt_FromString 做了什么,或者它正在花费时间。 其中7%的周期计数被收取到一个 movdqu xmm0,[rsi] 指令附近; 即加载由引用传递的16字节参数(作为第2个函数参数)。 如果存储该内存的任何内容都很慢,则此内存可能会获得比应得的更多的计数。 (有关在每个周期中有许多不同工作正在进行的乱序执行Intel CPU上如何将周期计数充电到指令的更多信息,请参见此Q&A 。)或者,如果此内存最近使用单独的较窄存储器写入,则可能从存储转发障碍中获得计数。

令人惊讶的是, strlen 花费了如此多的时间。 从其中的指令剖面来看,它正在获得短字符串,但并非仅限于1字节字符串。 看起来是 len&lt; 32 字节和 64&lt; len&gt; = 32 字节的混合。 可能有趣的是在gdb中设置断点,并查看常见参数。

浮点版本具有 strchr (可能正在寻找。小数点?),但没有任何 strlen 。 令人惊讶的是, int 版本必须重新在循环内部进行 strlen 。

实际的 PyOS_strtoul 函数占用总时间的2%,运行自 PyInt_FromString (总时间的3%)。 这些都是“self”时间,不包括它们的子项,因此分配内存并决定数字基数所花费的时间比解析单个数字所花费的时间更长。

在C中的等效循环将运行~ 50倍快(或者如果我们很慷慨,则可能为20倍),在常量字符串上调用 strtoul 并且丢弃结果。


使用显式基数的int

由于某种原因,这与 float 一样快。

<code>$ perf record python2.7 -m timeit 'int("1",10)'
10000000 loops, best of 3: 0.0894 usec per loop

$ perf report -Mintel
Samples: 14K of event 'cycles:uppp', Event count (approx.): 14289699408
Overhead  Command    Shared Object        Symbol
  30.84%  python2.7  libpython2.7.so.1.0  [.] PyEval_EvalFrameEx
  12.56%  python2.7  libpython2.7.so.1.0  [.] vgetargskeywords
   6.70%  python2.7  libpython2.7.so.1.0  [.] PyInt_FromString
   5.19%  python2.7  libpython2.7.so.1.0  [.] tupledealloc.lto_priv.335
   5.17%  python2.7  libpython2.7.so.1.0  [.] int_new.lto_priv.68
   4.12%  python2.7  libpython2.7.so.1.0  [.] lookdict_string.lto_priv.1492
   4.08%  python2.7  libpython2.7.so.1.0  [.] PyOS_strtoul
   3.78%  python2.7  libc-2.26.so         [.] __strchr_avx2
   3.29%  python2.7  libpython2.7.so.1.0  [.] type_call.lto_priv.51
   3.26%  python2.7  libpython2.7.so.1.0  [.] PyTuple_New
   3.09%  python2.7  libpython2.7.so.1.0  [.] PyOS_strtol
   3.06%  python2.7  libpython2.7.so.1.0  [.] PyObject_Call
   2.49%  python2.7  libpython2.7.so.1.0  [.] PyArg_ParseTupleAndKeywords
   2.01%  python2.7  libpython2.7.so.1.0  [.] PyType_IsSubtype
   1.65%  python2.7  libc-2.26.so         [.] __strlen_avx2
   1.52%  python2.7  libpython2.7.so.1.0  [.] object_init.lto_priv.86
   1.19%  python2.7  libpython2.7.so.1.0  [.] vgetargskeywords.cold.59
   1.03%  python2.7  libpython2.7.so.1.0  [.] PyInt_AsLong
   1.00%  python2.7  libpython2.7.so.1.0  [.] PyString_Size
   0.99%  python2.7  libpython2.7.so.1.0  [.] PyObject_GC_UnTrack
   0.87%  python2.7  libc-2.26.so         [.] __ctype_b_loc
   0.85%  python2.7  libc-2.26.so         [.] __memset_avx2_unaligned_erms
   0.47%  python2.7  itertools.so         [.] _init
</code>

按照功能分类的配置文件与使用float版本非常相似。


1
PyString_FromFormatV 通过间接方式被使用,它被 PyErr_Format 调用以格式化由 getattr(str, '__trunc__') 抛出的属性错误。格式化字符串会触发一些 strlen 调用。 - Martijn Pieters

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