|
| 1 | +# Case-study оптимизации |
| 2 | + |
| 3 | +## Актуальная проблема |
| 4 | +В нашем проекте возникла серьёзная проблема. |
| 5 | + |
| 6 | +Необходимо было обработать файл с данными, чуть больше ста мегабайт. |
| 7 | + |
| 8 | +У нас уже была программа на `ruby`, которая умела делать нужную обработку. |
| 9 | + |
| 10 | +Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. |
| 11 | + |
| 12 | +Я решил исправить эту проблему, оптимизировав эту программу. |
| 13 | + |
| 14 | +## Формирование метрики |
| 15 | +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: |
| 16 | +* время выполнения программы. |
| 17 | +* потребление памяти |
| 18 | + |
| 19 | +## Гарантия корректности работы оптимизированной программы |
| 20 | +Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. |
| 21 | + |
| 22 | +## Feedback-Loop |
| 23 | +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за 1-2 минуты |
| 24 | + |
| 25 | +Вот как я построил `feedback_loop`: создал файлы с различным количеством строк, чтобы программа могла выполняться за 10–20 секунд. |
| 26 | +После каждого изменения я запускал программу на файлах с разным количеством строк и смотрел на результаты отчетов. |
| 27 | + |
| 28 | +## Вникаем в детали системы, чтобы найти главные точки роста |
| 29 | +Для того, чтобы найти "точки роста" для оптимизации я воспользовался: |
| 30 | +- gem memory_profiler |
| 31 | +- gem ruby-prof и отчеты callstack & qcachegrind |
| 32 | +- gem stackprof + CLI и Speedscope |
| 33 | +- второй thread для мониторинга памяти |
| 34 | + |
| 35 | +Вот какие проблемы удалось найти и решить: |
| 36 | + |
| 37 | +### №1 Уменьшение создания временных массивов при добавлении элементов |
| 38 | +``` |
| 39 | + sessions = sessions + [parse_session(line)] if cols[0] == 'session' |
| 40 | +``` |
| 41 | +- memory_profiler |
| 42 | +- Вместо оператора + было применено использование метода <<, который добавляет элемент в существующий массив без создания нового объекта. |
| 43 | +- До оптимизации программе аллоцировалось 460MB памяти на файле размером 10_000 строк, после оптимизации уже 155MB |
| 44 | +- данная проблема перестала быть главной точкой роста |
| 45 | + |
| 46 | +### №2 Избыточное создание массивов при фильтрации сессий для каждого пользователя |
| 47 | +``` |
| 48 | + user_sessions = sessions.select { |session| session['user_id'] == user['id'] } |
| 49 | +``` |
| 50 | +- memory_profiler |
| 51 | +- Проблема возникает в строке user_sessions = sessions.select { |session| session['user_id'] == user['id'] }, где для каждого пользователя создается новый массив отфильтрованных сессий. Это приводит к повторным обходам большого массива sessions, и для каждого пользователя в памяти хранятся временные массивы, что заметно увеличивает использование памяти. |
| 52 | +- Чтобы избежать повторного обхода массива для каждого пользователя и избыточного создания временных массивов, все сессии были предварительно сгруппированы по user_id с использованием метода group_by. После этого для каждого пользователя мы просто обращаемся к уже сгруппированным данным через хеш (sessions_by_user[user['id']]). |
| 53 | +- До оптимизации программе аллоцировалось 155MB памяти на файле размером 10_000 строк, после оптимизации уже 42MB. |
| 54 | +- данная проблема перестала быть главной точкой роста |
| 55 | + |
| 56 | +### №3 Уменьшение создания временных массивов при добавлении элементов |
| 57 | +``` |
| 58 | + users = users + [parse_user(line)] if cols[0] == 'user' |
| 59 | +``` |
| 60 | +- memory_profiler |
| 61 | +- Вместо оператора + было применено использование метода <<, который добавляет элемент в существующий массив без создания нового объекта. |
| 62 | +- До оптимизации программе аллоцировалось 636MB памяти на файле размером 50_000 строк, после оптимизации уже 400MB |
| 63 | +- данная проблема перестала быть главной точкой роста |
| 64 | + |
| 65 | +### №4 Уменьшение создания временных массивов при добавлении элементов |
| 66 | +``` |
| 67 | + users_objects = users_objects + [user_object] |
| 68 | +``` |
| 69 | +- memory_profiler |
| 70 | +- Вместо оператора + было применено использование метода <<, который добавляет элемент в существующий массив без создания нового объекта. |
| 71 | +- До оптимизации программе аллоцировалось 400MB памяти на файле размером 50_000 строк, после оптимизации уже 160MB |
| 72 | +- Данная проблема перестала быть главной точкой роста |
| 73 | + |
| 74 | +### №5 Излишний парсинг дат и преобразование в формат iso8601 |
| 75 | +``` |
| 76 | + { 'dates' => user.sessions.map{|s| s['date']}.map {|d| Date.parse(d)}.sort.reverse.map { |d| d.iso8601 } } |
| 77 | +``` |
| 78 | +- qcachegrind из ruby-prof |
| 79 | +- Так как мы уже имеем данные в нужном формате, то было принято решение не тратить время на преобразование даты в формат iso8601 |
| 80 | +- До оптимизации программе аллоцировалось 160MB памяти на файле размером 50_000 строк, после оптимизации уже 120MB |
| 81 | +- Данная проблема перестала быть главной точкой роста |
| 82 | + |
| 83 | +### №6 Избыточное создание временных массивов |
| 84 | +``` |
| 85 | +cols = line.split(',') |
| 86 | +``` |
| 87 | +- memory_profiler |
| 88 | +- Вместо разделения строки на части с помощью split и проверки первого элемента, я решил использовать метод start_with? |
| 89 | +- До оптимизации программе аллоцировалось 120MB памяти на файле размером 50_000 строк, после оптимизации уже 100MB. |
| 90 | +- Данная проблема перестала быть главной точкой роста |
| 91 | + |
| 92 | +### №7 Избыточное потребление памяти из-за создания новых хэшей при merge |
| 93 | +``` |
| 94 | + report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user)) |
| 95 | +``` |
| 96 | +- memory_profiler |
| 97 | +- Был использован метод merge! вместо merge, который изменяет оригинальный хэш на месте, избегая лишнего копирования данных |
| 98 | +- До оптимизации программе аллоцировалось 200MB памяти на файле размером 100_000 строк, после оптимизации уже 184MB. |
| 99 | +- Данная проблема перестала быть главной точкой роста |
| 100 | + |
| 101 | +### №X |
| 102 | +``` |
| 103 | + report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user)) |
| 104 | +``` |
| 105 | +- какой отчёт показал главную точку роста |
| 106 | +- как вы решили её оптимизировать |
| 107 | +- как изменилась метрика |
| 108 | +- как изменился отчёт профилировщика |
| 109 | + |
| 110 | +## Результаты |
| 111 | +В результате проделанной оптимизации наконец удалось обработать файл с данными. |
| 112 | +Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* и уложиться в заданный бюджет. |
| 113 | + |
| 114 | +*Какими ещё результами можете поделиться* |
| 115 | + |
| 116 | +## Защита от регрессии производительности |
| 117 | +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали* |
| 118 | + |
| 119 | + |
| 120 | + |
| 121 | +подготовил файлы 10_000 и 100_000 строк для тестирования |
| 122 | +``` |
| 123 | +head -n N data_large.txt > dataN.txt # create smaller file from larger (take N first lines) |
| 124 | +``` |
| 125 | + |
| 126 | + |
| 127 | + |
0 commit comments