From 3601f3c058e8309a2acfdc83cd173966de1647a0 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Mon, 22 Jun 2026 10:29:59 +0200 Subject: [PATCH 1/3] feat(tableau): add measure_batch for a chosen set of qubits Add `LossyMeasureAll::measure_batch(&[usize])`, the indices-accepting analogue of the batched Clifford gates. `measure_all` becomes the `0..n_qubits` case; both share a private `measure_one_with_scratch` helper so the all-qubits path keeps its allocation-free loop. Also adds `measure_batch_with_scratch` for samplers, batch-vs-individual tests, and measure_batch cases in the measure-all / measure-scaling benches. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-tableau/benches/measure-all.rs | 10 +++ .../ppvm-tableau/benches/measure-scaling.rs | 12 +++ crates/ppvm-tableau/src/measure_all.rs | 62 ++++++++++---- crates/ppvm-tableau/tests/measure_batch.rs | 84 +++++++++++++++++++ 4 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 crates/ppvm-tableau/tests/measure_batch.rs diff --git a/crates/ppvm-tableau/benches/measure-all.rs b/crates/ppvm-tableau/benches/measure-all.rs index 32dd99833..eadff3533 100644 --- a/crates/ppvm-tableau/benches/measure-all.rs +++ b/crates/ppvm-tableau/benches/measure-all.rs @@ -181,6 +181,16 @@ pub fn benchmark_suite_msd(c: &mut Criterion, name: impl AsRef) { ); }); + // measure_batch over every qubit must match measure_all (no regression). + let all_indices: Vec = (0..tab.n_qubits()).collect(); + group.bench_function("measure-batch-all", |b| { + b.iter_batched_ref( + || tab.clone(), + |tab| tab.measure_batch(&all_indices), + criterion::BatchSize::SmallInput, + ); + }); + group.finish(); } diff --git a/crates/ppvm-tableau/benches/measure-scaling.rs b/crates/ppvm-tableau/benches/measure-scaling.rs index be6f8fdb9..094cc1b36 100644 --- a/crates/ppvm-tableau/benches/measure-scaling.rs +++ b/crates/ppvm-tableau/benches/measure-scaling.rs @@ -5,6 +5,7 @@ use std::time::Duration; use criterion::{Criterion, criterion_group, criterion_main}; use ppvm_pauli_sum::config::fx64hash::Byte8F64; +use ppvm_tableau::measure_all::LossyMeasureAll; use ppvm_tableau::prelude::*; type Tab = GeneralizedTableau, u128>; @@ -25,6 +26,7 @@ fn bench_measure_scaling(c: &mut Criterion) { let n_qubits = 85; let mut group = c.benchmark_group("measure-scaling"); + let all: Vec = (0..n_qubits).collect(); for n_t in [8, 10, 12, 14] { let tab = build_circuit(n_qubits, n_t); group.bench_function(format!("t{n_t}-{n_qubits}q"), |b| { @@ -38,6 +40,16 @@ fn bench_measure_scaling(c: &mut Criterion) { criterion::BatchSize::SmallInput, ); }); + // Batching every qubit should be no slower than the per-qubit loop. + group.bench_function(format!("batch-t{n_t}-{n_qubits}q"), |b| { + b.iter_batched_ref( + || tab.fork(Some(42)), + |t| { + let _ = t.measure_batch(&all); + }, + criterion::BatchSize::SmallInput, + ); + }); } group.finish(); diff --git a/crates/ppvm-tableau/src/measure_all.rs b/crates/ppvm-tableau/src/measure_all.rs index 7abd1a4e5..49ebca10d 100644 --- a/crates/ppvm-tableau/src/measure_all.rs +++ b/crates/ppvm-tableau/src/measure_all.rs @@ -15,6 +15,11 @@ use crate::{data::GeneralizedTableau, sparsevec::SparseVector, tableau_index::Ta pub trait LossyMeasureAll { fn measure_all(&mut self) -> Vec>; + + /// Measure the given qubit `indices` in order, returning one outcome per + /// index (positionally aligned to `indices`). A lost qubit yields `None`. + /// [`measure_all`](LossyMeasureAll::measure_all) is the `0..n_qubits` case. + fn measure_batch(&mut self, indices: &[usize]) -> Vec>; } impl, I>> LossyMeasureAll @@ -49,6 +54,11 @@ where let mut scratch: MeasureScratch = MeasureScratch::new(); self.measure_all_with_scratch(&mut scratch) } + + fn measure_batch(&mut self, indices: &[usize]) -> Vec> { + let mut scratch: MeasureScratch = MeasureScratch::new(); + self.measure_batch_with_scratch(indices, &mut scratch) + } } impl, I>> GeneralizedTableau @@ -85,20 +95,44 @@ where scratch: &mut MeasureScratch, ) -> Vec> { (0..self.n_qubits()) - .map(|idx| { - if self.is_lost[idx] { - return None; - } - let (phase_decomp, stab_anticomm_bits, destab_anticomm_bits) = - self.compute_decomposition(idx, Pauli::Z); - self.measure_with_scratch( - idx, - scratch, - phase_decomp, - stab_anticomm_bits, - destab_anticomm_bits, - ) - }) + .map(|idx| self.measure_one_with_scratch(idx, scratch)) + .collect() + } + + /// Same as [`LossyMeasureAll::measure_batch`], but the caller supplies a + /// `MeasureScratch` reused across the per-index measurements (and, if the + /// caller chooses, across many invocations / shots) — the batch analogue + /// of [`measure_all_with_scratch`](Self::measure_all_with_scratch). + pub fn measure_batch_with_scratch( + &mut self, + indices: &[usize], + scratch: &mut MeasureScratch, + ) -> Vec> { + indices + .iter() + .map(|&idx| self.measure_one_with_scratch(idx, scratch)) .collect() } + + /// Measure a single qubit `idx` in the Z basis, reusing `scratch`. Returns + /// `None` if the qubit is lost. Shared by `measure_all_with_scratch` and + /// `measure_batch_with_scratch` so both paths measure identically. + fn measure_one_with_scratch( + &mut self, + idx: usize, + scratch: &mut MeasureScratch, + ) -> Option { + if self.is_lost[idx] { + return None; + } + let (phase_decomp, stab_anticomm_bits, destab_anticomm_bits) = + self.compute_decomposition(idx, Pauli::Z); + self.measure_with_scratch( + idx, + scratch, + phase_decomp, + stab_anticomm_bits, + destab_anticomm_bits, + ) + } } diff --git a/crates/ppvm-tableau/tests/measure_batch.rs b/crates/ppvm-tableau/tests/measure_batch.rs new file mode 100644 index 000000000..505b7f7ee --- /dev/null +++ b/crates/ppvm-tableau/tests/measure_batch.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration test: `measure_batch` (measure a chosen set of qubit indices) +//! must agree with the established per-qubit `measure` / `measure_all` paths, +//! and `measure_all` is exactly the `0..n_qubits` special case. + +use ppvm_pauli_sum::config::fx64hash::Byte8F64; +use ppvm_tableau::measure_all::LossyMeasureAll; +use ppvm_tableau::prelude::*; + +type Tab = GeneralizedTableau, u128>; + +/// Deterministic, entangled, non-Clifford starting state (no measurement yet). +fn build_state(n: usize, seed: u64) -> Tab { + let mut tab: Tab = GeneralizedTableau::new_with_seed(n, 1e-10, seed); + for q in 0..n { + tab.h(q); + } + for q in 0..n - 1 { + tab.cz(q, q + 1); + } + // A couple of T gates exercise the case-a (Z-not-a-stabilizer) measurement path. + tab.t(0); + tab.t(n / 2); + tab +} + +#[test] +fn measure_batch_all_indices_matches_measure_all() { + let n = 12; + for seed in 0..25 { + let batch = build_state(n, seed).measure_batch(&(0..n).collect::>()); + let all = build_state(n, seed).measure_all(); + assert_eq!(batch, all, "seed={seed}"); + } +} + +#[test] +fn measure_batch_matches_individual_measure_in_order() { + let n = 10; + let order = [3usize, 0, 7, 1, 9, 2, 8, 4, 6, 5]; + for seed in 0..25 { + let batch = build_state(n, seed).measure_batch(&order); + let mut individual_tab = build_state(n, seed); + let individual: Vec> = + order.iter().map(|&q| individual_tab.measure(q)).collect(); + assert_eq!(batch, individual, "seed={seed}"); + } +} + +#[test] +fn measure_batch_subset_matches_individual() { + let n = 10; + let subset = [2usize, 5, 8]; + for seed in 0..25 { + let batch = build_state(n, seed).measure_batch(&subset); + let mut individual_tab = build_state(n, seed); + let individual: Vec> = + subset.iter().map(|&q| individual_tab.measure(q)).collect(); + assert_eq!(batch, individual, "seed={seed}"); + } +} + +#[test] +fn measure_batch_empty_returns_empty() { + let mut tab = build_state(6, 1); + assert_eq!(tab.measure_batch(&[]), Vec::>::new()); +} + +#[test] +fn measure_batch_lost_qubit_is_none() { + let mut tab: Tab = GeneralizedTableau::new_with_seed(4, 1e-10, 7); + tab.h(0); + tab.cz(0, 1); + tab.loss_channel(1, 1.0); // p = 1.0 -> qubit 1 is always lost + let res = tab.measure_batch(&[0, 1, 2, 3]); + assert_eq!(res.len(), 4); + assert_eq!(res[1], None, "lost qubit must measure as None"); + assert!( + res[0].is_some() && res[2].is_some() && res[3].is_some(), + "non-lost qubits must yield a result" + ); +} From 130298658823beb9a63e71a431db9cab74bc9a4f Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Mon, 22 Jun 2026 10:29:59 +0200 Subject: [PATCH 2/3] perf(stim): batch noiseless M/MZ measurement via measure_batch The executor's M/MZ arm now calls `measure_batch` for noiseless readout, reusing the measurement scratch across targets like the batched gate paths. Noisy readout keeps the per-qubit `measure_noisy` loop so the interleaved measure/flip RNG draws are unchanged, and MR is untouched. Adds a grouped-vs-separate measurement characterization test. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-stim/src/executor.rs | 14 ++++++++++++-- crates/ppvm-stim/tests/executor.rs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/ppvm-stim/src/executor.rs b/crates/ppvm-stim/src/executor.rs index eb04bc3eb..675352114 100644 --- a/crates/ppvm-stim/src/executor.rs +++ b/crates/ppvm-stim/src/executor.rs @@ -10,6 +10,7 @@ use num::{Complex, One, ToPrimitive, Zero}; use std::fmt::Debug; use ppvm_pauli_sum::prelude::*; +use ppvm_tableau::measure_all::LossyMeasureAll; use ppvm_tableau::prelude::*; use stim_parser::ast::{GateName, MeasureName, NoiseName}; use stim_parser::extended::{Axis, ExtendedInstruction, ExtendedProgram, RawPassthrough}; @@ -592,8 +593,17 @@ pub fn execute_validated( let noise = args.first().copied().unwrap_or(0.0); match name { MeasureName::M | MeasureName::MZ => { - for &q in targets { - results.push(tab.measure_noisy(q, noise)); + // Noiseless readout: batch the measurement so the shared + // scratch is reused across targets, mirroring the batched + // gate paths above. With readout noise, keep the per-qubit + // loop so each measure/flip pair's RNG draws stay + // interleaved exactly as before. + if noise > 0.0 { + for &q in targets { + results.push(tab.measure_noisy(q, noise)); + } + } else { + results.extend(tab.measure_batch(targets)); } } // MR cannot delegate to `measure_noisy` because the reset must use diff --git a/crates/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index 8a8829c2e..45da89e78 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -14,6 +14,12 @@ fn run(src: &str, n_qubits: usize) -> (Vec>, Tab) { (results, tab) } +fn run_seeded(src: &str, n_qubits: usize, seed: u64) -> Vec> { + let prog = parse_extended(src).expect("parse_extended"); + let mut tab: Tab = GeneralizedTableau::new_with_seed(n_qubits, 1e-10, seed); + execute(&prog, &mut tab).expect("execute") +} + #[test] fn x_then_measure_returns_one() { let (results, _) = run("X 0\nM 0", 1); @@ -97,6 +103,22 @@ fn comments_and_annotations_are_no_ops() { assert_eq!(results, vec![Some(true)]); } +#[test] +fn grouped_measurement_matches_separate_measurements() { + // A single multi-target `M` must produce identical results to the same + // qubits measured by separate single-target `M` instructions, for every + // seed. Grouping only changes internal batching (shared measurement + // scratch); it must never change outcomes or the RNG-draw order. + let prelude = "H 0\nCX 0 1\nH 2\nCX 2 3\nS 1\nCZ 1 2\n"; + let grouped = format!("{prelude}M 0 1 2 3"); + let separate = format!("{prelude}M 0\nM 1\nM 2\nM 3"); + for seed in 0..50 { + let g = run_seeded(&grouped, 4, seed); + let s = run_seeded(&separate, 4, seed); + assert_eq!(g, s, "grouped vs separate mismatch at seed={seed}"); + } +} + #[test] fn measurement_buffer_is_pre_sized() { let prog = parse_extended("X 0\nM 0 1 2 3 4").unwrap(); From fdb149207115e630c3b4dc01831d255df6496f6b Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Mon, 22 Jun 2026 11:06:31 +0200 Subject: [PATCH 3/3] chore(tableau): add measure_batch profiling harness and findings Adds a samply-based profiling setup for measure_batch across three coefficient-count regimes (few ~1, mid ~100, large ~1000): - examples/profile_measure_batch.rs: quick-timing + sustained-loop driver - scripts/profile_measure_batch.sh: records via samply (no sudo on macOS) - scripts/samply_top.py: headless top-functions summary from the saved profile - benches/measure_batch_profiling.md: findings Profiling shows two regimes: a constant ~16us O(n^2) tableau-bound floor (compute_decomposition + update_tableau) dominating low-coefficient states, and the case-a Vec<->HashMap round-trip (~35% at 1024 coefficients) dominating coefficient-heavy ones. No measure_batch code change yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../benches/measure_batch_profiling.md | 70 +++++++++ .../examples/profile_measure_batch.rs | 139 ++++++++++++++++++ scripts/profile_measure_batch.sh | 31 ++++ scripts/samply_top.py | 129 ++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 crates/ppvm-tableau/benches/measure_batch_profiling.md create mode 100644 crates/ppvm-tableau/examples/profile_measure_batch.rs create mode 100755 scripts/profile_measure_batch.sh create mode 100644 scripts/samply_top.py diff --git a/crates/ppvm-tableau/benches/measure_batch_profiling.md b/crates/ppvm-tableau/benches/measure_batch_profiling.md new file mode 100644 index 000000000..6c9d0b655 --- /dev/null +++ b/crates/ppvm-tableau/benches/measure_batch_profiling.md @@ -0,0 +1,70 @@ + + +# `measure_batch` profiling: where the time goes (2026-06-22) + +Question: beyond reusing a common `MeasureScratch`, is there an algorithmic win +for `measure_batch`? This note records the profiling that answers it. **No +optimization has been applied yet** — these are findings to act on later. + +## Harness + +- `examples/profile_measure_batch.rs` — builds a full-width (85-qubit) entangled + Clifford state so every measurement takes the case-a (random) path, with `n_t` + T-gates controlling the coefficient count. Three workloads bracket the regimes: + `few` (~1 coefficient), `mid` (~128), `large` (~1024). `quick` mode prints + timings + the achieved coefficient count; `flame` mode runs a sustained loop. +- `scripts/profile_measure_batch.sh` — records all three with **samply** (no + sudo on macOS, unlike dtrace/flamegraph) into `target/profiles/`, then prints + a top-functions summary. +- `scripts/samply_top.py` — turns a samply `profile.json.gz` (+ presymbolicated + `.syms.json` sidecar) into a top-functions-by-self/inclusive-time table, + headless (no browser UI needed). + +Reproduce: `./scripts/profile_measure_batch.sh` + +## Results (self time; per-shot from `quick` mode) + +Per-shot `measure_batch` cost: few 17.7 µs, mid 21 µs, large 48 µs (85 qubits). + +| function (self) | few (1) | mid (128) | large (1024) | +|------------------------------------------|---------|-----------|--------------| +| `compute_decomposition` (tableau scan) | 39% | 33% | 14% | +| `measure_one_with_scratch` *(see note)* | 56% | 54% | 49% | +| `insert` (build case-a HashMap) | ~1% | 7% | 23% | +| `retain` (drain Vec↔HashMap) | ~1% | 3% | 9% | +| memset / memmove | 1.4% | 1.3% | 4% | + +*Note: `measure_with_scratch`, `project_case_a`, and +`update_tableau_according_to_outcome` are inlined into `measure_one_with_scratch`.* + +## Conclusions + +The cost is two distinct regimes: + +1. **Tableau-bound floor (dominates few/mid).** `compute_decomposition` costs a + *constant* ~6.8 µs regardless of coefficient count (39% at 1 coeff → 14% at + 1024, same absolute µs). With the inlined `update_tableau_according_to_outcome`, + the tableau work is a fixed **~16 µs O(n²)-per-batch floor** (each of the n + measured qubits scans all 2n stabilizer/destabilizer rows). This is ~95% of + the cost on Clifford-heavy / low-coefficient states. + +2. **Coefficient-bound `Vec`↔`HashMap` round-trip (dominates large).** Each + case-a measurement drains `self.coefficients` (`Vec`) into the scratch + HashMap, works, then drains back; the next qubit rebuilds it. At 1024 + coefficients `insert` + `retain` + their memset/memmove churn is **~35% + (~17 µs)** of the cost, and ~0% at low coefficient counts. The shared scratch + keeps the *allocation*, but not the *representation*, across qubits. + +## Recommended next steps (not yet implemented) + +1. **Keep case-a coefficients HashMap-resident across the batch** — convert + `Vec`→`HashMap` once and back once *per batch* instead of per qubit. + Estimated ~30–35% on coefficient-heavy states; well-scoped refactor of + `measure_with_scratch` / `project_case_a` that leaves the RNG draw order + unchanged (so the existing characterization tests still apply). +2. **Batch the O(n²) tableau floor** — a blocked/incremental measurement that + avoids rescanning all 2n rows per qubit. Larger, riskier algorithmic change; + payoff concentrated on cheap (low-coefficient) states. Treat separately. diff --git a/crates/ppvm-tableau/examples/profile_measure_batch.rs b/crates/ppvm-tableau/examples/profile_measure_batch.rs new file mode 100644 index 000000000..267a8338f --- /dev/null +++ b/crates/ppvm-tableau/examples/profile_measure_batch.rs @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Profiling driver for `measure_batch` across coefficient-count regimes. +//! +//! `measure_batch` has two cost components per measured qubit: +//! - tableau work — `compute_decomposition` (scans all stabilizers + +//! destabilizers) and, on case-a, `update_tableau_according_to_outcome`. +//! This is O(n_qubits) per qubit and *independent of the coefficient count*. +//! - coefficient work — the case-a HashMap build / overlap / merge / normalize +//! passes, which scale with the number of stored coefficients. +//! +//! The three workloads bracket the crossover between the two regimes. Every +//! state is a full-width entangled Clifford state (so measurements take the +//! case-a path), with `n_t` T-gates controlling the coefficient count: +//! - `few` — ~1 coefficient (tableau-bound) +//! - `mid` — ~100 coefficients +//! - `large` — ~1000 coefficients (coefficient-bound) +//! +//! Usage: +//! # quick timing + achieved coefficient count (use this to calibrate n_t) +//! cargo run -p ppvm-tableau --example profile_measure_batch --release -- mid +//! +//! # sustained hot loop (~FLAME_SECS seconds, default 6) for a sampling +//! # profiler. scripts/profile_measure_batch.sh drives this with samply +//! # (no sudo on macOS) and summarizes via scripts/samply_top.py: +//! samply record --save-only --unstable-presymbolicate -o p.json.gz -- \ +//! target/release/examples/profile_measure_batch mid flame + +use ppvm_pauli_sum::config::fx64hash::Byte8F64; +use ppvm_tableau::measure_all::LossyMeasureAll; +use ppvm_tableau::prelude::*; +use std::time::{Duration, Instant}; + +type Tab = GeneralizedTableau, u128>; + +const N_QUBITS: usize = 85; + +/// Number of T-gates per workload, tuned so the post-circuit coefficient count +/// lands near the target. Recalibrate with the quick-timing mode if the state +/// builder changes. +fn n_t_for(workload: &str) -> usize { + match workload { + "few" => 0, + "mid" => 7, + "large" => 10, + other => panic!("unknown workload {other:?}; use few|mid|large"), + } +} + +/// Full-width entangled state: H on every qubit (Clifford superposition, so +/// measurements hit the case-a path), `n_t` T-gates to branch the coefficient +/// vector, then a CZ chain to entangle. +fn build_state(n_t: usize) -> Tab { + let mut tab: Tab = GeneralizedTableau::new(N_QUBITS, 1e-10); + for q in 0..N_QUBITS { + tab.h(q); + } + for q in 0..n_t { + tab.t(q); + } + for q in 0..N_QUBITS - 1 { + tab.cz(q, q + 1); + } + tab +} + +fn median(mut xs: Vec) -> Duration { + xs.sort(); + xs[xs.len() / 2] +} + +fn main() { + let mut args = std::env::args().skip(1); + let workload = args.next().unwrap_or_else(|| "mid".to_string()); + let mode = args.next().unwrap_or_else(|| "quick".to_string()); + + let n_t = n_t_for(&workload); + let base = build_state(n_t); + let n = base.n_qubits(); + let n_coeffs = base.coefficients.len(); + let all: Vec = (0..n).collect(); + + // Setup info goes to stderr so it never pollutes a flamegraph's sampled window. + eprintln!("workload={workload} n_qubits={n} n_t={n_t} coefficients={n_coeffs} mode={mode}"); + + match mode.as_str() { + "quick" => { + let n_runs = 200; + let mut shot = Vec::with_capacity(n_runs); + for _ in 0..n_runs { + let start = Instant::now(); + let mut t = base.fork(Some(42)); + std::hint::black_box(t.measure_batch(&all)); + shot.push(start.elapsed()); + } + let mut forks = Vec::with_capacity(n_runs); + for _ in 0..n_runs { + let start = Instant::now(); + let t = base.fork(Some(42)); + forks.push(start.elapsed()); + std::hint::black_box(t); + } + let shot_m = median(shot); + let fork_m = median(forks); + eprintln!("per-shot (fork + measure_batch) median over {n_runs}: {shot_m:?}"); + eprintln!(" fork only: {fork_m:?}"); + eprintln!(" measure_batch: {:?}", shot_m.saturating_sub(fork_m)); + eprintln!( + " per qubit: {:?}", + shot_m.saturating_sub(fork_m) / n as u32 + ); + } + "flame" => { + // Size the hot loop to ~10s of sampling regardless of workload cost. + let mut probe = Vec::with_capacity(50); + for _ in 0..50 { + let start = Instant::now(); + let mut t = base.fork(Some(42)); + std::hint::black_box(t.measure_batch(&all)); + probe.push(start.elapsed()); + } + let per = median(probe).max(Duration::from_nanos(1)); + let secs: u64 = std::env::var("FLAME_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(6); + let iters = (Duration::from_secs(secs).as_nanos() / per.as_nanos()).max(2_000) as u64; + eprintln!("flame: ~{per:?}/shot -> {iters} iterations (~{secs}s)"); + // fork feeds measure_batch and the result is black-boxed, so neither + // call can be hoisted out of the loop. + for _ in 0..iters { + let mut t = std::hint::black_box(base.fork(Some(42))); + std::hint::black_box(t.measure_batch(&all)); + } + } + other => panic!("unknown mode {other:?}; use quick|flame"), + } +} diff --git a/scripts/profile_measure_batch.sh b/scripts/profile_measure_batch.sh new file mode 100755 index 000000000..c58b8e4a0 --- /dev/null +++ b/scripts/profile_measure_batch.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Profile measure_batch across three coefficient-count regimes (few ~1, mid ~100, +# large ~1000 coefficients) with samply, which needs NO sudo on macOS. Saves a +# Firefox-Profiler profile per regime under target/profiles/ and prints a +# top-functions-by-self-time summary via scripts/samply_top.py. +# +# Open a saved profile interactively with: samply load target/profiles/.json.gz +# +# Run from the repo root: +# ./scripts/profile_measure_batch.sh +set -euo pipefail + +export PATH="$HOME/.cargo/bin:$PATH" +OUT_DIR="${OUT_DIR:-target/profiles}" +SECS="${FLAME_SECS:-6}" +mkdir -p "$OUT_DIR" + +cargo build --release -p ppvm-tableau --example profile_measure_batch +BIN="target/release/examples/profile_measure_batch" + +for w in few mid large; do + echo "=== samply: measure_batch / $w ===" + FLAME_SECS="$SECS" samply record --save-only --unstable-presymbolicate \ + -o "$OUT_DIR/measure_batch_${w}.json.gz" -- "$BIN" "$w" flame +done + +echo +for w in few mid large; do + python3 scripts/samply_top.py "$OUT_DIR/measure_batch_${w}.json.gz" 20 + echo +done diff --git a/scripts/samply_top.py b/scripts/samply_top.py new file mode 100644 index 000000000..1d94e8aff --- /dev/null +++ b/scripts/samply_top.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Summarize a samply profile as the top functions by self and inclusive sample +time — a headless substitute for the flame-graph UI. + +Record with presymbolication so symbols are available without the UI: + samply record --save-only --unstable-presymbolicate -o profile.json.gz -- + +Then: + scripts/samply_top.py profile.json.gz [top_n] + +If a `.syms.json` sidecar sits next to the profile, frames are +symbolicated from it (otherwise raw addresses are shown). +""" +import bisect +import gzip +import json +import os +import sys +from collections import Counter + + +def load(path): + op = gzip.open if path.endswith(".gz") else open + with op(path, "rt") as f: + return json.load(f) + + +def sidecar_path(profile_path): + base = profile_path[:-3] if profile_path.endswith(".gz") else profile_path + cand = base + ".syms.json" + return cand if os.path.exists(cand) else None + + +def build_symbolizer(sidecar): + """Return addr -> name. Primary: per-lib known_addresses (exact sampled + addresses samply already resolved). Fallback: symbol_table rva ranges.""" + st = sidecar["string_table"] + exact = {} + ranges = [] # (start, end, name) + for lib in sidecar["data"]: + symtab = lib["symbol_table"] + for e in symtab: + ranges.append((e["rva"], e["rva"] + e["size"], st[e["symbol"]])) + for addr, idx in lib.get("known_addresses", []): + exact[addr] = st[symtab[idx]["symbol"]] + ranges.sort() + starts = [r[0] for r in ranges] + + def resolve(addr): + if addr in exact: + return exact[addr] + i = bisect.bisect_right(starts, addr) - 1 + if i >= 0 and addr < ranges[i][1]: + return ranges[i][2] + return f"0x{addr:x}" + + return resolve + + +def main(): + if len(sys.argv) < 2: + sys.exit("usage: samply_top.py [top_n]") + path = sys.argv[1] + top_n = int(sys.argv[2]) if len(sys.argv) > 2 else 25 + prof = load(path) + + sc = sidecar_path(path) + resolve = build_symbolizer(load(sc)) if sc else (lambda a: f"0x{a:x}") + + self_c, incl_c = Counter(), Counter() + total = 0.0 + for t in prof["threads"]: + s = t["samples"] + prefix = t["stackTable"]["prefix"] + st_frame = t["stackTable"]["frame"] + fr_addr = t["frameTable"]["address"] + stacks = s["stack"] + weights = s.get("weight") or [1] * s["length"] + + def name_of(stack_idx): + return resolve(fr_addr[st_frame[stack_idx]]) + + for i in range(s["length"]): + node = stacks[i] + if node is None: + continue + w = weights[i] or 1 + total += w + self_c[name_of(node)] += w + seen = set() + while node is not None: + nm = name_of(node) + if nm not in seen: # count each function once per sample + seen.add(nm) + incl_c[nm] += w + node = prefix[node] + + if total == 0: + sys.exit("no samples in profile") + + def short(nm): + # Drop balanced <...> generic args, then keep the final path segment + # (the function/method name) so distinct functions stay distinct. + if nm.startswith("0x"): + return nm + out, depth = [], 0 + for ch in nm: + if ch == "<": + depth += 1 + elif ch == ">": + depth = max(0, depth - 1) + elif depth == 0: + out.append(ch) + segs = [s for s in "".join(out).split("::") if s and s != " as "] + return segs[-1] if segs else nm + + def dump(title, counter): + print(f"\n=== top {top_n} by {title} ===") + for nm, c in counter.most_common(top_n): + print(f"{100 * c / total:6.2f}% {c:8.0f} {short(nm)}") + + print(f"{os.path.basename(path)}: {total:.0f} weighted samples" + f"{' (symbolicated)' if sc else ' (RAW addrs — no sidecar)'}") + dump("SELF time", self_c) + dump("INCLUSIVE time", incl_c) + + +if __name__ == "__main__": + main()