【问题标题】:Why is a `for` loop so much faster to count True values?为什么`for`循环计算真值的速度如此之快?
【发布时间】:2019-05-24 07:45:08
【问题描述】:

我最近回答了一个question on a sister site,它要求提供一个计算一个数字的所有偶数位的函数。其中一个other answers 包含两个函数(结果证明这是迄今为止最快的):

def count_even_digits_spyr03_for(n):
    count = 0
    for c in str(n):
        if c in "02468":
            count += 1
    return count

def count_even_digits_spyr03_sum(n):
    return sum(c in "02468" for c in str(n))

此外,我查看了使用列表理解和list.count

def count_even_digits_spyr03_list(n):
    return [c in "02468" for c in str(n)].count(True)

前两个函数基本相同,只是第一个使用显式计数循环,而第二个使用内置sum。我本来预计第二个会更快(基于例如this answer),如果被要求进行审查,我会建议将前者变成。但是,事实证明情况正好相反。用一些位数增加的随机数对其进行测试(因此任何单个数字为偶数的可能性约为 50%)我得到以下时间:

为什么手动 for 循环要快这么多? 这几乎比使用 sum 快两倍。而且由于内置的​​sum 应该比手动求和列表快五倍(根据the linked answer),这意味着它实际上快了十倍!只需将一半的值加到计数器中,因为另一半被丢弃,节省的成本是否足以解释这种差异?


使用if 作为过滤器,如下所示:

def count_even_digits_spyr03_sum2(n):
    return sum(1 for c in str(n) if c in "02468")

仅将时序提高到与列表理解相同的水平。


当将时序扩展到更大的数字并归一化到 for 循环时序时,它们会渐近收敛于非常大的数字(>10k 位),这可能是由于 str(n) 花费的时间:

【问题讨论】:

  • 您将很多零相加,如果您将c in "02468" 移动到列表理解的末尾,作为过滤器,并设置值1
  • @jonrsharpe 这将时间改进到与list.count 的列表理解大致相同。
  • 如果你分析你的代码,你可能会发现你的时间被函数调用所支配,这在 Python 中是非常昂贵的。 count_even_digits_spyr03_for 只有一个 (str(n)),而其他所有都有两个。对于更大的ns,您可能应该开始看到sum() @jonrsharpe 编辑接管它。
  • @norok2 这不是真的,所有的函数都只有一个str(n) 调用,for ... in 之后的语句只执行一次,即使它是一个理解。
  • @MarkusMeskanen 我猜还有对sum的额外调用。

标签: python python-3.x performance for-loop sum


【解决方案1】:

sum 相当快,但sum 并不是导致减速的原因。三个主要因素导致放缓:

  • 使用生成器表达式会导致不断暂停和恢复生成器的开销。
  • 您的生成器版本无条件添加,而不是仅在数字为偶数时添加。当数字为奇数时,这会更昂贵。
  • 添加布尔值而不是整数会阻止 sum 使用其整数快速路径。

与列表推导相比,生成器有两个主要优势:它们占用的内存少得多,并且如果不需要所有元素,它们可以提前终止。它们不是旨在在需要所有元素的情况下提供时间优势。每个元素暂停和恢复一次生成器非常昂贵。

如果我们用列表推导替换 genexp:

In [66]: def f1(x):
   ....:     return sum(c in '02468' for c in str(x))
   ....: 
In [67]: def f2(x):
   ....:     return sum([c in '02468' for c in str(x)])
   ....: 
In [68]: x = int('1234567890'*50)
In [69]: %timeit f1(x)
10000 loops, best of 5: 52.2 µs per loop
In [70]: %timeit f2(x)
10000 loops, best of 5: 40.5 µs per loop

我们看到了立竿见影的加速,但代价是在列表上浪费了大量内存。


如果您查看您的 genexp 版本:

def count_even_digits_spyr03_sum(n):
    return sum(c in "02468" for c in str(n))

你会看到它没有if。它只是将布尔值扔进sum。相反,您的循环:

def count_even_digits_spyr03_for(n):
    count = 0
    for c in str(n):
        if c in "02468":
            count += 1
    return count

仅当数字为偶数时才添加任何内容。

如果我们将之前定义的f2 更改为也包含if,我们会看到另一个加速:

