Skip to content
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

Task 2 #118

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
164 changes: 164 additions & 0 deletions case-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Case-study оптимизации

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

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

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

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

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

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику на каждой итерации оптимизации:
1) отчет профилировщика должен поменять главную точку роста
2) обработка файла должна ускориться / потребление памяти должно уменьшиться
Copy link
Collaborator

Choose a reason for hiding this comment

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

это не метрика, это условие того что мы считаем изменение успешным

метрика это число просто какое-то, которое понятно как вычислять и которое характеризует нашу систему; в данном случае из метрик тут время работы и потребление памяти на соответствующей итерации оптимизации


Итоговая метрика: программа должна обрабатывать файл и не потреблять больше 70 MB памяти

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

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

Choose a reason for hiding this comment

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

10-15 секунд супер


Вот как я построил `feedback_loop`:
1) взять кол-во данных, время обработки которых не превышает 5 секунд
2) построить отчет профилировщика и найти главную точку роста
3) оптимизировать эту точку
4) запустить тесты
5) проверить отчет профилировщика
6) проверить временные показатели

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

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

Для тренировки прошлась по исходному файлу и нашла места, где можно оптимизировать память
Написала runner, в котором запускается программа и второй поток, который мониторит потребляемую память и записывает ее в файл 1 раз в секунду
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 👍


Показатели до оптимизации для 20_000 строк:
```
INITIAL MEMORY USAGE: 19 MB
MEMORY USAGE: 20 MB
MEMORY USAGE: 199 MB
MEMORY USAGE: 232 MB
```

### Ваша находка №1
1) Объем данных: 20_000, время: 2.859999
2) Профилировщик: memory_profiler
3) Главная точка роста: ```sessions = sessions + [parse_session(line)] if cols[0] == 'session'```: 1.15 GB из 1.70 GB за всю работу
```
allocated memory by class
-----------------------------------
1.65 GB Array
```
4) ```sessions = sessions + [parse_session(line)]``` -> ```sessions << parse_session(line)```
5) Метрики профайлера:
```
allocated memory by file: 551.33 MB
Оптимизированная строка: 800.00 kB
```
6) Главная точка роста поменялась
7) Время обработки: 2.815428

### Ваша находка №2
1) Объем данных: 20_000, время: 2.815428
2) Профилировщик: stackprof
3) Главная точка роста: String#split, 32.7%. Из них 40.4% в parse_session
4) Уберем двойное разбиение строки и передадим в метод уже готовый массив
5) Метрики профайлера: String#split уменьшился до 22.9%, кол-во аллокаций 293909 -> 175231, общее кол-во аллокаций: 900013 -> 764381
6) Главная точка роста частично поменялась (все еще String#split, но уже в другом методе)
7) Время обработки: 2.759818

### Ваша находка №3
1) Объем данных: 20_000, время: 2.754138
2) Профилировщик: ruby-prof
3) Главная точка роста по аллокациям:
```
Allocations: 61.14% (61.14%) Object#collect_stats_from_users -> 24.40% (73.45%) <Class::Date>#parse -> 4.44% Regexp#match
Memory: 65.74% (65.74%) Object#collect_stats_from_users -> 22.94% (70.95%) <Class::Date>#parse -> 5.26% Regexp#match
```
4) Так как дата приходит в уже правильном формате, то просто уберем парсинг даты
5) Главная точка роста поменялась
6) Время обработки: 2.712649

Показатели после оптимизации для 20_000 строк:
```
INITIAL MEMORY USAGE: 22 MB
MEMORY USAGE: 23 MB
MEMORY USAGE: 80 MB
MEMORY USAGE: 116 MB
FINAL MEMORY USAGE: 116 MB
```

### На этом закончим оптимизацию текущей программы и перепишем ее на потоковую обработку

После перевода программы на потоковую обработку потребление памяти на большом файле не превышает 20Мб и время сократилось до ~6-7 секунд
(возможно я слишком радикально поняла задание переписать ее на потоковую обработку и поменяла в ней сразу все, что для меня выглядело не оптимально)
```
INITIAL MEMORY USAGE: 18 MB
MEMORY USAGE: 18 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
FINAL MEMORY USAGE: 19 MB
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 👍

```

## Продолжим вникать в детали системы, чтобы найти главные точки роста

### Ваша находка №1
1) отчет memory_profiler:
основная точка роста ```12.05 MB: type, *info = line.strip.split(LINE_SEPARATOR)```
2) оптимизация: заменим strip на strip!
3) ```10.45 MB: type, *info = line.strip!.split(LINE_SEPARATOR)```
4) метрика изменилась, но главная точка роста все еще та же

