From 0b8d24155edd2211eddf14b55728f2e9b646dd48 Mon Sep 17 00:00:00 2001 From: Aleksandr Shelestov Date: Mon, 4 Mar 2019 22:13:02 +0300 Subject: [PATCH 1/4] Add metrics --- case-study-template.md | 21 +++++++++++++++++---- task-1.rb | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/case-study-template.md b/case-study-template.md index e0eef00..8c2a692 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -12,7 +12,11 @@ Я решил исправить эту проблему, оптимизировав эту программу. ## Формирование метрики -Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика* +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: + +Я решил произвести замеры использованной памяти (инструмент — ruby-prof). +Время выполнения скрипт (на следующем наборе входных данных: [1, 10, 100, 1_000] строк). +Количество созданных объектов (сфокусировался на классах, символах, массивах и строках). ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста позволяет не допустить изменения логики программы при оптимизации. @@ -20,15 +24,24 @@ ## Feedback-Loop Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* -Вот как я построил `feedback_loop`: *как вы построили feedback_loop* +Вот как я построил `feedback_loop`: + +Я немного изменил код, чтобы считывать не весь большой файл целиком, а только первые его x линий: +```ruby +def work(lines = nil) + file_name = lines ? 'data_large.txt' : 'data.txt' + file_lines = lines ? File.read(file_name).split("\n", lines) : File.read(file_name).split("\n") + ... +``` +Это позволило мне проводить замеры не только на тестовых данных, но и на большом объеме данных (например, на 10_000 строках). +Для сбора метрики и прогона программы, я добавил еще несколько тестов. ## Вникаем в детали системы, чтобы найти 20% точек роста -Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались* +Для того, чтобы найти "точки роста" для оптимизации я воспользовался: глазами, опытом, и результатами снятия метрик. Вот какие проблемы удалось найти и решить ### Ваша находка №1 -О вашей находке №1 ### Ваша находка №2 О вашей находке №2 diff --git a/task-1.rb b/task-1.rb index 778672d..403bb17 100644 --- a/task-1.rb +++ b/task-1.rb @@ -4,6 +4,11 @@ require 'pry' require 'date' require 'minitest/autorun' +require 'minitest/benchmark' +require 'ruby-prof' +require 'benchmark' + +RubyProf.measure_mode = RubyProf::MEMORY class User attr_reader :attributes, :sessions @@ -43,8 +48,10 @@ def collect_stats_from_users(report, users_objects, &block) end end -def work - file_lines = File.read('data.txt').split("\n") +def work(lines = nil) + file_name = lines ? 'data_large.txt' : 'data.txt' + + file_lines = lines ? File.read(file_name).split("\n", lines) : File.read(file_name).split("\n") users = [] sessions = [] @@ -143,6 +150,27 @@ def work File.write('result.json', "#{report.to_json}\n") end +# Some tests +class BenchmarkWork < Minitest::Benchmark + def self.bench_range + [1, 10, 100, 1_000, 10_000] + end + + def bench_work + assert_performance_constant 0.99 do |x| + work(x) + end + end +end + +class MemoryLeakTest < Minitest::Test + def test_memory + result = RubyProf.profile { work } + printer = RubyProf::CallTreePrinter.new(result) + printer.print(path: '.', profile: 'profile') + end +end + class TestMe < Minitest::Test def setup File.write('result.json', '') @@ -169,7 +197,10 @@ def setup end def test_result + GC.disable work + pp ObjectSpace.count_objects + 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 From eb441b47de427c689cd18d4a3ca6d94e9b442791 Mon Sep 17 00:00:00 2001 From: Aleksandr Shelestov Date: Tue, 5 Mar 2019 00:57:01 +0300 Subject: [PATCH 2/4] Read the only needed lines from a file Remove Array#select, refactoring session and user stats to make it work rapidly faster. --- case-study-template.md | 31 ++++++++- task-1.rb | 138 +++++++++++++++++++---------------------- 2 files changed, 93 insertions(+), 76 deletions(-) diff --git a/case-study-template.md b/case-study-template.md index 8c2a692..bec8602 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -42,9 +42,38 @@ def work(lines = nil) Вот какие проблемы удалось найти и решить ### Ваша находка №1 +Замеры времени выполнения программы показали, что независимо от того, сколько мы позже хотим обработать строк, +загрузка большого файла целиком занимает в среднем 6,5 секунд на моей машине. + +Решил начать с того, чтобы грузить файл не целиком, а ровно столько его строк, сколько нам понадобиться. +Как минимум это позволит быстрее проводить замеры при дальнейшей оптимизации. + +В итоге теперь файл считывается в память не целиком, а только нужно количество первых его строк. +Что сократило время "разогрева" с 6 секунд до 0.000200. ### Ваша находка №2 -О вашей находке №2 +Погонял замеры времени выполнения скрипта с таким набором данных: `[1, 10, 100, 1_000, 10_000, 100_000]`. +Получил интересный результат (в секундах): +```bash +bench_work 0.000915 0.001853 0.001687 0.040583 1.515984 315.730440 +``` +Глядя на радикальный прирост тормозов на 100_000 строк, можно сделать вывод, что дело не в количество строк (прирост строк всего лишь 10 кратный), +а в использовании памяти и неэффективных алгоритмах. + +Подобрал значение, которое выполняется в среднем за 6 секунд (20_000 строк), +и решил взять этот параметр за исходную точку для дальнейших замеров памяти и производительности. + +Результаты профилирования показали, что самое узкое место на данный момент приходится на операцию Array#select. +Общее кол-во памяти, отведенной на эти операции, составило 414 Mb. Когда количество вызовов этой функции всего 3,046 (по количеству users в первых 20_000 строк). +То есть, не самая частая операция отъедает почти 89% памяти. + +Путем рефакторинга удалось получить следующие показатели (сокращение времени на 100_000 строках более чем в 157 раз): + +```bash +bench_work 0.001317 0.000652 0.001504 0.019504 0.231620 2.087732 +``` + +На 20_000 строчках памяти же теперь используется всего ~17 Mb против 466 Mb ранее, а времени 0,3 секунды, против 6. ### Ваша находка №X О вашей находке №X diff --git a/task-1.rb b/task-1.rb index 403bb17..c995e21 100644 --- a/task-1.rb +++ b/task-1.rb @@ -48,20 +48,19 @@ def collect_stats_from_users(report, users_objects, &block) end end -def work(lines = nil) - file_name = lines ? 'data_large.txt' : 'data.txt' - - file_lines = lines ? File.read(file_name).split("\n", lines) : File.read(file_name).split("\n") - - users = [] - sessions = [] +def work(lines, file = nil) + users_objects = [] - 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' + IO.foreach(file || 'data_large.txt').take(lines).slice_before do |line| + line.start_with?('user') + end.each do |user_and_sessions| + user_line, *sessions_lines = user_and_sessions + sessions = sessions_lines.map! { |session_line| parse_session(session_line) } + users_objects << User.new(attributes: parse_user(user_line.rstrip!), sessions: sessions) end + sessions = users_objects.map(&:sessions).flatten + # Отчёт в json # - Сколько всего юзеров + # - Сколько всего уникальных браузеров + @@ -79,7 +78,7 @@ def work(lines = nil) report = {} - report[:totalUsers] = users.count + report[:totalUsers] = users_objects.count # Подсчёт количества уникальных браузеров uniqueBrowsers = [] @@ -101,15 +100,6 @@ def work(lines = nil) .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'] = {} # Собираем количество сессий по пользователям @@ -151,57 +141,55 @@ def work(lines = nil) end # Some tests -class BenchmarkWork < Minitest::Benchmark - def self.bench_range - [1, 10, 100, 1_000, 10_000] - end - - def bench_work - assert_performance_constant 0.99 do |x| - work(x) - end - end -end - -class MemoryLeakTest < Minitest::Test - def test_memory - result = RubyProf.profile { work } - printer = RubyProf::CallTreePrinter.new(result) - printer.print(path: '.', profile: 'profile') - end -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 - GC.disable - work - pp ObjectSpace.count_objects - - 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 +# class BenchmarkWork < Minitest::Benchmark +# def self.bench_range +# [1, 10, 100, 1_000, 10_000, 100_000] +# end +# +# def bench_work +# assert_performance_constant 0.99 do |x| +# work(x) +# end +# end +# end + +# class MemoryLeakTest < Minitest::Test +# def test_memory +# result = RubyProf.profile { work(20_000) } +# printer = RubyProf::CallTreePrinter.new(result) +# printer.print(path: '.', profile: 'profile') +# end +# 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(18, '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 +# end From f46c390296c6bbbc3327ff0123372ad2a4fb2554 Mon Sep 17 00:00:00 2001 From: Aleksandr Shelestov Date: Tue, 5 Mar 2019 03:51:16 +0300 Subject: [PATCH 3/4] Fix dates. --- case-study-template.md | 25 ++++- task-1.rb | 218 +++++++++++++++++++---------------------- 2 files changed, 125 insertions(+), 118 deletions(-) diff --git a/case-study-template.md b/case-study-template.md index bec8602..9449796 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -75,8 +75,29 @@ bench_work 0.001317 0.000652 0.001504 0.019504 0.231620 2.087732 На 20_000 строчках памяти же теперь используется всего ~17 Mb против 466 Mb ранее, а времени 0,3 секунды, против 6. -### Ваша находка №X -О вашей находке №X +### Ваша находка №3 +Следующий метод на очереди, который съедает почти 70% памяти (или 13 Mb), оказался Array#each в методе `collect_stats_from_users` +Там куча `Array#map`, повторных проходов по массиву пользователей, и все такое. Вынес все это в виде методов модели User, закэшировал значения. +Путем всяких прочих хитрых рефакторингов удалось снизить расход памяти на этих операциях до 7 Mb. Что почти в два раза меньше. Время тоже сократилось в два раза. +Но повторные запуски метрики показали, что рефакторинг привел к большому росту расхода памяти на постройку хэша и последующую его конвертацию в json для записи на диск. +Но это уже друга история. + +P.S. Кстати, нашел бажок. В тестах этого кейса нет. Если у пользователя вообще нет ни одной сессии, то он попадает в статистику как: +```json +... +"usedIE": false, +"alwaysUsedChrome": true, +... +``` + +### Ваша находка №4 +После всех манипуляций почему-то вырос расход памяти на конвертацию финального хэша в json. Аж до 17 Mb. + +Методом тыка обнаружил, что все дело было в моей "оптимизации" даты. Я ее стал хранить в хэше не в виде строк, а виде объекта Date. +Видимо потом #to_json не умело как-то ее конвертил, отчего отожрал памяти. + +Вернул обратно строку, теперь весь стек с 20_000 строками укладывается в ~12 Mb. +Есть еще куда оптимизировать. ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. diff --git a/task-1.rb b/task-1.rb index c995e21..0fc3096 100644 --- a/task-1.rb +++ b/task-1.rb @@ -1,12 +1,10 @@ -# Deoptimized version of homework task +# frozen_string_literal: true require 'json' -require 'pry' require 'date' require 'minitest/autorun' require 'minitest/benchmark' require 'ruby-prof' -require 'benchmark' RubyProf.measure_mode = RubyProf::MEMORY @@ -17,6 +15,36 @@ def initialize(attributes:, sessions:) @attributes = attributes @sessions = sessions end + + def key + "#{attributes['first_name']} #{attributes['last_name']}" + end + + def stats + { + 'sessionsCount' => sessions.count, + 'totalTime' => "#{sessions_time.sum.to_s} min.", + 'longestSession' => "#{sessions_time.max.to_s} min.", + 'browsers' => sessions_browsers.join(', '), + 'usedIE' => sessions_browsers.any? { |b| b =~ /INTERNET EXPLORER/ }, + 'alwaysUsedChrome' => sessions_browsers.all? { |b| b =~ /CHROME/ }, + 'dates' => sessions_dates + } + end + + def sessions_browsers + @sessions_browsers ||= sessions.map { |s| s['browser'].upcase! }.sort! + end + + private + + def sessions_time + @sessions_time ||= sessions.map { |s| s['time'] }.map!(&:to_i) + end + + def sessions_dates + @sessions_dates ||= sessions.map { |s| Date.parse(s['date']) }.sort!.reverse!.map(&:iso8601) + end end def parse_user(user) @@ -40,26 +68,33 @@ def parse_session(session) } 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(lines, file = nil) - users_objects = [] + report = {} + report['totalUsers'] = 0 + report['uniqueBrowsersCount'] = 0 + report['totalSessions'] = 0 + report['allBrowsers'] = '' + report['usersStats'] = {} + unique_browsers = [] - IO.foreach(file || 'data_large.txt').take(lines).slice_before do |line| + IO.foreach(file || 'data_large.txt').slice_before do |line| line.start_with?('user') end.each do |user_and_sessions| user_line, *sessions_lines = user_and_sessions sessions = sessions_lines.map! { |session_line| parse_session(session_line) } - users_objects << User.new(attributes: parse_user(user_line.rstrip!), sessions: sessions) + user = User.new(attributes: parse_user(user_line.rstrip!), sessions: sessions) + + report['usersStats'][user.key] = user.stats + report['totalUsers'] += 1 + report['totalSessions'] += user.sessions.count + + user.sessions_browsers.each do |browser| + unique_browsers << browser unless unique_browsers.include?(browser) + end end - sessions = users_objects.map(&:sessions).flatten + report['uniqueBrowsersCount'] = unique_browsers.count + report['allBrowsers'] = unique_browsers.sort!.join(',') # Отчёт в json # - Сколько всего юзеров + @@ -76,71 +111,9 @@ def work(lines, file = nil) # - Всегда использовал только Хром? + # - даты сессий в порядке убывания через запятую + - report = {} - - report[:totalUsers] = users_objects.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(',') - - # Статистика по пользователям - 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 -# Some tests # class BenchmarkWork < Minitest::Benchmark # def self.bench_range # [1, 10, 100, 1_000, 10_000, 100_000] @@ -153,43 +126,56 @@ def work(lines, file = nil) # end # end -# class MemoryLeakTest < Minitest::Test -# def test_memory -# result = RubyProf.profile { work(20_000) } -# printer = RubyProf::CallTreePrinter.new(result) -# printer.print(path: '.', profile: 'profile') -# end -# 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(18, '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 -# end +def test_memory + result = RubyProf.profile { work(20_000) } + printer = RubyProf::CallTreePrinter.new(result) + printer.print(path: '.', profile: 'profile') + # printer = RubyProf::GraphPrinter.new(result) + # printer.print(STDOUT, :min_percent => 2) +end + +test_memory + +class MemoryLeakTest < Minitest::Test + def test_memory + result = RubyProf.profile { work(20_000) } + # printer = RubyProf::CallTreePrinter.new(result) + # printer.print(path: '.', profile: 'profile') + printer = RubyProf::GraphPrinter.new(result) + printer.print(STDOUT, :min_percent => 2) + end +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(18, '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 +end From 78703a8a3ae02957c58290f26f5e0bd76b8772d8 Mon Sep 17 00:00:00 2001 From: Aleksandr Shelestov Date: Sat, 9 Mar 2019 03:19:22 +0300 Subject: [PATCH 4/4] Remove Date.parse Optimize Array#split Symbolize strings and so on. --- case-study-template.md | 44 ++++++++++-- task-1.rb | 158 ++++++++++++++++++++++++----------------- 2 files changed, 132 insertions(+), 70 deletions(-) diff --git a/case-study-template.md b/case-study-template.md index 9449796..db68867 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -15,7 +15,7 @@ Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: Я решил произвести замеры использованной памяти (инструмент — ruby-prof). -Время выполнения скрипт (на следующем наборе входных данных: [1, 10, 100, 1_000] строк). +Время выполнения скрипт (на следующем наборе входных данных: [1, 10, 100, 1_000, 10_000, 100_000] строк). Количество созданных объектов (сфокусировался на классах, символах, массивах и строках). ## Гарантия корректности работы оптимизированной программы @@ -64,7 +64,7 @@ bench_work 0.000915 0.001853 0.001687 0.040583 1.515984 315.730440 и решил взять этот параметр за исходную точку для дальнейших замеров памяти и производительности. Результаты профилирования показали, что самое узкое место на данный момент приходится на операцию Array#select. -Общее кол-во памяти, отведенной на эти операции, составило 414 Mb. Когда количество вызовов этой функции всего 3,046 (по количеству users в первых 20_000 строк). +Общее кол-во памяти, отведенной на эти операции, составило 414 Mb (из 470 всего). Когда количество вызовов этой функции всего 3,046 (по количеству users в первых 20_000 строк). То есть, не самая частая операция отъедает почти 89% памяти. Путем рефакторинга удалось получить следующие показатели (сокращение времени на 100_000 строках более чем в 157 раз): @@ -99,11 +99,45 @@ P.S. Кстати, нашел бажок. В тестах этого кейса Вернул обратно строку, теперь весь стек с 20_000 строками укладывается в ~12 Mb. Есть еще куда оптимизировать. +### Ваша находка №5 +При помощи утилиты профилирования и бенчмарка удалось найти очередное бутылочное горлышко. А точнее, сразу два. +Первое — парсинг даты в объект. +Второе — String#split создавал дополнительные объекты массивов, ел нехило памяти. + +С первым решилось просто — убрал парсинг (исходим из того, что формат даты неизменный), сортировал строку. +Со вторым пришлось переписать split используя блок. Выглядит коряво, совсем не руби-стайл. Но зато эффективнее. + ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. -Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* +Удалось улучшить метрику системы: + +Время выполнение изначальное, с параметрами `[1, 10, 100, 1_000, 10_000, 100_000]`: -*Какими ещё результами можете поделиться* +```bash +bench_work 0.000915 0.001853 0.001687 0.040583 1.515984 315.730440 +``` + +Стало: +```bash +bench_work 0.000581 0.000303 0.000716 0.007148 0.068577 0.648381 +``` + +Ускорение более чем в 487 раз! + +Изначальный расход памяти с кол-вом строк 20_000 был 470 Mb. +Стал: 7.1 Mb. + +Итого уменьшение расхода памяти в 67 раз. + +Весь файл целиком теперь выполняется за 29 секунд (ранее это занимало бесконечно много). +Память расходуется в районе ~2 Gb. (Если я правильно понял эту цифру в отчете профилировщика — 2 042 553) + +Код все еще не самый красивый, есть что поDRYить, как разбить на классы, и где пописать больше тестов. Но вроде как урок +был не про это, поэтому можно пока пропустить и оставить как есть. ## Защита от регресса производительности -Для защиты от потери достигнутого прогресса при дальнейших изменениях программы сделано *то, что вы для этого сделали* +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы добавил тест на O(N) бенчамарк. + +Как сделать тест на используемую память пока не нашел. + + diff --git a/task-1.rb b/task-1.rb index 0fc3096..a7b6522 100644 --- a/task-1.rb +++ b/task-1.rb @@ -1,71 +1,106 @@ # frozen_string_literal: true require 'json' -require 'date' require 'minitest/autorun' require 'minitest/benchmark' require 'ruby-prof' RubyProf.measure_mode = RubyProf::MEMORY +USER_KEYS = { + 1 => :id, + 2 => :first_name, + 3 => :last_name, + 4 => :age +} + +SESSION_KEYS = { + 1 => :user_id, + 2 => :session_id, + 3 => :browser, + 4 => :time, + 5 => :date +} + class User + class Sessions + attr_reader :sessions + + def initialize(sessions) + @sessions = sessions + end + + def sessions_time + @sessions_time ||= sessions.map { |s| s[:time] }.map!(&:to_i) + end + + def sessions_dates + sessions.map do |s| + s[:date] + end.sort_by! do |date_string| + date_string.rstrip!.delete('-') + end.reverse! + end + end + attr_reader :attributes, :sessions def initialize(attributes:, sessions:) @attributes = attributes - @sessions = sessions + @sessions = Sessions.new(sessions) end def key - "#{attributes['first_name']} #{attributes['last_name']}" + "#{attributes[:first_name]} #{attributes[:last_name]}" end def stats - { - 'sessionsCount' => sessions.count, - 'totalTime' => "#{sessions_time.sum.to_s} min.", - 'longestSession' => "#{sessions_time.max.to_s} min.", - 'browsers' => sessions_browsers.join(', '), - 'usedIE' => sessions_browsers.any? { |b| b =~ /INTERNET EXPLORER/ }, - 'alwaysUsedChrome' => sessions_browsers.all? { |b| b =~ /CHROME/ }, - 'dates' => sessions_dates - } + @stats ||= begin + browser_string = sessions_browsers.join(', ') + + { + :'sessionsCount' => sessions.sessions.count, + :'totalTime' => "#{sessions.sessions_time.sum} min.", + :'longestSession' => "#{sessions.sessions_time.max} min.", + :'browsers' => browser_string, + :'usedIE' => browser_string.include?('INTERNET EXPLORER'), + :'alwaysUsedChrome' => sessions_browsers.all? { |b| b =~ /CHROME/ }, + :'dates' => sessions.sessions_dates + } + end end def sessions_browsers - @sessions_browsers ||= sessions.map { |s| s['browser'].upcase! }.sort! - end - - private - - def sessions_time - @sessions_time ||= sessions.map { |s| s['time'] }.map!(&:to_i) - end - - def sessions_dates - @sessions_dates ||= sessions.map { |s| Date.parse(s['date']) }.sort!.reverse!.map(&:iso8601) + @sessions_browsers ||= sessions.sessions.map { |s| s[:browser].upcase! }.sort! 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], - } + order = 0 + fields = {} + user.split(',') do |value| + if order == 0 + order += 1 + next + end + fields[USER_KEYS[order]] = value + order += 1 + end + fields 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], - } + order = 0 + fields = {} + session.split(',') do |value| + if order == 0 + order += 1 + next + end + fields[SESSION_KEYS[order]] = value + order += 1 + end + fields end def work(lines, file = nil) @@ -77,16 +112,21 @@ def work(lines, file = nil) report['usersStats'] = {} unique_browsers = [] - IO.foreach(file || 'data_large.txt').slice_before do |line| + file = IO.foreach(file || 'data_large.txt') + if lines + file = file.take(lines) + end + + file.slice_before do |line| line.start_with?('user') end.each do |user_and_sessions| user_line, *sessions_lines = user_and_sessions - sessions = sessions_lines.map! { |session_line| parse_session(session_line) } + sessions = sessions_lines.map!(&method(:parse_session)) user = User.new(attributes: parse_user(user_line.rstrip!), sessions: sessions) report['usersStats'][user.key] = user.stats report['totalUsers'] += 1 - report['totalSessions'] += user.sessions.count + report['totalSessions'] += user.stats[:'sessionsCount'] user.sessions_browsers.each do |browser| unique_browsers << browser unless unique_browsers.include?(browser) @@ -114,38 +154,26 @@ def work(lines, file = nil) File.write('result.json', "#{report.to_json}\n") end -# class BenchmarkWork < Minitest::Benchmark -# def self.bench_range -# [1, 10, 100, 1_000, 10_000, 100_000] -# end -# -# def bench_work -# assert_performance_constant 0.99 do |x| -# work(x) -# end -# end -# end +class BenchmarkWork < Minitest::Benchmark + def self.bench_range + [1, 10, 100, 1_000, 10_000, 100_000] + end + + def bench_work + assert_performance_linear(0.99, &method(:work)) + end +end def test_memory - result = RubyProf.profile { work(20_000) } + result = RubyProf.profile { work(20_000, 'data_large.txt') } printer = RubyProf::CallTreePrinter.new(result) printer.print(path: '.', profile: 'profile') # printer = RubyProf::GraphPrinter.new(result) # printer.print(STDOUT, :min_percent => 2) end -test_memory - -class MemoryLeakTest < Minitest::Test - def test_memory - result = RubyProf.profile { work(20_000) } - # printer = RubyProf::CallTreePrinter.new(result) - # printer.print(path: '.', profile: 'profile') - printer = RubyProf::GraphPrinter.new(result) - printer.print(STDOUT, :min_percent => 2) - end -end +# test_memory class TestMe < Minitest::Test def setup @@ -173,7 +201,7 @@ def setup end def test_result - work(18, 'data.txt') + work(false,'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')