Ускорение операций по модулю в CPython

Это генератор псевдослучайных чисел Парка-Миллера:

def gen1(a=783):
    while True:
        a = (a * 48271) % 0x7fffffff
        yield a

783 — это просто произвольное семя. 48271 — это коэффициент, рекомендованный Парком и Миллером в оригинальной статье (PDF: Парк, Стивен К.; Миллер, Кит В. (1988). Генераторы случайных чисел: трудно найти хорошие)

Я хотел бы улучшить производительность этого LCG. В литературе описан способ избежать деления с помощью побитовых приемов (источник):

Простой модуль требует вычисления произведения двойной ширины и явного шага редукции. Если используется модуль чуть меньше степени 2 (популярны простые числа Мерсенна 231−1 и 261−1, а также 232< /sup>−5 и 264−59), сокращение по модулю m = 2e − d может быть реализовано дешевле, чем обычное деление двойной ширины с использованием тождества 2 e ≡ d (mod m).

Отметив, что модуль 0x7fffffff на самом деле является простым числом Мерсенна 2 ** 32 - 1, вот идея, реализованная в Python:

def gen2(a=783):
    while True:
        a *= 48271
        a = (a & 0x7fffffff) + (a >> 31)
        a = (a & 0x7fffffff) + (a >> 31)
        yield a

Базовый тестовый скрипт:

import time, sys

g1 = gen1()
g2 = gen2()

for g in g1, g2:
    t0 = time.perf_counter()
    for i in range(int(sys.argv[1])): next(g)
    print(g.__name__, time.perf_counter() - t0)

Производительность улучшена в pypy (7.3.0 @ 3.6.9), например, генерируется 100 млн терминов:

$ pypy lcg.py 100000000
gen1 0.4366550260456279
gen2 0.3180829349439591

К сожалению, в CPython (3.9.0/Linux) производительность фактически ухудшилась:

$ python3 lcg.py 100000000
gen1 20.650125587941147
gen2 26.844335232977755

Мои вопросы:

  • Почему побитовая арифметика, обычно рекламируемая как оптимизация, на самом деле даже медленнее, чем операция по модулю в CPython?
  • Можете ли вы улучшить производительность этого PRNG под CPython каким-либо другим способом, возможно, используя numpy или типы?

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

>>> 0x7fffffff.bit_length()
31

person wim    schedule 26.01.2021    source источник
comment
Я подозреваю, что с CPython лучше всего что-то вроде numpy с numba, возможно, или просто создание C-расширения (возможно, с Cython).   -  person juanpa.arrivillaga    schedule 27.01.2021
comment
Я получил примерно в 4 раза более быстрый результат со встроенным from random import getrandbits. %timeit getrandbits(31) IPython было 50 нс, а ваше gen1() было 210 нс. Я построил C-версию gen1() и gen2() и вызвал ctypes, но gen1() работал с той же скоростью, а gen2() все еще медленнее.   -  person Mark Tolonen    schedule 27.01.2021
comment
@MarkTolonen На самом деле, я не ищу старый генератор случайных чисел. Мне конкретно нужно работать с этой периодической последовательностью. Интересно, как pypy может сделать это намного лучше, чем я мог бы добиться с помощью ctypes, numpy и/или numba...   -  person wim    schedule 27.01.2021
comment
удивляюсь, как pypy может выполнять jit намного лучше, чем я мог бы добиться с помощью ctypes, numpy и/или numba — CPython проводит большую часть своего времени в интерпретаторе, просматривая байт-код и типы тоже. Поскольку в сложном коде гораздо больше шагов, накладные расходы интерпретатора съедают то, что вы получаете от исключения целочисленного деления. Компилятор JIT, конечно, также выдает более длинный код для сложного, но в этом случае действительно важно, что DIV/IDIV действительно медленная инструкция (в то время как инструкции shift/add/and часто занимают только один цикл).   -  person tevemadar    schedule 27.01.2021
comment
Похоже, все ответы уже есть в комментариях.   -  person user202729    schedule 27.01.2021
comment
Я должен был предложить попробовать собственный модуль, но я не уверен, достаточно ли он набирает на таких нескольких шагах. Дублированная тема stackoverflow.com/questions/ 9009139/ завершается этим предложением, но нет твердых фактов или измерений. И разрекламированный ответ совершенно не по теме для 32-битных чисел.   -  person tevemadar    schedule 27.01.2021


