itertools.product медленнее, чем вложенные циклы for

Я пытаюсь использовать функцию itertools.product, чтобы сделать сегмент моего кода (в симуляторе изотопных шаблонов) более легким для чтения и, надеюсь, более быстрым (documentation утверждает, что никакие промежуточные результаты не создаются), однако я проверил обе версии кода друг против друга, используя библиотеку cProfiling, и заметил, что itertools.product был значительно медленнее, чем мои вложенные циклы for.

Примеры значений, использованных для тестирования:

carbons = [(0.0, 0.004613223957020534), (1.00335, 0.02494768843632857), (2.0067, 0.0673219412049374), (3.0100499999999997, 0.12087054681917497), (4.0134, 0.16243239687902825), (5.01675, 0.17427700732161705), (6.020099999999999, 0.15550695260604208), (7.0234499999999995, 0.11869556397525197), (8.0268, 0.07911287899598853), (9.030149999999999, 0.04677626606764402)]
hydrogens = [(0.0, 0.9417611429667746), (1.00628, 0.05651245007201512)]
nitrogens = [(0.0, 0.16148864310897554), (0.99703, 0.2949830688288726), (1.99406, 0.26887643366755537), (2.99109, 0.16305943261399866), (3.98812, 0.0740163089529218), (4.98515, 0.026824040474519875), (5.98218, 0.008084687617425748)]
oxygens17 = [(0.0, 0.8269292736927519), (1.00422, 0.15717628899143962), (2.00844, 0.014907548827832968)]
oxygens18 = [(0.0, 0.3584191873916266), (2.00425, 0.36813434247849824), (4.0085, 0.18867830334103902), (6.01275, 0.06433912182670033), (8.017, 0.016421642936302827)]
sulfurs33 = [(0.0, 0.02204843659673093), (0.99939, 0.08442569434459646), (1.99878, 0.16131398792444965), (2.99817, 0.2050722764666321), (3.99756, 0.1951327596407101), (4.99695, 0.14824112268069747), (5.99634, 0.09365899226198841), (6.99573, 0.050618028523695714), (7.99512, 0.023888506307006133), (8.99451, 0.010000884811585533)]
sulfurs34 = [(0.0, 3.0106350597190195e-10), (1.9958, 6.747270089956428e-09), (3.9916, 7.54568412614702e-08), (5.9874, 5.614443102700176e-07), (7.9832, 3.1268212758750728e-06), (9.979, 1.3903197959791067e-05), (11.9748, 5.141248916434075e-05), (13.970600000000001, 0.0001626288218672788), (15.9664, 0.00044921518047309414), (17.9622, 0.0011007203440032396)]
sulfurs36 = [(0.0, 0.904828368500412), (3.99501, 0.0905009370374487)]

Фрагмент, демонстрирующий вложенные циклы for:

totals = []
for i in carbons:
    for j in hydrogens:
        for k in nitrogens:
            for l in oxygens17:
                for m in oxygens18:
                    for n in sulfurs33:
                        for o in sulfurs34:
                            for p in sulfurs36:
                                totals.append((i[0]+j[0]+k[0]+l[0]+m[0]+n[0]+o[0]+p[0], i[1]*j[1]*k[1]*l[1]*m[1]*n[1]*o[1]*p[1]))

Фрагмент, демонстрирующий использование itertools.product:

totals = []
for i in itertools.product(carbons,hydrogens,nitrogens,oxygens17,oxygens18,sulfurs33,sulfurs34,sulfurs36):
    massDiff = i[0][0]
    chance = i[0][1]
    for j in i[1:]:
        massDiff += j[0]
        chance = chance * j[1]
    totals.append((massDiff,chance))

Результаты профилирования (на основе 10 запусков каждого метода) составили в среднем ~ 0,8 секунды для подхода с вложенным циклом for и ~ 1,3 секунды для подхода itertools.product. Таким образом, мой вопрос заключается в том, неправильно ли я использую функцию itertools.product или я должен просто придерживаться вложенных циклов for?

-- ОБНОВЛЕНИЕ --

Я включил два из моих cProfile результатов:

# ITERTOOLS.PRODUCT APPROACH 
420003 function calls in 1.306 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.018    0.018    1.306    1.306 <string>:1(<module>)
        1    1.246    1.246    1.289    1.289 IsotopeBas.py:64(option1)
   420000    0.042    0.000    0.042    0.000 {method 'append' of 'list' objects}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

а также:

