Skip to content

Memory optimization #110

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

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
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
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/ruby_prof_reports
/stackprof_reports

data_1k.txt
data_10k.txt
data_100k.txt
data_large.txt
data_large.txt.gz
data_small.txt

.ruby-version
result.json

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

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

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

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

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

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

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

## Гарантия корректности работы оптимизированной программы
Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации.
Переписал тест используя Rspec matchers - изменил логику проверки соответствия ожидаемого и полученного результата (сравниваются значения по ключам хэшей)

Также добавил проверку на количество потребляемой памяти, используя `perform_allocation`

## Feedback-Loop
Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений.

Вот как я построил `feedback_loop`:
1. Написал тест используя Rspec, с проверкой логики выполнения и количеству потребляемой памяти. *(после каждой оптимизации проверяю)*
2. Подготовил файлы для профилирования:
- memory_profiler.rb - для отдельного профилирования с помощью гема `Memory profiler`
- profilers.rb - генерирует отчёты для `Ruby-prof` в режиме профилирования аллокаций в формате Flat, Graph, Callstac, а также в режиме профилирования памяти в формате CallTree. И генерирует отчёты для `Stackprof` - пользовался редко, в основном смотрел визуализацию графа.

## Вникаем в детали системы, чтобы найти главные точки роста
Смотрим, как обстоят дела с фризом строк. Его нет, и на 20 000 строках программа занимает 520МБ. Добавляем фриз, получаем - 515 МБ. Хорошо

Изучаем, что показывает `GS.stat`, `ObjectSpace.count`. На 10 000 строках видим работу GC:
```log
:total_allocated_objects => 398540,
:total_freed_objects => 282935,
```
Было освобождено достаточно много объектов, это говорит о том, что GC в Ruby v3.3.0 работает достаточно эффективно. Также это подтверждается замером с помощью `Memory profiler` - всего было использовано 767 MB, а осталось 4,24 KB (в уроке оставалось 5,76 МБ)

Использование Memory profiler начинает показывать первые точки роста

### Ваша находка №1
- MemoryProfiler показывает главную точку роста - неэффективное добавление элементов в массив - при заполнении массивов sessions и users.
- переписал добавление элемента в массив без инициирования дополнительных элементов
- Метрика изменилась с 767 МБ до 245 МБ
- Неэффективное сложение массивов использовалось в двух местах: 506 МБ и 18 МБ. После оптимизации оба метода стали использовать одинаковое количество памяти - 530 КБ, хотя в первом месте метод вызывается чаще в несколько раз. Интересно...

### Ваша находка №2
- Stackprof, Graphviz.dot, Ruby-prof:flat - показывают, что по количеству аллокаций Array#select является точкой роста
- Чтобы улучшить производительность создал вспомогательную хэш-таблицу в которой сгруппировал сессии по *user_id*
- Метрика изменилась с 245 МБ до 63 МБ
- Array#select занимал 182 МБ, теперь 26 МБ

### Ваша находка №3
- MemoryProfiler показывает что второй случай со сложением массивов начинает являться точкой роста с 17 МБ. Оптимизируем как и в первом кейсе.
- Метрика изменилась с 63 МБ до 46 МБ
- строка со сложением стала занимать 730 КБ вместо 17 МБ, количество всех массивов уменьшилось с 65028 до 60972

### Ваша находка №4
- MemoryProfiler указывает на строку с парсингом даты. Оптимизируем
- Метрика изменилась с 46 МБ до 35 МБ
- строка с датой занимала *13 МБ* и аллоцировала *171163 (!)* объекта. После оптимизации стала занимать *1 МБ с 9929 объектами*. Очень наглядная оптимизация :smirk:
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


### Ваша находка №5
- Ruby-prof: flat, graph, callstack, KCacheGrind показывает что Array#all? является точкой роста. Избавляемся от его применения, так как этот метод создаёт ещё три дополнительных объекта
- Метрика практически не изменилась: с 35 до 34 МБ
- выполнение кода в строке занимало 1 МБ, стало 183 КБ

### Ваша находка №5
- Дальнейшее профилирование указывает на split (это необходимо) и генерацию и накопление массивов с пользователями и сессиями.
- Поэтому решаем переписать программу на потоковый режим работы. В память будет загружаться информация о пользователе и его сессиях, считаться статистика, и записываться в файл. После чего в память будет загружаться следующий пользователь.
- Метрика показывает результат выполнения 21 МБ. Причём как на 10_000, так и на 3_250_940 строк (файл data_large.txt). Это означает, что мы вложились в бюджет (< 70 МБ). Ура! :smiley:
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 и самое приятное, что так любой объём данных можно перелопатить


