From 5f0eb2c7f2eb1961370f0adcade9121e51bd25b8 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Thu, 30 Jan 2025 20:41:14 +0400 Subject: [PATCH 01/11] small refactoring --- case-study.md | 0 data.txt => data/data.txt | 0 data_large.txt.gz => data/data_large.txt.gz | Bin task-2.rb | 37 ++------------------ tests/task-2_test.rb | 34 ++++++++++++++++++ 5 files changed, 36 insertions(+), 35 deletions(-) create mode 100644 case-study.md rename data.txt => data/data.txt (100%) rename data_large.txt.gz => data/data_large.txt.gz (100%) create mode 100644 tests/task-2_test.rb diff --git a/case-study.md b/case-study.md new file mode 100644 index 00000000..e69de29b diff --git a/data.txt b/data/data.txt similarity index 100% rename from data.txt rename to data/data.txt diff --git a/data_large.txt.gz b/data/data_large.txt.gz similarity index 100% rename from data_large.txt.gz rename to data/data_large.txt.gz diff --git a/task-2.rb b/task-2.rb index 34e09a3c..5813e410 100644 --- a/task-2.rb +++ b/task-2.rb @@ -3,7 +3,6 @@ require 'json' require 'pry' require 'date' -require 'minitest/autorun' class User attr_reader :attributes, :sessions @@ -43,8 +42,8 @@ def collect_stats_from_users(report, users_objects, &block) end end -def work - file_lines = File.read('data.txt').split("\n") +def work(filename) + file_lines = File.read(filename).split("\n") users = [] sessions = [] @@ -143,35 +142,3 @@ def work 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/tests/task-2_test.rb b/tests/task-2_test.rb new file mode 100644 index 00000000..ed72549f --- /dev/null +++ b/tests/task-2_test.rb @@ -0,0 +1,34 @@ +require 'minitest/autorun' +require_relative '../task-2' + +class TestMe < Minitest::Test + def setup + File.write('result.json', '') + File.write('data/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/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 From 55596af6c218da96182e0032e09399f4f9815be3 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Thu, 30 Jan 2025 21:08:00 +0400 Subject: [PATCH 02/11] memory usage monitor --- runner.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 runner.rb diff --git a/runner.rb b/runner.rb new file mode 100644 index 00000000..c7a441c1 --- /dev/null +++ b/runner.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative 'task-2' +require 'benchmark' + +puts format('INITIAL MEMORY USAGE: %d MB', (`ps -o rss= -p #{Process.pid}`.to_i / 1024)) + +monitor_thread = Thread.new do + while true + puts format('MEMORY USAGE: %d MB', (`ps -o rss= -p #{Process.pid}`.to_i / 1024)) + sleep(0.1) + end +end + +work('data/data20000.txt') +monitor_thread.kill + +# puts Benchmark.measure { work('data/data20000.txt') } From db0a70c050207f834feb92d7041f993336665d1c Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Sun, 2 Feb 2025 19:17:39 +0400 Subject: [PATCH 03/11] pre optimization --- case-study.md | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++ runner.rb | 73 ++++++++++++++++++++++++++++++---- task-2.rb | 7 ++-- 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/case-study.md b/case-study.md index e69de29b..3aa96857 100644 --- a/case-study.md +++ b/case-study.md @@ -0,0 +1,108 @@ +# Case-study оптимизации + +## Актуальная проблема +В нашем проекте возникла серьёзная проблема. + +Необходимо было обработать файл с данными, чуть больше ста мегабайт. + +У нас уже была программа на `ruby`, которая умела делать нужную обработку. + +Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. + +Я решил исправить эту проблему, оптимизировав эту программу. + +## Формирование метрики +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: +1) отчет профилировщика должен поменять главную точку роста +2) обработка файла должна ускориться + +## Гарантия корректности работы оптимизированной программы +Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. + +## Feedback-Loop +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за 10-15 секунд + +Вот как я построил `feedback_loop`: +1) взять кол-во данных, время обработки которых не превышает 5 секунд +2) построить отчет профилировщика и найти главную точку роста +3) оптимизировать эту точку +4) запустить тесты +5) проверить отчет профилировщика +6) проверить временные показатели + +## Вникаем в детали системы, чтобы найти главные точки роста +Для того, чтобы найти "точки роста" для оптимизации я воспользовался memory_profiler, ruby-prof, stackprof + +Вот какие проблемы удалось найти и решить + +Для тренировки прошлась по исходному файлу и нашла места, где можно оптимизировать память +Написала ранер, в котором запускается программа и второй поток, который мониторит память и выводит ее в консоль 1 раз в секунду + +Показатели до оптимизации: +``` +INITIAL MEMORY USAGE: 19 MB +MEMORY USAGE: 20 MB +MEMORY USAGE: 199 MB +MEMORY USAGE: 232 MB +``` + +### Ваша находка №1 +1) Объем данных: 20_000, время: 2.859999 +2) Профилировщик: memory_profiler +3) Главная точка роста: ```sessions = sessions + [parse_session(line)] if cols[0] == 'session'```: 1.15 GB из 1.70 GB за всю работу +``` +allocated memory by class +----------------------------------- + 1.65 GB Array +``` +4) ```sessions = sessions + [parse_session(line)]``` -> ```sessions << parse_session(line)``` +5) Метрики профайлера: +``` +allocated memory by file: 551.33 MB +Оптимизированная строка: 800.00 kB +``` +6) Главная точка роста поменялась +7) Время обработки: 2.815428 + +### Ваша находка №2 +1) Объем данных: 20_000, время: 2.815428 +2) Профилировщик: stackprof +3) Главная точка роста: String#split, 32.7%. Из них 40.4% в parse_session +4) Уберем двойное разбиение строки и передадим в метод уже готовый массив +5) Метрики профайлера: String#split уменьшился до 22.9%, кол-во аллокаций 293909 -> 175231, общее кол-во аллокаций: 900013 -> 764381 +6) Главная точка роста частично поменялась (все еще String#split, но уже в другом методе) +7) Время обработки: 2.759818 + +### Ваша находка №3 +1) Объем данных: 20_000, время: 2.754138 +2) Профилировщик: ruby-prof +3) Главная точка роста по аллокациям: +``` +Allocations: 61.14% (61.14%) Object#collect_stats_from_users -> 24.40% (73.45%) #parse -> 4.44% Regexp#match +Memory: 65.74% (65.74%) Object#collect_stats_from_users -> 22.94% (70.95%) #parse -> 5.26% Regexp#match +``` +4) Так как дата приходит в уже правильном формате, то просто уберем парсинг даты +5) Главная точка роста поменялась +6) Время обработки: 2.712649 + +Показатели после оптимизации: +``` +INITIAL MEMORY USAGE: 22 MB +MEMORY USAGE: 23 MB +MEMORY USAGE: 80 MB +MEMORY USAGE: 116 MB +FINAL MEMORY USAGE: 116 MB +``` + +### На этом закончим оптимизацию полного файла, перепишем на потоковую обработку + + + +## Результаты +В результате проделанной оптимизации наконец удалось обработать файл с данными. +Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* и уложиться в заданный бюджет. + +*Какими ещё результами можете поделиться* + +## Защита от регрессии производительности +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали* diff --git a/runner.rb b/runner.rb index c7a441c1..fc02a214 100644 --- a/runner.rb +++ b/runner.rb @@ -2,17 +2,74 @@ require_relative 'task-2' require 'benchmark' +require 'memory_profiler' +require 'stackprof' +require 'ruby-prof' -puts format('INITIAL MEMORY USAGE: %d MB', (`ps -o rss= -p #{Process.pid}`.to_i / 1024)) +size, mode, profiler = ARGV +FILENAME = "data/data#{size}.txt" -monitor_thread = Thread.new do - while true - puts format('MEMORY USAGE: %d MB', (`ps -o rss= -p #{Process.pid}`.to_i / 1024)) - sleep(0.1) +def memory_usage + (`ps -o rss= -p #{Process.pid}`.to_i / 1024) +end + +def run_stackprof + StackProf.run(mode: :object, out: 'stackprof_reports/stackprof.dump', raw: true) do + work(FILENAME) + end +end + +def run_memory_profiler + report = MemoryProfiler.report do + work(FILENAME) end + + report.pretty_print(scale_bytes: true) end -work('data/data20000.txt') -monitor_thread.kill +def run_ruby_prof(measure_mode) + result = RubyProf::Profile.profile(track_allocations: true, measure_mode: measure_mode) do + work(FILENAME) + end + + printer = RubyProf::GraphHtmlPrinter.new(result) + printer.print(File.open("ruby_prof_reports/graph_#{measure_mode}.html", 'w+'), :min_percent=>0) + + printer = RubyProf::CallStackPrinter.new(result) + printer.print(File.open("ruby_prof_reports/callstack_#{measure_mode}.html", 'w+')) +end + +def run_profiler(profiler) + case profiler + when 'stackprof' + run_stackprof + when 'memory_profiler' + run_memory_profiler + when 'ruby-prof-memory' + run_ruby_prof(:memory) + when 'ruby-prof-allocations' + run_ruby_prof(:allocations) + else + puts "Unknown profiler type: #{profiler}" + end +end + +def run_memory_monitor + monitor_thread = Thread.new do + while true + puts format('MEMORY USAGE: %d MB', memory_usage) + sleep(1) + end + ensure + puts format('FINAL MEMORY USAGE: %d MB', memory_usage) + end + + work(FILENAME) + monitor_thread.kill +end + +run_memory_monitor if mode == 'memory' + +puts Benchmark.measure { work(FILENAME) } if mode == 'time' -# puts Benchmark.measure { work('data/data20000.txt') } +run_profiler(profiler) if mode == 'profile' diff --git a/task-2.rb b/task-2.rb index 5813e410..310a44a0 100644 --- a/task-2.rb +++ b/task-2.rb @@ -23,8 +23,7 @@ def parse_user(user) } end -def parse_session(session) - fields = session.split(',') +def parse_session(fields) parsed_result = { 'user_id' => fields[1], 'session_id' => fields[2], @@ -51,7 +50,7 @@ def work(filename) 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' + sessions << parse_session(cols) if cols[0] == 'session' end # Отчёт в json @@ -136,7 +135,7 @@ def work(filename) # Даты сессий через запятую в обратном порядке в формате 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 } } + { 'dates' => user.sessions.map{|s| s['date']}.sort.reverse } end File.write('result.json', "#{report.to_json}\n") From 0722c9af56839bc47694e56851bc32a3b8f82622 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Thu, 6 Feb 2025 11:54:36 +0400 Subject: [PATCH 04/11] working flow --- runner.rb | 4 +- task-2-flow.rb | 89 ++++++++++++++++++++++++++++++++++++++++++++ tests/task-2_test.rb | 2 +- user.rb | 49 ++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 task-2-flow.rb create mode 100644 user.rb diff --git a/runner.rb b/runner.rb index fc02a214..fe637b1f 100644 --- a/runner.rb +++ b/runner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative 'task-2' +require_relative 'task-2-flow' require 'benchmark' require 'memory_profiler' require 'stackprof' @@ -55,6 +55,8 @@ def run_profiler(profiler) end def run_memory_monitor + puts format('INITIAL MEMORY USAGE: %d MB', memory_usage) + monitor_thread = Thread.new do while true puts format('MEMORY USAGE: %d MB', memory_usage) diff --git a/task-2-flow.rb b/task-2-flow.rb new file mode 100644 index 00000000..081e454e --- /dev/null +++ b/task-2-flow.rb @@ -0,0 +1,89 @@ +require_relative 'user' +require 'json' +require 'oj' + +class SessionsProcessor + LINE_SEPARATOR = ','.freeze + USER_TYPE = 'user'.freeze + SESSION_TYPE = 'session'.freeze + + attr_reader :total_users, :total_browsers, :total_sessions + + def initialize(filename) + @filename = filename + @total_users = 0 + @total_browsers = Set.new + @total_sessions = 0 + end + + def process + stream.push_object + stream.push_key('usersStats') + stream.push_object + + File.open(@filename).each do |line| + process_line(line) + end + + update_totals + save_user if user + + stream.pop + totals.each { |key, value| stream.push_value(value, key.to_s) } + stream.pop_all + io.close + end + + private + + attr_reader :user + + def process_line(line) + type, *info = line.strip.split(LINE_SEPARATOR) + + if type == USER_TYPE + update_totals + save_user if user + @user = User.new(*info) + end + + user.update_sessions_stats(*info) if type == SESSION_TYPE + end + + def update_totals + return unless user + + @total_users += 1 + @total_browsers.merge(user.browsers) + @total_sessions += user.sessions_count + end + + def totals + { + totalUsers: total_users, + uniqueBrowsersCount: total_browsers.size, + totalSessions: total_sessions, + allBrowsers: total_browsers.to_a.sort.join(',') + } + end + + def stream + @stream ||= Oj::StreamWriter.new(io) + end + + def io + @io ||= File.open('result.json', 'w') + end + + def save_user + stream.push_key("#{user.first_name} #{user.last_name}") + stream.push_object + user.as_json.each { |key, value| stream.push_value(value, key.to_s) } + stream.pop + end +end + +def work(filename) + stats = SessionsProcessor.new(filename) + stats.process +end diff --git a/tests/task-2_test.rb b/tests/task-2_test.rb index ed72549f..0b525d71 100644 --- a/tests/task-2_test.rb +++ b/tests/task-2_test.rb @@ -1,5 +1,5 @@ require 'minitest/autorun' -require_relative '../task-2' +require_relative '../task-2-flow' class TestMe < Minitest::Test def setup diff --git a/user.rb b/user.rb new file mode 100644 index 00000000..faf3d2e4 --- /dev/null +++ b/user.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class User + attr_reader :browsers, :sessions_count, :first_name, :last_name + + def initialize(id, first_name, last_name, age) + @id = id + @first_name = first_name + @last_name = last_name + @age = age + init_session_stats + end + + def update_sessions_stats(user_id, _session_id, browser, time, date) + return unless user_id == id + + time = time.to_i + + @total_time += time + @longest_session = time if time > @longest_session + @sessions_count += 1 + @browsers << browser.upcase + @dates << date + end + + def as_json + { + sessionsCount: sessions_count, + totalTime: "#{total_time} min.", + longestSession: "#{longest_session} min.", + browsers: browsers.sort.to_a.join(', '), + usedIE: browsers.any? { |browser| browser.include?('INTERNET EXPLORER') }, + alwaysUsedChrome: browsers.all? { |browser| browser.include?('CHROME') }, + dates: @dates.sort.reverse + } + end + + private + + attr_reader :id, :age, :total_time, :longest_session + + def init_session_stats + @total_time = 0 + @longest_session = 0 + @sessions_count = 0 + @browsers = [] + @dates = [] + end +end \ No newline at end of file From 018c7ee0bb0b8ebcd9aa0b67469284307dc9de87 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Thu, 6 Feb 2025 20:29:07 +0400 Subject: [PATCH 05/11] step 1 --- case-study.md | 43 +++++++++++++++++++++++++++++++++++++++++++ task-2-flow.rb | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/case-study.md b/case-study.md index 3aa96857..418b1feb 100644 --- a/case-study.md +++ b/case-study.md @@ -96,7 +96,50 @@ FINAL MEMORY USAGE: 116 MB ### На этом закончим оптимизацию полного файла, перепишем на потоковую обработку +После перевода программы на потоковый подход потребление памяти на большом файле не превышает 20Мб и время сократилось до ~6-7 секунд +``` +INITIAL MEMORY USAGE: 18 MB +MEMORY USAGE: 18 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +FINAL MEMORY USAGE: 19 MB +``` + +## Продолжим вникать в детали системы, чтобы найти главные точки роста + +### Ваша находка №1 +1) отчет memory_profiler: +основная точка роста ```12.05 MB: type, *info = line.strip.split(LINE_SEPARATOR)``` +2) оптимизация: заменим strip на strip! +3) ```10.45 MB: type, *info = line.strip!.split(LINE_SEPARATOR)``` +4) метрика изменилась, но главная точка роста все еще та же + +### Ваша находка №2 +1) отчет memory_profiler: + основная точка роста ```10.45 MB: type, *info = line.strip.split(LINE_SEPARATOR)``` +2) заметим, что строка session аллоцируется 16_954 из 20_000 - на каждой строке + +### Ваша находка №2 +- какой отчёт показал главную точку роста +- как вы решили её оптимизировать +- как изменилась метрика +- как изменился отчёт профилировщика +### Ваша находка №2 +- какой отчёт показал главную точку роста +- как вы решили её оптимизировать +- как изменилась метрика +- как изменился отчёт профилировщика + +### Ваша находка №2 +- какой отчёт показал главную точку роста +- как вы решили её оптимизировать +- как изменилась метрика +- как изменился отчёт профилировщика ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. diff --git a/task-2-flow.rb b/task-2-flow.rb index 081e454e..39a9d876 100644 --- a/task-2-flow.rb +++ b/task-2-flow.rb @@ -39,7 +39,7 @@ def process attr_reader :user def process_line(line) - type, *info = line.strip.split(LINE_SEPARATOR) + type, *info = line.strip!.split(LINE_SEPARATOR) if type == USER_TYPE update_totals From f181418eb2553c3fb3ea44fec1fcf71e262099b4 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Sat, 8 Feb 2025 13:31:35 +0400 Subject: [PATCH 06/11] report --- case-study.md | 42 +++++----- runner.rb | 11 +-- task-2-deoptimized.rb | 143 ++++++++++++++++++++++++++++++++++ task-2-flow.rb | 89 --------------------- task-2.rb | 175 +++++++++++++++--------------------------- tests/task-2_spec.rb | 20 +++++ tests/task-2_test.rb | 3 +- user.rb | 2 +- 8 files changed, 253 insertions(+), 232 deletions(-) create mode 100644 task-2-deoptimized.rb delete mode 100644 task-2-flow.rb create mode 100644 tests/task-2_spec.rb diff --git a/case-study.md b/case-study.md index 418b1feb..f2040985 100644 --- a/case-study.md +++ b/case-study.md @@ -38,7 +38,7 @@ Для тренировки прошлась по исходному файлу и нашла места, где можно оптимизировать память Написала ранер, в котором запускается программа и второй поток, который мониторит память и выводит ее в консоль 1 раз в секунду -Показатели до оптимизации: +Показатели до оптимизации 20_000: ``` INITIAL MEMORY USAGE: 19 MB MEMORY USAGE: 20 MB @@ -85,7 +85,7 @@ Memory: 65.74% (65.74%) Object#collect_stats_from_users -> 22.94% (70.95%) fields[1], + 'first_name' => fields[2], + 'last_name' => fields[3], + 'age' => fields[4], + } +end + +def parse_session(fields) + 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(filename) + file_lines = File.read(filename).split("\n") + + users = [] + sessions = [] + + file_lines.each do |line| + cols = line.split(',') + users = users + [parse_user(line)] if cols[0] == 'user' + sessions << parse_session(cols) 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']}.sort.reverse } + end + + File.write('result.json', "#{report.to_json}\n") + puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) +end diff --git a/task-2-flow.rb b/task-2-flow.rb deleted file mode 100644 index 39a9d876..00000000 --- a/task-2-flow.rb +++ /dev/null @@ -1,89 +0,0 @@ -require_relative 'user' -require 'json' -require 'oj' - -class SessionsProcessor - LINE_SEPARATOR = ','.freeze - USER_TYPE = 'user'.freeze - SESSION_TYPE = 'session'.freeze - - attr_reader :total_users, :total_browsers, :total_sessions - - def initialize(filename) - @filename = filename - @total_users = 0 - @total_browsers = Set.new - @total_sessions = 0 - end - - def process - stream.push_object - stream.push_key('usersStats') - stream.push_object - - File.open(@filename).each do |line| - process_line(line) - end - - update_totals - save_user if user - - stream.pop - totals.each { |key, value| stream.push_value(value, key.to_s) } - stream.pop_all - io.close - end - - private - - attr_reader :user - - def process_line(line) - type, *info = line.strip!.split(LINE_SEPARATOR) - - if type == USER_TYPE - update_totals - save_user if user - @user = User.new(*info) - end - - user.update_sessions_stats(*info) if type == SESSION_TYPE - end - - def update_totals - return unless user - - @total_users += 1 - @total_browsers.merge(user.browsers) - @total_sessions += user.sessions_count - end - - def totals - { - totalUsers: total_users, - uniqueBrowsersCount: total_browsers.size, - totalSessions: total_sessions, - allBrowsers: total_browsers.to_a.sort.join(',') - } - end - - def stream - @stream ||= Oj::StreamWriter.new(io) - end - - def io - @io ||= File.open('result.json', 'w') - end - - def save_user - stream.push_key("#{user.first_name} #{user.last_name}") - stream.push_object - user.as_json.each { |key, value| stream.push_value(value, key.to_s) } - stream.pop - end -end - -def work(filename) - stats = SessionsProcessor.new(filename) - stats.process -end diff --git a/task-2.rb b/task-2.rb index 310a44a0..95f38d82 100644 --- a/task-2.rb +++ b/task-2.rb @@ -1,143 +1,90 @@ -# Deoptimized version of homework task +# frozen_string_literal: true -require 'json' -require 'pry' -require 'date' +require_relative 'user' +require 'oj' -class User - attr_reader :attributes, :sessions +class SessionsProcessor + LINE_SEPARATOR = ','.freeze + USER_TYPE = 'user'.freeze + SESSION_TYPE = 'session'.freeze - 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 + attr_reader :total_users, :total_browsers, :total_sessions -def parse_session(fields) - 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)) + def initialize(filename) + @filename = filename + @total_users = 0 + @total_browsers = Set.new + @total_sessions = 0 end -end -def work(filename) - file_lines = File.read(filename).split("\n") + def process + stream.push_object + stream.push_key('usersStats') + stream.push_object - users = [] - sessions = [] + File.open(@filename).each do |line| + process_line(line) + end - file_lines.each do |line| - cols = line.split(',') - users = users + [parse_user(line)] if cols[0] == 'user' - sessions << parse_session(cols) if cols[0] == 'session' - end + update_totals + save_user if user - # Отчёт в json - # - Сколько всего юзеров + - # - Сколько всего уникальных браузеров + - # - Сколько всего сессий + - # - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом + - # - # - По каждому пользователю - # - сколько всего сессий + - # - сколько всего времени + - # - самая длинная сессия + - # - браузеры через запятую + - # - Хоть раз использовал IE? + - # - Всегда использовал только Хром? + - # - даты сессий в порядке убывания через запятую + - - report = {} - - report[:totalUsers] = users.count - - # Подсчёт количества уникальных браузеров - uniqueBrowsers = [] - sessions.each do |session| - browser = session['browser'] - uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser } + stream.pop + totals.each { |key, value| stream.push_value(value, key.to_s) } + stream.pop_all + io.close end - report['uniqueBrowsersCount'] = uniqueBrowsers.count + private - report['totalSessions'] = sessions.count + attr_reader :user - report['allBrowsers'] = - sessions - .map { |s| s['browser'] } - .map { |b| b.upcase } - .sort - .uniq - .join(',') + def process_line(line) + type, *info = line.strip!.split(LINE_SEPARATOR) - # Статистика по пользователям - users_objects = [] + if type == USER_TYPE + update_totals + save_user if user + @user = User.new(*info) + end - 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] + user.update_sessions_stats(*info) if type == SESSION_TYPE end - report['usersStats'] = {} - - # Собираем количество сессий по пользователям - collect_stats_from_users(report, users_objects) do |user| - { 'sessionsCount' => user.sessions.count } - end + def update_totals + return unless user - # Собираем количество времени по пользователям - 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.' } + @total_users += 1 + @total_browsers.merge(user.browsers) + @total_sessions += user.sessions_count 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.' } + def totals + { + totalUsers: total_users, + uniqueBrowsersCount: total_browsers.size, + totalSessions: total_sessions, + allBrowsers: total_browsers.to_a.sort.join(',') + } end - # Браузеры пользователя через запятую - collect_stats_from_users(report, users_objects) do |user| - { 'browsers' => user.sessions.map {|s| s['browser']}.map {|b| b.upcase}.sort.join(', ') } + def stream + @stream ||= Oj::StreamWriter.new(io) end - # Хоть раз использовал IE? - collect_stats_from_users(report, users_objects) do |user| - { 'usedIE' => user.sessions.map{|s| s['browser']}.any? { |b| b.upcase =~ /INTERNET EXPLORER/ } } + def io + @io ||= File.open('result.json', 'w') 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']}.sort.reverse } + def save_user + stream.push_key("#{user.first_name} #{user.last_name}") + stream.push_object + user.sessions_stats.each { |key, value| stream.push_value(value, key.to_s) } + stream.pop end +end - File.write('result.json', "#{report.to_json}\n") - puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) +def work(filename) + stats = SessionsProcessor.new(filename) + stats.process end diff --git a/tests/task-2_spec.rb b/tests/task-2_spec.rb new file mode 100644 index 00000000..69ee967f --- /dev/null +++ b/tests/task-2_spec.rb @@ -0,0 +1,20 @@ +require 'rspec-benchmark' +require_relative '../task-2' + +RSpec.configure do |config| + config.include RSpec::Benchmark::Matchers +end + +describe 'Performance' do + it 'uses under 20MB of memory' do + expect do + work('data/data.txt') + end.to perform_allocation(41_000).bytes + end + + it 'performs 20_000 under 0.05s' do + expect do + work('data/data20000.txt') + end.to perform_under(50).ms.warmup(2).times.sample(5).times + end +end diff --git a/tests/task-2_test.rb b/tests/task-2_test.rb index 0b525d71..40551d5e 100644 --- a/tests/task-2_test.rb +++ b/tests/task-2_test.rb @@ -1,5 +1,6 @@ require 'minitest/autorun' -require_relative '../task-2-flow' +require 'json' +require_relative '../task-2' class TestMe < Minitest::Test def setup diff --git a/user.rb b/user.rb index faf3d2e4..8335c692 100644 --- a/user.rb +++ b/user.rb @@ -23,7 +23,7 @@ def update_sessions_stats(user_id, _session_id, browser, time, date) @dates << date end - def as_json + def sessions_stats { sessionsCount: sessions_count, totalTime: "#{total_time} min.", From 68575d9feb9c42b111356412b8c887dc0e2a2491 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Sat, 8 Feb 2025 13:34:01 +0400 Subject: [PATCH 07/11] memory usage --- memory_usage.txt | 9 +++++++++ runner.rb | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 memory_usage.txt diff --git a/memory_usage.txt b/memory_usage.txt new file mode 100644 index 00000000..ad8b2255 --- /dev/null +++ b/memory_usage.txt @@ -0,0 +1,9 @@ +INITIAL MEMORY USAGE: 18 MB +MEMORY USAGE: 18 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +MEMORY USAGE: 19 MB +FINAL MEMORY USAGE: 19 MB diff --git a/runner.rb b/runner.rb index f0ba5755..b98f89eb 100644 --- a/runner.rb +++ b/runner.rb @@ -56,19 +56,19 @@ def run_profiler(profiler) def run_memory_monitor io = File.open('memory_usage.txt', 'w') - io << format('INITIAL MEMORY USAGE: %d MB', memory_usage) + io << format("INITIAL MEMORY USAGE: %d MB\n", memory_usage) monitor_thread = Thread.new do while true - io << format('MEMORY USAGE: %d MB', memory_usage) + io << format("MEMORY USAGE: %d MB\n", memory_usage) sleep(1) end ensure - io << format('FINAL MEMORY USAGE: %d MB', memory_usage) + io << format("FINAL MEMORY USAGE: %d MB\n", memory_usage) + io.close end work(FILENAME) monitor_thread.kill - io.close end run_memory_monitor if mode == 'memory' From 170c2a8e2aef9c0dda9e62b2601b66f4cccb9fd7 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Sat, 8 Feb 2025 13:37:11 +0400 Subject: [PATCH 08/11] fix report --- case-study.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/case-study.md b/case-study.md index f2040985..1cf6666a 100644 --- a/case-study.md +++ b/case-study.md @@ -132,7 +132,7 @@ FINAL MEMORY USAGE: 19 MB В результате проделанной оптимизации наконец удалось обработать файл с данными. Удалось улучшить метрику системы с 232 MB для 20_000 строк до 19 MB для любого кол-ва данных и уложиться в заданный бюджет. -Но, если взять граничное значение в виде один юзер и все сессии (кол-во сессий как в файле large), то память конечно раздувается из-за того что происходит накопление не уникальных браузеров / дат, и из-за этого время ухудшается до ~8 секунд +Но, если взять граничное значение в виде один юзер и все сессии (кол-во сессий как в файле large), то память конечно раздувается, вероятно из-за того что происходит накопление не уникальных браузеров / дат, и из-за этого время ухудшается до ~8 секунд ``` INITIAL MEMORY USAGE: 18 MB MEMORY USAGE: 18 MB From 5499596a19ea59788f9af6f75e06ad51cfe171d1 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Sat, 8 Feb 2025 14:15:04 +0400 Subject: [PATCH 09/11] fix report --- case-study.md | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/case-study.md b/case-study.md index 1cf6666a..49eeb2b2 100644 --- a/case-study.md +++ b/case-study.md @@ -9,12 +9,12 @@ Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. -Я решил исправить эту проблему, оптимизировав эту программу. +Я решила исправить эту проблему, оптимизировав эту программу. ## Формирование метрики -Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику на каждой итерации оптимизации: 1) отчет профилировщика должен поменять главную точку роста -2) обработка файла должна ускориться +2) обработка файла должна ускориться / потребление памяти должно уменьшиться ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. @@ -31,14 +31,14 @@ 6) проверить временные показатели ## Вникаем в детали системы, чтобы найти главные точки роста -Для того, чтобы найти "точки роста" для оптимизации я воспользовался memory_profiler, ruby-prof, stackprof +Для того, чтобы найти "точки роста" для оптимизации я воспользовалась memory_profiler, ruby-prof, stackprof Вот какие проблемы удалось найти и решить Для тренировки прошлась по исходному файлу и нашла места, где можно оптимизировать память -Написала ранер, в котором запускается программа и второй поток, который мониторит память и выводит ее в консоль 1 раз в секунду +Написала runner, в котором запускается программа и второй поток, который мониторит потребляемую память и записывает ее в файл 1 раз в секунду -Показатели до оптимизации 20_000: +Показатели до оптимизации для 20_000 строк: ``` INITIAL MEMORY USAGE: 19 MB MEMORY USAGE: 20 MB @@ -85,7 +85,7 @@ Memory: 65.74% (65.74%) Object#collect_stats_from_users -> 22.94% (70.95%) Date: Sat, 8 Feb 2025 14:18:07 +0400 Subject: [PATCH 10/11] add new line --- user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user.rb b/user.rb index 8335c692..3461ce4c 100644 --- a/user.rb +++ b/user.rb @@ -46,4 +46,4 @@ def init_session_stats @browsers = [] @dates = [] end -end \ No newline at end of file +end From 010a3f001b4fa92e8df833b6303ad8c9acbea041 Mon Sep 17 00:00:00 2001 From: elenachekhina Date: Sat, 8 Feb 2025 16:32:39 +0400 Subject: [PATCH 11/11] report fix --- case-study.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/case-study.md b/case-study.md index 49eeb2b2..c5a4152c 100644 --- a/case-study.md +++ b/case-study.md @@ -16,6 +16,8 @@ 1) отчет профилировщика должен поменять главную точку роста 2) обработка файла должна ускориться / потребление памяти должно уменьшиться +Итоговая метрика: программа должна обрабатывать файл и не потреблять больше 70 MB памяти + ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации.