Skip to content

Homework 2 #124

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 3 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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
data
data_large.txt
.ruby-version
reports
Gemfile.lock
12 changes: 12 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

ruby '3.3.6'

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

gem 'oj'
gem 'memory_profiler'
gem 'ruby-prof'
gem 'stackprof'
gem 'rspec-benchmark'
42 changes: 42 additions & 0 deletions bench_wrapper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 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
puts "Memory BEFORE: #{memory_before}"
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
memory_after = `ps -o rss= -p #{Process.pid}`.to_i/1024
gc_stat_after = GC.stat

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
20 changes: 20 additions & 0 deletions benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

require_relative 'memory_reporter'
require_relative 'bench_wrapper'
require_relative 'task-2'

# path = "data/data#{ARGV[0] || 50000}.txt"
path = 'data_large.txt'
report_memory = ARGV[0] == '--report-memory'

if report_memory
reporter = MemoryReporter.new

reporter.start
work(path)
else
measure do
work(path)
end
end
108 changes: 87 additions & 21 deletions case-study-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,44 +12,110 @@
Я решил исправить эту проблему, оптимизировав эту программу.

## Формирование метрики
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика*
Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: Программа не должна потреблять больше 70Мб памяти при обработке файла data_large.txt в течение всей своей работы.

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

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

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

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

Для первоначальной оценки запустил бенчмарк на 50_000 строк:
{"3.3.6":{"gc":"enabled","time":14.96,"gc_count":357,"memory":"269 MB"}}

Запустил в отдельном треде репорт потребления памяти в динамике:
* [2025-02-12 22:53:50 +0400] Memory usage: 22.00 MB
* [2025-02-12 22:53:52 +0400] Memory usage: 178.00 MB
* [2025-02-12 22:53:54 +0400] Memory usage: 194.00 MB
* [2025-02-12 22:53:56 +0400] Memory usage: 210.00 MB
* [2025-02-12 22:53:57 +0400] Memory usage: 229.00 MB
* [2025-02-12 22:53:59 +0400] Memory usage: 255.00 MB
* [2025-02-12 22:54:01 +0400] Memory usage: 281.00 MB
* [2025-02-12 22:54:03 +0400] Memory usage: 304.00 MB
* [2025-02-12 22:54:05 +0400] Memory usage: 334.00 MB
Copy link
Collaborator

Choose a reason for hiding this comment

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

вот, сразу всё видно - 22МБ чисто для старта; потом заргузка файла; потом плавный рост со временем


Решаю начать поиск точек роста с memory_profiler

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

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

### Ваша находка №1
- какой отчёт показал главную точку роста
- как вы решили её оптимизировать
- как изменилась метрика
- как изменился отчёт профилировщика
- memory_profiler показал главную точку роста в строке 7.16 GB rails-optimization-task2/task-2.rb:55
- Проблема конкатенации уже знакома по прошлому заданию, решаю ее заменой метода + на <<
То же самое проделываю и с предыдущей строкой.
- Метрика уменьшилась на 54 Мб на 50_000 срок
{"3.3.6":{"gc":"enabled","time":14.16,"gc_count":128,"memory":"215 MB"}}
Кратно уменьшилось количество срабатываний gc
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 вот это хороший инсайт

- Найденная точка роста перестала ей быть и уже не попадает в отчет

### Ваша находка №2
- какой отчёт показал главную точку роста
- как вы решили её оптимизировать
- как изменилась метрика
- как изменился отчёт профилировщика
- memory_profiler показал главную точку роста в строке
2.60 GB /Users/alex/pets/rails-optimization-task2/task-2.rb:104
также обратил внимание на большое количество алоцированные пробелов
223085 " "

169220 /rails-optimization-task2/task-2.rb:143
53865 /rails-optimization-task2/task-2.rb:40
- Вынес select из итератора, сгруппировав сессии по user_id
Исправил конкатенацию пробела, она там не нужна("#{user.attributes['first_name']}" + ' ' + "#{user.attributes['last_name']}").

Избавился от Date.parse, он также не нужен.

Добавил # frozen_string_literal: true
- Количество потребляемой памяти уменьшилось на 160MB на 50_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.3.6":{"gc":"enabled","time":0.27,"gc_count":30,"memory":"53 MB"}}
- Найденные точки роста престали ими быть

Смотрю метрики на 400_000 строк:

{"3.3.6":{"gc":"enabled","time":2.48,"gc_count":51,"memory":"412 MB"}}

### Ваша находка №3
- Использую stackprof для поиска очередной точки роста:

