Выпуск 31. Сентябрь 2015

← YAPC::Europe 2015 | Содержание | Обзор CPAN за август 2015 г. →

Прецизионные бенчмарки Perl

Какой Perl самый быстрый? Имеет ли смысл переходить на cperl или stableperl? Возможно ли провести точное сравнение производительности разных perl?

Бенчмарки по-настоящему сложны

Если вы смотрели доклад Питера Рэббитсона «Benchmarking is REALLY hard», то помните, что выполнение бенчмарков — зто нетривиальная задача. Основа большинства бенчмарков — это измерение времени, за которое выполняется тестируемый фрагмент кода. Данная метрика зависит от множества факторов, которые вносят большую погрешность в измеряемую величину:

  • Динамическая тактовая частота процессора: современные процессоры могут «саморазгоняться» или, наоборот, снижать тактовую частоту в случае перегрева или включать режим экономии энергии. Кроме того, может наблюдаться нестабильность тактовой частоты при использовании дешёвых комплектующих.

  • Многоуровневая система кешей в процессоре: в зависимости от числа «промахов» время выполнение одного и того же кода может существенно изменяться от запуска к запуску.

  • Конкуренция за процессорное время: операционная система может выделять тестируемому процессу различное число тиков процессорного времени в зависимости от текущей загрузки и наличия других фоновых процессов.

Таким образом, во всех подобных бенчмарках возникает случайная погрешность, которую пытаются устранить проведением серии из большого числа измерений и вычислением средних значений. Всё же погрешность может оказаться существенной, и вычисленное ускорение на 5% может на самом деле оказаться замедлением на 5% и наоборот.

Сферический код в вакууме

Можно ли устранить все побочные эффекты и получить чистые измерения скорости работы кода? Для ответа на этот вопрос попробуем обратиться к архитектуре современных процессоров. Компьютеры на архитектуре фон Неймана используют память для хранения инструкций и данных и процессор для выполнения операций. В процессор загружается очередная инструкция из памяти, и происходит её выполнение. В современных системах частота работы процессора и памяти существенно различаются, к примеру, доступ в основную память может занимать до 200 циклов процессора, поэтому работа с памятью становится «бутылочным горлышком» в производительности системы.

Чтобы ускорить работу с памятью, была создана система кешей — быстрой памяти, расположенной внутри процессора, куда предварительно загружаются фрагменты основной памяти, что ускоряет доступ. Может использоваться два и больше уровней кеша, которые различаются по объёму памяти и скорости работы. Например, кеш первого уровня L1 может составлять 64 КБ и требовать для загрузки порядка 10 тактов. Таким образом, код, использующий 100 элементов данных, которые окажутся в кеше, будет загружен на 95% быстрее, чем в случае загрузки их из памяти (так называемый «кеш-промах»). Как правило, кеш раздельно хранит инструкции и данные, поскольку промах при загрузке инструкции выливается в простой процессора, в то время как промах в данных позволяет выполнять следующие инструкции, поэтому они не должны конкурировать между собой за место в кеше.

Конечно, 64 КБ это слишком мало по сравнению с текущими объёмами оперативной памяти, поэтому далее может идти кеш второго уровня большего объёма, но меньшей скорости доступа, и так далее. Практическое значение имеет лишь объём последнего уровня кеша, поскольку разрыв в скорости доступа между ним и обычной памятью самый большой. На современных процессорах объём этого кеша может составлять 8 МБ и больше.

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

Cachegrind

На сегодняшний день существует инструмент, который позволяет измерить описанные выше характеристики — профайлер кеша cachegrind утилиты valgrind.

В отличие от специализированных профайлеров, например, perf для Linux, cachegrind использует симуляцию процессора, системы кешей и предсказания условных и косвенных переходов. С одной стороны, полученные результаты могут отличаться от данных на реальных процессорах, но, с другой стороны, это работает на множестве различных архитектур и операционных систем, не зависит от текущей загрузки системы, даёт высокую воспроизводимость результатов, позволяя проводить точные сравнения производительности.

Рассмотрим пример, для программы на perl, которая выводит слово hi на экран:

