heap-profiler 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|