Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize task 1 #152

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
data_large.txt
data_small.txt
result.json

/ruby_prof_reports
/stackprof_reports
33 changes: 33 additions & 0 deletions assert_performance_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require 'rspec/core'
require 'rspec-benchmark'

require_relative 'task-1_with_argument.rb'

RSpec.configure do |config|
config.include RSpec::Benchmark::Matchers
end

describe 'Performance' do
before do
`head -n #{8000} data_large.txt > data_small.txt`
end
it 'works under 1 ms' do
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

expect {
work('data_small.txt')
}.to perform_under(1000).ms.warmup(2).times.sample(10).times
end

let(:measurement_time_seconds) { 1 }
let(:warmup_seconds) { 0.2 }
it 'works faster than 1 ips' do
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

expect {
work('data_small.txt')
}.to perform_at_least(1).within(measurement_time_seconds).warmup(warmup_seconds).ips
end

it 'works with data_large under 35sec' do
Copy link
Collaborator

Choose a reason for hiding this comment

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

для реального теста конечно слишком много; сейчас пришла идея, что в принципе можно даже закомитить что-то подобное в рабочую репу, но добавить например ENV-переменную вроде PERF_TEST

и запускать такие тесты только если ENV[PERF_TEST] == 'true'

expect {
work('data_large.txt')
}.to perform_under(35).sec.warmup(2).times.sample(10).times
end
end
44 changes: 44 additions & 0 deletions benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
require 'benchmark'
require 'benchmark/ips'
require_relative 'task-1_with_argument.rb'

COUNTERS = [1, 2, 4, 8, 16, 32, 64, 128, 256]

COUNTERS.each do |counter|
time = Benchmark.realtime do
`head -n #{counter*1000} data_large.txt > data_small.txt`
work('data_small.txt')
end
puts "Finish in #{time.round(2)}"
end

# initial

# 1000 - Finish in 0.03
# 2000 - Finish in 0.13
# 4000 - Finish in 0.31
# 8000 - Finish in 0.97
# 16000 - Finish in 3.98
# 32000 - Finish in 22.26


Benchmark.ips do |x|
x.config(
stats: :bootstrap,
confidence: 95,
)

x.report("work") do
`head -n #{128000} data_large.txt > data_small.txt`
work('data_small.txt')
end
end

# initial

# work 0.236 (± 1.4%) i/s

time = Benchmark.realtime do
work('data_large.txt')
end
puts "Finish in #{time.round(2)}"
91 changes: 91 additions & 0 deletions case-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Case-study оптимизации

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

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

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

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

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

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: Файл размером 3250940 строк должен обрабатываться за 30 секунд.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Это не совсем метрика, это финальный бюджет скорее

Этот вопрос в данном случае tricky. По факту нет простого одного ответа на всю работу. У нас на каждую итерацию оптимизации новая метрика - время работы на файлах разного размера. Когда мы не можем посчитать общую метрику на всю систему / исходную проблему, то мы можем воспользоваться промежуточными метриками. Их функция получается в том, чтобы помочь нам понять, была ли оптимизация успешна на данной итерации.


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

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

Вот как я построил `feedback_loop`:
1. Создала копию рабочего файла, которая принимает аргументом файл меньшего размера.
2. Бенчмаркинг. Создала файл benchmark.rb для проверки времени выполнения программы на меньших данных и проверки ips на 16000 строк.
3. Assert performance. Создала файл assert_performance_spec.rb для тестирования времни выполнения и ips.
4. Профилирование. Создала файлы ruby-prof.rb и stackprof.rb для генерации отчетов.

## Вникаем в детали системы, чтобы найти главные точки роста
Для того, чтобы найти "точки роста" для оптимизации я воспользовался отчетами falt, graph и callstack от ruby-prof и cli stackprof.

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

