heap-profiler 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapProfiler
4
+ module Monochrome
5
+ class << self
6
+ def path(text)
7
+ text
8
+ end
9
+
10
+ def string(text)
11
+ text
12
+ end
13
+
14
+ def line(text)
15
+ text
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapProfiler
4
+ module Native
5
+ DEFAULT_BATCH_SIZE = 10_000_000 # 10MB
6
+ class << self
7
+ def build_index(path, batch_size: DEFAULT_BATCH_SIZE)
8
+ _build_index(path, batch_size)
9
+ end
10
+
11
+ def load_many(path, batch_size: DEFAULT_BATCH_SIZE, &block)
12
+ _load_many(path, batch_size, &block)
13
+ end
14
+
15
+ def filter_heap(source_path, destination_path, since:)
16
+ _filter_heap(source_path, destination_path, since)
17
+ end
18
+
19
+ def ruby_build_index(path)
20
+ require 'json'
21
+ classes_index = {}
22
+ strings_index = {}
23
+
24
+ File.open(path).each_line do |line|
25
+ object = JSON.parse(line, symbolize_names: true)
26
+ case object[:type]
27
+ when 'MODULE', 'CLASS'
28
+ if (name = object[:name])
29
+ classes_index[ruby_parse_address(object[:address])] = name
30
+ end
31
+ when 'STRING'
32
+ next if object[:shared]
33
+ if (value = object[:value])
34
+ strings_index[ruby_parse_address(object[:address])] = value
35
+ end
36
+ end
37
+ end
38
+
39
+ [classes_index, strings_index]
40
+ end
41
+
42
+ def ruby_parse_address(address)
43
+ address.to_i(16)
44
+ end
45
+ end
46
+ end
47
+ require "heap_profiler/heap_profiler"
48
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapProfiler
4
+ module Polychrome
5
+ class << self
6
+ def path(text)
7
+ blue(text)
8
+ end
9
+
10
+ def string(text)
11
+ green(text)
12
+ end
13
+
14
+ def line(text)
15
+ cyan(text)
16
+ end
17
+
18
+ private
19
+
20
+ def black(str)
21
+ "\033[30m#{str}\033[0m"
22
+ end
23
+
24
+ def red(str)
25
+ "\033[31m#{str}\033[0m"
26
+ end
27
+
28
+ def green(str)
29
+ "\033[32m#{str}\033[0m"
30
+ end
31
+
32
+ def brown(str)
33
+ "\033[33m#{str}\033[0m"
34
+ end
35
+
36
+ def blue(str)
37
+ "\033[34m#{str}\033[0m"
38
+ end
39
+
40
+ def magenta(str)
41
+ "\033[35m#{str}\033[0m"
42
+ end
43
+
44
+ def cyan(str)
45
+ "\033[36m#{str}\033[0m"
46
+ end
47
+
48
+ def gray(str)
49
+ "\033[37m#{str}\033[0m"
50
+ end
51
+
52
+ def bg_black(str)
53
+ "\033[40m#{str}\033[0m"
54
+ end
55
+
56
+ def bg_red(str)
57
+ "\033[41m#{str}\033[0m"
58
+ end
59
+
60
+ def bg_green(str)
61
+ "\033[42m#{str}\033[0m"
62
+ end
63
+
64
+ def bg_brown(str)
65
+ "\033[43m#{str}\033[0m"
66
+ end
67
+
68
+ def bg_blue(str)
69
+ "\033[44m#{str}\033[0m"
70
+ end
71
+
72
+ def bg_magenta(str)
73
+ "\033[45m#{str}\033[0m"
74
+ end
75
+
76
+ def bg_cyan(str)
77
+ "\033[46m#{str}\033[0m"
78
+ end
79
+
80
+ def bg_gray(str)
81
+ "\033[47m#{str}\033[0m"
82
+ end
83
+
84
+ def bold(str)
85
+ "\033[1m#{str}\033[22m"
86
+ end
87
+
88
+ def reverse_color(str)
89
+ "\033[7m#{str}\033[27m"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapProfiler
4
+ class << self
5
+ # This works around a Ruby bug present until at least 2.7.1
6
+ # ObjectSpace.dump include module and class names in the dump
7
+ # and for anonymous modules and classes this mean naming them.
8
+ #
9
+ # So we name them at the start of the profile to avoid that.
10
+ #
11
+ # See: https://github.com/ruby/ruby/pull/3349
12
+ #
13
+ # TODO: Could we actually do the dump ourselves? objspace is a extension already.
14
+ if RUBY_VERSION < '2.8'
15
+ def name_anonymous_modules!
16
+ ObjectSpace.each_object(Module) do |mod|
17
+ next if mod.singleton_class?
18
+ next if real_mod_name(mod)
19
+ # We have to assign it at the top level to avoid allocating a string for the name
20
+ ::Object.const_set(:AnonymousClassOrModule, mod)
21
+ ::Object.send(:remove_const, :AnonymousClassOrModule)
22
+ end
23
+ end
24
+
25
+ ::Module.alias_method(:__real_mod_name, :name)
26
+ def real_mod_name(mod)
27
+ mod.__real_mod_name
28
+ end
29
+ else
30
+ def name_anonymous_modules!
31
+ end
32
+ end
33
+ end
34
+
35
+ class Reporter
36
+ def initialize(dir_path)
37
+ @dir_path = dir_path
38
+ @enable_tracing = !allocation_tracing_enabled?
39
+ @generation = nil
40
+ end
41
+
42
+ def start
43
+ FileUtils.mkdir_p(@dir_path)
44
+ ObjectSpace.trace_object_allocations_start if @enable_tracing
45
+
46
+ @allocated_heap = open_heap("allocated")
47
+ @retained_heap = open_heap("retained")
48
+
49
+ HeapProfiler.name_anonymous_modules!
50
+
51
+ 4.times { GC.start }
52
+ GC.disable
53
+ @generation = GC.count
54
+ end
55
+
56
+ def stop
57
+ HeapProfiler.name_anonymous_modules!
58
+ ObjectSpace.trace_object_allocations_stop if @enable_tracing
59
+ dump_heap(@allocated_heap)
60
+ GC.enable
61
+ 4.times { GC.start }
62
+ dump_heap(@retained_heap)
63
+ @allocated_heap.close
64
+ @retained_heap.close
65
+ write_info("generation", @generation.to_s)
66
+ end
67
+
68
+ def run
69
+ start
70
+ begin
71
+ yield
72
+ rescue Exception
73
+ ObjectSpace.trace_object_allocations_stop if @enable_tracing
74
+ GC.enable
75
+ raise
76
+ else
77
+ stop
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def write_info(key, value)
84
+ File.write(File.join(@dir_path, "#{key}.info"), value)
85
+ end
86
+
87
+ # ObjectSpace.dump_all does allocate a few objects in itself (https://bugs.ruby-lang.org/issues/17045)
88
+ # because of this even en empty block of code will report a handful of allocations.
89
+ # To filter them more easily we attribute call `dump_all` from a method with a very specific `file`
90
+ # property.
91
+ class_eval <<~RUBY, '__hprof', __LINE__
92
+ # frozen_string_literal: true
93
+ def dump_heap(file)
94
+ ObjectSpace.dump_all(output: file)
95
+ file.close
96
+ end
97
+ RUBY
98
+
99
+ def open_heap(name)
100
+ File.open(File.join(@dir_path, "#{name}.heap"), 'w+')
101
+ end
102
+
103
+ def allocation_tracing_enabled?
104
+ ObjectSpace.allocation_sourceline(Object.new)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapProfiler
4
+ class AbstractResults
5
+ UNIT_PREFIXES = {
6
+ 0 => 'B',
7
+ 3 => 'kB',
8
+ 6 => 'MB',
9
+ 9 => 'GB',
10
+ 12 => 'TB',
11
+ 15 => 'PB',
12
+ 18 => 'EB',
13
+ 21 => 'ZB',
14
+ 24 => 'YB',
15
+ }.freeze
16
+
17
+ METRICS = ["memory", "objects", "strings"].freeze
18
+ GROUPINGS = ["gem", "file", "location", "class"].freeze
19
+
20
+ attr_reader :types, :dimensions
21
+
22
+ def initialize(*, **)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def print_title(io, title)
27
+ io.puts
28
+ io.puts title
29
+ io.puts @colorize.line("-----------------------------------")
30
+ end
31
+
32
+ def print_output(io, topic, detail)
33
+ io.puts "#{@colorize.path(topic.to_s.rjust(10))} #{detail}"
34
+ end
35
+
36
+ def print_output2(io, topic1, topic2, detail)
37
+ io.puts "#{@colorize.path(topic1.to_s.rjust(10))} #{@colorize.path(topic2.to_s.rjust(6))} #{detail}"
38
+ end
39
+
40
+ def normalize_path(path)
41
+ @normalize_path ||= {}
42
+ @normalize_path[path] ||= begin
43
+ if %r!(/gems/.*)*/gems/(?<gemname>[^/]+)(?<rest>.*)! =~ path
44
+ "#{gemname}#{rest}"
45
+ elsif %r!ruby/2\.[^/]+/(?<stdlib>[^/.]+)(?<rest>.*)! =~ path
46
+ "ruby/lib/#{stdlib}#{rest}"
47
+ elsif %r!(?<app>[^/]+/(bin|app|lib))(?<rest>.*)! =~ path
48
+ "#{app}#{rest}"
49
+ else
50
+ path
51
+ end
52
+ end
53
+ end
54
+
55
+ def scale_bytes(bytes)
56
+ return "0 B" if bytes.zero?
57
+
58
+ scale = Math.log10(bytes).div(3) * 3
59
+ scale = 24 if scale > 24
60
+ format("%.2f #{UNIT_PREFIXES[scale]}", (bytes / 10.0**scale))
61
+ end
62
+ end
63
+
64
+ class HeapResults < AbstractResults
65
+ def initialize(heap_path, metrics = METRICS, groupings = GROUPINGS)
66
+ @path = heap_path
67
+ @metrics = metrics
68
+ @groupings = groupings
69
+ end
70
+
71
+ def pretty_print(io = $stdout, **options)
72
+ heap = Dump.new(@path)
73
+ index = Index.new(heap)
74
+
75
+ color_output = options.fetch(:color_output) { io.respond_to?(:isatty) && io.isatty }
76
+ @colorize = color_output ? Polychrome : Monochrome
77
+
78
+ analyzer = Analyzer.new(heap, index)
79
+ dimensions = analyzer.run(@metrics, @groupings)
80
+
81
+ io.puts "Total: #{scale_bytes(dimensions['total_memory'].stats)} " \
82
+ "(#{dimensions['total_objects'].stats} objects)"
83
+
84
+ @metrics.each do |metric|
85
+ next if metric == "strings"
86
+ @groupings.each do |grouping|
87
+ dump_data(io, dimensions, metric, grouping, options)
88
+ end
89
+ end
90
+
91
+ if @metrics.include?("strings")
92
+ dump_strings(io, dimensions, options)
93
+ end
94
+ end
95
+
96
+ def dump_data(io, dimensions, metric, grouping, options)
97
+ print_title io, "#{metric} by #{grouping}"
98
+ data = dimensions["#{metric}_by_#{grouping}"].top_n(options.fetch(:top, 50))
99
+
100
+ scale_data = metric == "memory" && options[:scale_bytes]
101
+ normalize_paths = options[:normalize_paths]
102
+
103
+ if data && !data.empty?
104
+ data.each { |pair| pair[0] = normalize_path(pair[0]) } if normalize_paths
105
+ data.each { |pair| pair[1] = scale_bytes(pair[1]) } if scale_data
106
+ data.each { |k, v| print_output(io, v, k) }
107
+ else
108
+ io.puts "NO DATA"
109
+ end
110
+ end
111
+
112
+ def dump_strings(io, dimensions, options)
113
+ normalize_paths = options[:normalize_paths]
114
+ scale_data = options[:scale_bytes]
115
+ top = options.fetch(:top, 50)
116
+
117
+ print_title(io, "String Report")
118
+
119
+ dimensions["strings"].top_n(top).each do |string|
120
+ memsize = scale_data ? scale_bytes(string.memsize) : string.memsize
121
+ print_output2 io, memsize, string.count, @colorize.string(string.value.inspect)
122
+ string.top_n(top).each do |string_location|
123
+ location = string_location.location
124
+ location = normalize_path(location) if normalize_paths
125
+ print_output2 io, '', string_location.count, location
126
+ end
127
+ io.puts
128
+ end
129
+ end
130
+ end
131
+
132
+ class DiffResults < AbstractResults
133
+ TYPES = ["allocated", "retained"].freeze
134
+
135
+ def initialize(directory, types = TYPES, metrics = METRICS, groupings = GROUPINGS)
136
+ @directory = directory
137
+ @types = types
138
+ @metrics = metrics
139
+ @groupings = groupings
140
+ end
141
+
142
+ def pretty_print(io = $stdout, **options)
143
+ diff = Diff.new(@directory)
144
+ heaps = @types.to_h { |t| [t, diff.public_send("#{t}_diff")] }
145
+ index = Index.new(diff.allocated)
146
+
147
+ color_output = options.fetch(:color_output) { io.respond_to?(:isatty) && io.isatty }
148
+ @colorize = color_output ? Polychrome : Monochrome
149
+
150
+ dimensions = {}
151
+ heaps.each do |type, heap|
152
+ analyzer = Analyzer.new(heap, index)
153
+ dimensions[type] = analyzer.run(@metrics, @groupings)
154
+ end
155
+
156
+ dimensions.each do |type, metrics|
157
+ io.puts "Total #{type}: #{scale_bytes(metrics['total_memory'].stats)} " \
158
+ "(#{metrics['total_objects'].stats} objects)"
159
+ end
160
+
161
+ @types.each do |type|
162
+ @metrics.each do |metric|
163
+ next if metric == "strings"
164
+ @groupings.each do |grouping|
165
+ dump_data(io, dimensions, type, metric, grouping, options)
166
+ end
167
+ end
168
+ end
169
+
170
+ if @metrics.include?("strings")
171
+ @types.each do |type|
172
+ dump_strings(io, dimensions[type], type, options)
173
+ end
174
+ end
175
+ end
176
+
177
+ def dump_data(io, dimensions, type, metric, grouping, options)
178
+ print_title io, "#{type} #{metric} by #{grouping}"
179
+ data = dimensions[type]["#{metric}_by_#{grouping}"].top_n(options.fetch(:top, 50))
180
+
181
+ scale_data = metric == "memory" && options[:scale_bytes]
182
+ normalize_paths = options[:normalize_paths]
183
+
184
+ if data && !data.empty?
185
+ data.each { |pair| pair[0] = normalize_path(pair[0]) } if normalize_paths
186
+ data.each { |pair| pair[1] = scale_bytes(pair[1]) } if scale_data
187
+ data.each { |k, v| print_output(io, v, k) }
188
+ else
189
+ io.puts "NO DATA"
190
+ end
191
+ end
192
+
193
+ def dump_strings(io, dimensions, type, options)
194
+ normalize_paths = options[:normalize_paths]
195
+ scale_data = options[:scale_bytes]
196
+ top = options.fetch(:top, 50)
197
+
198
+ print_title(io, "#{type.capitalize} String Report")
199
+
200
+ dimensions["strings"].top_n(top).each do |string|
201
+ memsize = scale_data ? scale_bytes(string.memsize) : string.memsize
202
+ print_output2 io, memsize, string.count, @colorize.string(string.value.inspect)
203
+ string.top_n(top).each do |string_location|
204
+ location = string_location.location
205
+ location = normalize_path(location) if normalize_paths
206
+ print_output2 io, '', string_location.count, location
207
+ end
208
+ io.puts
209
+ end
210
+ end
211
+ end
212
+ end