Выпуск 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 г. →