In [71]: def f3(x):
   ....:     return sum([True for c in str(x) if c in '02468'])
   ....: 
In [72]: %timeit f3(x)
10000 loops, best of 5: 34.9 µs per loop

f1,与您的原始代码相同,耗时 52.2 µs,f2,仅更改列表理解,耗时 40.5 µs。


f3 中使用True 而不是1 可能看起来很尴尬。那是因为将其更改为 1 会激活最终加速。 sum 有一个用于整数的fast path,但快速路径仅对类型恰好为int 的对象激活。 bool 不算。这是检查项目是否为 int 类型的行:

if (PyLong_CheckExact(item)) {

一旦我们进行最后的更改,将True 更改为1

In [73]: def f4(x):
   ....:     return sum([1 for c in str(x) if c in '02468'])
   ....: 
In [74]: %timeit f4(x)
10000 loops, best of 5: 33.3 µs per loop

我们看到了最后一个小幅加速。


那么,毕竟,我们是否击败了显式循环?

In [75]: def explicit_loop(x):
   ....:     count = 0
   ....:     for c in str(x):
   ....:         if c in '02468':
   ....:             count += 1
   ....:     return count
   ....: 
In [76]: %timeit explicit_loop(x)
10000 loops, best of 5: 32.7 µs per loop

不。我们大致收支平衡,但我们没有打败它。剩下的大问题是列表。构建它很昂贵,sum 必须通过列表迭代器来检索元素,这有其自身的成本(尽管我认为这部分非常便宜)。不幸的是,只要我们通过 test-digits-and-call-sum 方法,我们就没有摆脱列表的好方法。显式循环获胜。

我们还能走得更远吗?好吧,到目前为止,我们一直在尝试使 sum 更接近显式循环,但如果我们被这个愚蠢的列表所困扰,我们可能会偏离显式循环并调用 len 而不是 sum

def f5(x):
    return len([1 for c in str(x) if c in '02468'])

单独测试数字也不是我们可以尝试打破循环的唯一方法。进一步偏离显式循环,我们也可以尝试str.countstr.count 直接在 C 中迭代字符串的缓冲区,避免了很多包装对象和间接。我们需要调用它 5 次,对字符串进行 5 次传递,但它仍然得到了回报:

def f6(x):
    s = str(x)
    return sum(s.count(c) for c in '02468')

不幸的是,这是我用来计时的网站因为使用太多资源而让我陷入“tarpit”的时候,所以我不得不切换网站。以下时序无法与上述时序直接比较:

>>> import timeit
>>> def f(x):
...     return sum([1 for c in str(x) if c in '02468'])
... 
>>> def g(x):
...     return len([1 for c in str(x) if c in '02468'])
... 
>>> def h(x):
...     s = str(x)
...     return sum(s.count(c) for c in '02468')
... 
>>> x = int('1234567890'*50)
>>> timeit.timeit(lambda: f(x), number=10000)
0.331528635986615
>>> timeit.timeit(lambda: g(x), number=10000)
0.30292080697836354
>>> timeit.timeit(lambda: h(x), number=10000)
0.15950968803372234
>>> def explicit_loop(x):
...     count = 0
...     for c in str(x):
...         if c in '02468':
...             count += 1
...     return count
... 
>>> timeit.timeit(lambda: explicit_loop(x), number=10000)
0.3305045129964128

【讨论】:

  • 感谢您的精彩回答!你的问题能够最简洁准确地描述所有问题,所以我选择它作为接受。第一点,生成器表达式的开销,我可能最终会弄清楚,不存在的if 是我可以看到但认为不足以解释差异的那个,但bool 不算@987654360 @ 在这种情况下,我绝对认为这不是原因。
【解决方案2】:

如果我们使用dis.dis(),我们可以看到函数的实际行为。

count_even_digits_spyr03_for():

  7           0 LOAD_CONST               1 (0)
              3 STORE_FAST               0 (count)

  8           6 SETUP_LOOP              42 (to 51)
              9 LOAD_GLOBAL              0 (str)
             12 LOAD_GLOBAL              1 (n)
             15 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             18 GET_ITER
        >>   19 FOR_ITER                28 (to 50)
             22 STORE_FAST               1 (c)

  9          25 LOAD_FAST                1 (c)
             28 LOAD_CONST               2 ('02468')
             31 COMPARE_OP               6 (in)
             34 POP_JUMP_IF_FALSE       19

 10          37 LOAD_FAST                0 (count)
             40 LOAD_CONST               3 (1)
             43 INPLACE_ADD
             44 STORE_FAST               0 (count)
             47 JUMP_ABSOLUTE           19
        >>   50 POP_BLOCK

 11     >>   51 LOAD_FAST                0 (count)
             54 RETURN_VALUE

我们可以看到只有一个函数调用,就是开头的str()

9 LOAD_GLOBAL              0 (str)
...
15 CALL_FUNCTION            1 (1 positional, 0 keyword pair)

其余部分是高度优化的代码,使用跳转、存储和就地添加。

count_even_digits_spyr03_sum() 会发生什么:

 14           0 LOAD_GLOBAL              0 (sum)
              3 LOAD_CONST               1 (<code object <genexpr> at 0x10dcc8c90, file "test.py", line 14>)
              6 LOAD_CONST               2 ('count2.<locals>.<genexpr>')
              9 MAKE_FUNCTION            0
             12 LOAD_GLOBAL              1 (str)
             15 LOAD_GLOBAL              2 (n)
             18 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             21 GET_ITER
             22 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             25 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             28 RETURN_VALUE

虽然我无法完美解释差异,但我们可以清楚地看到有更多的函数调用(可能是 sum()in(?)),这使得代码运行速度比直接执行机器指令慢得多.

【讨论】:

  • 这不是一个可以详细解释的完美答案,因为我自己对dis 输出不太熟悉。但我相信它显示了一般的想法;将作业委托给不同的功能比直接执行代码要慢得多。如果有人更了解该主题,请随时填补任何空白。
  • 第二个函数的dis 输出只显示了大约三分之一的故事。另外三分之一在 genexp 中,它没有被反汇编,另外三分之一在 sum,你不能反汇编,因为它在 C 中。函数调用并不是真正的问题;问题是不断进出 genexp 堆栈框架。
【解决方案3】:

@MarkusMeskanen 的回答是正确的——函数调用很慢,而且 genexprs 和 listcomps 基本上都是函数调用。

无论如何,要务实:

使用str.count(c) 更快,而this related answer of mine about strpbrk() in Python 可以让事情变得更快。

def count_even_digits_spyr03_count(n):
    s = str(n)
    return sum(s.count(c) for c in "02468")


def count_even_digits_spyr03_count_unrolled(n):
    s = str(n)
    return s.count("0") + s.count("2") + s.count("4") + s.count("6") + s.count("8")

结果:

string length: 502
count_even_digits_spyr03_list 0.04157966522
count_even_digits_spyr03_sum 0.05678154459
count_even_digits_spyr03_for 0.036128606150000006
count_even_digits_spyr03_count 0.010441866129999991
count_even_digits_spyr03_count_unrolled 0.009662931009999999

【讨论】:

  • 有趣的是,我原以为必须多次迭代字符串会破坏它,但它可以工作直到字符串变得非常大(请参阅更新的问题)。虽然对于小数字,它对我来说比 for 循环慢。
  • 这就是你想要strpbrk() 之类的东西的地方——glibc 版本在从字符串的集合中查找任何给定字符方面非常快。请参阅我的答案中的链接:)(虽然它确实需要额外的 C 扩展,但如果您需要速度......)
  • 如果您不介意,我会将您的代码(此答案中的那个,来自 glibc 的带有strpbrk 的那个可能是矫枉过正)添加到my answer to the question on Code Review 的计时中。除非您想将其作为单独的答案添加到那里?
  • @Graipher 随意。 :)
