Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions case-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Case-study оптимизации

## Актуальная проблема
В нашем проекте возникла серьёзная проблема.

Необходимо было обработать файл с данными, чуть больше ста мегабайт.

У нас уже была программа на `ruby`, которая умела делать нужную обработку.

Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время.

Я решил исправить эту проблему, оптимизировав эту программу.

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику:
Для 1мб файла: необходимо 156Мб памяти, парсинг проходит за 37 сек.

## Гарантия корректности работы оптимизированной программы
Программа поставлялась с тестом. Выполнение этого теста позволяет не допустить изменения логики программы при оптимизации.

## Feedback-Loop
Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений моментально

Вот как я построил `feedback_loop`: я проверял обратку файла на разные метрики, 1 изменение - 1 коммит, если изменение дало нужный результат

## Вникаем в детали системы, чтобы найти 20% точек роста
Для того, чтобы найти "точки роста" для оптимизации я воспользовался memory_profiler, stackprof, ruby-prof, QCacheGrind

Вот какие проблемы удалось найти и решить

### Ваша находка №1
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ваша находка №1 -> Находка №1 ?

Большое выделение памяти при работе с классами:
* Array
* String
* Hash

### Ваша находка №2
Основное процессорное время уходило на обработку массивов (Array#each, Array#map), на сбор данных (Object#collect_stats_from_users)
и конкатенацию строк (String#split)

### Ваша находка №3
Долго происходил процесс обработки сессий для каждого пользователя
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Хорошо было бы добавить к каждому шагу больше конкретики.

  • Долго - это сколько?
  • Что было основной причиной?
  • Как нашли причину?
  • Как устранили?
  • Как изменилась метрика?


## Результаты
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить метрику системы с:
* memory
allocated memory by class
-----------------------------------
293_586_874_4 Array
436_974_43 String
260_091_68 Hash
867_291_2 MatchData
162_813_6 Date
Total allocated: 301_605_315_2 bytes (1325648 objects)
* cpu
100.00% 0.00% 1405386.00 11.00 0.00 1405375.00 1 Object#work
* time
1mb: Finish in 37.34
~128mb: infinity

на:
* memory
allocated memory by class
-----------------------------------
326_126_88 String
204_421_52 MatchData
161_736_32 Hash
768_498_4 Array
162_813_6 Date
163_120 User
Total allocated: 787_261_33 bytes (857028 objects)
* cpu
100.00% 0.00% 857034.00 7.00 0.00 857027.00 1 Object#work
* time
1mb: Finish in 0.6
~128Mb: Finish in 110.46

## Защита от регресса производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы сделано *ничего* :)
127 changes: 57 additions & 70 deletions task-1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
require 'pry'
require 'date'
require 'minitest/autorun'
require 'benchmark'
require 'memory_profiler'
require 'ruby-prof'

RubyProf.measure_mode = RubyProf::ALLOCATIONS

class User
attr_reader :attributes, :sessions
Expand All @@ -15,44 +20,45 @@ def initialize(attributes:, sessions:)
end

def parse_user(user)
fields = user.split(',')
parsed_result = {
'id' => fields[1],
'first_name' => fields[2],
'last_name' => fields[3],
'age' => fields[4],
{
'id' => user[1],
'first_name' => user[2],
'last_name' => user[3],
'age' => user[4],
}
end

def parse_session(session)
fields = session.split(',')
parsed_result = {
'user_id' => fields[1],
'session_id' => fields[2],
'browser' => fields[3],
'time' => fields[4],
'date' => fields[5],
{
'session_id' => session[2],
'browser' => session[3].upcase!,
'time' => session[4].to_i,
'date' => session[5],
}
end

def collect_stats_from_users(report, users_objects, &block)
users_objects.each do |user|
user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}"
while users_objects.size > 0 do
user = users_objects.shift
user_key = "#{user.attributes['first_name']} #{user.attributes['last_name']}"
report['usersStats'][user_key] ||= {}
report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user))
report['usersStats'][user_key].merge!(block.call(user))
end
end

def work
file_lines = File.read('data.txt').split("\n")

def work(file = 'data.txt')
users = []
sessions = []
sessions = Hash.new { |hash, key| hash[key] = [] }
total_sessions = 0


file_lines.each do |line|
cols = line.split(',')
users = users + [parse_user(line)] if cols[0] == 'user'
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
File.foreach(file) do |line|
line = line.split(',')
users << parse_user(line) if line[0] == 'user'
if line[0] == 'session'
sessions[line[1]] << parse_session(line)
total_sessions += 1
end
end

# Отчёт в json
Expand All @@ -75,69 +81,50 @@ def work
report[:totalUsers] = users.count

# Подсчёт количества уникальных браузеров
uniqueBrowsers = []
sessions.each do |session|
browser = session['browser']
uniqueBrowsers += [browser] if uniqueBrowsers.all? { |b| b != browser }
end
uniqueBrowsers = sessions.values.flatten.map! { |session| session['browser'] }.uniq! || []

report['uniqueBrowsersCount'] = uniqueBrowsers.count

report['totalSessions'] = sessions.count
report['totalSessions'] = total_sessions

report['allBrowsers'] =
sessions
.map { |s| s['browser'] }
.map { |b| b.upcase }
.sort
.uniq
.join(',')
report['allBrowsers'] = uniqueBrowsers.sort!.join(',')

# Статистика по пользователям
users_objects = []

users.each do |user|
attributes = user
user_sessions = sessions.select { |session| session['user_id'] == user['id'] }
user_object = User.new(attributes: attributes, sessions: user_sessions)
users_objects = users_objects + [user_object]
users.map! do |user|
user_id = user['id']
user_sessions = sessions[user_id]
User.new(attributes: user, sessions: user_sessions)
end

report['usersStats'] = {}

# Собираем количество сессий по пользователям
collect_stats_from_users(report, users_objects) do |user|
{ 'sessionsCount' => user.sessions.count }
end

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

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

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

# Хоть раз использовал IE?
collect_stats_from_users(report, users_objects) do |user|
{ 'usedIE' => user.sessions.map{|s| s['browser']}.any? { |b| b.upcase =~ /INTERNET EXPLORER/ } }
end

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

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

collect_stats_from_users(report, users) do |user|
users_times = user.sessions.map { |s| s['time'] }
users_browsers = user.sessions.map { |s| s['browser'] }
ie_counter = 0
chrome_counter = 0
users_browsers.each do |b|
ie_counter += 1 if ie_counter == 0 && /INTERNET EXPLORER/.match?(b)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А зачем проверка if ie_counter == 0?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

я думал, что так можно сократить лишнюю проверку #match?

chrome_counter += 1 if /CHROME/.match?(b)
end

{
'sessionsCount' => user.sessions.count,
'totalTime' => "#{users_times.sum} min.",
'longestSession' => "#{users_times.max} min.",
'browsers' => users_browsers.sort!.join(', '),
'usedIE' => ie_counter > 0,
'alwaysUsedChrome' => chrome_counter == users_browsers.size,
'dates' => user.sessions.map { |s| Date.parse(s['date']).iso8601 }.sort!.reverse!
}
end

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