Почему это улучшенное сито медленнее с pypy?

def sieve(n):
    nums = [0] * n
    for i in range(2, int(n**0.5)+1):
        if nums[i] == 0:
            for j in range(i*i, n, i):
                nums[j] = 1

    return [i for i in range(2, n) if nums[i] == 0]

def sieve_var(n):
    nums = [0] * n
    for i in range(3, int(n**0.5)+1, 2):
        if nums[i] == 0:
            for j in range(i*i, n, i):
                nums[j] = 1

    return [2] + [i for i in range(3, n, 2) if nums[i] == 0]

На моей машине sieve(10**8) занимает 2,28 с, а sieve_var(10**8) - 2,67 с. Я не думаю, что время PIPY-разминки является виновником здесь, так почему же не sieve_var, который повторяет меньше, быстрее? В стандартном python 3.3 sieve_var работает быстрее, чем ожидалось. Использование pypy 4.0.1 32bit в Windows 8.1.

Изменить: В качестве теста я добавил count = 0 в начале функции и count += 1 внутри внутреннего цикла (где nums[j] = 1). sieve(10**8) подсчитывается 242570202, а sieve_var(10**8) - 192570204. Таким образом, хотя счетчик не уменьшается в два раза для sieve_var, он делает меньше "работы".

Ответы

Ответ 1

Я не уверен, почему он немного медленнее в Windows. В Linux скорость такая же. Однако я могу ответить, почему мы получаем в основном ту же скорость. Ответ будет таким же, если программа была написана на C, а ответ - только на уровне процессора. Эта программа связана с вводом-выводом памяти, доступ к списку, размер которого составляет 400 или 800 МБ. Во второй версии вы в основном избегаете одной дополнительной проверки if nums[i] == 0. Однако эта дополнительная проверка ничего не стоит, потому что CPU только что выбрал nums[i - 1] в своих кэшах во время предыдущей итерации и понадобится nums[i + 1] во время следующей итерации. CPU все равно ждет памяти.

Чтобы проверить, что я говорю, попробуйте сделать массив nums более компактным. Я попытался получить к нему доступ с помощью nums[i // 2], считая, что i всегда нечетный, и результат был в два раза быстрее. Вероятно, вы можете выиграть еще больше, не используя список Python (хранящийся в виде массива из 32-разрядных целых чисел в 32-разрядном PyPy), но вместо этого массив бит (но это намного больше кода, потому что нет стандартного встроенного массив бит).

Ответ 2

TL, DR;

В качестве C-программы это будет алгоритм привязки к памяти. Тем не менее, даже jit-скомпилированный pypy-code имеет значительно больше накладных расходов, и операции больше не "бесплатны". Удивительно (или, может быть, нет), rhese две версии sieve имеют разные jit-коды, вероятно, просто неудача, что вторая версия приводит к более медленному коду.


Если это будет C, ответ @Armin будет правильным. Хорошо известно, что для современных компьютеров/кэшей и кода, привязанного к памяти, не имеет значения, перепрыгиваем ли мы через целое число - тем не менее все значения должны извлекаться из памяти, а это бутылочная шее. См. эту статью для отличного объяснения.

Однако мои эксперименты показывают, что неоптимизированная версия (sieve) немного быстрее оптимизированной версии (sieve_var). Сроки также показывают, что последняя строка sieve i.e. [i for i in range(2, n) if nums[i] == 0] выполняется быстрее, чем строка sieve_var - return [2] + [i for i in range(3, n, 2) if nums[i] == 0].

На моей машине было 0.45 секунд против 0.65 секунд для 10^8 элементов. Эти числа могут отличаться от машины к машине, поэтому вполне возможно, что кто-то с более быстрым процессором и более медленной памятью вообще не увидит никакой разницы. Если это можно объяснить с точки зрения "памяти доминирует над всеми", то мы должны уметь видеть, что более медленная версия имеет больше промахов в кешках как более быструю версию.

Однако, запустив valgrind --tool=cachegrind pypy sieveXXX.py, мы увидим, что почти нет разницы в количестве промахов в кэше, по крайней мере, ничего, что могло бы объяснить наблюдаемую разницу.

Давайте рассмотрим несколько более простую версию, которая проявляет точно такое же поведение - мы не сохраняем простые числа, а просто считаем их:

def sieve(n):
    ...
    res=0
    for i in range(2, n): 
          if nums[i] == 0:
              res+=1
    return res

def sieve_var(n):
    ...
    res=1
    for i in range(3, n,2): 
          if nums[i] == 0:
              res+=1
    return res

Первая версия еще быстрее: 0.35 сек. vs. 0.45 sec (чтобы убедиться, что разница во времени не случайна, а не из-за некоторого jit-warmup, я поставил последнюю часть кода в цикл for и получил всегда одни и те же тайминги).

Прежде чем идти дальше, давайте взглянем на C-реализацию и ее сборку

long long int sum(long long int *a, int n){
    long long int res=0;
    for(int i=2;i<n;i++)
       if(a[i]==0)
          res++;
    return res;
} 

скомпилировано с gcc и -Os получаем:

        movl    $2, %edx
        xorl    %eax, %eax
.L4:
        cmpl    %edx, %esi
        jle     .L1
        cmpq    $0, (%rdi,%rdx,8)
        jne     .L3
        incq    %rax
.L3:
        incq    %rdx
        jmp     .L4
.L1:
        ret

Довольно маленький и прямой и занимает всего 0.08 секунды на моей машине. Моя память может идти так же быстро, как 10 ГБ/с, и есть 8*10^8 байты - поэтому все время было необходимо для получения данных.

Но из этого мы также видим, что pypy-версия имеет около 0.25 секунд накладных расходов по сравнению с C-кодом. От куда это? Используя vmprof-module, мы можем увидеть jit-код и:

  • для одной итерации цикла выполняется гораздо больше операций, чем в C-версии
  • версии для sieve и sieve_par очень разные. Мы можем использовать отладчик для подсчета количества команд итерации: 24 для sieve и 76 -операций для sieve_var, которые обрабатывают только каждую секунду элемент, поэтому отношение действительно 24:38.

Трудно сказать, почему jit-код настолько отличается для обеих версий без отладки pypy. Вероятно, это просто неудача, что sieve_par медленнее.