diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..be5041fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +data +data_large.txt +.ruby-version +reports +Gemfile.lock \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..298f0552 --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +ruby '3.3.6' + +source 'https://rubygems.org' +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +gem 'oj' +gem 'memory_profiler' +gem 'ruby-prof' +gem 'stackprof' +gem 'rspec-benchmark' diff --git a/bench_wrapper.rb b/bench_wrapper.rb new file mode 100644 index 00000000..c4ba7494 --- /dev/null +++ b/bench_wrapper.rb @@ -0,0 +1,42 @@ +# Measure ruby code performance +# Usage: +# require 'bench_wrapper' +# measure do +# code to measure +# end + +require "json" +require "benchmark" + +def measure(&block) + no_gc = (ARGV[0] == "--no-gc") + + if no_gc + GC.disable + else + GC.start + end + + memory_before = `ps -o rss= -p #{Process.pid}`.to_i/1024 + puts "Memory BEFORE: #{memory_before}" + gc_stat_before = GC.stat + time = Benchmark.realtime do + yield + end + # puts ObjectSpace.count_objects + # unless no_gc + # GC.start(full_mark: true, immediate_sweep: true, immediate_mark: false) + # end + # puts ObjectSpace.count_objects + memory_after = `ps -o rss= -p #{Process.pid}`.to_i/1024 + gc_stat_after = GC.stat + + puts({ + RUBY_VERSION => { + gc: no_gc ? 'disabled' : 'enabled', + time: time.round(2), + gc_count: gc_stat_after[:count] - gc_stat_before[:count], + memory: "%d MB" % (memory_after - memory_before) + } + }.to_json) +end diff --git a/benchmark.rb b/benchmark.rb new file mode 100644 index 00000000..45356325 --- /dev/null +++ b/benchmark.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative 'memory_reporter' +require_relative 'bench_wrapper' +require_relative 'task-2' + +# path = "data/data#{ARGV[0] || 50000}.txt" +path = 'data_large.txt' +report_memory = ARGV[0] == '--report-memory' + +if report_memory + reporter = MemoryReporter.new + + reporter.start + work(path) +else + measure do + work(path) + end +end diff --git a/case-study-template.md b/case-study-template.md index c3279664..62c8f5e6 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -12,44 +12,110 @@ Я решил исправить эту проблему, оптимизировав эту программу. ## Формирование метрики -Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика* +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: Программа не должна потреблять больше 70Мб памяти при обработке файла data_large.txt в течение всей своей работы. ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. ## Feedback-Loop -Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* - -Вот как я построил `feedback_loop`: *как вы построили feedback_loop* +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за время около 10мин, за исключением переписывания логики метода на работу в потоковом стиле. + +Вот как я построил `feedback_loop`: +Создал бенчмарк и ruby файлы под каждый вид профилирования. Как аргумент передаю количество строк. +Вот как я построил `feedback_loop`: +- Запускаю бенчмарк. +- Запускаю профилировщик. +- Определяю главную точку роста. +- Вношу изменения. +- Проверяю гипотезу повторным запуском профилировщика. +- Защищаю изменения тестом. + +Для первоначальной оценки запустил бенчмарк на 50_000 строк: +{"3.3.6":{"gc":"enabled","time":14.96,"gc_count":357,"memory":"269 MB"}} + +Запустил в отдельном треде репорт потребления памяти в динамике: +* [2025-02-12 22:53:50 +0400] Memory usage: 22.00 MB +* [2025-02-12 22:53:52 +0400] Memory usage: 178.00 MB +* [2025-02-12 22:53:54 +0400] Memory usage: 194.00 MB +* [2025-02-12 22:53:56 +0400] Memory usage: 210.00 MB +* [2025-02-12 22:53:57 +0400] Memory usage: 229.00 MB +* [2025-02-12 22:53:59 +0400] Memory usage: 255.00 MB +* [2025-02-12 22:54:01 +0400] Memory usage: 281.00 MB +* [2025-02-12 22:54:03 +0400] Memory usage: 304.00 MB +* [2025-02-12 22:54:05 +0400] Memory usage: 334.00 MB + +Решаю начать поиск точек роста с memory_profiler ## Вникаем в детали системы, чтобы найти главные точки роста -Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались* +Для того, чтобы найти "точки роста" для оптимизации я воспользовался memory_profiler Вот какие проблемы удалось найти и решить ### Ваша находка №1 -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика +- memory_profiler показал главную точку роста в строке 7.16 GB rails-optimization-task2/task-2.rb:55 +- Проблема конкатенации уже знакома по прошлому заданию, решаю ее заменой метода + на << +То же самое проделываю и с предыдущей строкой. +- Метрика уменьшилась на 54 Мб на 50_000 срок +{"3.3.6":{"gc":"enabled","time":14.16,"gc_count":128,"memory":"215 MB"}} +Кратно уменьшилось количество срабатываний gc +- Найденная точка роста перестала ей быть и уже не попадает в отчет ### Ваша находка №2 -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика +- memory_profiler показал главную точку роста в строке +2.60 GB /Users/alex/pets/rails-optimization-task2/task-2.rb:104 +также обратил внимание на большое количество алоцированные пробелов +223085 " " + + 169220 /rails-optimization-task2/task-2.rb:143 + 53865 /rails-optimization-task2/task-2.rb:40 +- Вынес select из итератора, сгруппировав сессии по user_id + Исправил конкатенацию пробела, она там не нужна("#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}"). + + Избавился от Date.parse, он также не нужен. + + Добавил # frozen_string_literal: true +- Количество потребляемой памяти уменьшилось на 160MB на 50_000 строк +{"3.3.6":{"gc":"enabled","time":0.27,"gc_count":30,"memory":"53 MB"}} +- Найденные точки роста престали ими быть + +Смотрю метрики на 400_000 строк: + +{"3.3.6":{"gc":"enabled","time":2.48,"gc_count":51,"memory":"412 MB"}} + +### Ваша находка №3 +- Использую stackprof для поиска очередной точки роста: + + (5876907 (57.5%) 5876907 (57.5%) String#split) -### Ваша находка №X -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика + Главная точка роста split методы в work и parse_session иетодах + Зная что далее всеравно предстоит переписывать код в потоковом стиле, решаю не тратить время на + оптимизацию split метода, потому как далее эта логика поменяется. +- Переписал код в потоковом стиле. Использовал гем Oj + +Так как порядок не важен, сначала запишем usersStats (как объект, куда по мере обработки добавим пары "имя юзера" => stats), а затем добавим остальные ключи. + +- После внедрения новой логики получил метрику на data_large.txt файле в 1MB и выполнил бюджет + + {"3.3.6":{"gc":"enabled","time":3.78,"gc_count":3037,"memory":"1 MB"}} +- 2738453(51.0%) 2738453(51.0%) String#split - остался главной точкой роста + + +### Ваша находка №4 +- Отчет ruby-prof qcachegring показал главную точку роста в String#split(36%) +- Изучил возможности профилирования в ruby-prof qcachegring, но т.к. бюджет в 1MB +меня вполне устравивает, решл завершить оптимизацию. +- Защитил изменетестом, который проверяет потребление памяти на большом файле(менее 70 Mb). ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. -Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* и уложиться в заданный бюджет. +Удалось улучшить метрику системы с оценки в 1.5Gb до 1Mb для большого файла и уложиться в заданный бюджет. + +Наиболее полезным и удобным показалось профилирование потребления памяти с помощью memory_profiler и ruby-prof. + +Гигантскую долю памяти удалось сэкономить преписав программу в потоковом стиле с помощью гема Oj. -*Какими ещё результами можете поделиться* +Кажется для более точной оценки потребления памяти необходимо учитывать память которая была выделена +до начала выполнения профилируемого метода(~20Mb) и вычитать это значение от потребления памяти которое зафмксировали по заверешению работы метода. ## Защита от регрессии производительности -Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали* +Защитил изменетестом, который проверяет потребление памяти на большом файле(менее 70 Mb). diff --git a/data_large.txt.gz b/data_large.txt.gz deleted file mode 100644 index 91c7e45e..00000000 Binary files a/data_large.txt.gz and /dev/null differ diff --git a/memory_reporter.rb b/memory_reporter.rb new file mode 100644 index 00000000..d10c4f61 --- /dev/null +++ b/memory_reporter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class MemoryReporter + DEFAULT_LIMIT_MB = 700 + DEFAULT_INTERVAL = 1 + DEFAULT_LOG_FILE = 'reports/memory_usage.log'.freeze + + class MemoryUsageError < StandardError; end + + def initialize(limit_mb: DEFAULT_LIMIT_MB, log_file: DEFAULT_LOG_FILE) + @limit_mb = limit_mb + @log_file = log_file + end + + def start + @thread = Thread.new do + loop do + mem_usage_mb = current_memory_usage + log_memory_usage(mem_usage_mb) + raise MemoryUsageError, "Memory usage exceeded limit of #{@limit_mb} MB" if mem_usage_mb > @limit_mb + sleep DEFAULT_INTERVAL + rescue => e + puts e.message + Process.kill("KILL", Process.pid) + end + end + @thread.abort_on_exception = true + end + + private + + def current_memory_usage + (`ps -o rss= -p #{Process.pid}`.to_i / 1024) + end + + def log_memory_usage(mem_usage_mb) + File.open(@log_file, 'a') do |f| + f.puts "[#{Time.now}] Memory usage: #{format('%.2f', mem_usage_mb)} MB" + end + end +end diff --git a/profilers/profile.rb b/profilers/profile.rb new file mode 100644 index 00000000..8cdd4ae7 --- /dev/null +++ b/profilers/profile.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'memory_profiler' +require 'ruby-prof' +require 'stackprof' +require_relative '../task-2' + +REPORTS_DIR = 'reports' +FileUtils.mkdir_p(REPORTS_DIR) + +# path = "data/data#{ARGV[0] || 50000}.txt" +path = 'data_large.txt' +mode = ARGV[0] || 'memory_profiler' + +case mode +when 'memory_profiler' + report = MemoryProfiler.report do + work(path) + end + report.pretty_print(scale_bytes: true) + +when 'stackprof' + StackProf.run(mode: :object, out: "#{REPORTS_DIR}/stackprof.dump", raw: true) do + work(path) + end + +when 'ruby-prof' + profile = RubyProf::Profile.new(measure_mode: RubyProf::MEMORY) + + result = profile.profile do + work(path) + end + printer = RubyProf::CallTreePrinter.new(result) + printer.print(path: REPORTS_DIR, profile: 'callgrind') + +else + puts "Invalid mode: #{mode}. Use 'flat', 'graph', 'callstack', 'stackprof' or 'callgrind'." + exit 1 +end diff --git a/result.json b/result.json new file mode 100644 index 00000000..f308b287 --- /dev/null +++ b/result.json @@ -0,0 +1 @@ +{"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"]}},"totalUsers":3,"totalSessions":15,"uniqueBrowsersCount":14,"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"} diff --git a/rspec/memory_usage_spec.rb b/rspec/memory_usage_spec.rb new file mode 100644 index 00000000..91e15c14 --- /dev/null +++ b/rspec/memory_usage_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rspec-benchmark' +require_relative '../task-2' + +RSpec.describe 'Memory usage' do + include RSpec::Benchmark::Matchers + + before do + File.write('result.json', '') + end + + it 'consumes no more than 70 MB of memory' do + expect { work('data_large.txt', 'result.json') } + .to perform_allocation(70_000_000).bytes + end +end diff --git a/task-2.rb b/task-2.rb index 34e09a3c..82b78257 100644 --- a/task-2.rb +++ b/task-2.rb @@ -1,149 +1,127 @@ +# frozen_string_literal: true # Deoptimized version of homework task require 'json' require 'pry' require 'date' +require 'oj' 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)) +def work(path = 'data.txt', output_path = 'result.json') + total_users = 0 + total_sessions = 0 + unique_browsers = {} + + current_user = nil + + File.open(output_path, 'w') do |f| + writer = Oj::StreamWriter.new(f) + writer.push_object + writer.push_key("usersStats") + writer.push_object + + flush_current_user = lambda do + next unless current_user + + full_name = "#{current_user[:first_name]} #{current_user[:last_name]}".strip + user_obj = { + 'sessionsCount' => current_user[:sessionsCount], + 'totalTime' => "#{current_user[:totalTime]} min.", + 'longestSession' => "#{current_user[:longestSession]} min.", + 'browsers' => current_user[:browsers].sort.join(', '), + 'usedIE' => current_user[:usedIE], + 'alwaysUsedChrome' => current_user[:alwaysUsedChrome], + 'dates' => current_user[:dates].sort.reverse + } + + writer.push_key(full_name) + writer.push_value(user_obj) + end + + File.foreach(path) do |line| + line.strip! + next if line.empty? + + parts = line.split(',') + case parts[0] + when 'user' + flush_current_user.call if current_user + + # user,ID,first_name,last_name,age + total_users += 1 + user_id = parts[1] + first_name = parts[2] + last_name = parts[3] + current_user = { + id: user_id, + first_name: first_name, + last_name: last_name, + sessionsCount: 0, + totalTime: 0, + longestSession: 0, + browsers: [], + usedIE: false, + alwaysUsedChrome: true, + dates: [] + } + when 'session' + total_sessions += 1 + session_user_id = parts[1] + + if current_user.nil? || current_user[:id] != session_user_id + flush_current_user.call if current_user + # Empty user statistics + current_user = { + id: session_user_id, + first_name: "", + last_name: "", + sessionsCount: 0, + totalTime: 0, + longestSession: 0, + browsers: [], + usedIE: false, + alwaysUsedChrome: true, + dates: [] + } + end + + browser = parts[3].strip + browser_up = browser.upcase + unique_browsers[browser_up] = true + time = parts[4].to_i + date = parts[5].strip + + current_user[:sessionsCount] += 1 + current_user[:totalTime] += time + current_user[:longestSession] = time if time > current_user[:longestSession] + current_user[:browsers] << browser_up + current_user[:dates] << date + current_user[:usedIE] ||= browser_up.include?("INTERNET EXPLORER") + current_user[:alwaysUsedChrome] &&= browser_up.include?("CHROME") + end + end + + flush_current_user.call if current_user + writer.pop + + writer.push_key("totalUsers") + writer.push_value(total_users) + + writer.push_key("totalSessions") + writer.push_value(total_sessions) + + writer.push_key("uniqueBrowsersCount") + writer.push_value(unique_browsers.keys.size) + + all_browsers = unique_browsers.keys.sort.join(',') + + writer.push_key("allBrowsers") + writer.push_value(all_browsers) + + writer.pop 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") - 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', '')