Почему цикл for намного быстрее подсчитывает истинные значения?

Недавно я ответил на вопрос на сайте-партнере, который попросил функцию, которая считает все четные числа числа. Один из других ответов содержал две функции (которые пока были самыми быстрыми):

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. Я бы ожидал, что второй будет быстрее (на основе, например, этого ответа), и это то, во что я бы порекомендовал превратить первое, если бы его попросили пересмотреть. Но, оказывается, все наоборот. Тестируя это с некоторыми случайными числами с увеличением количества цифр (таким образом, вероятность того, что любая отдельная цифра будет даже приблизительно 50%), я получаю следующие моменты времени:

enter image description here

Почему руководство for циклу намного быстрее? Это почти в два раза быстрее, чем использование sum. А поскольку встроенная sum должна быть примерно в пять раз быстрее, чем ручное суммирование списка (согласно связанному ответу), это означает, что на самом деле она в десять раз быстрее! Является ли экономия от необходимости только добавлять один к счетчику для половины значений, потому что другая половина отбрасывается, достаточно, чтобы объяснить эту разницу?


Используя if в качестве фильтра, вот так:

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

Улучшает синхронизацию только до того же уровня, что и понимание списка.


При расширении таймингов до больших чисел и нормализации к for синхронизации циклы, они асимптотически сходятся при очень больших количествах (> 10k цифры), вероятно, из - за времени str(n) принимает:

enter image description here

Ответ 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 мкс, а значение f2 с изменением понимания списка - 40,5 мкс.


Вероятно, выглядело довольно неловко, используя True вместо 1 в f3. Это потому, что изменение его на 1 активирует одно окончательное ускорение. sum есть быстрый путь для целых чисел, но быстрый путь активируется только для объектов, тип которых точно 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 должна пройти через итератор списка, чтобы получить элементы, которые имеют свою стоимость (хотя я думаю, что эта часть довольно дешево). К сожалению, пока мы проходим подход "тест-цифры-и- sum - sum, у нас нет хорошего способа избавиться от списка. Явный цикл выигрывает.

Можем ли мы пойти дальше? Что ж, мы до сих пор пытались приблизить sum к явному циклу, но если мы застряли с этим тупым списком, мы могли бы отклониться от явного цикла и просто вызвать len вместо sum:

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

Индивидуальное тестирование цифр - не единственный способ, которым мы можем попытаться обойти цикл. Отойдя еще дальше от явного цикла, мы также можем попробовать str.count. str.count перебирает строковый буфер непосредственно в C, избегая большого количества объектов-обёрток и косвенных str.count. Нам нужно вызвать его 5 раз, сделав 5 проходов через строку, но это все равно окупится:

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

К сожалению, это тот момент, когда сайт, который я использовал для синхронизации, застрял в "тарпите" за использование слишком большого количества ресурсов, поэтому мне пришлось переключать сайты. Следующие значения времени не могут быть напрямую сопоставлены с указанными выше параметрами:

>>> 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

Ответ 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 (?)), Которые делают код намного медленнее, чем выполнение машинных инструкций напрямую.

Ответ 3

Ответ @MarkusMeskanen имеет правильные биты - вызовы функций медленны, и как genexprs, так и listcomps в основном являются вызовами функций.

Во всяком случае, чтобы быть прагматичным

Использование str.count(c) быстрее, и мой связанный с этим ответ о strpbrk() в Python может еще ускорить strpbrk().

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

Ответ 4

Есть несколько различий, которые на самом деле способствуют наблюдаемым различиям в производительности. Я стремлюсь дать общий обзор этих различий, но стараюсь не вдаваться в подробности низкого уровня или возможные улучшения. Для тестов я использую свой собственный пакет simple_benchmark.

Генераторы против циклов

Генераторы и выражения генератора являются синтаксическим сахаром, который можно использовать вместо написания классов итераторов.

Когда вы пишете генератор, как:

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()

enter image description here

Генераторы и списки

Преимущество генераторов заключается в том, что они не создают список, а "производят" значения по одному. Таким образом, в то время как генератор имеет издержки "класса итератора", он может сохранить память для создания промежуточного списка. Это компромисс между скоростью (понимание списка) и памятью (генераторы). Это обсуждалось в различных статьях вокруг 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()

enter image description here

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()

enter image description here

Строковые методы против любого вида цикла 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()

В этом тесте ~ 200 раз быстрее позволить Python выполнять итерацию по строке, чем выполнять итерацию по строке с циклом for.

enter image description here

Почему все они сходятся для большого числа?

Это на самом деле потому, что преобразование числа в строку будет преобладать там. Таким образом, для действительно огромных чисел вы просто измеряете, сколько времени нужно, чтобы преобразовать это число в строку.

Вы увидите разницу, если сравните версии, которые принимают число и преобразуют его в строку с той, которая принимает преобразованное число (я использую функции из другого ответа здесь, чтобы проиллюстрировать это). Слева - эталон числа, а справа - эталон, который принимает строки - также ось Y одинакова для обоих графиков: enter image description here

Как вы можете видеть, тесты для функций, которые принимают строку, значительно быстрее для больших чисел, чем те, которые берут число и преобразуют их в строку внутри. Это указывает на то, что преобразование строк является "узким местом" для больших чисел. Для удобства я также включил тест, выполняющий только преобразование строки в левый график (который становится значительным/доминирующим для больших чисел).

%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" (для каждого c в n). С тех пор я хотел бы упростить:

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 вы count_simple_for только бросок num с циклом pure for for c in num, а в count_simple_sum вы создаете здесь generator объект (из ответа @Markus Meskanen с dis.dis):

  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: блока try:... except StopIteration: который также добавляет некоторые издержки.