为什么像a *= b这样的原地整数运算比a = a * b慢?

17

我知道整数是不可变的,因此计算出的值不会修改原始整数。因此,就地操作应该与简单操作相同,即先计算值,再将值重新分配回变量。但是为什么就地操作比简单操作慢?

import timeit
print("a = a + 1: ", end="")
print(timeit.timeit("for i in range(100): a = a + 1", setup="a = 0"))
print("a += 1: ", end="")
print(timeit.timeit("for i in range(100): a += 1", setup="a = 0"))

print("a = a - 1: ", end="")
print(timeit.timeit("for i in range(100): a = a - 1", setup="a = 0"))
print("a -= 1: ", end="")
print(timeit.timeit("for i in range(100): a -= 1", setup="a = 0"))

print("a = a * 1: ", end="")
print(timeit.timeit("for i in range(100): a = a * 1", setup="a = 1"))
print("a *= 1: ", end="")
print(timeit.timeit("for i in range(100): a *= 1", setup="a = 1"))

print("a = a // 1: ", end="")
print(timeit.timeit("for i in range(100): a = a // 1", setup="a = 1"))
print("a //= 1: ", end="")
print(timeit.timeit("for i in range(100): a //= 1", setup="a = 1"))

输出:

a = a + 1: 2.922127154
a += 1: 2.9701245480000003
a = a - 1: 2.9568866799999993
a -= 1: 3.1065419050000003
a = a * 1: 2.2483990140000003
a *= 1: 2.703524648
a = a // 1: 2.534561783000001
a //= 1: 2.6582312889999997

所有原地操作都比简单操作要慢。加法的差距最小,而乘法的差距最大。


1
我得到了不同的结果。也就是说://=a = a // b 更快,而 +=a = a + b 更快。 - S.B
3
我怀疑差别是由于int对象实际上没有实现原地操作符,因此在检查int.__iadd__时会有一些开销,发现不存在,然后会执行__add__...尽管如此,这并不能解释相对差异... - juanpa.arrivillaga
3
我得到的两组运行数据之间的差异比两种操作类型之间的差异要大得多。我不确定在这里能得出任何真正的结论。 - Tim Roberts
纯属猜测:CPython将小整数存储为“C int类型”,将大整数存储为“数字列表”。也许就地操作会检查溢出。 - hilberts_drinking_problem
1
要明确的是,+= 不是一个“原地”运算符 - 原地算法 意味着它应该在不分配与输入大小成比例的新内存的情况下改变输入;但是对于整数的 += 并不会改变原始整数,而是创建一个需要为其分配内存的新对象。Python 语言参考将 += 定义为增强赋值运算符,并且仅表示实现此类运算符的类应在可能的情况下进行原地操作。 - kaya3
显示剩余5条评论
2个回答

3

那个实验可能存在问题:一个只包含 a=a+1a+=1 这样的赋值语句的 for 循环,循环100次通常不需要太长时间(超过一秒钟)。

使用 timeit 比较这些结果和同一个 for 循环的直接执行:

def not_in_place(n, a=0):
    for i in range(n): a = a + 1
    return a

def in_place(n, a=0):
    for i in range(n): a += 1
    return a

正如预期的那样,需要进行近1亿次迭代才能获得类似的时间(以秒为数量级):

not_in_place(100_000_000)
in_place(100_000_000)

(编辑:正如在评论中指出的那样:包含 100 次迭代的定时语句被包装在一百万次迭代的循环中。)

我们仍然需要确定哪种方式更快:原地更新 (a+=1) 还是非原地更新 (a=a+1)。

为了这样做,我们需要观察两种情况在递增的迭代次数下的行为:

import perfplot
perfplot.bench(
    n_range=[2**k for k in range(26)],
    setup=lambda n: n,
    kernels=[not_in_place, in_place],
    labels=["a = a + 1", "a += 1"],
).show()

使用 timeit 进行前100次迭代的观察到的差异无法在每次运行的回调函数中使用相同的 for 循环代码进行大规模运行时复制:

enter image description here

最重要的是: 似乎两种情况(原地和非原地)之间的时间差异对于迭代次数是不变的。

对于其他操作符也是如此: (-,*等):

enter image description here enter image description here


1
timeit 默认情况下会将计时语句包装在一百万次迭代循环中。 - user2357112
1
感谢您的回答,很高兴知道这种差异在规模上是不变的。 - adamkwm

3

简短回答

就性能而言,inplace操作需要进行更多的判断工作,以确定是否定义了自定义的inplace操作或是否回退到常规二进制操作。

时间

对我来说,时间几乎相同:

$ python3.9 -m timeit -s 'a=1' 'a *= 1'
10000000 loops, best of 5: 27.6 nsec per loop

$ python3.9 -m timeit -s 'a=1' 'a = a * 1'
10000000 loops, best of 5: 27.8 nsec per loop

解释

我预期原地版本会稍微慢一些,因为分派代码首先检查是否定义了就地操作的插槽,然后才回退到常规二元操作。

对于像 intfloat 这样的不可变对象,确定没有定义就地插槽需要一点时间。

尽管如此,几乎所有其余的代码都是相同的,这就是为什么计时结果如此接近的原因。

深入源码

相关代码位于 Objects/abstract.c 文件中:

/* The in-place operators are defined to fall back to the 'normal',
   non in-place operations, if the in-place methods are not in place.

   - If the left hand object has the appropriate struct members, and
     they are filled, call the appropriate function and return the
     result.  No coercion is done on the arguments; the left-hand object
     is the one the operation is performed on, and it's up to the
     function to deal with the right-hand object.

   - Otherwise, in-place modification is not supported. Handle it exactly as
     a non in-place operation of the same kind.

   */

static PyObject *
binary_iop1(PyObject *v, PyObject *w, const int iop_slot, const int op_slot
            )
{
    PyNumberMethods *mv = Py_TYPE(v)->tp_as_number;
    if (mv != NULL) {
        binaryfunc slot = NB_BINOP(mv, iop_slot);
        if (slot) {
            PyObject *x = (slot)(v, w);
            if (x != Py_NotImplemented) {
                return x;
            }
            Py_DECREF(x);
        }
    }
    return binary_op1(v, w, op_slot);
}

我也尝试在CLI形式下使用timeit,但我的时间差更大,对于a = a * 1是20.4 nsec,而对于a *= 1是24.7 nsec,可能这是系统的差异?我正在使用带有Python 3.8.4的Windows 7。 - adamkwm

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