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
6 changes: 6 additions & 0 deletions Gemfile
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"
43 changes: 43 additions & 0 deletions Gemfile.lock
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
55 changes: 0 additions & 55 deletions case-study-template.md

This file was deleted.

113 changes: 113 additions & 0 deletions case-study.md
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 для мониторинга памяти
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


Вот какие проблемы удалось найти и решить:

### №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
- Вместо оператора + было применено использование метода <<, который добавляет элемент в существующий массив без создания нового объекта.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Choose a reason for hiding this comment

The 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
26 changes: 26 additions & 0 deletions memory_watcher.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
21 changes: 21 additions & 0 deletions performance_test.rb
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

Loading