diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64af563 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.ruby-version +.ruby-gemset + +*.txt +*.json +*.html +*.callgrind* + +.DS_Store diff --git a/case-study-template.md b/case-study-template.md index e0eef00..bb8d30c 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -12,7 +12,10 @@ Я решил исправить эту проблему, оптимизировав эту программу. ## Формирование метрики -Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика* +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: +- измерение общего времени выполнения с помощью `Benchmark.realtime` +- измерение общей потребляемой памяти с помощью `MemoryMeasure` +- профилирование программы с помощью `ruby-prof` и `qcachegrind` ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста позволяет не допустить изменения логики программы при оптимизации. @@ -20,27 +23,41 @@ ## Feedback-Loop Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* -Вот как я построил `feedback_loop`: *как вы построили feedback_loop* +Вот как я построил `feedback_loop`: +1. Построение гипотезы +2. Исправление кода +3. Запуск скрипта на тестовом файле небольшого объема (1 мб) +4. Сопоставление новых метрик с предыдущими ## Вникаем в детали системы, чтобы найти 20% точек роста -Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались* +Для того, чтобы найти "точки роста" для оптимизации я воспользовался библиотеками benchmark, ruby-prof и инструментом визуализации qcachegrind Вот какие проблемы удалось найти и решить ### Ваша находка №1 -О вашей находке №1 +Львиную долю времени занимает вложенная итерация сессий при переборе пользователей. ### Ваша находка №2 -О вашей находке №2 +Оптимизация `all?` (замена `=~` на `match?`, вынос регулярного выражения вне цикла, переписывание на `each`) не дает существенного прироста производительности и экономии памяти. -### Ваша находка №X -О вашей находке №X +### Ваша находка №3 +Удалось добиться небольшого ускорения, заменив парсинг даты на `Date.iso8601` и реализовав сортировку без `reverse` + +### Ваша находка №4 +Удалось добиться небольшого ускорения, собрав всю статистику пользователя за одну итерацию (вместо нескольких для каждого вида статистики). + +### Ваша находка №5 +Удалось добиться прироста производительности ~30% и сокращения объема потребляемой памяти заменой массива с проверкой на уникальность на `Set`. + +### Ваша находка №6 +В массив лучше записывать так `arr << new_el`. `arr + [new_el]` намного менее эффективен, т.к. создает дополнительный массив, и соединение двух массивов намного сложнее добавления 1 элемента в существующий массив. + +### Ваша находка №7 +Чтение файла построчно позволяет сэкономить немного памяти и ускорить работу. ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. -Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* - -*Какими ещё результами можете поделиться* +Удалось улучшить метрику системы с 20 сек и 36 МБ RSS до 0.35 сек и 19 МБ RSS. ## Защита от регресса производительности Для защиты от потери достигнутого прогресса при дальнейших изменениях программы сделано *то, что вы для этого сделали* diff --git a/task-1.rb b/task-1.rb index 778672d..e154989 100644 --- a/task-1.rb +++ b/task-1.rb @@ -4,6 +4,8 @@ require 'pry' require 'date' require 'minitest/autorun' +require 'ruby-prof' +require 'benchmark' class User attr_reader :attributes, :sessions @@ -14,45 +16,49 @@ def initialize(attributes:, 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], +def parse_user(fields) + { + id: fields[0], + first_name: fields[1], + last_name: fields[2], + age: fields[3] } 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], +def parse_session(fields) + { + user_id: fields[0], + session_id: fields[1], + browser: fields[2], + time: fields[3], + date: fields[4] } end -def collect_stats_from_users(report, users_objects, &block) +def collect_stats_from_users(report, users_objects) 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)) + user_key = "#{user.attributes[:first_name]} #{user.attributes[:last_name]}" + + report[:usersStats][user_key] ||= {} + report[:usersStats][user_key] = report[:usersStats][user_key].merge(yield(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' +def work(file_path) + users = [] + user_sessions = {} + + File.foreach(file_path) do |line| + type, *rest = line.split(',') + case type + when 'user' + users << parse_user(rest) + when 'session' + session = parse_session(rest) + user_id = session[:user_id] + user_sessions[user_id] ||= [] + user_sessions[user_id] << session + end end # Отчёт в json @@ -74,73 +80,70 @@ 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[:uniqueBrowsersCount] = 0 - report['totalSessions'] = sessions.count + report[:totalSessions] = 0 - report['allBrowsers'] = - sessions - .map { |s| s['browser'] } - .map { |b| b.upcase } - .sort - .uniq - .join(',') + report[:allBrowsers] = Set.new # Статистика по пользователям 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] + users.each do |user_attrs| + currnet_user_sessions = Array(user_sessions[user_attrs[:id]]) + user_object = User.new(attributes: user_attrs, sessions: currnet_user_sessions) + users_objects << user_object end - report['usersStats'] = {} - - # Собираем количество сессий по пользователям - collect_stats_from_users(report, users_objects) do |user| - { 'sessionsCount' => user.sessions.count } - end + report[:usersStats] = {} - # Собираем количество времени по пользователям 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.' } + result = { + sessionsCount: user.sessions.size, # Собираем количество сессий по пользователям + totalTime: 0, # Собираем количество времени по пользователям + longestSession: 0, # Выбираем самую длинную сессию пользователя + browsers: [], # Браузеры пользователя через запятую + usedIE: false, # Хоть раз использовал IE? + alwaysUsedChrome: true, # Всегда использовал только Chrome? + dates: [] # Даты сессий через запятую в обратном порядке в формате iso8601 + } + chrome_regexp = /CHROME/ + ie_regexp = /INTERNET EXPLORER/ + + user.sessions.each do |session| + result[:dates] << Date.iso8601(session[:date]) + + browser = session[:browser].upcase + result[:alwaysUsedChrome] &&= browser.match?(chrome_regexp) + result[:usedIE] ||= browser.match?(ie_regexp) + result[:browsers] << browser + + time = session[:time].to_i + result[:longestSession] = [result[:longestSession], time].max + result[:totalTime] += time + + report[:allBrowsers] << browser + report[:totalSessions] += 1 + end + + result[:dates].sort! { |d1, d2| d2 <=> d1 } + result.merge!( + totalTime: "#{result[:totalTime]} min.", + longestSession: "#{result[:longestSession]} min.", + browsers: result[:browsers].sort!.join(', ') + ) 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 + report[:uniqueBrowsersCount] = report[:allBrowsers].size # Подсчёт количества уникальных браузеров + report[:allBrowsers] = report[:allBrowsers].sort.join(',') - # Всегда использовал только Chrome? - collect_stats_from_users(report, users_objects) do |user| - { 'alwaysUsedChrome' => user.sessions.map{|s| s['browser']}.all? { |b| b.upcase =~ /CHROME/ } } - end + File.write('result.json', "#{report.to_json}\n") +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 } } +module MemoryMeasure + def self.call + `ps -o rss= -p #{Process.pid}`.to_i end - - File.write('result.json', "#{report.to_json}\n") end class TestMe < Minitest::Test @@ -169,8 +172,29 @@ 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 end + +if ENV['MEASURE'] + puts '=' * 20 + before_mem = after_mem = profiling_result = nil + time = Benchmark.realtime do + before_mem = MemoryMeasure.call + profiling_result = RubyProf.profile { work(ENV['MEASURE']) } + after_mem = MemoryMeasure.call + end + + puts "Time taken: #{time.round(2)}" + + mem_diff = after_mem - before_mem + puts "RSS diff #{mem_diff} KB" + puts '=' * 20 + + postfix = RubyProf.measure_mode == RubyProf::MEMORY ? 'memory' : 'time' + printer = RubyProf::CallTreePrinter.new(profiling_result) + printer.print(path: '.', profile: "profile_#{postfix}") +end