PyPy в 17 раз быстрее Python. Можно ли ускорить Python?

Решив недавнюю проблему появления кода, я обнаружил, что мой Python по умолчанию был примерно в 40 раз медленнее, чем PyPy. Мне удалось уменьшить это примерно до 17x с помощью этого кода, ограничив вызовы до len и ограничив глобальные поиск, запустив его в функции.

Прямо сейчас e.py выполняется за 5,162 секунды на python 3.6.3 и 0,297 секунды на PyPy на моей машине.

Мой вопрос: является ли это неснижаемым ускорением JIT или есть способ ускорить ответ CPython? (за исключением крайних случаев: я мог бы пойти на Cython / Numba или что-то в этом роде?) Как мне убедить себя, что я больше ничего не могу сделать?


См. Суть для списка входного файла чисел.

Как описано в постановке проблемы, они представляют собой смещения перехода. position += offsets[current] и увеличьте текущее смещение на 1. Вы закончили, когда прыжок выводит вас за пределы списка.

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

(0) 3  0  1  -3  - before we have taken any steps.
(1) 3  0  1  -3  - jump with offset 0 (that is, don't jump at all). Fortunately, the instruction is then incremented to 1.
 2 (3) 0  1  -3  - step forward because of the instruction we just modified. The first instruction is incremented again, now to 2.
 2  4  0  1 (-3) - jump all the way to the end; leave a 4 behind.
 2 (4) 0  1  -2  - go back to where we just were; increment -3 to -2.
 2  5  0  1  -2  - jump 4 steps forward, escaping the maze.

Код:

def run(cmds):
    location = 0
    counter = 0
    while 1:
        try:
            cmd = cmds[location]
            if cmd >= 3:
                cmds[location] -= 1
            else:
                cmds[location] += 1
            location += cmd
            if location < 0:
                print(counter)
                break
            counter += 1
        except:
            print(counter)
            break

if __name__=="__main__":
    text = open("input.txt").read().strip().split("\n")
    cmds = [int(cmd) for cmd in text]
    run(cmds)

edit: я скомпилировал и запустил код с помощью Cython, что снизило время выполнения до 2,53 с, но это все еще почти на порядок медленнее, чем PyPy.

изменить: Numba позволяет мне в пределах 2x

изменить: лучший Cython, который я мог написать, снизился до 1,32 с, а немного больше 4x pypy

edit: Перемещение переменной cmd в cdef, как было предложено @viraptor, снизило Cython до 0,157 секунды! Почти на порядок быстрее, и не так далеко от обычного python. Тем не менее, я очень впечатлен PyPy JIT, который сделал все это бесплатно!


person llimllib    schedule 06.12.2017    source источник
comment
Я спрашиваю не потому, что это, конечно, хорошая идея, а потому, что это величайший pypy - ›несоответствие python, которое я видел на практике, поэтому мне это интересно.   -  person Peter Cordes    schedule 06.12.2017
comment
Я не очень хорошо знаком с Python (или производительностью различных реализаций), но да, я ожидал, что JIT поможет много для небольшого цикла, связанного с процессором, подобного этому. Мне кажется, это интересный вопрос; проголосовали за. Возможно, вам следует кратко описать проблему, а не просто указать внешнюю ссылку.   -  person llimllib    schedule 06.12.2017
comment
Можете ли вы поместить _1_ вокруг цикла, чтобы интерпретатору не приходилось вводить новый _2_ блок каждый раз во время цикла?   -  person Peter Cordes    schedule 06.12.2017
comment
По крайней мере, пройдите через это и убедитесь, что вы отметили хорошо известный. на CodeReview   -  person Peter Cordes    schedule 06.12.2017
comment
@PeterCordes, выводя попытку из цикла, дает небольшой скачок, время выполнения сокращается до 4,8 с ????   -  person juanpa.arrivillaga    schedule 06.12.2017
comment
В частности, посмотрите здесь   -  person llimllib    schedule 06.12.2017
comment
И если вы собираетесь использовать _1_, вы также можете использовать _2_. Эти комбинации довольно быстрые.   -  person juanpa.arrivillaga    schedule 06.12.2017
comment
@ juanpa.arrivillaga в основном я использовал numba, чтобы ответить на вопрос для себя: является ли jit причиной разницы более чем на порядок? И ответ определенно кажется положительным. Но хотелось бы разобраться в этом подробнее!   -  person juanpa.arrivillaga    schedule 06.12.2017
comment
Откуда взялось _1_ условие? Я не вижу, чтобы это упоминалось в формулировке проблемы, просто всегда увеличивайте.   -  person llimllib    schedule 06.12.2017
comment
@PeterCordes, это условие части 2, которую вы должны заполнить часть 1, чтобы разблокировать   -  person Peter Cordes    schedule 06.12.2017
comment
Это намного быстрее без _1_? Меньше накладных расходов на интерпретатор, но версия JIT, вероятно, также быстрее из-за меньшего количества ошибочных прогнозов ветвлений (если только она не испускает asm без ветвей).   -  person llimllib    schedule 06.12.2017
comment
Что, если вы вытащите if из цикла и позволите проверке границ справиться с этим? Одно беззнаковое сравнение может поймать отрицательное и большое положительное _2_, поэтому либо сделайте это самостоятельно, либо позвольте интерпретатору сделать это, но не делайте и того, и другого.   -  person Peter Cordes    schedule 06.12.2017
comment
@PeterCordes, если бы я вытащил местоположение, тогда код не завершился бы ошибкой при отрицательных индексах, потому что python поддерживает отрицательные индексы, поэтому я бы получил неправильный ответ   -  person Peter Cordes    schedule 06.12.2017
comment
Ага, он оборачивается до конца контейнера. Ой! Есть ли способ превратить проверку в беззнаковое сравнение с _1_ или как там он называется, чтобы отрицательный результат рассматривался как большой положительный результат? (И оставьте _2_). Возможно, это приведет к увеличению накладных расходов на интерпретатор и даже того не стоит. Но это то, что вы бы сделали в asm: P   -  person llimllib    schedule 06.12.2017
comment
@ juanpa.arrivillaga, насколько я могу судить, никакие предложения на этой странице не относятся к этому коду. Я попытался заменить cmds.size на try и _3_ до того, как опубликовал этот вопрос, и это не ускоряет AFAICT   -  person Peter Cordes    schedule 06.12.2017
comment
@llimllib вы встроили эти вызовы методов?   -  person llimllib    schedule 06.12.2017
comment
Не уверен, что вы имеете в виду @ juanpa.arrivillaga   -  person juanpa.arrivillaga    schedule 06.12.2017
comment
@llimllib избегая точек   -  person llimllib    schedule 06.12.2017
comment
о да, вот как я реализовал это @ juanpa.arrivillaga   -  person juanpa.arrivillaga    schedule 06.12.2017
comment
чтение файла не имеет отношения к среде выполнения, вначале все это происходит как постоянные затраты. Хотя ты прав   -  person llimllib    schedule 06.12.2017


Ответы (2)


В качестве основы для Python я написал это на C (а затем решил использовать C ++ для более удобного ввода-вывода массива). Он эффективно компилируется для x86-64 с помощью clang ++. Это работает в 82 раза быстрее, чем CPython3.6.2 с указанным в вопросе кодом, на Skylake x86, поэтому даже ваши более быстрые версии Python по-прежнему на пару факторов отстают от почти оптимального машинного кода. (Да, я посмотрел на вывод asm компилятора, чтобы убедиться, что он работает хорошо).

Разрешение хорошему JIT-компилятору или опережающему компилятору увидеть логику цикла является ключом к производительности здесь. Логика проблемы по своей сути последовательна, поэтому нет возможности заставить Python запускать уже скомпилированный C для выполнения что-то по всему массиву (например, NumPy), потому что для этой конкретной проблемы не будет скомпилирован C, если вы не используете Cython или что-то в этом роде. Возвращение каждого шага к решению проблемы интерпретатору CPython - смерть для производительности, когда его медлительность не скрывается узкими местами в памяти или чем-то еще.


Обновление: преобразование массива смещений в массив указателей ускоряет его еще в 1,5 раза (простой режим адресации + удаление add из цепочки зависимостей, переносимых циклом критического пути, что снижает его до просто 4-тактная задержка загрузки-использования L1D для простого режима адресации (когда указатель исходит из другая загрузка), а не 6c = 5c + 1c для режима индексированной адресации + add задержка).

Но я думаю, что мы можем проявить щедрость к Python и не ожидать, что он будет идти в ногу с преобразованиями алгоритмов, подходящими для современных процессоров: P (особенно потому, что я использовал 32-битные указатели даже в 64-битном режиме, чтобы убедиться, что массив элементов 4585 был по-прежнему только 18kiB, поэтому он умещается в кэш-памяти L1D 32kiB. Как Linux x32 ABI или AArch64 ILP32 ABI.)


Кроме того, альтернативное выражение для обновления получает команду gcc для его компиляции без ветвей, как это делает clang. (Закомментированный и исходный perf stat результат оставлен в этом ответе, потому что интересно увидеть эффект безветвия и ветвления с ошибочными прогнозами.)

unsigned jumps(int offset[], unsigned size) {
    unsigned location = 0;
    unsigned counter = 0;

    do {
          //location += offset[location]++;            // simple version
          // >=3 conditional version below

        int off = offset[location];

        offset[location] += (off>=3) ? -1 : 1;       // branchy with gcc
        // offset[location] = (off>=3) ? off-1 : off+1;  // branchless with gcc and clang.  

        location += off;

        counter++;
    } while (location < size);

    return counter;
}

#include <iostream>
#include <iterator>
#include <vector>

int main()
{
    std::ios::sync_with_stdio(false);     // makes cin faster
    std::istream_iterator<int> begin(std::cin), dummy;
    std::vector<int> values(begin, dummy);   // construct a dynamic array from reading stdin

    unsigned count = jumps(values.data(), values.size());
    std::cout << count << '\n';
}

В clang4.0.1 -O3 -march=skylake внутренний цикл не имеет ветвей; он использует условный ход для условия >=3. Я использовал ? :, потому что надеялся, что компилятор сделает именно это. Источник + asm на Обозреватель компилятора Godbolt

.LBB1_4:                                # =>This Inner Loop Header: Depth=1
    mov     ebx, edi               ; silly compiler: extra work inside the loop to save code outside
    mov     esi, dword ptr [rax + 4*rbx]  ; off = offset[location]
    cmp     esi, 2
    mov     ecx, 1
    cmovg   ecx, r8d               ; ecx = (off>=3) ? -1 : 1;  // r8d = -1 (set outside the loop)
    add     ecx, esi               ; off += -1 or 1
    mov     dword ptr [rax + 4*rbx], ecx  ; store back the updated off
    add     edi, esi               ; location += off  (original value)
    add     edx, 1                 ; counter++
    cmp     edi, r9d
    jb      .LBB1_4                ; unsigned compare against array size

Вот результат perf stat ./a.out < input.txt (для версии clang) на моем процессоре Skylake i7-6700k:

21841249        # correct total, matches Python

 Performance counter stats for './a.out':

         36.843436      task-clock (msec)         #    0.997 CPUs utilized          
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               119      page-faults               #    0.003 M/sec                  
       143,680,934      cycles                    #    3.900 GHz                    
       245,059,492      instructions              #    1.71  insn per cycle         
        22,654,670      branches                  #  614.890 M/sec                  
            20,171      branch-misses             #    0.09% of all branches        

       0.036953258 seconds time elapsed

Среднее число инструкций за такт намного ниже 4 из-за зависимости данных в цикле. Адрес загрузки для следующей итерации зависит от нагрузки + добавления для этой итерации, и выполнение вне очереди не может это скрыть. Однако он может перекрывать всю работу по обновлению значения текущего местоположения.

Переход с int на short не приводит к снижению производительности (как и ожидалось; _ 13_ имеет ту же задержку, что и mov в Skylake), но снижает потребление памяти вдвое, поэтому больший массив может поместиться в кеш L1D если нужно.

Я попытался скомпилировать массив в программу (как int offsets[] = { file contents with commas added };, чтобы его не нужно было читать и анализировать. Он также сделал размер константой времени компиляции. Это сократило время выполнения до ~ 36,2 +/- 0,1 миллисекунды по сравнению с ~ 36.8, поэтому версия, которая читает из файла, по-прежнему тратила большую часть своего времени на решение реальной проблемы, а не на синтаксический анализ ввода (в отличие от Python, у C ++ незначительные накладные расходы на запуск, а мой процессор Skylake хорошо разгоняется до максимальной тактовой частоты. менее чем за миллисекунду благодаря аппаратному управлению P-состоянием в Skylake.)


gcc использует ветвь, и это примерно в 2 раза медленнее. (14% ветвей прогнозируются неверно в соответствии с perf stat по всей программе. Это только ветвление как часть обновления значений, но пропуски ветвления останавливают конвейер, поэтому они также влияют на критический путь таким образом, что зависимости данных не не здесь. Оптимизатор решил, что это плохое решение.)


Переписывание условного выражения как offset[location] = (off>=3) ? off-1 : off+1; убеждает gcc отказаться от использования asm, что выглядит неплохо.

gcc7.1.1 -O3 -march = skylake (для исходного источника, который компилируется с веткой для (off <= 3) ? : -1 : +1).

vs. CPython (Python3.6.2 в Arch Linux):

Performance counter stats for './ec-gcc':

     70.032162      task-clock (msec)         #    0.998 CPUs utilized          
             0      context-switches          #    0.000 K/sec                  
             0      cpu-migrations            #    0.000 K/sec                  
           118      page-faults               #    0.002 M/sec                  
   273,115,485      cycles                    #    3.900 GHz                    
   255,088,412      instructions              #    0.93  insn per cycle         
    44,382,466      branches                  #  633.744 M/sec                  
     6,230,137      branch-misses             #   14.04% of all branches        

   0.070181924 seconds time elapsed

У меня не установлен PyPy или другие реализации Python, извините.

perf stat python ./orig-v2.e.py
21841249

 Performance counter stats for 'python ./orig-v2.e.py':

       3046.703831      task-clock (msec)         #    1.000 CPUs utilized          
                10      context-switches          #    0.003 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               923      page-faults               #    0.303 K/sec                  
    11,880,130,860      cycles                    #    3.899 GHz                    
    38,731,286,195      instructions              #    3.26  insn per cycle         
     8,489,399,768      branches                  # 2786.421 M/sec                  
        18,666,459      branch-misses             #    0.22% of all branches        

       3.046819579 seconds time elapsed

Вы можете улучшить мелочи, но pypy (скорее всего) всегда будет быстрее в этой задаче.

person Peter Cordes    schedule 06.12.2017

И для CPython, и для Cython:

Для Cython:

  • Возможно, переключитесь со списка на массив.
  • Отметьте типы переменных. Особенно счетчики в виде ints и команды в виде массива ints для пропуска большинства проверок типа.

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

  • Из постановки задачи: Срочное прерывание поступает от ЦП: оно застряло в лабиринте инструкций перехода. Очевидно, бойкий ответ таков: не пишите обработчики IRQ на Python: P
person viraptor    schedule 06.12.2017
comment
сделайте это 1.3s. Закрывать! Менее 5-кратного штрафа скорости, но все дальше и дальше от питона :) - person llimllib; 06.12.2017
comment
@llimllib Вы можете сделать лучше: переименуйте _1_ в понимании списка, чтобы он не противоречил тому, что находится в _2_. Затем добавьте _3_ перед петлей. В противном случае каждый _4_ создается как объект. - person llimllib; 06.12.2017
comment
@llimllib cython фактически превосходит pypy со всеми предоставленными типами: pyx из сути: cmd, после изменений из последнего комментария: while (оба скомпилированы с cdef int cmd = 0 для python3.6) - person viraptor; 06.12.2017
comment
ах хороший момент, не могу поверить, что пропустил этот вар! cython до .157s, очень впечатляет! - person viraptor; 06.12.2017
comment
Как описано ранее, поиск указателя с использованием простого режима адресации, такого как _16_ вместо _17_, имеет на 1с меньшую задержку и позволяет избежать _18_ (_19_ становится _20_). Intel, поскольку IvyBridge имеет целое число _21_ с нулевой задержкой между регистрами, чтобы не удлинять критический путь. Вот <сильный> источник (с комментариями) + ASM для этого Hacky оптимизация . Типичный прогон (с синтаксическим анализом текста в _22_): _23_, 90,725 млн циклов (3,900 ГГц), _24_ (3,18 IPC). Интересно, что на самом деле это более полные инструкции, но они выполняются намного быстрее из-за более низкой задержки цепочки зависимостей с переносом цикла. - person llimllib; 06.12.2017