Class: Canopus::PerformanceRecorder

Inherits:
Object
  • Object
show all
Defined in:
lib/canopus/performance_recorder.rb,
sig/performance_recorder.rbs

Overview

Installed only for an explicitly profiled window, never on the Window class.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(window, trace_allocations: false, sampling: true, capacity: 600, sample_capacity: 2_000, interval: 0.01) ⇒ PerformanceRecorder

Returns a new instance of PerformanceRecorder.

Raises:

  • (ArgumentError)


12
13
14
15
16
17
18
19
20
21
22
# File 'lib/canopus/performance_recorder.rb', line 12

def initialize(window, trace_allocations: false, sampling: true, capacity: 600, sample_capacity: 2_000, interval: 0.01)
  raise ArgumentError, "profile capacities must be positive integers" unless [capacity, sample_capacity].all? { |n| n.is_a?(Integer) && n.positive? }
  raise ArgumentError, "sampling interval must be positive and finite" unless interval.positive? && interval.finite?
  @window, @trace_allocations = window, trace_allocations
  @sampling = sampling
  @capacity, @sample_capacity, @interval = capacity, sample_capacity, interval
  @frames, @stacks = [], []
  @count = @sample_count = @total_allocations = 0
  @max_frame_ms = 0.0
  @mutex, @wake = Mutex.new, ConditionVariable.new
end

Instance Attribute Details

#sampler ⇒ Thread? (readonly)

Returns the value of attribute sampler.

Returns:

  • (Thread, nil)


10
11
12
# File 'lib/canopus/performance_recorder.rb', line 10

def sampler
  @sampler
end

Class Method Details

.context(window = nil) ⇒ Hash[Symbol, untyped]

Parameters:

  • window (Zaniah::Platform::Headless::Window, nil) (defaults to: nil)

Returns:

  • (Hash[Symbol, untyped])


150
151
152
153
154
155
156
157
158
# File 'lib/canopus/performance_recorder.rb', line 150

def self.context(window = nil)
  {ruby: RUBY_DESCRIPTION, engine: RUBY_ENGINE, ruby_version: RUBY_VERSION, platform: RUBY_PLATFORM,
   canopus_version: Canopus::VERSION, zaniah_version: Zaniah::VERSION,
   yjit: !!(defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?),
   zjit: !!(defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled?),
   window: window&.class&.name, renderer: window&.device&.class&.name,
   scale_factor: window&.scale_factor,
   logical_size: window && {width: window.content_size.width, height: window.content_size.height}}
end

.validate_paths(paths, protected: []) ⇒ Array[String]

Parameters:

  • paths (Array[String?])
  • protected: (Array[String?]) (defaults to: [])

Returns:

  • (Array[String])

Raises:

  • (ArgumentError)


160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/canopus/performance_recorder.rb', line 160

def self.validate_paths(paths, protected: [])
  reports = paths.compact.map { |path| File.expand_path(path) }
  raise ArgumentError, "diagnostic report paths must be distinct" unless reports.uniq.length == reports.length
  protected = protected.compact.map { |path| File.expand_path(path) }
  reports.each do |path|
    raise ArgumentError, "report directory does not exist: #{File.dirname(path)}" unless File.directory?(File.dirname(path))
    raise ArgumentError, "report path is a directory: #{path}" if File.directory?(path)
    raise ArgumentError, "report path must not be a symlink: #{path}" if File.symlink?(path)
    if protected.any? { |input| input == path || (File.exist?(path) && File.exist?(input) && File.identical?(path, input)) }
      raise ArgumentError, "report path overlaps an input or output file: #{path}"
    end
  end
  reports
end

.write_crash(path, exception, window: nil) ⇒ String

Parameters:

  • path (String)
  • exception (Exception)
  • window: (Zaniah::Platform::Headless::Window, nil) (defaults to: nil)

Returns:

  • (String)


190
191
192
193
194
# File 'lib/canopus/performance_recorder.rb', line 190

def self.write_crash(path, exception, window: nil)
  write_json(path, {schema_version: 1, kind: "crash", generated_at: Time.now.utc.iso8601,
    context: context(window), exception: exception_details(exception),
    privacy: "Local only. Message and backtrace can contain sensitive paths or text; review before sharing. No environment, buffer contents or object dumps are collected."})
end

.write_json(path, report) ⇒ String

Parameters:

  • path (String)
  • report (Object)

Returns:

  • (String)


175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/canopus/performance_recorder.rb', line 175

