Skip to content

Commit 71b5ca2

Browse files
author
maksim.rabau
committed
Perform optimization
1 parent 3f9982d commit 71b5ca2

File tree

4 files changed

+190
-74
lines changed

4 files changed

+190
-74
lines changed

benchmark.rb

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
require 'benchmark/ips'
2+
require_relative 'task-1.rb'
3+
4+
Benchmark.ips do |x|
5+
# The default is :stats => :sd, which doesn't have a configurable confidence
6+
# confidence is 95% by default, so it can be omitted
7+
x.config(
8+
stats: :bootstrap,
9+
confidence: 95,
10+
)
11+
12+
x.report("slow string concatenation") do
13+
work('data_large.txt', disable_gc: false)
14+
end
15+
end

case-study.md

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Case-study оптимизации
2+
3+
## Актуальная проблема
4+
В нашем проекте возникла серьёзная проблема.
5+
6+
Необходимо было обработать файл с данными, чуть больше ста мегабайт.
7+
8+
У нас уже была программа на `ruby`, которая умела делать нужную обработку.
9+
10+
Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время.
11+
12+
Я решил исправить эту проблему, оптимизировав эту программу.
13+
14+
## Формирование метрики
15+
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *время выполнения в секундах*
16+
17+
## Гарантия корректности работы оптимизированной программы
18+
Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации.
19+
20+
## Feedback-Loop
21+
Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *29 секунд*
22+
23+
Вот как я построил `feedback_loop`:
24+
1) Использовал профилировщик `RubyProf Flat` с выключенным GC (по началу на объеме в 50к строк)
25+
2) Находил главную точку роста и начинал править ровно с этого места
26+
3) Перезапускал профилировщик повторно и смотрел на полученный результат + непосредственно сам скрипт с включенным GC
27+
4) Увеличивал объем данных и повторно начинал с пункта 1)
28+
29+
## Вникаем в детали системы, чтобы найти главные точки роста
30+
Для того, чтобы найти "точки роста" для оптимизации я воспользовался `RubyProf Flat`
31+
32+
Вот какие проблемы удалось найти и решить
33+
34+
### Ваша находка №1
35+
```
36+
user_sessions = sessions.select { |session| session['user_id'] == user['id'] }
37+
```
38+
- вместо полного перебора через select использовал
39+
```
40+
sessions_grouped = sessions.group_by {|session| session['user_id']}
41+
user_sessions = sessions_grouped[user['id']]
42+
```
43+
- На объеме данных в 50к строк скрипт работал +- 2 секунды
44+
- `Array#select` упал с 80% до 0%
45+
46+
### Ваша находка №2
47+
```
48+
file_lines.each do |line|
49+
cols = line.split(',')
50+
users = users + [parse_user(line)] if cols[0] == 'user'
51+
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
52+
end
53+
```
54+
- На полном объёме данных выжирает всю оперативу, процесс убивается системой (видно в syslog). Переписал всю логику на использование массива объектов классов User и Session вместо хранения в виде массива строк
55+
- Программа перестала убиваться системой
56+
57+
### Ваша находка №3
58+
- `RubyProf Flat` показывает большой % использования `Array#each`
59+
- Видно, что логика для построения статистики по каждому пользователю написана криво с использованием огромного кол-ва переборов. Нет смысла каждый раз итерироваться по всему массиву при построении отдельной статистики (т.е нет смысла начинать итерацию с нуля при построении sessionsCount, totalTime, longestSession, browsers, usedIE, alwaysUsedChrome и dates - можно строить все нужные статистики на текущей итерации)
60+
- После рефакторинга `Array#each` упал до 13.36%
61+
62+
### Ваша находка №4
63+
- `RubyProf Flat` показывает большой % использования `Array#map`
64+
- Некоторые метрики по пользователям (totalTime + longestSession, browsers + usedIE + alwaysUsedChrome) используют одинаковые куски кода с map. Можно использовать мемоизацию. Также заметно бросается в глаза двойной вызов `map` - в некоторых местах достаточно его вызывать лишь один раз
65+
- После рефакторинга `Array#map` до 10.18% соответственно
66+
67+
### Ваша находка №5
68+
- `RubyProf Flat` показывает большой % использования `Date#parse`
69+
- Обратив внимание на структуру данных, видно, что конструкции типа `Date#parse` можно опустить вообще - использование `.sort.reverse' более чем достаточно
70+
- После рефакторинга `Date#parse` упал до 0% соответственно
71+
72+
## Результаты
73+
В результате проделанной оптимизации наконец удалось обработать файл с данными.
74+
Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* и уложиться в заданный бюджет.
75+
76+
## Защита от регрессии производительности
77+
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написал скрипт с использованием `benchmark/ips` для замера производительности. Итоговый результат:
78+
```
79+
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux]
80+
Warming up --------------------------------------
81+
slow string concatenation
82+
1.000 i/100ms
83+
Calculating -------------------------------------
84+
slow string concatenation
85+
0.034 (± 0.0%) i/s - 1.000 in 29.189983s
86+
with 95.0% confidence
87+
Run options: --seed 39795
88+
89+
# Running:
90+
91+
.
92+
93+
Finished in 0.008757s, 114.2005 runs/s, 114.2005 assertions/s.
94+
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
95+
```
96+

