heap-profiler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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