### Ваша находка №1
- Все отчеты показали главную точку роста Array#select (59.64% по ruby-prof flat)
- Вместо перебора сессий в select создала массив sessins_hash c ключом user_id
- На 16000 строк ips увеличился с 0.236 до 2.581
Copy link
Collaborator

Choose a reason for hiding this comment

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

главное, что асимптотику поправили

- Проблема перестала быть точкой роста

### Ваша находка №2
- Все отчеты показывают точку роста в Array#all? (24,56% по ruby-prof flat)
- Вместо метода all? != использовала uniq
- На 16000 строк ips увеличился с 2.581 до 3.203
- Проблема перестала быть точкой роста

### Ваша находка №3
- callstack отчет показывает точку роста в collect_stats_from_users, а конкретно в Date#parse
- Я решила сделать сортировку даты без парсинга
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 да-да, это пасхалочка

- На 16000 строк ips увеличился с 3.203 до 4.265
- Проблема перестала быть точкой роста

### Ваша находка №4
- callstack отчет показывает точку роста в Array#each, а конкретно в Array#+
- Вместо создания нового массива на Array#+ я решила добавлять элементы в массив <<
- На 16000 строк ips увеличился с 4.265 до 7.329
- Проблема перестала быть точкой роста

### Ваша находка №5
- в этом месте кажется отчеты показывают разное, но я сосредоточилась на отчете ruby-prof flat и ruby-prof graph, которые показывают точну роста в tring#split (13.11%)
- Передаю в методы parse_user и parse_session массивы вместо строк, т.к. split уже сделан в методе, который их вызывает
- На 16000 строк ips увеличился с 7.329 до 7,640
- Проблема все еще имеет высокий процент, но он снизился до (8.84%)

На этом моменте я решила использовать для бенчмаркинга и профилирования большие величины, чтобы увеличить точность.
На 128_000 строк ips - 0.867i/s
Copy link
Collaborator

Choose a reason for hiding this comment

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

ips лучше подходит для микро-бенчмарков, где много итераций в секунду

у нас тут скорее ситуация, когда надо много секунд на одну итерацию, поэтому проще в секундах считать


### Ваша находка №6
- Отчеты показывают точку роста в Object#collect_stats_from_users, а конкретно в Array#map ( 16.50%)
- В нескольких вызовах collect_stats_from_users вызывается map на массиве сессий. Объединяют эти вызовы в один.
- 128_000 строк ips увеличился с 0.867 до 1.147
- collect_stats_from_users уменьшился с 56,19% до 41.28%

На этом этапе проверяю, сколько выполняется программа на большом файле - 43.88 и 17.25 со отключенным GC

### Ваша находка №7
- ruby-prof flat показывает точку роста в Array#map
- Несколько раз вызываются лишние map, убрала их. Так же заменила метод поиска уникальных браузеров на Set
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

- 128_000 строк ips увеличился с 1.147 до 1.265
- Array#map уменьшился с 17% до 10.93%
Общее время выполения на большом файле - 36.78 секунды

После этого не вижу в отчетах ничего, что могло бы мне помочь. В слепую изменяю пару методов, т.к. все еще не укладываюсь в метрику. Удалось оптимизировать время выполнения на большом файле до 33.65 секунд.

## Результаты
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить метрику системы с до 33.65 что почти укладывается в заданный бюджет.
Copy link
Collaborator

Choose a reason for hiding this comment

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

это конечно от компа зависит; в целом вроде у вас основное всё сделано чего обычно хватает для победы

есть шанс что во втором задании при подходе к этой проблеме с другой стороны получиться уложиться в 30 сек!



## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написала performance spec, проверяющий, что на большом файле работа выполняется менее 35 секунд на 10 сэмплах.

24 changes: 24 additions & 0 deletions ruby-prof.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# RubyProf Flat report
# ruby 12-ruby-prof-flat.rb
# cat ruby_prof_reports/flat.txt
require 'ruby-prof'
require_relative 'task-1_with_argument.rb'