【解决方案4】:

实际上有一些差异会导致观察到的性能差异。我的目标是对这些差异进行高级概述,但尽量不要过多介绍低级细节或可能的改进。对于基准测试,我使用我自己的包simple_benchmark

生成器与 for 循环

生成器和生成器表达式是可以用来代替编写迭代器类的语法糖。

当你写一个生成器时:

def count_even(num):
    s = str(num)
    for c in s:
        yield c in '02468'

或者一个生成器表达式:

(c in '02468' for c in str(num))

这将被(在幕后)转换为可通过迭代器类访问的状态机。最后它大致相当于(尽管围绕生成器生成的实际代码会更快):

class Count:
    def __init__(self, num):
        self.str_num = iter(str(num))

    def __iter__(self):
        return self

    def __next__(self):
        c = next(self.str_num)
        return c in '02468'

所以生成器总是会有一个额外的间接层。这意味着推进生成器(或生成器表达式或迭代器)意味着您在生成器生成的迭代器上调用__next__,生成器本身在您实际要迭代的对象上调用__next__。但它也有一些开销,因为您实际上需要创建一个额外的“迭代器实例”。通常,如果您在每次迭代中做任何实质性的事情,这些开销可以忽略不计。

仅举例说明生成器与手动循环相比会产生多少开销:

