diff --git a/Screenshot 2022-05-31 at 11.40.52.png b/Screenshot 2022-05-31 at 11.40.52.png new file mode 100644 index 00000000..a6140f6b Binary files /dev/null and b/Screenshot 2022-05-31 at 11.40.52.png differ diff --git a/case_study.md b/case_study.md new file mode 100644 index 00000000..c09268fa --- /dev/null +++ b/case_study.md @@ -0,0 +1,67 @@ +# Case-study оптимизации + +## Актуальная проблема +В нашем проекте возникла серьёзная проблема. + +Необходимо было обработать файл с данными, чуть больше ста мегабайт. + +У нас уже была программа на `ruby`, которая умела делать нужную обработку. + +Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. + +Я решил исправить эту проблему, оптимизировав эту программу. + +## Формирование метрики +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я решил использовать профилировщик rubyprof в режиме memory, а так же выводить потребление памяти системным процессом в нескольких местах кода + +## Гарантия корректности работы оптимизированной программы +Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. + +## Feedback-Loop +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений примерно за 10 секунд + +Вот как я построил `feedback_loop`: +Я разбил файл data_large.txt на несколько файлов с 1%, 5%, 10% и 50% от общего объема данных +Замеры начинались на 1% данных, что позволяло уложиться в 10 сек. +При видимом улучшении показателя потребляемой памяти изменения коммитились +При значительном снижении потребляемой памяти и времени работы программы постеменно увеличивался объем данных + +## Вникаем в детали системы, чтобы найти главные точки роста +Для того, чтобы найти "точки роста" для оптимизации я воспользовался отчетом ruby-prof в формате cachegrind + +Первые замеры (1% данных): +- время выполнения программы: 10.38 сек +- потребление памяти: 420мб + +Вот какие проблемы удалось найти и решить + +### Ваша находка №1 +- Из отчета стало очевидно, что считывать файл в память, фомировать отчеты и уже после записывать в файл - слишком "дорого" +- Было решено переделать программу на формирование отчета по каждому пользователю, записи этого отчета в файл, обнуления пользователя в памяти и переход к следующему +Так же для отчета по статистике были использованы переменные-счетчики, которые обновлялись по мере чтения информации по каждому пользователю, а для хранения уникальных названий браузеров был выбран `SortedSet` +Отчет по пользователям писался в отдельный файл, после чего в финальный отчет записывался отчет по статистике, считывался файл с отчетами по каждому пользователю и последовательно дописывался в финальный отчет +- В результате время выполнения программы и потребление памяти значительно сократились, но до попадания в бюджет было еще далеко + +Замеры во итогу (полный объем данных): +- время выполнения программы: 6.65 сек +- потребление памяти: 169 мб + +### Ваша находка №2 +- отчет rubyprof показал, что основное потребление памяти связано с повторным чтением из файла (`each_line`), в который писались отчеты по пользователям +стало очевидно, что использование временного вспомогательного файла хоть и дало результаты, но их явно недостаточно +- было решено писать отчеты по пользователям сразу в итоговый отчет, так же использовать счетчики для отчета по статистике и записывать статистику уже в конец файла. Это позволило бы избавиться от лишних операций с дополнительным файлом +- после некоторых "мучений" с формированием обоих отчетов в один синтаксически корректный .json файл результат был достигнут +- повторные запуски теста с выводом потребления памяти системным процессом показывали разные результаты, но каждый раз укладывались в бюджет + +Замеры по итогу (полный объем данных) +- время работы программы: 6.4 сек +- потребление памяти от 19 до 36 мб + +## Результаты +В результате проделанной оптимизации наконец удалось обработать файл с данными. +Удалось улучшить метрику системы с 420 мб на 1% данных, до ~30 мб на полном объеме и уложиться в заданный бюджет. + +Время выполнения программы так же значительно сократилось. + +## Защита от регрессии производительности +Так как отчеты по потребляемой памяти разнились от запуска к запуску, для защиты от потери достигнутого прогресса при дальнейших изменениях программы использовались максимальные из полученных значения по памяти, а так же простой тест на корректность формирования самого отчета diff --git a/task-2.rb b/task-2.rb deleted file mode 100644 index 34e09a3c..00000000 --- a/task-2.rb +++ /dev/null @@ -1,177 +0,0 @@ -# Deoptimized version of homework task - -require 'json' -require 'pry' -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(',') - parsed_result = { - 'id' => fields[1], - 'first_name' => fields[2], - 'last_name' => fields[3], - 'age' => fields[4], - } -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 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 - -def work - file_lines = File.read('data.txt').split("\n") - - users = [] - sessions = [] - - 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 - - # Отчёт в json - # - Сколько всего юзеров + - # - Сколько всего уникальных браузеров + - # - Сколько всего сессий + - # - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом + - # - # - По каждому пользователю - # - сколько всего сессий + - # - сколько всего времени + - # - самая длинная сессия + - # - браузеры через запятую + - # - Хоть раз использовал IE? + - # - Всегда использовал только Хром? + - # - даты сессий в порядке убывания через запятую + - - report = {} - - 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['allBrowsers'] = - sessions - .map { |s| s['browser'] } - .map { |b| b.upcase } - .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 - - # Всегда использовал только 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 } } - end - - File.write('result.json', "#{report.to_json}\n") - puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) -end - -class TestMe < Minitest::Test - def setup - File.write('result.json', '') - File.write('data.txt', -'user,0,Leida,Cira,0 -session,0,0,Safari 29,87,2016-10-23 -session,0,1,Firefox 12,118,2017-02-27 -session,0,2,Internet Explorer 28,31,2017-03-28 -session,0,3,Internet Explorer 28,109,2016-09-15 -session,0,4,Safari 39,104,2017-09-27 -session,0,5,Internet Explorer 35,6,2016-09-01 -user,1,Palmer,Katrina,65 -session,1,0,Safari 17,12,2016-10-21 -session,1,1,Firefox 32,3,2016-12-20 -session,1,2,Chrome 6,59,2016-11-11 -session,1,3,Internet Explorer 10,28,2017-04-29 -session,1,4,Chrome 13,116,2016-12-28 -user,2,Gregory,Santos,86 -session,2,0,Chrome 35,6,2018-09-21 -session,2,1,Safari 49,85,2017-05-22 -session,2,2,Firefox 47,17,2018-02-02 -session,2,3,Chrome 20,84,2016-11-25 -') - end - - def test_result - work - expected_result = JSON.parse('{"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"]}}}') - assert_equal expected_result, JSON.parse(File.read('result.json')) - end -end diff --git a/task_2.rb b/task_2.rb new file mode 100644 index 00000000..2ba4fb43 --- /dev/null +++ b/task_2.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Optimized version of homework task + +require 'json' +require 'pry' +require 'set' +require 'minitest/autorun' + +DELIMITER = ',' +WITH_SPACE = ', ' +IE = 'I' +CR = 'C' + +def work(path) + file = File.open(path) + @report_file = File.open('result.json', 'w') + @report_file.write('{"usersStats":{') + @user_report = {} + @users_count = 0 + @sessions_count = 0 + @all_browsers = SortedSet.new + + file.each_line(chomp: true) do |line| + cols = line.split(DELIMITER) + if line.start_with?('u') + @users_count += 1 + write_user_report(false) unless @user_report == {} + @user_name = "#{cols[2]} #{cols[3]}" + @user_report = {} + @user_report[@user_name] = initialize_user_report + elsif line.start_with?('s') + @sessions_count += 1 + @user_report[@user_name]['sessionsCount'] += 1 + @user_report[@user_name]['totalTime'] += cols[4].to_i + @user_report[@user_name]['longestSession'] = longest_session(cols[4].to_i) + @user_report[@user_name]['browsers'] << cols[3].upcase! + @user_report[@user_name]['usedIE'] = true if used_ie?(cols[3]) + @user_report[@user_name]['alwaysUsedChrome'] = only_chrome?(@user_report[@user_name]['browsers']) + @user_report[@user_name]['dates'] << cols[5] + @all_browsers << cols[3] + end + end + write_user_report(true) + @report_file.write(DELIMITER, summary_report.to_json[1..-1]) + @report_file.close + + puts 'MEMORY USAGE: %d MB' % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) +end + +def longest_session(time) + @user_report[@user_name]['longestSession'] < time ? time : @user_report[@user_name]['longestSession'] +end + +def used_ie?(browser) + browser.start_with?(IE) +end + +def only_chrome?(browsers) + return false if @user_report[@user_name]['usedIE'] + + browsers.all? { |b| b.start_with?(CR) } +end + +def initialize_user_report + { + 'sessionsCount' => 0, + 'totalTime' => 0, + 'longestSession' => 0, + 'browsers' => [], + 'usedIE' => false, + 'alwaysUsedChrome' => false, + 'dates' => [] + } +end + +def write_user_report(last) + prepare_report + if last + @report_file.write(@user_report.to_json[1..-2], '}') + else + @report_file.write(@user_report.to_json[1..-2], DELIMITER) + end +end + +def summary_report + { + 'totalUsers' => @users_count, + 'uniqueBrowsersCount' => @all_browsers.size, + 'totalSessions' => @sessions_count, + 'allBrowsers' => @all_browsers.to_a.join(DELIMITER) + } +end + +def prepare_report + @user_report[@user_name]['totalTime'] = "#{@user_report[@user_name]['totalTime']} min." + @user_report[@user_name]['longestSession'] = "#{@user_report[@user_name]['longestSession']} min." + @user_report[@user_name]['browsers'] = @user_report[@user_name]['browsers'].sort!.join(WITH_SPACE) + @user_report[@user_name]['dates'] = @user_report[@user_name]['dates'].sort!.reverse! +end + +class TestMe < Minitest::Test + def setup + File.write('result.json', '') + File.write('data.txt', +'user,0,Leida,Cira,0 +session,0,0,Safari 29,87,2016-10-23 +session,0,1,Firefox 12,118,2017-02-27 +session,0,2,Internet Explorer 28,31,2017-03-28 +session,0,3,Internet Explorer 28,109,2016-09-15 +session,0,4,Safari 39,104,2017-09-27 +session,0,5,Internet Explorer 35,6,2016-09-01 +user,1,Palmer,Katrina,65 +session,1,0,Safari 17,12,2016-10-21 +session,1,1,Firefox 32,3,2016-12-20 +session,1,2,Chrome 6,59,2016-11-11 +session,1,3,Internet Explorer 10,28,2017-04-29 +session,1,4,Chrome 13,116,2016-12-28 +user,2,Gregory,Santos,86 +session,2,0,Chrome 35,6,2018-09-21 +session,2,1,Safari 49,85,2017-05-22 +session,2,2,Firefox 47,17,2018-02-02 +session,2,3,Chrome 20,84,2016-11-25 +') + end + + def test_result + work('data.txt') + expected_result = JSON.parse('{"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"]}}}') + assert_equal expected_result, JSON.parse(File.read('result.json')) + end +end