一个加法需要多少个CPU周期?

6

我想测量在Python 3中执行加法操作所需的时钟周期数。

我编写了一个计算加法操作平均值的程序:

from timeit import timeit

def test(n):
    for i in range(n):
      1 + 1

if __name__ == '__main__':

    times = {}
    for i in [2 ** n for n in range(10)]:
      t = timeit.timeit("test(%d)" % i, setup="from __main__ import test", number=100000)
      times[i] = t
      print("%d additions takes %f" % (i, t))

    keys = sorted(list(times.keys()))

    for i in range(len(keys) - 2):
      print("1 addition takes %f" % ((times[keys[i+1]] - times[keys[i]]) / (keys[i+1] - keys[i])))

输出:

16 additions takes 0.288647
32 additions takes 0.422229
64 additions takes 0.712617
128 additions takes 1.275438
256 additions takes 2.415222
512 additions takes 5.050155
1024 additions takes 10.381530
2048 additions takes 21.185604
4096 additions takes 43.122559
8192 additions takes 88.323853
16384 additions takes 194.353927
1  addition takes 0.008292
1 addition takes 0.010068
1 addition takes 0.008654
1 addition takes 0.010318
1 addition takes 0.008349
1 addition takes 0.009075
1 addition takes 0.008794
1 addition takes 0.008905
1 addition takes 0.010293
1 addition takes 0.010413
1 addition takes 0.010551
1 addition takes 0.010711
1 addition takes 0.011035

所以根据这个输出,一次加法大约需要0.0095微秒。根据这个页面的说明,我计算出一次加法需要25个CPU周期。这个值正常吗?为什么?因为汇编指令ADD只需要1-2个CPU周期。

1
此外,加法甚至没有发生,因为它被优化掉了。 - user2357112
好的。但是如果我运行超过1000次加法,我猜它不会产生影响。 - Arsen
2
test函数内的for循环无论迭代次数如何,都会影响时间。 - Michael
你计算每次循环(“1加法”)所需时间的代码是错误的,应该为 times[keys[i]] / keys[i]。你对于0.0095秒对应多少个周期的计算也是错误的。以2 GHz运行的CPU在0.0095秒内执行了19,000,000个周期。Python非常慢,它的操作所需的时间比相当的汇编指令长得多。 - Ross Ridge
@ross,哦,这只是一个打字错误。我的意思是使用usec而不是sec。((times[keys[i+1]] - times[keys[i]]) / (keys[i+1] - keys[i])) 这段代码没有问题。通过除以增量,我可以调整计算的精度。 - Arsen
如果您想更准确地测量时钟周期,应该使用hwcounter,因为它使用低级汇编语言获取参考时钟周期的数量,使用RDTSC调用。 - not2qubit
2个回答

9
您正在计时一个函数调用(test()),一个 for 循环和对 range() 的调用。加法根本没有被计时。
def test(n):
    for i in range(n):
        1 + 1

import dis
dis.dis(test)

这是您测试函数的字节码(不包括对 test() 的调用):

  2           0 SETUP_LOOP              24 (to 27)
              3 LOAD_GLOBAL              0 (range)
              6 LOAD_FAST                0 (n)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                10 (to 26)
             16 STORE_FAST               1 (i)

  3          19 LOAD_CONST               2 (2)   **** 
             22 POP_TOP             
             23 JUMP_ABSOLUTE           13
        >>   26 POP_BLOCK           
        >>   27 LOAD_CONST               0 (None)
             30 RETURN_VALUE        

请注意,加法是在编译时完成的。许多其他语言及其编译器也会这样做,包括C语言。然而,标准很少定义1 + 1何时实际执行,因此通常取决于实现。

编辑

你的timeit函数调用可能是这样的:

    t = timeit("x += 1", setup="x = 1", number=100000)

我们可以创建一个虚拟函数来检查操作:
def myfunc(x):
    x += 1

