Почему цикл for так быстро подсчитывает значения True? - PullRequest
51 голосов
/ 24 мая 2019

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

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

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


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

enter image description here

Ответы [ 5 ]

41 голосов
/ 24 мая 2019

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

мы увидим немедленное ускорение, за счет потери группыпамяти в списке.


Если вы посмотрите на свою версию генаxp:

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 должен пройти через итератор списка, чтобы получить элементы, которые имеют свою стоимость (хотя я думаю, что эта часть довольно дешевая).К сожалению, пока мы проходим подход «test-digits-and-call- sum», у нас нет хорошего способа избавиться от списка.Явный цикл выигрывает.

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

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

Тестирование цифр по отдельности - не единственный способ, которым мы можем попытаться обойти цикл.Отойдя еще дальше от явного цикла, мы также можем попробовать str.count.str.count перебирает строковый буфер непосредственно в C, избегая большого количества объектов-обёрток и косвенных ссылок.Нам нужно вызвать его 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
9 голосов
/ 24 мая 2019

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

В любом случае, чтобы быть прагматичным:

Использование str.count(c) быстрееи этот мой связанный ответ о strpbrk() в 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
9 голосов
/ 24 мая 2019

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

5 голосов
/ 25 мая 2019

Есть несколько различий, которые на самом деле способствуют наблюдаемым различиям в производительности.Я стремлюсь дать общий обзор этих различий, но стараюсь не вдаваться в подробности низкого уровня или возможные улучшения.Для тестов я использую свой собственный пакет 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()

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

В этом тесте Python выполняет итерацию по строке в ~ 200 раз быстрее, чем итерацию по строке с циклом 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')
0 голосов
/ 24 мая 2019

Все ваши функции содержат одинаковое количество вызовов 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 вы итерируете только бросок num с чистым циклом for for c in num, но в count_simple_sum вы создаете здесь generator объект (из * 1015) * @ Маркус Месканен ответ с 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:, что также добавляет некоторые издержки.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...