Class: Canopus::PerformanceRecorder
- Inherits:
-
Object
- Object
- Canopus::PerformanceRecorder
- 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
-
#sampler ⇒ Thread?
readonly
Returns the value of attribute sampler.
Class Method Summary collapse
- .context(window = nil) ⇒ Hash[Symbol, untyped]
- .validate_paths(paths, protected: []) ⇒ Array[String]
- .write_crash(path, exception, window: nil) ⇒ String
- .write_json(path, report) ⇒ String
Instance Method Summary collapse
-
#initialize(window, trace_allocations: false, sampling: true, capacity: 600, sample_capacity: 2_000, interval: 0.01) ⇒ PerformanceRecorder
constructor
A new instance of PerformanceRecorder.
- #measure ⇒ void
- #report ⇒ Hash[Symbol, untyped]
- #start ⇒ self
- #statistics ⇒ statistics
- #stop ⇒ self
- #write(path) ⇒ String
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.
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.
10 11 12 |
# File 'lib/canopus/performance_recorder.rb', line 10 def sampler @sampler end |
Class Method Details
.context(window = nil) ⇒ 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]
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.(path) } raise ArgumentError, "diagnostic report paths must be distinct" unless reports.uniq.length == reports.length protected = protected.compact.map { |path| File.(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
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
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]
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
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
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
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
111 |
# File 'lib/canopus/performance_recorder.rb', line 111 def write(path) = self.class.write_json(path, report) |