787 lines
30 KiB
Rust
787 lines
30 KiB
Rust
//! M6 perf gates (T M6.6, T M6.7).
|
||
//!
|
||
//! # Contract
|
||
//!
|
||
//! Per `pmacs-tasks.tex` T M6.6 / T M6.7 and `pmacs-spec.tex`
|
||
//! §sec:repl-perf, M6.6 imposes three perf gates against a
|
||
//! fast-spewing producer ingested through the REPL package:
|
||
//!
|
||
//! 1. **Sustained ingest rate ≥ 100 MB/s** over a 10 s window after
|
||
//! warmup. Computed as bytes appended to the rope per second.
|
||
//! 2. **Buffer memory ≤ 200 MB RSS delta** for the duration of the
|
||
//! run, sampled at 100 Hz; a single excursion above is a fail.
|
||
//! 3. **Cancel response latency p99 ≤ 100 ms** over 100 trials,
|
||
//! each cancelling a fresh producer at a random point in the
|
||
//! first 5 seconds.
|
||
//!
|
||
//! M6.7 adds two more gates against a populated 10000-line scrollback
|
||
//! buffer:
|
||
//!
|
||
//! 4. **Navigation latency p99 ≤ 16 ms** for cursor motion across the
|
||
//! scrollback, 1000 trials cycling through `move_down`, `move_up`,
|
||
//! `move_page_down`, `move_page_up`.
|
||
//! 5. **Search latency p99 ≤ 100 ms** for buffer scans across the
|
||
//! scrollback, 1000 trials with needles distributed across early,
|
||
//! mid, and late position bands.
|
||
//!
|
||
//! # Methodology
|
||
//!
|
||
//! Locked here so future "is this regression real or measurement
|
||
//! noise?" debates have a single source of truth.
|
||
//!
|
||
//! - **What the producer is.** The spec names `find /` as the
|
||
//! workload exemplar. The gate's metric is *pmacs's rope-append
|
||
//! rate*, not the producer's read rate. `find /` is impractical
|
||
//! in CI: non-deterministic across runners, requires elevated
|
||
//! permissions for some paths, and easily produces over 200 MB on
|
||
//! developer machines (which would trip the memory ceiling for
|
||
//! reasons unrelated to pmacs's behavior). We use synthetic
|
||
//! producers (`yes "<line>"` for the infinite cases) that exhibit
|
||
//! the same high-rate spew profile and are reproducible across
|
||
//! environments.
|
||
//!
|
||
//! - **Tick cadence during measurement.** Tight loop; no artificial
|
||
//! 60 Hz cap. The gate measures *upper-bound capacity*: what
|
||
//! pmacs is capable of when given the CPU. A future "frame-rate-
|
||
//! capped throughput" gate (M6.7+) could measure user-perceived
|
||
//! throughput at 60 Hz.
|
||
//!
|
||
//! - **What "bytes appended to the rope" means.** Every byte that
|
||
//! reaches `Handle:append_output` and lands in history (i.e.,
|
||
//! passes the ANSI parser as a `Text` event and gets inserted via
|
||
//! `_emit_history`). Bytes consumed by escape sequences (SGR,
|
||
//! alt-screen markers, OSC titles) do not count toward ingest
|
||
//! because they don't grow the rope; this matches the spec's
|
||
//! "appended to the rope" wording exactly.
|
||
//!
|
||
//! - **Warmup vs measurement window.** 1 s warmup absorbs first-tick
|
||
//! costs (`LuaJIT` trace compilation, supervisor reader-thread
|
||
//! startup, kernel pipe buffer fills). The 10 s measurement
|
||
//! window is then the spec-specified gate.
|
||
//!
|
||
//! - **RSS sampling source.** Linux-only via `/proc/self/status`'s
|
||
//! `VmRSS:` field (kilobytes; multiply by 1024 for bytes). We
|
||
//! prefer `status` over `statm` because the latter reports pages
|
||
//! and the page-size lookup needs `sysconf(_SC_PAGESIZE)`, which
|
||
//! requires an `unsafe` block — incompatible with the project's
|
||
//! `forbid(unsafe_code)` posture. macOS would need a different
|
||
//! API; the M5 perf-gates workflow already pins CI to
|
||
//! ubuntu-latest, and we follow that precedent.
|
||
//!
|
||
//! - **Sampler thread cadence.** 100 Hz (10 ms inter-sample sleep).
|
||
//! `std::thread::sleep` is best-effort; expect ~95–100 samples per
|
||
//! second under typical CI load. The gate is "no excursion above
|
||
//! 200 MB", which holds regardless of sampling jitter.
|
||
//!
|
||
//! - **Cancel-latency definition.** From `pmacs.process.signal(id,
|
||
//! "INT")` returning to the moment `_on_exit` has run, observed
|
||
//! via the handle's `_exited` boolean. That flag is set as the
|
||
//! first thing in `_on_exit`, immediately before the exit marker
|
||
//! is written; sampling the flag is O(1) regardless of history
|
||
//! size, whereas grepping the rope for the marker text is O(N)
|
||
//! and would conflate cancel-response time with rope-scan cost
|
||
//! on trials with multi-hundred-MB history (the worst case under
|
||
//! `yes` at the M6.6 ingest rate). Both signals fire in the same
|
||
//! tick, so the choice is purely a measurement-cost decision.
|
||
//!
|
||
//! - **Why we cancel `yes` directly, not a shell.** `pmacs.process.
|
||
//! signal(_proc_id, "INT")` signals the *spawned PID*. For a
|
||
//! shell, that's bash itself, not bash's child (which is what
|
||
//! `find /` would be). Real-terminal SIGINT semantics involve
|
||
//! foreground-pgrp routing through the PTY layer, which M6.5
|
||
//! does not implement. A shell-foreground-pgrp aware C-c is M6.5+
|
||
//! work; for the M6.6 gate, the cleanest measurement is a
|
||
//! single-process target (`yes`), which catches SIGINT and exits
|
||
//! directly.
|
||
//!
|
||
//! - **Percentile computation.** Sort the latency samples; p99 is
|
||
//! `samples[(len * 99) / 100]`, matching M5.9c's exact-integer
|
||
//! formula. For len=100 this is index 99 (the 100th smallest of
|
||
//! 100 — i.e., the max). Conservative: a single bad trial fails
|
||
//! the gate at len=100. M6.7 gates use len=1000 to stabilize p99
|
||
//! (index 990 = 11th-from-max), since 100-sample p99 has too much
|
||
//! variance for a 16 ms threshold to be a regression signal rather
|
||
//! than measurement noise.
|
||
//!
|
||
//! - **Random-delay generator.** Hand-rolled xorshift seeded by
|
||
//! trial index. No `rand` dev-dep; reproducible across runs;
|
||
//! varied enough that we don't always hit the same supervisor
|
||
//! tick boundary. Methodology: random in `[10 ms, 5000 ms]` per
|
||
//! spec ("first 5 seconds").
|
||
//!
|
||
//! - **M6.7 buffer-direct populate.** The 10000-line scrollback is
|
||
//! built via the public buffer API (`pmacs.buffer.create` +
|
||
//! `Buffer:insert`), not through a real REPL with a producer
|
||
//! process. The gate measures cursor-motion and search latency,
|
||
//! which are buffer-level operations; injecting REPL machinery
|
||
//! (PTY scheduling, supervisor ticks, parser overhead) measures
|
||
//! the wrong thing. The user-felt experience is "cursor moved
|
||
//! within one frame," and the buffer-direct path is what dispatch
|
||
//! ultimately runs.
|
||
//!
|
||
//! - **M6.7 navigation cycle.** Each trial calls one of
|
||
//! `move_down`, `move_up`, `move_page_down`, `move_page_up` (round-
|
||
//! robin by trial index). All four are O(log N) over the rope, so
|
||
//! the gate is dominated by view-update / cursor-position math,
|
||
//! not by traversal. We cycle rather than fix on one operation so
|
||
//! p99 reflects the realistic mixed workload an interactive user
|
||
//! produces.
|
||
//!
|
||
//! - **M6.7 search position bands.** Needles are placed at trial-
|
||
//! varying line numbers spanning early (lines 1..3333), mid
|
||
//! (3334..6666), and late (6667..10000) bands. Without this
|
||
//! variation the gate would measure only the early-termination
|
||
//! case (fast) or only the full-scan case (slow), neither of
|
||
//! which reflects realistic isearch usage. Each needle is unique
|
||
//! (`PMACS_M67_PAD_<n>`) so trial N's search doesn't false-hit
|
||
//! on trial N-1's marker.
|
||
//!
|
||
//! # Why `#[ignore]`
|
||
//!
|
||
//! Perf measurement under debug-mode `cargo test` is meaningless.
|
||
//! CI runs these tests release-mode via the `m6-perf-gates` workflow
|
||
//! job (`cargo test --release --test m6_perf_acceptance -- --ignored
|
||
//! --nocapture`). Local dev runs (`cargo test`) skip them.
|
||
|
||
#![cfg(target_os = "linux")]
|
||
|
||
use pmacs::editor::EditorState;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||
use std::time::{Duration, Instant};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared harness
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Spawn the given argv as a REPL via the M6.5 surface. Returns the
|
||
/// proc-id raw integer for caller-side bookkeeping; the handle and
|
||
/// after-tick wiring live entirely Lua-side.
|
||
fn spawn_repl(editor: &mut EditorState, argv: &[&str]) -> i64 {
|
||
use std::fmt::Write as _;
|
||
let mut argv_lua = String::new();
|
||
for (i, a) in argv.iter().enumerate() {
|
||
if i > 0 {
|
||
argv_lua.push_str(", ");
|
||
}
|
||
write!(&mut argv_lua, "\"{a}\"").unwrap();
|
||
}
|
||
let chunk = format!(
|
||
r"
|
||
_G.h = pmacs.repl.spawn {{ argv = {{ {argv_lua} }} }}
|
||
return _G.h._proc_id:raw()
|
||
",
|
||
);
|
||
editor
|
||
.lua_host
|
||
.lua()
|
||
.load(&chunk)
|
||
.eval()
|
||
.expect("spawn_repl chunk runs and returns raw id")
|
||
}
|
||
|
||
/// Read history-region byte length via the handle's `:history_end()`
|
||
/// query. Matches the spec wording exactly: "bytes appended to the
|
||
/// rope per second."
|
||
fn history_bytes(editor: &mut EditorState) -> i64 {
|
||
editor
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.h:history_end()")
|
||
.eval()
|
||
.expect("history_end query")
|
||
}
|
||
|
||
/// Pump the supervisor + after-tick hook until `predicate` returns
|
||
/// true or the deadline elapses. Returns true on predicate-met,
|
||
/// false on timeout.
|
||
fn pump_until(
|
||
editor: &mut EditorState,
|
||
predicate: impl Fn(&mut EditorState) -> bool,
|
||
timeout: Duration,
|
||
) -> bool {
|
||
let deadline = Instant::now() + timeout;
|
||
while Instant::now() < deadline {
|
||
editor.tick_processes();
|
||
if predicate(editor) {
|
||
return true;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Wait for the spawned process to reach the `running` state. Without
|
||
/// this, the test's first ticks measure pre-exec state and skew the
|
||
/// warmup. 5 s budget is generous; the supervisor typically reaches
|
||
/// running in under 100 ms.
|
||
fn wait_until_running(editor: &mut EditorState) {
|
||
let ok = pump_until(
|
||
editor,
|
||
|e| {
|
||
let kind: String = e
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
local s = pmacs.process.status(_G.h._proc_id)
|
||
return (s and s.kind) or "nil"
|
||
"#,
|
||
)
|
||
.eval()
|
||
.expect("status query");
|
||
kind == "running"
|
||
},
|
||
Duration::from_secs(5),
|
||
);
|
||
assert!(ok, "spawned producer never reached running state");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T M6.6 acceptance bullet 1: sustained ingest rate
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 100 MB/s sustained ingest gate. Producer is `yes` repeating a
|
||
/// 100-character line, which produces ~200 MB/s on modern hardware
|
||
/// — fast enough that pmacs is the bottleneck, which is what the
|
||
/// gate measures.
|
||
#[test]
|
||
#[ignore = "perf gate; requires release build"]
|
||
fn m6_6_sustained_ingest_rate_meets_100mbps_gate() {
|
||
const WARMUP: Duration = Duration::from_secs(1);
|
||
const WINDOW: Duration = Duration::from_secs(10);
|
||
const RATE_THRESHOLD_BYTES_PER_SEC: u64 = 100 * 1024 * 1024;
|
||
|
||
let mut editor = EditorState::new();
|
||
// 100 chars + newline per line. The exact value is unimportant;
|
||
// what matters is that `yes` blasts at a higher rate than pmacs's
|
||
// ingest path, so pmacs is the bottleneck under measurement.
|
||
let line = "a".repeat(100);
|
||
let _proc_raw = spawn_repl(&mut editor, &["yes", &line]);
|
||
wait_until_running(&mut editor);
|
||
|
||
// Warmup: drain ticks for 1s without measuring.
|
||
let warmup_deadline = Instant::now() + WARMUP;
|
||
while Instant::now() < warmup_deadline {
|
||
editor.tick_processes();
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
|
||
let bytes_at_window_start = history_bytes(&mut editor);
|
||
let window_start = Instant::now();
|
||
let window_deadline = window_start + WINDOW;
|
||
while Instant::now() < window_deadline {
|
||
editor.tick_processes();
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
let elapsed = window_start.elapsed();
|
||
let bytes_at_window_end = history_bytes(&mut editor);
|
||
|
||
// Tear down the producer so it doesn't keep spewing into the
|
||
// supervisor's bound channel during teardown / next test.
|
||
let _ = editor
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pcall(pmacs.process.signal, _G.h._proc_id, "INT")
|
||
_G.h:close()
|
||
"#,
|
||
)
|
||
.exec();
|
||
|
||
let ingested = (bytes_at_window_end - bytes_at_window_start) as u64;
|
||
let rate = (ingested as f64 / elapsed.as_secs_f64()) as u64;
|
||
|
||
println!("M6.6 sustained ingest rate gate:");
|
||
println!(" window: {elapsed:?}");
|
||
println!(
|
||
" bytes ingested: {} ({:.1} MiB)",
|
||
ingested,
|
||
ingested as f64 / (1024.0 * 1024.0)
|
||
);
|
||
println!(
|
||
" rate: {} B/s ({:.1} MiB/s)",
|
||
rate,
|
||
rate as f64 / (1024.0 * 1024.0)
|
||
);
|
||
println!(
|
||
" threshold: {} B/s ({:.1} MiB/s)",
|
||
RATE_THRESHOLD_BYTES_PER_SEC,
|
||
RATE_THRESHOLD_BYTES_PER_SEC as f64 / (1024.0 * 1024.0)
|
||
);
|
||
|
||
assert!(
|
||
rate >= RATE_THRESHOLD_BYTES_PER_SEC,
|
||
"ingest rate {rate} B/s below {RATE_THRESHOLD_BYTES_PER_SEC} B/s gate"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T M6.6 acceptance bullet 2: buffer memory ceiling
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Read `VmRSS` in bytes from `/proc/self/status`. The kernel reports
|
||
/// `VmRSS:` in kilobytes; we multiply by 1024. Using `/proc/self/status`
|
||
/// (rather than `/proc/self/statm`'s page-count form) avoids a
|
||
/// `sysconf(_SC_PAGESIZE)` call, which would require an `unsafe`
|
||
/// block — incompatible with the project's `forbid(unsafe_code)`
|
||
/// posture.
|
||
fn read_rss_bytes() -> u64 {
|
||
let s = std::fs::read_to_string("/proc/self/status").expect("read /proc/self/status");
|
||
for line in s.lines() {
|
||
if let Some(rest) = line.strip_prefix("VmRSS:") {
|
||
// "VmRSS: 12345 kB" → split_whitespace handles leading
|
||
// whitespace; first token is the kB value.
|
||
let kb: u64 = rest
|
||
.split_whitespace()
|
||
.next()
|
||
.expect("VmRSS value")
|
||
.parse()
|
||
.expect("parse VmRSS kB");
|
||
return kb * 1024;
|
||
}
|
||
}
|
||
panic!("VmRSS not found in /proc/self/status");
|
||
}
|
||
|
||
/// 200 MB RSS-delta ceiling for a bounded ~150 MB run. We bound by
|
||
/// byte count rather than by producer-exit so the producer can be
|
||
/// `yes` (no shell-quoting concerns). When history reaches the
|
||
/// target byte count the test sends SIGINT to terminate the producer
|
||
/// cleanly. The sampler thread polls at 100 Hz; the test fails on
|
||
/// any single excursion above the ceiling.
|
||
#[test]
|
||
#[ignore = "perf gate; requires release build"]
|
||
fn m6_6_buffer_memory_stays_under_200mb_during_run() {
|
||
const TARGET_HISTORY_BYTES: i64 = 150 * 1024 * 1024;
|
||
const RSS_CEILING_BYTES: u64 = 200 * 1024 * 1024;
|
||
const SAMPLE_INTERVAL: Duration = Duration::from_millis(10);
|
||
|
||
let baseline_rss = read_rss_bytes();
|
||
println!(
|
||
"M6.6 memory ceiling gate: baseline RSS {} B ({:.1} MiB)",
|
||
baseline_rss,
|
||
baseline_rss as f64 / (1024.0 * 1024.0)
|
||
);
|
||
|
||
let mut editor = EditorState::new();
|
||
// 100-char line; the line content is unimportant for the ceiling
|
||
// gate. Total target = 150 MB of bytes-into-history.
|
||
let line = "a".repeat(100);
|
||
let _proc_raw = spawn_repl(&mut editor, &["yes", &line]);
|
||
wait_until_running(&mut editor);
|
||
|
||
// Sampler thread. Records the max RSS observed during the run.
|
||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||
let max_rss = Arc::new(AtomicU64::new(baseline_rss));
|
||
{
|
||
let stop_flag = stop_flag.clone();
|
||
let max_rss = max_rss.clone();
|
||
std::thread::spawn(move || {
|
||
while !stop_flag.load(Ordering::Relaxed) {
|
||
let rss = read_rss_bytes();
|
||
let prev = max_rss.load(Ordering::Relaxed);
|
||
if rss > prev {
|
||
max_rss.store(rss, Ordering::Relaxed);
|
||
}
|
||
std::thread::sleep(SAMPLE_INTERVAL);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Pump until history reaches the target size. With the gate of
|
||
// 100 MB/s, 150 MB takes ~1.5 s on a fast machine and well under
|
||
// 60 s budget on any sane runner.
|
||
let reached_target = pump_until(
|
||
&mut editor,
|
||
|e| history_bytes(e) >= TARGET_HISTORY_BYTES,
|
||
Duration::from_secs(60),
|
||
);
|
||
assert!(
|
||
reached_target,
|
||
"history did not reach {TARGET_HISTORY_BYTES} bytes within 60 s budget"
|
||
);
|
||
|
||
// Cancel the producer and drain the exit so the sampler still
|
||
// catches any post-cancel allocator churn before we stop sampling.
|
||
let _ = editor
|
||
.lua_host
|
||
.lua()
|
||
.load(r#"pmacs.process.signal(_G.h._proc_id, "INT")"#)
|
||
.exec();
|
||
let _ = pump_until(
|
||
&mut editor,
|
||
|e| {
|
||
let exited: bool = e
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.h._exited == true")
|
||
.eval()
|
||
.expect("exited query");
|
||
exited
|
||
},
|
||
Duration::from_secs(2),
|
||
);
|
||
|
||
stop_flag.store(true, Ordering::Relaxed);
|
||
|
||
let final_rss = max_rss.load(Ordering::Relaxed);
|
||
let delta = final_rss.saturating_sub(baseline_rss);
|
||
|
||
let history_len = history_bytes(&mut editor);
|
||
println!("M6.6 memory ceiling gate:");
|
||
println!(
|
||
" history bytes: {history_len} ({:.1} MiB)",
|
||
history_len as f64 / (1024.0 * 1024.0)
|
||
);
|
||
println!(
|
||
" max RSS: {} B ({:.1} MiB)",
|
||
final_rss,
|
||
final_rss as f64 / (1024.0 * 1024.0)
|
||
);
|
||
println!(
|
||
" RSS delta: {} B ({:.1} MiB)",
|
||
delta,
|
||
delta as f64 / (1024.0 * 1024.0)
|
||
);
|
||
println!(
|
||
" ceiling: {} B ({:.1} MiB)",
|
||
RSS_CEILING_BYTES,
|
||
RSS_CEILING_BYTES as f64 / (1024.0 * 1024.0)
|
||
);
|
||
|
||
let _ = editor.lua_host.lua().load("_G.h:close()").exec();
|
||
|
||
assert!(
|
||
delta < RSS_CEILING_BYTES,
|
||
"RSS delta {delta} exceeded {RSS_CEILING_BYTES} ceiling"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T M6.6 acceptance bullet 3: cancel response latency
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Hand-rolled xorshift64 — no `rand` dev-dep needed. Seeded by the
|
||
/// trial index for reproducibility. Sufficient variance to spread
|
||
/// trials across supervisor tick boundaries.
|
||
fn xorshift64(state: &mut u64) -> u64 {
|
||
let mut x = *state;
|
||
x ^= x << 13;
|
||
x ^= x >> 7;
|
||
x ^= x << 17;
|
||
*state = x;
|
||
x
|
||
}
|
||
|
||
/// 100-trial cancel-latency gate. Each trial: spawn `yes`, wait a
|
||
/// random delay in [10 ms, 5000 ms] per spec, send SIGINT, measure
|
||
/// the time until the exit marker appears in history. p99 of those
|
||
/// 100 latencies must be ≤ 100 ms.
|
||
#[test]
|
||
#[ignore = "perf gate; requires release build"]
|
||
fn m6_6_cancel_response_p99_under_100ms() {
|
||
const TRIALS: usize = 100;
|
||
const P99_THRESHOLD: Duration = Duration::from_millis(100);
|
||
const MIN_DELAY_MS: u64 = 10;
|
||
const MAX_DELAY_MS: u64 = 5000;
|
||
const PER_CANCEL_TIMEOUT: Duration = Duration::from_secs(2);
|
||
|
||
let mut latencies: Vec<Duration> = Vec::with_capacity(TRIALS);
|
||
let mut prng_state: u64 = 0xa5a5_5a5a_dead_beef;
|
||
|
||
for trial in 0..TRIALS {
|
||
// Vary the seed per trial so consecutive trials don't sample
|
||
// the same delay; xor with trial index keeps it deterministic.
|
||
prng_state ^= trial as u64;
|
||
let r = xorshift64(&mut prng_state);
|
||
let delay_ms = MIN_DELAY_MS + (r % (MAX_DELAY_MS - MIN_DELAY_MS + 1));
|
||
let delay = Duration::from_millis(delay_ms);
|
||
|
||
let mut editor = EditorState::new();
|
||
let _ = spawn_repl(&mut editor, &["yes"]);
|
||
wait_until_running(&mut editor);
|
||
|
||
// Pump the supervisor for the random delay to let `yes` spew
|
||
// into history. Captures the realistic case of cancelling
|
||
// mid-flow rather than the trivial just-spawned case.
|
||
let delay_deadline = Instant::now() + delay;
|
||
while Instant::now() < delay_deadline {
|
||
editor.tick_processes();
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
|
||
// Send SIGINT and start the latency clock.
|
||
let t0 = Instant::now();
|
||
let _ = editor
|
||
.lua_host
|
||
.lua()
|
||
.load(r#"pmacs.process.signal(_G.h._proc_id, "INT")"#)
|
||
.exec();
|
||
|
||
// Pump until `_on_exit` has run (the cheap signal). `_exited`
|
||
// is set as the first thing in `_on_exit`, immediately before
|
||
// the marker is written. Querying a boolean flag is O(1)
|
||
// regardless of history size; querying the marker via
|
||
// `buf:slice(0, history_end):find(...)` is O(N) and dominates
|
||
// the latency for trials with multi-MB history (the worst case
|
||
// — 5s of `yes` at >100 MB/s — produces 500+ MB of history,
|
||
// and string scan over that swamps the cancel-response measurement
|
||
// we are actually trying to gate).
|
||
let saw_exit = pump_until(
|
||
&mut editor,
|
||
|e| {
|
||
e.lua_host
|
||
.lua()
|
||
.load("return _G.h._exited == true")
|
||
.eval::<bool>()
|
||
.expect("exited query")
|
||
},
|
||
PER_CANCEL_TIMEOUT,
|
||
);
|
||
let elapsed = t0.elapsed();
|
||
assert!(
|
||
saw_exit,
|
||
"trial {trial}: cancel did not transition to exited within {PER_CANCEL_TIMEOUT:?}"
|
||
);
|
||
latencies.push(elapsed);
|
||
|
||
let _ = editor.lua_host.lua().load("_G.h:close()").exec();
|
||
}
|
||
|
||
let mut sorted = latencies.clone();
|
||
sorted.sort();
|
||
|
||
let percentile = |p: usize| -> Duration {
|
||
let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1);
|
||
sorted[idx]
|
||
};
|
||
let p50 = percentile(50);
|
||
let p90 = percentile(90);
|
||
let p99 = percentile(99);
|
||
let max = sorted[sorted.len() - 1];
|
||
|
||
println!("M6.6 cancel-response latency gate ({TRIALS} trials):");
|
||
println!(" p50: {p50:?}");
|
||
println!(" p90: {p90:?}");
|
||
println!(" p99: {p99:?}");
|
||
println!(" max: {max:?}");
|
||
println!(" threshold: {P99_THRESHOLD:?}");
|
||
|
||
assert!(
|
||
p99 <= P99_THRESHOLD,
|
||
"cancel p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \
|
||
p50={p50:?}, p90={p90:?}, max={max:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T M6.7 acceptance bullet 1: navigation latency
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Populate `_G.scroll_buf` with `lines` lines of synthetic content.
|
||
/// Each line is "line-N: <padding>\n" with N spelled out so search
|
||
/// trials can target a known string at a known line. The buffer is
|
||
/// then made the active buffer so motion / search dispatch lands on
|
||
/// it. The trailing `move_to_start` rewinds the cursor to position 0
|
||
/// for predictable starting state.
|
||
fn populate_scrollback(editor: &mut EditorState, lines: usize) {
|
||
let chunk = format!(
|
||
r#"
|
||
local buf = pmacs.buffer.create("*scrollback*")
|
||
-- Build the content in Lua-side first to avoid {lines}
|
||
-- separate insert-edit notifications. One big insert is
|
||
-- {lines}x faster and the gate is about post-populate
|
||
-- cursor behavior, not populate cost.
|
||
local parts = {{}}
|
||
for i = 1, {lines} do
|
||
parts[i] = string.format(
|
||
"line-%d: PMACS_M67_PAD_%d_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n",
|
||
i, i)
|
||
end
|
||
local body = table.concat(parts)
|
||
buf:insert(0, body)
|
||
pmacs.window.switch_buffer(buf)
|
||
_G.scroll_buf = buf
|
||
_G.scroll_buf_len = buf:len()
|
||
"#,
|
||
);
|
||
editor
|
||
.lua_host
|
||
.eval(Some("@populate"), &chunk)
|
||
.expect("populate");
|
||
}
|
||
|
||
/// Move the cursor halfway through the buffer. Without this, every
|
||
/// trial starts at position 0 and `move_up` / `move_page_up` no-op,
|
||
/// skewing the latency distribution low. With the cursor at midpoint,
|
||
/// the four motion operations all have somewhere to go.
|
||
fn seek_cursor_to_middle(editor: &mut EditorState, lines: usize) {
|
||
let down_count = lines / 2;
|
||
let chunk = format!(
|
||
r"
|
||
for _ = 1, {down_count} do pmacs.editor.move_down() end
|
||
",
|
||
);
|
||
editor.lua_host.eval(Some("@seek"), &chunk).expect("seek");
|
||
}
|
||
|
||
/// 1000-trial navigation-latency gate. Each trial dispatches one
|
||
/// motion command via `pmacs.command.invoke` (the keymap-equivalent
|
||
/// path) and times it. The four commands rotate by trial index so
|
||
/// p99 reflects mixed motion rather than one extreme. p99 ≤ 16 ms
|
||
/// is the spec gate (single-frame budget at 60 Hz).
|
||
#[test]
|
||
#[ignore = "perf gate; requires release build"]
|
||
fn m6_7_scrollback_navigation_p99_under_16ms() {
|
||
const LINES: usize = 10_000;
|
||
const TRIALS: usize = 1000;
|
||
const P99_THRESHOLD: Duration = Duration::from_millis(16);
|
||
|
||
let mut editor = EditorState::new();
|
||
populate_scrollback(&mut editor, LINES);
|
||
seek_cursor_to_middle(&mut editor, LINES);
|
||
|
||
// Warmup: 100 dispatches that don't get measured. Absorbs LuaJIT
|
||
// trace compilation and any first-call costs in command dispatch.
|
||
{
|
||
let chunk = r#"
|
||
for i = 1, 100 do
|
||
local op = ({"cursor.down", "cursor.up",
|
||
"cursor.page-down", "cursor.page-up"})[(i % 4) + 1]
|
||
pmacs.command.invoke(op)
|
||
end
|
||
"#;
|
||
editor
|
||
.lua_host
|
||
.eval(Some("@warmup"), chunk)
|
||
.expect("warmup");
|
||
}
|
||
|
||
let mut latencies: Vec<Duration> = Vec::with_capacity(TRIALS);
|
||
let ops = [
|
||
"cursor.down",
|
||
"cursor.up",
|
||
"cursor.page-down",
|
||
"cursor.page-up",
|
||
];
|
||
for trial in 0..TRIALS {
|
||
let op = ops[trial % 4];
|
||
let chunk = format!(r#"pmacs.command.invoke("{op}")"#);
|
||
let t0 = Instant::now();
|
||
editor
|
||
.lua_host
|
||
.eval(Some("@nav-trial"), &chunk)
|
||
.expect("nav");
|
||
latencies.push(t0.elapsed());
|
||
}
|
||
|
||
let mut sorted = latencies.clone();
|
||
sorted.sort();
|
||
let percentile = |p: usize| -> Duration {
|
||
let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1);
|
||
sorted[idx]
|
||
};
|
||
let p50 = percentile(50);
|
||
let p90 = percentile(90);
|
||
let p99 = percentile(99);
|
||
let max = sorted[sorted.len() - 1];
|
||
|
||
println!("M6.7 navigation latency gate ({TRIALS} trials, {LINES} lines):");
|
||
println!(" p50: {p50:?}");
|
||
println!(" p90: {p90:?}");
|
||
println!(" p99: {p99:?}");
|
||
println!(" max: {max:?}");
|
||
println!(" threshold: {P99_THRESHOLD:?}");
|
||
|
||
assert!(
|
||
p99 <= P99_THRESHOLD,
|
||
"navigation p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \
|
||
p50={p50:?}, p90={p90:?}, max={max:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T M6.7 acceptance bullet 2: search latency
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 1000-trial search-latency gate. Each trial searches the populated
|
||
/// buffer for a unique needle whose target line varies across early/
|
||
/// mid/late position bands. Search uses `Buffer:slice` + Lua
|
||
/// `string.find`; this is the path isearch will dispatch to when
|
||
/// implemented. p99 ≤ 100 ms.
|
||
#[test]
|
||
#[ignore = "perf gate; requires release build"]
|
||
fn m6_7_scrollback_search_p99_under_100ms() {
|
||
const LINES: usize = 10_000;
|
||
const TRIALS: usize = 1000;
|
||
const P99_THRESHOLD: Duration = Duration::from_millis(100);
|
||
|
||
let mut editor = EditorState::new();
|
||
populate_scrollback(&mut editor, LINES);
|
||
|
||
// Warmup: 100 searches at varied positions. Same trace-compilation
|
||
// / hot-path-warmup rationale as the navigation gate.
|
||
{
|
||
let chunk = r#"
|
||
for i = 1, 100 do
|
||
local target = ((i * 31) % 10000) + 1
|
||
local needle = "PMACS_M67_PAD_" .. target .. "_"
|
||
local _ = _G.scroll_buf:slice(0, _G.scroll_buf_len):find(needle, 1, true)
|
||
end
|
||
"#;
|
||
editor
|
||
.lua_host
|
||
.eval(Some("@warmup"), chunk)
|
||
.expect("warmup");
|
||
}
|
||
|
||
let mut latencies: Vec<Duration> = Vec::with_capacity(TRIALS);
|
||
for trial in 0..TRIALS {
|
||
// Spread targets across early/mid/late bands. (trial * 31) mod
|
||
// LINES gives a stride pattern that hits every line band
|
||
// without clustering; 31 is coprime to 10000.
|
||
let target = ((trial * 31) % LINES) + 1;
|
||
let chunk = format!(
|
||
r#"
|
||
local needle = "PMACS_M67_PAD_{target}_"
|
||
return _G.scroll_buf:slice(0, _G.scroll_buf_len):find(needle, 1, true)
|
||
"#,
|
||
);
|
||
let t0 = Instant::now();
|
||
let pos: Option<i64> = editor.lua_host.lua().load(&chunk).eval().expect("search");
|
||
latencies.push(t0.elapsed());
|
||
// Sanity: every needle is present in the buffer; if find
|
||
// returns nil we've broken the populate / lookup contract.
|
||
assert!(
|
||
pos.is_some(),
|
||
"trial {trial}: needle for line {target} not found"
|
||
);
|
||
}
|
||
|
||
let mut sorted = latencies.clone();
|
||
sorted.sort();
|
||
let percentile = |p: usize| -> Duration {
|
||
let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1);
|
||
sorted[idx]
|
||
};
|
||
let p50 = percentile(50);
|
||
let p90 = percentile(90);
|
||
let p99 = percentile(99);
|
||
let max = sorted[sorted.len() - 1];
|
||
|
||
println!("M6.7 search latency gate ({TRIALS} trials, {LINES} lines):");
|
||
println!(" p50: {p50:?}");
|
||
println!(" p90: {p90:?}");
|
||
println!(" p99: {p99:?}");
|
||
println!(" max: {max:?}");
|
||
println!(" threshold: {P99_THRESHOLD:?}");
|
||
|
||
assert!(
|
||
p99 <= P99_THRESHOLD,
|
||
"search p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \
|
||
p50={p50:?}, p90={p90:?}, max={max:?}"
|
||
);
|
||
}
|