$ valgrind --tool=cachegrind --branch-sim=yes \
    --cachegrind-out-file=/dev/null \
    perl -e 'print "hi\n"'

==19617== Cachegrind, a cache and branch-prediction profiler
==19617== Copyright (C) 2002-2013, and GNU GPL'd, by Nicholas Nethercote et al.
==19617== Using Valgrind-3.10.0.SVN and LibVEX; rerun with -h for copyright info
==19617== Command: perl -e print\ "hi\\n"
==19617== 
hi
==19617== 
==19617== I   refs:      1,129,633
==19617== I1  misses:        4,618
==19617== LLi misses:        3,168
==19617== I1  miss rate:      0.40%
==19617== LLi miss rate:      0.28%
==19617== 
==19617== D   refs:        407,661  (264,407 rd   + 143,254 wr)
==19617== D1  misses:       11,694  (  7,945 rd   +   3,749 wr)
==19617== LLd misses:        7,612  (  4,354 rd   +   3,258 wr)
==19617== D1  miss rate:       2.8% (    3.0%     +     2.6%  )
==19617== LLd miss rate:       1.8% (    1.6%     +     2.2%  )
==19617== 
==19617== LL refs:          16,312  ( 12,563 rd   +   3,749 wr)
==19617== LL misses:        10,780  (  7,522 rd   +   3,258 wr)
==19617== LL miss rate:        0.7% (    0.5%     +     2.2%  )
==19617== 
==19617== Branches:        200,099  (194,318 cond +   5,781 ind)
==19617== Mispredicts:      17,627  ( 16,447 cond +   1,180 ind)
==19617== Mispred rate:        8.8% (    8.4%     +    20.4%   )

О чём говорит полученный вывод:

  • I refs — количество загруженных инструкций;
  • I1 misses — число промахов в кеше первого уровня при загрузке инструкций;
  • LLi misses — число промахов в кеше последнего уровня при загрузке инструкций;
  • I1/LLi miss rate — соответствующая доля промахов в кешах в процентах;

  • D refs — количество операций с данными (суммарно для чтения и записи);
  • D1 misses — число промахов в кеше первого уровня при операциях с данными (суммарно для чтения и записи);
  • LLd misses — число промахов в кеше последнего уровня при операциях с данными (суммарно для чтения и записи);
  • D1/LLd miss rate — соответствующая доля промахов в кешах в процентах;

  • LL refs — сумма промахов в кеше первого уровня I1 + D1;
  • LL misses — сумма промахов в кеше последнего уровня LLi + LLd;
  • LL miss rate – доля промахов в кешах для данных и инструкций в процентах;

  • Branches — общее число переходов (условных и косвенных);
  • Mispredicts — число ошибок предсказания перехода;
  • Mispred rate — доля ошибок предсказаний перехода в процентах.

Если несколько раз повторить запуск команды, то можно увидеть, что каждый раз будет выводиться примерно одни и те же цифры с точностью до пятой(!) значащей цифры. Это означает, что можно выявить даже небольшие изменения в производительности. Например, добавив операцию конкатенации в программе: perl -e 'print "hi"."\n"', можно увидеть, что это будет стоить нам загрузки дополнительно порядка 6000 инструкций и 3000 данных.

Сравнение производительности разных версий Perl

В дереве исходников Perl есть утилита Porting/bench.pl, которая под капотом использует cachegrind и может проводить сравнение производительности для различных версий Perl. Она использует только базовые модули и работает на perl ≥ 5.10, поэтому её можно свободно загрузить и использовать отдельно от исходного кода Perl.

В оригинальном анонсе утилиты bench.pl показывалось сравнение пяти последних на тот момент стабильных версий perl и разрабатываемой версии perl 5.21.6:

$ Porting/bench.pl -j 8 \
        perl5125o perl5144o perl5163o perl5182o perl5201o perl5216o
