diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f9942e6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data +data_large.txt +result.json +ruby_prof_reports \ No newline at end of file diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..9c25013d --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.6 diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..7fa92729 --- /dev/null +++ b/Gemfile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +ruby '3.3.6' + +source 'https://rubygems.org' +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + + +gem 'pry' +gem 'rspec-benchmark' +gem 'ruby-progressbar' +gem 'ruby-prof' +gem 'stackprof' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..2b303197 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,50 @@ +GEM + remote: https://rubygems.org/ + specs: + benchmark-malloc (0.2.0) + benchmark-perf (0.6.0) + benchmark-trend (0.4.0) + coderay (1.1.3) + diff-lcs (1.5.1) + method_source (1.1.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-benchmark (0.6.0) + benchmark-malloc (~> 0.2) + benchmark-perf (~> 0.6) + benchmark-trend (~> 0.4) + rspec (>= 3.0) + rspec-core (3.13.3) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.2) + ruby-prof (1.7.1) + ruby-progressbar (1.13.0) + stackprof (0.2.27) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + pry + rspec-benchmark + ruby-prof + ruby-progressbar + stackprof + +RUBY VERSION + ruby 3.3.6p108 + +BUNDLED WITH + 2.5.22 diff --git a/bench_wrapper.rb b/bench_wrapper.rb new file mode 100644 index 00000000..c5d5e4e4 --- /dev/null +++ b/bench_wrapper.rb @@ -0,0 +1,41 @@ +# 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 + 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 + gc_stat_after = GC.stat + memory_after = `ps -o rss= -p #{Process.pid}`.to_i/1024 + + 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..b89b2b84 --- /dev/null +++ b/benchmark.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative 'bench_wrapper' +require_relative 'task-1' + +# path = "data/data#{ARGV[0] || 50000}.txt" +path = "data_large.txt" + +measure do + work(path) +end diff --git a/case-study-template.md b/case-study-template.md index d41034d9..e641e3cd 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -12,45 +12,105 @@ Я решил исправить эту проблему, оптимизировав эту программу. ## Формирование метрики -Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика* +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: + - Определил глобальну метрику - время выполнения программы на data_large файле. Бюджет - 3с. + - Т.к. работать с такими большими данными неэффективно, решил создать несколько файлов с 10_000, 25_000, 50_000, 100_000 и 200_000 строк. + - Оценил асимптотику: + * 10000 - "time":0.6,"gc_count":29,"memory":"78 MB"/ + * 25000 - "time":3.53,"gc_count":103,"memory":"128 MB" + * 50000 - "time":13.97,"gc_count":352,"memory":"147 MB" + * 100000 - "time":55.02,"gc_count":1290,"memory":"261 MB" + * 200000 - "time":229.75,"gc_count":4968,"memory":"180 MB" +> 55,02/13,97 ~ 4; 229,75/55,02 ~ 4 - асимптотика времени работы программы составляет квадратичную то есть время работы растёт пропорционально квадрату размера обрабатываемого файла O(n^2). Дал приблизительную оценку времени выполнения файла в 3 млн строк - 15ч. +- Для эффективной работы выбрал начать с файла в 50_000 строк. Оставил метрику на время выполнения файла, но бюджет решил обновлять после каждой итерации +по оптимизации программы. Для первой итерации установил бюджет в 2 секунды. ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. ## Feedback-Loop -Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* - -Вот как я построил `feedback_loop`: *как вы построили feedback_loop* +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за около 15 минут, так как аытался использовать различгые отчеты +изучая их возможности. + +Создал бенчмарк и ruby файлы под каждый вид профилирования. Как аргумент передаю количество строк. +Вот как я построил `feedback_loop`: +- Запускаю бенчмарк. +- Запускаю профилировщик. +- Определяю главную точку роста. +- Вношу изменения. +- Проверяю гипотезу повторным запуском профилировщика. +- Защищаю изменения тестом. ## Вникаем в детали системы, чтобы найти главные точки роста -Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались* +Для того, чтобы найти "точки роста" для оптимизации я воспользовался ruby-prof(flat), Вот какие проблемы удалось найти и решить ### Ваша находка №1 -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика - исправленная проблема перестала быть главной точкой роста? +- Отчет ruby-prof flat показал 70.78% времени выполнения на Array#select +- Проблема была в формировании юзер сессий отдеьлным селектом по каждому юзеру. Чтобы избежать этого решил сгруппировать +сессии по user_id до итерирования по юзерам. +- После внесения изменений выполнил поставленную метрику в 2с, получил 0.86с на 50_000 строк. +- Исправленная проблема перестала быть главной точкой роста -### Ваша находка №2 -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика - исправленная проблема перестала быть главной точкой роста? +Для следующего поиска выбрал объем данных в 100_000 строк, перед оптимизацией бенчмарк оценил длительность - 2.43с +Установил промежуточный бюджет в 1с. -### Ваша находка №X -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика - исправленная проблема перестала быть главной точкой роста? +### Ваша находка №2 +- Отчет callstack показал что 54.67% занимает метод Array#+ На каждую итерацию создается новый массив, а это тяжелая операция. +- Проблему решил добавлением элементов в массив через << (users << parse_user(line), sessions << parse_session(line)) +- Бенчмарк показал что метрику в 0.87c +- Исправленная проблема перестала быть главной точкой роста +- Защитил изменения обновлением перформенс теста. + +Оценил бенчмарком время выполнения на 200_000 строк(1.96с). Принял решение создать файл для тестов на +400_000 строк("time":5.11,"gc_count":542,"memory":"561 MB") для более точной оценки точек роста. +Оценил ассимпототику(400_000 - 5.11, 200_000 - 2.1, 100_000 - 0.9) - уже больше похоже на линейную зависимость. +Оптимистически предполагаю, что достижения бюджета в 3с для 400_000 строк должно хватить, для достижения основного бюджета. + +### Ваша находка №3 +- Для поиска главной точки роста решил использовать stackprof. Большую часть времени забирал на себя Array#each, но +я предположил что надо смотреть на то что конкретно в этом блоке тратится больше времени. Заметил что 55.66% времени +в этом блоке занимает Array#all?. Подтверждаю свою находку перезапуском ruby-prof callstack. +- Вместо того чтобы в каждой итерации проверять браузер на уникальность(uniqueBrowsers.all?), решил использовать Set, +который по определению хранит только уникальные значения. +- Метрика показала 4.36c, что не удовлетворяет промежуточному бюджету в 3с. +- Исправленная проблема перестала быть главной точкой роста +- Защитил изменения обновлением перформенс теста. + +### Ваша находка №4 +- Для поиска главной точки роста решил использовать stackprof speedscope. Оценил удобство исользования и количество доступной информации. +Определил что главная точка роста на данный момент Object#collect_stats_from_users(41%). +- В первую очередь вынес в отдельнвую перемменную user_stats = report['usersStats']. + Далее убрал ненужную тут конкатенацию "#{user.attributes['first_name']} #{user.attributes['last_name']}" + Вместо использования merge, который создает дополнительный хэш, обновляю данные напрямую block.call(user).each { |key, value| stats[key] = value } + Также заметил что в первой итерации не исправил в одном месте Array#+, заменил на << +- Метрика показала 2.09c, что удовлетворяет промежуточному бюджету в 3с. +Удивился, так как предполагаю что с учетом линейной асимптотики этого уже должно хватить для достижния главного бюджета в 30 секунд +на большом файле. Проверяю и подтверждаю достижение бюджета: +{"3.3.6":{"gc":"enabled","time":24.02,"gc_count":92,"memory":"1856 MB"}} +- Однако исправленная проблема не перестала быть главной точкой роста +- Защитил изменения обновлением перформенс теста. +{"3.3.6":{"gc":"enabled","time":24.02,"gc_count":92,"memory":"1856 MB"}} + +Не смотря на достижение бюджета, решаю провести еще одну итерацию по оптимизации выполнения метода Object#collect_stats_from_users: + +- С помощью callstack отчета отмечаю большую долю выполнения на #parse +- Отмечаю что Date.parse тут необязателен, использую { 'dates' => user.sessions.map { |s| s['date'] }.sort.reverse } +- Бенчмарк показывает 19.3с на большом файле. Бюджет выполнен, решаю остановить оптимизацию. +- Избавился от точки роста в #parse ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. -Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* и уложиться в заданный бюджет. +Удалось улучшить метрику системы с оценки времени выполнения в 15 часов до 19.3с и уложиться в заданный бюджет. -*Какими ещё результами можете поделиться* +Для себя отметил качество и удобство использования callstack а также stackrprof speedscope отчетов. +Отметил что при оптимизации лучше опираться на точки роста в отчетах, так как один раз при очевидной попытки оптимизации +"на глаз" метода Array#map получил регресс в метрике. +Также оптимальным показалось использование не одного конкретного профилировщика, а использование их в комплексе, для лучшей оценки точек роста. ## Защита от регрессии производительности -Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали* +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написал перформанс тест который запускает +программу 2 раза и проверяет время выполнения на прокинутом в него файле за определнное количество времени. Тест обновлял +по мере оптимизации, увеличивая количество строк и задавая соответствующее время выполнения. diff --git a/data_large.txt.gz b/data_large.txt.gz deleted file mode 100644 index 823c793a..00000000 Binary files a/data_large.txt.gz and /dev/null differ diff --git a/profilers/profile.rb b/profilers/profile.rb new file mode 100644 index 00000000..49002b41 --- /dev/null +++ b/profilers/profile.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'ruby-prof' +require 'stackprof' +require_relative '../task-1' + +REPORTS_DIR = 'ruby_prof_reports' +FileUtils.mkdir_p(REPORTS_DIR) + +path = "data/data#{ARGV[0] || 50000}.txt" +mode = ARGV[1] || 'flat' + +profile = RubyProf::Profile.new(measure_mode: RubyProf::WALL_TIME) + +result = profile.profile do + work(path, no_gc: true) +end + +case mode +when 'callgrind' + printer = RubyProf::CallTreePrinter.new(result) + printer.print(path: REPORTS_DIR, profile: "callgrind") + puts "Callgrind report generated at #{REPORTS_DIR}/callgrind.out" + +when 'graph' + printer = RubyProf::GraphHtmlPrinter.new(result) + File.open("#{REPORTS_DIR}/graph.html", 'w+') { |file| printer.print(file) } + puts "Graph HTML report generated at #{REPORTS_DIR}/graph.html" + +when 'flat' + printer = RubyProf::FlatPrinter.new(result) + File.open("#{REPORTS_DIR}/flat.txt", 'w+') { |file| printer.print(file) } + puts "Flat profile report generated at #{REPORTS_DIR}/flat.txt" + +when 'callstack' + printer = RubyProf::CallStackPrinter.new(result) + File.open("#{REPORTS_DIR}/callstack.html", 'w+') { |file| printer.print(file) } + puts "CallStack report generated at #{REPORTS_DIR}/callstack.html" +when 'stackprof' + StackProf.run(mode: :wall, out: "#{REPORTS_DIR}/stackprof.dump", interval: 1000) do + work(path, no_gc: true) + end + +when 'stackprof_speedscope' + profile = StackProf.run(mode: :wall, raw: true) do + work(path, no_gc: true) + end + File.write("#{REPORTS_DIR}/stackprof_speedscope.json", JSON.generate(profile)) + +else + puts "Invalid mode: #{mode}. Use 'flat', 'graph', 'callstack', 'stackprof' or 'callgrind'." + exit 1 +end diff --git a/rspec/perform_spec.rb b/rspec/perform_spec.rb new file mode 100644 index 00000000..afe13fc3 --- /dev/null +++ b/rspec/perform_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative 'rspec_helper' + +describe 'Performance reporter' do + let(:file_path) { 'data_large.txt' } + let(:time) { 28 } + + it 'create report' do + expect { + work(file_path) + }.to perform_under(time).sec.warmup(2).times.sample(2).times + end +end diff --git a/rspec/rspec_helper.rb b/rspec/rspec_helper.rb new file mode 100644 index 00000000..a8ff52c0 --- /dev/null +++ b/rspec/rspec_helper.rb @@ -0,0 +1,7 @@ +require 'rspec-benchmark' +require_relative "../task-1" + + +RSpec.configure do |config| + config.include RSpec::Benchmark::Matchers +end diff --git a/task-1.rb b/task-1.rb index 778672df..76f75740 100644 --- a/task-1.rb +++ b/task-1.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + # Deoptimized version of homework task require 'json' require 'pry' require 'date' require 'minitest/autorun' +require 'set' class User attr_reader :attributes, :sessions @@ -36,23 +39,30 @@ def parse_session(session) end def collect_stats_from_users(report, users_objects, &block) + user_stats = report['usersStats'] + 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']}" + stats = user_stats[user_key] ||= {} + + block.call(user).each { |key, value| stats[key] = value } end end -def work - file_lines = File.read('data.txt').split("\n") +def work(path = 'data.txt', no_gc: false) + GC.disable if no_gc + file_lines = File.read(path).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' + if cols[0] == 'user' + users << parse_user(line) + elsif cols[0] == 'session' + sessions << parse_session(line) + end end # Отчёт в json @@ -75,11 +85,9 @@ def work report[:totalUsers] = users.count # Подсчёт количества уникальных браузеров - uniqueBrowsers = [] - sessions.each do |session| - browser = session['browser'] - uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser } - end + unique_browsers_set = Set.new + sessions.each { |session| unique_browsers_set << session['browser'] } + uniqueBrowsers = unique_browsers_set.to_a report['uniqueBrowsersCount'] = uniqueBrowsers.count @@ -96,11 +104,12 @@ def work # Статистика по пользователям users_objects = [] + user_sessions = sessions.group_by { |session| session['user_id'] } + 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_object = User.new(attributes: attributes, sessions: user_sessions[user['id']] || []) + users_objects << user_object end report['usersStats'] = {} @@ -137,7 +146,7 @@ def work # Даты сессий через запятую в обратном порядке в формате 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")