-
Notifications
You must be signed in to change notification settings - Fork 193
Task1 home work #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6655991
15bded8
935aea8
46d8c72
3e35bce
c00bf95
d235321
cb8f2ca
c425ee7
09851d4
ac194dd
9fef38b
f8105db
fa262b4
f24916b
028ae35
0e72eec
8d85f42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| .ruby-version | ||
| .rvm-gemset | ||
| data/data_large.txt | ||
| profilers/ruby_prof_reports | ||
| profilers/stackprof_reports |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| --require spec_helper |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # frozen_string_literal: true | ||
| source 'https://rubygems.org' | ||
| git_source(:github) { |repo| "https://github.com/#{repo}.git" } | ||
| ruby '2.6.3' | ||
|
|
||
| gem 'pry-byebug' | ||
| gem 'rspec' | ||
| gem 'rspec-benchmark' | ||
| gem 'ruby-prof' | ||
| gem 'stackprof' | ||
| gem 'fasterer' | ||
| gem 'rubocop-performance' | ||
| gem 'memory_profiler' | ||
| gem 'oj' | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| GEM | ||
| remote: https://rubygems.org/ | ||
| specs: | ||
| ast (2.4.0) | ||
| benchmark-malloc (0.1.0) | ||
| benchmark-perf (0.5.0) | ||
| benchmark-trend (0.3.0) | ||
| byebug (11.0.1) | ||
| coderay (1.1.2) | ||
| colorize (0.8.1) | ||
| diff-lcs (1.3) | ||
| fasterer (0.6.0) | ||
| colorize (~> 0.7) | ||
| ruby_parser (>= 3.13.0) | ||
| jaro_winkler (1.5.3) | ||
| memory_profiler (0.9.14) | ||
| method_source (0.9.2) | ||
| oj (3.8.1) | ||
| parallel (1.17.0) | ||
| parser (2.6.3.0) | ||
| ast (~> 2.4.0) | ||
| pry (0.12.2) | ||
| coderay (~> 1.1.0) | ||
| method_source (~> 0.9.0) | ||
| pry-byebug (3.7.0) | ||
| byebug (~> 11.0) | ||
| pry (~> 0.10) | ||
| rainbow (3.0.0) | ||
| rspec (3.8.0) | ||
| rspec-core (~> 3.8.0) | ||
| rspec-expectations (~> 3.8.0) | ||
| rspec-mocks (~> 3.8.0) | ||
| rspec-benchmark (0.5.0) | ||
| benchmark-malloc (~> 0.1.0) | ||
| benchmark-perf (~> 0.5.0) | ||
| benchmark-trend (~> 0.3.0) | ||
| rspec (>= 3.0.0, < 4.0.0) | ||
| rspec-core (3.8.2) | ||
| rspec-support (~> 3.8.0) | ||
| rspec-expectations (3.8.4) | ||
| diff-lcs (>= 1.2.0, < 2.0) | ||
| rspec-support (~> 3.8.0) | ||
| rspec-mocks (3.8.1) | ||
| diff-lcs (>= 1.2.0, < 2.0) | ||
| rspec-support (~> 3.8.0) | ||
| rspec-support (3.8.2) | ||
| rubocop (0.74.0) | ||
| jaro_winkler (~> 1.5.1) | ||
| parallel (~> 1.10) | ||
| parser (>= 2.6) | ||
| rainbow (>= 2.2.2, < 4.0) | ||
| ruby-progressbar (~> 1.7) | ||
| unicode-display_width (>= 1.4.0, < 1.7) | ||
| rubocop-performance (1.4.1) | ||
| rubocop (>= 0.71.0) | ||
| ruby-prof (1.0.0) | ||
| ruby-progressbar (1.10.1) | ||
| ruby_parser (3.13.1) | ||
| sexp_processor (~> 4.9) | ||
| sexp_processor (4.12.1) | ||
| stackprof (0.2.12) | ||
| unicode-display_width (1.6.0) | ||
|
|
||
| PLATFORMS | ||
| ruby | ||
|
|
||
| DEPENDENCIES | ||
| fasterer | ||
| memory_profiler | ||
| oj | ||
| pry-byebug | ||
| rspec | ||
| rspec-benchmark | ||
| rubocop-performance | ||
| ruby-prof | ||
| stackprof | ||
|
|
||
| RUBY VERSION | ||
| ruby 2.6.3p62 | ||
|
|
||
| BUNDLED WITH | ||
| 1.17.3 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| # Case-study оптимизации | ||
|
|
||
| ## Актуальная проблема | ||
| В нашем проекте возникла серьёзная проблема. | ||
|
|
||
| Необходимо было обработать файл с данными, чуть больше ста мегабайт. | ||
|
|
||
| У нас уже была программа на `ruby`, которая умела делать нужную обработку. | ||
|
|
||
| Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. | ||
|
|
||
| Я решил исправить эту проблему, оптимизировав эту программу. | ||
|
|
||
| ## Формирование метрики | ||
| Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: Взял за основу 2 метрики: | ||
| 1. Количество операций в секунду на небольщом объёме данных (18 строк) | ||
| 2. Время выполения на объёме данных в 20к строк | ||
|
|
||
| ## Гарантия корректности работы оптимизированной программы | ||
| Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. | ||
|
|
||
| ## Feedback-Loop | ||
| Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений и времени выполения скрипта в 30 секунд | ||
|
|
||
| Вот как я построил `feedback_loop`: Написал тесты на производительность для разного объма данных: | ||
| 1. 1й тест проверял количество выполняемых операций в секунду | ||
| 2. 2й тест проверял среднее время прохождения теста на выборке в 20к строк. | ||
|
|
||
| После каждого нахождения и исправления точек роста, правились тесты для недопущения регресси в результате рефекторинга. | ||
|
|
||
| ## Вникаем в детали системы, чтобы найти главные точки роста | ||
| Для того, чтобы найти "точки роста" для оптимизации я воспользовался: `stackprof`, `ruby_callgrind`, `ruby_prof_graf` - для нахождения точек роста, а так же частоты вызова тех или иных методов, `htop` и `ruby_spy` - для отслеживания процесса, просмотра информации о потребляемой памяти и на каком этапе выполения находится скрипт | ||
|
|
||
| Вот какие проблемы удалось найти и решить | ||
|
|
||
| ### Находка №1 | ||
| Поиск сессии вызывается на каждого пользователя и проходит по всем сессиям каждый, затратная операция: | ||
| ``` | ||
| user_sessions = sessions.select { |session| session['user_id'] == user['id'] } | ||
| ``` | ||
|
|
||
| Решил данную проблему сразу сгруппировав сессию по user_id | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| На выборке в 10_000 строк, скорость работы выросла с минимальных 3.3 сек до 0.335, выигрыш составил 10х. | ||
|
|
||
| ### Находка №2 | ||
| Далее по коду проблемным стал collect_stats_from_users метод, не сама реализация, а количество неоправданных вызовов | ||
|
|
||
| Выигрыш в производительности на выборке в 20к составил 0.015с. | ||
|
|
||
| ### Находка №3 | ||
| Далее метод | ||
| ``` | ||
| sessions.each do |session| | ||
| browser = session['browser'] | ||
| uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser } | ||
| end | ||
| ``` | ||
| а именно метод `uniqueBrowsers.all, который вызывается 10к раз на выборке в 10к` | ||
| Выигрыш в производительности на выборке в 20к составил 0.018с. | ||
|
|
||
| ### Находка №4 | ||
| `Date.parse` был заменён на `Date.strptime(session['date'], '%F')`, казалось бы небольшая функция, на выходе сэкономила 0.050c времени на выборке в 20к и время составило 0.8 сек | ||
|
|
||
| ### Находка №5 | ||
| Замена вызова блока через `block.call` на `yield` дало прирост ещё 0.030 и составило 0.770 сек | ||
|
|
||
| ### Находка №6 | ||
| Количество вызовов метода `upcase`, преобразование вызывалось на одних и тех же данных c дополнительной итерацией, в результате удалось сократить время с 0.770 до 0.75 | ||
|
|
||
| ### Находка №7 | ||
| В самом 1м блоке `each` было найдено дублирование метода `split` . Удалось сократить время с 0.75 до 0.731 | ||
|
|
||
| ### Находка №8 | ||
| Данные для `report['allBrowsers']` брались путём дополнительной итерация, удалил дополнительную итерацию, использовал данные из `uniqueBrowsers`, сократил время c 0.731 до 0.716 | ||
|
|
||
| ### Находка №9 | ||
| Перебрал схему, понял, что входные данные приходят в определённом порядке, можно на этом сыграть и проводить меньше итерация и довести до линейной зависимости. В результате сократил время работы с 0.716 до 0.136 | ||
|
|
||
| ### Находка №10 | ||
| Бесполезный Date.strftime, заменён на сhomp. Заменён each + push, на map. `to_json` -медленный, заменён на `oj`.В результате сокращено время работы с 0.136 до 0.064 | ||
|
|
||
| ## Результаты | ||
| В результате проделанной оптимизации наконец удалось обработать файл с данными. | ||
| Удалось улучшить метрику системы с бесконечно долго выполнения до 33 секунд | ||
|
|
||
| Было весело :) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
|
|
||
| ## Защита от регрессии производительности | ||
| Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написаны 2 теста производительности. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| require 'bundler' | ||
|
|
||
| Bundler.require(:default) | ||
|
|
||
| require 'benchmark' | ||
| require 'json' | ||
| require 'pry' | ||
| require 'date' | ||
|
|
||
| $LOAD_PATH << File.expand_path('lib', __dir__) | ||
|
|
||
| Dir[Dir.pwd + '/lib/**/*.rb'].each do |file| | ||
| require file | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| class Task | ||
| def initialize(result_file_path: nil, data_file_path: nil, dasable_gc: true) | ||
| GC.disable if dasable_gc | ||
| @result_file_path = result_file_path || 'data/result.json' | ||
| @data_file_path = data_file_path || 'data/data_large.txt' | ||
| end | ||
|
|
||
| def parse_user(fields) | ||
| { | ||
| id: fields[1], | ||
| full_name: "#{fields[2]} #{fields[3]}" | ||
| } | ||
| end | ||
|
|
||
| def parse_session(fields) | ||
| { | ||
| user_id: fields[1], | ||
| session_id: fields[2], | ||
| browser: fields[3].upcase, | ||
| time: fields[4].to_i, | ||
| date: fields[5].chomp, | ||
| } | ||
| end | ||
|
|
||
| def collect_stats_from_user(report, user) | ||
| user_key =user.attributes[:full_name] | ||
| report[:usersStats][user_key] ||= {} | ||
| report[:usersStats][user_key] = report[:usersStats][user_key].merge(yield(user)) | ||
| end | ||
|
|
||
| def work | ||
| user_objects = [] | ||
| sessions = [] | ||
| uniqueBrowsers = Set.new | ||
|
|
||
| File.foreach(data_file_path) do |line| | ||
| cols = line.split(',') | ||
| if cols[0] == 'user' | ||
| @user = User.new(attributes: parse_user(cols), sessions: []) | ||
| user_objects << @user | ||
| end | ||
|
|
||
| if cols[0] == 'session' | ||
| session = parse_session(cols) | ||
| uniqueBrowsers << session[:browser] | ||
| sessions << session | ||
| @user.sessions << session | ||
| end | ||
| end | ||
|
|
||
| report = {} | ||
|
|
||
| report[:totalUsers] = user_objects.count | ||
| progress_bar = ProgressBar.create(total: user_objects.count, format: '%a, %J, %E %B') | ||
|
|
||
| report[:uniqueBrowsersCount] = uniqueBrowsers.count | ||
| report[:totalSessions] = sessions.count | ||
| report[:allBrowsers] = uniqueBrowsers.sort.join(',') | ||
| report[:usersStats] = {} | ||
|
|
||
| user_objects.each do |user_object| | ||
| prepare_stats(report, user_object) | ||
| progress_bar.increment | ||
| end | ||
|
|
||
| File.write(result_file_path, "#{Oj.dump(report, mode: :compat)}\n") | ||
| end | ||
|
|
||
| private | ||
|
|
||
| attr_reader :result_file_path, :data_file_path | ||
|
|
||
| def prepare_stats(report, user_object) | ||
| collect_stats_from_user(report, user_object) do |user| | ||
| user_times = user.sessions.map { |session| session[:time] } | ||
| user_browsers = user.sessions.map { |session| session[:browser] } | ||
| user_dates = user.sessions.map { |session| session[:date] } | ||
|
|
||
| { | ||
| sessionsCount: user.sessions.count, | ||
| totalTime: "#{user_times.sum} min.", | ||
| longestSession: "#{user_times.max} min.", | ||
| browsers: user_browsers.sort.join(', '), | ||
| usedIE: user_browsers.any? { |b| b.match? /INTERNET EXPLORER/ }, | ||
| alwaysUsedChrome: user_browsers.all? { |b| b.match? /CHROME/ }, | ||
| dates: user_dates.sort { |a, b| b <=> a } | ||
| } | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| class User | ||
| attr_reader :attributes, :sessions | ||
|
|
||
| def initialize(attributes:, sessions:) | ||
| @attributes = attributes | ||
| @sessions = sessions | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # brew install rbspy | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Плюсик за отдельные файлы для удобства работы |
||
| # DATA_FILE=large.txt §ruby work.rb # запуск долгого процесса | ||
| # sudo rbspy record --pid 19587 # подключение к работающему процессу | ||
| # sudo rbspy record ruby my-script.rb # постоение flamegraph | ||
|
|
||
| # sudo su | ||
| # rbspy record ruby profilers/1_ruby_spy.rb | ||
|
|
||
| require_relative '../config/environment' | ||
|
|
||
| p Benchmark.measure { Task.new(dasable_gc: false).work } | ||
|
|
||
|
|
||
| result_file_path = 'data/result.json' | ||
| File.delete(result_file_path) if File.exist?(result_file_path) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # ruby profilers/2_ruby_prof_flat.rb | ||
| # cat profilers/ruby_prof_reports/flat.txt | ||
| require_relative '../config/environment' | ||
|
|
||
| RubyProf.measure_mode = RubyProf::WALL_TIME | ||
|
|
||
| result = RubyProf.profile do | ||
| Task.new(data_file_path: './spec/fixtures/data_20k.txt').work | ||
| end | ||
|
|
||
| printer = RubyProf::FlatPrinter.new(result) | ||
| printer.print(File.open("profilers/ruby_prof_reports/flat.txt", "w+")) | ||
|
|
||
| result_file_path = 'data/result.json' | ||
| File.delete(result_file_path) if File.exist?(result_file_path) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # RubyProf Graph report | ||
| # ruby profilers/3_ruby_prof_graf.rb | ||
| # open profilers/ruby_prof_reports/graph.html | ||
| require_relative '../config/environment' | ||
|
|
||
| RubyProf.measure_mode = RubyProf::WALL_TIME | ||
|
|
||
| result = RubyProf.profile do | ||
| Task.new(data_file_path: './spec/fixtures/data_10k.txt').work | ||
| end | ||
|
|
||
| printer = RubyProf::GraphHtmlPrinter.new(result) | ||
| printer.print(File.open('profilers/ruby_prof_reports/graph.html', "w+")) | ||
|
|
||
| result_file_path = 'data/result.json' | ||
| File.delete(result_file_path) if File.exist?(result_file_path) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # RubyProf CallStack report | ||
| # ruby profilers/4_ruby_prof_callstack.rb | ||
| # open profilers/ruby_prof_reports/callstack.html | ||
| require_relative '../config/environment' | ||
|
|
||
| RubyProf.measure_mode = RubyProf::WALL_TIME | ||
|
|
||
| result = RubyProf.profile do | ||
| Task.new(data_file_path: './spec/fixtures/data_10k.txt').work | ||
| end | ||
|
|
||
| printer = RubyProf::CallStackPrinter.new(result) | ||
| printer.print(File.open('profilers/ruby_prof_reports/callstack.html', 'w+')) | ||
|
|
||
| result_file_path = 'data/result.json' | ||
| File.delete(result_file_path) if File.exist?(result_file_path) | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # RubyProf CallGrind report | ||
| # ruby profilers/5_ruby_prof_callgrind.rb | ||
|
|
||
| # brew install qcachegrind | ||
| # qcachegrind profilers/ruby_prof_reports/... | ||
|
|
||
| require_relative '../config/environment' | ||
|
|
||
| RubyProf.measure_mode = RubyProf::WALL_TIME | ||
|
|
||
| result = RubyProf.profile do | ||
| Task.new(data_file_path: './spec/fixtures/data_10k.txt').work | ||
| end | ||
|
|
||
| printer4 = RubyProf::CallTreePrinter.new(result) | ||
| printer4.print(path: 'profilers/ruby_prof_reports', profile: 'callgrind') | ||
|
|
||
| result_file_path = 'data/result.json' | ||
| File.delete(result_file_path) if File.exist?(result_file_path) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Плюсик за
Gemfile