Skip to content

Homework 1 #162

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

Open
wants to merge 4 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
data
data_large.txt
result.json
ruby_prof_reports
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.3.6
13 changes: 13 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

ruby '3.3.6'

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }


gem 'pry'
gem 'rspec-benchmark'
gem 'ruby-progressbar'
gem 'ruby-prof'
gem 'stackprof'
50 changes: 50 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
GEM
remote: https://rubygems.org/
specs:
benchmark-malloc (0.2.0)
benchmark-perf (0.6.0)
benchmark-trend (0.4.0)
coderay (1.1.3)
diff-lcs (1.5.1)
method_source (1.1.0)
pry (0.15.2)
coderay (~> 1.1)
method_source (~> 1.0)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-benchmark (0.6.0)
benchmark-malloc (~> 0.2)
benchmark-perf (~> 0.6)
benchmark-trend (~> 0.4)
rspec (>= 3.0)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.2)
ruby-prof (1.7.1)
ruby-progressbar (1.13.0)
stackprof (0.2.27)

PLATFORMS
arm64-darwin-24
ruby

DEPENDENCIES
pry
rspec-benchmark
ruby-prof
ruby-progressbar
stackprof

RUBY VERSION
ruby 3.3.6p108

BUNDLED WITH
2.5.22
41 changes: 41 additions & 0 deletions bench_wrapper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Measure ruby code performance
# Usage:
# require 'bench_wrapper'
# measure do
# code to measure
# end

require "json"
require "benchmark"

def measure(&block)
no_gc = (ARGV[0] == "--no-gc")

if no_gc
GC.disable
else
GC.start
end

memory_before = `ps -o rss= -p #{Process.pid}`.to_i/1024
gc_stat_before = GC.stat
time = Benchmark.realtime do
yield
end
puts ObjectSpace.count_objects
unless no_gc
GC.start(full_mark: true, immediate_sweep: true, immediate_mark: false)
end
puts ObjectSpace.count_objects
gc_stat_after = GC.stat
memory_after = `ps -o rss= -p #{Process.pid}`.to_i/1024

puts({
RUBY_VERSION => {
gc: no_gc ? 'disabled' : 'enabled',
time: time.round(2),
gc_count: gc_stat_after[:count] - gc_stat_before[:count],
memory: "%d MB" % (memory_after - memory_before)
}
}.to_json)
end
11 changes: 11 additions & 0 deletions benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

require_relative 'bench_wrapper'
require_relative 'task-1'

# path = "data/data#{ARGV[0] || 50000}.txt"
path = "data_large.txt"

measure do
work(path)
end
104 changes: 82 additions & 22 deletions case-study-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,45 +12,105 @@
Я решил исправить эту проблему, оптимизировав эту программу.

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика*
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику:
- Определил глобальну метрику - время выполнения программы на data_large файле. Бюджет - 3с.
- Т.к. работать с такими большими данными неэффективно, решил создать несколько файлов с 10_000, 25_000, 50_000, 100_000 и 200_000 строк.
Copy link
Collaborator

Choose a reason for hiding this comment

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

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

поэтому приходится вводить промежуточные метрики (время для обработки файла x, y, z); они нам нужны на каждом шаге оптимизации чтобы понять насколько изменение было полезным

- Оценил асимптотику:
* 10000 - "time":0.6,"gc_count":29,"memory":"78 MB"/
* 25000 - "time":3.53,"gc_count":103,"memory":"128 MB"
* 50000 - "time":13.97,"gc_count":352,"memory":"147 MB"
* 100000 - "time":55.02,"gc_count":1290,"memory":"261 MB"
* 200000 - "time":229.75,"gc_count":4968,"memory":"180 MB"
> 55,02/13,97 ~ 4; 229,75/55,02 ~ 4 - асимптотика времени работы программы составляет квадратичную то есть время работы растёт пропорционально квадрату размера обрабатываемого файла O(n^2). Дал приблизительную оценку времени выполнения файла в 3 млн строк - 15ч.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Лайк за оценку

- Для эффективной работы выбрал начать с файла в 50_000 строк. Оставил метрику на время выполнения файла, но бюджет решил обновлять после каждой итерации
по оптимизации программы. Для первой итерации установил бюджет в 2 секунды.

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

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

Вот как я построил `feedback_loop`: *как вы построили feedback_loop*
Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за около 15 минут, так как аытался использовать различгые отчеты
Copy link
Collaborator

Choose a reason for hiding this comment

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

время фидбек-лупа я скорее имею в виду это то сколько вам прихоидтся "тупо ждать" результата

то есть если вы сделали какое-то изменение и на проверку того, что стало лучше/хуже через минут 10 это очень плохо, потому что уже теряется фокус, начинается группировка изменений в одну итерацию и тд