Ответы (1)


Я предполагаю, что в CPython-версии львиная доля времени уходит на накладные расходы (интерпретатор, динамическая диспетчеризация), а не на собственно арифметические операции. Таким образом, добавление дополнительных шагов (т.е. дополнительных накладных расходов) не очень помогает.

Время работы PyPy больше похоже на то, что необходимо для 10 ^ 8 операций по модулю с C-целыми числами, поэтому он, вероятно, может использовать JIT, который не требует больших накладных расходов, и, таким образом, мы можем видеть ускорение арифметических операций. .

Возможный способ уменьшить накладные расходы — использовать Cython (здесь мое исследование того, как Cython может помочь уменьшить интерпретатор и диспетчерские-накладные расходы), и работает из коробки для генераторов:

%%cython
def gen_cy1(int a=783):
    while True:
        a = (a * 48271) % 0x7fffffff
        yield a
        
def gen_cy2(int a=783):
    while True:
        a *= 48271
        a = (a & 0x7fffffff) + (a >> 31)
        a = (a & 0x7fffffff) + (a >> 31)
        yield a

Я использую следующую функцию для тестирования:

def run(gen,N):
    for i in range(N): next(gen)

и тесты показывают:

N=10**6
%timeit run(gen1(),N)   #  246 ms
%timeit run(gen2(),N)   #  387 ms
%timeit run(gen_cy1(),N)   # 114 ms
%timeit run(gen_cy2(),N)   # 107 ms

Обе версии Cython одинаково быстры (и несколько быстрее, чем оригинал), потому что больше операций на самом деле не требует больших накладных расходов, поскольку арифметические операции выполняются с C-int, а не с Python-ints.

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

Просто чтобы дать представление, что было бы возможно, если бы не использовались Python-генераторы - функции, которые генерируют все числа (но не конвертируют их в Python-объекты и, следовательно, без накладных расходов):

%%cython
def gen_last_cy1(int n, int a=783):
    cdef int i
    for i in range(n):
        a = (a * 48271) % 0x7fffffff
    return a

def gen_last_cy2(int n, int a=783):
    cdef int i
    for i in range(n):
        a *= 48271
        a = (a & 0x7fffffff) + (a >> 31)
        a = (a & 0x7fffffff) + (a >> 31)
    return a

привести к следующим таймингам:

N=10**6
%timeit gen_last_cy1(N)  # 7.21 ms
%timeit gen_last_cy2(N)  # 2.59 ms

Это означает, что можно сэкономить более 90% рабочего времени, если генератор не используется!


Я был немного удивлен, что измененная вторая версия превзошла оригинальную первую. Обычно C-компиляторы не выполняют операции по модулю напрямую, а сами используют битовые трюки, если это возможно. Но здесь приемы Си-компилятора уступают по крайней мере на моей машине.

Ассемблер (доступен на gotbold.org), сгенерированный gcc (-O2) для оригинальной версии:

        imull   $48271, %edi, %edi
        movslq  %edi, %rdx
        movq    %rdx, %rax
        salq    $30, %rax
        addq    %rdx, %rax
        movl    %edi, %edx
        sarl    $31, %edx
        sarq    $61, %rax
        subl    %edx, %eax
        movl    %eax, %edx
        sall    $31, %edx
        subl    %eax, %edx
        movl    %edi, %eax
        subl    %edx, %eax

как видно, div нет.

А вот ассемблер для второй версии (с гораздо меньшим количеством операций):

        imull   $48271, %edi, %eax
        movl    %eax, %edx
        sarl    $31, %eax
        andl    $2147483647, %edx
        addl    %edx, %eax
        movl    %eax, %edx
        sarl    $31, %eax
        andl    $2147483647, %edx
        addl    %edx, %eax

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

person ead    schedule 27.01.2021