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
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.5.3
186 changes: 163 additions & 23 deletions case-study-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,175 @@
Я решил исправить эту проблему, оптимизировав эту программу.

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика*
Я попыталась проанализировать, как зависит время выполнения скрипта от объема обрабатываемых данных, замеряя его на файлах разного размера.
На рисунке ниже показана зависимость:
![chart](https://ucarecdn.com/ebcafac8-0bc3-4813-8493-55bf36ff977f/execution_time.png)


К сожалению, такого кол-во данных недостаточно, чтобы сделать вывод о характере зависимости (мне не хватило терпения сделать больше замеров).
Для простоты я решила пока допустить, что она линейная. И что максимальное время, за которое должен отрабатывать скрипт на целевом файле (~ 135Mb), – 300 секунд.
Тогда мы можем выбрать метрику: `скорость обработки текста объемом 1Мб` и целевой показатель для неё: `2 секунды`.

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

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

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

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

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

### Ваша находка №1
О вашей находке №1

### Ваша находка №2
О вашей находке №2

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

Вот как я построила `feedback-loop`:
* Выделила из большого файла кусок объемом примерно 26_000 строк (~ 1Mb);
* Время выполнения скрипта на исходной версии кода составило 28-30 секунд, что вполне терпимо по скорости обратной связи;
* Для оценки времени использовала стандартный модуль Benchmark;
* Полученное время – моя целевая метрика;
* Следом за скриптом выполняется тест, чтобы проверить работоспособность кода после внесенных изменений.

## Инструменты
Для того чтобы найти "точки роста" для оптимизации я воспользовалась:
* Гем memory_profiler
* Гем get_process_mem
* Гем ruby-prof

## Обнаруженные проблемы и проведенные оптимизации
_Прим.: там где я ссылаюсь на строки кода, имеется в виду номер строки в исходном неоптимизированном скрипте._

### Оптимизация №1
С помощью `get_process_mem` удалось выяснить, что больше всего потребление памяти возрастает после выполнения цикла по строкам,
в котором формируются массивы пользователей и сессий (`users`, `sessions`): с 30 до 230Mb.
Отчет `memory_profiler` так же показал, что больше всего памяти выделяется в строке 55, где идет формирование массива сессий.
В каждой итерации создаются новые объекты массивов – массив с новым элементом, массив с результатом конкатенации, это неэффективно.
Замена конкатенации на модификацию массивов `users` и `sessions` 'in place' с помощью `Array#push` снизило выделенную на эту
операцию память (после выполнения цикла занято оказалось около 50Mb), однако на итоговую скорость работы это особенно не повлияло.

### Оптимизация №2
Следующее место, на которое указывает `memory_profiler`, – строка 101, в которой идет выбор сессий пользователя с
помощью метода `#select`. Очевидно, можно значительно сэкономить ресурсы, если вместо массива `sessions` сразу же из
файла считывать данные в хэш, где ключом будет `id` пользователя, а значением – массив с сессиями.
Это изменение невозможно сделать, не порефакторив места, в которых используется массив `sessions`:
* он используется для подсчета `uniqueBrowsersCount`,
* `totalSessions` в итоговом объекте отчёта
* для формирования списка браузеров (который по факту совпадает с `uniqueBrowsers`).
Мы можем сделать всё это в том же цикле, где будем формировать хэш сессий. Т.о. не только избавимся от большого массива,
но и от всех лишних итераций по нему.

```
users = []
sessions_by_users = {}
unique_browsers = []
total_sessions = 0

file_lines.each do |line|
cols = line.split(',')
users << parse_user(line) if cols[0] == 'user'
next unless cols[1] == 'session'

session = parse_session(line)
sessions_by_users[session['user_id']] ||= []
sessions_by_users[session['user_id']] << session
browser = session['browser'].upcase
unique_browsers << browser unless unique_browsers.include?(browser)
total_sessions += 1
end
```

**Результат: скрипт на файле 1Мб выполнился за 0.6 сек.**

### Оптимизация №3
Дальше с помощью того же `memory_profiler` я обратила внимание на чудовищное кол-во объектов, под которые выделяется память в
процессе работы скрипта (даже после предыдущих двух пунктов). Total allocated: 1_221_129 objects – раз в 50 больше,
чем строк в обрабатываемом файле. Больше всего из них – массивы, далее – строки. Я последовательно прошлась по самым
заметным моментам, не замеряя каждое микродействие:
* Строка 103: меняем конкатенацию на `Array#push`, чтобы избавиться от лишних массивов.
* Строка 53: видно, что мы разбиваем строку на массив `line.split(',')` только для того чтобы узнать,
что лежит у нее в первой "колонке", и потом никак его не используем; выбрасываем! меняем на метод `String#start_with?`;
строки `user`, `session` фризим, выносим в константы.
* Меняем ключи хэшей со строк на символы.
* Строки и регулярные выражения вынесем в константы и зафризим.

**Результат: кол-во объектов уменьшилось в 1.5 раза. Объем памяти не превышает 90Mb. Скрипт выполнился за 0.4 сек.**

### Оптимизация №4
Обратила внимание на `allocated memory by location`, на первом месте код из строки 140 – обработка дат в массиве сессий
пользователя.
Во-первых, видим чейн из двух `#map`, потом сортировка, `reverse` и снова `map`. Каждый из методов создает новые массивы в памяти.
Кроме того, `RubyProf` показал, что большую часть времени тратится на вызов `<Class::Date>#parse`.
Замена на `<Class::Date>#strptime` оказалась примерно в три раза быстрее.

Далее видно, что в этом месте последовательно вызывается метод
`collect_stats_from_users` (7 раз!), который итерирует по массиву объектов пользователей и выдергивает из них данные для отчёта.
5/7 вызовов этого метода присутствуют в топ-10 отчета `allocated memory`. Очевидно, что можно доставать всю инфу
за один проход по этому массиву, сэкономить на повторном использовании одних и тех же данных (например, выделенные
массивы браузеров, времени визитов) + не создавать лишних хэшей в памяти (#merge).

```
users_objects.each do |user|
user_key = "#{user.attributes[:first_name]} #{user.attributes[:last_name]}"
sessions_duration = user.sessions.map { |s| s[:time].to_i }
browsers = user.sessions.map { |s| s[:browser].upcase }

report[:usersStats][user_key] = {
sessionsCount: user.sessions.count,
totalTime: "#{sessions_duration.sum} min.",
longestSession: "#{sessions_duration.max} min.",
browsers: browsers.sort.join(DELIMITER),
usedIE: browsers.any? { |b| b =~ IE_PATTERN },
alwaysUsedChrome: browsers.all? { |b| b =~ CHROME_PATTERN },
dates: user.sessions.map { |s| Date.strptime(s[:date], '%Y-%m-%d') }.sort.reverse.map!(&:iso8601)
}
end
```

**Итоговое время: 0.35 сек.**

### Оптимизация №5
После фикса предыдущего места лидером по медленности стал цикл `each` по массиву `users_objects`.
Во-первых, на этом шаге стало видно, что вообще-то от массива объектов `users_objects` никакого особо смысла нет,
можно работать прямо с массивом хэшей `users`, минуя создание лишних объектов.

Во-вторых, параллельно с тем, что мы держим в памяти `users_objects` и хэш с сессиями `sessions_by_users`
мы формируем объект отчёта `report`, куда копируются данные из первых двух, в результате чего потребление памяти растет.

Попробуем итерировать по `users` не с помощью `each`, а в цикле `while` и выкидывать отработанные записи.
Кроме того, можно так же удалять отработанные данные из хэша сессий пользователей (забирать значение с помощью `Hash#delete`).

```
until users.empty?
user = users.shift
user_key = "#{user[:first_name]} #{user[:last_name]}"
user_sessions = sessions_by_users.delete(user[:id]) || []
sessions_duration = user_sessions.map { |s| s[:time].to_i }
browsers = user_sessions.map { |s| s[:browser] }

report[:usersStats][user_key] = {
sessionsCount: user_sessions.count,
totalTime: "#{sessions_duration.sum} min.",
longestSession: "#{sessions_duration.max} min.",
browsers: browsers.sort.join(DELIMITER),
usedIE: browsers.any? { |b| b =~ IE_PATTERN },
alwaysUsedChrome: browsers.all? { |b| b =~ CHROME_PATTERN },
dates: user_sessions.map { |s| Date.strptime(s[:date], '%Y-%m-%d') }.sort.reverse.map!(&:iso8601)
}
end
```

**Итоговое время: 0.3 сек.**

После этого я запустила скрипт на исходном файле. Он отработал за **50 сек**.
В принципе, выглядит неплохо, и можно было бы остановиться.
Проблема в том, что при чтении всего файла в массив резко возрастает объем памяти (более 500Mb),
мы попадаем в ситуацию `memory bloat`, и в случае файла еще бОльшего размера файла это может привести к ошибкам.
Поэтому добавила след. пункт.

### Оптимизация №6
Заменить чтение всего файла в память сразу на построчное чтение.
В результате потребление памяти снизилось на те самые 500Mb, а график роста стал более пологий.
К сожалению, время обработки большого файла выросло при этом с 50 до 52 секунд, но я решила этой разницей пренебречь.

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

*Какими ещё результами можете поделиться*
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить метрику системы с 28-30 секунд до 0.3 сек, т.е. на два порядка.

## Защита от регресса производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы сделано *то, что вы для этого сделали*
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы нужно зафиксировать метрику в значении,
например, 0.5 секунд (с запасом).
Добавить автоматический тест, который прогоняет скрипт с тестовыми данными размером 1Mb, и падает, если по времени не уложился.
На CI добавить. Я не знаю, как правильно пишутся такие тесты, но вижу, что существуют решения https://github.com/piotrmurach/rspec-benchmark
18 changes: 18 additions & 0 deletions files/fixtures/data.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
user,0,Leida,Cira,0
session,0,0,Safari 29,87,2016-10-23
session,0,1,Firefox 12,118,2017-02-27
session,0,2,Internet Explorer 28,31,2017-03-28
session,0,3,Internet Explorer 28,109,2016-09-15
session,0,4,Safari 39,104,2017-09-27
session,0,5,Internet Explorer 35,6,2016-09-01
user,1,Palmer,Katrina,65
session,1,0,Safari 17,12,2016-10-21
session,1,1,Firefox 32,3,2016-12-20
session,1,2,Chrome 6,59,2016-11-11
session,1,3,Internet Explorer 10,28,2017-04-29
session,1,4,Chrome 13,116,2016-12-28
user,2,Gregory,Santos,86
session,2,0,Chrome 35,6,2018-09-21
session,2,1,Safari 49,85,2017-05-22
session,2,2,Firefox 47,17,2018-02-02
session,2,3,Chrome 20,84,2016-11-25
1 change: 1 addition & 0 deletions files/fixtures/expected_report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"totalUsers":3,"uniqueBrowsersCount":14,"totalSessions":15,"allBrowsers":"CHROME 13,CHROME 20,CHROME 35,CHROME 6,FIREFOX 12,FIREFOX 32,FIREFOX 47,INTERNET EXPLORER 10,INTERNET EXPLORER 28,INTERNET EXPLORER 35,SAFARI 17,SAFARI 29,SAFARI 39,SAFARI 49","usersStats":{"Leida Cira":{"sessionsCount":6,"totalTime":"455 min.","longestSession":"118 min.","browsers":"FIREFOX 12, INTERNET EXPLORER 28, INTERNET EXPLORER 28, INTERNET EXPLORER 35, SAFARI 29, SAFARI 39","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-09-27","2017-03-28","2017-02-27","2016-10-23","2016-09-15","2016-09-01"]},"Palmer Katrina":{"sessionsCount":5,"totalTime":"218 min.","longestSession":"116 min.","browsers":"CHROME 13, CHROME 6, FIREFOX 32, INTERNET EXPLORER 10, SAFARI 17","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-04-29","2016-12-28","2016-12-20","2016-11-11","2016-10-21"]},"Gregory Santos":{"sessionsCount":4,"totalTime":"192 min.","longestSession":"85 min.","browsers":"CHROME 20, CHROME 35, FIREFOX 47, SAFARI 49","usedIE":false,"alwaysUsedChrome":false,"dates":["2018-09-21","2018-02-02","2017-05-22","2016-11-25"]}}}
Loading