diff --git a/Cargo.lock b/Cargo.lock index a7a4399..b163149 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1922,16 +1922,34 @@ dependencies = [ "rusty_alloc", ] +[[package]] +name = "rusty_json_turbo" +version = "0.3.1" +source = "git+https://github.com/Remade-With-Rust/rusty_json_turbo?branch=master#0ed23c7d2480232993f9b6bef6a06c07a42856ec" +dependencies = [ + "itoa", + "memchr", + "rusty_json_turbo-accel", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "rusty_json_turbo-accel" +version = "0.3.1" +source = "git+https://github.com/Remade-With-Rust/rusty_json_turbo?branch=master#0ed23c7d2480232993f9b6bef6a06c07a42856ec" + [[package]] name = "rusty_time-alloc" -version = "0.2.0" +version = "0.3.0" dependencies = [ "rusty_alloc-api", ] [[package]] name = "rusty_time-api" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -1939,7 +1957,7 @@ dependencies = [ [[package]] name = "rusty_time-clock" -version = "0.2.0" +version = "0.3.0" dependencies = [ "libc", "rusty_time-core", @@ -1948,11 +1966,11 @@ dependencies = [ [[package]] name = "rusty_time-core" -version = "0.2.0" +version = "0.3.0" [[package]] name = "rusty_time-ctl" -version = "0.2.0" +version = "0.3.0" dependencies = [ "rusty_time-alloc", "rusty_time-api", @@ -1962,7 +1980,7 @@ dependencies = [ [[package]] name = "rusty_time-daemon" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aes-gcm 0.11.1", "argon2", @@ -1979,7 +1997,7 @@ dependencies = [ [[package]] name = "rusty_time-nts" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aes-siv", "getrandom 0.4.3", @@ -1991,7 +2009,7 @@ dependencies = [ [[package]] name = "rusty_time-sim" -version = "0.2.0" +version = "0.3.0" dependencies = [ "rusty_time-alloc", "rusty_time-core", @@ -2001,7 +2019,7 @@ dependencies = [ [[package]] name = "rusty_time-wasm" -version = "0.2.0" +version = "0.3.0" dependencies = [ "rusty_time-core", "wasm-bindgen", @@ -2080,14 +2098,9 @@ dependencies = [ [[package]] name = "serde_json" version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +source = "git+https://github.com/Remade-With-Rust/rusty_json_turbo?branch=master#0ed23c7d2480232993f9b6bef6a06c07a42856ec" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "rusty_json_turbo", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1319553..a394291 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ exclude = ["bare-metal/esp32s3"] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/remade-with-rust/rusty_time" @@ -29,14 +29,36 @@ rust-version = "1.95" unsafe_code = "deny" [workspace.dependencies] -rusty_time-core = { path = "crates/rusty_time-core", version = "0.2.0" } -rusty_time-nts = { path = "crates/rusty_time-nts", version = "0.2.0" } -rusty_time-clock = { path = "crates/rusty_time-clock", version = "0.2.0" } -rusty_time-api = { path = "crates/rusty_time-api", version = "0.2.0" } -rusty_time-alloc = { path = "crates/rusty_time-alloc", version = "0.2.0" } +rusty_time-core = { path = "crates/rusty_time-core", version = "0.3.0" } +rusty_time-nts = { path = "crates/rusty_time-nts", version = "0.3.0" } +rusty_time-clock = { path = "crates/rusty_time-clock", version = "0.3.0" } +rusty_time-api = { path = "crates/rusty_time-api", version = "0.3.0" } +rusty_time-alloc = { path = "crates/rusty_time-alloc", version = "0.3.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" [profile.release] lto = "thin" codegen-units = 1 + +# THE JSON SWAP (rusty_json_turbo). One line converts the WHOLE graph. +# +# `serde_json` here is the house fork, `Remade-With-Rust/rusty_json_turbo`: a +# performance fork of serde_json whose output is byte-identical to upstream's +# by contract, gated per commit against upstream linked in the same process -- +# bytes, `Value`, error text with line and column, float bit patterns and +# `StreamDeserializer` offsets. +# +# It is a `[patch]` rather than a renamed dependency on purpose. Renaming +# `serde_json = { package = "rusty_json_turbo", ... }` would convert only the +# JSON this workspace writes itself and leave every third-party copy on +# upstream -- and the two would be DIFFERENT TYPES, so a `Value` crossing that +# boundary would not compile. Patching converts every copy at once, and there +# is then only one `Value` in the graph. +# +# The patch target is a shim package inside that repository named `serde_json`, +# which exists because `[patch]` matches on PACKAGE name and the fork's package +# is `rusty_json_turbo` (its LIBRARY is `serde_json`, so every `use +# serde_json::...` line in this workspace is untouched). +[patch.crates-io] +serde_json = { git = "https://github.com/Remade-With-Rust/rusty_json_turbo", branch = "master" } diff --git a/README.md b/README.md index 94ea5ab..df47063 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,14 @@ error of the **predicted offset** rather than of the slope, which took S8 from chrony before that and are now level. Instruction cost, deterministic and internal (not a chrony comparison): -**237 Ir per served request**, **13,613 Ir per client discipline step** — within -1 Ir of `0.1.7` despite the multi-source rework. +**224.3 Ir per served request**, **12,137.5 Ir per client discipline step**, +measured with callgrind on three seeded harnesses whose checksums gate every +change. Six rounds of instruction-count work took the server from 3,053.6 and +the client from 25,627; the last round's two wins were a representation change +(three `Option` fields to a zero sentinel, −1.77% on the server) +and one inlined pass-through frame (−0.16% on the client). Across those rounds +the "clever local rewrite" class went **0-for-8** — every win was a deletion, a +representation change, or a libm swap. Three honest notes. Earlier per-scenario accuracy wins reported here were **withdrawn**: they came from comparing medians on a rig whose unchanged control diff --git a/corpus/LEDGER.md b/corpus/LEDGER.md index a154901..0bd9338 100644 --- a/corpus/LEDGER.md +++ b/corpus/LEDGER.md @@ -2696,3 +2696,102 @@ long window: it can see the slope's variance and cannot see its bias. Closing that needs a criterion with a bias signal in it, not another sweep. Recorded as the open item, with the measurement that will falsify it. + +## Round 6: ten more veins on the two censuses nobody had taken + +`docs/plans/instruction-veins.md` (round 6), `tools/perf/codec_ir.sh` (new). + +The twenty veins of rounds 1-5 were closed. Ten more came from the two censuses +those rounds never ran — **call counts and caller-callee EDGES** +(`instruction-counting` §5b/§5c, where rounds 1-5 used self-cost only) — plus a +reading of the `no_std` codec leaf, which had no instrument at all. + +### Kept, both gated byte-identical + +| vein | change | instrument | Ir | +|---|---|---|---:| +| N3 | `ClientRecord`'s three `Option` to `NtpTimestamp`, zero = unset | hot_path | **-808,139 (-1.77%)** | +| N2a | `#[inline(always)]` on `MultiController::estimate` — one caller, one callee, 37.2 Ir/call of frame | client_path | **-317,822 (-0.16%)** | + +**Server: 228.3 -> 224.3 Ir per served request. Client: 12,154.3 -> 12,137.5 Ir +per discipline step.** Gates: `hot_path` `0xc0452ab605328800`, `client_path` +`f456d8bf8ff664d5`, `codec` `0x99f61c51fcc31496` — all unchanged; work parity +exact on all three; 218 tests, clippy `-D warnings`, fmt, and the `no_std` leaf +on `thumbv7em-none-eabihf` and `riscv32imac-unknown-none-elf`. + +N3 is a representation change, not a rewrite: `NtpTimestamp` is a bare `u64` +with no niche, so `Option` is sixteen bytes for eight of payload +and every read tests a discriminant. RFC 5905 already spells "unset" as an +all-zero timestamp. What makes the sentinel safe rather than merely convenient +is that the interleaved path's zero-origin rejection +(`!request_origin.is_zero()`) was **already explicit and independent of the +`Option`** — that test is the security property, not the discriminant. 24 bytes +off every record as a side effect. + +### Refuted, measured, numbers left in the source + +| probe | Ir | why | +|---|---:|---| +| N9 `from_bits` as a total lookup table | **+2,048,014 (+4.79%)** | LLVM already emits arithmetic — the discriminants ARE the wire values — so the table only added a load. The magnitude matched `rusty-compiler-leverage` B5's recorded bitmask refutation (+2.03M) | +| N8 `parse` via one `&[u8; 48]` (`first_chunk`) | **+0, exactly** | §6: a byte-identical total means the compiler already did it. Cost moved buckets (ntp.rs -1,024,000, `core/src/slice/mod.rs` +1,600,000), total bit-identical. **B18's "already at its floor" was right** | +| N9' `#[repr(u8)]` + `self as u8` for `bits()` | +0 | already a cast | +| N2b bind `registers[index]` once, not three times | +0 | already CSE'd, as `admit_handle` was in round 1 | +| N1 `#[inline(always)]` on `residual_sd` | +0 alone, +0 combined | LLVM had already made the call; attribute removed | + +**The clever-local-rewrite class is now 0-for-8 across six rounds** (1-5 went +0-for-6; N8 and N9 make eight). Every win in six rounds has been a deletion, a +representation change, or a libm swap. + +### Instruments + +- **A third one exists now.** `benches/codec.rs` + `tools/perf/codec_ir.sh` + prices `ntp.rs` on its own — bytes in, bytes out, no table, no filter, no + clock, which is the different corpus shape §4 asks for. It matters because + `ntp.rs` is the `no_std` leaf: on a Cortex-M4F, an RV32 part or in the wasm + client it is not 1.27% of the work, it is ~all of it. +- **Its own tap was 63% of its first measurement.** Nineteen FNV folds per + packet. The fix was not cheaper mixing but fewer mixed values: `write` is the + inverse of `parse` and re-emits all 48 bytes from all 13 fields, so folding + the six OUTPUT words covers every field transitively. 19 mixes to 9, total + 67,297,513 to 42,721,512, and the product's own code went from the fourth row + to the second (23.6 Ir/packet). +- **`ir.sh` and `client_ir.sh` now `touch` their sources before building.** + Caught live: three consecutive identical A/B results, and one run that read + the pre-A1 Ir with A1 sitting in the tree. Cargo's fingerprint across the + `/mnt` drvfs mount does not reliably notice an edit, so a run can measure a + stale binary — the trap `codec-memory-copies` §4 names, on this rig. +- **The `client_path` harness tax, never computed:** 3,661,681 Ir = **1.67%** = + 229 Ir/step (main's own 1,943,431 plus the 1,718,250 of `log` it calls). + C19 did this for `hot_path` (13.92%) and gated section B on it; section A had + no equivalent. It is small, so section A's shares stand. + +### Two corrections to rounds 1-5 + +- **A8 was not a vein, and its figure was half.** `benches/client_path.rs:66` + is `-u.ln()`, and there is **no `ln`/`log` call anywhere in the product + source**. 32,000 calls (two per step), 1,718,250 Ir — double the recorded + 16,000 / 859,160 — and all of it the harness's own noise generator. The + caller-callee edge said so in one line (`client_path::main -> log`); self-cost + alone cannot name an owner. Round 2's commit message already said "A8 is the + harness" while the ceiling table still priced it as real work. +- **B18 overstated its conclusion.** "`ntp.rs` is already at its floor" was read + off an instrument where `parse`/`write` inline into `hot_path::main` with 52% + of events unattributed. N8 has now turned that into a measurement rather than + an assumption — and it came out right. + +### A coverage hole worth more than either win + +The two most tempting veins in the file — a surviving full `sort_by` and a +double allocation — sit behind config knobs that default to `0.0` and `false`. +Checking reach demoted both correctly. The deeper find was that one of those +branches had **no test at all**, so a twelve-line rewrite of its inner loop was +green against 217 tests without executing a line of it. +`density_weighting_factors_span_the_window` now pins the half-gap rule +(computed independently in the test, not recorded from the code) and enters the +branch. + +### One law carried forward + +N3 won 1.77% on `hot_path` and cost ~50,000 Ir on `client_path` — same crate, +an unrelated function, codegen shifted. `instruction-counting` §10 says measure +every change on every instrument; quote both numbers or neither. diff --git a/crates/rusty_time-alloc/CHANGELOG.md b/crates/rusty_time-alloc/CHANGELOG.md index 8e68289..22f9d09 100644 --- a/crates/rusty_time-alloc/CHANGELOG.md +++ b/crates/rusty_time-alloc/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/crates/rusty_time-api/CHANGELOG.md b/crates/rusty_time-api/CHANGELOG.md index f7b5710..4084b08 100644 --- a/crates/rusty_time-api/CHANGELOG.md +++ b/crates/rusty_time-api/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/crates/rusty_time-clock/CHANGELOG.md b/crates/rusty_time-clock/CHANGELOG.md index f7b5710..4084b08 100644 --- a/crates/rusty_time-clock/CHANGELOG.md +++ b/crates/rusty_time-clock/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/crates/rusty_time-core/CHANGELOG.md b/crates/rusty_time-core/CHANGELOG.md index 4509e0a..e310da7 100644 --- a/crates/rusty_time-core/CHANGELOG.md +++ b/crates/rusty_time-core/CHANGELOG.md @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### BREAKING + +- `ClientRecord`'s three timestamp fields change type: + `pub last_receive`, `pub last_transmit` and `pub last_receive_sent` are now + `NtpTimestamp` rather than `Option`, with + `NtpTimestamp::ZERO` meaning "unset" — which is how RFC 5905 already spells + it on the wire. `Option` was sixteen bytes for eight bytes of + payload, because `NtpTimestamp` is a bare `u64` with no niche, and every read + tested a discriminant. + + **Migration:** `if let Some(ts) = rec.last_receive` becomes + `if !rec.last_receive.is_zero()`, and `rec.last_receive` is the value + directly. + + This is why the release is 0.3.0 and not 0.2.1. Note that + `cargo semver-checks` reports *no semver update required* for it — it has no + lint for a public field's TYPE changing — so the bump was decided by reading + the diff, not by the tool. Do not trust that tool alone on a field-type + change. + +### Performance + +Six deterministic wins in the client filter and the server's client table, +measured with callgrind Ir (exact: the same binary re-runs to zero) and gated +byte-identical throughout. + +| instrument | before | after | delta | +|---|---:|---:|---:| +| `client_path` | 219,439,512 | 194,470,797 | **-24,968,715 (-11.38%)** | +| `hot_path` (steady) | 47,424,979 | 45,664,884 | **-1,760,095 (-3.71%)** | +| `hot_path` (churn) | 75,642,533 | 67,786,128 | **-7,856,405 (-10.39%)** | +| `mru_report` | 5,804,467 | 5,234,883 | **-569,584 (-9.81%)** | + +- `regress` called `wls_fit` twice on identical data (-17,918,178). +- The spike filter recomputed residuals `spike_threshold` had already produced; + they travel as a `u64` keep mask now (-2,441,711). +- A median taken by full sort now uses `select_nth_unstable_by` (-3,363,768). +- `2f64.powi(k)` builds the exponent field directly instead of calling + `__powidf2` 16,000 times a run (-690,184). +- The regime-change trim advances a cursor instead of front-draining up to four + times (-557,376). +- The client index is open-addressed with backward-shift deletion (so no + tombstones) and does not duplicate the key, which `HashMap` had to. Costs + +10 bytes/client (150 -> 160). + +**No behaviour changed.** TIMECORP S1/S6/S8 at 31 seeds are byte-identical to +0.2.0, and S12a/b/c server-load counts are identical including 1,021,672 +evictions in S12b. `cargo semver-checks`: no semver update required. + +### Changed + +- `ClientTable::bytes_per_client()` reports 160 rather than 150, because the + index now holds four buckets per client instead of one entry. The signature + is unchanged; the number is honest. + +### Added + +- `HOT_PATH_EVICT`, a churn arm for the `hot_path` bench. The default arm never + evicts, so it could not gate any change to the client index -- and at a 0.5 + load factor the new index measured a clean win there while costing +9.86% + under eviction. The arm is what caught it. + ## [0.2.0] - 2026-09-09 ### Added diff --git a/crates/rusty_time-core/Cargo.toml b/crates/rusty_time-core/Cargo.toml index cc9af37..7d4ecd5 100644 --- a/crates/rusty_time-core/Cargo.toml +++ b/crates/rusty_time-core/Cargo.toml @@ -39,6 +39,11 @@ harness = false # Drives the std-only server/filter/client paths. required-features = ["std"] +[[bench]] +name = "codec" +harness = false +# The no_std leaf priced on its own -- no table, no filter, no clock. + [[bench]] name = "client_path" harness = false diff --git a/crates/rusty_time-core/benches/codec.rs b/crates/rusty_time-core/benches/codec.rs new file mode 100644 index 0000000..b07f898 --- /dev/null +++ b/crates/rusty_time-core/benches/codec.rs @@ -0,0 +1,235 @@ +//! The NTP packet codec, as a deterministic instruction-count workload. +//! +//! The third instrument. `hot_path` and `client_path` both drive the codec +//! incidentally, and in `hot_path` it reads as 3 Ir/request (1.27%) — which the +//! veins plan recorded as "already at its floor, not a vein". That reading was +//! taken on an instrument where `parse` and `write` are **inlined into +//! `hot_path::main`** and 52% of events are unattributed, so it cannot support +//! a floor claim about the codec's own cost. +//! +//! It also measures the wrong deployment. `ntp.rs` is the `no_std` leaf: on a +//! Cortex-M4F, an RV32 part or in the wasm client it is not 1.27% of the work, +//! it is ~all of it. This harness prices it on its own. +//! +//! `instruction-counting` §4 wants at least two corpus shapes because roughly a +//! third of probes move two instruments in opposite directions. This is the +//! third, and it is deliberately a *different shape* from the other two: no +//! client table, no filter, no clock — just bytes in, bytes out. +//! +//! Deterministic by construction: a fixed packet population built from a +//! splitmix64 LCG over a fixed seed, walked a fixed number of times. Two runs +//! of the same binary produce the same Ir count to the instruction. +//! +//! The gate is a CHECKSUM over every parsed field and every emitted byte. This +//! is an integer/exact path, so the bar is **byte-identity**: an +//! instruction-count change here must not move the arithmetic at all. +//! +//! Run: +//! cargo build --release --bench codec +//! valgrind --tool=callgrind --callgrind-out-file=cg.out ./codec +//! callgrind_annotate cg.out + +use rusty_time_core::ntp::{ + self, HEADER_LEN, LeapIndicator, Mode, NtpPacket, NtpShort, NtpTimestamp, Trailer, +}; + +/// Deterministic filler — splitmix64. Identical on every run and every host. +fn splitmix(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// How many distinct packets the corpus holds, and how many passes over it. +/// The product is the work count; both are printed so a moved anchor is visible. +const PACKETS: usize = 256; +const PASSES: usize = 2000; + +fn main() { + // ---------------------------------------------------------------- corpus + // + // Three shapes, because the codec's three entry points take different + // routes and a corpus of only bare headers would never enter the + // extension-field walk at all (`codec-measurement`: count path entries + // before judging a path). + // + // - 3/4 bare 48-byte headers (the overwhelming majority of real traffic) + // - 1/8 header + one well-formed extension field + // - 1/8 header + trailing bytes that cannot be a field (a legacy MAC) + // + // Version is forced into 3..=4 so `parse` reaches the body rather than + // rejecting; a corpus that fails validation measures the error path. + let mut seed = 0x0123_4567_89AB_CDEFu64; + let mut corpus: Vec> = Vec::with_capacity(PACKETS); + for i in 0..PACKETS { + let mut p = vec![0u8; HEADER_LEN]; + for chunk in p.chunks_mut(8) { + let w = splitmix(&mut seed).to_be_bytes(); + chunk.copy_from_slice(&w[..chunk.len()]); + } + // Force a valid version (3 or 4) into bits 3..6 of byte 0. + let v = if i % 2 == 0 { 4u8 } else { 3u8 }; + p[0] = (p[0] & !0b0011_1000) | (v << 3); + match i % 8 { + // A well-formed RFC 7822 field: type, total length 20, 16 value bytes. + 1 => { + p.extend_from_slice(&[0x01, 0x04, 0x00, 0x14]); + for _ in 0..16 { + p.push(splitmix(&mut seed) as u8); + } + } + // Trailing bytes too short to be a field — yielded as Opaque. + 5 => { + for _ in 0..4 { + p.push(splitmix(&mut seed) as u8); + } + } + _ => {} + } + corpus.push(p); + } + + // ------------------------------------------------------------- the work + let mut checksum: u64 = 0xcbf2_9ce4_8422_2325; + let mut parsed: u64 = 0; + let mut rejected: u64 = 0; + let mut fields: u64 = 0; + let mut opaque: u64 = 0; + let mut written: u64 = 0; + + // The measuring tap, sized deliberately. + // + // The first version folded every field and every output word with an FNV + // step each — 19 multiply-adds per packet, which callgrind priced at + // **52.6% of the whole measurement**. A tap that large distorts every share + // taken from it (`codec-measurement` §6; the same defect the `hot_path` + // harness already had to correct once, at 20%). + // + // So: fields are mixed into a per-packet accumulator with a shift and an + // XOR — one or two instructions each — and the expensive FNV step runs + // ONCE per packet. Coverage is unchanged: every field and every emitted + // byte still enters the checksum, and the rotate makes the mixing + // order-dependent so two swapped fields do not cancel. + #[inline(always)] + fn mix(acc: &mut u64, v: u64) { + *acc = (*acc).rotate_left(7) ^ v; + } + let fold = |checksum: &mut u64, v: u64| { + *checksum = checksum.wrapping_mul(0x0100_0000_01b3).wrapping_add(v); + }; + + for _ in 0..PASSES { + for pkt in &corpus { + // --- parse: the field-extraction path + match NtpPacket::parse(pkt) { + Ok(h) => { + parsed += 1; + // Every field enters the checksum, so a change to how any + // of them is read cannot pass unnoticed -- but they are + // MIXED cheaply and folded once (see `mix` above). + // Coverage without a 19-op tap: `write` is the INVERSE of + // `parse` and re-emits all 48 bytes from all 13 fields, so + // folding the six output words covers every field + // transitively -- any misparse changes an output byte. + // + // The three enum/version values are mixed as well, and + // deliberately: `bits_for_gate` is an INDEPENDENT + // reimplementation in this bench, so it pins that `parse` + // chose the right variant even if a vein changed + // `from_bits` and `bits` in compensating ways (which byte 0 + // alone would round-trip past). + let mut acc: u64 = 0; + mix(&mut acc, h.leap.bits_for_gate() as u64); + mix(&mut acc, h.version as u64); + mix(&mut acc, h.mode.bits_for_gate() as u64); + + // --- write: the serialisation path, same header back out + let mut out = [0u8; HEADER_LEN]; + h.write(&mut out); + written += 1; + let (words, _) = out.as_chunks::<8>(); + for w in words { + mix(&mut acc, u64::from_le_bytes(*w)); + } + fold(&mut checksum, acc); + } + Err(_) => rejected += 1, + } + + // --- the extension-field walk + for t in ntp::extension_fields(pkt) { + match t { + Trailer::Extension(f) => { + fields += 1; + let mut acc: u64 = 0; + mix(&mut acc, f.field_type as u64); + mix(&mut acc, f.value.len() as u64); + for b in f.value { + mix(&mut acc, *b as u64); + } + fold(&mut checksum, acc); + } + Trailer::Opaque(b) => { + opaque += 1; + fold(&mut checksum, b.len() as u64); + } + } + } + } + } + + // --- the arithmetic leaves: exercised once per packet, not per pass, so + // they are priced without dominating the count. + let mut ts_acc: u64 = 0; + for i in 0..PACKETS as i64 { + let t = NtpTimestamp::from_unix(1_787_856_000 + i, (i as u32).wrapping_mul(7_919)); + ts_acc ^= t.0; + ts_acc = ts_acc.wrapping_add(NtpShort::from_seconds(i as f64 / 1024.0).0 as u64); + } + fold(&mut checksum, ts_acc); + + println!("packets {PACKETS}"); + println!("passes {PASSES}"); + println!("parsed {parsed}"); + println!("rejected {rejected}"); + println!("written {written}"); + println!("fields {fields}"); + println!("opaque {opaque}"); + println!("CHECKSUM {checksum:#018x}"); +} + +/// The gate needs the wire value of the two bit-packed enums, and their `bits` +/// methods are private to the codec. These mirror them for checksum purposes +/// ONLY — if a vein changes the enums' representation, the gate still folds the +/// same wire numbers, so the checksum stays comparable across that change. +trait GateBits { + fn bits_for_gate(self) -> u8; +} + +impl GateBits for LeapIndicator { + fn bits_for_gate(self) -> u8 { + match self { + LeapIndicator::NoWarning => 0, + LeapIndicator::LastMinute61 => 1, + LeapIndicator::LastMinute59 => 2, + LeapIndicator::Unsynchronized => 3, + } + } +} + +impl GateBits for Mode { + fn bits_for_gate(self) -> u8 { + match self { + Mode::Reserved => 0, + Mode::SymmetricActive => 1, + Mode::SymmetricPassive => 2, + Mode::Client => 3, + Mode::Server => 4, + Mode::Broadcast => 5, + Mode::Control => 6, + Mode::Private => 7, + } + } +} diff --git a/crates/rusty_time-core/benches/hot_path.rs b/crates/rusty_time-core/benches/hot_path.rs index df09867..604b779 100644 --- a/crates/rusty_time-core/benches/hot_path.rs +++ b/crates/rusty_time-core/benches/hot_path.rs @@ -94,7 +94,26 @@ fn main() { global_rate_hz: 0.0, global_burst: 1e9, }; - let mut table: ClientTable = ClientTable::new(CLIENTS as usize * 2, config); + // Two arms. The default sizes the table at 2x the client population, so it + // never evicts -- which is the steady state a real server spends its life + // in, and the right arm for pricing the admit path. + // + // `HOT_PATH_EVICT` sizes it at a QUARTER of the population instead, so the + // recency list churns and `evicted` is large. That arm exists because the + // default one reports `evicted 0`, and a harness that never removes a key + // cannot gate any change to the client index: tombstone accumulation and + // probe degradation are invisible to it. A fixture that never enters the + // code cannot measure a change to it. + // `is_ok()` would be true for an EMPTY value, and a harness that exports + // `HOT_PATH_EVICT=` to mean "off" would then silently run both arms in + // churn mode -- which is exactly what happened the first time. + let evict_arm = std::env::var("HOT_PATH_EVICT").is_ok_and(|v| !v.is_empty()); + let capacity = if evict_arm { + (CLIENTS as usize / 4).max(1) + } else { + CLIENTS as usize * 2 + }; + let mut table: ClientTable = ClientTable::new(capacity, config); let mut lcg = Lcg(0x0123_4567_89ab_cdef); let mut checksum: u64 = 0; @@ -189,6 +208,8 @@ fn main() { } let stats = table.stats; + println!("arm {}", if evict_arm { "evict" } else { "steady" }); + println!("capacity {capacity}"); println!("requests {requests}"); println!("answered {answered}"); println!("kod {kod}"); diff --git a/crates/rusty_time-core/src/client.rs b/crates/rusty_time-core/src/client.rs index 7057c6c..fa23f6b 100644 --- a/crates/rusty_time-core/src/client.rs +++ b/crates/rusty_time-core/src/client.rs @@ -218,7 +218,7 @@ impl MultiController { /// A source that is not steering still has to be scheduled, and the poll /// interval belongs to the loop rather than to any one source. pub fn poll_interval_s(&self) -> f64 { - (2.0f64).powi(self.discipline.poll_log2() as i32) + crate::discipline::exp2i(self.discipline.poll_log2() as i32) } pub fn samples(&self) -> usize { @@ -345,16 +345,26 @@ impl MultiController { /// /// Regression once it has enough spread; before that the lowest-delay /// single sample, which is the least contaminated reading available. + /// Single caller (`observe`), single callee (`regress`), and 37.2 Ir per + /// call of its own work: the edge census prices it as a pass-through frame. + /// `rusty-compiler-leverage` A3 — a single-caller function that is not + /// inlined pays a frame for nothing. + #[inline(always)] pub fn estimate(&mut self, index: usize, mono_now_s: f64) -> Estimate { - let samples = self.registers[index].len(); - match self.registers[index].regress(mono_now_s) { + // Bound ONCE. This was three separate `self.registers[index]` + // expressions -- three bounds checks and three address computations for + // one register (`rusty-curiosity`: bind the thing once and use it + // twice, rather than re-deriving the target). + let reg = &mut self.registers[index]; + let samples = reg.len(); + match reg.regress(mono_now_s) { Some(e) => Estimate { offset_s: e.offset, freq_ppm: e.freq_ppm, sd_s: e.offset_sd.max(1e-7), samples, }, - None => match self.registers[index].best() { + None => match reg.best() { Some(best) => Estimate { offset_s: best.offset, freq_ppm: None, diff --git a/crates/rusty_time-core/src/discipline.rs b/crates/rusty_time-core/src/discipline.rs index 2824b8c..3c69de3 100644 --- a/crates/rusty_time-core/src/discipline.rs +++ b/crates/rusty_time-core/src/discipline.rs @@ -151,6 +151,28 @@ pub struct DisciplineConfig { pub corr_time_ratio: f64, } +/// `2^k` for an integer exponent, exactly, without a libm call. +/// +/// `f64::powi` lowers to `__powidf2`, a real `call` into compiler-builtins — +/// 16,000 of them in one `client_path` run, ~688k Ir, for a value every caller +/// here asks for with a small integer exponent. A power of two is exactly +/// representable in IEEE-754, so the answer is just the exponent field, and the +/// result is bit-identical to `powi` across the normal range. +/// +/// `server.rs` already hoisted its own `powi` off the request path for the same +/// reason; these are the sites that fix never reached. +#[inline] +pub(crate) fn exp2i(k: i32) -> f64 { + if (-1022..=1023).contains(&k) { + // Biased exponent in the high bits, zero mantissa. + f64::from_bits(((k + 1023) as u64) << 52) + } else { + // Subnormal or overflow: hand it back to the slow path rather than + // open-coding the gradual-underflow rules. + 2f64.powi(k) + } +} + impl Default for DisciplineConfig { fn default() -> Self { DisciplineConfig { @@ -845,7 +867,7 @@ impl Discipline { if self.iburst_left > 0 { IBURST_SPACING_S } else { - 2f64.powi(self.poll as i32) + exp2i(self.poll as i32) } } @@ -856,7 +878,7 @@ impl Discipline { self.burst_used += 1; IBURST_SPACING_S } else { - 2f64.powi(self.poll as i32) + exp2i(self.poll as i32) } } } diff --git a/crates/rusty_time-core/src/filter.rs b/crates/rusty_time-core/src/filter.rs index a477a9a..7f9e908 100644 --- a/crates/rusty_time-core/src/filter.rs +++ b/crates/rusty_time-core/src/filter.rs @@ -570,18 +570,22 @@ impl SampleRegister { // Normalised to mean 1 so weight magnitudes stay comparable to the // un-weighted case (the `max(1e-6)` floor below is absolute). let density: Vec = if self.slope_density_weighting && n >= 3 { - let t: Vec = self.window().iter().map(|s| s.t).collect(); + // The window is already a slice of `Sample`; copying every `t` into + // a parallel `Vec` first was an allocation and a full pass to build + // a lookup that `w[i].t` answers directly. Pure redundancy, and the + // copy existed only to make the indexing read tidily. + let w = self.window(); let mut d = Vec::with_capacity(n); for i in 0..n { let lo = if i == 0 { - t[0] + w[0].t } else { - (t[i] + t[i - 1]) / 2.0 + (w[i].t + w[i - 1].t) / 2.0 }; let hi = if i + 1 == n { - t[n - 1] + w[n - 1].t } else { - (t[i] + t[i + 1]) / 2.0 + (w[i].t + w[i + 1].t) / 2.0 }; d.push((hi - lo).max(0.0)); } @@ -687,9 +691,16 @@ impl SampleRegister { // beat it. Starting from infinity meant any candidate won by // default and the window always narrowed — which is not adaptive, // it is just short. - let mut best = match wls_fit(&used) { - Some(f) if f.sxx > 0.0 && f.sw > 0.0 => se_offset(&used, &f), - _ => f64::INFINITY, + // `fit` IS `wls_fit(&used)` -- it was computed above and nothing + // since has touched `used`. Calling it again here re-walked the + // whole window twice (wls_fit is two passes) to arrive at a value + // already in hand: the most expensive helper in the hottest + // function in the crate, run for nothing, on every estimate that + // reaches the adaptive block. + let mut best = if fit.sxx > 0.0 && fit.sw > 0.0 { + se_offset(&used, &fit) + } else { + f64::INFINITY }; let mut best_len = used.len(); let mut len = MIN_ADAPTIVE; @@ -720,24 +731,38 @@ impl SampleRegister { // samples against a stale self-consistent history and lock the loop into // a constant error — TIMECORP S1 found exactly that failure before this // pass existed. + // A cursor, not four drains. `drain(..k)` memmoves the whole remainder, + // and this loop can fire up to four times — so the rows that survive + // were being shifted down again on every pass. Advancing `lo` and + // reclaiming ONCE at the end moves the same bytes a single time. + // + // This is the fix `SampleRegister` already uses for `samples`/`weights` + // (the `head` cursor at the top of this file, documented there as "the + // same total movement spread over sixty-four times fewer operations"); + // the trim loop never got it. + let mut lo = 0usize; for _ in 0..4 { - if used.len() < 8 { + let win = &used[lo..]; + if win.len() < 8 { break; } - if residuals_well_mixed(&used, &fit) { + if residuals_well_mixed(win, &fit) { break; // residuals look well mixed: one regime } - let (half_gap, mad, _) = residual_half_gap_and_mad(&used, &fit); + let (half_gap, mad, _) = residual_half_gap_and_mad(win, &fit); if half_gap <= 3.0 * (1.4826 * mad).max(1e-9) { break; // a lone spike, not a regime change — pass 2's job } - let drop = (used.len() / 4).max(2); - used.drain(..drop); - match wls_fit(&used) { + let drop = (win.len() / 4).max(2); + lo += drop; + match wls_fit(&used[lo..]) { Some(refit) => fit = refit, None => break, } } + if lo > 0 { + used.drain(..lo); + } // Pass 2 — interior spike trim (a delayed packet), thresholded on the // robust MAD scale (an rms threshold is inflated by the spike itself and @@ -748,20 +773,35 @@ impl SampleRegister { // large majority of estimates on a converged loop — the threshold is // about three sigma. Returning the answer rather than the median is // what lets that common case skip the selection entirely. - if let Some(threshold) = spike_threshold(&used, &fit) { + if let Some((threshold, mask)) = spike_keep_mask(&used, &fit) { let protect_from = used.len() - 3; let mut kept = std::mem::take(&mut self.rows_alt); kept.clear(); - kept.extend( - used.iter() - .copied() - .enumerate() - .filter(|(i, r)| { - *i >= protect_from - || (r.offset - (fit.a + fit.b * (r.t - fit.t0))).abs() <= threshold - }) - .map(|(_, r)| r), - ); + match mask { + // The mask was built from the |residual|s the threshold was + // derived from, so the decision is byte-identical -- and it + // costs a shift and a test per row instead of a subtract, a + // multiply, an add and an abs. + Some(keep) => kept.extend( + used.iter() + .copied() + .enumerate() + .filter(|(i, _)| keep & (1u64 << *i) != 0) + .map(|(_, r)| r), + ), + // Windows over 64 rows keep the original walk. + None => kept.extend( + used.iter() + .copied() + .enumerate() + .filter(|(i, r)| { + *i >= protect_from + || (r.offset - (fit.a + fit.b * (r.t - fit.t0))).abs() + <= threshold + }) + .map(|(_, r)| r), + ), + } if kept.len() >= 3 && kept.len() < used.len() && let Some(refit) = wls_fit(&kept) @@ -815,9 +855,15 @@ impl SampleRegister { let sharp_floor = if self.offset_weight_dispersion_k > 0.0 { // Median excess delay: the path's own noise scale, robust to // the long tail that a mean would follow. + // A full sort to read the middle element orders every other + // element for nothing. `select_nth_unstable_by` places exactly + // the k-th element at k, so the median is the SAME `f64` -- + // byte-identical -- for an O(n) partition instead of an + // O(n log n) driftsort instantiation. let mut d: Vec = used.iter().map(|r| r.delay).collect(); - d.sort_by(f64::total_cmp); - let median = d[d.len() / 2]; + let mid = d.len() / 2; + d.select_nth_unstable_by(mid, f64::total_cmp); + let median = d[mid]; (self.offset_weight_dispersion_k * (median - min_delay)) .max(min_delay * 1e-4) .max(1e-9) @@ -904,6 +950,14 @@ impl SampleRegister { /// is paid on every element of every pass; the allocation is paid once per /// estimate and glibc serves it from a hot bin. Settled, and not to be /// re-litigated without a fourth reason. +/// +/// A FOURTH attempt was made (2026-10-08) on the sibling site in +/// `spike_threshold`, because removing a duplicate `wls_fit` had just moved +/// 17.9M Ir and with it every inlining boundary here — which +/// `instruction-counting` §9 says is exactly when to re-test a refutation. +/// Pooling that buffer through the register measured **+1,428,602 Ir**, inside +/// the same band as the first three. Four measurements, one sign: the shape +/// loses on this code. Reverted. /// Inlined on purpose. It is called from two places in one function, and /// letting it inline lets the residual buffer live in the caller's frame and /// its loops merge with the surrounding code — measured 1.7M Ir. @@ -990,15 +1044,9 @@ fn residual_half_gap_and_mad(samples: &[Row], fit: &Fit) -> (f64, f64, f64) { /// /// The median is then selected only on the rare path that is going to use it, /// so the threshold handed back is bit-identical to the old one. +/// The threshold decision, over |residual|s the caller already has. #[inline(always)] -fn spike_threshold(samples: &[Row], fit: &Fit) -> Option { - let n = samples.len(); - if n == 0 { - return None; - } - let resid = |r: &Row| r.offset - (fit.a + fit.b * (r.t - fit.t0)); - let mut abs: Vec = samples.iter().map(|r| resid(r).abs()).collect(); - +fn spike_threshold_from(abs: &[f64], n: usize) -> Option { // The three newest are protected and can never be trimmed, so they must not // decide whether trimming is worth attempting. let droppable = n.saturating_sub(3); @@ -1016,7 +1064,7 @@ fn spike_threshold(samples: &[Row], fit: &Fit) -> Option { let mid = n / 2; let mut below = 0usize; - for v in &abs { + for v in abs.iter() { if 3.0 * 1.4826 * *v < worst { below += 1; } @@ -1025,8 +1073,51 @@ fn spike_threshold(samples: &[Row], fit: &Fit) -> Option { return None; } - abs.select_nth_unstable_by_key(mid, |v| v.to_bits()); - Some((3.0 * 1.4826 * abs[mid]).max(1e-9)) + // The selection PERMUTES what it is given, and the caller builds its keep + // mask by sample position -- so `abs` must stay in sample order. Select on + // a copy. Both early returns above fire on the large majority of estimates, + // so this copy is paid only where the median is actually needed. + let mut pick: Vec = abs.to_vec(); + pick.select_nth_unstable_by_key(mid, |v| v.to_bits()); + Some((3.0 * 1.4826 * pick[mid]).max(1e-9)) +} + +/// `spike_threshold`, plus the keep-mask it can produce for free. +/// +/// The threshold is derived from |residual| for every row; the caller then +/// compared every row's residual against it, recomputing all of them -- 5.6M Ir +/// on one line. Handing the BUFFER back was refuted twice (+629,281 indexed, +/// +1,331,400 zipped): carrying a second slice through the walk costs more than +/// the arithmetic it saves. A `u64` mask is the cheapest possible carrier -- +/// one register, no allocation, no indirection -- and turns the filter into a +/// bit test. +/// +/// `None` mask when the window exceeds 64 rows; the caller keeps its old path +/// there, so behaviour is unchanged at any capacity. +#[inline(always)] +fn spike_keep_mask(samples: &[Row], fit: &Fit) -> Option<(f64, Option)> { + let n = samples.len(); + if n == 0 { + return None; + } + let abs: Vec = samples.iter().map(|r| spike_resid_abs(r, fit)).collect(); + let threshold = spike_threshold_from(&abs, n)?; + if n > 64 { + return Some((threshold, None)); + } + let protect_from = n - 3; + let mut keep = 0u64; + for (i, v) in abs.iter().enumerate() { + if i >= protect_from || *v <= threshold { + keep |= 1u64 << i; + } + } + Some((threshold, Some(keep))) +} + +#[inline(always)] +fn spike_resid_abs(r: &Row, fit: &Fit) -> f64 { + (r.offset - (fit.a + fit.b * (r.t - fit.t0))).abs() } /// Whether the residuals are well mixed — that is, whether the number of runs @@ -1119,6 +1210,12 @@ fn wls_fit(samples: &[Row]) -> Option { /// compute a number that the next trim immediately discarded. It is computed /// once now, on whichever fit survives, from the same inputs and in the same /// order, so the value is bit-identical. +/// `#[inline(always)]` here measured **+0 applied alone** and **+0 in the +/// combined tree** (194,200,222 -> 194,200,218, four instructions, inside the +/// 6-14 rebuild band). One caller, 777 Ir per call, 15,720 calls -- the edge +/// census said it looked like a frame worth removing, and LLVM had already made +/// the same decision. Recorded so the probe is not repeated; left to the +/// compiler, because an attribute that buys nothing is still a constraint. fn residual_sd(samples: &[Row], fit: &Fit) -> f64 { let n = samples.len(); let mut sw = 0.0; @@ -1158,6 +1255,69 @@ mod tests { r } + /// The density-weighting path had NO test, which is how a rewrite of its + /// inner loop could pass a 217-test suite without exercising a line of it. + /// + /// It is off by default (`slope_density_weighting: false`), so the two + /// instruments read it as dead code and an Ir delta cannot speak for it. + /// This pins the rule the loop implements instead: each sample stands for + /// half the gap to either neighbour, so with the factors normalised to mean + /// 1 their sum is `n` and the UN-normalised spans sum to the window span. + #[test] + fn density_weighting_factors_span_the_window() { + // Deliberately uneven spacing -- even spacing makes every factor 1 and + // the test would pass on an implementation that ignored `t` entirely. + let pts = [ + (0.0, 1.0e-3, 0.010), + (1.0, 1.1e-3, 0.011), + (5.0, 0.9e-3, 0.010), + (6.0, 1.0e-3, 0.012), + (20.0, 1.2e-3, 0.011), + ]; + let n = pts.len(); + + // The rule, computed independently of the implementation. + let t: Vec = pts.iter().map(|p| p.0).collect(); + let mut want = Vec::with_capacity(n); + for i in 0..n { + let lo = if i == 0 { + t[0] + } else { + (t[i] + t[i - 1]) / 2.0 + }; + let hi = if i + 1 == n { + t[n - 1] + } else { + (t[i] + t[i + 1]) / 2.0 + }; + want.push((hi - lo).max(0.0)); + } + let span: f64 = want.iter().sum(); + assert!( + (span - (t[n - 1] - t[0])).abs() < 1e-12, + "the half-gap rule must tile the window exactly: {span} vs {}", + t[n - 1] - t[0] + ); + + // And the path must actually RUN and produce a finite estimate -- the + // point of the test is that this branch is entered at all. + let mut r = reg_with(&pts); + r.set_slope_density_weighting(true); + let est = r.regress(21.0).expect("five samples must regress"); + assert!( + est.offset.is_finite() && est.offset_sd.is_finite(), + "density-weighted estimate must be finite, got {est:?}" + ); + + // Same inputs with the feature off must also regress; the two need not + // agree (that is the whole point of the weighting), but neither may + // produce a non-finite number. + let mut plain = reg_with(&pts); + plain.set_slope_density_weighting(false); + let flat = plain.regress(21.0).expect("five samples must regress"); + assert!(flat.offset.is_finite()); + } + #[test] fn exact_line_is_recovered() { // offset = 1 ms + 10 ppm * t, no noise. diff --git a/crates/rusty_time-core/src/ntp.rs b/crates/rusty_time-core/src/ntp.rs index 2bd6aaf..8a6758b 100644 --- a/crates/rusty_time-core/src/ntp.rs +++ b/crates/rusty_time-core/src/ntp.rs @@ -67,15 +67,26 @@ impl NtpShort { } } +/// `#[repr(u8)]` with the RFC 5905 wire values as discriminants, so the +/// round-trip to and from the wire is a cast in one direction and a total table +/// lookup in the other. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] pub enum LeapIndicator { - NoWarning, - LastMinute61, - LastMinute59, - Unsynchronized, + NoWarning = 0, + LastMinute61 = 1, + LastMinute59 = 2, + Unsynchronized = 3, } impl LeapIndicator { + /// A 4-entry lookup table here measured **+2,048,014 Ir (+4.79%)** on the + /// codec instrument together with `Mode`'s (checksum unchanged, work parity + /// exact). LLVM already compiles this ladder to arithmetic — the + /// discriminants ARE the wire values — so the table only added a real + /// memory load. `rusty-compiler-leverage` B5 predicts exactly this, and the + /// magnitude matched its recorded bitmask refutation. Left as a match. + #[inline(always)] fn from_bits(b: u8) -> Self { match b & 0b11 { 0 => LeapIndicator::NoWarning, @@ -85,29 +96,33 @@ impl LeapIndicator { } } + /// `#[repr(u8)]` with the wire values as discriminants makes the inverse a + /// cast rather than a second match ladder. + #[inline(always)] fn bits(self) -> u8 { - match self { - LeapIndicator::NoWarning => 0, - LeapIndicator::LastMinute61 => 1, - LeapIndicator::LastMinute59 => 2, - LeapIndicator::Unsynchronized => 3, - } + self as u8 } } +/// `#[repr(u8)]` with the RFC 5905 wire values as discriminants. All eight +/// 3-bit values are defined, so the wire mapping is total in both directions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] pub enum Mode { - Reserved, - SymmetricActive, - SymmetricPassive, - Client, - Server, - Broadcast, - Control, - Private, + Reserved = 0, + SymmetricActive = 1, + SymmetricPassive = 2, + Client = 3, + Server = 4, + Broadcast = 5, + Control = 6, + Private = 7, } impl Mode { + /// See [`LeapIndicator::from_bits`] — the table form of this lost together + /// with that one at +2,048,014 Ir. Left as a match. + #[inline(always)] fn from_bits(b: u8) -> Self { match b & 0b111 { 1 => Mode::SymmetricActive, @@ -121,17 +136,9 @@ impl Mode { } } + #[inline(always)] fn bits(self) -> u8 { - match self { - Mode::Reserved => 0, - Mode::SymmetricActive => 1, - Mode::SymmetricPassive => 2, - Mode::Client => 3, - Mode::Server => 4, - Mode::Broadcast => 5, - Mode::Control => 6, - Mode::Private => 7, - } + self as u8 } } @@ -176,20 +183,37 @@ pub struct NtpPacket { pub transmit_ts: NtpTimestamp, } -fn read_u32(buf: &[u8], at: usize) -> u32 { - let mut b = [0u8; 4]; - if let Some(s) = buf.get(at..at + 4) { - b.copy_from_slice(s); - } - u32::from_be_bytes(b) +/// Read a big-endian `u32` from a FIXED offset in the fixed header. +/// +/// The offset is a const generic and the argument is `&[u8; HEADER_LEN]`, not +/// `&[u8]`, so every index below is provably inside the array at compile time: +/// no bounds check, no `Option`, and no zero-initialised temporary. +/// +/// The previous form took `(&[u8], usize)` and could prove none of that, so for +/// each of the six fields it zeroed a stack array, did a checked `get()`, +/// branched on the `Option`, and called `copy_from_slice` — a real `memcpy` for +/// four or eight bytes. `parse` establishes `buf.len() >= HEADER_LEN` before any +/// of them run, so all of that was re-proving a fact the caller already held +/// (`rusty-compiler-leverage` B1: hand LLVM the bound relation it cannot +/// derive; and the same shape as the `peek_window` fix in rusty_aac). +#[inline(always)] +fn be_u32(h: &[u8; HEADER_LEN]) -> u32 { + u32::from_be_bytes([h[AT], h[AT + 1], h[AT + 2], h[AT + 3]]) } -fn read_u64(buf: &[u8], at: usize) -> u64 { - let mut b = [0u8; 8]; - if let Some(s) = buf.get(at..at + 8) { - b.copy_from_slice(s); - } - u64::from_be_bytes(b) +/// Big-endian `u64` at a fixed offset. See [`be_u32`]. +#[inline(always)] +fn be_u64(h: &[u8; HEADER_LEN]) -> u64 { + u64::from_be_bytes([ + h[AT], + h[AT + 1], + h[AT + 2], + h[AT + 3], + h[AT + 4], + h[AT + 5], + h[AT + 6], + h[AT + 7], + ]) } impl NtpPacket { @@ -217,30 +241,35 @@ impl NtpPacket { /// Parse the 48-byte header. Trailing bytes (extension fields / legacy MAC) are /// left to [`extension_fields`]. pub fn parse(buf: &[u8]) -> Result { - if buf.len() < HEADER_LEN { + // One bound check for the whole header, and it yields a `&[u8; 48]`. + // + // `first_chunk` is the safe, zero-copy way to turn "this slice is long + // enough" into a type that SAYS so, which is what lets every field read + // below compile to a plain load. The old form checked the length, threw + // the proof away, and then re-derived it six times inside `read_u32` / + // `read_u64` plus once for each of the five direct `buf[..]` indexes. + let Some(h) = buf.first_chunk::() else { return Err(ParseError::TooShort { len: buf.len() }); - } - let b0 = buf[0]; + }; + let b0 = h[0]; let version = (b0 >> 3) & 0b111; if !(3..=4).contains(&version) { return Err(ParseError::BadVersion { version }); } - let mut reference_id = [0u8; 4]; - reference_id.copy_from_slice(&buf[12..16]); Ok(NtpPacket { leap: LeapIndicator::from_bits(b0 >> 6), version, mode: Mode::from_bits(b0), - stratum: buf[1], - poll: buf[2] as i8, - precision: buf[3] as i8, - root_delay: NtpShort(read_u32(buf, 4)), - root_dispersion: NtpShort(read_u32(buf, 8)), - reference_id, - reference_ts: NtpTimestamp(read_u64(buf, 16)), - origin_ts: NtpTimestamp(read_u64(buf, 24)), - receive_ts: NtpTimestamp(read_u64(buf, 32)), - transmit_ts: NtpTimestamp(read_u64(buf, 40)), + stratum: h[1], + poll: h[2] as i8, + precision: h[3] as i8, + root_delay: NtpShort(be_u32::<4>(h)), + root_dispersion: NtpShort(be_u32::<8>(h)), + reference_id: [h[12], h[13], h[14], h[15]], + reference_ts: NtpTimestamp(be_u64::<16>(h)), + origin_ts: NtpTimestamp(be_u64::<24>(h)), + receive_ts: NtpTimestamp(be_u64::<32>(h)), + transmit_ts: NtpTimestamp(be_u64::<40>(h)), }) } diff --git a/crates/rusty_time-core/src/refclock.rs b/crates/rusty_time-core/src/refclock.rs index 312c4c4..fc75843 100644 --- a/crates/rusty_time-core/src/refclock.rs +++ b/crates/rusty_time-core/src/refclock.rs @@ -132,7 +132,7 @@ impl RefclockSample { /// The dispersion this sample implies, from the reference's own precision /// claim. pub fn dispersion_s(&self) -> f64 { - 2f64.powi(self.precision_log2 as i32) + crate::discipline::exp2i(self.precision_log2 as i32) } } diff --git a/crates/rusty_time-core/src/server.rs b/crates/rusty_time-core/src/server.rs index 564d878..76e69a2 100644 --- a/crates/rusty_time-core/src/server.rs +++ b/crates/rusty_time-core/src/server.rs @@ -9,7 +9,6 @@ //! product type). The daemon instantiates it with the peer address. use crate::ntp::NtpTimestamp; -use std::collections::HashMap; use std::hash::{Hash, Hasher}; /// Rate-limit policy, mirroring chrony's `ratelimit interval/burst/leak`. @@ -119,15 +118,25 @@ pub struct ClientRecord { pub dropped: u64, /// Dropped requests since the last Kiss-o'-Death, for deterministic leak. drops_since_kod: u32, - /// T2 of the last request we accepted. - pub last_receive: Option, + /// T2 of the last request we accepted. [`NtpTimestamp::ZERO`] until there + /// has been one. + /// + /// Zero rather than `Option` deliberately: `NtpTimestamp` is a bare `u64` + /// with no niche, so `Option` is SIXTEEN bytes for eight + /// bytes of payload and every read tests a discriminant. An all-zero + /// timestamp is already how RFC 5905 spells "unset" on the wire, so the + /// sentinel is the protocol's own, not one invented here + /// (`rusty-compiler-leverage` B5: the one win in that table was a + /// representation change, not a bit trick). + pub last_receive: NtpTimestamp, /// The true transmit timestamp of our last response, once the driver - /// reports it. `None` until then, which is why interleaved mode cannot - /// answer the very first request. - pub last_transmit: Option, + /// reports it. [`NtpTimestamp::ZERO`] until then, which is why interleaved + /// mode cannot answer the very first request. + pub last_transmit: NtpTimestamp, /// The receive timestamp we put in our last response. A client asks for - /// interleaved mode by echoing exactly this. - pub last_receive_sent: Option, + /// interleaved mode by echoing exactly this. [`NtpTimestamp::ZERO`] until + /// we have answered once. + pub last_receive_sent: NtpTimestamp, /// Whether the last answered request was actually served interleaved. /// /// Distinct from "we *could* serve it interleaved": every client we have @@ -145,9 +154,9 @@ impl ClientRecord { responses: 0, dropped: 0, drops_since_kod: 0, - last_receive: None, - last_transmit: None, - last_receive_sent: None, + last_receive: NtpTimestamp::ZERO, + last_transmit: NtpTimestamp::ZERO, + last_receive_sent: NtpTimestamp::ZERO, interleaved_now: false, } } @@ -289,6 +298,185 @@ impl Hasher for ClientHasher { } } +/// An open-addressed index from client key to slot number. +/// +/// Replaces `HashMap`, measured on the churn arm +/// (`HOT_PATH_EVICT`): hashbrown was **13.7M Ir, 18.1% of that arm** against +/// 3.5M / 7.4% on the steady arm, which never removes a key and so priced the +/// vein at a quarter of its size. +/// +/// Four properties, all of them facts about what a client table IS rather than +/// cleverness: +/// +/// 1. **Fixed capacity, so no resize.** The table evicts its least recently +/// seen client instead of growing, so the bucket array is allocated once and +/// never rehashed. The whole load-factor/regrow path does not exist. +/// 2. **Backward-shift deletion, so no tombstones.** A removal walks the rest +/// of the probe run and pulls back any entry that may legally occupy the +/// hole. Tombstones are the one failure mode an open-addressed table has +/// under churn -- they accumulate until a probe degrades to a full scan -- +/// and this design has none. That is what the churn arm exists to check. +/// 3. **Load factor <= 0.25**, so probe runs stay short. Not a free knob: at +/// 0.5 this WON the steady arm (-789,020) and LOST under churn +/// (+7,455,786), because backward-shift deletion walks the probe run. At +/// 0.25 both arms win. +/// 4. **The bucket does not store the key.** It is already in +/// `slots[handle].key`, stored exactly once -- where `HashMap` had to keep +/// its own copy. A bucket is two `u32`s, so the lower load factor costs +/// almost nothing: the corpus priced the key-carrying version at +106 +/// bytes/client and this one at +2. +/// +/// The low 32 bits of the hash live in the bucket, which is what lets the key +/// stay out: a non-match is rejected on the fragment, so the dependent load +/// into `slots` happens on a true match or a 1-in-4-billion collision. +struct Index { + buckets: Box<[Bucket]>, + mask: usize, + len: usize, + hasher: ClientHashBuilder, +} + +/// `handle == EMPTY` is a free bucket. There is deliberately no tombstone. +#[derive(Clone, Copy)] +struct Bucket { + /// Low 32 bits of the hash. Only two things are ever asked of it -- reject + /// a non-match before touching the key, and recover the ideal bucket during + /// backward-shift deletion (`hash & mask`) -- and the low word answers + /// both, because the mask cannot approach 2^32. + hash: u32, + handle: u32, +} + +/// Sentinel for an empty bucket. Distinct from `NIL`'s role in the recency +/// list only by where it is used; the value is the same and both mean "none". +const EMPTY: u32 = u32::MAX; + +impl Index { + fn with_capacity(capacity: usize, hasher: ClientHashBuilder) -> Self { + let n = capacity.max(1).saturating_mul(4).next_power_of_two(); + // Compare the MASK, not the count. `(u32::MAX as usize) + 1` overflows + // at compile time on a 32-bit `usize` -- wasm32 is a shipping target -- + // and `arithmetic_overflow` is deny-by-default, so it is a build error + // rather than a runtime one. Phrased against `n - 1` it is always true + // on a 32-bit target (nothing can exceed `usize::MAX` buckets) and a + // real bound on a 64-bit one. + assert!( + n - 1 <= u32::MAX as usize, + "bucket count exceeds the 32-bit hash word" + ); + Index { + buckets: vec![ + Bucket { + hash: 0, + handle: EMPTY + }; + n + ] + .into_boxed_slice(), + mask: n - 1, + len: 0, + hasher, + } + } + + #[inline] + fn hash_of(&self, key: &K) -> u32 { + use std::hash::BuildHasher as _; + // The finaliser avalanches high bits into low, so the low word + // discriminates as well as any other 32 bits of it. + self.hasher.hash_one(key) as u32 + } + + /// Bucket holding `key`, or `None`. + /// + /// `i & self.mask` is provably in range for any `i` because `x & m <= m` + /// and the array is `m + 1` long, so every bucket read here loses its + /// bounds check without a line of `unsafe`. + #[inline] + fn probe(&self, hash: u32, key: &K, slots: &[Slot]) -> Option { + let mut i = (hash as usize) & self.mask; + loop { + let b = self.buckets[i]; + if b.handle == EMPTY { + return None; + } + if b.hash == hash + && slots + .get(b.handle as usize) + .is_some_and(|sl| sl.key == *key) + { + return Some(i); + } + i = (i + 1) & self.mask; + } + } + + #[inline] + fn get(&self, key: &K, slots: &[Slot]) -> Option { + let h = self.hash_of(key); + let i = self.probe(h, key, slots)?; + Some(self.buckets[i].handle) + } + + fn insert(&mut self, key: &K, handle: u32, slots: &[Slot]) { + let hash = self.hash_of(key); + if let Some(i) = self.probe(hash, key, slots) { + self.buckets[i].handle = handle; + return; + } + let mut i = (hash as usize) & self.mask; + while self.buckets[i].handle != EMPTY { + i = (i + 1) & self.mask; + } + self.buckets[i] = Bucket { hash, handle }; + self.len += 1; + } + + fn remove(&mut self, key: &K, slots: &[Slot]) { + let hash = self.hash_of(key); + let Some(mut hole) = self.probe(hash, key, slots) else { + return; + }; + self.buckets[hole].handle = EMPTY; + self.len -= 1; + + // Backward-shift deletion. Walk the remainder of the probe run and move + // an entry back into the hole whenever the hole lies between that + // entry's ideal bucket and where it sits. That preserves the only + // invariant `probe` needs -- every key reachable by a forward scan from + // its own ideal bucket -- which is why no tombstone is required. + // + // Distances are measured forward from `ideal` and masked, so wrapping + // needs no signed comparison. + let mut i = (hole + 1) & self.mask; + loop { + let b = self.buckets[i]; + if b.handle == EMPTY { + break; + } + let ideal = (b.hash as usize) & self.mask; + let to_hole = hole.wrapping_sub(ideal) & self.mask; + let to_here = i.wrapping_sub(ideal) & self.mask; + if to_hole < to_here { + self.buckets[hole] = b; + self.buckets[i].handle = EMPTY; + hole = i; + } + i = (i + 1) & self.mask; + } + } + + #[inline] + fn len(&self) -> usize { + self.len + } + + #[inline] + fn is_empty(&self) -> bool { + self.len == 0 + } +} + /// One client's storage: its key, its record, and its place in the recency /// list. Slots are stable — an index handed out stays valid until the client /// is evicted — which is what makes the list links safe as plain integers. @@ -348,7 +536,7 @@ impl ClientHandle { pub struct ClientTable { /// Key to slot. The only hashed structure, and the reason a request costs /// one hash instead of several. - index: HashMap, + index: Index, /// Records in stable storage. Slots are handed out from `free` and never /// move, which is what lets the ordering below be pointers rather than /// comparisons. @@ -377,7 +565,7 @@ pub struct ClientTable { impl ClientTable { pub fn new(capacity: usize, config: RateLimitConfig) -> Self { ClientTable { - index: HashMap::with_capacity_and_hasher(capacity.max(1), ClientHashBuilder::default()), + index: Index::with_capacity(capacity.max(1), ClientHashBuilder::default()), slots: Vec::with_capacity(capacity.max(1)), free: Vec::new(), mru: NIL, @@ -451,10 +639,13 @@ impl ClientTable { /// alone, which stopped being the whole story the moment records moved /// into slots. pub fn bytes_per_client() -> usize { - core::mem::size_of::>() - + core::mem::size_of::() // the index's own copy of the key - + core::mem::size_of::() // the slot number it maps to - + 1 // hashbrown's control byte + // The index is open-addressed at a load factor of 0.25, so it holds + // FOUR buckets per client, not one entry. That is the honest figure and + // it is a real cost: the lower load factor is what makes backward-shift + // deletion cheap (measured -6.45% Ir on the churn arm against 0.5), and + // it is paid in memory. A caller sizing a table for a large population + // needs to see it here rather than discover it in RSS. + core::mem::size_of::>() + 4 * core::mem::size_of::() } pub fn len(&self) -> usize { @@ -466,7 +657,7 @@ impl ClientTable { } pub fn get(&self, key: &K) -> Option<&ClientRecord> { - let i = *self.index.get(key)?; + let i = self.index.get(key, &self.slots)?; Some(&self.slots[i as usize].record) } @@ -499,8 +690,10 @@ impl ClientTable { return; } self.unlink(victim); - let key = self.slots[victim as usize].key.clone(); - self.index.remove(&key); + let ClientTable { index, slots, .. } = self; + // No clone: `index` is borrowed mutably and `slots` immutably, and they + // are different fields, so destructuring `self` lets both live at once. + index.remove(&slots[victim as usize].key, slots); self.free.push(victim); self.stats.evicted += 1; } @@ -587,8 +780,8 @@ impl ClientTable { // client we have never seen. The previous form hashed three times per // request: `contains_key`, then `touch`'s `get_mut`, then a final // `get_mut` to reach the record. - let slot = match self.index.get(key) { - Some(&i) => { + let slot = match self.index.get(key, &self.slots) { + Some(i) => { self.touch(i); i } @@ -597,7 +790,8 @@ impl ClientTable { self.evict_one(); } let i = self.alloc_slot(key.clone(), ClientRecord::new(now, self.config.burst)); - self.index.insert(key.clone(), i); + let ClientTable { index, slots, .. } = self; + index.insert(key, i, slots); i } }; @@ -646,8 +840,8 @@ impl ClientTable { /// timestamp. That is unforgeable in the useful sense: only a client that /// actually saw our last response knows it. pub fn response_mode(&mut self, key: &K, request_origin: NtpTimestamp) -> ResponseMode { - match self.index.get(key) { - Some(&i) => { + match self.index.get(key, &self.slots) { + Some(i) => { let handle = self.handle_for(i); self.response_mode_at(handle, request_origin) } @@ -669,11 +863,15 @@ impl ClientTable { return ResponseMode::Basic; }; let record = &mut self.slots[i].record; - let (Some(sent_receive), Some(prev_transmit)) = - (record.last_receive_sent, record.last_transmit) - else { + let (sent_receive, prev_transmit) = (record.last_receive_sent, record.last_transmit); + // "Unset" is zero for both. Note the zero-origin rejection below is + // INDEPENDENT of this test and was already there: a client echoing an + // all-zero origin cannot match an unset record even if this guard were + // removed, which is what makes the sentinel safe here rather than + // merely convenient. + if sent_receive.is_zero() || prev_transmit.is_zero() { return ResponseMode::Basic; - }; + } // The client names a specific earlier response by echoing the receive // timestamp we reported for it. We keep one slot, so only the most // recent qualifies; anything older falls back to basic rather than @@ -690,7 +888,7 @@ impl ClientTable { /// Record what we received and what we told the client, after answering. pub fn note_response(&mut self, key: &K, receive: NtpTimestamp, receive_sent: NtpTimestamp) { - if let Some(&i) = self.index.get(key) { + if let Some(i) = self.index.get(key, &self.slots) { let handle = self.handle_for(i); self.note_response_at(handle, receive, receive_sent); } @@ -705,8 +903,8 @@ impl ClientTable { ) { if let Some(i) = self.resolve(handle) { let record = &mut self.slots[i].record; - record.last_receive = Some(receive); - record.last_receive_sent = Some(receive_sent); + record.last_receive = receive; + record.last_receive_sent = receive_sent; } } @@ -715,8 +913,8 @@ impl ClientTable { /// timestamp the basic exchange cannot report because the packet has not /// left yet when its own transmit field is written. pub fn note_transmit(&mut self, key: &K, transmit: NtpTimestamp) { - if let Some(&i) = self.index.get(key) { - self.slots[i as usize].record.last_transmit = Some(transmit); + if let Some(i) = self.index.get(key, &self.slots) { + self.slots[i as usize].record.last_transmit = transmit; } } @@ -728,7 +926,7 @@ impl ClientTable { /// on whoever inherited the slot. pub fn note_transmit_at(&mut self, handle: ClientHandle, transmit: NtpTimestamp) { if let Some(i) = self.resolve(handle) { - self.slots[i].record.last_transmit = Some(transmit); + self.slots[i].record.last_transmit = transmit; } } diff --git a/crates/rusty_time-ctl/CHANGELOG.md b/crates/rusty_time-ctl/CHANGELOG.md index f7b5710..4084b08 100644 --- a/crates/rusty_time-ctl/CHANGELOG.md +++ b/crates/rusty_time-ctl/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/crates/rusty_time-daemon/CHANGELOG.md b/crates/rusty_time-daemon/CHANGELOG.md index f7b5710..4084b08 100644 --- a/crates/rusty_time-daemon/CHANGELOG.md +++ b/crates/rusty_time-daemon/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/crates/rusty_time-nts/CHANGELOG.md b/crates/rusty_time-nts/CHANGELOG.md index f7b5710..4084b08 100644 --- a/crates/rusty_time-nts/CHANGELOG.md +++ b/crates/rusty_time-nts/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/crates/rusty_time-sim/CHANGELOG.md b/crates/rusty_time-sim/CHANGELOG.md index f7b5710..4084b08 100644 --- a/crates/rusty_time-sim/CHANGELOG.md +++ b/crates/rusty_time-sim/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/crates/rusty_time-wasm/CHANGELOG.md b/crates/rusty_time-wasm/CHANGELOG.md index f7b5710..4084b08 100644 --- a/crates/rusty_time-wasm/CHANGELOG.md +++ b/crates/rusty_time-wasm/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-08 + +### Changed + +- Version moved to 0.2.1 with the rest of the workspace. **Nothing in this + crate's own API or behaviour changed.** The release is a performance pass in + `rusty_time-core`; see that crate's changelog. + ## [0.2.0] - 2026-09-09 ### Changed diff --git a/docs/plans/instruction-veins.md b/docs/plans/instruction-veins.md new file mode 100644 index 0000000..2f48f2f --- /dev/null +++ b/docs/plans/instruction-veins.md @@ -0,0 +1,855 @@ +# Twenty veins — deterministic instruction-count wins in rusty_time + +Opened 2026-10-08. Every number here is a **callgrind Ir count**, not a clock: +same binary, same input, same count to the instruction. That is what makes a +0.05% verdict possible on a shared box, and it is the only reason this list is +ranked rather than guessed. Governed by `instruction-counting`; anything that +becomes a wall-clock claim moves to `codec-measurement`'s rules instead. + +**What Ir cannot see:** cache misses, branch misprediction, memory bandwidth, +threading. It prices every instruction at 1. A vein below that is really a +locality problem will read as a small win here and a large one on a clock, or +the reverse. Where that risk is live, the row says so. + +## The instruments + +Three, already in the tree, built for exactly this (`harness = false`, a +deterministic LCG, a simulated clock, and a CHECKSUM line that is the +correctness gate): + +| bench | Ir | unit | per unit | what it drives | +|---|---:|---:|---:|---| +| `client_path` | 219,438,543 | 16,000 steps | **13,715** | filter + discipline: the client loop | +| `hot_path` | 47,426,865 | 200,000 requests | **237** | server: admit, rate-limit, reply | +| `mru_report` | 5,802,466 | 2,000 reports | 2,901 | the MRU status report | + +Reproduce (WSL; valgrind does not run on Windows, the Windows tree mounts at +`/mnt/f`, target dir must be on the Linux fs): + +```sh +cd /mnt/f/coding/rusty_time +CARGO_TARGET_DIR=$HOME/rt-target CARGO_PROFILE_RELEASE_DEBUG=2 \ + cargo build --release -p rusty_time-core --benches +valgrind --tool=callgrind --cache-sim=no --branch-sim=no \ + --callgrind-out-file=cg.out $HOME/rt-target/release/deps/client_path- +callgrind_annotate --auto=yes --threshold=99 cg.out +``` + +`CARGO_PROFILE_RELEASE_DEBUG=2` is load-bearing — `RUSTFLAGS="-C debuginfo=2"` +loses to the profile and you get `???:` for every line. Verify with +`readelf -S | grep debug_info` before trusting a line census. + +**Anchors held across the debuginfo rebuild** (checksums `0xc0452ab605328800`, +`f456d8bf8ff664d5`, `0x9dc2477057bf9530`), so the two builds are comparable. + +## The headline + +`SampleRegister::regress` is **78.94% of the client path — 173,220,826 Ir over +16,000 calls.** Two per-call figures, and they are different quantities: + +| per call | from | counts | +|---|---:|---| +| **10,826 Ir** | 173,220,826 / 16,000 (function census) | the lines attributed to `regress` itself | +| **12,627 Ir** | 202,038,502 / 16,000 (call-count census) | inclusive — plus `wls_fit`, `residual_sd` and the rest called from it | + +Quote the first when pricing a change inside `regress`, the second when pricing +the whole estimate. For a weighted least-squares fit over a +window of at most a few dozen `f64` rows, that is one to two orders of +magnitude more than the arithmetic requires. Everything in section A is inside +it. + +And the decomposition that matters most is not arithmetic at all: + +| inside `regress` | Ir | share of client path | +|---|---:|---:| +| `filter.rs` own lines | 104,618,223 | 47.68% | +| `core::ptr::non_null` | 11,390,296 | 5.19% | +| `core::slice::iter::macros` | 11,346,788 | 5.17% | +| `core::ptr::mod` | 10,825,534 | 4.93% | +| `core::slice::sort::unstable::quicksort` | 5,400,244 | 2.46% | +| `alloc::vec::mod` | 4,327,338 | 1.97% | +| `core::num::f64` | 4,225,236 | 1.93% | +| `core::cmp` | 3,513,559 | 1.60% | +| `core::ptr::mut_ptr` | 3,086,095 | 1.41% | + +**Pointer, iterator and Vec plumbing sums to 41.0M Ir — 18.7% of the whole +client path.** Sorting machinery is another ~11.4M (5.2%). + +--- + +## A. Inside `regress` — the client path (173.2M Ir, 78.94%) + +### A1. `wls_fit(&used)` is called twice on identical data — **free, byte-identical** + +`filter.rs:660` is `let mut fit = wls_fit(&used)?;`. `filter.rs:690` is +`let mut best = match wls_fit(&used) { ... }`. Nothing between them mutates +`used`, and `fit` already holds exactly that result. The second call is a pure +recompute of the most expensive helper in the hottest function in the codebase +(`codec-eliminate-redundancy` move #3). + +`wls_fit` is 11,553,772 Ir over the run. The adaptive block runs on every +estimate where `used.len() >= MIN_ADAPTIVE`, so this is close to one whole +redundant `wls_fit` per call. + +Fix: `let mut best = if fit.sxx > 0.0 && fit.sw > 0.0 { se_offset(&used, &fit) } else { f64::INFINITY };` + +Gate: **byte-identical** — same inputs, same function, same order. +(The duplicated "Seeded with the FULL window" comment at 668–670 and 687–689 is +the fingerprint of the edit that introduced it.) + +### A2. The residual is recomputed by four separate walkers + +`row.offset - (fit.a + fit.b * (row.t - fit.t0))` appears in `residual_sd` +(10,524,080 Ir), `residuals_well_mixed`, `residual_half_gap_and_mad` and +`spike_threshold`. Each walks the same rows and recomputes the same value for +the same fit. Up to four passes where one would do +(`codec-eliminate-redundancy` move #4). + +Fix: one pass that produces the residual slice (into the already-pooled +`rows_alt`-style scratch), consumers read it. + +Gate: byte-identical if the summation order per consumer is preserved. + +### A3. `wls_fit` is two passes where the algebra needs one + +```rust +for r in samples { sw += r.w; swt += r.w * r.t; } // pass 1 -> t0 +let t0 = swt / sw; +for r in samples { let x = r.t - t0; sxx += r.w*x*x; ... } // pass 2 +``` + +The centred moments are recoverable from uncentred ones in a single pass, since +`Σw·t = sw·t0`: + +- `sxx = Σw·t² − sw·t0²` +- `sxy = Σw·t·y − t0·Σw·y` + +Halves the walk on 11.55M Ir plus every nested call. + +Gate: **tolerance, NOT byte-identical.** Different summation order and a +subtraction of like-sized quantities — this is the one row here that can lose +precision, and on a clock-discipline filter that matters. Needs the scalar twin +as oracle plus a TIMECORP corpus run, not just an Ir delta. + +### A4. The `Row` round-trip is 41.0M Ir of plumbing (18.7%) + +`non_null` + `iter::macros` + `ptr::mod` + `vec::mod` + `mut_ptr`. `Row` is +four `f64` = 32 bytes, built per estimate by +`used.extend(window().iter().zip(cached).map(|(s,&w)| Row{..}))`, then walked +by up to a dozen passes. The annotated lines `t: s.t,` and `delay: s.delay,` +each carry 943,240 Ir. + +The AoS copy was a deliberate past win (the comment says so, and it bought +contiguity). The next move is the other direction: **SoA scratch slices** +(`t[]`, `offset[]`, `delay[]`, `w[]`) so each pass is a flat `f64` walk that +auto-vectorises, instead of a strided 32-byte gather. + +Gate: byte-identical per pass if order is preserved. Risk: this is a layout +change, which is exactly what Ir is blind to — price it on a clock too. + +### A5. ~11.4M Ir of generic sort machinery on a few dozen floats + +`quicksort` 5,400,244 + `cmp` 3,513,559 + `select.rs` 493,500 + `pivot::median3_rec` +(995,490 + 486,684 + 499,279). Driven by `spike_threshold`'s +`select_nth_unstable_by_key` and `residual_half_gap_and_mad`'s MAD. + +The static census agrees loudly: **44 of 79 guard branches in the whole crate +are in `core/src/slice/sort/select.rs`**, and the instantiated sort/select +symbols sum to ~6,260 static instructions — the largest single block of +emitted code in the crate after `regress` itself. + +Fix: a median/MAD for `n <= 64` is a sorting network or a partial selection +over a fixed array, not driftsort. Retiring the generic instantiation retires +its 44 guards with it. + +Gate: byte-identical — same selected element. + +### A6. `2f64.powi(k)` goes out to `__powidf2` — 16,000 calls, ~688K Ir + +`__powidf2` is called **16,000×** (400,000 Ir in the builtin macro + 288,000 in +`float/pow.rs`). Sites: `client.rs:221`, `discipline.rs:848`, `discipline.rs:859`, +`refclock.rs:135`. + +A power of two with an integer exponent is exactly representable: build it from +bits, or `f64::from_bits(((1023 + k) as u64) << 52)` with a range guard. + +**And this exact fix already exists in this repo.** `server.rs:390` carries the +comment *"recomputing it per request was a `powi` call on the hot path"* — it +was fixed there and never propagated. This is +`codec-eliminate-redundancy`'s "a fix found in one place is a HYPOTHESIS about +every place with the same shape", caught in the act. + +Gate: byte-identical (exact for powers of two). + +### A7. `used.drain(..)` front-drains in the trim passes + +`filter.rs:711` `used.drain(..used.len() - best_len)` and `filter.rs:735` +`used.drain(..drop)`. A front-drain memmoves the remainder — `codec-memory-copies` +§1, and it runs up to five times per estimate. + +**The fix is 270 lines up in the same file.** `SampleRegister` already solved +this for `samples`/`weights` with a `head` cursor and amortised compaction +(`filter.rs:446`), explicitly documented as "the same total movement spread over +sixty-four times fewer operations". `used` never got it. + +Gate: byte-identical. + +### A8. `log` from libm, 16,000 calls, 859,160 Ir + +One `log@@GLIBC_2.29` per step at 53.7 Ir. Small (0.39%) but exact and +mechanical if the argument range is narrow — see `rusty-fast-transcendentals` +for the range-reduction recipe and its accuracy gate. **Low priority; listed so +it is not rediscovered.** + +### A9. Hoist `fit.a − fit.b·fit.t0` out of every residual loop + +Every residual walk computes `fit.a + fit.b * (row.t - fit.t0)` — a subtract, +a multiply and an add per row. Pre-folding to `c = fit.a - fit.b*fit.t0` makes +the body `c + fit.b*row.t`: one FMA. Multiplies through A2's single pass. + +Gate: **tolerance** — reassociation. Same caveat as A3. + +### A10. `residuals_well_mixed` — 907,338 Ir on one predicate line + +`if runs * 3 >= need` carries 907,338 Ir and `runs += 1` another 297,276. The +loop is a sign-run count with a data-dependent branch per row. Branchless sign +extraction (`(r > 0.0) as i8 * 2 - 1`, or XOR of sign bits) removes the +mispredict and lets the loop vectorise +(`codec-eliminate-redundancy` move #5). + +Gate: byte-identical. Note the early `return true` means a branchless rewrite +must not lose the early-out — price it in calls, not static instructions +(`codec-measurement`: a fast path is priced in calls). + +### A11. `spike_threshold`'s per-call `Vec` — malloc 485,304 + free 534,556 + memcpy 297,659 + +`filter.rs:1000`: `let mut abs: Vec = samples.iter().map(|r| resid(r).abs()).collect();` +and `filter.rs:928` the same shape. ~1.3M Ir (0.6%) of allocator and memcpy +traffic per estimate. The register already pools `rows` and `rows_alt` via +`mem::take`; these two want the same treatment. + +Gate: byte-identical. + +### A12. `num/f64` 4,225,236 Ir + `uint_macros` 487,939 inside `regress` + +Not yet decomposed to a mechanism — `f64` method calls (`abs`, `sqrt`, +`total_cmp`, `max`, `min`) at 1.93%. `total_cmp` in particular is a bit-twiddling +sequence used as the sort comparator, and it is almost certainly most of `cmp`'s +3.5M too. **Needs one more line census before it is a vein rather than a +suspicion** — recorded as open, not as a plan. + +--- + +## B. The server path — `hot_path` (47.4M Ir, 237 Ir/request) + +### B13. `slice/index.rs` is 10.48% — 4,970,506 Ir of bounds checks + +`ClientTable` holds `slots: Vec>` addressed by `u32` handles, and the +intrusive MRU relink touches `self.slots[prev as usize]`, +`[next as usize]`, `[old as usize]`, `[i as usize]` — three or four +bounds-checked indexes per request. `prev`/`next` are read *out of `slots` +itself*, so LLVM can prove nothing about them. + +The crate is `deny(unsafe_code)`, so `get_unchecked` is barred — which is the +right constraint and points at the better fix anyway +(`codec-eliminate-redundancy` redundancy #2, "the bounds check that a mask +would retire"): size `slots` to a power of two and mask the handle, so the +index is *provably* in range with no `unsafe`. See also +`rusty-compiler-leverage` B1 on handing LLVM a bound relation it cannot derive. + +Gate: byte-identical. **This is the single largest server-path vein.** + +### B14. `num/uint_macros.rs` is 10.55% — 5,004,093 Ir + +Integer-method cost attributed into `hot_path::main`. Per +`instruction-counting` §5(d), `uint_macros` appearing with 10% means *our* +integer handling, not a slow stdlib. Candidates: the generation/handle packing, +the token-bucket arithmetic, checked conversions. **Needs the line census split +before it is actionable — listed because 10.55% cannot be left unnamed.** + +### B15. hashbrown lookup, 2,858,968 Ir (6.03%) + `sse2` 640,954 (1.35%) + +One `HashMap` probe per request, ~17 Ir/request. The +table already hashes once and then addresses by handle (a documented past win). +The remaining lever is the lookup itself: an open-addressed table keyed on the +already-computed hash, sized to a power of two, removes the generic hashbrown +probe and composes with B13's masking. + +Gate: byte-identical output; the eviction *order* must be preserved or the +anchors (`evicted`, `table_len`) move. + +### B16. The SplitMix64 finaliser — 1,799,991 Ir over three lines + +`z = (z ^ (z>>30)).wrapping_mul(...)`, `>>27`, `^ (z>>31)` at ~600K Ir each +(3.8% total, 9 Ir/request). Three full mixing rounds for what is often a +4-byte IPv4 key. One round plus a good multiplier is usually enough for an +open-addressed table of this size. + +Gate: changes bucket order ⇒ changes eviction order ⇒ **anchors move**. This +one needs its own correctness argument (distribution + no clustering), not just +an Ir delta. Weigh against B15, which may subsume it. + +### B17. `iter/range.rs` 1,800,014 Ir (3.80%) + +Range iteration inside the server loop. Not localised yet; likely the MRU walk +or a `0..n` over slots. Open. + +### B18. `ntp.rs` is 600,000 Ir — exactly 3 Ir/request. **Not a vein.** + +The packet codec, the thing one would reach for first, is **1.27%** of the +server path. Recorded explicitly so nobody optimises it: `parse`/`to_bytes` are +already at their floor, and the `no_std` leaf work (0.2.0) did not cost +anything measurable here. + +--- + +## C. The instrument itself, and the static census + +### C19. The `hot_path` harness is 13.92% of its own number — 6,600,072 Ir + +`IpAddr::V4(Ipv4Addr::from(...))` 1,000,000 Ir, `answered += 1` 1,000,000, +`self.0 >> 33` 400,000, the checksum 200,000, plus 1,000,006 unattributed in +the bench file. `codec-measurement` §6: the instrument is part of the system +under test. + +**Every share taken from `hot_path` is therefore diluted by ~14%** — the real +server-side shares are ~1.16× the printed ones. Fix the harness (hoist the +address construction into a precomputed table, drop the redundant counter) +before using it to price anything under ~2%. + +This is a measurement fix, not a product win, and it must land **first** or it +mis-sizes B13–B17. + +### C20. Eight callee-saved pushes on the hottest function + +Static asm census (68 of our symbols, 14,408 instructions, 79 guard branches): +the largest `filter::SampleRegister` symbol is **2,590 instructions, 8 pushes, +10 guards**. Eight pushes plus eight pops plus call/return is ~18 instructions +of frame, and per `rusty-compiler-leverage` A1/A2 that many on one function +means cold arms are inlined into it and have claimed its registers. + +Fix: identify the rare arms in `regress` (the density-weighting branch, the +`vec![1.0; n]` fallback, the error returns) and `#[cold] #[inline(never)]` them +**as a SET** — A2 is explicit that either arm left inline still demands the +registers, so bisecting the set reports the wrong sign. + +Gate: Ir, both instruments, and re-measure after any other inlining change. + +**Not a vein:** `config::parse` at 1,890 instructions / 8 pushes is the second +largest symbol in the crate and runs once at startup. Listed so its size does +not attract work. + +--- + +## Refutations, recorded with their numbers + +- **"The adaptive-window loop is O(n²)."** Wrong. `filter.rs:708` is `len *= 2`, + so it visits ~log₂(n/8) candidate lengths, not n. The loop is O(n log n) and + the per-call cost is not coming from there. Recorded because the hypothesis is + the obvious one on reading `while len < used.len()` and it would have sent a + campaign at the wrong line. +- **`ntp.rs` is not a hot path** — 3 Ir/request (B18). +- **`config::parse` is not a hot path** — startup only (C20). + +## Order to work in + +1. **C19 first** — fix the instrument, or every share below ~2% is wrong. +2. **A1, A6, A7** — byte-identical, mechanical, and A6/A7 are fixes that already + exist elsewhere in this repo and were never propagated. +3. **A2, A5, A11** — byte-identical, larger, need a scratch buffer each. +4. **B13, B15** — the server path's real levers; they compose (both want a + power-of-two table). +5. **A4, C20** — layout and frame work. Ir is blind to the first, so these need + a clock as well; `codec-measurement` governs from there. +6. **A3, A9, B16** last — they change float results or bucket order and need a + corpus gate, not an Ir delta. + +Per `instruction-counting` §7: one change per measurement, anchors printed +beside every number, and a refuted probe gets reverted **with its number +written down**. + +--- + +# RESULTS -- every vein mined, 2026-10-08 + +Instrument: callgrind Ir, WSL, `RUSTY_TIME_HASH_SEED=1` pinned. +**Ladder verified this session: same binary = 0 (exact), rebuild = 6-14.** +Gate on every row: all three checksums + every work-parity anchor + 217 tests ++ clippy `-D warnings` + fmt + the `no_std` leaf on both bare-metal targets. + +## Kept -- client_path 219,439,512 -> 197,467,368 = **-21,972,144 Ir (-10.01%)** + +| vein | change | Ir | note | +|---|---|---:|---| +| **A1** | drop the duplicate `wls_fit(&used)`; reuse `fit` | **-17,918,178** (-8.17%) | byte-identical. Accounting closes: `regress` -4.9M, `residual_sd` -10.5M, satellites -2.4M | +| **A5** | `sort_by` -> `select_nth_unstable_by` for a median | **-3,363,768** (-1.67%) | byte-identical, but a **codegen** win: the branch never executes (dispersion_k = 0 default), so this is a dead instantiation leaving the binary. Layout-dependent; can reverse | +| **A6** | `2f64.powi(k)` -> exact bit construction (`exp2i`) | **-690,184** (-0.34%) | byte-identical. 16,000 `__powidf2` calls gone. Predicted 688K, measured 690K | + +hot_path and mru_report unchanged -- all three wins are filter-side. + +## Refuted -- measured, reverted, numbers recorded + +| vein | probe | Ir | why it lost | +|---|---|---:|---| +| **A2** | reuse `spike_threshold`'s `abs` in the filter, indexed | **+629,281** | traded an FMA for a bounds-checked load. **And v1 broke the checksum** -- `select_nth_unstable_by_key` *permutes* the buffer, so `abs[i]` no longer matched `samples[i]`; fixed by selecting on a copy | +| **A2'** | same, zipped instead of indexed | **+1,331,400** | second probe, same sign. Carrying a second buffer costs more than the residual it saves | +| **A3** | one-pass `wls_fit` via uncentred moments | **-12,228,455** (-6.19%) | **REFUSED despite the win.** `plans` anchor moved 16029 -> 16025: the work changed. `sxx = sum(wt^2) - sw*t0^2` on clustered timestamps (`t0 ~ 1e9`, span ~1e2) loses ~14 of 16 digits, and `sxx` feeds the slope and the window criterion. A compensated/Welford form would add the ops back | +| **A9** | hoist `c = a - b*t0` out of the residual loop | **+1,754,122** | checksum *unchanged* => LLVM had already hoisted the loop-invariant | +| **A10** | branchless sign in `residuals_well_mixed` | **+584,293** | LLVM had already cmov'd it (`rusty-compiler-leverage` B5) | +| **A11** | pool `spike_threshold`'s `Vec` through the register | **+1,428,602** | **fourth** refutation of this shape; the source already recorded +3.4M/+2.4M/+1.1M. Re-tested because A1 moved an inlining boundary (section 9). Four measurements, one sign | +| **C19** | precompute the harness's address table | **+1,659,861** | the table lookup (load + check + 24-byte `IpAddr` copy) costs more than `Ipv4Addr::from`, which was already ~5 Ir | +| **C20** | outline `regress`'s two cold arms as a set | **+741,504** | **premise disproved**: instructions fell 2,590 -> 2,031 and guards 10 -> 4, but the **push count stayed at 8** -- the register pressure is intrinsic to the hot body, not the cold arms | + +**The "clever local rewrite" class went 0-for-6** (A2, A2', A9, A10, A11, C20). +That is exactly what `rusty-compiler-leverage` B5 predicts, now measured on this +codebase. The three wins were: delete a duplicate call, delete a dead +instantiation, and replace a libm call -- no cleverness in any of them. + +## Priced but not built -- ceilings, so nobody re-derives them + +| vein | ceiling | why not built | +|---|---:|---| +| **B13** bounds checks on `slots[..]` | **4,970,506 Ir (10.48% of hot_path)** | all cost attributed to `slice/index.rs` *is* the check machinery, so this is the honest upper bound. `deny(unsafe_code)` bars `get_unchecked`; the safe route (power-of-two capacity + mask) needs `slots.len()` provable to LLVM, which a `Vec` does not give. Largest unmined vein in the repo | +| **A4** `Row` AoS -> SoA | **41.0M Ir (18.7%)** of ptr/iter/Vec plumbing | the ceiling is large but Ir is **blind to locality**, which is most of what this change moves. Needs a clock as co-instrument; `codec-measurement` governs | +| **A7** `used.drain(..)` front-drains | **< ~298K (0.15%)** | bounded by total `__memcpy_avx` in the run. Pruned on arithmetic before building | +| **A8** libm `log`, 16,000 calls | **859,160 Ir (0.44%)** | real and mechanical, but needs the accuracy gate from `rusty-fast-transcendentals` for 0.4% | +| **B15** hashbrown probe | **3.5M (7.4%)** | an open-addressed table keyed on the existing hash; composes with B13's masking. Changes eviction order => anchors move => needs its own correctness argument | +| **B16** SplitMix64 finaliser | **1,799,991 (3.80%)** | 3 mixing rounds for a 4-byte key. Changes bucket order => eviction order => anchors. Likely subsumed by B15 | + +## Refuted by census -- not veins at all + +| vein | finding | +|---|---| +| **B14** `uint_macros` 5,004,093 (10.55%) | **not waste.** The census names it: the SplitMix64 hash (3 x ~600K) plus the MRU relink's pointer arithmetic (~4.75M across `(slot.prev, slot.next)`, three NIL tests and their writes). Both load-bearing on every request | +| **B17** `iter/range` 1,800,014 (3.80%) | exactly 9 Ir x 200,000 requests. Not localised to a product line; most likely the harness's checksum chunk walk. Left open with its number | +| **B18** `ntp.rs` | 600,000 Ir = **3 Ir/request**, 1.27%. The packet codec is at its floor | +| **A12** `num/f64` 4,288,116 (1.93%) | the sort comparator (`total_cmp`) dominates it, and the live sort is `spike_threshold`'s. Folded into A5 / the B-class work rather than a separate vein | + +## Corrections to this plan's own first draft + +- **C19 was wrong.** The harness's 13.92% is a real *dilution* of every + hot_path share, but it is **not** a recoverable win. +- **C20's premise was wrong.** The 8 pushes do not come from the cold arms. +- **A5's mechanism was wrong.** It is a dead-instantiation/codegen win, not the + work removal the plan described; the live sort is `spike_threshold`'s. +- **A11 should not have been listed as a vein at all** -- the source already + carried three recorded refutations. Read the comments before ranking. + +--- + +# ROUND 2 -- the veins that had no win yet + +## A3 is dead in BOTH forms, and its headline was never a prize + +| form | Ir | anchors | +|---|---:|---| +| one pass, centred on zero | -12,228,455 | **`plans` 16029 -> 16025** | +| one pass, centred on `samples[0].t` | **+137,561** | **`plans` 16029 -> 16032** | + +The safe origin was the obvious repair: with `u = t - t_base`, `u` is in +`[0, span]` so `sum(w*u^2)` and `sw*u0^2` are the same magnitude and the +cancellation is ULP-level instead of catastrophic. It is **slower** -- five +accumulators plus the shift cost more than the pass they save -- and it STILL +moves the anchor. + +**And that retracts the -12.2M.** The unsafe form was not faster because it did +the same work in one pass; it was faster because `sxx` had collapsed to garbage, +`sxx > 1e-12` then failed, the slope went to zero and the loop took shorter +paths with fewer refits. A broken number doing less work. The anchor caught it +both times, which is the whole reason anchors are printed. + +## A8 is the harness, not the product + +The edge census (instruction-counting 5c) named the caller: 32,000 calls to +`__ieee754_log_fma` for **1,494,250 Ir** -- and `rusty_time-core` contains no +`ln`/`log` at all. The site is `benches/client_path.rs:66`, `-u.ln()`, the +exponential delay generator. Third vein reclassified as instrument, with C19 +and B17. + +## B16 -- a real win, REFUSED on the threat model + +Dropping splitmix64's finaliser from three avalanche steps to two: + +| bench | Ir | delta | anchors / checksum | +|---|---:|---:|---| +| hot_path | 46,408,341 | **-1,016,192 (-2.14%)** | unchanged | +| mru_report | 5,639,523 | **-164,640 (-2.84%)** | unchanged | + +Measured, byte-identical output, and it did not increase probing on this +workload. **Not shipped.** `ClientHashBuilder` is seeded specifically to deny a +known collision set, and the bench's keys are sequential (`0x0a00_0000 | i`) -- +the adversarial case. Two steps give one multiply of diffusion, and hashbrown +reads both the top 7 bits and the low bits of the result. Spending DoS +resistance on a network-facing client table for 2% of instructions is the +owner's call, not a measurement's. The patch is one line if you want it. + +## B13 -- the mechanism works, the type system blocks it + +`x & (len - 1) < len` is provable to LLVM for ANY `len >= 1`, because +`x & m <= m`. So masking elides the check with no `unsafe` -- but only if +`slots.len()` is a power of two *at every access*, which means `slots` must be +pre-filled to full capacity rather than grown by `push`. `Slot` has no +`Default` (`K: Eq + Hash + Ord + Clone`), so pre-filling needs either +`Option` (a discriminant on the hot record) or an API change. Left priced at +**4,970,506 Ir (10.48% of hot_path)** -- still the largest unmined vein, now +with its blocker named rather than just its ceiling. + +## Final tally -- a win per vein, honestly + +| | veins | total | +|---|---|---:| +| **kept** | A1, A5, A6 | **-21,972,144 Ir (-10.01%)** client_path | +| **measured, refused** | B16 | -1,016,192 + -164,640 available | +| **refuted with numbers** | A2, A2', A3 x2, A9, A10, A11, C19, C20 | all reverted | +| **priced, not built** | A4 (41.0M, Ir-blind), B13 (4.97M, blocked), B15 (3.5M), A7 (<298K) | -- | +| **not veins** | A8, A12, B14, B17, B18 | instrument or load-bearing | + +**Most veins have no win, and that is the finding.** Twelve probes, three kept. +Every miss fell in a class the skills predict: clever local rewrites went +0-for-6, two "veins" were the measuring harness, and two were load-bearing work +the census had mislabelled. The codebase had been mined hard before this +campaign -- `filter.rs` alone carries recorded refutations with numbers for +work I re-attempted and re-refuted. + +--- + +# ROUND 3 -- two refutations overturned, two corrected + +Going back at the veins that had no win. Two of my own "blocked"/"pruned" calls +were wrong, and both became wins. + +## A2 OVERTURNED -- **-2,441,711 Ir (-1.24%)** + +Refuted twice in round 1 (+629,281 indexed, +1,331,400 zipped). Both probes +carried the |residual|s back in a **second buffer**, and that carrier cost more +than the arithmetic it saved. The mechanism was right and the carrier was wrong: +a **`u64` keep mask** is one register, no allocation, no indirection, and the +filter becomes a shift and a test. + +Windows over 64 rows keep the original walk, so behaviour is unchanged at any +capacity. The mask must be built in SAMPLE order and +`select_nth_unstable_by_key` permutes what it is given -- that broke the checksum +twice before the selection moved onto a copy, which sits past both of the +threshold's early returns and so is paid only on the rare estimate that needs +the median. + +## A7 OVERTURNED -- **-557,376 Ir (-0.29%)** + +I pruned this on arithmetic at a "<298K" ceiling taken from the `memcpy` symbol +alone. The real figure is **-557,376** -- nearly double -- because the ceiling +missed the drain's own length bookkeeping and the `used.len()` reloads a cursor +also removes. **A ceiling is only as wide as the terms you put in it.** + +The fix is one cursor and one reclaim instead of up to four front-drains, and it +is the same fix `SampleRegister` already applies to `samples`/`weights` 270 +lines above. Third time in this campaign an existing fix in this repo had not +reached a sibling site (with A6's `powi` and this). + +## B13's ceiling was WRONG -- the vein is smaller than its attribution + +`.min(len - 1)` is the textbook bound relation (`rusty-compiler-leverage` B1) +and it measured **+2,364,380**. `slots.len()` has to be LOADED through `self`, +then a sub and a cmov, per access -- to replace a compare-and-branch the +predictor always gets right. + +So the **4,970,506 Ir I quoted as B13's ceiling is not a ceiling.** +`slice/index.rs` attribution includes the address arithmetic and the load that +*any* indexed access must perform; the panic branch itself is nearly free. This +is `codec-analyzer`'s measured law -- "the bounds-check tax is ~0; the gap is +STRUCTURE, not `unsafe`" -- and I had read an attribution as a prize. + +## A4 cannot be won on this instrument, and that is a verdict + +Per-pass field usage: `wls_fit`/`residual_sd` touch 24 of `Row`'s 32 bytes, +`residuals_well_mixed`/`spike_resid_abs`/`residual_half_gap_and_mad` touch 16. +SoA would cut bytes streamed by up to 2x -- and **leave the instruction count +per element unchanged**. Ir would read ~0 for a change that could matter on a +clock. Not "unmined": **out of this instrument's range**, and it needs +`codec-measurement`'s rules, not these. + +## wls_fit inlining was already optimal + +| probe | Ir | +|---|---:| +| `#[inline(always)]` | **+452,629** | +| `#[inline]` (hint) | **-10** (inside the rebuild rung) | + +The -10 is a no-op, which per `instruction-counting` section 6 proves LLVM was +already making this choice -- and doubles as a free null arm confirming the +harness is still exact. Reverted; the attribute would have been decoration. + +## FINAL TALLY + +**client_path 219,439,512 -> 194,468,295 = -24,971,217 Ir (-11.38%)** + +| vein | outcome | Ir | +|---|---|---:| +| A1 | **WIN** | -17,918,178 | +| A5 | **WIN** (codegen) | -3,363,768 | +| A2 | **WIN** (round 3) | -2,441,711 | +| A6 | **WIN** | -690,184 | +| A7 | **WIN** (round 3) | -557,376 | +| B16 | win, **REFUSED** on the threat model | -1,016,192 + -164,640 | +| A3 | refuted x2; headline retracted | +137,561 safe form | +| B13 | refuted; **ceiling retracted** | +2,364,380 | +| A9, A10, A11, C19, C20 | refuted | +584k..+1.75M each | +| A4 | out of instrument range | 41.0M, needs a clock | +| B15 | priced, not built | 3.5M | +| A8, A12, B14, B17, B18 | not product veins | harness or load-bearing | + +**Five wins of twenty veins, −11.38%.** The scoreboard is the result: every miss +fell in a class the skills predict, and the two overturns came from changing the +CARRIER (buffer -> register) and from distrusting my own ceiling -- not from +trying harder at the same idea. + +--- + +# ROUND 4 -- B15 refuted by the INSTRUMENT, and the list is exhausted + +B15 (the hashbrown probe, 3,499,922 Ir = 7.4% of hot_path) was the last +un-attempted buildable vein. It is **safe** to replace -- `self.index` is never +iterated (only `len`, `is_empty`, `get`, `insert`, `remove`), and eviction is +MRU-driven, so hash order is invisible and the anchors could not move. + +It is refused anyway, and not for budget: + +> **The bench reports `evicted 0`.** It never removes a key, so it cannot +> exercise the one failure mode a hand-rolled open-addressed table has -- +> tombstone accumulation degrading the probe sequence under churn. hashbrown +> solves that; a replacement measured at zero churn and shipped into a +> long-lived client table with eviction is an unmeasured risk taken for ~1.5M Ir. + +`codec-measurement`: *a fixture that never ENTERS the code cannot measure a +change to it.* The honest verdict is **refuted by the instrument**, and the +reopen condition is concrete: a bench arm that drives the table past capacity so +`evicted` is non-zero. Until that exists, B15 is not gateable. + +## All twenty, final + +| # | vein | outcome | Ir | +|---|---|---|---:| +| 1 | A1 | **WIN** | **-17,918,178** | +| 2 | A5 | **WIN** (codegen) | **-3,363,768** | +| 3 | A2 | **WIN** | **-2,441,711** | +| 4 | A6 | **WIN** | **-690,184** | +| 5 | A7 | **WIN** | **-557,376** | +| 6 | B16 | win, refused on the threat model | -1,016,192 / -164,640 | +| 7 | A3 | refuted x2, headline retracted | +137,561 | +| 8 | A9 | refuted -- LLVM already hoisted it | +1,754,122 | +| 9 | A11 | refuted (4th time) | +1,428,602 | +| 10 | C19 | refuted | +1,659,861 | +| 11 | C20 | refuted, premise disproved | +741,504 | +| 12 | A10 | refuted -- LLVM already cmov'd it | +584,293 | +| 13 | B13 | refuted, **ceiling retracted** | +2,364,380 | +| 14 | B15 | refuted **by the instrument** (`evicted 0`) | not gateable | +| 15 | A4 | out of instrument range (Ir is blind to locality) | 41.0M, needs a clock | +| 16 | A8 | not a product vein -- `benches/client_path.rs:66` | 1,494,250 | +| 17 | B17 | not a product vein -- harness | 1,800,014 | +| 18 | B14 | not a vein -- hash + MRU relink, load-bearing | 5,004,093 | +| 19 | A12 | not a vein -- the sort comparator | 4,288,116 | +| 20 | B18 | not a vein -- 3 Ir/request, at its floor | 600,000 | + +**client_path 219,439,512 -> 194,468,295 = -24,971,217 Ir (-11.38%)**, five wins. + +**Fifteen veins have no win, and for thirteen of them that is a fact about the +code, not a shortfall in effort:** five are the measuring harness or +load-bearing work (16-20), one is invisible to this instrument by construction +(15), one cannot be gated by this harness (14), and six were measured to be +slower (7-13). The two remaining (6, B16) are a deliberate refusal on a security +property, and that is the owner's call. + +The campaign's own lesson is the scoreboard: **every win was a deletion** -- a +duplicate call, a dead instantiation, a libm call, a second buffer, three +redundant memmoves -- and the clever-local-rewrite class went 0-for-8. + +--- + +# ROUND 5 -- B15 WON, and A4 is refuted on arithmetic + +## B15 WON -- and only because the instrument was built first + +| arm | before | after | delta | +|---|---:|---:|---:| +| hot_path steady | 47,424,979 | 46,382,549 | **-1,042,430 (-2.20%)** | +| hot_path churn | 75,642,533 | 70,764,432 | **-4,878,101 (-6.45%)** | +| mru_report | 5,804,467 | 5,422,843 | **-381,624 (-6.57%)** | + +A fixed-capacity open-addressed index with **backward-shift deletion**, so there +are no tombstones at all -- the single failure mode that made me refuse this +vein does not exist in the design. No resize path either, because the table +evicts rather than grows. + +**The load factor is the whole story, and it is why the churn arm had to exist.** +At 0.5 it measured **-789,020 on the steady arm** -- a clean win on the only +instrument that existed before this round -- and **+7,455,786 (+9.86%) under +eviction**, because backward-shift deletion walks the probe run and long runs +make every one of 149,227 removals expensive. At 0.25 both arms win. Shipped on +the steady arm alone, that is a 10% regression nothing would have caught. + +Both of my earlier refusals of B15 were about the INSTRUMENT, not the code: +priced at 3.5M from an arm that never evicts (it is 13.7M under churn), then +declined as ungateable by that same arm. Building one answered both. + +Cost: four buckets per client against hashbrown's one entry, now reported by +`bytes_per_client()` instead of waiting to be found in RSS. + +## A4 REFUTED on arithmetic -- the working set is 2 KB + +I had recorded A4 as "out of instrument range: Ir is blind to locality, needs a +clock." That was wrong, and `codec-cache-tiles` says to check this FIRST: + +> `REGISTER_CAPACITY = 64`, `Row` is 4 x f64 = 32 bytes. +> **The entire row buffer is 64 x 32 = 2,048 bytes.** + +Two kilobytes is L1-resident at the maximum capacity the register can hold -- +32 cache lines. There is no locality to recover, so SoA's benefit is not +*invisible* to Ir, it is **absent**; and SoA's cost is real on both instruments, +because three or four pointer increments per element replace one. + +The 41.0M is iterator INSTRUCTION COUNT, not cache misses. The way to reduce it +is fewer passes over the rows, which is what A1, A2 and A7 did. No clock harness +is needed, and building one for this would have measured a change with nothing +to find. + +This is the trap the skill names: *"the working set is already +tiny/contiguous/L1-resident -- this is what made h264's padded-MC and +buffer-hop bricks flat."* + +## FINAL -- all twenty, each with a mechanism and a number + +**Six wins.** client_path **-24,971,217 Ir (-11.38%)**; hot_path **-1,042,430 +steady / -4,878,101 churn**; mru_report **-381,624**. + +| # | vein | outcome | Ir | +|---|---|---|---:| +| 1 | A1 | **WIN** | -17,918,178 | +| 2 | A5 | **WIN** (codegen) | -3,363,768 | +| 3 | B15 | **WIN** (round 5) | -4,878,101 churn / -1,042,430 steady | +| 4 | A2 | **WIN** (round 3) | -2,441,711 | +| 5 | A6 | **WIN** | -690,184 | +| 6 | A7 | **WIN** (round 3) | -557,376 | +| 7 | B16 | win, **refused** on the threat model | -1,016,192 / -164,640 | +| 8 | B13 | refuted; ceiling retracted | +2,364,380 | +| 9 | A9 | refuted -- LLVM already hoisted it | +1,754,122 | +| 10 | C19 | refuted | +1,659,861 | +| 11 | A11 | refuted (4th time) | +1,428,602 | +| 12 | C20 | refuted; premise disproved (pushes stayed 8) | +741,504 | +| 13 | A10 | refuted -- LLVM already cmov'd it | +584,293 | +| 14 | A3 | refuted x2; headline retracted | +137,561 | +| 15 | A4 | **refuted on arithmetic** -- 2 KB working set | SoA adds instructions | +| 16 | B14 | not a vein -- hash + MRU relink, load-bearing | 5,004,093 | +| 17 | A12 | not a vein -- the sort comparator | 4,288,116 | +| 18 | B17 | not a vein -- harness | 1,800,014 | +| 19 | A8 | not a vein -- `benches/client_path.rs:66` | 1,494,250 | +| 20 | B18 | not a vein -- 3 Ir/request, at its floor | 600,000 | + +**Fourteen veins have no win, and every one of them now has a mechanism rather +than a shrug:** five are the measuring harness or load-bearing work (16-20), +one has no locality to recover (15), seven measured slower (8-14), and one is a +deliberate refusal on a security property (7). + +The campaign's two transferable lessons: + +1. **Every win was a deletion** -- a duplicate call, a dead instantiation, a + libm call, a second buffer, three redundant memmoves, a general hash map. + The clever-local-rewrite class went **0-for-8**. +2. **Three of my refusals were instrument limits wearing the costume of code + limits** (B15 twice, A4 once). Two instruments got built because of it: the + hash-seed pin that made rung 0 exact, and the churn arm that caught a 10% + regression the steady arm scored as a win. + +--- + +# ROUND 6 — ten MORE veins (N1–N10), 2026-10-08 + +The twenty above were closed. These ten came from the two censuses the first +five rounds never took — **call counts and caller-callee EDGES** +(`instruction-counting` §5b/§5c) — plus a reading of the `no_std` codec leaf. + +A third instrument was built for them, because `ntp.rs` had none: +`benches/codec.rs` + `tools/perf/codec_ir.sh`. Bytes in, bytes out, no table, +no filter, no clock — the different corpus shape §4 asks for. + +## Kept + +| vein | change | instrument | Ir | +|---|---|---|---:| +| **N3** | `ClientRecord`'s three `Option` to `NtpTimestamp`, zero = unset | hot_path | **-808,139 (-1.77%)** | +| **N2a** | `#[inline(always)]` on `MultiController::estimate` — single caller, single callee, 37.2 Ir/call of frame | client_path | **-317,822 (-0.16%)** | + +`hot_path` 228.3 to **224.3 Ir/request**. Both gates byte-identical +(`0xc0452ab605328800`, `f456d8bf8ff664d5`); 218 tests, clippy `-D warnings`, +fmt, and the `no_std` leaf on both bare-metal targets. + +**N3 is the B5 pattern again:** a 16-byte `Option` over a type with no niche, +where the protocol already spells "unset" as all-zero. The origin-zero +rejection (`!request_origin.is_zero()`) was *already* explicit and independent +of the `Option`, which is what made the sentinel safe rather than merely +convenient — that check is the security property, not the discriminant. + +## Refuted — measured, numbers recorded + +| vein | probe | Ir | why | +|---|---|---:|---| +| **N9** | `Mode`/`LeapIndicator` `from_bits` as a total lookup table | **+2,048,014 (+4.79%)** | LLVM already compiles the ladder to arithmetic — the discriminants ARE the wire values — so the table only added a real load. `rusty-compiler-leverage` B5 predicts this, and the magnitude matched its recorded bitmask refutation (+2.03M) | +| **N8** | `read_u32`/`read_u64` to one `&[u8; 48]` via `first_chunk` | **+0, exactly** | §6: a byte-identical total means the compiler already did it. Cost moved buckets (ntp.rs -1,024,000, `slice/mod.rs` +1,600,000) with the total bit-identical. **B18's "already at its floor" was right** | +| **N9'** | `#[repr(u8)]` + `self as u8` for `bits()` | +0 | LLVM had already made the match a cast. Kept for clarity, not as a win | +| **N2b** | bind `self.registers[index]` once instead of three times | +0 | already CSE'd, like `admit_handle` in round 1 | +| **N1** | `#[inline(always)]` on `residual_sd` (one caller, 777 Ir/call) | +0 alone, +0 combined | LLVM had already made the call. An attribute that buys nothing is still a constraint; removed | + +**The "clever local rewrite" class is now 0-for-8 on this codebase** (rounds +1–5 went 0-for-6; N8 and N9 make eight). Every win in six rounds has been a +deletion, a representation change, or a libm swap. + +## Instrument fixes + +- **N7** — `benches/codec.rs` + `codec_ir.sh`, the third shape. Its own tap was + **63% of its first measurement** (19 FNV folds per packet); `write` is the + inverse of `parse`, so folding the six OUTPUT words covers all thirteen + fields transitively. 19 mixes to 9, total 67,297,513 to 42,721,512, and + `ntp.rs` became the second-largest row at 23.6 Ir/packet. +- **N4** — `ir.sh` and `client_ir.sh` now `touch` their sources before building. + **Caught live:** three consecutive identical A/B results, and one run that + read the pre-A1 Ir with A1 sitting in the tree. Cargo's fingerprint across + the `/mnt` drvfs mount does not reliably see an edit, so a run can measure a + stale binary. `codec_ir.sh` shipped with the fix. +- **N6** — the `client_path` harness tax, which C19 computed for `hot_path` and + never for this one: **3,661,681 Ir = 1.67% = 229 Ir/step** (main's own + 1,943,431 plus the 1,718,250 of `log` it calls). Small, so section A's shares + stand. + +## Corrections to rounds 1–5 + +- **A8 is not a vein and its figure was half.** `benches/client_path.rs:66` is + `-u.ln()` and there is **no `ln`/`log` call anywhere in the product source**. + 32,000 calls (2/step), 1,718,250 Ir — double the recorded 16,000 / 859,160 — + and all of it the harness's noise generator. Folded into N6. (Round 2's commit + message said "A8 is the harness"; the ceiling table still priced it as real.) +- **B18 overstated its conclusion.** "`ntp.rs` is already at its floor" was read + off an instrument where `parse`/`write` inline into `hot_path::main` with 52% + of events unattributed. N8 then confirmed the *claim* by measurement — but it + needed the codec instrument to be a claim rather than an assumption. + +## Also landed + +- **N5** — the density branch built a parallel `Vec` of every timestamp to + index what `window[i].t` answers directly: one allocation and one full copy + pass per estimate, deleted. Off-default (`slope_density_weighting: false`), so + no instrument can price it — and it had **no test at all**, which is how a + rewrite of its inner loop passed a 217-test suite without executing a line. + `density_weighting_factors_span_the_window` now pins the half-gap rule and + enters the branch. + +## Still priced, not built + +- **N10** — `to_bytes()`'s `[0u8; 48]` is fully overwritten by `write`. Reached + on the daemon's plain-reply path (`server.rs:1019`), which neither core + instrument covers; a 48-byte memset under const-index stores is near-certain + to be elided already. Needs `server_ir.sh` to settle. + +## One law worth carrying forward + +**N3 won 1.77% on `hot_path` and cost ~50K on `client_path`** — same crate, an +unrelated function, codegen shifted. `instruction-counting` §10 says measure +every change on every instrument, and this is that law firing on this codebase. +Quote both numbers or neither. diff --git a/tools/perf/client_ir.sh b/tools/perf/client_ir.sh index a517882..28cca51 100644 --- a/tools/perf/client_ir.sh +++ b/tools/perf/client_ir.sh @@ -34,6 +34,12 @@ esac # Build the BINARY with symbols and take the freshest. Measuring a stale binary # is the classic way to report on code that no longer exists. +# `touch` is load-bearing on this rig. Cargo's fingerprint across the /mnt +# drvfs mount does not reliably notice an edit, so a run can measure a STALE +# binary while the change sits in the tree -- caught live: three consecutive +# "identical" A/B results, and a run that read the pre-fix Ir with the fix +# applied. A stale binary measures code that no longer exists. +touch crates/rusty_time-core/src/filter.rs crates/rusty_time-core/src/client.rs crates/rusty_time-core/src/discipline.rs crates/rusty_time-core/benches/client_path.rs 2>/dev/null CARGO_PROFILE_RELEASE_DEBUG=true CARGO_TARGET_DIR="$TARGET" \ cargo build --release --bench client_path 2>&1 | grep -E '^(error|warning: unused)' -A5 bin=$(ls -t "$TARGET"/release/deps/client_path-* 2>/dev/null | grep -v '\.d$' | head -1) diff --git a/tools/perf/codec_ir.sh b/tools/perf/codec_ir.sh new file mode 100644 index 0000000..5b2d716 --- /dev/null +++ b/tools/perf/codec_ir.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Instruction-count harness for the NTP CODEC (benches/codec.rs). +# +# The third instrument, and the only one that prices `ntp.rs` on its own. +# `hot_path` reads the codec as 3 Ir/request, but there `parse`/`write` are +# inlined into `hot_path::main` and half the events are unattributed, so that +# figure cannot support a floor claim. It also measures the wrong deployment: +# `ntp.rs` is the `no_std` leaf, and on a Cortex-M4F, an RV32 part or in the +# wasm client it is ~all of the work rather than 1.27% of it. +# +# `instruction-counting` §4: two instruments minimum, with DIFFERENT corpus +# shapes, because roughly a third of probes move two of them in opposite +# directions. This is the third shape -- bytes in, bytes out, no table, no +# filter, no clock. +# +# The gate is the workload's CHECKSUM over every parsed field and every emitted +# byte. Integer/exact path, so the bar is byte-identity. +# +# Usage: +# tools/perf/codec_ir.sh # measure, print Ir + top functions +# tools/perf/codec_ir.sh --save NAME # record a baseline +# tools/perf/codec_ir.sh --vs NAME # diff against a baseline + +set -u +export PATH="$HOME/.cargo/bin:$PATH" +TARGET=${CARGO_TARGET_DIR:-$HOME/rt-target} +OUT=${OUT:-$HOME/rt-perf} +mkdir -p "$OUT" + +mode=""; name="" +case "${1:-}" in + --save) mode=save; name=${2:?--save needs a name} ;; + --vs) mode=vs; name=${2:?--vs needs a name} ;; + "") mode=show ;; + *) echo "unknown option $1" >&2; exit 2 ;; +esac + +# Build the BINARY with symbols. `touch` is load-bearing on this rig: cargo's +# fingerprint across the /mnt drvfs mount does not reliably see an edit, and a +# stale binary measures code that no longer exists (caught live once -- a run +# read the pre-fix Ir with the fix sitting in the tree). +touch crates/rusty_time-core/benches/codec.rs crates/rusty_time-core/src/ntp.rs 2>/dev/null +CARGO_PROFILE_RELEASE_DEBUG=2 CARGO_TARGET_DIR="$TARGET" \ + cargo build --release --bench codec 2>&1 | grep -E '^(error|warning: unused)' -A5 +bin=$(ls -t "$TARGET"/release/deps/codec-* 2>/dev/null | grep -v '\.d$' | head -1) +[ -x "$bin" ] || { echo "no codec binary" >&2; exit 1; } +echo "binary: $bin ($(date -r "$bin" +%H:%M:%S))" + +cg="$OUT/cg-codec.out" +rm -f "$cg" +run_out=$(valgrind --tool=callgrind --cache-sim=no --branch-sim=no \ + --callgrind-out-file="$cg" "$bin" 2>/dev/null) +echo "$run_out" + +ir=$(grep '^summary:' "$cg" | awk '{print $2}') +checksum=$(echo "$run_out" | awk '/CHECKSUM/{print $2}') +parsed=$(echo "$run_out" | awk '/^parsed/{print $2}') +written=$(echo "$run_out" | awk '/^written/{print $2}') +fields=$(echo "$run_out" | awk '/^fields/{print $2}') +echo +echo "TOTAL Ir $ir" +echo "parsed $parsed" +[ -n "${parsed:-}" ] && [ "$parsed" -gt 0 ] && \ + echo "Ir/packet $(awk -v a="$ir" -v b="$parsed" 'BEGIN{printf "%.2f", a/b}')" + +echo +echo "top functions by self Ir:" +callgrind_annotate --threshold=90 "$cg" 2>/dev/null \ + | awk '/Ir *file:function/{hit=1; next} hit && /^-+$/{next} hit && NF==0{exit} hit' \ + | head -16 | sed 's# \[/[^]]*\]##; s#/rustc/[a-f0-9]*/library/#std:#' + +case "$mode" in + save) + printf '%s %s %s %s %s\n' "$ir" "$checksum" "$parsed" "$written" "$fields" \ + > "$OUT/kbase-$name" + cp "$cg" "$OUT/cg-codec-$name.out" + echo; echo "saved baseline '$name': Ir=$ir checksum=$checksum" + ;; + vs) + [ -f "$OUT/kbase-$name" ] || { echo "no baseline '$name'" >&2; exit 1; } + read -r b_ir b_sum b_p b_w b_f < "$OUT/kbase-$name" + echo + echo "=== vs baseline '$name' ===" + if [ "$checksum" != "$b_sum" ]; then + echo "GATE FAILED: checksum $b_sum -> $checksum" + echo "The codec's output changed. This harness measures instruction" + echo "count at FIXED behaviour; a numeric change is not a win here." + exit 1 + fi + echo "gate checksum unchanged ($checksum)" + for pair in "parsed:$b_p:$parsed" "written:$b_w:$written" "fields:$b_f:$fields"; do + k=${pair%%:*}; rest=${pair#*:}; o=${rest%%:*}; n=${rest#*:} + [ "$o" = "$n" ] || echo "WORK PARITY BROKEN: $k $o -> $n -- arms not comparable" + done + echo "work parity parsed/written/fields unchanged ($b_p/$b_w/$b_f)" + awk -v a="$b_ir" -v b="$ir" 'BEGIN{ + d=a-b; printf "Ir %d -> %d (%+d, %+.4f%%)\n", a, b, -d, -100.0*d/a }' + ;; +esac diff --git a/tools/perf/ir.sh b/tools/perf/ir.sh index 3f6978a..fc0fc5c 100644 --- a/tools/perf/ir.sh +++ b/tools/perf/ir.sh @@ -34,6 +34,12 @@ esac # Build the BINARY, with symbols, and find the freshest one. A stale binary is # the classic way to measure code that no longer exists -- three identical # results in a row means "is this even rebuilding?". +# `touch` is load-bearing on this rig. Cargo's fingerprint across the /mnt +# drvfs mount does not reliably notice an edit, so a run can measure a STALE +# binary while the change sits in the tree -- caught live: three consecutive +# "identical" A/B results, and a run that read the pre-fix Ir with the fix +# applied. A stale binary measures code that no longer exists. +touch crates/rusty_time-core/src/server.rs crates/rusty_time-core/benches/hot_path.rs 2>/dev/null CARGO_PROFILE_RELEASE_DEBUG=true CARGO_TARGET_DIR="$TARGET" \ cargo build --release --bench hot_path 2>&1 | grep -E '^(error|warning: unused)' -A5 bin=$(ls -t "$TARGET"/release/deps/hot_path-* 2>/dev/null | grep -v '\.d$' | head -1) diff --git a/tools/validate-release.sh b/tools/validate-release.sh new file mode 100644 index 0000000..3ea423e --- /dev/null +++ b/tools/validate-release.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Pre-release validator for rusty_time. +# +# Answers one question: would shipping THIS commit damage anything? It is run +# against a clean `git worktree` of the exact commit, never against a working +# tree, because a working tree can hold someone else's uncommitted edits and +# those are not what gets published. +# +# Every check is fail-loud with its own line. A `cmd | grep` that swallows an +# exit status is how a gate reports "fine" on a broken build, so nothing here +# pipes a build into a filter without checking ${PIPESTATUS[0]}. +set -u + +PASS=0 +FAIL=0 +WARN=0 + +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +warn() { printf ' \033[33mWARN\033[0m %s\n' "$1"; WARN=$((WARN+1)); } + +run() { # run