import matplotlib.pyplot as plt
from simple_benchmark import BenchmarkBuilder
%matplotlib notebook

bench = BenchmarkBuilder()

@bench.add_function()
def iteration(it):
    for i in it:
        pass

@bench.add_function()
def generator(it):
    it = (item for item in it)
    for i in it:
        pass

@bench.add_arguments()
def argument_provider():
    for i in range(2, 15):
        size = 2**i
        yield size, [1 for _ in range(size)]

plt.figure()
result = bench.run()
result.plot()

生成器与列表推导式

生成器的优点是它们不创建列表,它们一个接一个地“生成”值。因此,虽然生成器具有“迭代器类”的开销,但它可以节省内存以创建中间列表。这是速度(列表理解)和内存(生成器)之间的权衡。这已经在 StackOverflow 周围的各种帖子中讨论过,所以我不想在这里详细介绍。

import matplotlib.pyplot as plt
from simple_benchmark import BenchmarkBuilder
%matplotlib notebook

bench = BenchmarkBuilder()

@bench.add_function()
def generator_expression(it):
    it = (item for item in it)
    for i in it:
        pass

@bench.add_function()
def list_comprehension(it):
    it = [item for item in it]
    for i in it:
        pass

@bench.add_arguments('size')
def argument_provider():
    for i in range(2, 15):
        size = 2**i
        yield size, list(range(size))

plt.figure()
result = bench.run()
result.plot()

sum 应该比手动迭代更快

是的,sum 确实比显式 for 循环快。尤其是在迭代整数时。

import matplotlib.pyplot as plt
from simple_benchmark import BenchmarkBuilder
%matplotlib notebook

bench = BenchmarkBuilder()

@bench.add_function()
def my_sum(it):
    sum_ = 0
    for i in it:
        sum_ += i
    return sum_

bench.add_function()(sum)

@bench.add_arguments()
def argument_provider():
    for i in range(2, 15):
        size = 2**i
        yield size, [1 for _ in range(size)]

plt.figure()
result = bench.run()
result.plot()

字符串方法与任何类型的 Python 循环

要了解使用 str.count 等字符串方法与循环(显式或隐式)相比的性能差异,Python 中的字符串实际上是作为 存储在(内部)数组中的。这意味着循环实际上不会调用任何__next__ 方法,它可以直接在数组上使用循环,这将显着更快。然而,它也会对字符串进行方法查找和方法调用,这就是为什么它对于非常短的数字会更慢。

只是为了提供一个小比较,即迭代字符串需要多长时间与 Python 迭代内部数组需要多长时间:

import matplotlib.pyplot as plt
from simple_benchmark import BenchmarkBuilder
%matplotlib notebook

bench = BenchmarkBuilder()

@bench.add_function()
def string_iteration(s):
    # there is no "a" in the string, so this iterates over the whole string
    return 'a' in s  

@bench.add_function()
def python_iteration(s):
    for c in s:
        pass

@bench.add_arguments('string length')
def argument_provider():
    for i in range(2, 20):
        size = 2**i
        yield size, '1'*size

plt.figure()
result = bench.run()
result.plot()

在这个基准测试中,让 Python 对字符串进行迭代比使用 for 循环对字符串进行迭代快约 200 倍。

为什么它们都对大数收敛?

这实际上是因为数字到字符串的转换将占主导地位。因此,对于非常大的数字,您实际上只是在测量将该数字转换为字符串所需的时间。

如果您将采用数字并将其转换为字符串的版本与采用转换后的数字的版本进行比较,您会看到差异(我使用another answer here 中的函数来说明这一点)。左边是数字基准,右边是采用字符串的基准 - 两个图的 y 轴也是相同的:

