В нашем проекте возникла серьёзная проблема.
Необходимо было обработать файл с данными, чуть больше ста мегабайт.
У нас уже была программа на ruby, которая умела делать нужную обработку.
Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время.
Я решил исправить эту проблему, оптимизировав эту программу.
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: время выполнения в секундах
Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации.
Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный feedback-loop, который позволил мне получать обратную связь по эффективности сделанных изменений за 29 секунд
Вот как я построил feedback_loop:
- Использовал профилировщик
RubyProf Flatс выключенным GC (по началу на объеме в 50к строк) - Находил главную точку роста и начинал править ровно с этого места
- Перезапускал профилировщик повторно и смотрел на полученный результат + непосредственно сам скрипт с включенным GC
- Увеличивал объем данных и повторно начинал с пункта 1)
Для того, чтобы найти "точки роста" для оптимизации я воспользовался RubyProf Flat
Вот какие проблемы удалось найти и решить
user_sessions = sessions.select { |session| session['user_id'] == user['id'] }
- вместо полного перебора через select использовал
sessions_grouped = sessions.group_by {|session| session['user_id']}
user_sessions = sessions_grouped[user['id']]
- На объеме данных в 50к строк скрипт работал +- 2 секунды
Array#selectупал с 80% до 0%
file_lines.each do |line|
cols = line.split(',')
users = users + [parse_user(line)] if cols[0] == 'user'
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
end
- На полном объёме данных выжирает всю оперативу, процесс убивается системой (видно в syslog). Переписал всю логику на использование массива объектов классов User и Session вместо хранения в виде массива строк
- Программа перестала убиваться системой
RubyProf Flatпоказывает большой % использованияArray#each- Видно, что логика для построения статистики по каждому пользователю написана криво с использованием огромного кол-ва переборов. Нет смысла каждый раз итерироваться по всему массиву при построении отдельной статистики (т.е нет смысла начинать итерацию с нуля при построении sessionsCount, totalTime, longestSession, browsers, usedIE, alwaysUsedChrome и dates - можно строить все нужные статистики на текущей итерации по каждому пользователю, т.е можно перебрать массив users лишь один раз, а не 7)
- После рефакторинга
Array#eachупал до 13.36%
RubyProf Flatпоказывает большой % использованияArray#map- Некоторые метрики по пользователям (totalTime + longestSession, browsers + usedIE + alwaysUsedChrome) используют одинаковые куски кода с map. Можно использовать мемоизацию. Также заметно бросается в глаза двойной вызов
map- в некоторых местах достаточно его вызывать лишь один раз - После рефакторинга
Array#mapупал до 10.18% соответственно
RubyProf Flatпоказывает большой % использованияDate#parse- Обратив внимание на структуру данных, видно, что конструкцию
Date#parseможно опустить вообще - использование `.sort.reverse' более чем достаточно - После рефакторинга
Date#parseупал до 0% соответственно
В результате проделанной оптимизации наконец удалось обработать файл с данными. Удалось улучшить метрику системы с того, что у вас было в начале, до того, что получилось в конце и уложиться в заданный бюджет.
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написал скрипт с использованием benchmark/ips для замера производительности. Итоговый результат:
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux]
Warming up --------------------------------------
slow string concatenation
1.000 i/100ms
Calculating -------------------------------------
slow string concatenation
0.034 (± 0.0%) i/s - 1.000 in 29.189983s
with 95.0% confidence
Run options: --seed 39795
# Running:
.
Finished in 0.008757s, 114.2005 runs/s, 114.2005 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips