Skip to content

Commit 65dbbab

Browse files
author
aleksey.ryabchikov
committed
loop_3
1 parent 3f9982d commit 65dbbab

File tree

8 files changed

+159
-47
lines changed

8 files changed

+159
-47
lines changed

Gemfile

Whitespace-only changes.

case-study.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось*
22+
23+
Вот как я построил `feedback_loop`:
24+
Измерив время выполнения программы на разном объеме дынных, алгоритмическая сложность получилась O(n^2), т.е. время выполнения в зависимости от объема данных возрастает квадратично.
25+
26+
- Нужно зафиксировать бюджет метрики, которую определили выше.
27+
То-есть выбрать объем данных, который выполняется за адекватное время, на первой итерации например 5 сек. (20_000 строк кода на моей машине)
28+
- Защитить метрику от дальнейшей деградации, написать тест
29+
- С помощью профилировщика найти главную точку роста (Профилируем с выключенным GC предварительно прогрев кеши)
30+
- Внести оптимизационные правки
31+
- С помощью профилировщика проверить есть ли улучшения
32+
- Запустить тест, проверить если улучшения есть, если да то закоммитить.
33+
34+
## Вникаем в детали системы, чтобы найти главные точки роста
35+
Для того, чтобы найти "точки роста" для оптимизации я воспользовался rbspy (удобно, потому что встроен в rubymine), stackprof и ruby-prof в разных режимах отчетов
36+
37+
Вот какие проблемы удалось найти и решить
38+
39+
### Ваша находка №1
40+
- stackprof cli и rbspy указали на одинаковую точку роста, 89% занимает `user_sessions = sessions.select { |session| session['user_id'] == user['id'] }`
41+
```
42+
%Total %Self Total Self Wait Child Calls Name
43+
89.15% 89.15% 7.19 7.19 0.00 0.00 3046 Array#select
44+
45+
```
46+
- Заменил перебор всех сессий на хэш с группированные данных по user_id. В данном конкретном месте алгоритмическая сложность с O(n) изменилась на O(1)
47+
- Метрика кратно уменьшилась при прогоне теста на перфоманс с средних 5 сек. до 0.4 сек. Это и было самым узким местом программы по всей видимости.
48+
- Повторный запуск профилировщика показал, что вместо 89,15% теперь это место занимает 0.3%.
49+
50+
### Ваша находка №2
51+
- Профилировщики указали на следующую точку роста:
52+
```
53+
%Total %Self Total Self Wait Child Calls Name
54+
96.01% 43.30% 0.73 0.33 0.00 0.40 10 Array#each
55+
```
56+
- Заменил `sessions = sessions + [parse_session(line)] if cols[0] == 'session'` на `sessions = sessions << parse_session(line) if cols[0] == 'session'`
57+
Известная проблема в ruby. Оператор << позволяет не создавать новую переменную каждый раз, а писать все в существующую.
58+
- При прогоне теста, среднее значение метрики упало с 0.4 сек. до 0.25 сек.
59+
60+
### Ваша находка №3
61+
- Ради интереса прогнал тесты с разным объемом данных, 20к, 40к, 80к и так далее. В том числе и на полном файле.
62+
На данном этапе уже удалось избавится от O(n^2) благодаря первому шагу, теперь алгоритмическая сложность О(n).
63+
- Профилировщики указали на следующую точку роста:
64+
```
65+
%Total %Self Total Self Wait Child Calls Name
66+
93.26% 13.71% 0.44 0.06 0.00 0.37 10 Array#each
67+
```
68+
- Оптимизировал блок each, заменил '+' на Set.
69+
- При прогоне теста, среднее значение метрики упало с 0.25 сек. до 0.18 сек.
70+
71+
### Ваша находка №4
72+
- По какой-то причине показания stackprof и ruby-prof стали расходится.
73+
- stackprof указал на главную точку роста в строке `{ 'dates' => user.sessions.map{|s| s['date']}.map {|d| Date.parse(d)}.sort.reverse.map { |d| d.iso8601 } }`
74+
- ruby-prof указал на строку `{ 'totalTime' => user.sessions.map {|s| s['time']}.map {|t| t.to_i}.sum.to_s + ' min.' }`
75+
```
76+
%Total %Self Total Self Wait Child Calls Name
77+
90.29% 21.60% 0.37 0.09 0.00 0.28 10 Array#each
78+
```
79+
- Попробовал увеличить кол-во данных, чтобы проще было увидеть проблематику до 100_000 строк.
80+
- Изучив код более подробно, понял, что обе точки роста связаны между собой.
81+
82+

