vernier 0.1.1 → 0.2.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,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.1"
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