Skip to content

Commit 8bfce80

Browse files
committed
Find hardcode-dev#1: unefficient user sessions selection
1 parent 7d68352 commit 8bfce80

5 files changed

Lines changed: 136 additions & 48 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
/samples/*
2-
!/samples/.keep
2+
!/samples/.keep
3+
/tmp/*
4+
!/tmp/.keep

bin/ruby-prof

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require_relative '../boot'
5+
6+
require 'optparse'
7+
require 'ruby-prof'
8+
require 'tempfile'
9+
10+
require_relative '../samples'
11+
12+
RubyProf.measure_mode = RubyProf::WALL_TIME
13+
14+
REPORTS_ROOT = Pathname(__dir__).dirname / 'tmp'
15+
PRINTERS = %i(flat graph callstack callgrind).freeze
16+
17+
opts = {
18+
printer: :flat
19+
}
20+
21+
options_parser = OptionParser.new do |parser|
22+
parser.banner = "Usage: bin/ruby-prof [options] SAMPLE_SIZE"
23+
24+
parser.on('--printer [PRINTER]', PRINTERS,
25+
"Printer used to present collected data (#{PRINTERS.join(', ')}) ",
26+
"[default: #{opts[:printer]}]")
27+
parser.on('-h', '--help', 'Display this message') do
28+
puts parser.help
29+
exit
30+
end
31+
end
32+
33+
options_parser.parse!(into: opts)
34+
35+
abort(options_parser.help) if ARGV.size != 1
36+
37+
GC.disable
38+
39+
sample = Sample.new(ARGV.first)
40+
41+
report = RubyProf.profile do
42+
Tempfile.create do |result|
43+
work(src: sample.path, dest: result.path)
44+
end
45+
end
46+
47+
case opts[:printer]
48+
when :flat
49+
RubyProf::FlatPrinter.new(report).print(File.open(REPORTS_ROOT / 'flat.txt', 'w'))
50+
when :graph
51+
RubyProf::GraphHtmlPrinter.new(report).print(File.open(REPORTS_ROOT / 'graph.html', 'w'))
52+
when :callstack
53+
RubyProf::CallStackPrinter.new(report).print(File.open(REPORTS_ROOT / 'callstack.html', 'w'))
54+
when :callgrind
55+
RubyProf::CallTreePrinter.new(report).print(path: REPORTS_ROOT, profile: 'callgrind')
56+
end

case-study.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,49 @@ irb(main):002:0> (factor * factor * 177.3695).round
115115
Пункты 3-4 реализованы в скрипте `bin/feedback`.
116116

117117
## Вникаем в детали системы, чтобы найти главные точки роста
118-
Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались*
118+
Для того, чтобы найти "точки роста" для оптимизации, я воспользовался следующими инструментами:
119119

120-
Вот какие проблемы удалось найти и решить
120+
- `ruby-prof` (вызывается через скрипт `bin/ruby-prof`).
121+
- `stack-prof`
121122

122-
### Ваша находка №1
123-
- какой отчёт показал главную точку роста
124-
- как вы решили её оптимизировать
125-
- как изменилась метрика
126-
- как изменился отчёт профилировщика - исправленная проблема перестала быть главной точкой роста?
123+
Я также воспользовался `rbspy` на `samples/data_large.txt`, однако на данном скрипте какой-либо полезной информации
124+
он не показал:
125+
126+
```
127+
% self % total name
128+
99.58 99.97 block in work - /home/nameless/edu/ruby-opt/rails-optimization-task1/task-1.rb
129+
0.50 100.00 <c function> - unknown
130+
[... snip ...]
131+
```
132+
133+
Кроме того, я запускал `rubocop` с метриками `performance`, однако он тоже ничего полезного не предложил.
134+
135+
Вот какие проблемы удалось найти и решить.
136+
137+
### Находка №1: очень неэффективная выборка сессий пользователя
138+
139+
- Я построил `callgrind`-отчёт с помощью `ruby-prof` и попытался проанализировать его с помощью `kcachegrind`.
140+
141+
Во разделе "Flat & Profile" сразу бросается в глаза метод `Array#select`, у в котором программа провела 96%
142+
времени (Self) и у которого большое число вызовов (~3000). Дальше я посмотрел вкладку "All Callers", по
143+
по которой видно, что большее число раз этот метод вызывался в методе `Array#each`, который в свою очередь
144+
больше всего вызывался из `#work`.
145+
146+
В качестве точки роста я выбрал `Array#select` (я также смотрел другие отчёты `ruby-prof`, они показали примерно
147+
ту же информацию).
148+
149+
- В исходном коде только один раз встречается метод `Array#select`, а именно при выборе сессий пользователя из
150+
общего массива сессий. Очевидно, что время выполнения этого кода будет расти с увеличением размера данных. Но
151+
если бы мы заранее сформировали индекс пользовательских сессий в виде хэш-таблицы с ID пользователя в качестве
152+
ключа, то выбор сессий для конкретного пользователя обладало бы характеристиками получения значения в хэше
153+
по ключу: 0(1).
154+
155+
Поэтому я решил во время хэш `user_id => user_sessions`, а заодно избавиться от массивов `users` и и `sessions`,
156+
т.к. они используется в основном для формирования массива `users_objects`, и сразу формировать `users_objects`.
157+
Значения `totalUsers`, `uniqueBrowsersCount`, `totalSessions`, `allBrowsers` же, которые тоже использовали эти
158+
массивы, можно посчитать и без них.
159+
- Значение метрики для 20000 строк снизилось с 4.5404 секунд до 0.1924 секунд.
160+
- В отчёте профилировщика `Array#select` перестал быть главной точкой роста.
127161

128162
### Ваша находка №2
129163
- какой отчёт показал главную точку роста

task-1.rb

Lines changed: 36 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,25 @@
33
require 'json'
44
require 'pry'
55
require 'date'
6+
require 'set'
67

78
class User
8-
attr_reader :attributes, :sessions
9+
attr_reader :attributes
910

1011
def initialize(attributes:, sessions:)
1112
@attributes = attributes
12-
@sessions = sessions
13+
@all_sessions = sessions
14+
end
15+
16+
def sessions
17+
@sessions ||= @all_sessions[attributes['id']]
1318
end
1419
end
1520

1621
def parse_user(user)
1722
fields = user.split(',')
18-
parsed_result = {
23+
24+
{
1925
'id' => fields[1],
2026
'first_name' => fields[2],
2127
'last_name' => fields[3],
@@ -25,7 +31,8 @@ def parse_user(user)
2531

2632
def parse_session(session)
2733
fields = session.split(',')
28-
parsed_result = {
34+
35+
{
2936
'user_id' => fields[1],
3037
'session_id' => fields[2],
3138
'browser' => fields[3],
@@ -45,13 +52,31 @@ def collect_stats_from_users(report, users_objects, &block)
4552
def work(src:, dest:)
4653
file_lines = File.read(src).split("\n")
4754

48-
users = []
49-
sessions = []
55+
report = {}
56+
57+
totalUsers = 0
58+
totalSessions = 0
59+
uniqueBrowsers = Set.new
60+
61+
users_objects = []
62+
sessions = Hash.new { |hash, user_id| hash[user_id] = [] }
5063

5164
file_lines.each do |line|
5265
cols = line.split(',')
53-
users = users + [parse_user(line)] if cols[0] == 'user'
54-
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
66+
67+
case cols[0]
68+
when 'user'
69+
user = parse_user(line)
70+
users_objects << User.new(attributes: user, sessions: sessions)
71+
72+
totalUsers += 1
73+
when 'session'
74+
session = parse_session(line)
75+
sessions[session['user_id']] << session
76+
77+
totalSessions += 1
78+
uniqueBrowsers << session['browser'].upcase
79+
end
5580
end
5681

5782
# Отчёт в json
@@ -69,39 +94,10 @@ def work(src:, dest:)
6994
# - Всегда использовал только Хром? +
7095
# - даты сессий в порядке убывания через запятую +
7196

72-
report = {}
73-
74-
report[:totalUsers] = users.count
75-
76-
# Подсчёт количества уникальных браузеров
77-
uniqueBrowsers = []
78-
sessions.each do |session|
79-
browser = session['browser']
80-
uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser }
81-
end
82-
97+
report['totalUsers'] = totalUsers
8398
report['uniqueBrowsersCount'] = uniqueBrowsers.count
84-
85-
report['totalSessions'] = sessions.count
86-
87-
report['allBrowsers'] =
88-
sessions
89-
.map { |s| s['browser'] }
90-
.map { |b| b.upcase }
91-
.sort
92-
.uniq
93-
.join(',')
94-
95-
# Статистика по пользователям
96-
users_objects = []
97-
98-
users.each do |user|
99-
attributes = user
100-
user_sessions = sessions.select { |session| session['user_id'] == user['id'] }
101-
user_object = User.new(attributes: attributes, sessions: user_sessions)
102-
users_objects = users_objects + [user_object]
103-
end
104-
99+
report['totalSessions'] = totalSessions
100+
report['allBrowsers'] = uniqueBrowsers.to_a.sort.join(',')
105101
report['usersStats'] = {}
106102

107103
# Собираем количество сессий по пользователям

tmp/.keep

Whitespace-only changes.

0 commit comments

Comments
 (0)