-
Notifications
You must be signed in to change notification settings - Fork 137
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
base: master
Are you sure you want to change the base?
Task 2 #118
Changes from all commits
5f0eb2c
55596af
db0a70c
0722c9a
018c7ee
f181418
68575d9
170c2a8
5499596
81efe65
010a3f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
# Case-study оптимизации | ||
|
||
## Актуальная проблема | ||
В нашем проекте возникла серьёзная проблема. | ||
|
||
Необходимо было обработать файл с данными, чуть больше ста мегабайт. | ||
|
||
У нас уже была программа на `ruby`, которая умела делать нужную обработку. | ||
|
||
Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. | ||
|
||
Я решила исправить эту проблему, оптимизировав эту программу. | ||
|
||
## Формирование метрики | ||
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику на каждой итерации оптимизации: | ||
1) отчет профилировщика должен поменять главную точку роста | ||
2) обработка файла должна ускориться / потребление памяти должно уменьшиться | ||
|
||
Итоговая метрика: программа должна обрабатывать файл и не потреблять больше 70 MB памяти | ||
|
||
## Гарантия корректности работы оптимизированной программы | ||
Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. | ||
|
||
## Feedback-Loop | ||
Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за 10-15 секунд | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 раз в секунду | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 секунд | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
``` | ||
|
||
## Защита от регрессии производительности | ||
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы я написала перфоманс тест на время и аллокацию памяти. | ||
Правда в данном случае нас больше интересует чтобы программа не потребляла больше Х памяти в течение всего выполнения, а тест проверяет общее кол-во аллоцированной памяти, что не совсем подходит для текущей задачи. |
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 |
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' |
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
это не метрика, это условие того что мы считаем изменение успешным
метрика это число просто какое-то, которое понятно как вычислять и которое характеризует нашу систему; в данном случае из метрик тут время работы и потребление памяти на соответствующей итерации оптимизации