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
52 changes: 52 additions & 0 deletions case-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Case-study оптимизации

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

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

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

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

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

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *кол-во в МБ потребляемой RAM*

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

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

Вот как я построил `feedback_loop`:
1) Использовал профилировщик памяти `Valgrind massif`
2) Переписал программу на *потоковый подход* и запустил на полном объеме данных
3) Сразу получилось уложиться во все метрики

## Результаты
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить следующие метрики и уложиться в заданный бюджет (в сравнении с предущим дз)
1) Скорость работы программы уменьшилась с 30с до 11с
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 слегка внезапно, да? вроде оптимизировали с точки зрения времени выполнения, а заход через память ещё в 3 раза лучше дал результат

2) Пиковое потребление памяти уменьшилось c 3068 MB до 37 MB
Copy link
Collaborator

Choose a reason for hiding this comment

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

и самое топовое, что так любой объём данных можно перелопатить


## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написал скрипт с использованием `benchmark/ips` для замера производительности. Итоговый результат:
```
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux]
Warming up --------------------------------------
generating reportMEMORY USAGE: 51 MB
1.000 i/100ms
Calculating -------------------------------------
generating reportMEMORY USAGE: 51 MB
0.094 (± 0.0%) i/s - 1.000 in 10.584967s
with 95.0% confidence
Run options: --seed 41844

# Running:


Finished in 0.001156s, 0.0000 runs/s, 0.0000 assertions/s.
0 runs, 0 assertions, 0 failures, 0 errors, 0 skips
```
Binary file added massif-visualizer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
207 changes: 92 additions & 115 deletions task-2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,46 @@
require 'pry'
require 'date'
require 'minitest/autorun'
require 'set'

USER_STATS = {
'sessionsCount' => -> (user) { user.sessions.count },
'totalTime' => -> (user) { user.sessions_time.sum.to_s + ' min.' },
'longestSession' => -> (user) { user.sessions_time.max.to_s + ' min.' },
'browsers' => -> (user) { user.sessions_browsers.join(', ') },
'usedIE' => -> (user) { user.sessions_browsers.join(', ').include? 'INTERNET EXPLORER' },
'alwaysUsedChrome' => -> (user) { user.sessions_browsers.uniq.all? { |b| b.upcase =~ /CHROME/ } },
'dates' => -> (user) { user.sessions.map{ |s| s.attributes['date'] }.sort.reverse }
}

JSON_FILE_PATH = 'result.json'

class User
attr_reader :attributes, :sessions

def initialize(attributes:, sessions:)
def initialize(attributes:)
@attributes = attributes
@sessions = []
end

def add_session(session)
@sessions << session
end

def sessions_time
@sessions_time ||= sessions.map {|s| s.attributes['time'].to_i}
end

def sessions_browsers
@sessions_browsers ||= sessions.map {|s| s.attributes['browser'].upcase}.sort
end
end

class Session
attr_reader :attributes

def initialize(attributes:)
@attributes = attributes
@sessions = sessions
end
end

Expand All @@ -35,26 +68,25 @@ def parse_session(session)
}
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']}"
report['usersStats'][user_key] ||= {}
report['usersStats'][user_key] = report['usersStats'][user_key].merge(block.call(user))
end
def collect_stats_from_users(report, user, stat, block)
user_key = "#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}"
report['usersStats'][user_key] ||= {}
report['usersStats'][user_key][stat] = block.call(user)
end

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

users = []
sessions = []
def process_user(user, file, is_last_one)
report = {}
report['usersStats'] = {}

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'
USER_STATS.each do |stat, block|
collect_stats_from_users(report, user, stat, block)
end

file.write("#{report['usersStats'].to_json[1..-2]}")
file.write(',') unless is_last_one
end

def work(filepath, options = {})
# Отчёт в json
# - Сколько всего юзеров +
# - Сколько всего уникальных браузеров +
Expand All @@ -70,108 +102,53 @@ def work
# - Всегда использовал только Хром? +
# - даты сессий в порядке убывания через запятую +

report = {}

report[:totalUsers] = users.count

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

report['uniqueBrowsersCount'] = uniqueBrowsers.count

report['totalSessions'] = sessions.count

report['allBrowsers'] =
sessions
.map { |s| s['browser'] }
.map { |b| b.upcase }
.sort
.uniq
.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]
end

report['usersStats'] = {}

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

# Собираем количество времени по пользователям
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
GC.disable if options[:disable_gc]

# Выбираем самую длинную сессию пользователя
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 } }
report = {}
report['totalUsers'] = 0
report['totalSessions'] = 0
all_browsers = Set.new

previos_user, current_user = nil

result_file = File.open(JSON_FILE_PATH, 'a')
result_file.write("{\"usersStats\":{")

File.open(filepath) do |file|
file.lazy.each_slice(1000) do |batch|
batch.each do |line|
cols = line.split(',')

if cols[0] == 'user'
previos_user = current_user if current_user
new_user_attributes = parse_user(line)
current_user = User.new(attributes: new_user_attributes)

process_user(previos_user, result_file, false) if previos_user
report['totalUsers'] += 1
elsif cols[0] == 'session'
session_attributes = parse_session(line)
session_object = Session.new(attributes: session_attributes)

current_user.add_session(session_object)
report['totalSessions'] += 1
all_browsers.add(session_object.attributes['browser'].upcase)
end
end
end

process_user(current_user, result_file, true)
end

File.write('result.json', "#{report.to_json}\n")
puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024)
end
report['allBrowsers'] = all_browsers.to_a
report['uniqueBrowsersCount'] = all_browsers.size

class TestMe < Minitest::Test
def setup
File.write('result.json', '')
File.write('data.txt',
'user,0,Leida,Cira,0
session,0,0,Safari 29,87,2016-10-23
session,0,1,Firefox 12,118,2017-02-27
session,0,2,Internet Explorer 28,31,2017-03-28
session,0,3,Internet Explorer 28,109,2016-09-15
session,0,4,Safari 39,104,2017-09-27
session,0,5,Internet Explorer 35,6,2016-09-01
user,1,Palmer,Katrina,65
session,1,0,Safari 17,12,2016-10-21
session,1,1,Firefox 32,3,2016-12-20
session,1,2,Chrome 6,59,2016-11-11
session,1,3,Internet Explorer 10,28,2017-04-29
session,1,4,Chrome 13,116,2016-12-28
user,2,Gregory,Santos,86
session,2,0,Chrome 35,6,2018-09-21
session,2,1,Safari 49,85,2017-05-22
session,2,2,Firefox 47,17,2018-02-02
session,2,3,Chrome 20,84,2016-11-25
')
end
result_file.write("},")
result_file.write("#{report.to_json[1..-2]}")
result_file.write('}')
result_file.close

def test_result
work
expected_result = JSON.parse('{"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"]}}}')
assert_equal expected_result, JSON.parse(File.read('result.json'))
end
puts format('MEMORY USAGE: %d MB', (`ps -o rss= -p #{Process.pid}`.to_i / 1024))
end