### Ваша находка №2
1) отчет memory_profiler:
основная точка роста ```10.45 MB: type, *info = line.strip.split(LINE_SEPARATOR)```
2) заметим, что строка session аллоцируется 16_954 из 20_000 - на каждой строке, которая отновится к сессиям
3) попробовала разные вариации - от удаления префикса строки с дальнейшим разбиением до прсто сплита по подстроке. Удаление префикса позволяет избавиться от лишних аллокаций, но прибавляет потребление памяти в процессе, и код теряет свою читаемость. Остальные варианты просто снижают читаемость кода и добавляют потребление памяти
4) поэтому решила что на текущем этапе при потреблении 19МБ за любое кол-во строк при 18МБ на старте - это оптимальное решение и дальше оптимизировать смысла нет

### Ваша находка №3
1) также из отчета видно, что аллоцируются новые одинаковые строки при записи в отчет на каждого пользователя, потому что идет преобразование из symbol в string ключей данных по пользователю (стр. 82)
2) также оставила как есть, так как потребление памяти не превышает 19МБ. Можно было бы оптимизировать сделав константы этих строк в базовом классе, но мне кажется это было бы неудобно для дальнейшего использования

## Результаты
В результате проделанной оптимизации удалось обработать файл с данными.
Удалось улучшить метрику системы с 232 MB для 20_000 строк до 19 MB для любого кол-ва данных и уложиться в заданный бюджет.
```
INITIAL MEMORY USAGE: 18 MB
MEMORY USAGE: 18 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
FINAL MEMORY USAGE: 19 MB
```

Но, если взять граничное значение в виде один юзер и все сессии (кол-во сессий как в файле large), то память конечно раздувается, вероятно из-за того что происходит накопление не уникальных браузеров / дат, и из-за этого время ухудшается до ~8 секунд
Copy link
Collaborator

Choose a reason for hiding this comment

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

nice catch с краевым случаем!

```
INITIAL MEMORY USAGE: 18 MB
MEMORY USAGE: 18 MB
MEMORY USAGE: 137 MB
MEMORY USAGE: 242 MB
MEMORY USAGE: 336 MB
MEMORY USAGE: 410 MB
MEMORY USAGE: 661 MB
FINAL MEMORY USAGE: 695 MB
```

## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы я написала перфоманс тест на время и аллокацию памяти.
Правда в данном случае нас больше интересует чтобы программа не потребляла больше Х памяти в течение всего выполнения, а тест проверяет общее кол-во аллоцированной памяти, что не совсем подходит для текущей задачи.
File renamed without changes.
File renamed without changes.
9 changes: 9 additions & 0 deletions memory_usage.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
INITIAL MEMORY USAGE: 18 MB
MEMORY USAGE: 18 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
MEMORY USAGE: 19 MB
FINAL MEMORY USAGE: 19 MB
78 changes: 78 additions & 0 deletions runner.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

require_relative 'task-2'
require 'benchmark'
require 'memory_profiler'
require 'stackprof'
require 'ruby-prof'

size, mode, profiler = ARGV
FILENAME = "data/data#{size}.txt"

def memory_usage
(`ps -o rss= -p #{Process.pid}`.to_i / 1024)
end

def run_stackprof
StackProf.run(mode: :object, out: 'stackprof_reports/stackprof.dump', raw: true) do
work(FILENAME)
end
end

def run_memory_profiler
report = MemoryProfiler.report do
work(FILENAME)
end

report.pretty_print(scale_bytes: true)
end

def run_ruby_prof(measure_mode)
result = RubyProf::Profile.profile(track_allocations: true, measure_mode: measure_mode) do
work(FILENAME)
end

printer = RubyProf::GraphHtmlPrinter.new(result)
printer.print(File.open("ruby_prof_reports/graph_#{measure_mode}.html", 'w+'), :min_percent=>0)

printer = RubyProf::CallStackPrinter.new(result)
printer.print(File.open("ruby_prof_reports/callstack_#{measure_mode}.html", 'w+'))
end

def run_profiler(profiler)
case profiler
when 'stackprof'
run_stackprof
when 'memory_profiler'
run_memory_profiler
when 'ruby-prof-memory'
run_ruby_prof(:memory)
when 'ruby-prof-allocations'
run_ruby_prof(:allocations)
else
puts "Unknown profiler type: #{profiler}"
end
end

