В нашем проекте возникла серьёзная проблема.
Необходимо было обработать файл с данными, чуть больше ста мегабайт.
У нас уже была программа на ruby, которая умела делать нужную обработку.
Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время.
Я решил исправить эту проблему, оптимизировав эту программу.
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: программа должна потреблять меньше 70Мб памяти при обработке файла data_large.txt
Предварительно на
- 10_000 строк из файла используется MEMORY USAGE: без GB 360 MB(c GB 82 MB) скороть работы 1.765 сек
- 20_000 строк из файла используется MEMORY USAGE: без GB 1292 MB(c GB 92) скороть работы 7.57 сек
Видно что с увеличеине объема данных в 2 раза, память и время увеличивается без GB (1292 / 360 ≈ 3.59) почти в 4 раза это почти квадратичная сложность O(n^2) C GB ситуация лучше, но надо понимать что это большая нагрузка на постоянную очистку.
Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации.
Так же добавился тест чтобы проверить используемую память и отслеживать изменения
Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный feedback-loop, который позволил мне получать обратную связь по эффективности сделанных изменений за время, которое у вас получилось
Вот как я построил feedback_loop:
- поделил входные данные на по 10_000, 20_000 и тд
-
- для быстрого сбора всех отчетов
SIZE=10000 make all_reports - подобрал данные по которым видны точки роста
- провел анализ всех отчтетов и поредедлил точку роста
- внес правки
- запустил все отчтеты
- если результат есть, закомитился
- зациклил весь 2й пункт
- для быстрого сбора всех отчетов
Для того, чтобы найти "точки роста" для оптимизации я воспользовался в освновном использовались данные из benchmark и memory_profiler, иногда для разнообразия смотрел через qcachegrind.
Вот какие проблемы удалось найти и решить
- по отчету memory_profiler первоночально показала что проблема есть тут:
MEMORY USAGE: БЕЗ GB 360 MB (c GB 38 MB)
allocated memory by location
-----------------------------------
287.67 MB rails-optimization-task2/work.rb:56
allocated memory by class
-----------------------------------
416.75 MB Array
=>
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
подрзреваем что проблема с массивами
- первым решением заменить сложение массивов на
sessions << parse_session(line) - по результатам изменений видим что потребление памяти уменьшилось в данном месте c 287 MB -> 0.4 MB что означает уменьшение в 717.5 раз
MEMORY USAGE: 77 MB
allocated memory by location
-----------------------------------
400.00 kB rails-optimization-task2/work.rb:56
скорость выполнения почти не поменялась 1.765 -> 1.678075
- Тк определили что складывать массивы затраратно сразу определяем похожие места
users = users + [parse_user(line)]
9.97 MB rails-optimization-task2/work.rb:55
users_objects = users_objects + [user_object]
9.57 MB rails-optimization-task2/work.rb:104
заменяем на операцию добавления в конец массива
- по резульататам всех изменений получаем
MEMORY USAGE: 58 MB
allocated memory by location
-----------------------------------
400.00 kB rails-optimization-task2/work.rb:56
400.00 kB rails-optimization-task2/work.rb:55
rails-optimization-task2/work.rb:104 # даже не попал в отчет
allocated memory by class
-----------------------------------
110.48 MB Array
15.22 MB String
- для большей наглядности повысим файл нагрузки до 20_000 (SIZE=20000 make all_reports)
замеры через benchmark и сисемный замер памяти
SIZE 20000
MEMORY USAGE: без GB 95 MB (c GB 54 MB)
6.934339999977965
замеры через memory_profiler.rb
MEMORY USAGE: 207 MB
Total allocated: 476.27 MB (887788 objects)
Total retained: 4.14 kB (9 objects)
413.26 MB rails-optimization-task2/work.rb:102
allocated memory by class
-----------------------------------
425.92 MB Array
30.41 MB String
подозреваем 102 строку user_sessions = sessions.select { |session| session['user_id'] == user['id'] }, тут происходит работа с select
в оффициальной документации
select () - Returns a new array containing all elements of
- как решение вводим HASH и ищем через него.
sessions_by_users = sessions.group_by { |session| session['user_id'] }
users.each do |user|
attributes = user
user_object = User.new(attributes: attributes, sessions: sessions_by_users[user['id']] || [])
users_objects << user_object
end
- смотрим на результат, память потребляемя уменьшилась не не сильно с 95 до 84 MB (c GB 54 -> 42)
SIZE 20000
MEMORY USAGE: 84 MB (с GB 42 MB)
0.3854719999944791
но потребляемая память в конкретном месте уменьшилась до
700.82 kB rails-optimization-task2/work.rb:100
633.61 kB rails-optimization-task2/work.rb:103
allocated memory by class
-----------------------------------
30.53 MB String
14.24 MB Hash
- исходя из отчетов большую часть памяти занимает цикл, когда мы проходим по всем строкам файла. Посмотрев через qcachegrind, и memory_profiler находим новуб точку роста это split
MEMORY USAGE: 84 MB(c GB 42 MB)
9.48 MB rails-optimization-task2/work.rb:54
8.14 MB rails-optimization-task2/work.rb:28
- заменяб сохранение после split не в массив, а в переннеыю
type, user_id, second, third, fourth, fifth = line.split(','), так жеparse_userиparse_sessionубрал split и пользуюсь уже готовыми переменными. - Резульатты улучшения появились, уменьшенеие но не крититчно всег она 14 MB
SIZE 20000
MEMORY USAGE: 70 MB (c GB 32 MB)
- Подняд нагрузку до 200_000
- Воспользовшись подсказкой о том что надо посмотреть на потоковую реализацию, сделал акцент на то а как память уделяется на по объектам:
SIZE 200000
MEMORY USAGE: 515 MB (c GB 274 MB)
4.325512000000344
allocated memory by class
-----------------------------------
108.42 MB String
71.43 MB Hash
44.68 MB Array
- так же вижу что начинает расти размер память на чтение файла, соответвенно при использовании максимального файла будет только ухуодшаться:
12.94 MB rails-optimization-task2/work.rb:46
-
Было принято решение переписать логику
- читать с файла по строкам и сразу собирать статистику
- записывать в файл результат тоже сразу в новый файл
- хранить уникальные бразуеры через Set
- соотвественно вся статитскика собирается на лету не хранится в памяти
-
Как результат измеенний собераем бенчмарки: без GB
SIZE 200000
MEMORY USAGE: 198 MB
0.8822719999998299
с GB
SIZE 200000
MEMORY USAGE: 20 MB
0.5364520000002813
- Пробуем запустить _large файл, видем что всё удовлетворяет условиям мы уложились в заданый бюджет.
SIZE _large
MEMORY IN PROCESS USAGE: 21 MB
MEMORY IN PROCESS USAGE: 21 MB
MEMORY USAGE RESULT: 22 MB
MEMORY USAGE: 22 MB
8.345943000000261
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить метрику системы с потребления памяти примерно в 22 Мб и уложиться в заданный бюджет.
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы были написан перформанс тест, который следит за потребляемой памятью