# NESTED FOR LOOP APPROACH
420003 function calls in 0.830 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.019    0.019    0.830    0.830 <string>:1(<module>)
        1    0.769    0.769    0.811    0.811 IsotopeBas.py:78(option2)
   420000    0.042    0.000    0.042    0.000 {method 'append' of 'list' objects}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

person Bas Jansen    schedule 03.07.2014    source источник
comment
Не могли бы вы дать несколько примеров значений для carbons,hydrogens,nitrogens,oxygens17,oxygens18,sulfurs33,sulfurs34,sulfurs36, чтобы мы могли воспроизвести это и подтвердить   -  person thefourtheye    schedule 03.07.2014
comment
Это не то, как вы измеряете скорость кода в Python. Используйте docs.python.org/3.4/library/timeit.html.   -  person    schedule 03.07.2014
comment
Я повторю это с timeit и добавлю несколько примеров списков (извините за большой размер) в ОП.   -  person Bas Jansen    schedule 03.07.2014
comment
вы используете добавление, чтобы сделать шанс, и повторное выполнение цикла уменьшает, даже если вы могли бы умножить в for и т. д. и т. д.   -  person Antti Haapala    schedule 03.07.2014
comment
Я так и сделал. Я исправил фрагмент itertools, чтобы исправить то, что вы описали, однако он по-прежнему остается медленнее.   -  person Bas Jansen    schedule 03.07.2014


Ответы (3)


Ваш исходный код itertool потратил много дополнительного времени на ненужные lambda и создание списков промежуточных значений вручную - многое из этого можно заменить встроенными функциями.

Теперь внутренний цикл for добавляет довольно много дополнительных накладных расходов: просто попробуйте следующее, и производительность будет очень близкой к вашему исходному коду:

for a in itertools.product(carbons,hydrogens,nitrogens,oxygens17,
                           oxygens18,sulfurs33,sulfurs34,sulfurs36):
    i, j, k, l, m, n, o, p = a
    totals.append((i[0]+j[0]+k[0]+l[0]+m[0]+n[0]+o[0]+p[0],
                   i[1]*j[1]*k[1]*l[1]*m[1]*n[1]*o[1]*p[1]))

Следующий код, насколько это возможно, работает на встроенной стороне CPython, и я протестировал его на соответствие коду. Примечательно, что код использует zip(*iterable) для распаковки каждого из результатов продукта; затем использует reduce с operator.mul для произведения и sum для суммирования; 2 генератора для прохождения списков. Цикл for по-прежнему немного бьет, но, поскольку он жестко запрограммирован, вероятно, это не то, что вы можете использовать в долгосрочной перспективе.

import itertools
from operator import mul
from functools import partial

prod = partial(reduce, mul)
elems = carbons, hydrogens, nitrogens, oxygens17, oxygens18, sulfurs33, sulfurs34, sulfurs36
p = itertools.product(*elems)

totals = [
    ( sum(massdiffs), prod(chances) )
    for massdiffs, chances in
    ( zip(*i) for i in p )
]
person Antti Haapala    schedule 03.07.2014
comment
Я удалил кусок лямбда (что было немного глупо, я должен согласиться). Могли бы вы сказать (в моих фрагментах, как они есть сейчас), что оставшаяся разница (около 0,5 секунды) происходит от промежуточных значений? - person Bas Jansen; 03.07.2014
comment
мой код дает 0,788 против 0,562 для циклов, так что я думаю, не намного быстрее - person Antti Haapala; 03.07.2014
comment
Разница уже намного меньше, чем то, что я первоначально наблюдал, но я все еще удивлен заметной разницей в производительности с функцией itertools.product. - person Bas Jansen; 03.07.2014
comment
Я должен сказать, что у меня нет дальнейших идей относительно того, что указывает на то, что все еще делает вариант itertools медленнее, чем вложенные циклы for, поскольку сокращение было удалено из кода, и единственная разница в соответствии с cProfiler исходит из второй строки в вывод профилирования (который мало что говорит). - person Bas Jansen; 03.07.2014
comment
Мне очень нравится, как вы избавились от лишнего цикла for, который у меня был там, и действительно, эта реализация делает разницу в производительности незначительной (между обоими вариантами), ура. - person Bas Jansen; 03.07.2014
comment
каждый поиск имени в python и python 2 особенно очень дорог - если вы можете избавиться от них, вы значительно улучшите скорость. - person Antti Haapala; 03.07.2014

Я засекал эти две функции, которые используют абсолютный минимум лишнего кода:

