makepad/libs/workspace/tests/document_worker_latency.rs
Admin c408dab471 widgets, platform, script, draw: clean builds 7-9 s faster -- rare widgets leave widgets-core, the tweaker leaves the front crate, network and math_aot leave the build chain, and less generated code
Clean release builds on the Mac mini (M4, empty target dirs, nothing else building), before -> after on the same tree: Scope 73 -> 64 s, the shared stack (makepad-widgets, no default features) 32 -> 25 s, calculator 42 -> 35 s, storybook 45 -> 38 s. The base crates' frontends are on the critical path and the machine is CPU-saturated during a build, so both a shorter chain and less generated code count.

- widgets-core keeps the base widgets. About 58k compiled lines move into seven feature-free families behind the front crate's features: fab (fab_controls, property_inspector, menu_bar, tween_inspector), editors (ease_editor, sequencer, gizmo), hotkeys, media (video, playback_bar, level_meter, media, placeholder, marquee), collections, pickers and catalog. DSL names don't change. Each app turns on the families it uses, and all of them stay in the front crate's defaults. The dead command_text_input is gone. core is 85.5k lines instead of 143.5k.
- The tweaker is linked by the app: makepad-widgets no longer depends on makepad-widgets-tweaker. An app's `tweaker` feature (on by default, off in Builder releases) is now `dep:makepad-widgets-tweaker` plus `makepad-widgets/fab`, and the app calls `makepad_widgets_tweaker::link(vm)`. Library crates (code_editor, workspace, wm_theme, wm_api, terminal, aichat, diskmap, ...) depend on makepad-widgets-core plus their families, so they start as soon as core's metadata is ready. task and mixer drop families they never use.
- makepad-network no longer depends on makepad-script: the script-facing network types live in script-std. math_aot is its own crate (makepad-script-math-aot), so script no longer pulls in stitch. The Cx-free half of the remote bridge (HTTP, routing, parsing, encoders) is makepad-remote-core. Dead code removed: the old apple/windows video_file encoders and decoders (superseded by makepad-platform-video), pty_spawn (moved to tools/agents, its only user), and unreferenced pub items.
- Less generated code: #[derive(Widget)] generates the XxxSet wrapper only through #[derive(WidgetSet)]; hot generics in widgets-core and the event hit test are outlined into non-generic cores; CxOsOp's Debug and script's Opcode Display use name tables. A slim makepad_network::mpsc channel replaces std::sync::mpsc in platform, draw and widgets-core, except the log sink, whose producers must never wait. LLVM lines drop about 10% in platform, 12% in draw and 7.5% in widgets-core.

Checked on the mini: cargo check --release --workspace with and without the private apps; --no-default-features for calculator, storybook and Scope; cross-target checks for makepad-widgets, -core, platform and draw on x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-linux-android, wasm32-unknown-unknown and aarch64-apple-ios; lib tests for widgets-core (656), platform (312), script (58), draw (140), network (32) and remote-core (6). The tweaker's 5 theme_builder test failures predate this change. Shift+F10 was not tried by hand. apps/sandbox (its own workspace) still needs the tweaker link to keep its overlay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:55:14 +02:00