ruby-prof.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
require 'ruby-prof'
2+
require_relative './task-1'
3+
4+
RubyProf.measure_mode = RubyProf::WALL_TIME
5+
6+
result = RubyProf.profile do
7+
work('./spec/fixtures/files/dataN.txt')
8+
end
9+
10+
printer = RubyProf::GraphHtmlPrinter.new(result)
11+
printer.print(File.open('./ruby-prof.html', 'w+'))
File renamed without changes.

spec/rails_helper.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
require 'rspec-benchmark'
2+
3+
RSpec.configure do |config|
4+
config.include RSpec::Benchmark::Matchers
5+
end

spec/task-1_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
require_relative './rails_helper'
2+
require_relative '../task-1'
3+
4+
describe 'task-1' do
5+
let(:result) { './spec/fixtures/files/result.json' }
6+
describe 'Result' do
7+
let(:file) { './spec/fixtures/files/data.txt' }
8+
9+
let(:expected_result) do
10+
'{"totalUsers":3,"uniqueBrowsersCount":14,"totalSessions":15,"allBrowsers":"CHROME 13,CHROME 20,
11+
CHROME 35,CHROME 6,FIREFOX 12,FIREFOX 32,FIREFOX 47,INTERNET EXPLORER 10,INTERNET EXPLORER 28,INTERNET EXPLORER 35,
12+
SAFARI 17,SAFARI 29,SAFARI 39,SAFARI 49","usersStats":{"Leida Cira":{"sessionsCount":6,"totalTime":"455 min.",
13+
"longestSession":"118 min.","browsers":"FIREFOX 12, INTERNET EXPLORER 28, INTERNET EXPLORER 28, INTERNET EXPLORER 35,
14+
SAFARI 29, SAFARI 39","usedIE":true,"alwaysUsedChrome":false,"dates":["2017-09-27","2017-03-28","2017-02-27","2016-10-23",
15+
"2016-09-15","2016-09-01"]},"Palmer Katrina":{"sessionsCount":5,"totalTime":"218 min.","longestSession":"116 min.",
16+
"browsers":"CHROME 13, CHROME 6, FIREFOX 32, INTERNET EXPLORER 10, SAFARI 17","usedIE":true,"alwaysUsedChrome":false,
17+
"dates":["2017-04-29","2016-12-28","2016-12-20","2016-11-11","2016-10-21"]},"Gregory Santos":{"sessionsCount":4,"totalTime":"192 min.",
18+
"longestSession":"85 min.","browsers":"CHROME 20, CHROME 35, FIREFOX 47, SAFARI 49","usedIE":false,"alwaysUsedChrome":false,
19+
"dates":["2018-09-21","2018-02-02","2017-05-22","2016-11-25"]}}}' + "\n"
20+
end
21+
22+
it 'returns equal' do
23+
work(file)
24+
expect(File.read(result)).to eq(expected_result)
25+
end
26+
end
27+
28+
describe 'Performance' do
29+
let(:file) { './spec/fixtures/files/dataN.txt' }
30+
31+
describe 'with partial file' do
32+
it 'works under 0.4 sec' do
33+
expect { work(file) }.to perform_under(0.18).sec.warmup(2).times.sample(10).times
34+
end
35+
end
36+
37+
describe 'with full file' do
38+
it 'works under 30 sec' do
39+
expect { work('./data_large.txt') }.to perform_under(30).sec
40+
end
41+
end
42+
end
43+
end

