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.
- checksums.yaml +7 -0
- data/.github/workflows/tests.yml +59 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +20 -0
- data/.travis.yml +6 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +51 -0
- data/LICENSE.txt +21 -0
- data/README.md +269 -0
- data/Rakefile +17 -0
- data/TODO.md +9 -0
- data/benchmark/address-parsing.rb +12 -0
- data/benchmark/indexing.rb +14 -0
- data/bin/console +15 -0
- data/bin/generate-report +34 -0
- data/bin/rubocop +29 -0
- data/bin/setup +8 -0
- data/bin/testunit +9 -0
- data/dev.yml +20 -0
- data/exe/heap-profiler +6 -0
- data/ext/heap_profiler/extconf.rb +7 -0
- data/ext/heap_profiler/heap_profiler.cpp +262 -0
- data/ext/heap_profiler/simdjson.cpp +17654 -0
- data/ext/heap_profiler/simdjson.h +7716 -0
- data/heap-profiler.gemspec +31 -0
- data/lib/heap-profiler.rb +6 -0
- data/lib/heap_profiler/analyzer.rb +147 -0
- data/lib/heap_profiler/cli.rb +32 -0
- data/lib/heap_profiler/diff.rb +35 -0
- data/lib/heap_profiler/dump.rb +101 -0
- data/lib/heap_profiler/full.rb +12 -0
- data/lib/heap_profiler/index.rb +89 -0
- data/lib/heap_profiler/monochrome.rb +19 -0
- data/lib/heap_profiler/native.rb +48 -0
- data/lib/heap_profiler/polychrome.rb +93 -0
- data/lib/heap_profiler/reporter.rb +107 -0
- data/lib/heap_profiler/results.rb +212 -0
- data/lib/heap_profiler/runtime.rb +29 -0
- data/lib/heap_profiler/version.rb +6 -0
- metadata +86 -0
@@ -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
|