def nested_for(first_iter, second_iter):
    for i in first_iter:
        for j in second_iter:
            pass

def using_product(first_iter, second_iter):
    for i in product(first_iter, second_iter):
        pass

Их инструкции байт-кода похожи:

dis(nested_for)
  2           0 SETUP_LOOP              26 (to 28)
              2 LOAD_FAST                0 (first_iter)
              4 GET_ITER
        >>    6 FOR_ITER                18 (to 26)
              8 STORE_FAST               2 (i)

  3          10 SETUP_LOOP              12 (to 24)
             12 LOAD_FAST                1 (second_iter)
             14 GET_ITER
        >>   16 FOR_ITER                 4 (to 22)
             18 STORE_FAST               3 (j)

  4          20 JUMP_ABSOLUTE           16
        >>   22 POP_BLOCK
        >>   24 JUMP_ABSOLUTE            6
        >>   26 POP_BLOCK
        >>   28 LOAD_CONST               0 (None)
             30 RETURN_VALUE

dis(using_product)
  2           0 SETUP_LOOP              18 (to 20)
              2 LOAD_GLOBAL              0 (product)
              4 LOAD_FAST                0 (first_iter)
              6 LOAD_FAST                1 (second_iter)
              8 CALL_FUNCTION            2
             10 GET_ITER
        >>   12 FOR_ITER                 4 (to 18)
             14 STORE_FAST               2 (i)

  3          16 JUMP_ABSOLUTE           12
        >>   18 POP_BLOCK
        >>   20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

И вот результаты:

>>> timer = partial(timeit, number=1000, globals=globals())
>>> timer("nested_for(range(100), range(100))")
0.1294467518782625
>>> timer("using_product(range(100), range(100))")
0.4335527486212385

Результаты дополнительных тестов, выполненных с помощью timeit и ручного использования perf_counter, соответствовали приведенным выше. Использование product явно значительно медленнее, чем использование вложенных циклов for. Однако, исходя из тестов, уже показанных в предыдущих ответах, расхождение между двумя подходами обратно пропорционально количеству вложенных циклов (и, конечно же, размеру кортежа, содержащего декартово произведение).

person Isaac Saffold    schedule 05.10.2017

Мое сильное подозрение в том, что медлительность связана с созданием временных переменных/местами добавления/созданием функции каждый раз через lambda, а также накладными расходами на вызов функции. Просто чтобы продемонстрировать, почему то, как вы делаете сложение, медленнее в случае 2, я сделал это:

import dis
s = '''
    a = (1, 2)
    b = (2, 3)
    c = (3, 4)

    z = (a[0] + b[0] + c[0])

    t = 0
    t += a[0]
    t += b[0]
    t += c[0]
    '''

x = compile(s, '', 'exec')

dis.dis(x)

Это дает:

<snip out variable declaration>
5          18 LOAD_NAME                0 (a)
           21 LOAD_CONST               4 (0)
           24 BINARY_SUBSCR
           25 LOAD_NAME                1 (b)
           28 LOAD_CONST               4 (0)
           31 BINARY_SUBSCR
           32 BINARY_ADD
           33 LOAD_NAME                2 (c)
           36 LOAD_CONST               4 (0)
           39 BINARY_SUBSCR
           40 BINARY_ADD
           41 STORE_NAME               3 (z)

7          50 LOAD_NAME                4 (t)
           53 LOAD_NAME                0 (a)
           56 LOAD_CONST               4 (0)
           59 BINARY_SUBSCR
           60 INPLACE_ADD
           61 STORE_NAME               4 (t)

8          64 LOAD_NAME                4 (t)
           67 LOAD_NAME                1 (b)
           70 LOAD_CONST               4 (0)
           73 BINARY_SUBSCR
           74 INPLACE_ADD
           75 STORE_NAME               4 (t)

9          78 LOAD_NAME                4 (t)
           81 LOAD_NAME                2 (c)
           84 LOAD_CONST               4 (0)
           87 BINARY_SUBSCR
           88 INPLACE_ADD
           89 STORE_NAME               4 (t)
           92 LOAD_CONST               5 (None)
           95 RETURN_VALUE

As you can see there is an additional 2 opcode overhead because of the += addition vs the inline addition. This overhead comes from needing to load and store the name. I imagine this is just the beginning and Antti Haapala has code that spends more time in cpython builtins calling c code than running just in python. Function call overhead is expensive in python.

person dreamriver    schedule 03.07.2014