Skip to content
Open
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
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source 'https://rubygems.org'

gem 'stackprof'
14 changes: 14 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.PHONY: all_reports
all_reports:
ruby benchmark.rb
ruby rubyprof_allocation.rb
ruby rubyprof_memory.rb
ruby memory_profiler.rb




5 changes: 5 additions & 0 deletions benchmark.rb
Original file line number Diff line number Diff line change
@@ -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) }
Copy link
Collaborator

Choose a reason for hiding this comment

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

почему GB-то? 😄

Copy link
Author

Choose a reason for hiding this comment

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

Опечатка которая преследовала до конца. :)

234 changes: 234 additions & 0 deletions case-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# Case-study оптимизации

## Актуальная проблема
В нашем проекте возникла серьёзная проблема.

Необходимо было обработать файл с данными, чуть больше ста мегабайт.

У нас уже была программа на `ruby`, которая умела делать нужную обработку.

Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время.

Я решил исправить эту проблему, оптимизировав эту программу.

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: программа должна потреблять меньше 70Мб памяти при обработке файла data_large.txt

Предварительно на
- 10_000 строк из файла используется MEMORY USAGE: без GB 360 MB(c GB 82 MB) скороть работы 1.765 сек
Copy link
Collaborator

Choose a reason for hiding this comment

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

GC == Garbage Collector

- 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

через profiler не надо делать замеры. замеры отдельно, профайлинг отдельно

```
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
```

но потребляемая память в конкретном месте уменьшилась до
Copy link
Collaborator

Choose a reason for hiding this comment

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

тут лучше быть более точным, что такое потребляемая память не совсем понятно; тут конкретно речь идёт про объём аллоцированной памяти

```
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 и пользуюсь уже готовыми переменными.
Copy link
Collaborator

Choose a reason for hiding this comment

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

да-да, отлично, простое решение, которое убирает огромное количество аллокаций в цикле

- Резульатты улучшения появились, уменьшенеие но не крититчно всег она 14 MB
```
SIZE 20000
MEMORY USAGE: 70 MB (c GB 32 MB)
```

### Ваша находка №4
- Подняд нагрузку до 200_000
Copy link
Collaborator

Choose a reason for hiding this comment

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

это не нагрузка, а скорее объём файла просто (сорри за духоту)

- Воспользовшись подсказкой о том что надо посмотреть на потоковую реализацию, сделал акцент на то а как память уделяется на по объектам:
```
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 Мб и уложиться в заданный бюджет.

## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы были написан перформанс тест, который следит за потребляемой памятью
Binary file removed data_large.txt.gz
Binary file not shown.
8 changes: 8 additions & 0 deletions memory_profiler.rb
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 38 additions & 0 deletions report_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

end
end
end
1 change: 1 addition & 0 deletions result.json
Original file line number Diff line number Diff line change
@@ -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"}
20 changes: 20 additions & 0 deletions rubyprof_allocation.rb
Original file line number Diff line number Diff line change
@@ -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')
20 changes: 20 additions & 0 deletions rubyprof_memory.rb
Original file line number Diff line number Diff line change
@@ -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')
Loading