diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..957eb13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data* +result* +tmp/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..f442d98 --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +source 'https://rubygems.org' + +#ruby '2.6.1' + +gem 'memory_profiler' +gem 'stackprof' +gem 'ruby-prof' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..c51f98c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + memory_profiler (0.9.12) + ruby-prof (0.17.0) + stackprof (0.2.12) + +PLATFORMS + ruby + +DEPENDENCIES + memory_profiler + ruby-prof + stackprof + +BUNDLED WITH + 1.17.2 diff --git a/case-study-template.md b/case-study-template.md index e0eef00..50f00ef 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -12,35 +12,43 @@ Я решил исправить эту проблему, оптимизировав эту программу. ## Формирование метрики -Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика* +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: 20-30% выйгрыш по времени после изменений. ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста позволяет не допустить изменения логики программы при оптимизации. ## Feedback-Loop -Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за 2.5 минут обработки файла. -Вот как я построил `feedback_loop`: *как вы построили feedback_loop* +Вот как я построил `feedback_loop`: +1. Сократил файл до 50_000 строк +2. Добавил профилеровщик памяти +3. Вносил изменения в исходный код +4. Проверял скорость работы обработки файла +5. Если скорость не менялась, переходил к шагу 3. Если ускорялось, к шагу 6. +6. Увеличивал размер файла в 2 раза и переходил к шагу 3. Если код мог обратывать весь исходный файл за вменяемое время, переходил к шагу 7. +7. Все работает. Задача завершена. ## Вникаем в детали системы, чтобы найти 20% точек роста -Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались* +Для того, чтобы найти "точки роста" для оптимизации я воспользовался stackprof и ruby-prof. Вот какие проблемы удалось найти и решить ### Ваша находка №1 -О вашей находке №1 +.split(',') занимает достаточно много времени, так как повторялся в методе parse_user и parse_session. +Решение: делать split один раз и уже пробрасывать массив полей в parse_user и parse_session. ### Ваша находка №2 -О вашей находке №2 +Избыточное использование collect_stats_from_users, где идет сбор статистики по всем пользователям каждый раз для нового аттрибута статистики. +Решение: вынос блока из collect_stats_from_users в отдельные методы. ### Ваша находка №X -О вашей находке №X +Вложенный select в each, используемый для поиска сессий для пользователей, занимал 99% времени. +Решение: рефакторинг и добавление сессий к пользователю на этапе чтений файла. ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. -Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* - -*Какими ещё результами можете поделиться* +Удалось улучшить метрику системы с 2.5 минут для 50_000 записей на 1.5 минут для полного файла(3_250_940 записей) ## Защита от регресса производительности -Для защиты от потери достигнутого прогресса при дальнейших изменениях программы сделано *то, что вы для этого сделали* +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы добавлен новый юнит тест, который зафиксировал 1.5 минуты как эталонное значение. Но с погрешностью в еще 1.5 минуты с учетом загруженность или других факторов на системы, которые могут повлиять на скорость обработки данных. diff --git a/task-1.rb b/task-1.rb index 778672d..bb16e16 100644 --- a/task-1.rb +++ b/task-1.rb @@ -1,176 +1,14 @@ -# Deoptimized version of homework task +require_relative 'task_class' +require 'stackprof' +require 'ruby-prof' -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 +#StackProf.run(mode: :object, out: 'tmp/stackprof.dump', raw: true) do +result = RubyProf.profile do + TaskClass.new.work(filename: ARGV[0]) 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 +printer = RubyProf::DotPrinter.new(result) +printer.print(File.open("tmp/ruby_prof_allocations_profile.dot", "w+")) - 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") -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 = '{"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 -end +printer = RubyProf::GraphHtmlPrinter.new(result) +printer.print(File.open("tmp/ruby_prof_graph_allocations_profile.html", "w+")) diff --git a/task_class.rb b/task_class.rb new file mode 100644 index 0000000..96bde35 --- /dev/null +++ b/task_class.rb @@ -0,0 +1,141 @@ +# Deoptimized version of homework task +# frozen_string_literal: true + +require 'json' +require 'date' +#require 'pry' +require 'csv' + +class TaskClass + def parse_user(fields) + { + 'id' => fields[1], + 'first_name' => fields[2], + 'last_name' => fields[3], + 'age' => fields[4], + 'sessions' => [] + } + end + + def parse_session(fields) + { + '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) + report['usersStats'] = {} + users_objects.each do |user| + user_key = "#{user['first_name']}" + ' ' + "#{user['last_name']}" + report['usersStats'][user_key] ||= {} + report['usersStats'][user_key]['sessionsCount'] = collect_session_count(user['sessions']) + report['usersStats'][user_key]['totalTime'] = collect_session_time(user['sessions']) + report['usersStats'][user_key]['longestSession'] = collect_session_longest(user['sessions']) + report['usersStats'][user_key]['browsers'] = collect_browsers(user['sessions']) + report['usersStats'][user_key]['usedIE'] = collect_ie_usage(user['sessions']) + report['usersStats'][user_key]['alwaysUsedChrome'] = collect_if_only_chrome_used(user['sessions']) + report['usersStats'][user_key]['dates'] = collect_session_dates(user['sessions']) + end + end + + # Собираем количество сессий по пользователям + def collect_session_count(sessions) + sessions.count + end + + # Собираем количество времени по пользователям + def collect_session_time(sessions) + sessions.sum {|s| s['time'].to_i }.to_s + ' min.' + end + + # Выбираем самую длинную сессию пользователя + def collect_session_longest(sessions) + sessions.map {|s| s['time'].to_i}.max.to_s + ' min.' + end + + # Браузеры пользователя через запятую + def collect_browsers(sessions) + sessions.map {|s| s['browser'].upcase}.sort.join(', ') + end + + # Хоть раз использовал IE? + def collect_ie_usage(sessions) + !!sessions.find {|s| s['browser'] =~ /INTERNET EXPLORER/i } + end + + # Всегда использовал только Chrome? + def collect_if_only_chrome_used(sessions) + browsers = sessions.map {|s| s['browser']}.uniq + browsers.count == 1 && browsers.first =~ /CHROME/i + end + + # Даты сессий через запятую в обратном порядке в формате iso8601 + def collect_session_dates(sessions) + sessions.map{|s| s['date']}.sort {|a,b| b <=> a} + end + + def prepare_data(filename, users, sessions) + file_lines = File.open(filename, "r") + file_lines.each_line do |line| + cols = line.chomp("\n").split(',') + if cols[0] == 'session' + session = parse_session(cols) + sessions << session + users[session['user_id'].to_i]['sessions'] << session + else + users << parse_user(cols) + end + end + end + + def work(filename:) + users = [] + sessions = [] + prepare_data(filename, users, sessions) + + # Отчёт в json + # - Сколько всего юзеров + + # - Сколько всего уникальных браузеров + + # - Сколько всего сессий + + # - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом + + # + # - По каждому пользователю + # - сколько всего сессий + + # - сколько всего времени + + # - самая длинная сессия + + # - браузеры через запятую + + # - Хоть раз использовал IE? + + # - Всегда использовал только Хром? + + # - даты сессий в порядке убывания через запятую + + + report = {} + + report[:totalUsers] = users.count + + # Подсчёт количества уникальных браузеров + uniqueBrowsers = [] + sessions.each do |session| + browser = session['browser'] + uniqueBrowsers << browser unless uniqueBrowsers.include?(browser) + end + + report['uniqueBrowsersCount'] = uniqueBrowsers.count + + report['totalSessions'] = sessions.count + + report['allBrowsers'] = + sessions + .map { |s| s['browser'].upcase } + .sort + .uniq + .join(',') + + collect_stats_from_users(report, users) + + File.write('result.json', "#{report.to_json}\n") + end +end diff --git a/task_test.rb b/task_test.rb new file mode 100644 index 0000000..08412da --- /dev/null +++ b/task_test.rb @@ -0,0 +1,47 @@ +require 'test/unit' +require 'timeout' + +require_relative 'task_class' + +class TestMe < Test::Unit::TestCase + def setup + @filename = 'data.txt' + File.write('result.json', '') + File.write(@filename, +'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 + TaskClass.new.work(filename: @filename) + 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 + + def test_performance_degradation + time_to_process = 90 + + assert_nothing_raised Timeout::Error do + Timeout::timeout(time_to_process * 2) do + TaskClass.new.work(filename: 'data_large.txt') + end + end + end +end