Frame time alone cannot explain a Monte Carlo renderer. One method may trace more rays per sample but need fewer samples; another may generate cheap candidates and one expensive visibility ray. This commit records nested timing and statistical work through the same named-buffer system as rendered data.

Quantities recorded by the renderer

Quantity Question
Pass/phase time Where is time spent?
Samples How many estimator draws were accumulated?
Rays/candidates How much physical or proposal work produced each draw?
Error/variance How good is the current estimate?

A renderer comparison should connect them by reporting the time required to reach a specified error threshold. Sample count alone measures estimator work, not execution cost. Frame time alone measures cost without image quality.

CUDA VolPT records the testing hardware separately from its performance results. The same separation is used here: hardware and build configuration define the measurement context; timings, work counts, and image error are the observations.

HdRestir records RMSE against an independent reference and reports sample variance separately; this distinction is retained in the implementation and the convergence plots below.

Nested timing in parallel code

Summing a timer from every parallel worker measures aggregate CPU time, which can exceed wall time. The metrics system collects worker-local values and rescales or reports them within the parent wall-clock scope. Names and parent indices form a hierarchy:

IntegrationPass
├── BSDF sample
└── Direct light
    ├── NEE candidates
    ├── BSDF candidates
    └── resampling

Metrics are compiled behind a flag so measurement overhead is explicit.

Wall time and aggregate worker time have different interpretations:

Measurement Includes Can exceed frame wall time?
Parent wall timer elapsed duration including waits and scheduling no
Sum of worker-local timers CPU time attributed across all workers yes
GPU timestamp device interval between timestamp commands independent CPU timeline
End-to-end frame time host work, device work, synchronization, and handoff no

This commit implements CPU-side hierarchical attribution. A later GPU profiler would need device timestamps rather than assuming command submission time is kernel execution time.

Read how the timing tree is built

Commit contents Files discussed in this chapter metrics_on_buffers.cpp · metrics_on_buffers.h · integration_pass.cpp
Complete commit diff ↗

Metric storage

buffers.Add(kMetricTimingOutputName, sizeof(float), idx + 1);
buffers.Get<float>(kMetricTimingOutputName)[idx] = 0.0f;

buffers.Add(kMetricNameOutputName, sizeof(MetricNameEntry), idx + 1);
std::snprintf(
    buffers.Get<MetricNameEntry>(kMetricNameOutputName)[idx].Name,
    sizeof(MetricNameEntry::Name),
    "%s",
    std::string(name).c_str());

buffers.Add(kMetricParentOutputName, sizeof(std::size_t), idx + 1);
buffers.Get<std::size_t>(kMetricParentOutputName)[idx] = parent;

Index idx ties duration, display name, and parent together. A flat collection of buffers can therefore encode the tree shown above without pass-specific profiling objects.

Scope-based timers

ScopedMetricTimer::~ScopedMetricTimer()
{
    const float ms{
        std::chrono::duration<float, std::milli>(
            std::chrono::steady_clock::now() - _t0)
            .count()};
    _buffers.Get<float>(kMetricTimingOutputName)[_index] = ms;
}

The destructor runs for normal return and stack unwinding. This RAII pattern prevents an early return from leaving a metric open or requiring a duplicated “stop timer” call.

Concurrent worker accumulation

auto values{provider.GetChecked(name).As<float>()};
std::atomic_ref<float> counter{values[index]};
counter.fetch_add(ms, std::memory_order_relaxed);

Several pixels may finish the same phase concurrently. atomic_ref prevents lost additions without changing the buffer layout. memory_order_relaxed is sufficient because the operation needs atomic numeric accumulation, not a happens-before relationship for other memory.

Wall-time attribution

float cpuSum{0.0f};
for (const auto &[name, ms] : metrics)
{
    cpuSum += ms;
}
const float scale{cpuSum > 0.0f ? wallMs / cpuSum : 0.0f};

// ...

for (const auto &[name, ms] : metrics)
{
    indices.push_back(RecordMetric(buffers, name, ms * scale, parent));
}

If four equally busy workers each report 10 ms, their sum is 40 ms even though the enclosing phase may take about 10 ms of wall time. Scaling preserves the relative subdivision while making the children add up to the measured parent. It is an attribution model, not a claim that the phases ran serially.

The scaling assumes the worker-local categories account for the useful work in the measured parent. Time spent outside them—scheduler overhead, cache misses between scopes, allocation, or synchronization—is distributed in proportion to the recorded categories. The displayed child values therefore answer “how should parent wall time be attributed using these samples?” rather than “how long did this code execute in isolation?”

When metrics are disabled, the macros compile away. This keeps “measurement off” a distinct build whose timing is not silently charged for atomic updates.

Measurement overhead and repeatability

atomic_ref<float>::fetch_add is safe for concurrent accumulation, but many workers updating the same cache line can contend. Timers, string registration, debug overlays, and buffer reads also perturb the program being measured. A performance report should state whether metrics were enabled and should avoid comparing a metrics-enabled pipeline with a metrics-disabled one.

A practical protocol is:

  1. warm shader compilation, scene loading, and one-time allocations;
  2. fix scene, camera, resolution, renderer settings, and seed policy;
  3. run several independent repetitions;
  4. record wall time, work counts, and error at regular checkpoints;
  5. report median and dispersion across repetitions;
  6. retain raw measurements in addition to the plotted image.

Thermal state, background load, CPU frequency policy, and GPU capture tools can change timings. They do not invalidate a measurement, but they belong in its environment description.

Reading convergence evidence

A convergence curve must identify the scene and camera, resolution, renderer revision and settings, random-seed policy, reference image, hardware, build configuration, error metric, and horizontal coordinate. A curve against sample count measures estimator efficiency but omits different per-sample costs. A curve against elapsed time includes those costs and is the relevant comparison when the goal is a better image within a fixed time.

The final revision exposes ordinary path tracing and ReSTIR on both CPU and GPU. Separating estimator choice from execution backend requires a four-way comparison with a common scene, reference, exposure, stopping rule, and error metric.

Convergence curves for CPU and GPU path-tracing and ReSTIR pipelines in a many-lights scene
The four curves separate estimator choice from execution backend in the recorded many-lights configuration. Measurement scope: Commit 4648adc and the associated hardware, settings, seed policy, and stop criterion. Source: full GPU implementation commit 4648adc

Many-light scenes emphasize candidate selection. A scene dominated by one large emitter or by long indirect paths can produce a different ordering, so the figures support only the configurations stated in their scopes. The corresponding four-pipeline reference-error graph for the simpler internal scene is published with its reference image and internal-noise curve in Convergence across scenes; it is linked here rather than displaying the same graph twice.

HdRestir profiling overlay on the many-lights scene
The overlay reports frame index, rendered resolution, frame and total time, mean and maximum estimator variance, and the relative time of each compiled pass. In this capture the denoiser and GPU accumulation dominate the displayed pass percentages. Working tree based on revision dbca10b, GPU ReSTIR, 64 spp, seed 0, debugOverlay=1 and profileOverlay=1. Percentages describe this capture only; enabling the overlay and OIDN changes the work being measured.