diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0c378bb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +data_large.txt +data_prof.txt +result.json +tmp/ +memory_profiler/ +stackprof_reports/ \ No newline at end of file diff --git a/case-study.md b/case-study.md new file mode 100644 index 00000000..d1a5ff5d --- /dev/null +++ b/case-study.md @@ -0,0 +1,71 @@ +# Case-study оптимизации + +## Актуальная проблема +В нашем проекте возникла серьёзная проблема. + +Необходимо было обработать файл с данными, чуть больше ста мегабайт. + +У нас уже была программа на `ruby`, которая умела делать нужную обработку. + +Она успешно работала на файлах размером пару мегабайт, но для большого файла она работала слишком долго, и не было понятно, закончит ли она вообще работу за какое-то разумное время. + +Я решил исправить эту проблему, оптимизировав эту программу. + +## Формирование метрики +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: Потребление памяти. + +## Гарантия корректности работы оптимизированной программы +Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. + +## Feedback-Loop +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за за 10-20 секунд. + +Вот как я построил `feedback_loop`: Создавал файл с N строк, чтобы программа могла выполнятся 10-20 секунд. + +## Вникаем в детали системы, чтобы найти главные точки роста +Для того, чтобы найти "точки роста" для оптимизации я воспользовался memory_profiler, в котором смотрел какой код создает больше всего объектов, stackprof для большей детальности. Так же дополнительно был написан скрипт запускающий код в 2 потока, один из которых раз в секунду печатал потребление памяти. + +Вот какие проблемы удалось найти и решить + +### Ваша находка №1 +Для первого профилирования я сделал файл из 10000 строк, получил потребление памяти 460 МВ, для 30000 - 405 МВ, для 50000 - 492 МВ, на этом количестве остановился. +Попробовал использовать memory_profiler, который показал 560712 созданных объектов в строке + ``` + { 'dates' => user.sessions.map{|s| s['date']}.map {|d| Date.parse(d)}.sort.reverse.map { |d| d.iso8601 } } + ``` +Использовал stackprof, он показал `734611 (31.3%) String#split` и `380763 (16.2%) Date.parse`. +Посмотрел детальнее метод String#split, большая часть создается в методе `296135 (40.3%) Object#parse_session` +Делаю вывод что Главная точка роста - Date.parse + +https://github.com/fastruby/fast-ruby?tab=readme-ov-file#date рекомендуют использовать Date.iso8601. +Потребление памяти не сильно изменилось, осталось 504 MB, `211530 (9.7%) Date.iso8601` +Попробовал Date.strftime, потребление памяти снизилось до `MEMORY USAGE: 356 MB`, `126921 (6.3%) Date.strptime`, оставил это решение. + +### Ваша находка №2 +Теперь результат memory_profiler стал показывать что в строке +``` +cols = line.split(',') +``` +создается 392305 объектов. StackProf так же показывает что split создает много объектов, не только в этом месте +``` +String#split (:1) + samples: 734611 self (36.6%) / 734611 total (36.6%) + callers: + 392306 ( 53.4%) Object#work + 296135 ( 40.3%) Object#parse_session + 46170 ( 6.3%) Object#parse_user +``` +Изучив код, принял решение отказаться от использования в данном месте метода split, а определять принадлежность к сущности user/session по началу строки. +Общее количество объектов String#split сократилось до 392306 (24.3%), текущее место перестало был точкой роста. + +### Ваша находка №3 +Для понимания когда программа начинает потреблять больше памяти я написал скрипт который использует два потока, в одном выполняется программа, а другой выводит на экран каждую секунду потребляемую память. Скрипт показал что после чтения файла потребление памяти резко увеличивается. Для того чтобы всё содержимое файла не помещать в память, было решено переписать программу с использованием чтения строк. +После изменения кода, программа на больших данных стала тратить 22 МБ. + +## Результаты +В результате проделанной оптимизации наконец удалось обработать файл с данными. +Удалось улучшить метрику системы с более 700 Мб до менее 70 Мб и уложиться в заданный бюджет. + +## Защита от регрессии производительности +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы был создан performance тест. + diff --git a/task-2-assert-performance.rb b/task-2-assert-performance.rb new file mode 100644 index 00000000..2a1b67a6 --- /dev/null +++ b/task-2-assert-performance.rb @@ -0,0 +1,19 @@ +# rspec task-2-assert-performance.rb + +require 'rspec-benchmark' +require_relative 'task-2' + +RSpec.configure do |config| + config.include RSpec::Benchmark::Matchers +end + +describe 'Performance' do + describe 'task-2#work' do + let(:file_name) { 'data_large.txt' } + + it 'works with large allocating less than 70 Mb' do + expect(File.size(file_name)).to eq(134424508) # байт + expect(work(file_name:)).to be < 70 + end + end +end diff --git a/task-2-memory-profiler.rb b/task-2-memory-profiler.rb new file mode 100644 index 00000000..fb37d009 --- /dev/null +++ b/task-2-memory-profiler.rb @@ -0,0 +1,10 @@ +# head -n data_large.txt > data_prof.txt +# ruby task-2-memory-profiler.rb + +require 'memory_profiler' +require_relative 'task-2' + +report = MemoryProfiler.report do + work(file_name: 'data_prof.txt') +end +report.pretty_print(scale_bytes: true, to_file: 'memory_profiler/report_step3.txt') diff --git a/task-2-stackprof.rb b/task-2-stackprof.rb new file mode 100644 index 00000000..49fc6176 --- /dev/null +++ b/task-2-stackprof.rb @@ -0,0 +1,11 @@ +# head -n data_large.txt > data_prof.txt +# ruby task-2-stackprof.rb +# stackprof stackprof_reports/stackprof.dump + +require 'stackprof' +require_relative 'task-2' + +# Note mode: :object +StackProf.run(mode: :object, out: 'stackprof_reports/stackprof.dump', raw: true) do + work(file_name: 'data_prof.txt') +end \ No newline at end of file diff --git a/task-2-test.rb b/task-2-test.rb new file mode 100644 index 00000000..ef6d596a --- /dev/null +++ b/task-2-test.rb @@ -0,0 +1,36 @@ +# ruby task-1-test.rb + +require 'minitest/autorun' +require_relative 'task-2' + +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 + + 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 +end \ No newline at end of file diff --git a/task-2-thread.rb b/task-2-thread.rb new file mode 100644 index 00000000..baccebf6 --- /dev/null +++ b/task-2-thread.rb @@ -0,0 +1,30 @@ +require 'thread' +require_relative 'task-2' + +pid = Process.pid # Получаем ID текущего процесса +memory_limit = 70_000 # Лимит памяти в KB (например, 15_000 = 15MB) + +# Поток мониторинга памяти +memory_thread = Thread.new do + loop do + memory_usage = `ps -o rss= -p #{pid}`.strip.to_i # Получаем память в КБ + + puts "Используемая память: #{memory_usage/1024} MB" + + if memory_usage > memory_limit + puts "Превышен лимит памяти! Завершаем процесс..." + Process.kill('TERM', pid) # Отправляем сигнал завершения процесса + end + + sleep 1 # Ожидание 1 секунду перед следующим измерением + end +end + +# Поток выполнения программы +work_thread = Thread.new do + work(file_name: 'data_large.txt') +end + +# Ожидаем завершения выполнения программы +work_thread.join +memory_thread.kill # Завершаем поток мониторинга памяти после окончания работы diff --git a/task-2-time.rb b/task-2-time.rb new file mode 100644 index 00000000..f8e5fe4b --- /dev/null +++ b/task-2-time.rb @@ -0,0 +1,10 @@ +require 'benchmark' +require_relative 'task-2' + +puts "Start" + +time = Benchmark.realtime do + work(file_name: 'data_large.txt') +end + +puts "Finish in #{time.round(2)} seconds" diff --git a/task-2.rb b/task-2.rb index 34e09a3c..dd8b97c2 100644 --- a/task-2.rb +++ b/task-2.rb @@ -3,16 +3,6 @@ require 'json' require 'pry' require 'date' -require 'minitest/autorun' - -class User - attr_reader :attributes, :sessions - - def initialize(attributes:, sessions:) - @attributes = attributes - @sessions = sessions - end -end def parse_user(user) fields = user.split(',') @@ -31,30 +21,11 @@ def parse_session(session) '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']}" - 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| - cols = line.split(',') - users = users + [parse_user(line)] if cols[0] == 'user' - sessions = sessions + [parse_session(line)] if cols[0] == 'session' - end - +def work(file_name: 'data.txt') # Отчёт в json # - Сколько всего юзеров + # - Сколько всего уникальных браузеров + @@ -69,109 +40,71 @@ def work # - Хоть раз использовал IE? + # - Всегда использовал только Хром? + # - даты сессий в порядке убывания через запятую + - - 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] + File.open('result.json', 'w') do |file_result| + file_result.write('{"usersStats":{') + + File.open(file_name, 'r') do |file| + user = nil + sessions = [] + total_users = 0 + total_sessions = 0 + unique_browsers = Set.new + + file.each_line do |line| + if line.start_with?('user') + unless user.nil? + save_user(file_result, user:, sessions:) + file_result.write(',') + end + + user = parse_user(line) + + sessions = [] + total_users += 1 + else + session = parse_session(line) + + sessions << session + total_sessions += 1 + unique_browsers << session['browser'].upcase + end + end + + # Запись последнего пользователя и общей статистики + unless user.nil? + save_user(file_result, user:, sessions:) + file_result.write('},') # usersStats + + save_common(file_result, total_users:, total_sessions:, unique_browsers:) + + file_result.write("}\n") # JSON + end + end 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 } } - end - - File.write('result.json', "#{report.to_json}\n") - puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) + memory = (`ps -o rss= -p #{Process.pid}`.to_i / 1024) + puts "MEMORY USAGE: #{memory} MB" + memory end -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 - - 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 +def save_user(file_result, user:, sessions:) + file_result.write("\"#{user['first_name']} #{user['last_name']}\":") + file_result.write(JSON.dump( + { + 'sessionsCount' => sessions.count, + 'totalTime' => (sessions.map {|s| s['time']}.map {|t| t.to_i}.sum.to_s + ' min.'), + 'longestSession' => (sessions.map {|s| s['time']}.map {|t| t.to_i}.max.to_s + ' min.'), + 'browsers' => sessions.map {|s| s['browser']}.map {|b| b.upcase}.sort.join(', '), + 'usedIE' => sessions.map{|s| s['browser']}.any? { |b| b.upcase =~ /INTERNET EXPLORER/ }, + 'alwaysUsedChrome' => sessions.map{|s| s['browser']}.all? { |b| b.upcase =~ /CHROME/ }, + 'dates' => sessions.map{|s| s['date']}.map {|d| Date.strptime(d)}.sort.reverse.map { |d| d.iso8601 } + } + )) end + +def save_common(file_result, total_users:, total_sessions:, unique_browsers:) + file_result.write("\"totalUsers\":#{total_users},") + file_result.write("\"uniqueBrowsersCount\":#{unique_browsers.count},") + file_result.write("\"totalSessions\":#{total_sessions},") + file_result.write("\"allBrowsers\":\"#{unique_browsers.sort.join(',')}\"") +end \ No newline at end of file