diff --git a/case-study.md b/case-study.md new file mode 100644 index 00000000..8ec0386e --- /dev/null +++ b/case-study.md @@ -0,0 +1,84 @@ +# Case-study оптимизации + +## Актуальная проблема +В нашем проекте возникла серьёзная проблема. + +Необходимо было обработать файл с данными, чуть больше ста мегабайт. + +У нас уже была программа на `ruby`, которая умела делать нужную обработку. + +Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. + +Я решил исправить эту проблему, оптимизировав эту программу. + +## Формирование метрики +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: Время обработки текстового файла, содержащего 3,25 млн. строк. Бюджет данной метрики равен 30 секунд. + +## Гарантия корректности работы оптимизированной программы +Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. + +## Feedback-Loop +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за ~48 секунд. + +Вот как я построил `feedback_loop`: +- Определение оптимального набора данных для профилирования +- Определение главной точки роста с помощью профилирования +- Нахождение в коде проблемного места, связанного с главной точкой роста +- Внесение в код изменений, касающихся оптимизации только главной точки роста +- Повторное профилирование и определение новой главной точки роста + +## Вникаем в детали системы, чтобы найти главные точки роста +Для того, чтобы найти "точки роста" для оптимизации я воспользовался библиотекой ruby-prof с различными вариантами визуализации отчета. + +Вот какие проблемы удалось найти и решить + +### Array#select +- Начал профилирования с объема данных в 20000 строк. С таким объемом данных программа выполнялась ~25 секунд, что является достаточным для определения главной точки роста. +- После сделанных замеров и анализа отчетов, получилось, что основной точкой роста является вызов метода select у массива. +- В коде select вызывается только в одном месте для получения сессий пользователя внутри цикла по пользователям. +- Я решил отказаться от этого метода, немного изменив хранение данных. В первом цикле по строкам файла, сессии я заполняю в новом хэше, ключами которого являются id пользователей. Таким образом, в цикле по заполнению массива объектов пользователей, получить сессии можно обратившись по ключу к хэшу. +- После сделанных изменений проблема перестала быть основной точкой роста и программа стала выполняться в ~10 раз быстрей, т.е. выбранная метрика улучшилась в 10 раз. + +### Много вызовов добавления элементов в массив +- Следующей точкой роста (~25% времени) была операция Array#+, т.е. добавление элемента в массив. Причем, на объеме данных в 20000 строк, эта операция выполнялась > 40000 раз. +- Я проанализировал код на предмет избыточных циклов. Обнаружил, что от цикла, заполняющего массив уникальных браузеров можно избавиться и вычислить все уникальные браузеры еще в первом цикле по строкам файла. Реализовал я это с помощью множества (Set). Также, на этом же шаге оптимизации я изменил расчет параметра allBrowsers в итоговом отчете, заменив получение браузеров из массива сессий на множество. +- Метрика улучшилась больше, чем в 2 раза, файл с 20000 строками стал обрабатываться за 1.1 сек. +- Точка роста осталась прежней, но уменьшилось количество вызовов добавления в массив + +### Вместо добавления элементов в массив, можно присваивать значения по индексу +- Основная точка роста осталась операция Array#+. Я предположил, что на больших массивах быстрей будет операция присваивания по индексу, чем добавление нового элемента, т.к. в последнем случае интерпретатору нужно регулярно расширять занимаемую память массивом, на что тратится дополнительное время. Если массив изначально будет задан с определенным количеством элементов, то наполнение этого массива данными по индексу должно быть быстрей +- Перед первым циклом, инициализируются массивы пользователей и сессий размером, равным количеству строк в файле. Такой размер избыточен, т.к. часть данных в файле являются сессиями, а часть - пользователями. Поэтому, после заполнения массивов, вызывается метод compact! для каждого из массивов. +- Метрика улучшилась еще примерно в ~2 раза, 20000 строк обработались менее чем за 1 сек. Поэтому, для более точного профилирования пришлось увеличить выборку строк до 100000. +- Проблема решилась, после профилирования на 100000 строк, основной точкой роста стала операция Array#each + +### Много циклов перебора элементов массива +- Основной точкой роста стала операция Array#each, вызывающаяся 9 раз. Исходя из логики программы, более чем достаточно пройти по всем элементам всего 2 раза - сначала, для чтения данных из файла, затем, для заполнения итогового отчета. Но алгоритм реализован так, что вторым циклом заполняется массив объектов пользователей с нужными атрибутами, а потом, для 7 статистических показателей отчета производится итерирование по массиву объектов класса User. +- Было принято решение отказаться вообще от массива объектов пользователей и 7 статистических показателей вычислять во втором цикле по массиву users. Это стало возможным, т.к. на предыдущих шагах оптимизации я сделал хэш, из которого в каждой итерации по массиву users, можно получить по ключу сессии пользователя. На этом шаге оптимизации я избавился от класса User и метода collect_stats_from_users. +- Метрика улучшилась на ~30%, программа обработала файл со 100000 строками за ~2.2 сек. +- Количество вызовов Array#each сократилось до 2, но при профилировании эта операция всё равно осталась по продолжительности на первом месте. Так произошло, потому что после проделанных изменений, все вычисления оказались внутри двух циклов. Поэтому, основную точку роста следует искать в дочерних вызовах. Такой получилась операция Array#map с 12% времени выполнения. + +### Много вызовов Array#map +- Основной точкой роста является метод map для массива. Количество вызовов этого метода составило ~170000 при общем количестве обрабатываемых строк 100000. На мой взгляд, такое количество избыточно и нужно попытаться оптимизировать код для их уменьшения. +- Всё использование map находится во втором цикле, когда рассчитывается статистика пользователя для итогового отчета. При этом, всю эту статистику можно посчитать еще на первом цикле, расширив количество значений хэша, в котором хранятся сессии. Тогда во втором цикле, можно будет только обратиться к хэшу и получить уже готовые значения. +- Метрика улучшилась еще на ~25% +- Проделанные изменения позволили полностью избавиться от вызова map, и этот метод перестал быть основной точкой роста. + +### Долгий парсинг даты +- Профилирование на 100000 строк показало, что основной точкой роста является парсинг даты (#parse). +- Анализ исходных данных показал, что парсинг даты в целом не нужен, т.к. в исходных данных дата уже представлена в нужном формате iso8601. Сортировка строковых значений даст тот же эффект, что и сортировка объектов Date. +- Полностью убрал парсинг даты, метрика улучшилась на ~60%. Обработка 100000 строк стала выполняться за ~1.5 сек. +- Дальнейшее профилирование производилось на полном объеме данных в ~3.2 млн. строк. + +### Избыточный вызов split +- Основной точкой роста стал вызов метода split, причем количество вызовов превышало в 2 раза количество строк в файле, хотя по логике программы должно совпадать. Это произошло потому, что в методы parse_user и parse_session передается строка, а не массив, полученный в начале цикла методом split. +- В метод parse_user я стал передавать массив, а от метода parse_session решил избавиться и производить заполнение хэша из массива col. +- Метрика улучшилась еще на ~8%. Обработка полного файла составила ~48.5 сек. +- Основной точкой роста остался метод String#split, который вызывается столько раз, сколько строк в исходном файле. + +## Результаты +В результате проделанной оптимизации наконец удалось обработать файл с данными. +Удалось улучшить метрику системы с 25 сек. на файле 20000 строк до 48 сек. на файле с 3 млн строк и почти уложиться в заданный бюджет. + +## Защита от регрессии производительности +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы с помощью библиотеки rspec-benchmark был написан тест на производительность, который выдаст ошибку, если значение выбранной метрики превысит 50 сек. + diff --git a/task-1.rb b/task-1.rb index 778672df..fa325d41 100644 --- a/task-1.rb +++ b/task-1.rb @@ -5,17 +5,7 @@ require 'date' require 'minitest/autorun' -class User - attr_reader :attributes, :sessions - - def initialize(attributes:, sessions:) - @attributes = attributes - @sessions = sessions - end -end - -def parse_user(user) - fields = user.split(',') +def parse_user(fields) parsed_result = { 'id' => fields[1], 'first_name' => fields[2], @@ -24,37 +14,52 @@ def parse_user(user) } end -def parse_session(session) - fields = session.split(',') - parsed_result = { - 'user_id' => fields[1], - 'session_id' => fields[2], - 'browser' => fields[3], - 'time' => fields[4], - 'date' => fields[5], - } -end +def work(file_name, disable_gc=false) + GC.disable if disable_gc -def collect_stats_from_users(report, users_objects, &block) - users_objects.each do |user| - user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}" - report['usersStats'][user_key] ||= {} - report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user)) - end -end + file_lines = File.read(file_name).split("\n") + + users = Array.new(file_lines.count) -def work - file_lines = File.read('data.txt').split("\n") + users_sessions = {} + uniqueBrowsers = Set.new - users = [] - sessions = [] + total_sessions = 0 - file_lines.each do |line| + file_lines.each_with_index do |line, index| cols = line.split(',') - users = users + [parse_user(line)] if cols[0] == 'user' - sessions = sessions + [parse_session(line)] if cols[0] == 'session' + users[index] = parse_user(cols) if cols[0] == 'user' + + if cols[0] == 'session' + users_sessions[cols[1]] ||= { + 'sessions_count' => 0, + 'total_time' => 0, + 'max_time' => 0, + 'browsers' => [], + 'used_ie' => false, + 'always_used_chrome' => true, + 'dates' => [] + } + + session_time = cols[4].to_i + session_browser = cols[3].upcase + + sessions = users_sessions[cols[1]] + sessions['sessions_count'] += 1 + sessions['total_time'] += session_time + sessions['max_time'] = [session_time, sessions['max_time']].max + sessions['browsers'] << session_browser + sessions['used_ie'] = (session_browser =~ /INTERNET EXPLORER/).instance_of? Integer unless sessions['used_ie'] + sessions['always_used_chrome'] = (session_browser =~ /CHROME/).instance_of? Integer if sessions['always_used_chrome'] + sessions['dates'] << cols[5] + + uniqueBrowsers.add(session_browser) + total_sessions += 1 + end end + users.compact! + # Отчёт в json # - Сколько всего юзеров + # - Сколько всего уникальных браузеров + @@ -74,70 +79,46 @@ def work report[:totalUsers] = users.count - # Подсчёт количества уникальных браузеров - uniqueBrowsers = [] - sessions.each do |session| - browser = session['browser'] - uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser } - end - report['uniqueBrowsersCount'] = uniqueBrowsers.count - report['totalSessions'] = sessions.count + report['totalSessions'] = total_sessions report['allBrowsers'] = - sessions - .map { |s| s['browser'] } - .map { |b| b.upcase } + uniqueBrowsers .sort - .uniq .join(',') # Статистика по пользователям - users_objects = [] - - users.each do |user| - attributes = user - user_sessions = sessions.select { |session| session['user_id'] == user['id'] } - user_object = User.new(attributes: attributes, sessions: user_sessions) - users_objects = users_objects + [user_object] - end report['usersStats'] = {} - # Собираем количество сессий по пользователям - collect_stats_from_users(report, users_objects) do |user| - { 'sessionsCount' => user.sessions.count } - end - - # Собираем количество времени по пользователям - collect_stats_from_users(report, users_objects) do |user| - { 'totalTime' => user.sessions.map {|s| s['time']}.map {|t| t.to_i}.sum.to_s + ' min.' } - end - - # Выбираем самую длинную сессию пользователя - collect_stats_from_users(report, users_objects) do |user| - { 'longestSession' => user.sessions.map {|s| s['time']}.map {|t| t.to_i}.max.to_s + ' min.' } - end - - # Браузеры пользователя через запятую - collect_stats_from_users(report, users_objects) do |user| - { 'browsers' => user.sessions.map {|s| s['browser']}.map {|b| b.upcase}.sort.join(', ') } - end - - # Хоть раз использовал IE? - collect_stats_from_users(report, users_objects) do |user| - { 'usedIE' => user.sessions.map{|s| s['browser']}.any? { |b| b.upcase =~ /INTERNET EXPLORER/ } } - end + users.each_with_index do |user, index| + user_sessions = users_sessions[user['id']] - # Всегда использовал только Chrome? - collect_stats_from_users(report, users_objects) do |user| - { 'alwaysUsedChrome' => user.sessions.map{|s| s['browser']}.all? { |b| b.upcase =~ /CHROME/ } } - end - - # Даты сессий через запятую в обратном порядке в формате iso8601 - collect_stats_from_users(report, users_objects) do |user| - { 'dates' => user.sessions.map{|s| s['date']}.map {|d| Date.parse(d)}.sort.reverse.map { |d| d.iso8601 } } + user_key = "#{user['first_name']}" + ' ' + "#{user['last_name']}" + + report['usersStats'][user_key] ||= {} + + # Собираем количество сессий по пользователям + report['usersStats'][user_key].merge!('sessionsCount' => user_sessions['sessions_count']) + + # Собираем количество времени по пользователям + report['usersStats'][user_key].merge!('totalTime' => user_sessions['total_time'].to_s + ' min.') + + # Выбираем самую длинную сессию пользователя + report['usersStats'][user_key].merge!('longestSession' => user_sessions['max_time'].to_s + ' min.') + + # Браузеры пользователя через запятую + report['usersStats'][user_key].merge!('browsers' => user_sessions['browsers'].sort.join(', ')) + + # Хоть раз использовал IE? + report['usersStats'][user_key].merge!('usedIE' => user_sessions['used_ie']) + + # Всегда использовал только Chrome? + report['usersStats'][user_key].merge!('alwaysUsedChrome' => user_sessions['always_used_chrome']) + + # Даты сессий через запятую в обратном порядке в формате iso8601 + report['usersStats'][user_key].merge!('dates' => user_sessions['dates'].sort.reverse) end File.write('result.json', "#{report.to_json}\n") @@ -169,7 +150,7 @@ def setup end def test_result - work + work('data.txt') expected_result = '{"totalUsers":3,"uniqueBrowsersCount":14,"totalSessions":15,"allBrowsers":"CHROME 13,CHROME 20,CHROME 35,CHROME 6,FIREFOX 12,FIREFOX 32,FIREFOX 47,INTERNET EXPLORER 10,INTERNET EXPLORER 28,INTERNET EXPLORER 35,SAFARI 17,SAFARI 29,SAFARI 39,SAFARI 49","usersStats":{"Leida Cira":{"sessionsCount":6,"totalTime":"455 min.","longestSession":"118 min.","browsers":"FIREFOX 12, INTERNET EXPLORER 28, INTERNET EXPLORER 28, INTERNET EXPLORER 35, SAFARI 29, SAFARI 39","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-09-27","2017-03-28","2017-02-27","2016-10-23","2016-09-15","2016-09-01"]},"Palmer Katrina":{"sessionsCount":5,"totalTime":"218 min.","longestSession":"116 min.","browsers":"CHROME 13, CHROME 6, FIREFOX 32, INTERNET EXPLORER 10, SAFARI 17","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-04-29","2016-12-28","2016-12-20","2016-11-11","2016-10-21"]},"Gregory Santos":{"sessionsCount":4,"totalTime":"192 min.","longestSession":"85 min.","browsers":"CHROME 20, CHROME 35, FIREFOX 47, SAFARI 49","usedIE":false,"alwaysUsedChrome":false,"dates":["2018-09-21","2018-02-02","2017-05-22","2016-11-25"]}}}' + "\n" assert_equal expected_result, File.read('result.json') end