изучая их возможности.

Создал бенчмарк и ruby файлы под каждый вид профилирования. Как аргумент передаю количество строк.
Вот как я построил `feedback_loop`:
- Запускаю бенчмарк.
- Запускаю профилировщик.
- Определяю главную точку роста.
- Вношу изменения.
- Проверяю гипотезу повторным запуском профилировщика.
- Защищаю изменения тестом.

## Вникаем в детали системы, чтобы найти главные точки роста
Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались*
Для того, чтобы найти "точки роста" для оптимизации я воспользовался ruby-prof(flat),

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

### Ваша находка №1
- какой отчёт показал главную точку роста
- как вы решили её оптимизировать
- как изменилась метрика
- как изменился отчёт профилировщика - исправленная проблема перестала быть главной точкой роста?
- Отчет ruby-prof flat показал 70.78% времени выполнения на Array#select
- Проблема была в формировании юзер сессий отдеьлным селектом по каждому юзеру. Чтобы избежать этого решил сгруппировать
сессии по user_id до итерирования по юзерам.
Copy link
Collaborator

Choose a reason for hiding this comment

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

главное тут исправилась асимпототика с O(N^2) на O(N)

по идее тут можно было бы заново оценить асимптотику

и тогда можно было бы по идее даже задаться какой-то аппроксимацией а-ля если 3М - 30 сек, то надо уложиться 300К в 1сек и тогда должно быть норм

- После внесения изменений выполнил поставленную метрику в 2с, получил 0.86с на 50_000 строк.
- Исправленная проблема перестала быть главной точкой роста

### Ваша находка №2
- какой отчёт показал главную точку роста
- как вы решили её оптимизировать
- как изменилась метрика
- как изменился отчёт профилировщика - исправленная проблема перестала быть главной точкой роста?
Для следующего поиска выбрал объем данных в 100_000 строк, перед оптимизацией бенчмарк оценил длительность - 2.43с
Установил промежуточный бюджет в 1с.

### Ваша находка №X
- какой отчёт показал главную точку роста
- как вы решили её оптимизировать
- как изменилась метрика
- как изменился отчёт профилировщика - исправленная проблема перестала быть главной точкой роста?
### Ваша находка №2
- Отчет callstack показал что 54.67% занимает метод Array#+ На каждую итерацию создается новый массив, а это тяжелая операция.
- Проблему решил добавлением элементов в массив через << (users << parse_user(line), sessions << parse_session(line))
- Бенчмарк показал что метрику в 0.87c
- Исправленная проблема перестала быть главной точкой роста
- Защитил изменения обновлением перформенс теста.

Оценил бенчмарком время выполнения на 200_000 строк(1.96с). Принял решение создать файл для тестов на
400_000 строк("time":5.11,"gc_count":542,"memory":"561 MB") для более точной оценки точек роста.
Оценил ассимпототику(400_000 - 5.11, 200_000 - 2.1, 100_000 - 0.9) - уже больше похоже на линейную зависимость.
Оптимистически предполагаю, что достижения бюджета в 3с для 400_000 строк должно хватить, для достижения основного бюджета.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


### Ваша находка №3
- Для поиска главной точки роста решил использовать stackprof. Большую часть времени забирал на себя Array#each, но
я предположил что надо смотреть на то что конкретно в этом блоке тратится больше времени. Заметил что 55.66% времени
в этом блоке занимает Array#all?. Подтверждаю свою находку перезапуском ruby-prof callstack.
- Вместо того чтобы в каждой итерации проверять браузер на уникальность(uniqueBrowsers.all?), решил использовать Set,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Set 👍

который по определению хранит только уникальные значения.
- Метрика показала 4.36c, что не удовлетворяет промежуточному бюджету в 3с.
- Исправленная проблема перестала быть главной точкой роста
- Защитил изменения обновлением перформенс теста.

### Ваша находка №4
- Для поиска главной точки роста решил использовать stackprof speedscope. Оценил удобство исользования и количество доступной информации.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 stackprof + speedscope = one love

Определил что главная точка роста на данный момент Object#collect_stats_from_users(41%).
- В первую очередь вынес в отдельнвую перемменную user_stats = report['usersStats'].
Далее убрал ненужную тут конкатенацию "#{user.attributes['first_name']} #{user.attributes['last_name']}"
Copy link
Collaborator

Choose a reason for hiding this comment

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

много изменений в одну итерацию == не понятно что сработало

