diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..115907c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM ruby:2.6-stretch + +RUN apt-get update && apt-get install g++ valgrind -y \ + massif-visualizer \ + --no-install-recommends \ + && apt-get purge --auto-remove -y curl \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /src/*.deb + +RUN groupadd -r massif && useradd -r -g massif massif \ + && mkdir -p /home/massif/test && chown -R massif:massif /home/massif +USER massif +WORKDIR /home/massif/test + +COPY Gemfile /home/massif/test +RUN bundle install diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..7ca780ba --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'oj' +gem 'progress_bar' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..5a445ec3 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,19 @@ +GEM + remote: https://rubygems.org/ + specs: + highline (2.0.3) + oj (3.13.13) + options (2.3.2) + progress_bar (1.3.3) + highline (>= 1.6, < 3) + options (~> 2.3.0) + +PLATFORMS + ruby + +DEPENDENCIES + oj + progress_bar + +BUNDLED WITH + 1.17.2 diff --git a/README.md b/README.md index d73dc702..d86aa746 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,15 @@ puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024)" - в описание `PR` добавьте чеклист и отметьте, что из него сделали; для получения максимальной пользы надо отметить всё. ## Checklist -- [ ] Построить и проанализировать отчёт гемом `memory_profiler` -- [ ] Построить и проанализировать отчёт `ruby-prof` в режиме `Flat`; -- [ ] Построить и проанализировать отчёт `ruby-prof` в режиме `Graph`; -- [ ] Построить и проанализировать отчёт `ruby-prof` в режиме `CallStack`; -- [ ] Построить и проанализировать отчёт `ruby-prof` в режиме `CallTree` c визуализацией в `QCachegrind`; -- [ ] Построить и проанализировать текстовый отчёт `stackprof`; +- [x] Построить и проанализировать отчёт гемом `memory_profiler` +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `Flat`; +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `Graph`; +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `CallStack`; +- [x] Построить и проанализировать отчёт `ruby-prof` в режиме `CallTree` c визуализацией в `QCachegrind`; +- [x] Построить и проанализировать текстовый отчёт `stackprof`; - [ ] Построить и проанализировать отчёт `flamegraph` с помощью `stackprof` и визуализировать его в `speedscope.app`; -- [ ] Построить график потребления памяти в `valgrind massif visualier` и включить скриншот в описание вашего `PR`; -- [ ] Написать тест, на то что программа укладывается в бюджет по памяти +- [x] Построить график потребления памяти в `valgrind massif visualier` и включить скриншот в описание вашего `PR`; +- [x] Написать тест, на то что программа укладывается в бюджет по памяти Не нужно включать в `PR` выводы всех этих отчётов, просто используйте каждый хотя бы по разу в вашем `Case-study`. diff --git a/build-docker.sh b/build-docker.sh new file mode 100755 index 00000000..5bcd40b2 --- /dev/null +++ b/build-docker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker build . -t spajic/docker-valgrind-massif diff --git a/case-study-template.md b/case-study-template.md index c3279664..a4a08e93 100644 --- a/case-study-template.md +++ b/case-study-template.md @@ -12,44 +12,81 @@ Я решил исправить эту проблему, оптимизировав эту программу. ## Формирование метрики -Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: *тут ваша метрика* +Для того, чтобы понимать, дают ли мои изменения положительный эффект на быстродействие программы я придумал использовать такую метрику: какое количество памяти использует программа для файла размером 20000 строк. Начальное значение - около 82 МБ. ## Гарантия корректности работы оптимизированной программы Программа поставлялась с тестом. Выполнение этого теста в фидбек-лупе позволяет не допустить изменения логики программы при оптимизации. ## Feedback-Loop -Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за *время, которое у вас получилось* +Для того, чтобы иметь возможность быстро проверять гипотезы я выстроил эффективный `feedback-loop`, который позволил мне получать обратную связь по эффективности сделанных изменений за ~30 секунд -Вот как я построил `feedback_loop`: *как вы построили feedback_loop* +Вот как я построил `feedback_loop`: +1. Изучал отчеты профилировщиков +2. Вносил изменения в программу +3. Запускал тесты для проверки корректности работы программы +4. Запускал тесты производительности ## Вникаем в детали системы, чтобы найти главные точки роста -Для того, чтобы найти "точки роста" для оптимизации я воспользовался *инструментами, которыми вы воспользовались* +Для того, чтобы найти "точки роста" для оптимизации я воспользовался: +1. Гемом memory_profiler; +2. Stackprof; +3. Ruby-prof в режиме graph; +4. Ruby-prof в режиме callstack; +5. Ruby-prof в режиме flat; +6. Ruby-prof с визуализацией в QCacheGrind. Вот какие проблемы удалось найти и решить -### Ваша находка №1 -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика - -### Ваша находка №2 -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика - -### Ваша находка №X -- какой отчёт показал главную точку роста -- как вы решили её оптимизировать -- как изменилась метрика -- как изменился отчёт профилировщика +### Выделение 15мб памяти для парсинга даты +- какой отчёт показал главную точку роста: гем memory_profiler +- как вы решили её оптимизировать: убрал лишние вызовы метода map, а также переводы в различные форматы +- как изменилась метрика: выделение памяти снизилось до 24.6 Мб +- как изменился отчёт профилировщика: проблема перестала быть главной точкой роста + +### Большое количество аллокаций для метода String#upcase +- какой отчёт показал главную точку роста: Stackprof +- как вы решили её оптимизировать: Всего было 4 вызова метода upcase, все они были использованы для браузеров сессий. Я оставил только 1 вызов метода upcase - при парсинге браузера сессий, тогда все остальные вызовы можно убрать +- как изменилась метрика: снизилась до 23 МБ +- как изменился отчёт профилировщика: проблема перестала быть главной точкой роста + +### Выделение большого количества памяти при сборе данных о пользователе +- какой отчёт показал главную точку роста: Ruby-prof в режиме graph +- как вы решили её оптимизировать: изначально блок кода выглядел так: + ``` + user_key = "#{@user['first_name']}" + ' ' + "#{@user['last_name']}" + user_data = { + 'sessionsCount' => @user_sessions.count, + 'totalTime' => @user_sessions.map {|s| s['time']}.map {|t| t.to_i}.sum.to_s + ' min.', + 'longestSession' => @user_sessions.map {|s| s['time']}.map {|t| t.to_i}.max.to_s + ' min.', + 'browsers' => @user_sessions.map {|s| s['browser']}.sort.join(', '), + 'usedIE' => @user_sessions.map{|s| s['browser']}.any? { |b| b =~ /INTERNET EXPLORER/ }, + 'alwaysUsedChrome' => @user_sessions.map{|s| s['browser']}.all? { |b| b =~ /CHROME/ }, + 'dates' => @user_sessions.map{|s| s['date']}.sort.reverse + } + ``` + В нем я изменил конкатенацию строк, лишние приведения значений к типу int, использовал bang-методы, где это возможно. В результате данный блок кода стал выглядеть следующим образом: + + ``` + user_key = "#{@user['first_name']} #{@user['last_name']}" + user_data = { + 'sessionsCount' => @user_sessions.count, + 'totalTime' => @user_sessions.map {|s| s['time']}.sum.to_s + ' min.', + 'longestSession' => @user_sessions.map {|s| s['time']}.max.to_s + ' min.', + 'browsers' => @user_sessions.map {|s| s['browser']}.sort!.join(', '), + 'usedIE' => @user_sessions.map{|s| s['browser']}.any? { |b| b =~ /INTERNET EXPLORER/ }, + 'alwaysUsedChrome' => @user_sessions.map{|s| s['browser']}.all? { |b| b =~ /CHROME/ }, + 'dates' => @user_sessions.map{|s| s['date']}.sort!.reverse! + } + ``` + +- как изменилась метрика: снизилась до 18 МБ +- как изменился отчёт профилировщика: главная проблема не является главной точкой роста ## Результаты В результате проделанной оптимизации наконец удалось обработать файл с данными. -Удалось улучшить метрику системы с *того, что у вас было в начале, до того, что получилось в конце* и уложиться в заданный бюджет. +Удалось улучшить метрику системы с 80 МБ для файла размеров 20000 строк до 40 МБ на протяжении всей работы программы для основного файла и уложиться в заданный бюджет. -*Какими ещё результами можете поделиться* +Также хочу отметить, что программа сейчас работает быстрее, чем программа из первого домашнего задания. Удалось добиться результата в 27 секунд. ## Защита от регрессии производительности -Для защиты от потери достигнутого прогресса при дальнейших изменениях программы *о performance-тестах, которые вы написали* +Для защиты от потери достигнутого прогресса при дальнейших изменениях программы я написал performance-test, который проверяет, что программа потребляет менее 40 МБ памяти для файла data_large.txt diff --git a/json_writer.rb b/json_writer.rb new file mode 100644 index 00000000..c6d15d67 --- /dev/null +++ b/json_writer.rb @@ -0,0 +1,40 @@ +require 'oj' + +class JsonWriter + attr_reader :file_writer + + def initialize(file_name) + @file_writer = Oj::StreamWriter.new(file_name) + end + + def prepare_user_stats + @file_writer.push_object + @file_writer.push_key('usersStats') + @file_writer.push_object + end + + def write_user_stats(user_key, user_data) + @file_writer.push_key(user_key) + @file_writer.push_value(user_data) + end + + def write_common_stats(users_count, unique_browsers, sessions_count) + @file_writer.pop + + @file_writer.push_key('totalUsers') + @file_writer.push_value(users_count) + + @file_writer.push_key('uniqueBrowsersCount') + @file_writer.push_value(unique_browsers.count) + + @file_writer.push_key('totalSessions') + @file_writer.push_value(sessions_count) + + @file_writer.push_key('allBrowsers') + @file_writer.push_value(unique_browsers.sort.join(',')) + + @file_writer.pop + + @file_writer.flush + end +end diff --git a/massif_vizualizer.png b/massif_vizualizer.png new file mode 100644 index 00000000..c5327d97 Binary files /dev/null and b/massif_vizualizer.png differ diff --git a/memory_profiler.rb b/memory_profiler.rb new file mode 100644 index 00000000..c7991c28 --- /dev/null +++ b/memory_profiler.rb @@ -0,0 +1,9 @@ +require_relative 'parser' +require 'benchmark' +require 'memory_profiler' + +report = MemoryProfiler.report do + Parser.new.work(file_name: 'data20000.txt') +end + +report.pretty_print(scale_bytes: true) diff --git a/memory_usage_spec.rb b/memory_usage_spec.rb new file mode 100644 index 00000000..e12d4977 --- /dev/null +++ b/memory_usage_spec.rb @@ -0,0 +1,21 @@ +require 'rspec-benchmark' +require 'rspec' +require 'bytesize' + +require_relative 'parser' + +RSpec.configure do |config| + config.include RSpec::Benchmark::Matchers +end + +RSpec.describe 'parser' do + describe 'memory usage' do + let(:expected_memory_usage) { ByteSize.mb(40).to_bytes } + + it 'uses less than expected memory' do + expect { + Parser.new.work(file_name: 'data_large.txt') + }.to perform_allocation(expected_memory_usage).bytes + end + end +end diff --git a/parser.rb b/parser.rb new file mode 100644 index 00000000..9e4fdf68 --- /dev/null +++ b/parser.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative 'json_writer' + +class Parser + attr_accessor :users_count, :sessions_count, :user, :user_sessions, :json_writer + attr_reader :unique_browsers + + def initialize + @users_count = 0 + @unique_browsers = [] + @sessions_count = 0 + end + + def work(file_name: "data.txt", disable_gc: false) + GC.disable if disable_gc + file_path = ENV['DATA_FILE'] || file_name + + File.open('result.json', 'a') do |output_file| + @json_writer = JsonWriter.new(output_file) + @json_writer.prepare_user_stats + + File.foreach(file_path, 'user') do |chunk| + chunk.split("\n") do |line| + cols = line.split(',') + case cols[0] + when 'user' + next + when '' + parse_user(cols) + when 'session' + parse_session(cols) + end + end + end + + collect_stats_for_previous_user + + @json_writer.write_common_stats(@users_count, @unique_browsers, @sessions_count) + + puts "MEMORY USAGE: %d MB" % (`ps -o rss= -p #{Process.pid}`.to_i / 1024) + end + end + + private + + def collect_unique_browsers + @user_sessions.each do |session| + browser = session['browser'] + @unique_browsers.push(browser) unless @unique_browsers.include?(browser) + end + end + + def parse_user(fields) + collect_stats_for_previous_user if @users_count > 0 + @users_count += 1 + + @user_sessions = [] + @user = { + 'first_name' => fields[2], + 'last_name' => fields[3], + } + end + + def parse_session(fields) + @sessions_count += 1 + + @user_sessions.push( + { + 'browser' => fields[3].upcase!, + 'time' => fields[4].to_i, + 'date' => fields[5], + } + ) + end + + def collect_stats_for_previous_user + collect_unique_browsers + + user_key = "#{@user['first_name']} #{@user['last_name']}" + user_data = { + 'sessionsCount' => @user_sessions.count, + 'totalTime' => @user_sessions.map {|s| s['time']}.sum.to_s + ' min.', + 'longestSession' => @user_sessions.map {|s| s['time']}.max.to_s + ' min.', + 'browsers' => @user_sessions.map {|s| s['browser']}.sort!.join(', '), + 'usedIE' => @user_sessions.map{|s| s['browser']}.any? { |b| b =~ /INTERNET EXPLORER/ }, + 'alwaysUsedChrome' => @user_sessions.map{|s| s['browser']}.all? { |b| b =~ /CHROME/ }, + 'dates' => @user_sessions.map{|s| s['date']}.sort!.reverse! + } + + @json_writer.write_user_stats(user_key, user_data) + end +end diff --git a/profile.sh b/profile.sh new file mode 100755 index 00000000..9ed3d674 --- /dev/null +++ b/profile.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +docker run -it \ + -v $(pwd):/home/massif/test \ + -e DATA_FILE=data_large.txt \ + spajic/docker-valgrind-massif \ + valgrind --tool=massif ruby work.rb diff --git a/task-2.rb b/task-2.rb index 34e09a3c..ea455eae 100644 --- a/task-2.rb +++ b/task-2.rb @@ -1,149 +1,7 @@ -# Deoptimized version of homework task - +require_relative "parser.rb" 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(',') - parsed_result = { - 'id' => fields[1], - 'first_name' => fields[2], - 'last_name' => fields[3], - 'age' => fields[4], - } -end - -def parse_session(session) - fields = session.split(',') - parsed_result = { - 'user_id' => fields[1], - 'session_id' => fields[2], - 'browser' => fields[3], - 'time' => fields[4], - '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 - - # Отчёт в json - # - Сколько всего юзеров + - # - Сколько всего уникальных браузеров + - # - Сколько всего сессий + - # - Перечислить уникальные браузеры в алфавитном порядке через запятую и капсом + - # - # - По каждому пользователю - # - сколько всего сессий + - # - сколько всего времени + - # - самая длинная сессия + - # - браузеры через запятую + - # - Хоть раз использовал 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] - 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) -end - class TestMe < Minitest::Test def setup File.write('result.json', '') @@ -170,7 +28,7 @@ def setup end def test_result - work + Parser.new.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 diff --git a/visualize.sh b/visualize.sh new file mode 100755 index 00000000..81512f65 --- /dev/null +++ b/visualize.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# +# NOTE: On macOS with XQuartz, you will need to allow network connections to X11 +# + +ip=$(ifconfig en0 | grep inet | awk '$1=="inet" {print $2}') && echo "IP: $ip" +xhost + ${ip} + +docker run -d -ti --rm \ + -e DISPLAY=${ip}:0 \ + -v /tmp/.X11-unix:/tmp/.X11-unix \ + -v $(pwd):/home/massif/test \ + spajic/docker-valgrind-massif \ + massif-visualizer diff --git a/work.rb b/work.rb new file mode 100644 index 00000000..f78c3898 --- /dev/null +++ b/work.rb @@ -0,0 +1,3 @@ +require_relative 'parser' + +Parser.new.work