(5876907 (57.5%) 5876907 (57.5%) String#split)

### Ваша находка №X
- какой отчёт показал главную точку роста
- как вы решили её оптимизировать
- как изменилась метрика
- как изменился отчёт профилировщика
Главная точка роста split методы в work и parse_session иетодах
Зная что далее всеравно предстоит переписывать код в потоковом стиле, решаю не тратить время на
оптимизацию split метода, потому как далее эта логика поменяется.
- Переписал код в потоковом стиле. Использовал гем Oj

Так как порядок не важен, сначала запишем usersStats (как объект, куда по мере обработки добавим пары "имя юзера" => stats), а затем добавим остальные ключи.

- После внедрения новой логики получил метрику на data_large.txt файле в 1MB и выполнил бюджет

{"3.3.6":{"gc":"enabled","time":3.78,"gc_count":3037,"memory":"1 MB"}}
- 2738453(51.0%) 2738453(51.0%) String#split - остался главной точкой роста


### Ваша находка №4
- Отчет ruby-prof qcachegring показал главную точку роста в String#split(36%)
- Изучил возможности профилирования в ruby-prof qcachegring, но т.к. бюджет в 1MB
меня вполне устравивает, решл завершить оптимизацию.
- Защитил изменетестом, который проверяет потребление памяти на большом файле(менее 70 Mb).

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

Наиболее полезным и удобным показалось профилирование потребления памяти с помощью memory_profiler и ruby-prof.

Гигантскую долю памяти удалось сэкономить преписав программу в потоковом стиле с помощью гема Oj.

*Какими ещё результами можете поделиться*
Кажется для более точной оценки потребления памяти необходимо учитывать память которая была выделена
Copy link
Collaborator

Choose a reason for hiding this comment

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

нам не так важно обычно сколько именно было добавлено дополнительно; важно в результате просто сколько процесс потребляет (например чтобы в лимиты в k8s вписываться)

до начала выполнения профилируемого метода(~20Mb) и вычитать это значение от потребления памяти которое зафмксировали по заверешению работы метода.

## Защита от регрессии производительности
Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали*
Защитил изменетестом, который проверяет потребление памяти на большом файле(менее 70 Mb).
Binary file removed data_large.txt.gz
Binary file not shown.
41 changes: 41 additions & 0 deletions memory_reporter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

class MemoryReporter
DEFAULT_LIMIT_MB = 700
Copy link
Collaborator

Choose a reason for hiding this comment

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

70 же

DEFAULT_INTERVAL = 1
DEFAULT_LOG_FILE = 'reports/memory_usage.log'.freeze

class MemoryUsageError < StandardError; end

def initialize(limit_mb: DEFAULT_LIMIT_MB, log_file: DEFAULT_LOG_FILE)
@limit_mb = limit_mb
@log_file = log_file
end

def start
@thread = Thread.new do
loop do
mem_usage_mb = current_memory_usage
log_memory_usage(mem_usage_mb)
raise MemoryUsageError, "Memory usage exceeded limit of #{@limit_mb} MB" if mem_usage_mb > @limit_mb
sleep DEFAULT_INTERVAL
rescue => e
puts e.message
Process.kill("KILL", Process.pid)
end
end
@thread.abort_on_exception = true
end

private

def current_memory_usage
(`ps -o rss= -p #{Process.pid}`.to_i / 1024)
end

def log_memory_usage(mem_usage_mb)
File.open(@log_file, 'a') do |f|
f.puts "[#{Time.now}] Memory usage: #{format('%.2f', mem_usage_mb)} MB"
end
end
end
40 changes: 40 additions & 0 deletions profilers/profile.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

require 'fileutils'
require 'memory_profiler'
require 'ruby-prof'
require 'stackprof'
require_relative '../task-2'

REPORTS_DIR = 'reports'
FileUtils.mkdir_p(REPORTS_DIR)

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

case mode
when 'memory_profiler'
report = MemoryProfiler.report do
work(path)
end
report.pretty_print(scale_bytes: true)

when 'stackprof'
StackProf.run(mode: :object, out: "#{REPORTS_DIR}/stackprof.dump", raw: true) do
work(path)
end

when 'ruby-prof'
profile = RubyProf::Profile.new(measure_mode: RubyProf::MEMORY)

result = profile.profile do
work(path)
end
printer = RubyProf::CallTreePrinter.new(result)
printer.print(path: REPORTS_DIR, profile: 'callgrind')

else
puts "Invalid mode: #{mode}. Use 'flat', 'graph', 'callstack', 'stackprof' or 'callgrind'."
exit 1
end
1 change: 1 addition & 0 deletions result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"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"]}},"totalUsers":3,"totalSessions":15,"uniqueBrowsersCount":14,"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"}
17 changes: 17 additions & 0 deletions rspec/memory_usage_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

require 'rspec-benchmark'
require_relative '../task-2'

RSpec.describe 'Memory usage' do
include RSpec::Benchmark::Matchers

before do
File.write('result.json', '')
end

it 'consumes no more than 70 MB of memory' do
expect { work('data_large.txt', 'result.json') }
.to perform_allocation(70_000_000).bytes
Copy link
Collaborator

Choose a reason for hiding this comment

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

perform_allocation это не то; нам надо MAX RSS оценить, а не кол-во аллокаций и не объём выделенной памяти

end
end
Loading