483 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Outside-in latency probe for DocumentWorker notify bursts.
//!
//! The worker exposes no per-stage timestamps. This test measures notify →
//! snapshot arrival from the public API, first vs last, inter-arrival batching
//! (to see the snapshot-channel / recv_timeout cadence), and process CPU via
//! getrusage when available.
//!
//! Run:
//! cargo test --release -p makepad-workspace --test document_worker_latency -- --ignored --nocapture
use makepad_workspace::document_worker::{DocumentWorker, FileSnapshot, MAX_DOCUMENTS};
use makepad_workspace::makepad_widgets_core::makepad_platform::makepad_network::install_ui_waker;
use makepad_workspace::makepad_widgets_core::Cx;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
/// Headless tests must not let the platform waker create NSApplication off-thread.
fn headless_cx() -> Cx {
let cx = Cx::new(Box::new(|_, _| {}));
install_ui_waker(None);
cx
}
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"studio-doc-latency-{}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self {
path: std::fs::canonicalize(path).unwrap(),
}
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn percentile_ms(mut samples: Vec<f64>, p: f64) -> f64 {
if samples.is_empty() {
return 0.0;
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
let idx = ((samples.len() as f64 - 1.0) * p).round() as usize;
samples[idx.min(samples.len() - 1)]
}
fn collect_until(
worker: &mut DocumentWorker,
timeout: Duration,
mut done: impl FnMut(&[Arc<FileSnapshot>]) -> bool,
) -> (Vec<Arc<FileSnapshot>>, BTreeMap<PathBuf, Instant>) {
let deadline = Instant::now() + timeout;
let mut out = Vec::new();
let mut first_seen = BTreeMap::new();
while Instant::now() < deadline {
let batch = worker.poll();
if !batch.is_empty() {
let now = Instant::now();
for snap in &batch {
first_seen
.entry(snap.requested_path.clone())
.or_insert(now);
}
out.extend(batch);
}
if done(&out) {
break;
}
// 1 ms empty-poll sleep: coarse enough to keep the test thread from
// dominating getrusage, fine enough to resolve a 50 ms send cadence.
std::thread::sleep(Duration::from_millis(1));
}
(out, first_seen)
}
/// Process-wide user+sys CPU in milliseconds. RUSAGE_THREAD is not portable
/// (absent on Darwin), so this includes the test thread's poll loop.
#[cfg(unix)]
fn process_cpu_ms() -> Option<f64> {
#[cfg(target_os = "macos")]
#[repr(C)]
struct Timeval {
tv_sec: std::os::raw::c_long,
tv_usec: std::os::raw::c_int,
}
#[cfg(not(target_os = "macos"))]
#[repr(C)]
struct Timeval {
tv_sec: std::os::raw::c_long,
tv_usec: std::os::raw::c_long,
}
#[repr(C)]
struct Rusage {
ru_utime: Timeval,
ru_stime: Timeval,
_pad: [u64; 32],
}
fn timeval_ms(t: Timeval) -> f64 {
t.tv_sec as f64 * 1000.0 + t.tv_usec as f64 / 1000.0
}
unsafe {
extern "C" {
fn getrusage(who: i32, usage: *mut Rusage) -> i32;
}
const RUSAGE_SELF: i32 = 0;
let mut usage = std::mem::zeroed::<Rusage>();
if getrusage(RUSAGE_SELF, &mut usage) != 0 {
return None;
}
Some(timeval_ms(usage.ru_utime) + timeval_ms(usage.ru_stime))
}
}
#[cfg(not(unix))]
fn process_cpu_ms() -> Option<f64> {
None
}
#[derive(Clone, Debug)]
struct BurstRow {
notifies: usize,
expected_snaps: usize,
got_snaps: usize,
storm_ms: f64,
first_ms: f64,
last_ms: f64,
p50_ms: f64,
p95_ms: f64,
max_ms: f64,
wall_ms: f64,
cpu_ms: Option<f64>,
batch_sizes: Vec<usize>,
batch_gaps_ms: Vec<f64>,
latencies: Vec<f64>,
}
fn cluster_batches(mut arrivals_ms: Vec<f64>, gap_ms: f64) -> (Vec<usize>, Vec<f64>) {
arrivals_ms.sort_by(|a, b| a.partial_cmp(b).unwrap());
if arrivals_ms.is_empty() {
return (Vec::new(), Vec::new());
}
let mut sizes = Vec::new();
let mut gaps = Vec::new();
let mut size = 1usize;
let mut prev = arrivals_ms[0];
gaps.push(0.0);
for &t in arrivals_ms.iter().skip(1) {
if t - prev > gap_ms {
sizes.push(size);
gaps.push(t - prev);
size = 1;
} else {
size += 1;
}
prev = t;
}
sizes.push(size);
(sizes, gaps)
}
fn run_burst(
worker: &mut DocumentWorker,
paths: &[PathBuf],
watched_n: usize,
notifies: usize,
tag: &str,
) -> BurstRow {
let expected = notifies.min(watched_n);
for (i, path) in paths.iter().take(expected).enumerate() {
std::fs::write(path, format!("{tag}-{i}\n")).unwrap();
}
let cpu0 = process_cpu_ms();
let storm_start = Instant::now();
let mut first_notify: BTreeMap<PathBuf, Instant> = BTreeMap::new();
for i in 0..notifies {
let path = &paths[i];
let t = Instant::now();
worker.notify_changed(path.clone(), 7, 1000 + i as u64).unwrap();
first_notify.entry(path.clone()).or_insert(t);
if i + 1 < notifies {
let target = storm_start + Duration::from_millis(100) * (i as u32 + 1) / notifies as u32;
let now = Instant::now();
if target > now {
std::thread::sleep(target - now);
}
}
}
let storm_ms = storm_start.elapsed().as_secs_f64() * 1000.0;
let (after, first_seen) = collect_until(worker, Duration::from_secs(5), |snaps| {
snaps
.iter()
.filter(|s| s.observed.is_some())
.map(|s| s.requested_path.clone())
.collect::<std::collections::BTreeSet<_>>()
.len()
== expected
});
let wall_ms = storm_start.elapsed().as_secs_f64() * 1000.0;
let cpu_ms = match (cpu0, process_cpu_ms()) {
(Some(a), Some(b)) => Some((b - a).max(0.0)),
_ => None,
};
let mut observed: BTreeMap<PathBuf, Vec<Arc<FileSnapshot>>> = BTreeMap::new();
for snap in &after {
if snap.observed.is_some() {
observed
.entry(snap.requested_path.clone())
.or_default()
.push(snap.clone());
}
}
let mut latencies = Vec::new();
let mut arrivals = Vec::new();
for path in observed.keys() {
if let (Some(t0), Some(seen)) = (first_notify.get(path), first_seen.get(path)) {
let ms = seen.saturating_duration_since(*t0).as_secs_f64() * 1000.0;
latencies.push(ms);
arrivals.push(seen.saturating_duration_since(storm_start).as_secs_f64() * 1000.0);
}
}
let (batch_sizes, batch_gaps_ms) = cluster_batches(arrivals, 5.0);
let first_ms = latencies.iter().copied().fold(f64::INFINITY, f64::min);
let last_ms = latencies.iter().copied().fold(0.0, f64::max);
BurstRow {
notifies,
expected_snaps: expected,
got_snaps: observed.len(),
storm_ms,
first_ms: if first_ms.is_finite() { first_ms } else { 0.0 },
last_ms,
p50_ms: percentile_ms(latencies.clone(), 0.50),
p95_ms: percentile_ms(latencies.clone(), 0.95),
max_ms: last_ms,
wall_ms,
cpu_ms,
batch_sizes,
batch_gaps_ms,
latencies,
}
}
fn print_row(row: &BurstRow) {
let cpu = row
.cpu_ms
.map(|c| format!("{c:.2}"))
.unwrap_or_else(|| "n/a".into());
let ratio = match row.cpu_ms {
Some(c) if row.wall_ms > 0.0 => format!("{:.2}", c / row.wall_ms),
_ => "n/a".into(),
};
eprintln!(
"record=document_notify_latency notifies={} expected={} got={} storm_ms={:.2} first_ms={:.2} last_ms={:.2} p50_ms={:.2} p95_ms={:.2} max_ms={:.2} wall_ms={:.2} cpu_ms={} cpu/wall={} batches={:?} gaps_ms={:?}",
row.notifies,
row.expected_snaps,
row.got_snaps,
row.storm_ms,
row.first_ms,
row.last_ms,
row.p50_ms,
row.p95_ms,
row.max_ms,
row.wall_ms,
cpu,
ratio,
row.batch_sizes,
row.batch_gaps_ms.iter().map(|g| format!("{g:.1}")).collect::<Vec<_>>(),
);
}
fn print_stage_breakdown(row: &BurstRow) {
eprintln!();
eprintln!("=== stage breakdown ({} notifies, {} watched files expected) ===", row.notifies, row.expected_snaps);
eprintln!(
" notify storm spread: {:>8.2} ms (target 100 ms for burst>1)",
row.storm_ms
);
eprintln!(
" first snapshot arrival: {:>8.2} ms (notify[path] → first poll that sees it)",
row.first_ms
);
eprintln!(
" last snapshot arrival: {:>8.2} ms (slowest per-file notify → arrival)",
row.last_ms
);
eprintln!(
" p50 / p95 / max: {:>8.2} / {:>8.2} / {:>8.2} ms",
row.p50_ms, row.p95_ms, row.max_ms
);
eprintln!(
" wall (storm+collect): {:>8.2} ms",
row.wall_ms
);
match row.cpu_ms {
Some(c) => eprintln!(
" process CPU (getrusage): {:>8.2} ms cpu/wall={:.2} (process-wide; no RUSAGE_THREAD on Darwin)",
c,
if row.wall_ms > 0.0 { c / row.wall_ms } else { 0.0 }
),
None => eprintln!(" process CPU (getrusage): n/a"),
}
eprintln!(
" arrival batches (>5 ms gap): sizes={:?} gaps_ms={:?}",
row.batch_sizes,
row.batch_gaps_ms
.iter()
.map(|g| format!("{g:.1}"))
.collect::<Vec<_>>()
);
let mut sorted = row.latencies.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let shown: Vec<String> = sorted.iter().map(|v| format!("{v:.0}")).collect();
eprintln!(" per-file latencies_ms: {shown:?}");
// Inferences from public-API timings only.
let mean_gap = if row.batch_gaps_ms.len() > 1 {
row.batch_gaps_ms.iter().skip(1).sum::<f64>() / (row.batch_gaps_ms.len() - 1) as f64
} else {
0.0
};
let all_fours = !row.batch_sizes.is_empty()
&& row.batch_sizes.iter().all(|&s| s <= 4)
&& row.batch_sizes.iter().filter(|&&s| s == 4).count() >= 2;
let gaps_near_50 = mean_gap >= 35.0 && mean_gap <= 70.0;
let first_near_retry = row.first_ms >= 250.0; // 16 cmds × 20 ms ENOENT retry
let cpu_low = row.cpu_ms.map(|c| c < row.wall_ms * 0.35).unwrap_or(false);
eprintln!(" inference:");
if first_near_retry {
eprintln!(
" first_ms={:.0} looks like the 20 ms ENOENT retry serialised across the first command drain (up to 16).",
row.first_ms
);
} else {
eprintln!(
" first_ms={:.1} is far below 16×20 ms, so the ENOENT retry is not firing on these existing paths.",
row.first_ms
);
}
if all_fours && gaps_near_50 {
eprintln!(
" snapshots arrive in groups of ≤4, ~{mean_gap:.0} ms apart → snapshot channel depth 4 + worker recv_timeout(50 ms) while pending sends remain."
);
} else {
eprintln!(
" batch pattern sizes={:?} mean_gap={:.1} ms (expected ≤4-wide / ~50 ms if channel+timeout dominate).",
row.batch_sizes, mean_gap
);
}
if cpu_low {
eprintln!(
" cpu/wall is low → the worker is sleeping, not hashing or doing serial disk I/O for the 447 ms."
);
} else if let Some(c) = row.cpu_ms {
eprintln!(
" cpu={c:.1} ms vs wall={:.1} ms → a substantial fraction is on-CPU (reads/hashing/test poll), not just sleep.",
row.wall_ms
);
}
}
#[test]
#[ignore = "timing probe; run with --ignored --nocapture"]
fn notify_burst_latency_table_and_breakdown() {
let dir = TempDir::new();
const FILE_COUNT: usize = 200;
let watched_n = MAX_DOCUMENTS;
let mut paths = Vec::with_capacity(FILE_COUNT);
for i in 0..FILE_COUNT {
let path = dir.path.join(format!("f{i:03}.rs"));
std::fs::write(&path, format!("v0-{i}\n")).unwrap();
paths.push(path);
}
let cx = headless_cx();
let mut worker =
DocumentWorker::start_with_fallback(&cx.thread_spawner(), Duration::from_secs(120))
.unwrap();
for path in paths.iter().take(watched_n) {
worker.watch(path.clone()).unwrap();
}
let (initial, _) = collect_until(&mut worker, Duration::from_secs(5), |snaps| {
snaps
.iter()
.map(|s| s.requested_path.clone())
.collect::<std::collections::BTreeSet<_>>()
.len()
== watched_n
});
let initial_n = initial
.iter()
.map(|s| s.requested_path.clone())
.collect::<std::collections::BTreeSet<_>>()
.len();
assert_eq!(initial_n, watched_n, "initial snapshots missing");
let bursts = [1usize, 8, 32, 200];
let mut rows = Vec::new();
for (round, &n) in bursts.iter().enumerate() {
// Drain anything left from the previous round so observed-seq snapshots
// in this burst are attributable to these notifies.
let _ = worker.poll();
let row = run_burst(&mut worker, &paths, watched_n, n, &format!("b{round}"));
assert_eq!(
row.got_snaps, row.expected_snaps,
"burst {n}: expected {} unique observed snapshots, got {}",
row.expected_snaps, row.got_snaps
);
print_row(&row);
if n == 200 {
print_stage_breakdown(&row);
}
rows.push(row);
}
eprintln!();
eprintln!("=== latency vs burst size (32 watched, notifies spread over 100 ms) ===");
eprintln!(
"| notifies | snapshots | first_ms | last_ms | p50_ms | p95_ms | max_ms | wall_ms | cpu_ms | batches |"
);
eprintln!(
"|---------:|----------:|---------:|--------:|-------:|-------:|-------:|--------:|-------:|---------|"
);
for row in &rows {
let cpu = row
.cpu_ms
.map(|c| format!("{c:.1}"))
.unwrap_or_else(|| "n/a".into());
let batches = format!("{:?}", row.batch_sizes);
eprintln!(
"| {:>8} | {:>9} | {:>8.1} | {:>7.1} | {:>6.1} | {:>6.1} | {:>6.1} | {:>7.1} | {:>6} | {} |",
row.notifies,
row.got_snaps,
row.first_ms,
row.last_ms,
row.p50_ms,
row.p95_ms,
row.max_ms,
row.wall_ms,
cpu,
batches
);
}
eprintln!();
eprintln!("live-mode budget: event→arrival p95 ≤ 250 ms");
if let Some(row) = rows.iter().find(|r| r.notifies == 200) {
eprintln!(
"200-notify p95={:.1} ms first={:.1} ms last={:.1} ms {}",
row.p95_ms,
row.first_ms,
row.last_ms,
if row.p95_ms <= 250.0 {
"WITHIN budget"
} else {
"OVER budget"
}
);
}
worker.request_stop();
}