diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..37c05c14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea +.idea/ +.vscode/ + +# ignore file data +data_large.txt +data.json +data_small.txt +result.json + +# ignore directory +ruby_prof_reports +stackprof_reports diff --git a/app/bar.rb b/app/bar.rb new file mode 100644 index 00000000..6f8d52f4 --- /dev/null +++ b/app/bar.rb @@ -0,0 +1,15 @@ +class Bar + attr_reader :parts_of_work + + def initialize(parts_of_work) + @parts_of_work = parts_of_work + end + + def progress + ProgressBar.create( + total: parts_of_work, + format: '%a, %J, %E %B' # elapsed time, percent complete, estimate, bar + # output: File.open(File::NULL, 'w') # IN TEST ENV + ) + end +end diff --git a/app/parsing.rb b/app/parsing.rb new file mode 100644 index 00000000..989ecdfe --- /dev/null +++ b/app/parsing.rb @@ -0,0 +1,61 @@ +class Parsing + attr_reader :file_name, :disable_bar + + def initialize(file_name, disable_bar) + @file_name = file_name + @disable_bar = disable_bar + end + + def call + file_lines = File.read(file_name).split("\n") + bar = Bar.new(file_lines.count).progress unless disable_bar + + result = {} + + file_lines.each do |line| + bar.increment unless disable_bar + + fields = line.split(',') + + if fields[0] == 'user' + id = fields[1] + + result[id] = { + first_name: fields[2], + last_name: fields[3], + age: fields[4], + sessions: {} + } + + next + end + + user_id = fields[1] + browser = fields[3].upcase + time = fields[4].to_i + date = fields[5] + sessions = result[user_id][:sessions] + sessions[:items] ||= {} + + sessions[:total_time] ||= 0 + sessions[:total_time] += time + + sessions[:long_session] ||= 0 + sessions[:long_session] = time if time > sessions[:long_session] + + sessions[:browsers] ||= [] + sessions[:browsers].push(browser) + + sessions[:dates] ||= [] + sessions[:dates].push(date) + + sessions[:items][fields[2]] = { + browser: browser, + time: time, + date: date + } + end + + result + end +end diff --git a/app/report.rb b/app/report.rb new file mode 100644 index 00000000..dd4d19c8 --- /dev/null +++ b/app/report.rb @@ -0,0 +1,96 @@ +class Report + attr_reader :users, :disable_bar + + def initialize(users, disable_bar) + @users = users + @disable_bar = disable_bar + end + + def call + # Отчёт в json + # - Сколько всего юзеров + + # - Сколько всего уникальных браузеров + + # - Сколько всего сессий + + # - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом + + # + # - По каждому пользователю + # - сколько всего сессий + + # - сколько всего времени + + # - самая длинная сессия + + # - браузеры через запятую + + # - Хоть раз использовал IE? + + # - Всегда использовал только Хром? + + # - даты сессий в порядке убывания через запятую + + + report = {} + + report[:totalUsers] = users.count + + # Подсчёт количества уникальных браузеров + unique_browsers = users.values.flat_map do |user| + user[:sessions][:items].values.flat_map { |session| session[:browser] } + end.compact.uniq + + report['uniqueBrowsersCount'] = unique_browsers.count + + report['totalSessions'] = users.values.sum { |user| user[:sessions].size } + + all_browsers = users.values.flat_map do |user| + user[:sessions][:items].values.map { |session| session[:browser] } + end.compact.sort.uniq.join(',') + + report['allBrowsers'] = all_browsers + + # Статистика по пользователям + users_objects = [] + + users.each_value do |user| + sessions = user.delete(:sessions) + + user_object = User.new(attributes: user, sessions: sessions) + + users_objects.append(user_object) + end + + report['usersStats'] = {} + + collect_data(report, users_objects) + end + + def collect_data(report, users_objects) + bar = Bar.new(users_objects.count).progress unless disable_bar + + report['usersStats'] = {} + + users_objects.each do |user| + bar.increment unless disable_bar + + user_key = "#{user.attributes[:first_name]} #{user.attributes[:last_name]}" + + report['usersStats'][user_key] ||= {} + + # Собираем количество сессий по пользователям + report['usersStats'][user_key][:sessionsCount] = user.sessions[:items].count + + # Собираем количество времени по пользователям + report['usersStats'][user_key][:totalTime] = "#{user.sessions[:total_time]} min." + + # Выбираем самую длинную сессию пользователя + report['usersStats'][user_key][:longestSession] = "#{user.sessions[:long_session]} min." + + # Браузеры пользователя через запятую + report['usersStats'][user_key][:browsers] = user.sessions[:browsers].sort.join(', ') + + # Хоть раз использовал IE? + report['usersStats'][user_key][:usedIE] = user.sessions[:browsers].any? { |b| b =~ /INTERNET EXPLORER/ } + + # Всегда использовал только Chrome? + report['usersStats'][user_key][:alwaysUsedChrome] = user.sessions[:browsers].all? { |b| b =~ /CHROME/ } + + # Даты сессий через запятую в обратном порядке в формате iso8601 + report['usersStats'][user_key][:dates] = user.sessions[:dates].sort.reverse + end + + report + end +end diff --git a/app/task-1.rb b/app/task-1.rb new file mode 100644 index 00000000..71085072 --- /dev/null +++ b/app/task-1.rb @@ -0,0 +1,91 @@ +# Deoptimized version of homework task + +require 'json' +require 'pry' +require 'date' +require 'minitest/autorun' +require 'benchmark' +require 'ruby-progressbar' + +require 'ruby-prof' +require 'stackprof' + +# additional classes +require_relative 'user' +require_relative 'parsing' +require_relative 'report' +require_relative 'bar' + +# FILE_NAME = '../data_large.txt' +FILE_NAME = '../data_small.txt' +# FILE_NAME = '../data.txt' + +def work(disable_gc: false, disable_bar: false) + GC.disable if disable_gc + + users = Parsing.new(FILE_NAME, disable_bar).call + report = Report.new(users, disable_bar).call + + 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 + elapsed_time = Benchmark.realtime { work(disable_bar: true) } + + available_time = 30 + + msg = "Execution time exceeded: #{elapsed_time} seconds. + The available time to complete the test is #{available_time} seconds." + + assert elapsed_time <= available_time, msg + + # assert_equal File.read('data.json'), File.read('result.json') + + # 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_profile + # result = RubyProf::Profile.profile { work(disable_gc: true, disable_bar: true) } + # + # printer = RubyProf::CallStackPrinter.new(result) + # printer.print(File.open('../ruby_prof_reports/callstack.html', 'w+')) + # + # printer = RubyProf::GraphHtmlPrinter.new(result) + # printer.print(File.open("../ruby_prof_reports/graph.html", "w+")) + # end + + # def test_stackprof + # profile = StackProf.run(mode: :wall, raw: true) do + # work(disable_gc: true) + # end + # + # File.write('stackprof_reports/stackprof.json', JSON.generate(profile)) + # end +end diff --git a/app/user.rb b/app/user.rb new file mode 100644 index 00000000..c0a90d89 --- /dev/null +++ b/app/user.rb @@ -0,0 +1,8 @@ +class User + attr_reader :attributes, :sessions + + def initialize(attributes:, sessions:) + @attributes = attributes + @sessions = sessions + end +end diff --git a/case-study.md b/case-study.md new file mode 100644 index 00000000..fb236d01 --- /dev/null +++ b/case-study.md @@ -0,0 +1,85 @@ +# Case-study оптимизации + +## Checklist +- [x] Прикинуть зависимость времени работы программы от размера обрабатываемого файла +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `Flat`; +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `Graph`; +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `CallStack`; +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `CallTree` c визуализацией в `QCachegrind`; +- [x] Построить дамп `stackprof` и проанализировать его с помощью `CLI` +- [x] Построить дамп `stackprof` в `json` и проанализировать его с помощью `speedscope.app` +- [x] Профилировать работающий процесс `rbspy`; +- [x] Добавить в программу `ProgressBar`; +- [ ] Постараться довести асимптотику до линейной и проверить это тестом; +- [x] Написать простой тест на время работы: когда вы придёте к оптимизированному решению, замерьте, сколько оно будет работать на тестовом объёме данных; и напишите тест на то, что это время не превышается (чтобы не было ложных срабатываний, задайте время с небольшим запасом); + + +## Актуальная проблема +Запуск программы с данными в 12 500 срок показало работу в 2.3 секунд. +Запуск программы с данными в 25 000 срок показало работу в 8 секунд. +Запуск программы с данными в 50 000 срок показало работу в 55 секунд. +Запуск программы с данными в 100 000 срок показало работу в 257 секунд. + +Тут видно, что с увеличением строк в файле в два раза, процесс обработки увеличивается более чем в 5 раз. + +То есть если файл будет с 3 250 940 строк, то этот файл может обрабатываться примерно неделю. + +Моя первая цель ускорить обработку файла в 25 000 срок до 2 секунд. Это примерная оценка из "потолка". + +## Формирование метрики +Для понимания процесса обработки данных я использовал метрику *Benchmark*. Так я могу видеть время работы программы + +## Гарантия корректности работы оптимизированной программы +Программа поставлялась с тестом. + +Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. + +Еще добавлен тест на время выполнения теста. + +## Feedback-Loop +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* + +Вот как я построил `feedback_loop`: *как вы построили feedback_loop* + +- Для начала я запустил Rbspy что бы обнаружить точку роста. 99% времени работа происходит в этом блоке кода block in work - task-1.rb:63 + +- Ruby-prof в режиме Flat показал точку роста в вызове метода Array#select + +- Ruby-prof в режиме Graph так же показал точку роста в Array#select и вызывался из Array#each + +- В Ruby-prof в режиме callgrind получил красивый отчет, так же показан рост в Array#select + +- StackProf показал основное время работы в Object#work + +- StackProf в формате json на много интереснее получается и так же информативно + + +## Вникаем в детали системы, чтобы найти главные точки роста +Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались* + +Вот какие проблемы удалось найти и решить + +### Моя находка №1 +- Ruby-prof в режиме Graph так же показал точку роста в Array#select +- Самое главное что я сделал, это первую обработку данных. Что бы все было структурировано и была некая связь между пользователем и его сессиями. После этого внес правки в весь код, что бы тесты начали выполняться +- Метрика стала на много лучше . Запуск программы с данными в 25 000 срок показало работу в 0.8 секунд. Это укладывается в мой первоначальный бюджет. +- Теперь профилировщик показывает две точки роста. Это 71% Object#collect_stats_from_users и 50% Array#map + +### Моя находка №2 +- Ruby-prof в режиме Graph так же показал две точки роста 71% в Object#collect_stats_from_users и 50% в Array#map +- Я решил изменить метод collect_stats_from_users и теперь он сам обрабатывает все данные, без передачи блока и лишнего обхода в цикле +- А вот метрика не сильно изменилась, нужно менять подход +- Проблема сохраняется, нужно внести изменения в исходные данные + +### Моя находка №3 +- Ruby-prof в режиме Graph так же показал две точки роста 67% в Object#collect_stats_from_users и 50% в Array#map +- Решил изменить самый первый цикл. Сделаю правильный сбор данных который в последствии не нужно будет повторно обрабатывать. +- Метрика стала лучше . Запуск программы с данными в 25 000 срок показало работу в 0.2 секунды. +- Теперь точка роста цикл Array#each в модуле parsing + +## Результаты +В результате проделанной оптимизации наконец удалось обработать файл с данными. +Мне удалось обработать весь файл в среднем на 28 секунд. Это укладывается в мой бюджет. + +## Защита от регрессии производительности +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали* \ No newline at end of file diff --git a/task-1.rb b/task-1.rb deleted file mode 100644 index 778672df..00000000 --- a/task-1.rb +++ /dev/null @@ -1,176 +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") -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