def run_memory_monitor
io = File.open('memory_usage.txt', 'w')
io << format("INITIAL MEMORY USAGE: %d MB\n", memory_usage)
monitor_thread = Thread.new do
while true
io << format("MEMORY USAGE: %d MB\n", memory_usage)
sleep(1)
end
ensure
io << format("FINAL MEMORY USAGE: %d MB\n", memory_usage)
io.close
end

work(FILENAME)
monitor_thread.kill
end

run_memory_monitor if mode == 'memory'

puts Benchmark.measure { work(FILENAME) } if mode == 'time'

run_profiler(profiler) if mode == 'profile'
143 changes: 143 additions & 0 deletions task-2-deoptimized.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Deoptimized version of homework task

require 'json'
require 'pry'
require 'date'

class User
attr_reader :attributes, :sessions

def initialize(attributes:, sessions:)
@attributes = attributes
@sessions = sessions
end
end

def parse_user(user)
fields = user.split(',')
parsed_result = {
'id' => fields[1],
'first_name' => fields[2],
'last_name' => fields[3],
'age' => fields[4],
}
end

def parse_session(fields)
parsed_result = {
'user_id' => fields[1],
'session_id' => fields[2],
'browser' => fields[3],
'time' => fields[4],
'date' => fields[5],
}
end

def collect_stats_from_users(report, users_objects, &block)
users_objects.each do |user|
user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}"
report['usersStats'][user_key] ||= {}
report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user))
end
end

def work(filename)
file_lines = File.read(filename).split("\n")

users = []
sessions = []

file_lines.each do |line|
cols = line.split(',')
users = users + [parse_user(line)] if cols[0] == 'user'
sessions << parse_session(cols) if cols[0] == 'session'
end

# Отчёт в json
# - Сколько всего юзеров +
# - Сколько всего уникальных браузеров +
# - Сколько всего сессий +
# - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом +
#
# - По каждому пользователю
# - сколько всего сессий +
# - сколько всего времени +
# - самая длинная сессия +
# - браузеры через запятую +
# - Хоть раз использовал IE? +
# - Всегда использовал только Хром? +
# - даты сессий в порядке убывания через запятую +

report = {}

report[:totalUsers] = users.count

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

report['uniqueBrowsersCount'] = uniqueBrowsers.count

report['totalSessions'] = sessions.count

report['allBrowsers'] =
sessions
.map { |s| s['browser'] }
.map { |b| b.upcase }
.sort
.uniq
.join(',')

# Статистика по пользователям
users_objects = []

users.each do |user|
attributes = user
user_sessions = sessions.select { |session| session['user_id'] == user['id'] }
user_object = User.new(attributes: attributes, sessions: user_sessions)
users_objects = users_objects + [user_object]
end

report['usersStats'] = {}

# Собираем количество сессий по пользователям
collect_stats_from_users(report, users_objects) do |user|
{ 'sessionsCount' => user.sessions.count }
end

# Собираем количество времени по пользователям
collect_stats_from_users(report, users_objects) do |user|
{ 'totalTime' => user.sessions.map {|s| s['time']}.map {|t| t.to_i}.sum.to_s + ' min.' }
end

# Выбираем самую длинную сессию пользователя
collect_stats_from_users(report, users_objects) do |user|
{ 'longestSession' => user.sessions.map {|s| s['time']}.map {|t| t.to_i}.max.to_s + ' min.' }
end

# Браузеры пользователя через запятую
collect_stats_from_users(report, users_objects) do |user|
{ 'browsers' => user.sessions.map {|s| s['browser']}.map {|b| b.upcase}.sort.join(', ') }
end

# Хоть раз использовал IE?
collect_stats_from_users(report, users_objects) do |user|
{ 'usedIE' => user.sessions.map{|s| s['browser']}.any? { |b| b.upcase =~ /INTERNET EXPLORER/ } }
end

# Всегда использовал только Chrome?
collect_stats_from_users(report, users_objects) do |user|
{ 'alwaysUsedChrome' => user.sessions.map{|s| s['browser']}.all? { |b| b.upcase =~ /CHROME/ } }
end

# Даты сессий через запятую в обратном порядке в формате iso8601
collect_stats_from_users(report, users_objects) do |user|
{ 'dates' => user.sessions.map{|s| s['date']}.sort.reverse }
end

File.write('result.json', "#{report.to_json}\n")
puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024)
end
Loading