...
    expr::assign::scalar_lex
    lexical $x = 1

           perl5125o perl5144o perl5163o perl5182o perl5201o perl5216o
           --------- --------- --------- --------- --------- ---------
        Ir    100.00    107.05    101.83    106.37    107.74    103.73
        Dr    100.00    103.64    100.00    105.56    105.56    100.00
        Dw    100.00    100.00     96.77    100.00    100.00     96.77
      COND    100.00    120.83    116.00    126.09    126.09    120.83
       IND    100.00     80.00     80.00     80.00     80.00     80.00

    COND_m    100.00    100.00    100.00    100.00    100.00    100.00
     IND_m    100.00    100.00    100.00    100.00    100.00    100.00

     Ir_m1    100.00    100.00    100.00    100.00    100.00    100.00
     Dr_m1    100.00    100.00    100.00    100.00    100.00    100.00
     Dw_m1    100.00    100.00    100.00    100.00    100.00    100.00

     Ir_mm    100.00    100.00    100.00    100.00    100.00    100.00
     Dr_mm    100.00    100.00    100.00    100.00    100.00    100.00
     Dw_mm    100.00    100.00    100.00    100.00    100.00    100.00

В данном примере сравнивалось, как менялась производительность простейшей операции присвоения значения лексической переменной $x = 1. Все указанные цифры — относительные, от первого значения, т.е. в сравнении с perl 5.12.5. Чем больше значение, тем лучше (меньше реальных операций). Наименование строк в первой колонке соответствуют данным cachegrind: Ir — чтение инструкций, Dr — чтение данных, Dw — запись данных, COND и IND — количество условных и косвенных переходов, суффикс m означает соответствующие «промахи» в кешах.

Здесь была замечена регрессия в производительности между perl 5.20.1 и perl 5.21.6 по показателю Ir: 107.74 против 103.73. С помощью git bisect удалось найти, что это связано с коммитом, в котором началась использоваться опция компилятора, закручивающая гайки в безопасности, -fstack-protector-strong, которая и привела к замедлению работы perl.

Как работает утилита bench.pl?

Утилита использует файл t/perf/benchmarks, в котором перечислены множество небольших тестов в следующем формате:

'expr::assign::scalar_lex' => {
    desc    => 'lexical $x = 1',
    setup   => 'my $x',
    code    => '$x = 1',
},

Поле desc описывает суть теста, setup — некоторый инициализирующий код, затем code — собственно код, который будет тестироваться.

Для каждого подобного теста утилита создаёт тестовый скрипт вида:

package $test;
BEGIN { srand(0) }
$setup;
for my \$__loop__ (1..\$ARGV[0]) {
    $code;
}

Где $setup — это код инициализации, $code — тестируемый код. Утилита делает запуски со значением итераций цикла 10 (short) и 20 (long), с пустым тестируемым кодом и актуальным тестируемым кодом (всего четыре запуска valgrind). Таким образом, можно рассчитать и убрать оверхед, вносимый кодом инициализации, и сравнивать только тестируемый код.

Для чего можно применять bench.pl обычным пользователям?

Утилита bench.pl может быть полезна обычным пользователям, например, чтобы тестировать своё приложение для работы на новых версиях Perl. Критические к производительности участки кода могут быть вынесены в виде отдельного теста и прогоняться на blead или других версиях Perl, чтобы заранее обнаружить регрессии производительности и начать бить тревогу.

Утилиту bench.pl также можно использовать и для привычных бенчмарков, когда сравнивается не perl, а код программы. Например, если есть матрица 101x101, то что быстрее: записывать в колонку или ряд? Для этого можно создать файл с тестами my.bench:

[
    'test1' => {
        'desc'  => 'row',
        'setup' => 'my @x; $x[100][100] = 1',
        'code' => '$x[100][$_]=1 for 0..100'
    },
    'test2' => {
        'desc'  => 'col',
        'setup' => 'my @x; $x[100][100] = 1',
        'code' => '$x[$_][100]=1 for 0..100 '
    }
]

Далее запустим с параметром --raw, который покажет не относительные результаты, а фактические:

$ bench.pl -j 8 --benchfile=my.bench \
    --fields=Ir,Dr,Dw,COND,IND,COND_m,IND_m \
    --raw perl

test1
row

           perl
       --------
    Ir  77847.0
    Dr  27711.0
    Dw  12982.0
  COND  11314.0
   IND   1435.0
COND_m    108.0
 IND_m   1128.0

