Выпуск 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. Больше подписчиков — лучше выпуски!

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