-
Notifications
You must be signed in to change notification settings - Fork 139
Memory optimization #125
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
base: master
Are you sure you want to change the base?
Memory optimization #125
Changes from all commits
00027fa
1a16afa
7c112e0
410328b
84b728c
2179cc6
e11c2fb
96988a1
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,6 @@ | ||
| source "https://rubygems.org" | ||
| gem "ruby-prof" | ||
| gem "rspec-benchmark" | ||
| gem "minitest" | ||
| gem "memory_profiler" | ||
| gem "stackprof" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| GEM | ||
| remote: https://rubygems.org/ | ||
| specs: | ||
| benchmark-malloc (0.2.0) | ||
| benchmark-perf (0.6.0) | ||
| benchmark-trend (0.4.0) | ||
| diff-lcs (1.6.0) | ||
| memory_profiler (1.1.0) | ||
| minitest (5.25.4) | ||
| 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.6.3) | ||
| stackprof (0.2.27) | ||
|
|
||
| PLATFORMS | ||
| arm64-darwin-23 | ||
| ruby | ||
|
|
||
| DEPENDENCIES | ||
| memory_profiler | ||
| minitest | ||
| rspec-benchmark | ||
| ruby-prof | ||
| stackprof | ||
|
|
||
| BUNDLED WITH | ||
| 2.4.10 |
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| # Case-study оптимизации | ||
|
|
||
| ## Актуальная проблема | ||
| В нашем проекте возникла серьёзная проблема. | ||
|
|
||
| Необходимо было обработать файл с данными, чуть больше ста мегабайт. | ||
|
|
||
| У нас уже была программа на `ruby`, которая умела делать нужную обработку. | ||
|
|
||
| Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. | ||
|
|
||
| Я решил исправить эту проблему, оптимизировав эту программу. | ||
|
|
||
| ## Формирование метрики | ||
| Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: | ||
| * время выполнения программы. | ||
| * потребление памяти | ||
|
|
||
| ## Гарантия корректности работы оптимизированной программы | ||
| Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. | ||
|
|
||
| ## Feedback-Loop | ||
| Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за 1-2 минуты | ||
|
|
||
| Вот как я построил `feedback_loop`: подготовил файлы с различным количеством строк (head -n N data_large.txt > dataN.txt), чтобы программа могла выполняться за 10–20 секунд. | ||
| После каждого изменения я запускал программу на файлах с разным количеством строк и смотрел на результаты отчетов. | ||
|
|
||
| ## Вникаем в детали системы, чтобы найти главные точки роста | ||
| Для того, чтобы найти "точки роста" для оптимизации я воспользовался: | ||
| - gem memory_profiler | ||
| - gem ruby-prof и отчеты callstack & qcachegrind | ||
| - gem stackprof + CLI и Speedscope | ||
| - второй thread для мониторинга памяти | ||
|
|
||
| Вот какие проблемы удалось найти и решить: | ||
|
|
||
| ### №1 Уменьшение создания временных массивов при добавлении элементов | ||
| ``` | ||
| sessions = sessions + [parse_session(line)] if cols[0] == 'session' | ||
| ``` | ||
| - memory_profiler | ||
| - Вместо оператора + было применено использование метода <<, который добавляет элемент в существующий массив без создания нового объекта. | ||
| - До оптимизации программе аллоцировалось 460MB памяти на файле размером 10_000 строк, после оптимизации уже 155MB | ||
| - данная проблема перестала быть главной точкой роста | ||
|
|
||
| ### №2 Избыточное создание массивов при фильтрации сессий для каждого пользователя | ||
| ``` | ||
| user_sessions = sessions.select { |session| session['user_id'] == user['id'] } | ||
| ``` | ||
| - memory_profiler | ||
| - Проблема возникает в строке user_sessions = sessions.select { |session| session['user_id'] == user['id'] }, где для каждого пользователя создается новый массив отфильтрованных сессий. Это приводит к повторным обходам большого массива sessions, и для каждого пользователя в памяти хранятся временные массивы, что заметно увеличивает использование памяти. | ||
| - Чтобы избежать повторного обхода массива для каждого пользователя и избыточного создания временных массивов, все сессии были предварительно сгруппированы по user_id с использованием метода group_by. После этого для каждого пользователя мы просто обращаемся к уже сгруппированным данным через хеш (sessions_by_user[user['id']]). | ||
| - До оптимизации программе аллоцировалось 155MB памяти на файле размером 10_000 строк, после оптимизации уже 42MB. | ||
| - данная проблема перестала быть главной точкой роста | ||
|
|
||
| ### №3 Уменьшение создания временных массивов при добавлении элементов | ||
| ``` | ||
| users = users + [parse_user(line)] if cols[0] == 'user' | ||
| ``` | ||
| - memory_profiler | ||
| - Вместо оператора + было применено использование метода <<, который добавляет элемент в существующий массив без создания нового объекта. | ||
| - До оптимизации программе аллоцировалось 636MB памяти на файле размером 50_000 строк, после оптимизации уже 400MB | ||
| - данная проблема перестала быть главной точкой роста | ||
|
|
||
| ### №4 Уменьшение создания временных массивов при добавлении элементов | ||
| ``` | ||
| users_objects = users_objects + [user_object] | ||
| ``` | ||
| - memory_profiler | ||
| - Вместо оператора + было применено использование метода <<, который добавляет элемент в существующий массив без создания нового объекта. | ||
|
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. nice work, like что не стали сразу все << во всех местах в один шаг объединять |
||
| - До оптимизации программе аллоцировалось 400MB памяти на файле размером 50_000 строк, после оптимизации уже 160MB | ||
| - Данная проблема перестала быть главной точкой роста | ||
|
|
||
| ### №5 Излишний парсинг дат и преобразование в формат iso8601 | ||
| ``` | ||
| { 'dates' => user.sessions.map{|s| s['date']}.map {|d| Date.parse(d)}.sort.reverse.map { |d| d.iso8601 } } | ||
| ``` | ||
| - qcachegrind из ruby-prof | ||
| - Так как мы уже имеем данные в нужном формате, то было принято решение не тратить время на преобразование даты в формат iso8601 | ||
| - До оптимизации программе аллоцировалось 160MB памяти на файле размером 50_000 строк, после оптимизации уже 120MB | ||
| - Данная проблема перестала быть главной точкой роста | ||
|
|
||
| ### №6 Избыточное создание временных массивов | ||
| ``` | ||
| cols = line.split(',') | ||
| ``` | ||
| - memory_profiler | ||
| - Вместо разделения строки на части с помощью split и проверки первого элемента, я решил использовать метод start_with? | ||
|
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. 👍 clever move; можно даже по первой букве определить тип строки |
||
| - До оптимизации программе аллоцировалось 120MB памяти на файле размером 50_000 строк, после оптимизации уже 100MB. | ||
| - Данная проблема перестала быть главной точкой роста | ||
|
|
||
| ### №7 Избыточное потребление памяти из-за создания новых хэшей при merge | ||
| ``` | ||
| report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user)) | ||
| ``` | ||
| - memory_profiler | ||
| - Был использован метод merge! вместо merge, который изменяет оригинальный хэш на месте, избегая лишнего копирования данных | ||
| - До оптимизации программе аллоцировалось 200MB памяти на файле размером 100_000 строк, после оптимизации уже 184MB. | ||
| - Данная проблема перестала быть главной точкой роста | ||
|
|
||
| ### №8 Вспомнил про волшебный коммент `# frozen_string_literal: true` | ||
| - Видеоурок | ||
| - Добавил в начало файла `# frozen_string_literal: true` | ||
| - До оптимизации программе аллоцировалось 184MB памяти и 2.5 млн объектов на файле размером 100_000 строк, после оптимизации уже 166MB и только 2млн объектов. | ||
|
|
||
| Дальнейшие оптимизации не приносили существенного прироста по эффективности. Было принято решение переписать приложение на потоковую обработку файла. | ||
|
|
||
| ## Результаты | ||
| В результате проделанной оптимизации наконец удалось обработать файл с данными. | ||
| Удалось добиться стабильного потребления памяти в пределах 20-30 мб при обработке файлов любых размеров. | ||
|
|
||
| ## Защита от регрессии производительности | ||
| Для защиты от потери достигнутого прогресса при дальнейших изменениях программы был добавлен тест производительности performance_test.rb | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| class MemoryWatcher | ||
| def initialize(memory_limit_mb) | ||
| @memory_limit_mb = memory_limit_mb | ||
| @should_stop = false | ||
| end | ||
|
|
||
| def start | ||
| @thread = Thread.new do | ||
| until @should_stop | ||
| current_memory = `ps -o rss= -p #{Process.pid}`.to_i / 1024 | ||
| puts "MEMORY USAGE: #{current_memory} MB" | ||
| if current_memory > @memory_limit_mb | ||
| puts "Memory limit exceeded: #{current_memory}MB > #{@memory_limit_mb}MB" | ||
| puts "Killing process..." | ||
| Process.kill('KILL', Process.pid) | ||
| end | ||
| sleep 1 | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def stop | ||
| @should_stop = true | ||
| @thread.join if @thread | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require 'rspec' | ||
| require_relative 'task-2' | ||
|
|
||
| RSpec.describe 'Memory usage' do | ||
| before { File.write('result.json', '') } | ||
|
|
||
| it 'consumes no more than 70MB of memory' do | ||
| memory_before = `ps -o rss= -p #{Process.pid}`.to_i / 1024 | ||
|
|
||
| work('data_large.txt', true) | ||
|
|
||
| memory_after = `ps -o rss= -p #{Process.pid}`.to_i / 1024 | ||
| memory_usage = memory_after - memory_before | ||
|
|
||
| puts "Memory usage during test: #{memory_usage} MB" | ||
| expect(memory_usage).to be <= 70 | ||
| end | ||
| end | ||
|
|
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.
👍