def self.write_json(path, report)
  target = validate_paths([path]).first
  json = JSON.pretty_generate(report)
  Tempfile.create([".canopus-report-", ".json"], File.dirname(target), mode: File::RDWR, perm: 0o600) do |file|
    file.chmod(0o600)
    file.write(json)
    file.write("\n")
    file.flush
    file.fsync
    file.close
    File.rename(file.path, target)
  end
  target
end

Instance Method Details

#measure ⇒ void

This method returns an undefined value.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/canopus/performance_recorder.rb', line 50

def measure
  return yield unless @running
  allocated = GC.stat(:total_allocated_objects)
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  completed = false
  begin
    result = yield
    completed = true
    result
  ensure
    elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000
    allocations = GC.stat(:total_allocated_objects) - allocated
    draws = @window.device.draw_calls if completed && @window.device.respond_to?(:draw_calls)
    @last = {frame: @count + 1, frame_ms: elapsed, allocations: allocations, draw_calls: draws,
      scale_factor: @window.scale_factor, completed: completed}
    @frames[@count % @capacity] = @last
    @count += 1
    @total_allocations += allocations
    @max_frame_ms = [@max_frame_ms, elapsed].max
  end
end

#report ⇒ Hash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])

Raises:

  • (ArgumentError)


95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/canopus/performance_recorder.rb', line 95

def report
  raise ArgumentError, "stop the profile before saving it" if @running
  {schema_version: 1, kind: "profile", generated_at: Time.now.utc.iso8601,
   context: self.class.context(@window), elapsed_seconds: @started_at ? @stopped_at - @started_at : 0,
   measurement: {
     frame: "Window#render: UI construction, layout, prepaint, paint, GPU submission and synchronous presentation; excludes input/tick/draw callbacks and asynchronous GPU completion",
     allocations: "Process-wide GC total_allocated_objects delta during render, including other threads and sampling overhead; excludes frame-record bookkeeping",
     percentiles: "Retained frame window only; max and mean allocations cover all observed frames",
     timing: "Wall clock, including GC, scheduler waits and synchronous native calls; not GPU timestamps or idle CPU",
     trace_allocations: @trace_allocations
   }, statistics: statistics, frames: @frames.sort_by { |frame| frame[:frame] },
   sampling: {enabled: @sampling, interval_seconds: @interval, max_depth: 32, total_samples: @sample_count, retained_samples: @stacks.length,
     stacks: @stacks.tally.map { |stack, count| {count: count, stack: stack} }.sort_by { |entry| -entry[:count] }},
   allocation_sites: @allocation_sites}
end

#start ⇒ self

Returns:

  • (self)


24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/canopus/performance_recorder.rb', line 24

def start
  raise ArgumentError, "a profile can only be started once" if @started
  @started = true
  if @trace_allocations
    require "objspace"
    ObjectSpace.trace_object_allocations_start
    @tracing = true
  end
  @running = true
  @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  profile = self
  @window.singleton_class.prepend(Module.new do
    define_method(:render) do |*args, **kwargs, &block|
      profile.measure { super(*args, **kwargs, &block) }
    end
  end)
  if @sampling
    @sampler = Thread.new { sample_main_thread }
    @sampler.name = "canopus-profile"
  end
  self
rescue Exception
  stop
  raise
end

#statistics ⇒ statistics

Returns:



87
88
89
90
91
92
93
# File 'lib/canopus/performance_recorder.rb', line 87

def statistics
  times = @frames.map { |frame| frame[:frame_ms] }.sort
  {frames: @count, retained_frames: @frames.length,
   last_frame_ms: @last&.fetch(:frame_ms), last_allocations: @last&.fetch(:allocations), last_draw_calls: @last&.fetch(:draw_calls),
   median_frame_ms: percentile(times, 0.5), p95_frame_ms: percentile(times, 0.95), max_frame_ms: @max_frame_ms,
   mean_allocations: @count.zero? ? 0 : @total_allocations.fdiv(@count), sampling_samples: @sample_count}
end

#stop ⇒ self

Returns:

  • (self)


72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/canopus/performance_recorder.rb', line 72

def stop
  @mutex.synchronize { @running = false; @wake.broadcast }
  begin
    @sampler&.join
  ensure
    @stopped_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
    if @tracing
      ObjectSpace.trace_object_allocations_stop
      @tracing = false
      @allocation_sites = allocation_sites
    end
  end
  self
end

#write(path) ⇒ String

Parameters:

  • path (String)

Returns:

  • (String)


111
# File 'lib/canopus/performance_recorder.rb', line 111

def write(path) = self.class.write_json(path, report)