vernier 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "marker"
4
+
5
+ module Vernier
6
+ class Collector
7
+ def initialize(mode)
8
+ @mode = mode
9
+ end
10
+
11
+ def stop
12
+ result = finish
13
+
14
+ markers = []
15
+ marker_list = self.markers
16
+ size = marker_list.size
17
+ marker_strings = Marker.name_table
18
+
19
+ marker_list.each_with_index do |(tid, id, ts), i|
20
+ name = marker_strings[id]
21
+ finish = nil
22
+ phase = Marker::Phase::INSTANT
23
+
24
+ if id == Marker::Type::GC_EXIT
25
+ # skip because these are incorporated in "GC enter"
26
+ else
27
+ if id == Marker::Type::GC_ENTER
28
+ j = i + 1
29
+
30
+ name = "GC pause"
31
+ phase = Marker::Phase::INTERVAL
32
+
33
+ while j < size
34
+ if marker_list[j][1] == Marker::Type::GC_EXIT
35
+ finish = marker_list[j][2]
36
+ break
37
+ end
38
+
39
+ j += 1
40
+ end
41
+ end
42
+
43
+ markers << [tid, name, ts, finish, phase]
44
+ end
45
+ end
46
+
47
+ result.instance_variable_set(:@markers, markers)
48
+
49
+ result
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vernier" # Make sure constants are loaded
4
+
5
+ module Vernier
6
+ module Marker
7
+ # These are equal to the marker phase types from gecko-profile.js
8
+ module Phase # :nodoc:
9
+ INSTANT = 0
10
+ INTERVAL = 1
11
+ INTERVAL_START = 2
12
+ INTERVAL_END = 3
13
+ end
14
+
15
+ MARKER_STRINGS = []
16
+
17
+ MARKER_STRINGS[Type::GVL_THREAD_STARTED] = "thread started"
18
+ MARKER_STRINGS[Type::GVL_THREAD_READY] = "thread ready"
19
+ MARKER_STRINGS[Type::GVL_THREAD_RESUMED] = "thread resumed"
20
+ MARKER_STRINGS[Type::GVL_THREAD_SUSPENDED] = "thread suspended"
21
+ MARKER_STRINGS[Type::GVL_THREAD_EXITED] = "thread exited"
22
+
23
+ MARKER_STRINGS[Type::GC_START] = "GC start"
24
+ MARKER_STRINGS[Type::GC_END_MARK] = "GC end marking"
25
+ MARKER_STRINGS[Type::GC_END_SWEEP] = "GC end sweeping"
26
+ MARKER_STRINGS[Type::GC_ENTER] = "GC enter"
27
+ MARKER_STRINGS[Type::GC_EXIT] = "GC exit"
28
+
29
+ MARKER_STRINGS.freeze
30
+
31
+ ##
32
+ # Return an array of marker names. The index of the string maps to the
33
+ # value of the corresponding constant
34
+ def self.name_table
35
+ MARKER_STRINGS
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Vernier
6
+ module Output
7
+ # https://profiler.firefox.com/
8
+ # https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.js
9
+ class Firefox
10
+ class Categorizer
11
+ attr_reader :categories, :gc_category, :jit_category
12
+ def initialize
13
+ @categories = []
14
+
15
+ add_category(name: "Default", color: "grey")
16
+ add_category(name: "Idle", color: "transparent")
17
+ @gc_category = add_category(name: "GC", color: "red")
18
+ add_category(
19
+ name: "stdlib",
20
+ color: "red",
21
+ matcher: starts_with(RbConfig::CONFIG["rubylibdir"])
22
+ )
23
+ add_category(name: "cfunc", color: "yellow", matcher: "<cfunc>")
24
+
25
+ rails_components = %w[ activesupport activemodel activerecord
26
+ actionview actionpack activejob actionmailer actioncable
27
+ activestorage actionmailbox actiontext railties ]
28
+ add_category(
29
+ name: "Rails",
30
+ color: "green",
31
+ matcher: gem_path(*rails_components)
32
+ )
33
+ add_category(
34
+ name: "gem",
35
+ color: "red",
36
+ matcher: starts_with(*Gem.path)
37
+ )
38
+ add_category(
39
+ name: "Application",
40
+ color: "purple"
41
+ )
42
+ @jit_category = add_category(
43
+ name: "JIT",
44
+ color: "blue"
45
+ )
46
+ end
47
+
48
+ def add_category(**kw)
49
+ category = Category.new(@categories.length, **kw)
50
+ @categories << category
51
+ category
52
+ end
53
+
54
+ def starts_with(*paths)
55
+ %r{\A#{Regexp.union(paths)}}
56
+ end
57
+
58
+ def gem_path(*names)
59
+ %r{\A#{Regexp.union(Gem.path)}/gems/#{Regexp.union(names)}}
60
+ end
61
+
62
+ def categorize(path)
63
+ @categories.detect { |category| category.matches?(path) } || @categories.first
64
+ end
65
+
66
+ class Category
67
+ attr_reader :idx, :name, :color, :matcher
68
+ def initialize(idx, name:, color:, matcher: nil)
69
+ @idx = idx
70
+ @name = name
71
+ @color = color
72
+ @matcher = matcher
73
+ end
74
+
75
+ def matches?(path)
76
+ @matcher && @matcher === path
77
+ end
78
+ end
79
+ end
80
+
81
+ def initialize(profile)
82
+ @profile = profile
83
+ @categorizer = Categorizer.new
84
+ end
85
+
86
+ def output
87
+ ::JSON.generate(data)
88
+ end
89
+
90
+ private
91
+
92
+ attr_reader :profile
93
+
94
+ def data
95
+ threads = Hash.new {|h,k| h[k] = {
96
+ timestamps: [],
97
+ weights: [],
98
+ samples: [],
99
+ categories: [],
100
+ markers: [],
101
+ }}
102
+
103
+ profile.samples.size.times do |i|
104
+ tid = profile.sample_threads[i]
105
+ thread = threads[tid]
106
+
107
+ thread[:timestamps] << profile.timestamps[i]
108
+ thread[:weights] << profile.weights[i]
109
+ thread[:samples] << profile.samples[i]
110
+ thread[:categories] << profile.sample_categories[i]
111
+ end
112
+
113
+ profile.markers.each do |marker|
114
+ threads[marker[0]][:markers] << marker
115
+ end
116
+
117
+ thread_data = profile.threads.map do |tid, thread_info|
118
+ data = threads[tid]
119
+ Thread.new(
120
+ profile,
121
+ @categorizer,
122
+ **thread_info,
123
+ **data
124
+ ).data
125
+ end
126
+
127
+ {
128
+ meta: {
129
+ interval: 1, # FIXME: memory vs wall
130
+ startTime: profile.meta[:started_at] / 1_000_000.0,
131
+ endTime: (profile.timestamps&.max || 0) / 1_000_000.0,
132
+ processType: 0,
133
+ product: "Ruby/Vernier",
134
+ stackwalk: 1,
135
+ version: 28,
136
+ preprocessedProfileVersion: 47,
137
+ symbolicated: true,
138
+ markerSchema: [],
139
+ sampleUnits: {
140
+ time: "ms",
141
+ eventDelay: "ms",
142
+ threadCPUDelta: "µs"
143
+ }, # FIXME: memory vs wall
144
+ categories: @categorizer.categories.map do |category|
145
+ {
146
+ name: category.name,
147
+ color: category.color,
148
+ subcategories: []
149
+ }
150
+ end
151
+ },
152
+ libs: [],
153
+ threads: thread_data
154
+ }
155
+ end
156
+
157
+ class Thread
158
+ attr_reader :profile
159
+
160
+ def initialize(profile, categorizer, name:, tid:, samples:, weights:, timestamps:, categories:, markers:, started_at:, stopped_at: nil)
161
+ @profile = profile
162
+ @categorizer = categorizer
163
+ @tid = tid
164
+ @name = name
165
+
166
+ @samples, @weights, @timestamps = samples, weights, timestamps
167
+ @sample_categories = categories
168
+ @markers = markers
169
+
170
+ @started_at, @stopped_at = started_at, stopped_at
171
+
172
+ names = profile.func_table.fetch(:name)
173
+ filenames = profile.func_table.fetch(:filename)
174
+
175
+ stacks_size = profile.stack_table.fetch(:frame).size
176
+ @categorized_stacks = Hash.new do |h, k|
177
+ h[k] = h.size + stacks_size
178
+ end
179
+
180
+ @strings = Hash.new { |h, k| h[k] = h.size }
181
+ @func_names = names.map do |name|
182
+ @strings[name]
183
+ end
184
+ @filenames = filenames.map do |filename|
185
+ @strings[filename]
186
+ end
187
+ @frame_categories = filenames.map do |filename|
188
+ @categorizer.categorize(filename)
189
+ end
190
+ end
191
+
192
+ def data
193
+ {
194
+ name: @name,
195
+ isMainThread: @tid == ::Thread.main.native_thread_id,
196
+ processStartupTime: 0, # FIXME
197
+ processShutdownTime: nil, # FIXME
198
+ registerTime: (@started_at - 0) / 1_000_000.0,
199
+ unregisterTime: ((@stopped_at - 0) / 1_000_000.0 if @stopped_at),
200
+ pausedRanges: [],
201
+ pid: profile.pid || Process.pid,
202
+ tid: @tid,
203
+ frameTable: frame_table,
204
+ funcTable: func_table,
205
+ nativeSymbols: {},
206
+ samples: samples_table,
207
+ stackTable: stack_table,
208
+ resourceTable: {
209
+ length: 0,
210
+ lib: [],
211
+ name: [],
212
+ host: [],
213
+ type: []
214
+ },
215
+ markers: markers_table,
216
+ stringArray: string_table
217
+ }
218
+ end
219
+
220
+ def markers_table
221
+ size = @markers.size
222
+
223
+ string_indexes = []
224
+ start_times = []
225
+ end_times = []
226
+ phases = []
227
+ categories = []
228
+
229
+ @markers.each_with_index do |(_, name, start, finish, phase), i|
230
+ string_indexes << @strings[name]
231
+ start_times << (start / 1_000_000.0)
232
+
233
+ # Please don't hate me. Divide by 1,000,000 only if finish is not nil
234
+ end_times << (finish&./(1_000_000.0))
235
+ phases << phase
236
+ categories << (name =~ /GC/ ? gc_category.idx : 0)
237
+ end
238
+
239
+ {
240
+ data: [nil] * start_times.size,
241
+ name: string_indexes,
242
+ startTime: start_times,
243
+ endTime: end_times,
244
+ phase: phases,
245
+ category: categories,
246
+ length: start_times.size
247
+ }
248
+ end
249
+
250
+ def samples_table
251
+ samples = @samples
252
+ weights = @weights
253
+ categories = @sample_categories
254
+ size = samples.size
255
+ if categories.empty?
256
+ categories = [0] * size
257
+ end
258
+
259
+ if @timestamps
260
+ times = @timestamps.map { _1 / 1_000_000.0 }
261
+ else
262
+ # FIXME: record timestamps for memory samples
263
+ times = (0...size).to_a
264
+ end
265
+
266
+ raise unless samples.size == size
267
+ raise unless weights.size == size
268
+ raise unless times.size == size
269
+
270
+ samples = samples.zip(categories).map do |sample, category|
271
+ if category == 0
272
+ sample
273
+ else
274
+ @categorized_stacks[[sample, category]]
275
+ end
276
+ end
277
+
278
+ {
279
+ stack: samples,
280
+ time: times,
281
+ weight: weights,
282
+ weightType: "samples",
283
+ #weightType: "bytes",
284
+ length: samples.length
285
+ }
286
+ end
287
+
288
+ def stack_table
289
+ frames = profile.stack_table.fetch(:frame).dup
290
+ prefixes = profile.stack_table.fetch(:parent).dup
291
+ categories = frames.map{|idx| @frame_categories[idx].idx }
292
+
293
+ @categorized_stacks.keys.each do |(stack, category)|
294
+ frames << frames[stack]
295
+ prefixes << prefixes[stack]
296
+ categories << category
297
+ end
298
+
299
+ size = frames.length
300
+ raise unless frames.size == size
301
+ raise unless prefixes.size == size
302
+ {
303
+ frame: frames,
304
+ category: categories,
305
+ subcategory: [0] * size,
306
+ prefix: prefixes,
307
+ length: prefixes.length
308
+ }
309
+ end
310
+
311
+ def frame_table
312
+ funcs = profile.frame_table.fetch(:func)
313
+ lines = profile.frame_table.fetch(:line)
314
+ size = funcs.length
315
+ none = [nil] * size
316
+ categories = @frame_categories.map(&:idx)
317
+
318
+ raise unless lines.size == funcs.size
319
+
320
+ {
321
+ address: [-1] * size,
322
+ inlineDepth: [0] * size,
323
+ category: categories,
324
+ subcategory: nil,
325
+ func: funcs,
326
+ nativeSymbol: none,
327
+ innerWindowID: none,
328
+ implementation: none,
329
+ line: lines,
330
+ column: none,
331
+ length: size
332
+ }
333
+ end
334
+
335
+ def func_table
336
+ size = @func_names.size
337
+
338
+ cfunc_idx = @strings["<cfunc>"]
339
+ is_js = @filenames.map { |fn| fn != cfunc_idx }
340
+ {
341
+ name: @func_names,
342
+ isJS: is_js,
343
+ relevantForJS: is_js,
344
+ resource: [-1] * size, # set to unidentified for now
345
+ fileName: @filenames,
346
+ lineNumber: profile.func_table.fetch(:first_line),
347
+ columnNumber: [0] * size,
348
+ #columnNumber: functions.map { _1.column },
349
+ length: size
350
+ }
351
+ end
352
+
353
+ def string_table
354
+ @strings.keys
355
+ end
356
+
357
+ private
358
+
359
+ def gc_category
360
+ @categorizer.gc_category
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vernier
4
+ module Output
5
+ class Top
6
+ def initialize(profile)
7
+ @profile = profile
8
+ end
9
+
10
+ def output
11
+ stack_weights = Hash.new(0)
12
+ @profile.samples.zip(@profile.weights) do |stack_idx, weight|
13
+ stack_weights[stack_idx] += weight
14
+ end
15
+
16
+ top_by_self = Hash.new(0)
17
+ stack_weights.each do |stack_idx, weight|
18
+ stack = @profile.stack(stack_idx)
19
+ top_by_self[stack.leaf_frame.name] += weight
20
+ end
21
+
22
+ s = +""
23
+ top_by_self.sort_by(&:last).reverse.each do |frame, samples|
24
+ s << "#{samples}\t#{frame}\n"
25
+ end
26
+ s
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vernier
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/vernier.rb CHANGED
@@ -1,19 +1,181 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "vernier/version"
4
+ require_relative "vernier/collector"
4
5
  require_relative "vernier/vernier"
6
+ require_relative "vernier/output/firefox"
7
+ require_relative "vernier/output/top"
5
8
 
6
9
  module Vernier
7
10
  class Error < StandardError; end
8
11
 
12
+ class Result
13
+ attr_reader :weights, :samples, :stack_table, :frame_table, :func_table
14
+ attr_reader :timestamps, :sample_threads, :sample_categories
15
+ attr_reader :markers
16
+
17
+ attr_accessor :pid, :start_time, :end_time
18
+ attr_accessor :threads
19
+ attr_accessor :meta
20
+
21
+ def started_at
22
+ meta[:started_at]
23
+ end
24
+
25
+ def to_gecko
26
+ Output::Firefox.new(self).output
27
+ end
28
+
29
+ def write(out:)
30
+ File.write(out, to_gecko)
31
+ end
32
+
33
+ def each_sample
34
+ return enum_for(__method__) unless block_given?
35
+ @samples.size.times do |sample_idx|
36
+ weight = @weights[sample_idx]
37
+ stack_idx = @samples[sample_idx]
38
+ yield stack(stack_idx), weight
39
+ end
40
+ end
41
+
42
+ class BaseType
43
+ attr_reader :result, :idx
44
+ def initialize(result, idx)
45
+ @result = result
46
+ @idx = idx
47
+ end
48
+
49
+ def to_s
50
+ idx.to_s
51
+ end
52
+
53
+ def inspect
54
+ "#<#{self.class}\n#{to_s}>"
55
+ end
56
+ end
57
+
58
+ class Func < BaseType
59
+ def label
60
+ result.func_table[:name][idx]
61
+ end
62
+ alias name label
63
+
64
+ def filename
65
+ result.func_table[:filename][idx]
66
+ end
67
+
68
+ def to_s
69
+ "#{name} at #{filename}"
70
+ end
71
+ end
72
+
73
+ class Frame < BaseType
74
+ def label; func.label; end
75
+ def filename; func.filename; end
76
+ alias name label
77
+
78
+ def func
79
+ func_idx = result.frame_table[:func][idx]
80
+ Func.new(result, func_idx)
81
+ end
82
+
83
+ def line
84
+ result.frame_table[:line][idx]
85
+ end
86
+
87
+ def to_s
88
+ "#{func}:#{line}"
89
+ end
90
+ end
91
+
92
+ class Stack < BaseType
93
+ def each_frame
94
+ return enum_for(__method__) unless block_given?
95
+
96
+ stack_idx = idx
97
+ while stack_idx
98
+ frame_idx = result.stack_table[:frame][stack_idx]
99
+ yield Frame.new(result, frame_idx)
100
+ stack_idx = result.stack_table[:parent][stack_idx]
101
+ end
102
+ end
103
+
104
+ def leaf_frame_idx
105
+ result.stack_table[:frame][idx]
106
+ end
107
+
108
+ def leaf_frame
109
+ Frame.new(result, leaf_frame_idx)
110
+ end
111
+
112
+ def frames
113
+ each_frame.to_a
114
+ end
115
+
116
+ def to_s
117
+ arr = []
118
+ each_frame do |frame|
119
+ arr << frame.to_s
120
+ end
121
+ arr.join("\n")
122
+ end
123
+ end
124
+
125
+ def stack(idx)
126
+ Stack.new(self, idx)
127
+ end
128
+
129
+ def total_bytes
130
+ @weights.sum
131
+ end
132
+ end
133
+
134
+ def self.trace(mode: :wall, out: nil, interval: nil)
135
+ collector = Vernier::Collector.new(mode, { interval: })
136
+ collector.start
137
+
138
+ result = nil
139
+ begin
140
+ yield
141
+ ensure
142
+ result = collector.stop
143
+ end
144
+
145
+ if out
146
+ File.write(out, Output::Firefox.new(result).output)
147
+ end
148
+ result
149
+ end
150
+
9
151
  def self.trace_retained(out: nil, gc: true)
10
152
  3.times { GC.start } if gc
11
- Vernier.trace_retained_start
12
- yield
13
- 3.times { GC.start } if gc
14
- result = Vernier.trace_retained_stop
15
153
 
16
- File.write(out, result) if out
154
+ start_time = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
155
+
156
+ collector = Vernier::Collector.new(:retained)
157
+ collector.start
158
+
159
+ result = nil
160
+ begin
161
+ yield
162
+ ensure
163
+ result = collector.stop
164
+ end_time = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
165
+ result.pid = Process.pid
166
+ result.start_time = start_time
167
+ result.end_time = end_time
168
+ end
169
+
170
+ if out
171
+ result.write(out:)
172
+ end
17
173
  result
18
174
  end
175
+
176
+ class Collector
177
+ def self.new(mode, options = {})
178
+ _new(mode, options)
179
+ end
180
+ end
19
181
  end
data/vernier.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = spec.summary
13
13
  spec.homepage = "https://github.com/jhawthorn/vernier"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 2.6.0"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = spec.homepage