### Остальные находки
- В процессе оптимизации программы визуально выявляются ещё достаточное количество мест, которые, как кажется, можно оптимизировать. Но так как это будет хлопотно и нецелесообразно, а в бюджет уже укладываемся - то оставляем без изменений
- После оптимизации программы решил проверить как влияет фриз строк после оптимизации, и выяснил интересный момент - на 100 000 строках:
- 146 МБ - без фриза
- 139 МБ - с фризом
Получается, данная оптимизация не зависит от количества данных, а зависит от количества использования String в программе?
Copy link
Collaborator

Choose a reason for hiding this comment

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

он же фризит только строковые литералы в коде; от данных не зависит


### Замер скорости
- После оптимизации по памяти провели тестирование на времени выполнения программы. С файлом data_large.txt оно составило - **всего лишь 22 секунды(!)** против 32 сек до оптимизации памяти. Возможно, разница больше, так как замеры выполнялись на разных конфигурациях VM
Copy link
Collaborator

Choose a reason for hiding this comment

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

Да, чаще всего у всех получается в таком подходе результаты лучше чем в первом ДЗ

Такой интересный момент, что когда заходили только с точки зрения CPU вроде бы более прямо ориентировались на время выполнения; но работа с памятью и GC могут давать свои тормоза, которые мы можем не заметить при профилировании CPU. Хотя справедливости ради зачастую именно GC бывает на первых местах в отчётах профилировщиков CPU. Тогда можно задуматься о том, что надо как-то объектов поменьше аллоцировать постараться


## Результаты
- В результате проделанной оптимизации наконец удалось обработать файл с данными. Удалось улучшить метрику системы с 767 МБ до 29 МБ и уложиться в заданный бюджет < 70 MB.
- Приобрел практические навыки в оптимизации используемой памяти в работе приложений
- На практике выявил сильную взаимосвязь между оптимизацией CPU и памятью
Copy link
Collaborator

Choose a reason for hiding this comment

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

++

- Закрепил навыки по построению эффективного `Feedback-loop`

## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написал тест для проверки потребляемой памяти и количества аллоцированных объектов.
12 changes: 12 additions & 0 deletions memory_profiler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# memory_profiler (ruby 2.3.8+)
# allocated - total memory allocated during profiler run
# retained - survived after MemoryProfiler finished

require_relative 'work_method.rb'
require 'benchmark'
require 'memory_profiler'

report = MemoryProfiler.report do
work('data_100k.txt', disable_gc: false)
end
report.pretty_print(scale_bytes: true)
52 changes: 52 additions & 0 deletions profilers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Stackprof ObjectAllocations and Flamegraph
#
# Text:
# stackprof stackprof_reports/stackprof.dump
# stackprof stackprof_reports/stackprof.dump --method Object#work
#
# Graphviz:
# stackprof --graphviz stackprof_reports/stackprof.dump > stackprof_reports/graphviz.dot
# dot -Tpng stackprof_reports/graphviz.dot > stackprof_reports/graphviz.png
# imgcat stackprof_reports/graphviz.png

require 'stackprof'
require 'ruby-prof'
require_relative 'work_method.rb'

StackProf.run(mode: :object, out: 'stackprof_reports/stackprof.dump', raw: true) do
work('data_small.txt', disable_gc: false)
end

# ruby-prof
# dot -Tpng graphviz.dot > graphviz.png
# imgcat graphviz.png
# cat ruby_prof_reports/flat.txt

RubyProf.measure_mode = RubyProf::ALLOCATIONS

result = RubyProf::Profile.profile do
work('data_small.txt', disable_gc: true)
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::DotPrinter.new(result)
# printer.print(File.open('ruby_prof_reports/graphviz.dot', 'w+'))

# На этот раз профилируем не allocations, а объём памяти!
RubyProf.measure_mode = RubyProf::MEMORY

result = RubyProf::Profile.profile do
work('data_small.txt', disable_gc: false)
end

printer = RubyProf::CallTreePrinter.new(result)
printer.print(path: 'ruby_prof_reports', profile: 'profile')

Loading