Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
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
1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--require spec_helper
14 changes: 14 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Плюсик за Gemfile

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'
82 changes: 82 additions & 0 deletions Gemfile.lock
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
89 changes: 89 additions & 0 deletions case-study.md
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 секунд

Было весело :)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написаны 2 теста производительности.
14 changes: 14 additions & 0 deletions config/environment.rb
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
File renamed without changes.
90 changes: 90 additions & 0 deletions lib/task.rb
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
8 changes: 8 additions & 0 deletions lib/user.rb
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
15 changes: 15 additions & 0 deletions profilers/1_ruby_spy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# brew install rbspy
Copy link
Collaborator

Choose a reason for hiding this comment

The 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)
15 changes: 15 additions & 0 deletions profilers/2_ruby_prof_flat.rb
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)
16 changes: 16 additions & 0 deletions profilers/3_ruby_prof_graf.rb
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)
17 changes: 17 additions & 0 deletions profilers/4_ruby_prof_callstack.rb
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)

19 changes: 19 additions & 0 deletions profilers/5_ruby_prof_callgrind.rb
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)
Loading