rubyprof_flat.rb

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
require 'ruby-prof'
2+
require_relative 'task-1.rb'
3+
4+
RubyProf.measure_mode = RubyProf::WALL_TIME
5+
6+
result = RubyProf.profile do
7+
work('data_large.txt', disable_gc: true)
8+
end
9+
10+
printer = RubyProf::FlatPrinter.new(result)
11+
printer.print(File.open("ruby_prof_reports/flat.txt", "w+"))

task-1.rb

+68-74
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,43 @@
55
require 'date'
66
require 'minitest/autorun'
77

8+
USER_STATS = {
9+
'sessionsCount' => -> (user) { user.sessions.count },
10+
'totalTime' => -> (user) { user.sessions_time.sum.to_s + ' min.' },
11+
'longestSession' => -> (user) { user.sessions_time.max.to_s + ' min.' },
12+
'browsers' => -> (user) { user.sessions_browsers },
13+
'usedIE' => -> (user) { user.sessions_browsers.include? 'INTERNET EXPLORER' },
14+
'alwaysUsedChrome' => -> (user) { user.sessions_browsers.split(',').uniq.all? { |b| b.upcase =~ /CHROME/ } },
15+
'dates' => -> (user) { user.sessions.map{ |s| s.attributes['date'] }.sort.reverse }
16+
}
17+
818
class User
919
attr_reader :attributes, :sessions
1020

11-
def initialize(attributes:, sessions:)
21+
def initialize(attributes:)
22+
@attributes = attributes
23+
@sessions = []
24+
end
25+
26+
def add_session(session)
27+
@sessions << session
28+
end
29+
30+
def sessions_time
31+
@sessions_time ||= sessions.map {|s| s.attributes['time'].to_i}
32+
end
33+
34+
def sessions_browsers
35+
@sessions_browsers ||= sessions.map {|s| s.attributes['browser'].upcase}.sort.join(', ')
36+
end
37+
end
38+
39+
class Session
40+
attr_reader :attributes, :time
41+
42+
def initialize(attributes:)
1243
@attributes = attributes
13-
@sessions = sessions
44+
@time = attributes['time']
1445
end
1546
end
1647

@@ -35,26 +66,13 @@ def parse_session(session)
3566
}
3667
end
3768

38-
def collect_stats_from_users(report, users_objects, &block)
39-
users_objects.each do |user|
40-
user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}"
41-
report['usersStats'][user_key] ||= {}
42-
report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user))
43-
end
69+
def collect_stats_from_users(report, user, stat, block)
70+
user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}"
71+
report['usersStats'][user_key] ||= {}
72+
report['usersStats'][user_key][stat] = block.call(user)
4473
end
4574

46-
def work
47-
file_lines = File.read('data.txt').split("\n")
48-
49-
users = []
50-
sessions = []
51-
52-
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'
56-
end
57-
75+
def work(filepath, options = {})
5876
# Отчёт в json
5977
# - Сколько всего юзеров +
6078
# - Сколько всего уникальных браузеров +
@@ -70,74 +88,50 @@ def work
7088
# - Всегда использовал только Хром? +
7189
# - даты сессий в порядке убывания через запятую +
7290

73-
report = {}
91+
GC.disable if options[:disable_gc]
7492

75-
report[:totalUsers] = users.count
93+
file_lines = File.read(filepath).split("\n")
7694

