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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/profiling/results/*
/samples/data_*
/test/data.txt
/test/result.json
124 changes: 124 additions & 0 deletions case-study-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Case-study оптимизация. Часть 2.

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

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

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

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

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

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

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

## Асимптотика
До оптимизации:
```
Calculating -------------------------------------
File: 512Kb 4.817 (± 5.3%) i/s - 24.000 in 5.032638s
File: 1Mb 2.527 (± 6.3%) i/s - 13.000 in 5.183298s
File: 2Mb 1.203 (±15.4%) i/s - 6.000 in 5.132240s
File: 4Mb 0.586 (±19.5%) i/s - 3.000 in 5.269748s
File: 8Mb 0.287 (±14.0%) i/s - 2.000 in 7.100129s
with 99.0% confidence

Comparison:
File: 512Kb: 4.8 i/s
File: 1Mb: 2.5 i/s - 1.91x (± 0.15) slower
File: 2Mb: 1.2 i/s - 4.00x (± 0.68) slower
File: 4Mb: 0.6 i/s - 8.18x (± 1.96) slower
File: 8Mb: 0.3 i/s - 16.77x (± 3.11) slower

```
Исходя из данных показателей мы видим, что увеличение объема обрабатываемых данных в 2 раза ведет к росту времени работы в 2 раза

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

Вот как я построил `feedback_loop`:
1. Изначально выбрал маленький размер исходных данных (около 1Мб) позволяющий скрипту успешно отработать без оптимизаций
2. Поиск базовой метрики (время и память)
3. Дописал тест на регрессию по времени и памяти
4. Профилирование и поиск "точек роста"
5. Внесение изменений в код
6. Повторное тестирование и сбор новых метрик
7. Увеличение объема данных

## Вникаем в детали системы, чтобы найти 20% точек роста
Для того, чтобы найти "точки роста" для оптимизации я воспользовался

Гемы:
* ruby-prof
* memory_profiler
* get_process_mem
* benchmark-ips
Stdlib:
* benchmark
C
* valgrind (tool - massif)

Вот какие проблемы удалось найти и решить
Согласно результатам ruby-prof в режиме cpu:
```
%self total self wait child calls name
23.99 9.674 2.918 0.000 6.756 1 IO#each_line
6.89 1.007 0.838 0.000 0.169 48639 Array#map
6.74 1.413 0.819 0.000 0.594 89009 <Class::Date>#iso8601
6.71 2.488 0.816 0.000 1.672 1 JSON::Ext::Generator::GeneratorMethods::Hash#to_json
5.94 2.302 0.722 0.000 1.581 89009 Object#parse_session
5.64 3.431 0.685 0.000 2.746 16214 Object#aggregate_user_stats
```
### Находка №1
Проанализировав исходные данные выяснилось, что даты изначально приходят в нужном формате, поэтому нет нужды создавать Date объект
Также были убраны неиспользуемые поля, а вместо полей first_name и last_name было сделано поле full_name.
```
Comparison:
File: 512Kb: 10.9 i/s
File: 1Mb: 5.9 i/s - 1.85x (± 0.14) slower
File: 2Mb: 2.8 i/s - 3.94x (± 0.57) slower
File: 4Mb: 1.4 i/s - 7.90x (± 1.57) slower
File: 8Mb: 0.6 i/s - 16.93x (± 5.53) slower
with 99.0% confidence
```
Фактически мы видим двукратный прирост производительности

### Находка №2
Также стандартная json библиотека не самая производительная заменив ее на gem oj, мы получили еще небольшой прирост:
```
Comparison:
File: 512Kb: 12.5 i/s
File: 1Mb: 7.0 i/s - 1.78x (± 0.12) slower
File: 2Mb: 3.4 i/s - 3.68x (± 0.42) slower
File: 4Mb: 1.6 i/s - 7.71x (± 1.27) slower
File: 8Mb: 0.8 i/s - 15.36x (± 3.33) slower
with 99.0% confidence
```

Однако нерешенной остается проблема резкого выделения [памяти](https://imgur.com/cvkmvMS) связанная с формирование json строки

### Находка №3
Вместо библиотеки, которая целиком парсит hash в json, можно использовать yajl, которая позволяет "стримить" json в файл
Мы немного проигрываем в производительности на маленьких файлах
```
Comparison:
File: 512Kb: 12.3 i/s
File: 1Mb: 6.8 i/s - 1.81x (± 0.13) slower
File: 2Mb: 3.3 i/s - 3.67x (± 0.33) slower
File: 4Mb: 1.6 i/s - 7.56x (± 1.22) slower
File: 8Mb: 0.8 i/s - 16.21x (± 5.33) slower
with 99.0% confidence
```
Однако мы существенно сокращаем объемы потребляемой ![памяти](https://imgur.com/pzdL2kW)

## Результаты
В результате проделанной оптимизации наконец удалось обработать файл с данными.
В среднем файл обрабатывается за 28 - 29 секунд при средних затратах по памяти 600 - 620Мб без резких скачков по памяти

## Защита от регресса производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы добавлены дополнительные тесты для защиты от регрессий по памяти и времени выполнения
67 changes: 53 additions & 14 deletions case-study-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,74 @@
Я решил исправить эту проблему, оптимизировав эту программу.

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

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

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

Вот как я построил `feedback_loop`: *как вы построили feedback_loop*
Вот как я построил `feedback_loop`:
1. Изначально выбрал маленький размер исходных данных (около 1Мб) позволяющий скрипту успешно отработать без оптимизаций
2. Поиск базовой метрики (время и память)
3. Дописал тест на регрессию по времени и памяти
Copy link
Owner

Choose a reason for hiding this comment

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

👍

4. Профилирование и поиск "точек роста"
5. Внесение изменений в код
6. Повторное тестирование и сбор новых метрик
7. Увеличение объема данных

## Вникаем в детали системы, чтобы найти 20% точек роста
Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались*
Для того, чтобы найти "точки роста" для оптимизации я воспользовался

Гемы:
* ruby-prof
* memory_profiler
* get_process_mem

Stdlib:
* benchmark

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

### Ваша находка №1
О вашей находке №1
### Находка №1
Считывание всего файла в строку с дальнейшим разбиением на массив строк крайне неэффективно и было одним из основным блокеров для работы с большими файлами
Copy link
Owner

Choose a reason for hiding this comment

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

Опечатка одним из основных

Copy link
Owner

Choose a reason for hiding this comment

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

Как именно это обнаружили?


Использование построчной обработки существенно ускорило выполнение программы примерно в 2,3 раза

### Находка №2
Memory_profiler показал, что аллоцируется огромное количество строк, хоть большинство из них собираются GC, однако все равно увеличивают объем потребляемой памяти и время выполнения (как минимум за счет работы GC)

Для исправления этой проблемы был использован ```# frozen_string_literal: true``` и в качестве ключей стали использоваться Symbol
Copy link
Owner

Choose a reason for hiding this comment

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

Насколько сильно помогло?
Как повлияло на метрики?


### Находка №3
Избыточное и неоптимальное использование итераторов

По возможности, обработка данных переписана так, чтобы использовать минимальное число итераций и использоание "in-place" модификаций чтобы снизить расходы по памяти

### Ваша находка №2
О вашей находке №2
### Находка №4
Генерация "сущностей без надобности"

### Ваша находка №X
О вашей находке №X
Был убран класс User, а так же вместо отдельных массивов users и sessions был использован хеш ключем, которого стал user_id, что упростило связывание пользователей и сессий

### Находка №5
Неоптимальная аггрегация данных

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

### Находка №6
Использование "медленных" методов без особой надобности

Ruby-prof показал, что метод Date#parse занимает порядка 8% от всего времени выполнения, поэтому он был заменен на Date#strptime
Copy link
Owner

Choose a reason for hiding this comment

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

👍


### Находка №7
Использование регулярных выражений без особой надобности

Поиск по регулярным выражениям был заменен на поиск по вхождению подстроки, поскольку он более производительный

## Результаты
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце*

*Какими ещё результами можете поделиться*
В среднем файл обрабатывается за 29 - 30 секунд при средних затратах по памяти 850 - 860Мб
Copy link
Owner

Choose a reason for hiding this comment

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

👍


## Защита от регресса производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы сделано *то, что вы для этого сделали*
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы добавлены дополнительные тесты для защиты от регрессий по памяти и времени выполнения
Binary file removed data_large.txt.gz
Binary file not shown.
104 changes: 104 additions & 0 deletions lib/task-1.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# frozen_string_literal: true

require 'yajl'
#require 'ruby-progressbar'

def parse_user(fields)
{
id: fields[1],
full_name: fields[2] << " " << fields[3]
}
end

def parse_session(fields)
{
user_id: fields[1],
browser: fields[3].upcase!,
time: fields[4].to_i,
date: fields[5].chomp!,
}
end

def aggregate_user_stats(data)
return {} unless data

user = data[1][:user]
sessions = data[1][:sessions]
time = sessions.map {|s| s[:time] }
browsers = sessions.map { |s| s[:browser] }
{
user[:full_name]=> {
sessionsCount: sessions.count,
totalTime: time.sum.to_s << ' min.',
longestSession: time.max.to_s << ' min.',
browsers: browsers.sort!.join(', '),
usedIE: browsers.any? { |b| b.include?('INTERNET EXPLORER') },
alwaysUsedChrome: browsers.all? { |b| b.include?('CHROME')},
dates: sessions.map{|s| s[:date]}.sort!.reverse!
}
}
end

def work(input: "data.txt", output: "result.json")
# Отчёт в json
# - Сколько всего юзеров +
# - Сколько всего уникальных браузеров +
# - Сколько всего сессий +
# - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом +
#
# - По каждому пользователю
# - сколько всего сессий +
# - сколько всего времени +
# - самая длинная сессия +
# - браузеры через запятую +
# - Хоть раз использовал IE? +
# - Всегда использовал только Хром? +
# - даты сессий в порядке убывания через запятую +

data = {}
_tmp_hash = {}
report = {
totalUsers: 0,
uniqueBrowsersCount: 0,
totalSessions: 0,
allBrowsers: [],
usersStats: {}
}

#bar_output = ENV["NOPROGRESS"] ? File.open(File::NULL, "w") : $stdout
#bar = ProgressBar.create(total: nil, output: bar_output)
File.open(input) do |f|
f.each_line do |line|
cols = line.split(',')
key = cols[1]
if cols[0] == "user"
if data[key].nil?
report[:usersStats].merge!(aggregate_user_stats(data.shift))
end
data[key] ||= {}
data[key][:user] = parse_user(cols)
report[:totalUsers] += 1
else
data[key] ||= {}
data[key][:sessions] ||= []
session = parse_session(cols)
browser = session[:browser]
if _tmp_hash[browser].nil?
_tmp_hash[browser] = 1
report[:allBrowsers].push(browser)
report[:uniqueBrowsersCount] += 1
end
report[:totalSessions] += 1
data[key][:sessions].push(session)
end
# bar.increment
end
report[:usersStats].merge!(aggregate_user_stats(data.shift))
report[:allBrowsers] = report[:allBrowsers].sort!.join(',')
# bar.finish
end
File.open(output, 'w') do |f|
Yajl::Encoder.encode(report, f)
f << "\n"
end
end
46 changes: 46 additions & 0 deletions profiling/ips.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
ENV["NOPROGRESS"] = '1'
require_relative '../lib/task-1.rb'
require 'benchmark/ips'

FILES = {
"512Kb" => 524_268,
"1Mb" => 1_048_576,
"2Mb" => 2_097_152,
"4Mb" => 4_194_304,
"8Mb" => 8_388_608,
}.freeze

class GCSuite
def warming(*)
run_gc
end

def running(*)
run_gc
end

def warmup_stats(*)
end

def add_report(*)
end

private

def run_gc
GC.enable
GC.start
GC.disable
end
end


suite = GCSuite.new

Benchmark.ips do |x|
x.config(suite: suite, stats: :bootstrap, confidence: 99)
FILES.keys.each do |size|
x.report("File: #{size}") { work(input: "../samples/data_#{size}", output: "/dev/null") }
end
x.compare!
end
Loading