【问题标题】:How many CPU cycles one addition take?一次加法需要多少 CPU 周期?
【发布时间】:2016-03-31 15:59:21
【问题描述】:

我想测量在 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 微秒。按照this page 指令,我计算出一个加法需要25 个CPU 周期。这是一个正常值吗?为什么?因为汇编指令 ADD 只需要 1-2 个 CPU 周期。

【问题讨论】:

  • 另外,添加甚至没有发生,因为它已经被优化了。
  • 好的。但如果我运行超过 1000 次添加,我猜它不会影响
  • test 中的 for 循环肯定会影响时间,无论迭代次数如何。
  • 您用于计算循环一次迭代(“1 次加法”)时间的代码是错误的。应该是times[keys[i]] / keys[i]。您计算多少个周期对应于 0.0095 秒也是错误的。以 2 GHz 运行的 CPU 在 0.0095 秒内执行 19,000,000 个周期。 Python 很慢,非常慢。它的操作比等效的汇编指令要长几个数量级。
  • @ross,哦,这只是一个错字。我的意思是 usec 而不是 sec.((times[keys[i+1]] - times[keys[i]]) / (keys[i+1] - keys[i])) 这段代码没有错。通过除以增量,我可以调整计算的准确性。

标签: python assembly clock timeit


【解决方案1】:

您正在计时函数调用 (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 += 1INPLACE_ADD,不同于x = x + 1BINARY_ADD。所以你需要决定要测量哪个 OPCode。

【讨论】:

  • 谢谢,现在我明白了。但是有什么方法可以更准确地计算加法的时间吗?
  • 你想达到什么目的?当您在高级面向对象语言中调用+ 时,您将两个对象相加,而+ 涉及函数调用。一些语言,如 Java 和 C++,对整数使用所谓的“原语”——它们根本不是对象。在 python 中,您获得了相当大的灵活性。高密度数字运算 Python 模块,如 numpy,在 C 扩展中进行计算,而不是原生 Python,主要是出于性能原因。您测量的目的是什么?
  • 您还应该意识到 Python 还进行了其他优化。如果您正在编写这种低级测试,您应该检查字节码是否真的按照您的预期进行。
  • 查看我的 EDIT 以获取建议,并在我的 cmets 中给出所有警告。
【解决方案2】:

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

具体来说,dis.dis 函数采用已编译 Python 代码的 sn-p 并返回所述 sn-p 解释为的字节码。在 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文件(here)中找到相关部分:

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 不知道 xy 的类型,直到您实际调用 add,所以类型检查是在运行时而不是编译时完成的。可以在动态语言中进行巧妙的优化来解决这个问题(请参阅 V8 for JavaScript 或 PyPy for Python),但一般来说,这是您为解释型、动态类型语言的灵活性付出的代价。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2014-07-26
    • 2016-11-12
    • 2010-10-16
    • 1970-01-01
    • 2016-05-10
    • 1970-01-01
    • 2015-10-08
    • 2021-06-24
    相关资源
    最近更新 更多