77-
# Подсчёт количества уникальных браузеров
78-
uniqueBrowsers = []
79-
sessions.each do |session|
80-
browser = session['browser']
81-
uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser }
82-
end
95+
users_objects = []
96+
sessions_objects = []
97+
report = {}
8398

84-
report['uniqueBrowsersCount'] = uniqueBrowsers.count
99+
file_lines.each_slice(10000) do |batch|
100+
batch.each do |line|
101+
cols = line.split(',')
85102

86-
report['totalSessions'] = sessions.count
103+
if cols[0] == 'user'
104+
user_attributes = parse_user(line)
87105

88-
report['allBrowsers'] =
89-
sessions
90-
.map { |s| s['browser'] }
91-
.map { |b| b.upcase }
92-
.sort
93-
.uniq
94-
.join(',')
106+
users_objects << User.new(attributes: user_attributes)
107+
elsif cols[0] == 'session'
108+
session_attributes = parse_session(line)
109+
session_object = Session.new(attributes: session_attributes)
95110

96-
# Статистика по пользователям
97-
users_objects = []
98-
99-
users.each do |user|
100-
attributes = user
101-
user_sessions = sessions.select { |session| session['user_id'] == user['id'] }
102-
user_object = User.new(attributes: attributes, sessions: user_sessions)
103-
users_objects = users_objects + [user_object]
111+
sessions_objects << session_object
112+
users_objects.last.add_session(session_object)
113+
end
114+
end
104115
end
105116

106-
report['usersStats'] = {}
117+
all_browsers = sessions_objects.map {|session| session.attributes['browser'].upcase}.sort.uniq.join(',')
107118

108-
# Собираем количество сессий по пользователям
109-
collect_stats_from_users(report, users_objects) do |user|
110-
{ 'sessionsCount' => user.sessions.count }
111-
end
119+
report[:totalUsers] = users_objects.count
112120

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

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

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

128-
# Хоть раз использовал IE?
129-
collect_stats_from_users(report, users_objects) do |user|
130-
{ 'usedIE' => user.sessions.map{|s| s['browser']}.any? { |b| b.upcase =~ /INTERNET EXPLORER/ } }
131-
end
127+
# Статистика по пользователям
132128

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

138-
# Даты сессий через запятую в обратном порядке в формате iso8601
139-
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 } }
131+
users_objects.each do |user|
132+
USER_STATS.each do |stat, block|
133+
collect_stats_from_users(report, user, stat, block)
134+
end
141135
end
142136

143137
File.write('result.json', "#{report.to_json}\n")
@@ -169,7 +163,7 @@ def setup
169163
end
170164

171165
def test_result
172-
work
166+
work('data.txt')
173167
expected_result = '{"totalUsers":3,"uniqueBrowsersCount":14,"totalSessions":15,"allBrowsers":"CHROME 13,CHROME 20,CHROME 35,CHROME 6,FIREFOX 12,FIREFOX 32,FIREFOX 47,INTERNET EXPLORER 10,INTERNET EXPLORER 28,INTERNET EXPLORER 35,SAFARI 17,SAFARI 29,SAFARI 39,SAFARI 49","usersStats":{"Leida Cira":{"sessionsCount":6,"totalTime":"455 min.","longestSession":"118 min.","browsers":"FIREFOX 12, INTERNET EXPLORER 28, INTERNET EXPLORER 28, INTERNET EXPLORER 35, SAFARI 29, SAFARI 39","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-09-27","2017-03-28","2017-02-27","2016-10-23","2016-09-15","2016-09-01"]},"Palmer Katrina":{"sessionsCount":5,"totalTime":"218 min.","longestSession":"116 min.","browsers":"CHROME 13, CHROME 6, FIREFOX 32, INTERNET EXPLORER 10, SAFARI 17","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-04-29","2016-12-28","2016-12-20","2016-11-11","2016-10-21"]},"Gregory Santos":{"sessionsCount":4,"totalTime":"192 min.","longestSession":"85 min.","browsers":"CHROME 20, CHROME 35, FIREFOX 47, SAFARI 49","usedIE":false,"alwaysUsedChrome":false,"dates":["2018-09-21","2018-02-02","2017-05-22","2016-11-25"]}}}' + "\n"
174168
assert_equal expected_result, File.read('result.json')
175169
end

0 commit comments

Comments
 (0)