Вместо использования merge, который создает дополнительный хэш, обновляю данные напрямую block.call(user).each { |key, value| stats[key] = value }
Также заметил что в первой итерации не исправил в одном месте Array#+, заменил на <<
- Метрика показала 2.09c, что удовлетворяет промежуточному бюджету в 3с.
Удивился, так как предполагаю что с учетом линейной асимптотики этого уже должно хватить для достижния главного бюджета в 30 секунд
на большом файле. Проверяю и подтверждаю достижение бюджета:
{"3.3.6":{"gc":"enabled","time":24.02,"gc_count":92,"memory":"1856 MB"}}
- Однако исправленная проблема не перестала быть главной точкой роста
- Защитил изменения обновлением перформенс теста.
{"3.3.6":{"gc":"enabled","time":24.02,"gc_count":92,"memory":"1856 MB"}}

Не смотря на достижение бюджета, решаю провести еще одну итерацию по оптимизации выполнения метода Object#collect_stats_from_users:

- С помощью callstack отчета отмечаю большую долю выполнения на <Class::Date>#parse
- Отмечаю что Date.parse тут необязателен, использую { 'dates' => user.sessions.map { |s| s['date'] }.sort.reverse }
- Бенчмарк показывает 19.3с на большом файле. Бюджет выполнен, решаю остановить оптимизацию.
- Избавился от точки роста в <Class::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.

да-да, это пасхалочка одна из моих любимых


## Результаты
В результате проделанной оптимизации наконец удалось обработать файл с данными.
Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* и уложиться в заданный бюджет.
Удалось улучшить метрику системы с оценки времени выполнения в 15 часов до 19.3с и уложиться в заданный бюджет.

*Какими ещё результами можете поделиться*
Для себя отметил качество и удобство использования callstack а также stackrprof speedscope отчетов.
Отметил что при оптимизации лучше опираться на точки роста в отчетах, так как один раз при очевидной попытки оптимизации
"на глаз" метода Array#map получил регресс в метрике.
Также оптимальным показалось использование не одного конкретного профилировщика, а использование их в комплексе, для лучшей оценки точек роста.

## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали*
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы написал перформанс тест который запускает
программу 2 раза и проверяет время выполнения на прокинутом в него файле за определнное количество времени. Тест обновлял
по мере оптимизации, увеличивая количество строк и задавая соответствующее время выполнения.
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


Binary file removed data_large.txt.gz
Binary file not shown.
54 changes: 54 additions & 0 deletions profilers/profile.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

require 'fileutils'
require 'ruby-prof'
require 'stackprof'
require_relative '../task-1'

REPORTS_DIR = 'ruby_prof_reports'
FileUtils.mkdir_p(REPORTS_DIR)

path = "data/data#{ARGV[0] || 50000}.txt"
mode = ARGV[1] || 'flat'

profile = RubyProf::Profile.new(measure_mode: RubyProf::WALL_TIME)

result = profile.profile do
work(path, no_gc: true)
end

case mode
when 'callgrind'
printer = RubyProf::CallTreePrinter.new(result)
printer.print(path: REPORTS_DIR, profile: "callgrind")
puts "Callgrind report generated at #{REPORTS_DIR}/callgrind.out"

when 'graph'
printer = RubyProf::GraphHtmlPrinter.new(result)
File.open("#{REPORTS_DIR}/graph.html", 'w+') { |file| printer.print(file) }
puts "Graph HTML report generated at #{REPORTS_DIR}/graph.html"

when 'flat'
printer = RubyProf::FlatPrinter.new(result)
File.open("#{REPORTS_DIR}/flat.txt", 'w+') { |file| printer.print(file) }
puts "Flat profile report generated at #{REPORTS_DIR}/flat.txt"

when 'callstack'
printer = RubyProf::CallStackPrinter.new(result)
File.open("#{REPORTS_DIR}/callstack.html", 'w+') { |file| printer.print(file) }
puts "CallStack report generated at #{REPORTS_DIR}/callstack.html"
when 'stackprof'
StackProf.run(mode: :wall, out: "#{REPORTS_DIR}/stackprof.dump", interval: 1000) do
work(path, no_gc: true)
end

when 'stackprof_speedscope'
profile = StackProf.run(mode: :wall, raw: true) do
work(path, no_gc: true)
end
File.write("#{REPORTS_DIR}/stackprof_speedscope.json", JSON.generate(profile))

else
puts "Invalid mode: #{mode}. Use 'flat', 'graph', 'callstack', 'stackprof' or 'callgrind'."
exit 1
end
14 changes: 14 additions & 0 deletions rspec/perform_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

require_relative 'rspec_helper'

describe 'Performance reporter' do
let(:file_path) { 'data_large.txt' }
let(:time) { 28 }

it 'create report' do
expect {
work(file_path)
}.to perform_under(time).sec.warmup(2).times.sample(2).times
end
end
7 changes: 7 additions & 0 deletions rspec/rspec_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require 'rspec-benchmark'
require_relative "../task-1"


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