test2
col

           perl
       --------
    Ir  77847.0
    Dr  27711.0
    Dw  12982.0
  COND  11314.0
   IND   1435.0
COND_m    107.0
 IND_m   1128.0

Ответ: абсолютно всё равно.

Сравнение производительности perl 5.22.0, stableperl 1.001, cperl 5.22.1

После выпуска Perl 5.22.0 произошёл раскол в Perl-сообществе, проявившийся в виде серии форков: сначала stableperl от Марка Леманна, затем cperl от Рейни Урбана. Их объединяет несогласие с официальным направлением развития Perl, когда возникают нарушения обратной совместимости или принимаются спорные решения в реализации тех или иных новшеств без всестороннего обсуждения.

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

Попробуем сравнить производительность этих трёх реализаций: perl 5.22.0, cperl 5.22.1 и stableperl-1.001.

Можно воспользоваться perlbrew для установки подопытных:

# perl 5.22.0
$ perlbrew install perl-5.22.0

# stableperl 1.001
$ perlbrew install --as=stableperl-1.001 \
    http://stableperl.schmorp.de/dist/stableperl-5.22.0-1.001.tar.gz

# cperl 5.22.1
# хак: имя каталога внутри архива не соответствует имени архива
$ wget https://github.com/perl11/cperl/archive/cperl-5.22.1.tar.gz \
    -O cperl-cperl-5.22.1.tar.gz
$ perlbrew install --as=cperl-5.22.1 $(pwd)/cperl-cperl-5.22.1.tar.gz

Загрузим скрипт bench.pl:

$ wget http://perl5.git.perl.org/perl.git/blob_plain/blead:/Porting/bench.pl

Загрузим файл бенчмарков:

$ wget http://perl5.git.perl.org/perl.git/blob_plain/blead:/t/perf/benchmarks

Запустим бенчмарк:

$ bench.pl -j 8 --benchfile=benchmarks \
    $PERLBREW_ROOT/perls/perl-5.22.0/bin/perl5.22.0=perl5220 \
    $PERLBREW_ROOT/perls/stableperl-1.001/bin/perl5.22.0=stable5220 \
    $PERLBREW_ROOT/perls/cperl-5.22.1/bin/cperl5.22.1=cperl5221

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

AVERAGE

       perl5220 stable5220 cperl5221
       -------- ---------- ---------
    Ir   100.00     101.37    101.19
    Dr   100.00     100.39    100.60
    Dw   100.00     100.00     98.77
  COND   100.00     100.00     99.76
   IND   100.00     100.00    100.00

COND_m   100.00      96.17     62.23
 IND_m   100.00     100.00    100.00

 Ir_m1   100.00     100.55    100.00
 Dr_m1   100.00     100.00    100.00
 Dw_m1   100.00     100.00    100.00

 Ir_mm   100.00     100.00    100.00
 Dr_mm   100.00     100.00    100.00
 Dw_mm   100.00     100.00    100.00

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

Например, практически во всех тестах, где используются операции с хешами, заметно проигрывает Perl 5.22:

expr::hash::ref_lex_2var
lexical $hashref->{$k1}{$k2}

         perl stableperl  cperl
       ------ ---------- ------
    Ir 100.00     120.42 119.16
    Dr 100.00     105.10 106.45
    Dw 100.00     100.00  93.94
  COND 100.00     100.00 100.00
   IND 100.00     100.00 100.00

COND_m 100.00     100.00 111.11
 IND_m 100.00     100.00 100.00

Это объясняется тем, что как в cperl, так и stableperl по умолчанию используется хеш-функция FNV-1A, которая существенно быстрее медленной, но более безопасной дефолтовой хеш-функции Perl.

В остальных тестах сохраняется практически полный паритет.

Выводы

Безусловно, cachegrind даёт большие возможности для проведения замеров производительности. К сожалению, данные не всегда удобно сравнивать, поскольку нет абсолютных значений затраченных циклов процессора. В любом случае, радует, что при разработке Perl начали проводить тестирование на регресс производительности.

■ Владимир Леттиев


← YAPC::Europe 2015 | Содержание | Обзор CPAN за август 2015 г. →
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье