diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..2ad6dc6e --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'stackprof' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..fc148242 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,14 @@ +GEM + remote: https://rubygems.org/ + specs: + stackprof (0.2.27) + +PLATFORMS + ruby + x86_64-darwin-21 + +DEPENDENCIES + stackprof + +BUNDLED WITH + 2.5.16 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..cfe51214 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: all_reports +all_reports: + ruby benchmark.rb + ruby rubyprof_allocation.rb + ruby rubyprof_memory.rb + ruby memory_profiler.rb + + + + diff --git a/benchmark.rb b/benchmark.rb new file mode 100644 index 00000000..fbf97762 --- /dev/null +++ b/benchmark.rb @@ -0,0 +1,5 @@ +require_relative 'work' +require 'benchmark' + +puts "SIZE #{ENV['SIZE']}" +puts Benchmark.realtime { work("data/data#{ENV['SIZE']}.txt", disable_gc: ENV['GB'] || false) } diff --git a/case-study.md b/case-study.md new file mode 100644 index 00000000..06dc35c2 --- /dev/null +++ b/case-study.md @@ -0,0 +1,234 @@ +# Case-study оптимизации + +## Актуальная проблема +В нашем проекте возникла серьёзная проблема. + +Необходимо было обработать файл с данными, чуть больше ста мегабайт. + +У нас уже была программа на `ruby`, которая умела делать нужную обработку. + +Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. + +Я решил исправить эту проблему, оптимизировав эту программу. + +## Формирование метрики +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: программа должна потреблять меньше 70Мб памяти при обработке файла data_large.txt + +Предварительно на +- 10_000 строк из файла используется MEMORY USAGE: без GB 360 MB(c GB 82 MB) скороть работы 1.765 сек +- 20_000 строк из файла используется MEMORY USAGE: без GB 1292 MB(c GB 92) скороть работы 7.57 сек + +Видно что с увеличеине объема данных в 2 раза, память и время увеличивается без GB (1292 / 360 ≈ 3.59) почти в 4 раза это почти квадратичная сложность O(n^2) +C GB ситуация лучше, но надо понимать что это большая нагрузка на постоянную очистку. +## Гарантия корректности работы оптимизированной программы +Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. + +Так же добавился тест чтобы проверить используемую память и отслеживать изменения + +## Feedback-Loop +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* + +Вот как я построил `feedback_loop`: +1) поделил входные данные на по 10_000, 20_000 и тд +2) - для быстрого сбора всех отчетов `SIZE=10000 make all_reports` + - подобрал данные по которым видны точки роста + - провел анализ всех отчтетов и поредедлил точку роста + - внес правки + - запустил все отчтеты + - если результат есть, закомитился + - зациклил весь 2й пункт + + +## Вникаем в детали системы, чтобы найти главные точки роста +Для того, чтобы найти "точки роста" для оптимизации я воспользовался в освновном использовались данные из benchmark и memory_profiler, иногда для разнообразия смотрел через qcachegrind. + +Вот какие проблемы удалось найти и решить + +### Ваша находка №1 +- по отчету memory_profiler первоночально показала что проблема есть тут: +``` +MEMORY USAGE: БЕЗ GB 360 MB (c GB 38 MB) + +allocated memory by location +----------------------------------- +287.67 MB rails-optimization-task2/work.rb:56 + +allocated memory by class +----------------------------------- + 416.75 MB Array +=> +sessions = sessions + [parse_session(line)] if cols[0] == 'session' +``` +подрзреваем что проблема с массивами +- первым решением заменить сложение массивов на `sessions << parse_session(line)` +- по результатам изменений видим что потребление памяти уменьшилось в данном месте +c 287 MB -> 0.4 MB что означает уменьшение в 717.5 раз + +``` + MEMORY USAGE: 77 MB + + allocated memory by location + ----------------------------------- + 400.00 kB rails-optimization-task2/work.rb:56 +``` + +скорость выполнения почти не поменялась 1.765 -> 1.678075 + +- Тк определили что складывать массивы затраратно сразу определяем похожие места +``` +users = users + [parse_user(line)] +9.97 MB rails-optimization-task2/work.rb:55 + + +users_objects = users_objects + [user_object] +9.57 MB rails-optimization-task2/work.rb:104 +``` +заменяем на операцию добавления в конец массива + +- по резульататам всех изменений получаем +``` +MEMORY USAGE: 58 MB + +allocated memory by location +----------------------------------- + 400.00 kB rails-optimization-task2/work.rb:56 + 400.00 kB rails-optimization-task2/work.rb:55 + rails-optimization-task2/work.rb:104 # даже не попал в отчет + + +allocated memory by class +----------------------------------- + 110.48 MB Array + 15.22 MB String +``` + +### Ваша находка №2 +- для большей наглядности повысим файл нагрузки до 20_000 (SIZE=20000 make all_reports) +- +замеры через benchmark и сисемный замер памяти +``` +SIZE 20000 +MEMORY USAGE: без GB 95 MB (c GB 54 MB) +6.934339999977965 +``` + +замеры через memory_profiler.rb +``` +MEMORY USAGE: 207 MB +Total allocated: 476.27 MB (887788 objects) +Total retained: 4.14 kB (9 objects) + +413.26 MB rails-optimization-task2/work.rb:102 + +allocated memory by class +----------------------------------- + 425.92 MB Array + 30.41 MB String +``` +подозреваем 102 строку `user_sessions = sessions.select { |session| session['user_id'] == user['id'] }`, тут происходит работа с select +в оффициальной документации +``` +select () - Returns a new array containing all elements of +``` + +- как решение вводим HASH и ищем через него. +``` +sessions_by_users = sessions.group_by { |session| session['user_id'] } + +users.each do |user| + attributes = user + user_object = User.new(attributes: attributes, sessions: sessions_by_users[user['id']] || []) + users_objects << user_object +end +``` +- смотрим на результат, память потребляемя уменьшилась не не сильно с 95 до 84 MB (c GB 54 -> 42) +``` +SIZE 20000 +MEMORY USAGE: 84 MB (с GB 42 MB) +0.3854719999944791 +``` + +но потребляемая память в конкретном месте уменьшилась до +``` + 700.82 kB rails-optimization-task2/work.rb:100 + 633.61 kB rails-optimization-task2/work.rb:103 + +allocated memory by class +----------------------------------- + 30.53 MB String + 14.24 MB Hash +``` + +### Ваша находка №3 +- исходя из отчетов большую часть памяти занимает цикл, когда мы проходим по всем строкам файла. Посмотрев через qcachegrind, и memory_profiler находим новуб точку роста это split +``` + MEMORY USAGE: 84 MB(c GB 42 MB) + + 9.48 MB rails-optimization-task2/work.rb:54 + 8.14 MB rails-optimization-task2/work.rb:28 +``` +- заменяб сохранение после split не в массив, а в переннеыю +`type, user_id, second, third, fourth, fifth = line.split(',')`, так же `parse_user` и `parse_session` убрал split и пользуюсь уже готовыми переменными. +- Резульатты улучшения появились, уменьшенеие но не крититчно всег она 14 MB +``` +SIZE 20000 +MEMORY USAGE: 70 MB (c GB 32 MB) +``` + +### Ваша находка №4 +- Подняд нагрузку до 200_000 +- Воспользовшись подсказкой о том что надо посмотреть на потоковую реализацию, сделал акцент на то а как память уделяется на по объектам: +``` +SIZE 200000 +MEMORY USAGE: 515 MB (c GB 274 MB) +4.325512000000344 + + +allocated memory by class +----------------------------------- + 108.42 MB String + 71.43 MB Hash + 44.68 MB Array +``` ++ так же вижу что начинает расти размер память на чтение файла, соответвенно при использовании максимального файла будет только ухуодшаться: +``` +12.94 MB rails-optimization-task2/work.rb:46 +``` +- Было принято решение переписать логику + 1) читать с файла по строкам и сразу собирать статистику + 2) записывать в файл результат тоже сразу в новый файл + 3) хранить уникальные бразуеры через Set + 4) соотвественно вся статитскика собирается на лету не хранится в памяти + +- Как результат измеенний собераем бенчмарки: +без GB +``` +SIZE 200000 +MEMORY USAGE: 198 MB +0.8822719999998299 +``` + +с GB +``` +SIZE 200000 +MEMORY USAGE: 20 MB +0.5364520000002813 +``` + + +- Пробуем запустить _large файл, видем что всё удовлетворяет условиям мы уложились в заданый бюджет. +``` +SIZE _large +MEMORY IN PROCESS USAGE: 21 MB +MEMORY IN PROCESS USAGE: 21 MB +MEMORY USAGE RESULT: 22 MB +MEMORY USAGE: 22 MB +8.345943000000261 +``` + +## Результаты +В результате проделанной оптимизации наконец удалось обработать файл с данными. +Удалось улучшить метрику системы с потребления памяти примерно в 22 Мб и уложиться в заданный бюджет. + +## Защита от регрессии производительности +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы были написан перформанс тест, который следит за потребляемой памятью \ No newline at end of file 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_profiler.rb b/memory_profiler.rb new file mode 100644 index 00000000..86134e62 --- /dev/null +++ b/memory_profiler.rb @@ -0,0 +1,8 @@ +require 'memory_profiler' +require_relative 'work' + + +report = MemoryProfiler.report do + work("data/data#{ENV['SIZE']}.txt", disable_gc: ENV['GB'] || false) +end +report.pretty_print(scale_bytes: true) diff --git a/report_spec.rb b/report_spec.rb new file mode 100644 index 00000000..16c38372 --- /dev/null +++ b/report_spec.rb @@ -0,0 +1,38 @@ +require 'rspec' +require 'rspec-benchmark' +require_relative 'work' +require 'byebug' +require 'benchmark' + + +RSpec.configure do |config| + config.include RSpec::Benchmark::Matchers +end + +RSpec.describe do + let(:data_file_path) { 'data.txt' } + + describe '#to_json logic test' do + subject { File.read(data_file_path) } + + let(:expected_result) do + 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"]}}}') + end + + it { + work(data_file_path) + + res = JSON.parse(File.read('result.json')) + expect(res).to eq(expected_result) + } + end + + describe '#performance max' do + let(:data_file_path) { "data_large.txt" } + + it 'performs success' do + work(data_file_path) + expect((`ps -o rss= -p #{Process.pid}`.to_i / 1024)).to be < 70 + end + end +end \ No newline at end of file diff --git a/result.json b/result.json new file mode 100644 index 00000000..343664b1 --- /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,"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"} \ No newline at end of file diff --git a/rubyprof_allocation.rb b/rubyprof_allocation.rb new file mode 100644 index 00000000..42395144 --- /dev/null +++ b/rubyprof_allocation.rb @@ -0,0 +1,20 @@ +require 'ruby-prof' +require_relative 'work' + +profile = RubyProf::Profile.new(measure_mode: RubyProf::ALLOCATIONS) + +result = profile.profile do + work("data/data#{ENV['SIZE']}.txt", disable_gc: ENV['GB'] || false) +end + +printer = RubyProf::FlatPrinter.new(result) +printer.print(File.open('ruby_prof_reports/flat.txt', 'w+')) + +printer = RubyProf::GraphHtmlPrinter.new(result) +printer.print(File.open('ruby_prof_reports/graph.html', 'w+')) + +printer = RubyProf::CallStackPrinter.new(result) +printer.print(File.open('ruby_prof_reports/callstack.html', 'w+')) + +printer = RubyProf::CallTreePrinter.new(result) +printer.print(path: 'ruby_prof_reports', profile: 'profile') \ No newline at end of file diff --git a/rubyprof_memory.rb b/rubyprof_memory.rb new file mode 100644 index 00000000..663b0324 --- /dev/null +++ b/rubyprof_memory.rb @@ -0,0 +1,20 @@ +require 'ruby-prof' +require_relative 'work' + +profile = RubyProf::Profile.new(measure_mode: RubyProf::MEMORY) + +result = profile.profile do + work("data/data#{ENV['SIZE']}.txt", disable_gc: ENV['GB'] || false) +end + +printer = RubyProf::FlatPrinter.new(result) +printer.print(File.open('ruby_prof_reports/flat_memory.txt', 'w+')) + +printer = RubyProf::GraphHtmlPrinter.new(result) +printer.print(File.open('ruby_prof_reports/graph_memory.html', 'w+')) + +printer = RubyProf::CallStackPrinter.new(result) +printer.print(File.open('ruby_prof_reports/callstack_memory.html', 'w+')) + +printer = RubyProf::CallTreePrinter.new(result) +printer.print(path: 'ruby_prof_reports', profile: 'profile') \ No newline at end of file diff --git a/work.rb b/work.rb new file mode 100644 index 00000000..fbbba830 --- /dev/null +++ b/work.rb @@ -0,0 +1,87 @@ +# Deoptimized version of homework task + +require 'json' +require 'pry' +require 'date' +require 'set' + +# DELIMITER = ','.freeze + +def build_user_stat(user) + "\"#{user[:name]}\":{" \ + "\"sessionsCount\":#{user[:s_count]}," \ + "\"totalTime\":\"#{user[:total_time]} min.\"," \ + "\"longestSession\":\"#{user[:longest_session]} min.\"," \ + "\"browsers\":\"#{user[:browsers].sort.join(', ')}\"," \ + "\"usedIE\":#{user[:used_ie]}," \ + "\"alwaysUsedChrome\":#{user[:only_chorme]}," \ + "\"dates\":#{user[:dates].sort.reverse}" \ + '}' +end + +def work(file, disable_gc: false) + GC.disable if [true, 'true'].include?(disable_gc) + + File.open('result.json', 'w') do |result_file| + result_file.write('{"usersStats":{') + + user = {} + current_use_id = nil + total_users = 0 + total_sessions = 0 + uniq_browsers = Set.new + + File.foreach(file, chomp: true).each do |line| + type, user_id, first, second, three, fourth = line.split(',') + + if current_use_id && current_use_id != user_id + result_file.write(build_user_stat(user)) + result_file.write(',') + end + + case type + when 'user' + user = { + s_count: 0, + total_time: 0, + longest_session: 0, + browsers: [], + used_ie: false, + only_chorme: true, + dates: [], + name: "#{first} #{second}" + } + current_use_id = user_id + total_users += 1 + when 'session' + user[:s_count] += 1 + + time = three.to_i + browser = second.upcase + + user[:total_time] += time + user[:longest_session] = time if time > user[:longest_session] + user[:browsers] << browser + user[:used_ie] = true if user[:used_ie] == false && browser.include?('INTERNET EXPLORER') + user[:only_chorme] = false if user[:only_chorme] == true && !browser.include?('CHROME') + user[:dates] << fourth + + uniq_browsers.add(browser) + total_sessions += 1 + end + + # puts "MEMORY IN PROCESS USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) + end + result_file.write(build_user_stat(user)) + result_file.write('},') + + result_file.write("\"totalUsers\":#{total_users},") + result_file.write("\"uniqueBrowsersCount\":#{uniq_browsers.count},") + result_file.write("\"totalSessions\":#{total_sessions},") + result_file.write("\"allBrowsers\":\"#{uniq_browsers.sort.join(',')}\"") + result_file.write('}') + puts "MEMORY USAGE RESULT: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) + end + + puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) +end \ No newline at end of file