RubyProf.measure_mode = RubyProf::WALL_TIME
`head -n #{128000} data_large.txt > data_small.txt`

result = RubyProf.profile do
work("data_small.txt")
end

flat_printer = RubyProf::FlatPrinter.new(result)
flat_printer.print(File.open("ruby_prof_reports/flat.txt", "w+"))

graph_printer = RubyProf::GraphHtmlPrinter.new(result)
graph_printer.print(File.open("ruby_prof_reports/graph.html", "w+"))

printer_callstack = RubyProf::CallStackPrinter.new(result)
printer_callstack.print(File.open('ruby_prof_reports/callstack.html', 'w+'))

# printer_calltree = RubyProf::CallTreePrinter.new(result)
# printer_calltree.print(:path => "ruby_prof_reports", :profile => 'callgrind')
20 changes: 20 additions & 0 deletions stackprof.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Stackprof report
# ruby 16-stackprof.rb
# cd stackprof_reports
# stackprof stackprof.dump
# stackprof stackprof.dump --method Object#work
require 'json'
require 'stackprof'
require_relative 'task-1_with_argument.rb'

`head -n #{8000} data_large.txt > data_small.txt`

StackProf.run(mode: :wall, out: 'stackprof_reports/stackprof.dump', interval: 1000) do
work("data_small.txt")
end

profile = StackProf.run(mode: :wall, raw: true) do
work("data_small.txt")
end

File.write('stackprof_reports/stackprof.json', JSON.generate(profile))
92 changes: 41 additions & 51 deletions task-1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

require 'json'
require 'pry'
require 'date'
require 'minitest/autorun'

class User
Expand All @@ -14,47 +13,49 @@ def initialize(attributes:, sessions:)
end
end

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

def parse_session(session)
fields = session.split(',')
parsed_result = {
def parse_session(fields)
{
'user_id' => fields[1],
'session_id' => fields[2],
'browser' => fields[3],
'time' => fields[4],
'date' => fields[5],
'date' => fields[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']}"
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
end

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

users = []
sessions = []

file_lines.each do |line|
File.read('data.txt').split("\n").each do |line|
cols = line.split(',')
users = users + [parse_user(line)] if cols[0] == 'user'
sessions = sessions + [parse_session(line)] if cols[0] == 'session'
case cols[0]
when 'user'
users << parse_user(cols)
when 'session'
sessions << parse_session(cols)
end
end

sessions_hash = sessions.group_by { |session| session['user_id'] }

# Отчёт в json
# - Сколько всего юзеров +
# - Сколько всего уникальных браузеров +
Expand All @@ -75,69 +76,58 @@ def work
report[:totalUsers] = users.count

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

uniqueBrowsers = Set.new
sessions.each { |session| uniqueBrowsers.add(session['browser']) }

report['uniqueBrowsersCount'] = uniqueBrowsers.count

report['totalSessions'] = sessions.count

report['allBrowsers'] =
sessions
.map { |s| s['browser'] }
.map { |b| b.upcase }
.map { |s| s['browser'].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]
users_objects = users.map do |user|
User.new(attributes: user, sessions: sessions_hash[user['id']] || [])
end

report['usersStats'] = {}

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

user_sessions = user.sessions
# Собираем количество времени по пользователям
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
totalTime = user_sessions.map {|s| s['time'].to_i}.sum.to_s + ' min.'

# Выбираем самую длинную сессию пользователя
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

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

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

# Всегда использовал только 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 } }
alwaysUsedChrome = browsers.all? { |b| b =~ /CHROME/ }

# Даты сессий через запятую в обратном порядке в формате iso8601|
dates = user_sessions.map{|s| s['date']}.sort.reverse

{
'sessionsCount' => user_sessions.count,
'totalTime' => totalTime,
'longestSession' => longestSession,
'browsers' => browsers.join(', '),
'usedIE' => usedIE,
'alwaysUsedChrome' => alwaysUsedChrome,
'dates' => dates
}
end

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