Skip to content

Optimization CPU #145

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 11 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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
data_large.txt
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.gz
data_small.txt

.ruby-version
result.json

/ruby_prof_reports
/stackprof_reports
27 changes: 27 additions & 0 deletions benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
require 'benchmark'
require 'benchmark/ips'

require_relative 'task-1'

LINE_COUNT_FOR_IPS = 8

Benchmark.bm do |x|
counters = [1, 2, 4, 8, 160, 3000]

counters.each do |counter|
`head -n #{counter * 1000} data_large.txt > data.txt`

x.report { work }
end
end

Benchmark.ips do |x|
x.config(
stats: :bootstrap,
confidence: 95
)
`head -n #{LINE_COUNT_FOR_IPS * 1000} data_large.txt > data.txt`

x.report("work(#{LINE_COUNT_FOR_IPS * 1000} lines)") { work }
end

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

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

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

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

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

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

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

## Гарантия корректности работы оптимизированной программы
Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации.

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

Вот как я построил `feedback_loop`:

1. Подготовил тест для защиты метрики от деградации. В нём проверяется время выполнения программы, ips и асимптотика *(performance_spec.rb)*
2. Подготовил проверку производительности программы, в которой вычисляется время выполнения программы с (1, 2, 4, 8, 16) * 1000 строк, и проверяется ips на 8 * 1000 строк. В ходе оптимизации программы эти коэффициенты будут увеличиваться *(benchmark.rb)*.
3. Подготовил файлы для профилирования:
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 лайк за создание удобства для себя

- ruby-prof.rb - генеруирует отчёты в формате falt, graph, callstack, callgrind
- stackprof.rb - генеруирует отчёты для командной строки и JSON для speedscope
- work-method.rb - копия оптимизируемой программы, но с отключённым GC и возможностью в виде аргумента задавать файл с разными количествами строк

Благодаря такой подготовке я могу быстро проверять гипотезы при профилировании и тестировать полученные результаты в ходе реального выполнения программы.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


## Вникаем в детали системы, чтобы найти главные точки роста
Сначала проверил время выполнения программы на Ruby v3.0.6 и 3.3.0. Разница по времени была незначительная и проявлялась на больших объёмах данных. Решил что при оптимизации буду использовать Ruby v3.3.0.

Изучил работу программы, исследовал алгоритм работы, в ходе чего увидел несколько методов которые можно было бы улучшить, но не стал их трогать, т.к. надо подходить к ним через профилирование.

## Определение асимптотики

Чтобы определить асимптотику метода обработки файла, использую данные о времени выполнения при разном числе строк.
- От 1000 до 2000 строк время увеличивается с 0.051 с до 0.143 с, то есть примерно в 2.8 раза.
- От 2000 до 4000 строк время увеличивается с 0.143 с до 0.449 с, то есть примерно в 3.14 раза.
- От 4000 до 8000 строк время увеличивается с 0.449 с до 1.591 с, что в 3.54 раза больше.
Время выполнения растёт быстрее чем линейно. Возможно, что зависимость времени близка к квадратичной, т.е. O(n^2).

Если предположить, что сложность O(n^2), то время выполнения можно приближенно рассчитать, используя уравнение T(n) = T(k) ×((n)/(k))^2, где T(k) — известное время выполнения для k строк.
- Используем значения для 22000 строк (11.616 секунды):

T3250940 ≈ 11.616 ×(3250940/22000)^2
≈ 11.616 ×(147.77)^2
≈ 11.616 × 21852
≈ 253839.792 секунд

Это примерно 253839.792 / 3600 ≈ 70.51 часов.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


## Оптимизация

Для того, чтобы найти "точки роста" для оптимизации я воспользовался Ruby-prof (flat, graph, callstack, callgrind) и Stackprof (cli, speedscope)

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

### Ваша находка №1
- Все профилировщики показывают самое проблемной место - Array#select (**67%**) в котором для каждого пользователя перебирается весь массив сессий, что приводит к асимптотической сложности сложности порядка O(n*m), где n - число пользователей, а m - число сессий.
- Чтобы улучшить производительность создал вспомогательную хэш-таблицу в которой сгруппировал сессии по *user_id*
- Получил значительный прирост в скорости: на 16 000 строках было 7,3сек, стало 0,7сек.
- Данный метод перестал являться точкой роста.
Copy link
Collaborator

Choose a reason for hiding this comment

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

и тут асимптотика становится лучше, и можно переприкинуть расчёты


### Ваша находка №2
- Ruby-prof: flat, graph, callstack показали следующую точку роста - сложение массивов - Array#+ (**33%**). Это занимает много времени т.к. каждый раз инициализируется новый массив. Сложение вызывается в 3-х местах.
- Использую **map** для обработки users_objects
- 16 000 строк - было 0,7сек, стало 0,5 сек.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Хорошо подбирать объём данных так, чтобы программа успевала покрутиться пару секунд. Если она завершается слишком быстро (“не успевает поработать”) могут возникнуть какие-то перекосы (например, на полном объёме основная часть времени тратится в основном цикле, а если данных мало - то большая часть уходит на инициализацию и финализацию, например на чтение из файла и запись потом в файл)

