Skip to content

Commit 00027fa

Browse files
committed
init
1 parent 9c9410e commit 00027fa

File tree

6 files changed

+180
-64
lines changed

6 files changed

+180
-64
lines changed

Gemfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
source "https://rubygems.org"
2+
gem "ruby-prof"
3+
# gem "rspec-benchmark"
4+
gem "ruby-progressbar"
5+
gem "minitest"
6+
gem "memory_profiler"
7+
gem "stackprof"

Gemfile.lock

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
GEM
2+
remote: https://rubygems.org/
3+
specs:
4+
memory_profiler (1.1.0)
5+
minitest (5.25.4)
6+
ruby-prof (1.6.3)
7+
ruby-progressbar (1.13.0)
8+
stackprof (0.2.27)
9+
10+
PLATFORMS
11+
arm64-darwin-23
12+
ruby
13+
14+
DEPENDENCIES
15+
memory_profiler
16+
minitest
17+
ruby-prof
18+
ruby-progressbar
19+
stackprof
20+
21+
BUNDLED WITH
22+
2.4.10

app.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require_relative 'task-2'
2+
require 'benchmark'
3+
require 'memory_profiler'
4+
5+
report = MemoryProfiler.report do
6+
work(file_name = '50000.txt', disable_gc =false)
7+
end
8+
report.pretty_print(scale_bytes: true)

case-study-template.md

Lines changed: 0 additions & 55 deletions
This file was deleted.

case-study.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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+

task-2.rb

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,25 @@ def collect_stats_from_users(report, users_objects, &block)
3939
users_objects.each do |user|
4040
user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}"
4141
report['usersStats'][user_key] ||= {}
42-
report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user))
42+
report['usersStats'][user_key] = report['usersStats'][user_key].merge!(block.call(user))
4343
end
4444
end
4545

46-
def work
47-
file_lines = File.read('data.txt').split("\n")
46+
def work(file_path = 'data.txt', disable_gc = false)
47+
GC.disable if disable_gc
48+
49+
file_lines = File.read(file_path).split("\n")
4850

4951
users = []
5052
sessions = []
5153

5254
file_lines.each do |line|
53-
cols = line.split(',')
54-
users = users + [parse_user(line)] if cols[0] == 'user'
55-
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
55+
case
56+
when line.start_with?('user,')
57+
users << parse_user(line)
58+
when line.start_with?('session,')
59+
sessions << parse_session(line)
60+
end
5661
end
5762

5863
# Отчёт в json
@@ -96,11 +101,13 @@ def work
96101
# Статистика по пользователям
97102
users_objects = []
98103

104+
sessions_by_user = sessions.group_by { |session| session['user_id'] }
105+
99106
users.each do |user|
100107
attributes = user
101-
user_sessions = sessions.select { |session| session['user_id'] == user['id'] }
108+
user_sessions = sessions_by_user[user['id']] || []
102109
user_object = User.new(attributes: attributes, sessions: user_sessions)
103-
users_objects = users_objects + [user_object]
110+
users_objects << user_object
104111
end
105112

106113
report['usersStats'] = {}
@@ -137,7 +144,7 @@ def work
137144

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

143150
File.write('result.json', "#{report.to_json}\n")

0 commit comments

Comments
 (0)