Выпуск 11. Январь 2014
← Впечатления от Saint Perl 5 | Содержание | Асинхронное программирование с IO::Async →Про опыт разработки на Perl под Raspberry PI
В статьей представлен расширенный вариант доклада на воркшопе Saint Perl 2013
Задача
Как-то раз на одном из фрилансерских сайтов я нашел такой проект: разработать на Perl программу для поиска объявлений, релевантных для соответствующих пользователей. Данные объявлений и профилей пользователей хранятся в XML-файлах и имеют примерно следующий вид:
Профили (profiles.xml
):
<?xml version="1.0" encoding="utf-8"?>
<xml>
<profiles>
. . .
<profile>
<name>David Brown</name>
<gender>Male</gender>
<age>34</age>
<likes>
<like>Music</like>
<like>Sports</like>
<like>Movies</like>
</likes>
<timestamp>2013-10-25 13:34:45</timestamp>
</profile>
. . .
</profiles>
</xml>
Объявления (media.xml
):
<?xml version="1.0" encoding="utf-8"?>
<xml>
<Playlist PlayerId="1000001">
<Content id="23" type="image" location="/Content/a2/1w3ewsed.jpg" ageMin="23" ageMax="35" gender="male" likes="music;movies" />
<Content id="237" type="image" location="/Content/0f/gtfrddsed.jpg" ageMax="35" gender="all" likes="sports" />
<Content id="21" type="image" location="/Content/bf/1w3ewsed.jpg" ageMin="40" gender="female" />
<Content id="33" type="video" location="/Content/9b/jiuhnnj.mp4" gender="male" likes="music;movies" />
</Playlist>
</xml>
Для определения релевантности объявления используется следующий алгоритм: каждое объявление сравнивается с каждым профилем; если у объявления и профиля совпали такие параметры как возраст, пол, каждая из пар предпочтений (поле likes
), то объявлению в каждом случае добавляется одно очко. Затем выбирается объявление с максимальным числом очков или, если таких объявлений несколько, случайным образом выбираем одно из них.
Основным условием задачи было то, что программа должна работать на Raspbery Pi — время поиска наиболее релевантного объявления для файлов из 100 профилей и 100 объявлений не должно превышать двух секунд. Как оказалось, последнее условие и стало самым сложным для выполнения. Также необходимо было записывать устаревшие профили в отдельный файл profiles_outdated.xml
, а в оригинальном файле оставлять только те, у которых timestamp
имеет значение не раньше, чем «10 секунд назад».
Raspberry Pi
Это компьютер размером с кредитную карту на платформе ARM11 с процессором частотой 700 МГц (я говорю о Raspberry Pi model B) и графическим ядром, которое поддерживает OpenGL ES 2.0 и может декодировать Full HD видео, 512 Мб ОЗУ, парой разъемов USB 2.0, разъемом Ethernet, HDMI, RCA Video, Audio, GPIO и разъемом для SD-карты, с которой и загружается ОС.
Питать его можно от зарядного устройства смартфона, способного выдавать 5В x 750 мА (ток лучше больше) через разъем Micro USB, ну или от других аналогичных девайсов (коллега запитывал от USB-разъема Mac Mini через переходник).
Для него существует несколько «официальных» дистрибутивов на базе Linux, доступных на странице http://www.raspberrypi.org/downloads (в их числе и нашумевшая в рунетах Pidora) и много неофициальных. Как правило, систему можно установить простым копированием образа на карту памяти формата SD. Лично мне для первоначальной настройки не понадобились даже клавиатура или монитор — девайс после загрузки сразу стал доступен по сети по доменному имени raspberrypi
.
Стоимость такого устройства официально $35 — но это в Англии, а в российской рознице мне пришлось купить его за 2200 руб (примерно $66).
Для работы мы выбрали Raspbian — это дистрибутив, основанный на Debian, который из коробки содержит Perl v5.14.2, много модулей в пакетах системы, а также на него можно установить все необходимые дополнения типа perlbrew
, cpanminus
, local::lib
и т.д.
Написание программы
Первоначальный вариант программы был написан довольно быстро — в нем использовались модули, которые я привык использовать при работе на быстрых серверах при низкой нагрузке и в предварительно загружаемой среде (т.е. время загрузки модулей не имело для меня значения). Для парсинга XML я использовал потоковый парсер XML::Rules
в предположении, что мне будет удобно асинхронно обрабатывать устаревшие профили. Также я активно использовал отладку, а для отображения структур данных использовал функции модуля Data::Dump
. Начало программы выглядело примерно так:
use common::sense;
use local::lib;
use Const::Fast;
use DateTime;
use Data::Dump qw(pp);
use File::Temp ();
use File::Copy qw(mv);
use List::UtilsBy qw(max_by);
use Time::HiRes qw(gettimeofday tv_interval);
use XML::Rules;
Используемый алгоритм был довольно прост: сначала файл профилей целиком считывался в память, значение атрибута likes
приводилось к нижнему регистру и преобразовывалось в массив предпочтений. Затем в процессе парсинга файла профилей для каждого профиля код обходил все объявления и искал совпадающие атрибуты — пол, возраст, предпочтения. Для каждого совпадения объявлению добавлялось одно очко. Разумеется, это выполнялось только для достаточно свежих профилей (не старше 10 секунд), устаревшие профили отсеивались.
Правило для XML::Rules
выглядело таким образом:
# Parse profiles file
my $profile_parser = XML::Rules->new(
style => ‘filter’,
rules => {
_default => 'raw',
profile => sub {
my ($tag_name, $attr, undef, undef, $parser) = @_;
my $profile = extract_tags({ %{ $attr } }->{_content});
$profile->{likes}
= [map { lc $_->[1]->{_content} } @{ $profile->{likes} }];
if (is_profile_outdated($profile->{timestamp})) {
mark_as_outdated($profile);
return ();
}
for my $ad (@{ $parser->{parameters}->{ads} }) {
my $score = calc_score($ad, $profile);
$ad->{scores}->{ $profile->{name} } = $score;
$ad->{scores}->{total} += $score;
$parser->{parameters}->{total_scores} += $score;
}
return $tag_name => $attr;
},
});
$profile_parser->filterfile($profile_file, $tmp_fh->filename, $playlist);
Здесь можно увидеть, что XML::Rules
используется в режиме фильтра, а не парсера и сохраняет результат (только свежие профили) во временный файл $tmp_fh
.
Все работало, но время выполнения этого варианта программы составляло 30 секунд, что было для заказчика совершенно неприемлемо. Поэтому я начал оптимизировать.
Оптимизация
Шаг 1. Используем быстрые модули
Первые мои действия по оптимизации были немного наивными и основывались не на точном расчете, а, скорее, на интуиции. Для определения возраста профилей я использовал модуль DateTime
. Я знал, что модуль DateTime::TimeZone
довольно долго определяет локальную временную зону при первом запуске (это написано и в его документации), и поэтому решил заменить DateTime
чем-то более быстрым. После некоторого поиска решил использовать Date::Calc
, который имеет не слишком-то красивый интерфейс, безо всякого ООП, зато очень сильно оптимизирован при помощи XS. Замена DateTime
на Date::Calc
сократило время выполнения программы примерно на 4 секунды.
Помимо этого, так как мне не нужна была потоковая загрузка объявлений, для загрузки файла объявлений я решил использовать модуль XML::Fast
, который также написан на Си и очень быстро работает на небольших файлах.
Шаг 2. Ненужная отладка
Для отладки изначально использовался следующий код:
sub debug {
warn @_ if $DEBUG;
}
# Где-то в коде…
debug('ads with scores: ' . pp($playlist));
Может это и незаметно на первый взгляд, но этот код работает даже тогда, когда нам это не нужно — т.е. когда флаг $DEBUG
не установлен. В том случае, когда $playlist
содержит много данных, преобразование содержащейся в этой переменной структуры в читабельный вид занимает довольно много времени, поэтому, когда я изменил код для отладки на приведенный ниже, время работы программы сократилось на 19 (!) секунд.
if ($DEBUG) {
require Data::Dump;
}
sub debug {
return if !$DEBUG;
warn map { ref $_ ? Data::Dump::pp($_) : $_ } @_;
}
# Где-то в коде…
debug('ads with scores: ', $playlist); # Здесь запятая!
Шаг 3. Замеряем время работы
На данный момент время работы программы составляло уже около 7 секунд, но все «интуитивные» возможности оптимизации были уже исчерпаны, поэтому я решил наконец-то взяться за точные измерения и вооружился модулем Devel::Timer
от Gábor Szabó. Этот модуль позволяет сделать то же, что бы вы сделали при помощи функций Time::HiRes
— измерить время выполнения каждой отдельной секции кода, но предоставляет для этого удобный интерфейс и формирует отчет на основании измеренных значений.
Понаставив везде «отметок», я получил следующие результаты для хода выполнения программы:
Devel::Timer Report -- Total time: 7.3259 secs
Count Time Percent
----------------------------------------------
1 3.6184 49.39% created temp file -> profiles processed # подсчет очков
1 1.6455 22.46% BEGIN -> loaded # загрузка модулей
1 1.1747 16.03% loaded -> media file read # чтение файла объявлений
1 0.8662 11.82% file moved -> ad shown # get_ad_to_show() && show_ad()
1 0.0079 0.11% ad shown -> END
1 0.0060 0.08% set up profile rules -> created temp file
1 0.0036 0.05% profiles processed -> file moved
1 0.0032 0.04% media file read -> set up profile rules
1 0.0004 0.00% INIT -> BEGIN
Таким образом, видно, что одна только загрузка и компиляция программы и используемых модулей занимает 1,6 секунды — почти столько, сколько должна выполняться вся программа. Поэтому я решил проверить, сколько времени загружаются модули, и получил следующий результат:
Devel::Timer Report -- Total time: 1.6027 secs
Interval Time Percent
----------------------------------------------
09 -> 10 0.3867 24.13% loaded List::UtilsBy -> loaded XML::Rules;
06 -> 07 0.3401 21.22% loaded Data::Dump -> loaded File::Temp
02 -> 03 0.3279 20.46% loaded common::sense; -> loaded local::lib;
10 -> 11 0.1328 8.28% loaded XML::Rules; -> loaded XML::Fast
04 -> 05 0.1080 6.74% loaded Const::Fast; -> loaded Date::Calc
05 -> 06 0.0851 5.31% loaded Date::Calc -> loaded Data::Dump
11 -> 12 0.0772 4.81% loaded XML::Fast -> loaded File::Map
03 -> 04 0.0539 3.36% loaded local::lib; -> loaded Const::Fast;
07 -> 08 0.0521 3.25% loaded File::Temp -> loaded File::Copy
08 -> 09 0.0265 1.65% loaded File::Copy -> loaded List::UtilsBy
01 -> 02 0.0095 0.59% BEGIN -> loaded common::sense;
13 -> 14 0.0024 0.15% modules loaded -> END
00 -> 01 0.0004 0.02% INIT -> BEGIN
12 -> 13 0.0003 0.02% loaded File::Map -> modules loaded
Из этого отчета видно, что дольше всего загружаются модули XML::Rules
, File::Temp
и local::lib
. От local::lib
и File::Temp
можно было избавиться довольно легко — прописав пути к библиотекам в переменной среды PERL5LIB
и используя стандартное имя для временного файла — типа profiles.xml.tmp
. Также я решил избавиться и от XML::Rules
, заменив его полностью на XML::Fast
. Асинхронная обработка, которую я надеялся использовать для ускорения программы, мне оказалась не нужна, и выигрыш от ее использования перекрывался необходимостью загрузки «тяжелого» модуля.
Также я избавился от модулей File::Copy
, File::Map
и Const::Fast
— работать с файлами оказалось быстрее стандартными средствами Perl, а пару используемых констант можно было заменить переменными — программа получилась не настолько большой, чтобы использование констант было принципиально.
Некоторый прирост по скорости принесло то, что я решил сразу приводить к нижнему регистру при помощи функции lc()
все содержимое файлов перед парсингом, а не отдельно предпочтения для каждого профиля или объявления.
После выполнения всех этих шагов я получил такой результат работы программы:
time ./parser.pl samples/profile.xml samples/media.xml
Devel::Timer Report -- Total time: 1.8889 secs
Count Time Percent
----------------------------------------------
1 1.3133 69.53% calculating scores: start -> calculating scores: end
1 0.4887 25.87% program: start -> modules loaded
1 0.0329 1.74% read profile file: start -> read profile file: end
1 0.0157 0.83% searching for ad to show: start -> searching for ad to show: end
1 0.0143 0.76% write profile file: start -> write profile file: end
1 0.0126 0.67% read media file: start -> read media file: end
1 0.0046 0.24% convert media file: start -> convert media file: end
1 0.0027 0.14% searching for ad to show: end -> program: end
1 0.0023 0.12% write profile file: end -> searching for ad to show: start
real 0m2.142s
user 0m2.040s
sys 0m0.070s
То есть можно было бы на этом и остановиться, но тут в голову пришла интересная мысль, которую я и решил воплотить в жизнь.
Шаг 4. Оптимизация алгоритма
Из замеров выше можно увидеть, что наибольшее время теперь занимает подсчет очков для каждого объявления — то есть собственно ядро программы — ее основная функция, вычисление очков для каждого объявления.
На втором месте по «тормознутости» — загрузка и компиляция программы. Этот этап можно было бы оптимизировать при помощи демонизации — т.е. предварительно загружать программу и по таймеру или через Inotify
проверять изменения в файлах, и заново подсчитывать очки. Но этот вариант я оставил в качестве резервного, решив поближе рассмотреть алгоритм основной функции программы.
Как мы видим, ядром этой функции является вложенный цикл, который выглядит примерно так:
for my $profile (@profiles) {
for my $ad (@ads) {
$ad->{score} += calc_score($ad, $profile);
}
}
Если оценить сложность этого алгоритма, то она составляет O(MxN)
, где M
— количество объявлений, а N
— количество профилей. Таким образом, для 100 объявлений и 100 профилей мы получим 10000 итераций. Можно ли уменьшить сложность алгоритма и количество итераций? В программировании одной из частых практик для достижения подобной цели является обмен процессора на память или наоборот. То есть мы должны пожертвовать неким количеством памяти, чтобы уменьшить загрузку процессора. В данном случае мы можем сделать это, проиндексировав объявления — то есть сохранив для каждого возможного значения каждого параметра (пол, возраст, предпочтения) список Id
объявлений, которым соответствует значение этого параметра. И использовать впоследствии этот список при подсчете очков. Вот код, который выполняет эту работу:
my %age; # Ads valid for given age
my %gender; # Ads scored for given gender
my %like; # Ads scored for given preference
for my $ad (@{ $playlist->{content} }) {
# Fill ages
my $age_min = int($ad->{agemin}) || 0;
my $age_max = int($ad->{agemax}) || 100;
for my $current_age ($age_min .. $age_max) {
push @{ $age{$current_age} }, $ad->{id};
}
# Fill genders
given ($ad->{gender}) {
when ('male') {
push @{ $gender{male} }, $ad->{id};
}
when ('female') {
push @{ $gender{female} }, $ad->{id};
}
when ('all') {
push @{ $gender{male} }, $ad->{id};
push @{ $gender{female} }, $ad->{id};
}
}
# Fill likes
for my $current_like (split /;/, $ad->{likes}) {
push @{ $like{$current_like} }, $ad->{id};
}
}
Объявления проиндексированы, теперь мы должны подсчитать очки для каждого объявления. Теперь это сделать намного проще, так как нам не нужно для каждого профиля проходить по всем объявлениям и проверять, совпадают ли его параметры с параметрами профиля — мы можем сразу получить список всех объявлений, значение соответствующего параметра которых совпадает со значением параметра профиля, так как эти объявления у нас хранятся в индексе. И, соответственно, добавлять очки только этим объявлениям. Код для подсчета очков выглядит таким образом:
# Score of each ad
my %score;
sub calc_score {
my ($profile) = @_;
# Gender
if (exists $profile->{gender}) {
for my $ad_id (@{ $gender{$profile->{gender}} || [] }) {
$score{$ad_id}++;
}
}
# Age
if (exists $profile->{age}) {
for my $ad_id (@{ $age{$profile->{age}} }) {
$score{$ad_id}++;
}
}
# Likes
if (ref $profile->{likes} && ref $profile->{likes}->{like}) {
for my $profile_like (@{ $profile->{likes}->{like} }) {
for my $ad_id (@{ $like{ $profile_like } || [] }) {
$score{$ad_id}++;
}
}
}
}
После этой оптимизации тайминги работы программы на имеющихся тестовых данных выглядели так:
time ./parser.pl samples/profile.xml samples/media.xml
Devel::Timer Report -- Total time: 0.8667 secs
Count Time Percent
----------------------------------------------
1 0.5066 58.45% program: start -> modules loaded
1 0.1656 19.11% calculating scores: start -> calculating scores: end
1 0.1177 13.58% index media file: start -> index media file: end
1 0.0339 3.91% read profile file: start -> read profile file: end
1 0.0152 1.75% write profile file: start -> write profile file: end
1 0.0130 1.50% read media file: start -> read media file: end
real 0m1.119s
user 0m1.070s
sys 0m0.020s
Таким образом, за счет индексации объявлений мы сэкономили около одной секунды — то есть почти вдвое уменьшили время работы программы.
Шаг 5 и последний. С ног на голову
В последний момент я решил попробовать ускорить работу программы, не трогая саму программу. Как это так? Очень просто — дело в том, что Raspberry Pi поддерживает оверклокинг, причем Raspbian из коробки предлагает инструменты для него.
Вводим команду sudo raspi-config
, входим в меню Overclocking и выбираем нужную частоту из доступных: 700 (по умолчанию), 800, 900, 950, 1000 МГц. При выборе последней опции нас предупреждают, что в этом режиме может испортиться карта памяти, но нас это не страшит… В итоге при частоте 1000 МГц время выполнения программы сократилось еще на 0,3 секунды и составило около 0,8 секунд. Не то чтобы этот выигрыш нам был так уж нужен, но мы можем просто иметь его в виду на случай, если нам вдруг понадобится прибавка к скорости.
Заключение
На конференции Saint Perl 2013, где я впервые делал этот доклад, мне немного не хватило времени, и поэтому залу разрешили задать только один вопрос. Этот вопрос был: «Зачем нужна вся эта оптимизация, если можно просто переписать всё на Си?». Ответил я на него в том ключе, что изначально задача состояла в написании программы на Perl, и я действовал в рамках задания. Однако, подумав над этим вопросом хорошенько после конференции, я пришел и к другому ответу.
Во-первых, мне нравится Perl и я знаю его намного лучше, чем Си. Я бы просто не стал браться за задачу, если бы ее нужно было написать на Си. Видимо, какие-то подобные размышления руководили и заказчиком этой программы — наверное, он знал немного Perl и чувствовал себя в силах изменить или исправить как-то программу на Perl, если в ней что-то пойдет не так. А может им и экономические мотивы руководили, не знаю. В любом случае это как раз тот случай, когда не технология руководит людьми, а люди меняют технологию под себя, чтобы она служила их интересам. Кроме того, основные используемые модули, собственно, написаны на Си и вызываются из Perl при помощи XS.
Во-вторых, как бы ни был быстр язык сам по себе, плохой алгоритм рано или поздно даст о себе знать. И лучше заранее включить мозг и найти реальные проблемы в своей программе, чем пытаться использовать другую технологию, которая по интуитивной оценке должна работать быстрее, чем используемая в данный момент. Интуитивные оценки мы перестали использовать на шаге 2 оптимизации, когда включили логику и начали искать реальные проблемы в коде, а полностью искоренили их на шаге 3, когда замерили время выполнения, условно говоря, каждой строчки кода и начали делать из этого правильные выводы :-)
← Впечатления от Saint Perl 5 | Содержание | Асинхронное программирование с IO::Async →