正如您所见,对于大数而言,采用字符串的函数的基准测试明显快于采用数字并将其转换为内部字符串的函数。这表明字符串转换是大数的“瓶颈”。为方便起见,我还包括了一个仅将字符串转换为左图的基准(这对于大数字变得重要/占主导地位)。

%matplotlib notebook

from simple_benchmark import BenchmarkBuilder
import matplotlib.pyplot as plt
import random

bench1 = BenchmarkBuilder()

@bench1.add_function()
def f1(x):
    return sum(c in '02468' for c in str(x))

@bench1.add_function()
def f2(x):
    return sum([c in '02468' for c in str(x)])

@bench1.add_function()
def f3(x):
    return sum([True for c in str(x) if c in '02468'])    

@bench1.add_function()
def f4(x):
    return sum([1 for c in str(x) if c in '02468'])

@bench1.add_function()
def explicit_loop(x):
    count = 0
    for c in str(x):
        if c in '02468':
            count += 1
    return count

@bench1.add_function()
def f5(x):
    s = str(x)
    return sum(s.count(c) for c in '02468')

bench1.add_function()(str)

@bench1.add_arguments(name='number length')
def arg_provider():
    for i in range(2, 15):
        size = 2 ** i
        yield (2**i, int(''.join(str(random.randint(0, 9)) for _ in range(size))))


bench2 = BenchmarkBuilder()

@bench2.add_function()
def f1(x):
    return sum(c in '02468' for c in x)

@bench2.add_function()
def f2(x):
    return sum([c in '02468' for c in x])

@bench2.add_function()
def f3(x):
    return sum([True for c in x if c in '02468'])    

@bench2.add_function()
def f4(x):
    return sum([1 for c in x if c in '02468'])

@bench2.add_function()
def explicit_loop(x):
    count = 0
    for c in x:
        if c in '02468':
            count += 1
    return count

@bench2.add_function()
def f5(x):
    return sum(x.count(c) for c in '02468')

@bench2.add_arguments(name='number length')
def arg_provider():
    for i in range(2, 15):
        size = 2 ** i
        yield (2**i, ''.join(str(random.randint(0, 9)) for _ in range(size)))

f, (ax1, ax2) = plt.subplots(1, 2, sharey=True)
b1 = bench1.run()
b2 = bench2.run()
b1.plot(ax=ax1)
b2.plot(ax=ax2)
ax1.set_title('Number')
ax2.set_title('String')

【讨论】:

    【解决方案5】:

    您的所有函数都包含对str(n)(一次调用)和c in "02468"(对于n 中的每个c)的相同数量的调用。从那时起,我想简化:

    import timeit
    
    num = ''.join(str(i % 10) for i in range(1, 10000001))
    
    def count_simple_sum():
        return sum(1 for c in num)
    
    def count_simple_for():
        count = 0
        for c in num:
            count += 1
        return count
    
    
    print('For Loop Sum:', timeit.timeit(count_simple_for, number=10))
    print('Built-in Sum:', timeit.timeit(count_simple_sum, number=10))
    

    sum 还是比较慢:

    For Loop Sum: 2.8987821330083534
    Built-in Sum: 3.245505138998851
    

    这两个函数之间的主要区别在于,在 count_simple_for 中,您仅使用纯 for 循环 for c in num 迭代抛出 num,但在 count_simple_sum 中,您在此处创建 generator 对象(来自 @987654321 @):

      3 LOAD_CONST               1 (<code object <genexpr> at 0x10dcc8c90, file "test.py", line 14>)
      6 LOAD_CONST               2 ('count2.<locals>.<genexpr>')
    

    sum 正在迭代此生成器对象以求和生成的元素,并且此生成器正在迭代 num 中的元素以在每个元素上生成 1。多一个迭代步骤代价高昂,因为它需要在每个元素上调用generator.__next__(),并且这些调用被放入try: ... except StopIteration: 块中,这也增加了一些开销。

    【讨论】:

      猜你喜欢
      • 2012-11-07
      • 2018-07-21
      • 2020-05-11
      • 1970-01-01
      • 1970-01-01
      • 2020-10-01
      相关资源
      最近更新 更多