import dis
dis.dis(myfunc)

进行这个更改会产生以下结果:
1 additions takes 0.008976
2 additions takes 0.007419
4 additions takes 0.007282
8 additions takes 0.007693
16 additions takes 0.007026
32 additions takes 0.007793
64 additions takes 0.010168
128 additions takes 0.008124
256 additions takes 0.009064
512 additions takes 0.007256
1 addition takes -0.001557
1 addition takes -0.000068
1 addition takes 0.000103
1 addition takes -0.000083
1 addition takes 0.000048
1 addition takes 0.000074
1 addition takes -0.000032
1 addition takes 0.000007

 26           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 INPLACE_ADD
              7 STORE_FAST               0 (x)
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

请注意,x += 1 是一个 INPLACE_ADD,与 x = x + 1 不同,后者是一个 BINARY_ADD。因此,您需要决定要测量哪个OPCode。

谢谢,现在我明白了。但是有没有更准确地计算加法时间的方法? - Arsen
1
你想要实现什么目标?在高级面向对象语言中调用 + 时,你是在将两个对象相加,并且 + 涉及函数调用。一些语言(如Java和C++)使用所谓的“原始类型”来表示整数 - 它们根本不是对象。在Python中,你获得了相当大的灵活性。像 numpy 这样的高密度数字计算Python模块会使用 C 扩展进行计算,而不是使用本地Python,主要是出于性能原因。你的测量的目的是什么? - cdarke
你还应该意识到Python进行了其他优化。如果你正在编写这种低级测试,你应该检查字节码是否实际上做了你期望的事情。 - cdarke
请查看我的编辑建议,其中包含我在评论中提到的所有注意事项。 - cdarke

6

您可以使用 dis 模块来更深入地了解背后发生的事情。

具体而言,dis.dis 函数获取一段已编译的 Python 代码片段,并返回该代码片段被解释为的字节代码。对于 1 + 1 的情况:

In [1]: import dis

In [2]: def add1and1():
    return 1 + 1

In [3]: dis.dis(add1and1)
  2           0 LOAD_CONST               2 (2)
              3 RETURN_VALUE 

在这种情况下,当源代码被编译为字节码时,操作1 + 1只会被执行一次,然后将结果存储为常量。我们可以通过返回传递给函数的参数的总和来解决这个问题:

In [1]: import dis

In [2]: def add(x, y):
    return x + y

In [3]: dis.dis(add)
      2           0 LOAD_FAST                0 (x)
              3 LOAD_FAST                1 (y)
              6 BINARY_ADD          
              7 RETURN_VALUE          

所以你实际上感兴趣的字节码指令是BINARY_ADD。如果你想了解更多信息,可以在CPython解释器的ceval.c文件中找到相关部分(这里):

TARGET(BINARY_ADD) {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *sum;
    if (PyUnicode_CheckExact(left) &&
             PyUnicode_CheckExact(right)) {
        sum = unicode_concatenate(left, right, f, next_instr);
        /* unicode_concatenate consumed the ref to v */
    }
    else {
        sum = PyNumber_Add(left, right);
        Py_DECREF(left);
    }
    Py_DECREF(right);
    SET_TOP(sum);
    if (sum == NULL)
        goto error;
    DISPATCH();
}

所以这里的情况比你最初预料的要复杂。我们有:
  1. 一个条件语句用于确定我们是将 BINARY_ADD 用于字符串连接还是用于数值类型相加

  2. 实际调用 PyNumber_Add,而不是像预期的那样使用 left + right

这两点都可以解释为 Python 的动态性质;由于 Python 在实际调用 add 之前不知道 xy 的类型,因此类型检查是在运行时而不是编译时进行的。在动态语言中可以进行巧妙的优化来解决这个问题(例如 JavaScript 的 V8 或 Python 的 PyPy),但总的来说这是一种代价,你必须为解释型、动态类型语言的灵活性付出代价。

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