stackprof.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
require_relative './task-1'
2+
require 'stackprof'
3+
4+
StackProf.run(mode: :wall, out: './stackprof.dump', interval: 1000) do
5+
GC.disable
6+
work('./spec/fixtures/files/dataN.txt')
7+
end

task-1.rb

Lines changed: 11 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'pry'
55
require 'date'
66
require 'minitest/autorun'
7+
require "set"
78

89
class User
910
attr_reader :attributes, :sessions
@@ -43,16 +44,16 @@ def collect_stats_from_users(report, users_objects, &block)
4344
end
4445
end
4546

46-
def work
47-
file_lines = File.read('data.txt').split("\n")
47+
def work(file_path, result_path = 'spec/fixtures/files/result.json')
48+
file_lines = File.read(file_path).split("\n")
4849

4950
users = []
5051
sessions = []
5152

5253
file_lines.each do |line|
5354
cols = line.split(',')
54-
users = users + [parse_user(line)] if cols[0] == 'user'
55-
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
55+
users = users << parse_user(line) if cols[0] == 'user'
56+
sessions = sessions << parse_session(line) if cols[0] == 'session'
5657
end
5758

5859
# Отчёт в json
@@ -75,10 +76,9 @@ def work
7576
report[:totalUsers] = users.count
7677

7778
# Подсчёт количества уникальных браузеров
78-
uniqueBrowsers = []
79+
uniqueBrowsers = Set.new
7980
sessions.each do |session|
80-
browser = session['browser']
81-
uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser }
81+
uniqueBrowsers.add(session['browser'])
8282
end
8383

8484
report['uniqueBrowsersCount'] = uniqueBrowsers.count
@@ -93,14 +93,10 @@ def work
9393
.uniq
9494
.join(',')
9595

96+
user_sessions = sessions.group_by { |session| session['user_id'] }
9697
# Статистика по пользователям
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]
98+
users_objects = users.map do |user|
99+
User.new(attributes: user, sessions: user_sessions[user['id']] || [])
104100
end
105101

106102
report['usersStats'] = {}
@@ -140,37 +136,5 @@ def work
140136
{ 'dates' => user.sessions.map{|s| s['date']}.map {|d| Date.parse(d)}.sort.reverse.map { |d| d.iso8601 } }
141137
end
142138

143-
File.write('result.json', "#{report.to_json}\n")
144-
end
145-
146-
class TestMe < Minitest::Test
147-
def setup
148-
File.write('result.json', '')
149-
File.write('data.txt',
150-
'user,0,Leida,Cira,0
151-
session,0,0,Safari 29,87,2016-10-23
152-
session,0,1,Firefox 12,118,2017-02-27
153-
session,0,2,Internet Explorer 28,31,2017-03-28
154-
session,0,3,Internet Explorer 28,109,2016-09-15
155-
session,0,4,Safari 39,104,2017-09-27
156-
session,0,5,Internet Explorer 35,6,2016-09-01
157-
user,1,Palmer,Katrina,65
158-
session,1,0,Safari 17,12,2016-10-21
159-
session,1,1,Firefox 32,3,2016-12-20
160-
session,1,2,Chrome 6,59,2016-11-11
161-
session,1,3,Internet Explorer 10,28,2017-04-29
162-
session,1,4,Chrome 13,116,2016-12-28
163-
user,2,Gregory,Santos,86
164-
session,2,0,Chrome 35,6,2018-09-21
165-
session,2,1,Safari 49,85,2017-05-22
166-
session,2,2,Firefox 47,17,2018-02-02
167-
session,2,3,Chrome 20,84,2016-11-25
168-
')
169-
end
170-
171-
def test_result
172-
work
173-
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"
174-
assert_equal expected_result, File.read('result.json')
175-
end
139+
File.write(result_path, "#{report.to_json}\n")
176140
end

0 commit comments

Comments
 (0)