И плюс когда время уже на миллисекунды - больше влияние погрешностей.

- Отчёт профилировщика показал, что именно в этом месте сложение массивов критично влияло на время выполнения программы. После исправления этой проблемы метод сложения ушёл на 11 место, поэтому оставляем его и ищем следующую точку роста

### Ваша находка №3
- Ruby-prof: flat, graph, callstack и Stackprof: cli, показали следующую точку роста: Array#all? (**26%**). Используется в 2-х местах:
1. Подсчёт уникальных браузеров
2. Подсчёт статистики "Всегда использовался только Chrome"

- Рефакторим подсчёт уникальных браузеров
```ruby
# было
uniqueBrowsers = []
sessions.each do |session|
browser = session['browser']
uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser }
end

# стало
uniqueBrowsers = sessions.map { |s| s['browser'] }.uniq
```
- 16 000 строк - было 0,5сек, стало 0,4сек
- Array#all? перестаёт быть точкой роста

### Ваша находка №4
- Ruby-prof: flat, graph, callstack и Stackprof: cli, показали следующую точку роста: **#collect_stats_from_users** (**65%**). В этот метод стекаются все методы по сбору статистики по конкретному пользователю. Изучая callstack, определил, что больше всего времени уходит на метод парсинга даты - который, как мы уже знаем, занимает продолжительное время.
- Избавляемся от Date.parse - т.к. даты уже находятся в нужном формате *iso8601*
- 16 000 строк - было 0,4сек, стало 0,26сек
- Date.parse перестаёт быть точкой роста

### Ваша находка №5
- Ruby-prof: callstack и Stackprof: cli, показали следующую точку роста: **String#split** (**30%**). Используется в 3-х местах - в парсинге файла с данными (**56%**) и **parse_session** (**36%**) и **parse_user** (**8%**)
- Вместо строк в методы **parse_session** и **parse_user** качестве аргументов передаю готовые массивы сгенерированные из этих строк
- 16 000 строк - было 0,26сек, стало 0,23сек
- String#split перестаёт быть точкой роста, но всё равно остаётся близкой к этому, т.к. всё равно приходится обрабатывать строки.

### Ваша находка №6
- Ruby-prof: flat, graph, callstack и Stackprof: cli показывают, что **#collect_stats_from_users** снова является точкой роста. Этот метод вызывается 7 раз с разными блоками, поэтому, чтобы определить какой блок сколько занимает - обворачиваем каждый в именованный метод.
Профилировщики показывают, что все они занимают примерно одинаковое время. Один из этих методов мы уже рефакторили (Date.parse), поэтому смотрим какие из них ещё можно оптимизировать. Почти во всех используется двойной обход по массиву user.sessions. Рефакторим
- Избавляемся от Date.parse - т.к. даты уже находятся в нужном формате *iso8601*
- Получаем незначительный прирост, но всё равно остаётся точкой роста

### Ваша находка №7
- Ruby-prof: flat, graph, callstack и Stackprof: cli - продолжаем изучать метод **#collect_stats_from_users** с использующимися в нём блоками. Все вызовы этого метода очень похожи, и их можно объединить в один. Также в последующих вызовах можно использовать вычисленные ранее переменные - используем это, чтобы сократить вычисления.
- 200 000 строк - было 3,5сек, стало 3сек
- метод #collect_stats_from_users перестаёт быть точкой роста

### Ваша находка №8
- Профилировщики показывают, что **Array#each** занимает много времени. Скорее всего, потому что он используется для обхода массивов. Для рефакторинга надо менять логику работы, чтобы уменьшить количество проходов по массивам и как-то кэшировать результаты.
- Поэтому рефакторим то, что можно отрефакторить за небольшой промежуток времени найденый ранее **Array#+**, и **String#+**
- Получаем ещё незначительный прирост производительности, добиваясь этим выполнения программы с необходимым тяжёлым файлом *data_large.txt* с более чем 3 000 000 строк за 32 секунды. Учитывая, что всё происходит на виртуальной машине - считаю это приемлимым результатом :smirk:


## Результаты
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить метрику системы с 70.51 часов (ориентировочно) до 32 секунд и практически уложиться в заданный бюджет.

Самое главное - сумел организовать рабочий процесс по оптимизации программы с помощью фреймворка оптимизации. Разобрался как формировать и фиксировать метрики, быстро проверять гипотезы и выстраивать эффективный feedback-loop!
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы gодготовил тест *(performance_spec.rb)* для защиты метрики от деградации. В нём проверяется время выполнения программы, ips и асимптотика.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


Loading