From d6d57b6d6ff229f3bb2e7cd2e7e986a31adf979e Mon Sep 17 00:00:00 2001 From: Hao Hu Date: Thu, 5 Nov 2026 08:54:25 +0100 Subject: [PATCH 01/20] Docs: rewrite CONTRIBUTING for clojure-rs, add master-vs-release stability note --- CONTRIBUTING.md | 106 +++++++++++++++++++++++++++++++++++------------- README.md | 4 ++ 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84f0aee84b..3af239fa8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,42 +1,92 @@ -Hi! Thanks for your interest in Clojure! +Hi! Thanks for your interest in clojure-rs! -## I want to ask a question +This repository (`hhh-pa/clojure`) is a native Clojure port in Rust — +not upstream JVM Clojure (`clojure/clojure`). Upstream language +questions still belong with the Clojure community, but anything about +this port (bugs, gaps, build issues, contributions) belongs here, on +GitHub. -If you have a question about Clojure, please use the official Ask Clojure forum at https://ask.clojure.org. This forum is monitored by the Clojure maintainers. +> **Stability note:** `master` is under active development and may be +> unstable. Tagged `v*` releases are the tested, stable snapshots — +> please rely on the latest release for anything important. -## I want to discuss an idea +## I want to ask a question -There are many interactive Clojure forums for discussion and you can find a list at [Clojure Discussion](https://clojure.org/community/resources#_clojure_discussion). +- About the Clojure language in general: use the official Ask Clojure + forum at https://ask.clojure.org, or the forums listed under + [Clojure Discussion](https://clojure.org/community/resources#_clojure_discussion). +- About this Rust port (behavior, build, LLVM setup, roadmap): please + [open a GitHub issue](https://github.com/hhh-pa/clojure/issues). ## I want to file a bug / suggest an enhancement -Please file it as a question on https://ask.clojure.org with the tag "problem" (possible bugs) or "request" (enhancements). - -## I want to provide a patch / PR - -If you would like to contribute patches, the Clojure dev process is described in detail at https://clojure.org/dev. - -In short, this process requires: +Please [open a GitHub issue](https://github.com/hhh-pa/clojure/issues) +(not Ask Clojure, not JIRA). Include: -- [Signing the Contributor Agreement](https://clojure.org/dev/contributor_agreement) -- [Requesting jira access](https://clojure.atlassian.net/servicedesk/customer/portal/1) +- What you ran and what you expected vs. what happened. +- A minimal repro (a `.clj` snippet or command line). +- The version: release tag (e.g. `v1.13.0-alpha9`) or `master` SHA — + and prefer reproducing against the latest release first, since + `master` moves fast. +- Your platform (`uname -a` / Windows version), Rust version + (`rustc --version`), and whether the `aot` feature / LLVM 22 was + enabled. +- For behavior questions, the JVM Clojure output for the same input, + if you have it — the port is differentially verified against it. -This project does not accept pull requests. - -## I am looking for official documentation - -You can find official documentation on the Clojure web site: +## I want to provide a patch / PR -* Reference docs https://clojure.org/reference -* Tutorials and guides: https://clojure.org/guides -* API: https://clojure.org/api/api +Pull requests are welcome here (unlike upstream, this project works +entirely through GitHub PRs). + +1. Fork, branch off `master`, and open a PR against `master`. +2. Sign your commits — branch protection requires verified signatures + (SSH: `gpg.format=ssh`, `commit.gpgsign=true`, key registered under + GitHub Settings → SSH and GPG keys → Signing keys). +3. Keep CI green and get one approving review (`CODEOWNERS` + auto-requests it) before merge. + +Before opening the PR, run at least (from `rust/`): + +```sh +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --locked +``` + +Notes for contributors: + +- MSRV is stable Rust ≥ 1.85. The default `aot` feature needs LLVM 22 + — see [LLVM setup](rust/README.md#llvm-setup-the-aot-feature). Code + must also build with `--no-default-features` (interpreter-only). +- Add or update tests with every behavior change. CI enforces 100% + line + patch coverage floors, plus `cargo-deny`, `cargo-geiger`, + `cargo-udeps`, `cargo-audit`, loom schedule tests, and TLA+ model + checks — a PR that drops coverage or trips a gate will not merge. +- Behavior changes should match JVM Clojure (same input, same printed + output). If a difference is intentional, document and pin it: update + the [design spec](docs/t1174/specification.md) / per-area contracts + (`docs/t1174/*-contract.md`) and the normative + [core coverage list](rust/core-coverage.md), which CI machine-checks. +- New code is `Copyright (c) Hao Hu` under EPL-1.0; keep the + `Copyright (c) Rich Hickey` notices on vendored upstream sources, + and use dual notices on adapted files. + +## I am looking for documentation + +- This port: the [root README](README.md) (scope, CLI, accepted + differences), the [workspace README](rust/README.md) (LLVM setup, + build commands), the [design spec](docs/t1174/specification.md), and + the [core coverage list](rust/core-coverage.md). +- The Clojure language: [reference](https://clojure.org/reference), + [guides](https://clojure.org/guides), and [API](https://clojure.org/api/api). ## What release should I use? -Find the current release info here: - -https://clojure.org/releases/downloads - -A list of all releases can be found here: +Use the latest tagged `v*` release: +https://github.com/hhh-pa/clojure/releases -https://clojure.org/releases/downloads_older +Each release attaches per-platform `clojure-rs-.tar.gz` +archives (binary + runtime archive). `master` tracks upstream Clojure +master and is where development happens — it may be broken or +inconsistent at any given commit, so count on the released version. diff --git a/README.md b/README.md index 0edf51f9fa..5f2afd76b3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # clojure-rs — native Clojure in Rust +> **Stability note:** `master` is under active development and may be +> unstable. Tagged `v*` releases are the tested, stable snapshots — +> please rely on the latest release for anything important. + A self-contained native Clojure runtime, REPL, and LLVM AOT compiler with no JDK required, tracking upstream Clojure master (`clojure/clojure`). (`clojure-version` still reports `1.13.0-alpha9` From 29057819357358d8ecb9c1d3ac76946291efd5a8 Mon Sep 17 00:00:00 2001 From: Hao Hu Date: Thu, 5 Nov 2026 19:54:06 +0100 Subject: [PATCH 02/20] Register VM and hot-path redesign: specialized ops, direct calls, inline vector trie (#226) Work in progress toward #226 (clojure-rs faster than CPython on the snippet corpus). Analyzed forms compile to closure code over flat per-call register frames (`vm.rs`), escaping to the tree evaluator only for forms it does not model; last-use analysis (`vm_live.rs`) moves dead locals so uniquely owned collections update in place. - vm_ops.rs: the hot clojure.core ops (arith, compare, `=`, `nth`, `get`, `count`, predicates, casts, `conj!`) compile per operand shape (register, capture, literal, computed) with the op inline; n-ary folds and comparison chains; `assoc`/`conj` read their collection last so it can move. Ops are chosen from the analyzed native's own name; every miss runs the native over the same values. - Calls: fixed-arity VM calls write args straight into the callee's registers; natives calling back (`reduce`, `map`, `mapv`, `keep`) resolve the callee once (`Prepared`); stack probe every 16 levels. - Seqs: `for`'s innermost `:when` keeps through `__for-keep*` (no per-item list); vectors, counted long ranges and finite repeats walk/collect directly; `and`/`or` fuse as branch tests. - clj-value trie.rs: a Clojure-style persistent vector (inline items, O(1) tail) replaces the rpds vector. - Lock-free identity counter, multi-slot string scan memo, fat-LTO release profile, mimalloc global allocator. - Fix: StringIndexOutOfBoundsException now descends from IndexOutOfBoundsException (it was uncatchable as Exception). - STM tests: history-count pins take an exclusive gate (a concurrent test's transaction pinned history, making history_trim flaky). Measured locally (retired instructions): every snippet case still matches; total over the 1754 snippet cases 1595G -> 884G; heavy-case median vs CPython 3.14 5.96x -> 3.84x. Gates: fmt, clippy -D warnings, complexity ratchet, 100% patch and floor coverage, MSRV 1.85. Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.lock | 19 + rust/Cargo.toml | 7 + rust/crates/clj-eval/src/classemu.rs | 47 +- rust/crates/clj-eval/src/coll.rs | 95 +- rust/crates/clj-eval/src/collmisc.rs | 2 +- rust/crates/clj-eval/src/concurrency.rs | 7 +- rust/crates/clj-eval/src/core.clj | 59 +- rust/crates/clj-eval/src/core.rs | 10 +- rust/crates/clj-eval/src/core_lazy.rs | 832 ++++++++ rust/crates/clj-eval/src/core_macros.rs | 662 +++++++ rust/crates/clj-eval/src/env.rs | 675 +++++-- rust/crates/clj-eval/src/eval.rs | 700 +++++-- rust/crates/clj-eval/src/expr.rs | 324 +++- rust/crates/clj-eval/src/ext.rs | 18 + rust/crates/clj-eval/src/hierarchy.rs | 2 +- rust/crates/clj-eval/src/io.rs | 6 +- rust/crates/clj-eval/src/lazy.rs | 243 ++- rust/crates/clj-eval/src/lib.rs | 9 +- rust/crates/clj-eval/src/macros.rs | 26 +- rust/crates/clj-eval/src/ns.rs | 512 ++--- rust/crates/clj-eval/src/portdeps.rs | 6 +- rust/crates/clj-eval/src/printsys.rs | 36 +- rust/crates/clj-eval/src/re.rs | 26 +- rust/crates/clj-eval/src/reader.rs | 20 +- rust/crates/clj-eval/src/seqs.rs | 139 +- rust/crates/clj-eval/src/stdlib_natives.rs | 176 ++ rust/crates/clj-eval/src/types.rs | 4 +- rust/crates/clj-eval/src/vars.rs | 15 + rust/crates/clj-eval/src/vm.rs | 1670 +++++++++++++++++ rust/crates/clj-eval/src/vm_intrinsics.rs | 197 ++ rust/crates/clj-eval/src/vm_live.rs | 358 ++++ rust/crates/clj-eval/src/vm_ops.rs | 1028 ++++++++++ rust/crates/clj-eval/tests/cellstaps.rs | 17 +- rust/crates/clj-eval/tests/classemu.rs | 6 +- rust/crates/clj-eval/tests/compiler.rs | 5 +- rust/crates/clj-eval/tests/jdkio.rs | 15 +- rust/crates/clj-eval/tests/jvm_parity_226.rs | 246 +++ rust/crates/clj-eval/tests/lazy_core.rs | 463 +++++ rust/crates/clj-eval/tests/stm.rs | 41 + rust/crates/clj-eval/tests/vm_engine.rs | 530 ++++++ rust/crates/clj-eval/tests/vm_ops.rs | 279 +++ .../crates/clj-eval/tests/zero_oos_s3_seqs.rs | 24 +- .../clj-eval/tests/zero_oos_s7_readgates.rs | 13 +- rust/crates/clj-reader/src/coll.rs | 19 +- rust/crates/clj-reader/src/lib.rs | 6 + rust/crates/clj-reader/src/metaform.rs | 6 +- rust/crates/clj-reader/src/opts.rs | 53 +- rust/crates/clj-reader/src/stream.rs | 7 + rust/crates/clj-reader/src/symbol.rs | 2 +- rust/crates/clj-reader/tests/harness.rs | 13 +- rust/crates/clj-value/src/extension.rs | 19 +- rust/crates/clj-value/src/keyword.rs | 51 +- rust/crates/clj-value/src/lib.rs | 9 +- rust/crates/clj-value/src/pvec.rs | 2 +- rust/crates/clj-value/src/seq.rs | 110 ++ rust/crates/clj-value/src/symbol.rs | 103 + rust/crates/clj-value/src/trie.rs | 424 +++++ rust/crates/clj-value/src/value.rs | 76 +- rust/src/bin/clojure-rs/main.rs | 36 +- rust/tests/cli.rs | 14 + 60 files changed, 9712 insertions(+), 807 deletions(-) create mode 100644 rust/crates/clj-eval/src/core_lazy.rs create mode 100644 rust/crates/clj-eval/src/core_macros.rs create mode 100644 rust/crates/clj-eval/src/stdlib_natives.rs create mode 100644 rust/crates/clj-eval/src/vm.rs create mode 100644 rust/crates/clj-eval/src/vm_intrinsics.rs create mode 100644 rust/crates/clj-eval/src/vm_live.rs create mode 100644 rust/crates/clj-eval/src/vm_ops.rs create mode 100644 rust/crates/clj-eval/tests/jvm_parity_226.rs create mode 100644 rust/crates/clj-eval/tests/lazy_core.rs create mode 100644 rust/crates/clj-eval/tests/vm_engine.rs create mode 100644 rust/crates/clj-eval/tests/vm_ops.rs create mode 100644 rust/crates/clj-value/src/trie.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1ede18e40b..3f41389d56 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -244,6 +244,7 @@ dependencies = [ "clj-eval", "clj-repl", "clj-value", + "mimalloc", "rustix", ] @@ -376,6 +377,15 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -430,6 +440,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "nibble_vec" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5a0c641b2c..a8686d1724 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -33,6 +33,7 @@ clj-aot = { path = "crates/clj-aot", optional = true } clj-eval = { path = "crates/clj-eval" } clj-repl = { path = "crates/clj-repl" } clj-value = { path = "crates/clj-value" } +mimalloc = { version = "0.1.52", default-features = false } [features] default = ["aot"] @@ -43,6 +44,12 @@ aot = ["clj-aot"] [target.'cfg(unix)'.dev-dependencies] rustix = { version = "1", features = ["pipe", "stdio"] } +# Release builds ship the interpreter (#226): one codegen unit and +# fat LTO let the hot value/eval paths inline across the crate seams. +[profile.release] +lto = "fat" +codegen-units = 1 + # Deny the `unused` lint group workspace-wide, on every platform and # profile: clippy's `-D warnings` only runs on Linux CI, so without # this, Windows/macOS-only dead code compiles with warnings no gate diff --git a/rust/crates/clj-eval/src/classemu.rs b/rust/crates/clj-eval/src/classemu.rs index beaf59c1e3..560efc99da 100644 --- a/rust/crates/clj-eval/src/classemu.rs +++ b/rust/crates/clj-eval/src/classemu.rs @@ -2290,6 +2290,16 @@ const DEFAULT_IMPORTS: &[&str] = &[ /// namespace seeding. Every name resolves: a missing row is a /// lattice bug and fails loud here rather than seeding a /// divergent fallback. +/// [`default_imports`] as a namespace's starting imports table: +/// built once per process and shared (namespaces copy it on their +/// first import change), so namespace creation skips ~170 class +/// lookups. +pub(crate) fn default_import_map() -> Arc>> { + static MAP: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Arc::new(default_imports().collect())); + Arc::clone(&MAP) +} + pub(crate) fn default_imports() -> impl Iterator)> { DEFAULT_IMPORTS.iter().map(|fqn| { let simple = fqn.rsplit('.').next().unwrap_or(fqn).to_owned(); @@ -3958,7 +3968,7 @@ fn static_character_digit(args: &[Value]) -> Result { } /// The Java whitespace predicate (see `static_character_is_whitespace`). -fn java_is_whitespace(c: char) -> bool { +pub(crate) fn java_is_whitespace(c: char) -> bool { matches!(c, '\u{1C}'..='\u{1F}') || (c.is_whitespace() && !matches!(c, '\u{85}' | '\u{A0}' | '\u{2007}' | '\u{202F}')) } @@ -5191,9 +5201,7 @@ fn identity_hash_ptr(v: &Value) -> Option { /// Read an import mapping (shared with name resolution). pub(crate) fn import_in(eval: &Evaluator, ns: &str, simple: &str) -> Option> { - eval.registry - .with_ns_mut(ns, |ns| ns.get_import(simple)) - .flatten() + eval.registry.import_in(ns, simple) } /// A bean key. @@ -7322,6 +7330,19 @@ pub(crate) struct StringBuilder { static NEXT_BUILDER: AtomicU64 = AtomicU64::new(1); +/// The text of a `CharSequence` argument (strings and +/// `StringBuilder`s; `None` for anything else). +pub(crate) fn char_sequence_text(v: &Value) -> Option> { + match v { + Value::Str(s) => Some(Arc::clone(s)), + Value::Extension(e) => e + .as_any() + .downcast_ref::() + .map(|sb| Arc::from(sb.lock().as_str())), + _ => None, + } +} + impl StringBuilder { pub(crate) fn fresh(content: String) -> Self { Self { @@ -7489,14 +7510,28 @@ impl ExtensionValue for StringBuilder { "java.lang.StringBuilder".to_owned() } + /// `print-object`: the `toString` (the buffer) printed + /// readably. fn print(&self) -> String { + let text = Value::Str(Arc::from(self.lock().as_str())); format!( - "#object[java.lang.StringBuilder 0x{:x} \"java.lang.StringBuilder@{:x}\"]", - self.id, self.id + "#object[java.lang.StringBuilder 0x{:x} {}]", + self.id, + clj_value::pr_to_string(&text, &clj_value::PrintOpts::pr()) ) } + /// Not readably, the buffer goes in unquoted. fn print_str(&self) -> Option { + Some(format!( + "#object[java.lang.StringBuilder 0x{:x} {}]", + self.id, + self.lock() + )) + } + + /// `toString` is the buffer. + fn java_str(&self) -> Option { Some(self.lock().clone()) } diff --git a/rust/crates/clj-eval/src/coll.rs b/rust/crates/clj-eval/src/coll.rs index 197409abdf..9bfeedf343 100644 --- a/rust/crates/clj-eval/src/coll.rs +++ b/rust/crates/clj-eval/src/coll.rs @@ -726,7 +726,7 @@ fn count_fallback(eval: &mut Evaluator, v: &Value) -> Result { return Ok(r.walked_len() as i64); } if let Some(s) = as_realized(v) { - return Ok(s.items().len() as i64); + return Ok(s.seq().count() as i64); } if let Some(n) = as_repeat(v).and_then(|r| r.n()) { return Ok(n.max(0)); @@ -1548,34 +1548,47 @@ static STR_SCAN_CACHE: Mutex> = Mutex::new(Vec::new()); /// would churn the global mutex for no reuse (#235). const STR_SCAN_SMALL_MAX: usize = 64; -// The last string-scan hit, per thread: parse loops `nth` one -// `Arc` millions of times, and the memo answers repeats -// without the global cache mutex — and without touching -// refcounts, since a strong key compares by `ptr_eq` alone -// (sound: `Arc` is immutable, so the same allocation is -// the same string, and liveness pins the address against -// ABA). One pinned string per thread at most (freed on the -// next memoize); the global cache stays `Weak` (8 slots must -// not pin). +// The last string-scan hits, per thread: parse loops `nth` one +// `Arc` millions of times, and search loops alternate two (a +// text and a pattern — #226: one slot ping-ponged through the global +// mutex per read). The memo answers repeats without the global +// cache mutex — and without touching refcounts, since a strong key +// compares by `ptr_eq` alone (sound: `Arc` is immutable, so the +// same allocation is the same string, and liveness pins the address +// against ABA). At most `LAST_SCAN_SLOTS` strings pinned per thread +// (each freed when its slot is reused); the global cache stays +// `Weak` (8 slots must not pin). +const LAST_SCAN_SLOTS: usize = 4; + +/// The memo slots plus the next slot to reuse. +type ScanMemo = ([Option<(Arc, StrScan)>; LAST_SCAN_SLOTS], usize); + thread_local! { - static LAST_SCAN: std::cell::RefCell, StrScan)>> = - const { std::cell::RefCell::new(None) }; + static LAST_SCAN: std::cell::RefCell = + const { std::cell::RefCell::new(([None, None, None, None], 0)) }; } -/// The memoized scan for a string, if it was the last one scanned. -fn last_scan_hit(s: &Arc) -> Option { +/// Answer `f` over the memoized scan for a string, if it is one of +/// the last few scanned (no `StrScan` clone on a hit). +fn with_scan_hit(s: &Arc, f: impl FnOnce(&StrScan) -> T) -> Option { LAST_SCAN.with(|memo| { - memo.borrow() - .as_ref() - .and_then(|(key, scan)| Arc::ptr_eq(key, s).then(|| scan.clone())) + let memo = memo.borrow(); + memo.0 + .iter() + .flatten() + .find(|(key, _)| Arc::ptr_eq(key, s)) + .map(|(_, scan)| f(scan)) }) } /// Memoize a string's scan (called on every global-cache hit and -/// fresh build, so repeats skip the mutex). +/// fresh build, so repeats skip the mutex), reusing slots in turn. fn memoize_scan(s: &Arc, scan: &StrScan) { LAST_SCAN.with(|memo| { - *memo.borrow_mut() = Some((Arc::clone(s), scan.clone())); + let mut memo = memo.borrow_mut(); + let slot = memo.1; + memo.0[slot] = Some((Arc::clone(s), scan.clone())); + memo.1 = (slot + 1) % LAST_SCAN_SLOTS; }); } @@ -1587,9 +1600,7 @@ const STR_SCAN_INDEX_CAP: usize = 2_000_000; /// (one O(n) pass; ASCII costs no table). `None` reads over /// the index cap (uncached slow path). fn str_scan(s: &Arc) -> Option { - if let Some(hit) = last_scan_hit(s) { - return Some(hit); - } + // (Callers consult the per-thread memo first.) { let mut cache = STR_SCAN_CACHE.lock().unwrap_or_else(|e| e.into_inner()); if let Some(pos) = cache @@ -1646,22 +1657,30 @@ fn build_str_scan(s: &str) -> Option { /// strings ride the memoized scan at every index (a cached /// `Ascii` flag answers in one byte load even for small /// indexes, where a direct scan would re-decode, #235). -fn str_char_at(s: &Arc, idx: usize) -> Option { +pub(crate) fn str_char_at(s: &Arc, idx: usize) -> Option { if s.len() <= STR_SCAN_SMALL_MAX { if s.is_ascii() { return s.as_bytes().get(idx).map(|b| *b as char); } return s.chars().nth(idx); } + if let Some(hit) = with_scan_hit(s, |scan| scan_char_at(s, scan, idx)) { + return hit; + } match str_scan(s) { - Some(StrScan::Ascii) => s.as_bytes().get(idx).map(|b| *b as char), - Some(StrScan::Indexed { byte_at, .. }) => { - byte_at.get(idx).and_then(|b| s[*b..].chars().next()) - } + Some(scan) => scan_char_at(s, &scan, idx), None => s.chars().nth(idx), } } +/// The char at `idx` through a string's scan. +fn scan_char_at(s: &str, scan: &StrScan, idx: usize) -> Option { + match scan { + StrScan::Ascii => s.as_bytes().get(idx).map(|b| *b as char), + StrScan::Indexed { byte_at, .. } => byte_at.get(idx).and_then(|b| s[*b..].chars().next()), + } +} + /// The UTF-16 length (`count` units), via the scan cache for /// big strings. Shared with the `clj-rt` string fast path (same /// function, zero drift — the fast path skips only the @@ -1670,6 +1689,13 @@ pub fn str_utf16_len(s: &Arc) -> usize { if s.len() <= 256 { return s.encode_utf16().count(); } + let memo = with_scan_hit(s, |scan| match scan { + StrScan::Ascii => s.len(), + StrScan::Indexed { utf16_len, .. } => *utf16_len, + }); + if let Some(n) = memo { + return n; + } match str_scan(s) { Some(StrScan::Ascii) => s.len(), Some(StrScan::Indexed { utf16_len, .. }) => utf16_len, @@ -3045,6 +3071,21 @@ pub(crate) const COLL_NATIVES: &[(&str, NativeFnPtr)] = &[ mod tests { use super::*; + #[test] + fn str_scan_global_cache_answers_memo_evictions() { + // More big strings than memo slots: the first falls out of + // the per-thread memo but stays in the global cache, which + // answers (and re-memoizes) it. + let strings: Vec> = (0..LAST_SCAN_SLOTS + 2) + .map(|i| Arc::from(format!("{i}").repeat(100))) + .collect(); + for s in &strings { + assert!(str_char_at(s, 10).is_some()); + } + assert_eq!(str_char_at(&strings[0], 99), Some('0')); + assert_eq!(str_utf16_len(&strings[0]), 100); + } + #[test] fn str_scan_memo_answers_repeats() { // Past the direct-scan threshold: the first read builds and diff --git a/rust/crates/clj-eval/src/collmisc.rs b/rust/crates/clj-eval/src/collmisc.rs index 18f3b6f4f4..53630b02d9 100644 --- a/rust/crates/clj-eval/src/collmisc.rs +++ b/rust/crates/clj-eval/src/collmisc.rs @@ -989,7 +989,7 @@ struct MatcherState { /// and `find` requires the match at the search offset). #[derive(Clone, Debug)] pub struct MatcherHandle { - re: regex::Regex, + re: Arc, anchored: bool, text: Arc, state: Arc>, diff --git a/rust/crates/clj-eval/src/concurrency.rs b/rust/crates/clj-eval/src/concurrency.rs index 0ab493827c..4a89d1bf84 100644 --- a/rust/crates/clj-eval/src/concurrency.rs +++ b/rust/crates/clj-eval/src/concurrency.rs @@ -556,7 +556,10 @@ fn native_deref(eval: &mut Evaluator, args: &[Value]) -> Result force_delay(eval, d).map_err(signal_to_native), Value::Future(f) => future_outcome(&f.get()), Value::Promise(p) => Ok(p.get()), - Value::Var(v) => Ok(eval.deref_var(v.id, &v.sym)), + Value::Var(v) => { + eval.force_lazy(v.id); + Ok(eval.deref_var(v.id, &v.sym)) + } Value::Nil => Err(npe(DEREF_NIL)), other => Err(cce_err(other, "java.util.concurrent.Future")), } @@ -1661,7 +1664,7 @@ fn agent_var(eval: &Evaluator) -> Option { /// Whether `*agent*` is bound (await refuses inside actions). fn agent_is_bound(eval: &Evaluator) -> bool { - agent_var(eval).is_some_and(|v| eval.deref_var(v.id, &v.sym).is_truthy()) + agent_var(eval).is_some_and(|v| eval.deref_var_unforced(v.id, &v.sym).is_truthy()) } /// Bind `*agent*` around an action fn (skipped when the lineage has diff --git a/rust/crates/clj-eval/src/core.clj b/rust/crates/clj-eval/src/core.clj index 250f56336b..1eb7defa11 100644 --- a/rust/crates/clj-eval/src/core.clj +++ b/rust/crates/clj-eval/src/core.clj @@ -1193,22 +1193,45 @@ :else (throw-illegal-arg! (str "Invalid " kind " spec: " (pr-str s)))))))) +(defn- type-class-name [name] + (str (namespace-munge *ns*) "." name)) + +;; `build-positional-factory`: `->Name` with the JVM's doc and +;; arglists, nested in `(let [] …)` like the JVM expansion (its fn +;; names under the `eval` wrapper: `user/eval12/->Point--13`). +(defn- positional-factory [name fields] + (let [arrow (symbol (str "->" name)) + doc (str "Positional factory function for class " (type-class-name name) ".")] + (list 'let [] + (list 'def (with-meta arrow {:arglists (list 'quote (list fields)) :doc doc}) + ;; The captured descriptor binding is a gensym: the fn + ;; params ARE the field names, so a literal `t` would be + ;; shadowed by a field named `t` (#205 `Matrix [t n]`). + (let [t (gensym "t")] + (list 'let [t name] + (list 'fn arrow fields + (apply list 'clojure.core/__new* t fields)))))))) + (defmacro deftype [name & args] (let [{:keys [doc attr fields specs]} (parse-type-head "deftype" name args) {:keys [ifaces triples]} (parse-type-specs "deftype" specs)] - (finish-type-def name doc attr - (list 'clojure.core/__deftype* - (list 'str (list 'ns-name '*ns*)) - (str name) - (list 'quote fields) - (emit-type-methods "deftype" triples fields) - (list 'quote (vec ifaces)))))) + (list 'do + (finish-type-def name doc attr + (list 'clojure.core/__deftype* + (list 'str (list 'ns-name '*ns*)) + (str name) + (list 'quote fields) + (emit-type-methods "deftype" triples fields) + (list 'quote (vec ifaces)))) + (positional-factory name fields) + name))) (defmacro defrecord [name & args] (let [{:keys [doc attr fields specs]} (parse-type-head "defrecord" name args) {:keys [ifaces triples]} (parse-type-specs "defrecord" specs) - arrow (symbol (str "->" name)) - map-arrow (symbol (str "map->" name))] + map-arrow (symbol (str "map->" name)) + map-doc (str "Factory function for class " (type-class-name name) + ", taking a map of keywords to field values.")] (list 'do (finish-type-def name doc attr (list 'clojure.core/__record* @@ -1217,18 +1240,12 @@ (list 'quote fields) (emit-type-methods "defrecord" triples fields) (list 'quote (vec ifaces)))) - (list 'def arrow - ;; The captured descriptor binding is a gensym: the fn - ;; params ARE the field names, so a literal `t` would be - ;; shadowed by a field named `t` (#205 `Matrix [t n]`). - (let [t (gensym "t")] - (list 'let [t name] - (list 'fn arrow fields - (apply list 'clojure.core/__new* t fields))))) - (list 'def map-arrow - (list 'let ['t name] - (list 'fn map-arrow '[m] - (list 'clojure.core/__record_map* 't 'm)))) + (positional-factory name fields) + (list 'let [] + (list 'def (with-meta map-arrow {:arglists (list 'quote '([m])) :doc map-doc}) + (list 'let ['t name] + (list 'fn map-arrow '[m] + (list 'clojure.core/__record_map* 't 'm))))) name))) (defmacro reify [& specs] diff --git a/rust/crates/clj-eval/src/core.rs b/rust/crates/clj-eval/src/core.rs index 0677f716d7..ede993f8de 100644 --- a/rust/crates/clj-eval/src/core.rs +++ b/rust/crates/clj-eval/src/core.rs @@ -2765,10 +2765,10 @@ fn str_of_realized(eval: &mut Evaluator, v: &Value) -> Result clj_value::java_debug(&r), - // Extensions with a `str` display (only `MathContext`, - // S2 oracle `wp-str`) render it; the rest print - // readably. - Value::Extension(e) => match e.print_str() { + // Extensions with a `toString` or `str` display (namespaces, + // `MathContext` — S2 oracle `wp-str`) render it; the rest + // print readably. + Value::Extension(e) => match e.java_str().or_else(|| e.print_str()) { Some(s) => s, None => clj_value::pr_to_string(&r, &clj_value::PrintOpts::pr()), }, @@ -4541,7 +4541,7 @@ fn native_unchecked_negate(eval: &mut Evaluator, args: &[Value]) -> Result Result { +pub(crate) fn intcast(v: &Value) -> Result { match v { Value::Int(i) => int_exact_long(*i), Value::Double(d) => int_cast_double(*d), diff --git a/rust/crates/clj-eval/src/core_lazy.rs b/rust/crates/clj-eval/src/core_lazy.rs new file mode 100644 index 0000000000..ed0bb4dfb3 --- /dev/null +++ b/rust/crates/clj-eval/src/core_lazy.rs @@ -0,0 +1,832 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! Lazy `core.clj` (#226): boot registers each `defn`/`defn-`/ +//! `defmacro` form (and the `Inst` protocol pair) without reading or +//! evaluating it, and the first resolution of one of its vars +//! evaluates the form ([`force`]). Interpreting all ~140 forms was +//! most of every process's startup, while a script touches a few. +//! Embedded libraries every run requires (`clojure.string`) load the +//! same way ([`load_lazy`]). +//! +//! Until forced, a lazy var is interned (resolution, refers, and +//! `ns-publics` see it), unbound, and flagged `:macro`/`:private` +//! like the evaluated form would flag it — the only metadata the +//! analyzer consults before it resolves (and so forces) the var. +//! Forcing evaluates the form as boot would have: in `clojure.core`, +//! under boot's source name and line numbers, with a fresh binding +//! stack; the gensym/eval/`fn` counters are restored afterwards, so +//! user-visible numbering never depends on what got forced. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use clj_value::{Keyword, Symbol, Value, new_map}; + +use crate::env::Bindings; +use crate::eval::{Evaluator, SourceError}; +use crate::ext::NativeImpl; +use crate::ns::{core_var, core_var_handle, ns_value}; + +/// The source name boot evaluates under (`Evaluator::new_bare`'s +/// argument in `boot`, so forced vars keep `:file "user"`). +const BOOT_SOURCE: &str = "user"; + +/// One lazily evaluated top-level form: its text and 1-based start +/// line (forms start in column 1). +#[derive(Clone, Copy, Debug)] +pub(crate) struct LazyForm { + text: &'static str, + line: usize, +} + +/// The forms one force evaluates (in order) and every var they +/// define (forcing any of them forces all). +#[derive(Debug)] +pub(crate) struct LazyGroup { + forms: Vec, + pub(crate) ids: Vec, + /// The namespace and source name its file evaluated under. + ns: Arc, + source: Arc, +} + +/// Lock-free membership over the lazy var ids (a bitset from the +/// lowest id): the analyzer tests every resolution against it. +#[derive(Debug)] +pub(crate) struct LazyIndex { + lo: u64, + words: Box<[AtomicU64]>, +} + +impl LazyIndex { + pub(crate) fn new(ids: &[u64]) -> Self { + let lo = ids.iter().copied().min().unwrap_or(0); + let span = ids.iter().map(|id| id - lo).max().map_or(0, |m| m + 1); + let words: Box<[AtomicU64]> = (0..span.div_ceil(64)).map(|_| AtomicU64::new(0)).collect(); + for id in ids { + let off = id - lo; + words[(off / 64) as usize].fetch_or(1 << (off % 64), Ordering::Relaxed); + } + Self { lo, words } + } + + /// Whether `id` is still lazy (acquire: a cleared bit publishes + /// the forced root). + pub(crate) fn test(&self, id: u64) -> bool { + let Some(off) = id.checked_sub(self.lo) else { + return false; + }; + self.words + .get((off / 64) as usize) + .is_some_and(|w| w.load(Ordering::Acquire) & (1 << (off % 64)) != 0) + } + + pub(crate) fn clear(&self, id: u64) { + if let Some(off) = id.checked_sub(self.lo) { + if let Some(w) = self.words.get((off / 64) as usize) { + w.fetch_and(!(1 << (off % 64)), Ordering::Release); + } + } + } +} + +/// A top-level form's byte range and start line. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TopForm { + start: usize, + end: usize, + line: usize, +} + +const NO_FORM: TopForm = TopForm { + start: 0, + end: 0, + line: 0, +}; + +/// Scan `b` for its top-level forms (whitespace, commas, and line +/// comments between them skipped), storing the first `out.len()` +/// and returning how many there are. Inside a form, strings (and +/// regexes), char literals, and comments are skipped so their +/// brackets never count; the embedded files hold lists only at top +/// level. `const`, so the embedded files' tables are computed at +/// compile time ([`forms_of`]) — every boot used to rescan +/// `core.clj` (#226). +const fn scan_top_forms(b: &[u8], out: &mut [TopForm]) -> usize { + let (mut i, mut line, mut n) = (0, 1, 0); + while i < b.len() { + match b[i] { + b'\n' => { + line += 1; + i += 1; + } + b';' => i = skip_comment(b, i), + b'(' => { + let start = i; + let start_line = line; + i = skip_form(b, i, &mut line); + if n < out.len() { + out[n] = TopForm { + start, + end: i, + line: start_line, + }; + } + n += 1; + } + _ => i += 1, + } + } + n +} + +/// Split `src` into its top-level forms (see [`scan_top_forms`]). +fn split_top_forms(src: &str) -> Vec { + let b = src.as_bytes(); + let mut out = vec![NO_FORM; scan_top_forms(b, &mut [])]; + scan_top_forms(b, &mut out); + out +} + +/// An embedded file's form table, computed at compile time. +macro_rules! form_table { + ($src:expr) => {{ + const N: usize = scan_top_forms($src.as_bytes(), &mut []); + static FORMS: [TopForm; N] = { + let mut out = [NO_FORM; N]; + scan_top_forms($src.as_bytes(), &mut out); + out + }; + &FORMS + }}; +} + +/// `src`'s top-level forms: the compile-time table for the embedded +/// files that load lazily (boot's `core.clj`, `clojure.string` — +/// statics, so one address each), else a scan now. +fn forms_of(src: &'static str) -> std::borrow::Cow<'static, [TopForm]> { + if std::ptr::eq(src, crate::ns::CORE_CLJ) { + std::borrow::Cow::Borrowed(form_table!(include_str!("core.clj"))) + } else if std::ptr::eq(src, crate::ns::STDLIB_STRING_CLJ) { + std::borrow::Cow::Borrowed(form_table!(include_str!("stdlib/clojure/string.clj"))) + } else { + std::borrow::Cow::Owned(split_top_forms(src)) + } +} + +const fn skip_comment(b: &[u8], mut i: usize) -> usize { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + i +} + +/// Skip the bracketed form opening at `i`, returning the index past +/// its closer (`line` counts the newlines crossed). +const fn skip_form(b: &[u8], mut i: usize, line: &mut usize) -> usize { + let mut depth = 0usize; + while i < b.len() { + match b[i] { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + b'"' => { + i = skip_string(b, i + 1, line); + continue; + } + // A char literal: the next char is literal (`\(`, `\"`); + // named chars (`\space`) are plain letters after it. + b'\\' => i += 1, + b';' => { + i = skip_comment(b, i); + continue; + } + b'\n' => *line += 1, + _ => {} + } + i += 1; + } + b.len() +} + +/// Skip a string body from `i` (past the opening quote), returning +/// the index past the closing quote. +const fn skip_string(b: &[u8], mut i: usize, line: &mut usize) -> usize { + while i < b.len() { + match b[i] { + b'"' => return i + 1, + b'\\' => i += 1, + b'\n' => *line += 1, + _ => {} + } + i += 1; + } + b.len() +} + +/// The token at the start of `s` and the rest after it (tokens end +/// at whitespace or a bracket). +fn token(s: &str) -> (&str, &str) { + let end = s + .find(|c: char| c.is_whitespace() || "()[]{}\"".contains(c)) + .unwrap_or(s.len()); + (&s[..end], &s[end..]) +} + +/// What a top-level form defines, read off its head. +enum Head<'a> { + /// `defn`/`defn-`/`defmacro`: one var, flagged as evaluation + /// would flag it. + Def { + name: &'a str, + macro_: bool, + private: bool, + }, + /// `defprotocol`: the protocol and `-interface` vars plus + /// one var per method. + Protocol { names: Vec }, + /// `extend-protocol P …`: completes protocol `P`'s group. + Extends(&'a str), + /// `declare`: forward declarations of the named vars. + Declare(Vec<&'a str>), + /// Anything else evaluates eagerly at boot. + Eager, +} + +fn head_of(form: &str) -> Head<'_> { + let (op, rest) = token(&form[1..]); + let rest = rest.trim_start(); + match op { + "defn" | "defn-" | "defmacro" => { + let (private_meta, rest) = skip_name_meta(rest); + Head::Def { + name: token(rest).0, + macro_: op == "defmacro", + private: op == "defn-" || private_meta, + } + } + "defprotocol" => { + let (name, body) = token(rest); + let mut names = vec![name.to_owned(), format!("{name}-interface")]; + names.extend(protocol_methods(body).into_iter().map(str::to_owned)); + Head::Protocol { names } + } + "extend-protocol" => Head::Extends(token(rest).0), + "declare" => Head::Declare(rest.trim_end_matches(')').split_whitespace().collect()), + _ => Head::Eager, + } +} + +/// Skip `^:private`/`^{…}`/`^Tag` metadata before a def name, +/// reporting whether it marks the var private. +fn skip_name_meta(mut s: &str) -> (bool, &str) { + let mut private = false; + while let Some(after) = s.strip_prefix('^') { + if let Some(map) = after.strip_prefix('{') { + // Name metadata maps in `core.clj` are flat. + let close = map.find('}').unwrap_or(map.len()); + private |= map[..close].contains(":private true"); + s = map.get(close + 1..).unwrap_or("").trim_start(); + } else { + let (m, more) = token(after); + private |= m == ":private"; + s = more.trim_start(); + } + } + (private, s) +} + +/// The method names of a `defprotocol` body: the head symbol of +/// each nested list one level in. +fn protocol_methods(body: &str) -> Vec<&str> { + let b = body.as_bytes(); + let mut out = Vec::new(); + let mut line = 0; + let mut i = 0; + while i < b.len() { + match b[i] { + b'(' => { + out.push(token(&body[i + 1..]).0); + i = skip_form(b, i, &mut line); + } + b'"' => i = skip_string(b, i + 1, &mut line), + _ => i += 1, + } + } + out +} + +/// The deferred defs of one file plus the forms that still run +/// eagerly (in file order), split out by [`plan`]. +struct Plan { + groups: Vec, + /// Metadata sources of native-backed macros (`core_macros`). + meta_groups: Vec, + eager: Vec, +} + +/// Register `forms`' defs in namespace `ns` (unbound, flagged) and +/// collect the eager remainder. Declarations whose names are all +/// deferred are dropped: forcing defines them, and evaluating them +/// would replace the registered flags (a declared private var would +/// look public to a `refer`). +fn plan( + eval: &mut Evaluator, + src: &'static str, + forms: &[TopForm], + ns: &str, + source: &Arc, +) -> Plan { + let mut planner = Planner { + ns, + ns_arc: Arc::from(ns), + source: Arc::clone(source), + plan: Plan { + groups: Vec::new(), + meta_groups: Vec::new(), + eager: Vec::new(), + }, + protocols: Vec::new(), + declares: Vec::new(), + }; + for f in forms { + planner.add( + eval, + LazyForm { + text: &src[f.start..f.end], + line: f.line, + }, + ); + } + planner.finish(eval) +} + +/// [`plan`]'s accumulator: the plan so far, the protocol groups an +/// `extend-protocol` can join, and the declarations awaiting the +/// all-deferred check. +struct Planner<'a> { + ns: &'a str, + ns_arc: Arc, + source: Arc, + plan: Plan, + protocols: Vec<(String, usize)>, + declares: Vec<(LazyForm, Vec<&'static str>)>, +} + +impl Planner<'_> { + fn group(&self, forms: Vec, ids: Vec) -> LazyGroup { + LazyGroup { + forms, + ids, + ns: Arc::clone(&self.ns_arc), + source: Arc::clone(&self.source), + } + } + + /// File one top-level form by its head. + fn add(&mut self, eval: &mut Evaluator, form: LazyForm) { + let ns = self.ns; + match head_of(form.text) { + Head::Def { name, .. } if native_backed(ns, name) => { + // The native runs; the form is metadata (and the + // native's slow path) only. + let q = Symbol::new(Some(ns), name); + if let Some(id) = eval.registry.lookup_in_ns(ns, &q) { + let g = self.group(vec![form], vec![id]); + self.plan.meta_groups.push(g); + } + } + Head::Def { + name, + macro_, + private, + } => { + let id = register(eval, ns, name, macro_, private); + let g = self.group(vec![form], vec![id]); + self.plan.groups.push(g); + } + Head::Protocol { names } => { + let ids = names + .iter() + .map(|n| register(eval, ns, n, false, false)) + .collect(); + self.protocols + .push((names[0].clone(), self.plan.groups.len())); + let g = self.group(vec![form], ids); + self.plan.groups.push(g); + } + Head::Extends(p) => match self.protocols.iter().find(|(n, _)| *n == p) { + Some(&(_, g)) => self.plan.groups[g].forms.push(form), + None => self.plan.eager.push(form), + }, + Head::Declare(names) => self.declares.push((form, names)), + Head::Eager => self.plan.eager.push(form), + } + } + + /// Keep the declarations naming any eager var, then order the + /// eager forms by line. + fn finish(mut self, eval: &Evaluator) -> Plan { + if !self.declares.is_empty() { + let deferred_ids: std::collections::HashSet = self + .plan + .groups + .iter() + .flat_map(|g| g.ids.iter().copied()) + .collect(); + let ns = self.ns; + let deferred = |n: &&str| { + eval.registry + .lookup_in_ns(ns, &Symbol::new(Some(ns), n)) + .is_some_and(|id| deferred_ids.contains(&id)) + }; + for (form, names) in self.declares { + if !names.iter().all(deferred) { + self.plan.eager.push(form); + } + } + } + self.plan.eager.sort_by_key(|f| f.line); + self.plan + } +} + +/// Whether `ns/name` is native-backed (a `core_macros` macro or a +/// `stdlib_natives` fn). +fn native_backed(ns: &str, name: &str) -> bool { + match ns { + "clojure.core" => crate::core_macros::CORE_MACROS + .iter() + .any(|(n, _)| *n == name), + "clojure.string" => crate::stdlib_natives::STRING_NATIVES + .iter() + .any(|(n, _)| *n == name), + _ => false, + } +} + +/// Install a library's native-backed fns into its namespace (just +/// made by the file's `ns` form). +fn install_lib_natives(eval: &mut Evaluator, ns: &str) { + if ns == "clojure.string" { + for &(name, f) in crate::stdlib_natives::STRING_NATIVES { + eval.install_native(ns, name, Arc::new(f) as Arc); + } + } +} + +/// Install the native-backed `core.clj` macros (macro-flagged +/// natives; their forms register as metadata sources in [`plan`]). +fn install_core_macros(eval: &mut Evaluator) { + let macro_key = Value::Keyword(Keyword::new(None, "macro")); + for &(name, f) in crate::core_macros::CORE_MACROS { + eval.install_native("clojure.core", name, Arc::new(f) as Arc); + let id = eval + .registry + .intern(&Symbol::new(Some("clojure.core"), name)); + eval.registry.with_state_mut(id, |st| { + let meta = st.meta.take().unwrap_or_else(new_map); + st.meta = Some(meta.insert(macro_key.clone(), Value::Bool(true))); + }); + } +} + +/// Boot's `core.clj` load (current namespace `clojure.core`): defs +/// defer, the rest evaluates in order. +pub(crate) fn boot_lazy(eval: &mut Evaluator, src: &'static str) { + install_core_macros(eval); + let forms = forms_of(src); + let source = Arc::from(BOOT_SOURCE); + let plan = plan(eval, src, &forms, "clojure.core", &source); + // Boot's segment always installs (the lineage is fresh). + let _ = eval.registry.install_lazy(plan.groups); + eval.registry.install_meta_lazy(plan.meta_groups); + for form in &plan.eager { + if let Err(e) = eval_form(eval, form) { + unreachable!("core.clj form failed ({}): {e:?}", form.text); + } + } +} + +/// Load an embedded library lazily inside the caller's load frame: +/// the forms before its first def evaluate eagerly (the `ns` form +/// makes the namespace), then its defs defer into that namespace +/// and the rest evaluates in order. `false` (nothing evaluated) +/// when the file cannot defer: no def follows its prefix, a later +/// form switches namespaces, or every lazy index segment is taken. +pub(crate) fn load_lazy( + eval: &mut Evaluator, + source: &str, + src: &'static str, +) -> Result { + let forms = forms_of(src); + let first_def = forms + .iter() + .position(|f| matches!(head_of(&src[f.start..f.end]), Head::Def { .. })) + .unwrap_or(forms.len()); + let switches = |f: &TopForm| { + let op = token(&src[f.start + 1..f.end]).0; + op == "ns" || op == "in-ns" + }; + if first_def == forms.len() + || forms[first_def..].iter().any(switches) + || !eval.registry.lazy_room() + { + return Ok(false); + } + let source: Arc = Arc::from(source); + for f in &forms[..first_def] { + eval_form( + eval, + &LazyForm { + text: &src[f.start..f.end], + line: f.line, + }, + )?; + } + let ns = eval.current_ns_name(); + install_lib_natives(eval, &ns); + let plan = plan(eval, src, &forms[first_def..], &ns, &source); + eval.registry.install_meta_lazy(plan.meta_groups); + assert!( + eval.registry.install_lazy(plan.groups), + "lazy segment vanished under the load lock" + ); + for form in &plan.eager { + eval_form(eval, form)?; + } + Ok(true) +} + +/// Intern one lazy var of `ns`: unbound, carrying only the flags +/// the analyzer reads before resolving it. +fn register(eval: &mut Evaluator, ns: &str, name: &str, macro_: bool, private: bool) -> u64 { + let id = eval.registry.intern(&Symbol::new(Some(ns), name)); + eval.registry.with_state_mut(id, |st| { + st.root = None; + let mut meta = st.meta.take().unwrap_or_else(new_map); + if macro_ { + meta = meta.insert( + Value::Keyword(Keyword::new(None, "macro")), + Value::Bool(true), + ); + } + if private { + meta = meta.insert( + Value::Keyword(Keyword::new(None, "private")), + Value::Bool(true), + ); + } + st.meta = Some(meta); + }); + id +} + +/// Evaluate one form as a whole-file read would have: read from its +/// line, so spans and `:line` metadata match. +fn eval_form(eval: &mut Evaluator, form: &LazyForm) -> Result, SourceError> { + let line = u32::try_from(form.line).expect("embedded files stay under 4G lines"); + eval.eval_source_at(form.text, line) +} + +/// Force the lazy group defining `id` (`false` when another force +/// already did, or `id` is mid-force on this thread — a form +/// resolving its own name sees the unbound var, exactly as when +/// its file evaluated it). +pub(crate) fn force(eval: &mut Evaluator, id: u64) -> bool { + let _serial = eval.registry.lock_lazy_force(); + let Some(group) = eval.registry.take_lazy(id) else { + return false; + }; + eval_group(eval, &group); + eval.registry.finish_lazy(&group); + true +} + +/// Give a native-backed macro its `core.clj` metadata: evaluate the +/// form (which defines the interpreted macro and its metadata), +/// then put the native root back. `false` when `id` has no pending +/// metadata source. +pub(crate) fn force_meta(eval: &mut Evaluator, id: u64) -> bool { + let _serial = eval.registry.lock_lazy_force(); + let Some(group) = eval.registry.take_meta_lazy(id) else { + return false; + }; + let native = eval.registry.root_cloned(id); + eval_group(eval, &group); + // The form just defined the interpreted fn: keep it for the + // native's slow path (see [`interpreted`]) — unless the def + // landed elsewhere (a recreated namespace), when the root is + // still the native and must never be its own fallback. + if let Some(f) = eval + .registry + .root_cloned(id) + .filter(|f| !same_root(f, native.as_ref())) + { + eval.registry.set_fallback(id, f); + } + eval.registry.with_state_mut(id, |st| st.root = native); + true +} + +/// Whether `f` is the very root `root` (natives compare by identity). +fn same_root(f: &Value, root: Option<&Value>) -> bool { + matches!((f, root), (Value::Extension(a), Some(Value::Extension(b))) if Arc::ptr_eq(a, b)) +} + +/// The interpreted definition behind a native-backed var (a +/// native's slow path): its file form evaluated once — defining +/// the upstream fn — with the var's state put back as it was +/// (`None` when the var has no source). +pub(crate) fn interpreted(eval: &mut Evaluator, id: u64) -> Option { + // Under the force lock (only argument shapes outside the fast + // paths get here): the cached fallback, else the pending + // metadata force captures one (settling the metadata with the + // same evaluation — a def inside the source would drop it + // otherwise), else the permanent source runs. + let _serial = eval.registry.lock_lazy_force(); + if let Some(f) = eval.registry.fallback(id) { + return Some(f); + } + if force_meta(eval, id) { + return eval.registry.fallback(id); + } + let group = eval.registry.native_source(id)?; + let saved = eval.registry.state(id)?; + eval_group(eval, &group); + let f = eval.registry.root_cloned(id); + let native = saved.root.clone(); + eval.registry.with_state_mut(id, |st| *st = saved); + let f = f.filter(|f| !same_root(f, native.as_ref()))?; + eval.registry.set_fallback(id, f.clone()); + Some(f) +} + +/// Evaluate a group's forms in its file's context (see [`force`]). +fn eval_group(eval: &mut Evaluator, group: &LazyGroup) { + let counters = eval.registry.counters(); + let saved = eval.swap_context(Bindings::new(), Arc::clone(&group.source)); + // The file's `*ns*`, as a permanent-style base frame (its + // tombstone after `remove-ns`, which then absorbs the defs). + if let (Some(var), Some(ns)) = ( + core_var(eval, "*ns*"), + eval.registry.ns_live_or_removed_id(&group.ns), + ) { + eval.push_binding(core_var_handle(var, "*ns*"), ns_value(&group.ns, ns)); + } + for form in &group.forms { + if let Err(e) = eval_form(eval, form) { + unreachable!("deferred form failed ({}): {e:?}", form.text); + } + } + eval.swap_context(saved.0, saved.1); + eval.registry.restore_counters(counters); +} + +#[cfg(test)] +mod tests { + use super::{ + Head, LazyIndex, boot_lazy, head_of, install_lib_natives, load_lazy, native_backed, + split_top_forms, + }; + use crate::ns::boot; + + #[test] + fn scans_stray_tokens_escapes_and_unterminated_forms() { + // Top-level atoms are skipped (the embedded files hold none); + // string escapes and newlines keep their brackets literal and + // their lines counted; an unterminated form runs to the end. + let src = "x 1 :k\n(def s \"a\\\"(\nb\")\n(def t \"open"; + let forms = split_top_forms(src); + let texts: Vec<(&str, usize)> = forms + .iter() + .map(|f| (&src[f.start..f.end], f.line)) + .collect(); + assert_eq!( + texts, + vec![("(def s \"a\\\"(\nb\")", 2), ("(def t \"open", 4)] + ); + } + + #[test] + fn plan_keeps_unknown_extends_and_partly_eager_declares() { + // `extend-protocol` of a protocol the file never defined, and a + // `declare` naming an eagerly defined var, both stay eager (the + // eager forms run in the current namespace, `user` here). + let mut eval = boot(); + boot_lazy( + &mut eval, + "(declare lz-eager lz-later) (def lz-eager 1) (defn lz-later [] 2) + (extend-protocol clojure.core/Inst java.util.Date (inst-ms* [d] 0))", + ); + let out = eval + .eval_source("[lz-eager (clojure.core/lz-later)]") + .unwrap(); + assert_eq!( + clj_value::pr_to_string(&out[0], &clj_value::PrintOpts::pr()), + "[1 2]" + ); + assert!(!native_backed("other.ns", "split")); + // Only `clojure.string` has library natives. + install_lib_natives(&mut eval, "other.ns"); + } + + #[test] + fn lazy_load_declines_files_it_cannot_defer() { + let mut eval = boot(); + // No def after the prefix, and a later namespace switch: both + // decline untouched (the caller loads them eagerly). + assert!(!load_lazy(&mut eval, "a.clj", "(ns lz-a) (+ 1 2)").unwrap()); + assert!(!load_lazy(&mut eval, "b.clj", "(ns lz-b) (defn f [] 1) (in-ns 'lz-c)").unwrap()); + // A failing eager form after the defs is the load's error. + let err = load_lazy(&mut eval, "c.clj", "(ns lz-d) (defn g [] 1) (no-such-fn)"); + assert!(err.is_err()); + } + + // The embedded boot sources are fixed, so a failing form is a + // port bug: loud at boot (eager) or at the first use (deferred). + #[test] + #[should_panic(expected = "core.clj form failed")] + fn broken_eager_form_panics_loudly() { + let mut eval = boot(); + boot_lazy(&mut eval, "(do (broken"); + } + + #[test] + #[should_panic(expected = "deferred form failed")] + fn broken_deferred_form_panics_on_force() { + let mut eval = boot(); + boot_lazy(&mut eval, "(defn broken-lazy [)"); + let _ = eval.eval_source("(clojure.core/broken-lazy)"); + } + + #[test] + fn splits_top_forms_past_strings_chars_and_comments() { + let src = "; c (\n(defn a \"doc (\" [x] \\( x)\n\n(defmacro b [] ;; ) x\n `(\\) ~@[]))\n(def c 1)"; + let forms = split_top_forms(src); + let texts: Vec<(&str, usize)> = forms + .iter() + .map(|f| (&src[f.start..f.end], f.line)) + .collect(); + assert_eq!( + texts, + vec![ + ("(defn a \"doc (\" [x] \\( x)", 2), + ("(defmacro b [] ;; ) x\n `(\\) ~@[]))", 4), + ("(def c 1)", 6), + ] + ); + } + + #[test] + fn heads_name_vars_and_flags() { + let def = |s| match head_of(s) { + Head::Def { + name, + macro_, + private, + } => Some((name.to_owned(), macro_, private)), + _ => None, + }; + assert_eq!(def("(defn f [x] x)"), Some(("f".into(), false, false))); + assert_eq!(def("(defn- g [] 1)"), Some(("g".into(), false, true))); + assert_eq!( + def("(defmacro ^{:private true} m [])"), + Some(("m".into(), true, true)) + ); + assert_eq!( + def("(defn ^:private ^String h [])"), + Some(("h".into(), false, true)) + ); + assert_eq!(def("(def x 1)"), None); + assert!(matches!( + head_of("(defprotocol P \"doc\" (m1 [x]) (m2 [x y]))"), + Head::Protocol { names } if names == ["P", "P-interface", "m1", "m2"] + )); + assert!(matches!( + head_of("(extend-protocol P T (m1 [x] x))"), + Head::Extends("P") + )); + } + + #[test] + fn index_tests_and_clears() { + let ix = LazyIndex::new(&[5, 7, 70]); + assert!(ix.test(5) && ix.test(7) && ix.test(70)); + assert!(!ix.test(4) && !ix.test(6) && !ix.test(71) && !ix.test(1000)); + ix.clear(7); + ix.clear(3); + assert!(!ix.test(7) && ix.test(70)); + let empty = LazyIndex::new(&[]); + assert!(!empty.test(0)); + } +} diff --git a/rust/crates/clj-eval/src/core_macros.rs b/rust/crates/clj-eval/src/core_macros.rs new file mode 100644 index 0000000000..c15c919998 --- /dev/null +++ b/rust/crates/clj-eval/src/core_macros.rs @@ -0,0 +1,662 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! Native expanders for the `core.clj` macros nearly every script +//! uses (#226). Each returns exactly what its `core.clj` definition +//! returns — the same (unqualified or `clojure.core/`) symbols, +//! gensym shapes, and errors — so expansion skips the interpreted +//! macro and its definition is never forced. The `core.clj` form +//! stays the var's metadata source (`core_lazy` forces it on a +//! metadata read only). Each native is the macro fn: `&form` and +//! `&env` are the first two arguments. + +use clj_value::{List, Map, Symbol, Value}; + +use crate::error::NativeError; +use crate::eval::Evaluator; +use clj_value::ThrowClass; + +use crate::fx4_form::{ + form_head, gs, iae, is_vector_val, kw, lst, qcall, str_of, sym, ucall, vector_items, vecv, +}; +use crate::lazy::{collect_all, is_seq_value}; +use crate::ns::signal_to_native; + +type Expand = fn(&mut Evaluator, &[Value]) -> Result; + +/// The native-backed `core.clj` macros, by name. +pub(crate) const CORE_MACROS: &[(&str, Expand)] = &[ + ("when", expand_when), + ("when-not", expand_when_not), + ("if-not", expand_if_not), + ("and", expand_and), + ("or", expand_or), + ("cond", expand_cond), + ("->", expand_thread_first), + ("->>", expand_thread_last), + ("comment", expand_comment), + ("while", expand_while), + ("defn", expand_defn), + ("defn-", expand_defn_private), + ("for", expand_for), + ("doseq", expand_doseq), + ("..", expand_dotdot), +]; + +/// The user args after `&form`/`&env`, with at least `min` of them +/// (a short call is the macro's own arity error, counted by the +/// expansion wrapper without the hidden params). +fn user_args<'a>(display: &str, args: &'a [Value], min: usize) -> Result<&'a [Value], NativeError> { + if args.len() < min + 2 { + return Err(NativeError::arity(display, args.len())); + } + Ok(&args[2..]) +} + +/// `(apply list 'do body)`. +fn do_body(body: &[Value]) -> Value { + ucall("do", body.to_vec()) +} + +/// `[test & body]` ⇒ `(if test (do body…))`. +fn expand_when(_: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/when", args, 1)?; + Ok(ucall("if", vec![rest[0].clone(), do_body(&rest[1..])])) +} + +/// `[test & body]` ⇒ `(if test nil (do body…))`. +fn expand_when_not(_: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/when-not", args, 1)?; + Ok(ucall( + "if", + vec![rest[0].clone(), Value::Nil, do_body(&rest[1..])], + )) +} + +/// `[test then]` ⇒ `(clojure.core/if-not test then nil)`; +/// `[test then else]` ⇒ `(if test else then)`. +fn expand_if_not(_: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/if-not", args, 2)?; + match rest { + [test, then] => Ok(qcall( + "if-not", + vec![test.clone(), then.clone(), Value::Nil], + )), + [test, then, els] => Ok(ucall("if", vec![test.clone(), els.clone(), then.clone()])), + _ => Err(NativeError::arity("clojure.core/if-not", args.len())), + } +} + +/// `and`/`or` share their shape: one gensym'd `let*` around an `if` +/// that recurs on the rest (`and` keeps the falsey value, `or` the +/// truthy one). +fn expand_and_or( + eval: &mut Evaluator, + args: &[Value], + name: &str, + empty: Value, +) -> Result { + let rest = user_args(&format!("clojure.core/{name}"), args, 0)?; + match rest { + [] => Ok(empty), + [x] => Ok(x.clone()), + [x, next @ ..] => { + let g = Value::Symbol(gs(eval, name)); + let recur = qcall(name, next.to_vec()); + let branches = if name == "and" { + vec![g.clone(), recur, g.clone()] + } else { + vec![g.clone(), g.clone(), recur] + }; + Ok(ucall( + "let*", + vec![vecv(vec![g.clone(), x.clone()]), ucall("if", branches)], + )) + } + } +} + +fn expand_and(eval: &mut Evaluator, args: &[Value]) -> Result { + expand_and_or(eval, args, "and", Value::Bool(true)) +} + +fn expand_or(eval: &mut Evaluator, args: &[Value]) -> Result { + expand_and_or(eval, args, "or", Value::Nil) +} + +/// `[& clauses]` ⇒ `(if t e (clojure.core/cond rest…))`, nil when +/// empty; a lone trailing test is the IAE. +fn expand_cond(_: &mut Evaluator, args: &[Value]) -> Result { + let clauses = user_args("clojure.core/cond", args, 0)?; + match clauses { + [] => Ok(Value::Nil), + [_] => Err(iae("cond requires an even number of forms".to_owned())), + [test, then, rest @ ..] => Ok(ucall( + "if", + vec![test.clone(), then.clone(), qcall("cond", rest.to_vec())], + )), + } +} + +/// The items of a threaded form that is a seq (`seq?`), else `None`. +fn seq_form_items(eval: &mut Evaluator, form: &Value) -> Result>, NativeError> { + match form { + Value::List(l) => Ok(Some(l.iter().cloned().collect())), + v if is_seq_value(v) => collect_all(eval, v.clone()) + .map(Some) + .map_err(signal_to_native), + _ => Ok(None), + } +} + +/// `->`/`->>`: thread `x` into each form (seq forms keep their +/// metadata; anything else becomes `(form x)`). +fn thread( + eval: &mut Evaluator, + args: &[Value], + display: &str, + last: bool, +) -> Result { + let rest = user_args(display, args, 1)?; + let mut x = rest[0].clone(); + for form in &rest[1..] { + x = match seq_form_items(eval, form)? { + Some(items) => { + let mut iter = items.into_iter(); + let mut threaded = vec![iter.next().unwrap_or(Value::Nil)]; + if last { + threaded.extend(iter); + threaded.push(x); + } else { + threaded.push(x); + threaded.extend(iter); + } + Value::List(List::from_vec(threaded)).with_meta(form.meta().cloned()) + } + None => lst(vec![form.clone(), x]), + }; + } + Ok(x) +} + +fn expand_thread_first(eval: &mut Evaluator, args: &[Value]) -> Result { + thread(eval, args, "clojure.core/->", false) +} + +fn expand_thread_last(eval: &mut Evaluator, args: &[Value]) -> Result { + thread(eval, args, "clojure.core/->>", true) +} + +/// `[& body]` ⇒ nil. +fn expand_comment(_: &mut Evaluator, args: &[Value]) -> Result { + user_args("clojure.core/comment", args, 0)?; + Ok(Value::Nil) +} + +/// `[test & body]` ⇒ `(clojure.core/loop [] (clojure.core/when test +/// body… (recur)))`. +fn expand_while(_: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/while", args, 1)?; + let mut when = vec![rest[0].clone()]; + when.extend(rest[1..].iter().cloned()); + when.push(ucall("recur", Vec::new())); + Ok(qcall("loop", vec![vecv(Vec::new()), qcall("when", when)])) +} + +/// `defn`: `(do (def name doc? (fn sigs…)) (alter-meta! (var name) +/// assoc :arglists '(…)) [(alter-meta! (var name) merge 'attr)] +/// (var name))` — doc, attr map, single-arity vector, and trailing +/// attr map handled like `core.clj`'s `defn`. +fn expand_defn(eval: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/defn", args, 1)?; + defn_form(eval, &rest[0], &rest[1..]) +} + +fn defn_form(eval: &mut Evaluator, name: &Value, fdecl: &[Value]) -> Result { + if !matches!(name, Value::Symbol(_)) { + return Err(iae("First argument to defn must be a symbol".to_owned())); + } + let (doc, attr, fdecl) = defn_parts(fdecl); + let mut arglists = Vec::with_capacity(fdecl.len()); + for sig in &fdecl { + arglists.push(sig_arglist(eval, sig)?); + } + let mut fnform = vec![sym("fn")]; + fnform.extend(fdecl); + let mut defform = vec![sym("def"), name.clone()]; + defform.extend(doc); + defform.push(lst(fnform)); + let var = ucall("var", vec![name.clone()]); + let metaform = ucall( + "alter-meta!", + vec![ + var.clone(), + sym("assoc"), + kw("arglists"), + ucall("quote", vec![lst(arglists)]), + ], + ); + let mut out = vec![sym("do"), lst(defform), metaform]; + if let Some(attr) = attr.filter(|m| !m.is_empty()) { + out.push(ucall( + "alter-meta!", + vec![ + var.clone(), + sym("merge"), + ucall("quote", vec![Value::Map(attr)]), + ], + )); + } + out.push(var); + Ok(lst(out)) +} + +/// `defn`'s declaration peeled like `core.clj`: the docstring, the +/// leading and trailing attr maps (merged), and the arity forms (a +/// single-arity body wrapped as one). +fn defn_parts(fdecl: &[Value]) -> (Option, Option, Vec) { + let mut fdecl = fdecl.to_vec(); + let doc = match fdecl.first() { + Some(d @ Value::Str(_)) => Some(d.clone()), + _ => None, + }; + if doc.is_some() { + fdecl.remove(0); + } + let attr = match fdecl.first() { + Some(Value::Map(m)) => Some(m.clone()), + _ => None, + }; + if attr.is_some() { + fdecl.remove(0); + } + if fdecl.first().is_some_and(is_vector_val) { + fdecl = vec![lst(fdecl)]; + } + let attr2 = match fdecl.last() { + Some(Value::Map(m)) => Some(m.clone()), + _ => None, + }; + if attr2.is_some() { + fdecl.pop(); + } + (doc, merge_attrs(attr, attr2), fdecl) +} + +/// `(merge attr attr2)` over optional maps (nil when both are). +fn merge_attrs(attr: Option, attr2: Option) -> Option { + match (attr, attr2) { + (None, None) => None, + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (Some(a), Some(b)) => Some(b.iter().fold(a, |m, (k, v)| m.insert(k.clone(), v.clone()))), + } +} + +/// One signature's `:arglists` entry: its params (minus a leading +/// `&form &env` pair), carrying a prepost map's keys as metadata +/// when a body follows the map. +fn sig_arglist(eval: &mut Evaluator, sig: &Value) -> Result { + let items = form_items(eval, sig)?; + let args = items.first().cloned().unwrap_or(Value::Nil); + // `(first args)`, erroring on a non-seqable like `core.clj`'s. + let params = match vector_items(&args) { + Some(params) => params, + None => form_items(eval, &args)?, + }; + let args = if params.first().is_some_and(is_form_sym) { + vecv(params.into_iter().skip(2).collect()) + } else { + args + }; + Ok(match items.get(1) { + Some(Value::Map(pp)) if items.len() > 2 => { + let merged = pp.iter().fold( + args.meta().cloned().unwrap_or_else(clj_value::new_map), + |m, (k, v)| m.insert(k.clone(), v.clone()), + ); + args.with_meta(Some(merged)) + } + _ => args, + }) +} + +/// A form's items: a seq form's elements, else the data seq (the +/// ISeq error for a non-seqable). +fn form_items(eval: &mut Evaluator, v: &Value) -> Result, NativeError> { + match seq_form_items(eval, v)? { + Some(items) => Ok(items), + None => crate::fx4_form::seq_data(v), + } +} + +fn is_form_sym(v: &Value) -> bool { + matches!(v, Value::Symbol(s) if s.namespace().is_none() && s.name() == "&form") +} + +/// `defn-`: `(defn name' decls…)` with `name'` marked `:private`. +fn expand_defn_private(_: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/defn-", args, 1)?; + let Value::Symbol(name) = &rest[0] else { + // `with-meta` on a non-symbol: the JVM cast, as `core.clj` + // would have raised it. + return Err(crate::core::cce_err(&rest[0], "clojure.lang.IObj")); + }; + let meta = name + .meta() + .cloned() + .unwrap_or_else(clj_value::new_map) + .insert(kw("private"), Value::Bool(true)); + let mut out = vec![ + sym("defn"), + Value::Symbol(Symbol::clone(name).with_meta(Some(meta))), + ]; + out.extend(rest[1..].iter().cloned()); + Ok(lst(out)) +} + +/// `core.clj`'s private `assert-args`: the first failing check +/// throws `" requires in :"` (the operator as +/// written; the line from `&form`, empty when absent). +fn assert_args(eval: &Evaluator, form: &Value, checks: &[(bool, &str)]) -> Result<(), NativeError> { + let Some(&(_, what)) = checks.iter().find(|(ok, _)| !ok) else { + return Ok(()); + }; + let line = form + .meta() + .and_then(|m| m.get(&kw("line")).cloned()) + .map_or_else(String::new, |l| str_of(&l)); + Err(iae(format!( + "{} requires {what} in {}:{line}", + str_of(&form_head(form)), + eval.current_ns_name() + ))) +} + +/// Which comprehension: `for` (lazy, `:let`/`:when`/`:while`) or +/// `doseq` (`dorun` over the same shape). +#[derive(Clone, Copy, PartialEq)] +enum Comp { + For, + Doseq, +} + +/// A binding group: the binding pair plus its modifier pairs. +type Group = Vec<(Value, Value)>; + +/// Split the binding vector into groups: each binding pair starts +/// a group, each keyword pair joins the current one (a leading +/// modifier pops the empty group vector, like `core.clj`). +fn binding_groups(pairs: &[Value]) -> Result, NativeError> { + let mut groups: Vec = Vec::new(); + for pair in pairs.chunks(2) { + let (k, v) = (pair[0].clone(), pair[1].clone()); + if matches!(k, Value::Keyword(_)) { + let Some(last) = groups.last_mut() else { + return Err(NativeError::new( + ThrowClass::IllegalStateException, + "Can't pop empty vector".to_owned(), + )); + }; + last.push((k, v)); + } else { + groups.push(vec![(k, v)]); + } + } + Ok(groups) +} + +fn is_kw(v: &Value, name: &str) -> bool { + matches!(v, Value::Keyword(k) if k.namespace().is_none() && k.name() == name) +} + +/// An unknown modifier: `for` names it (IAE); `doseq` throws the +/// JVM's `steppair` NPE verbatim (it never validated modifiers). +fn bad_modifier(comp: Comp, k: &Value) -> NativeError { + match comp { + Comp::For => iae(format!("Invalid 'for' keyword {}", str_of(k))), + Comp::Doseq => NativeError::new( + ThrowClass::NullPointerException, + "Cannot invoke \"clojure.lang.IFn.invoke(Object)\" because \"steppair\" is null" + .to_owned(), + ), + } +} + +/// `(into ls v)` for a `:let` vector (any seqable; anything else is +/// the ISeq error). +fn into_lets(eval: &mut Evaluator, ls: &mut Vec, v: &Value) -> Result<(), NativeError> { + let items = match v { + Value::Nil => Vec::new(), + _ => match vector_items(v) { + Some(items) => items, + None => form_items(eval, v)?, + }, + }; + ls.extend(items); + Ok(()) +} + +/// `(let [pat tmp] inner)`. +fn bind_tmp(pat: &Value, tmp: &Value, inner: Value) -> Value { + ucall("let", vec![vecv(vec![pat.clone(), tmp.clone()]), inner]) +} + +/// `(fn [tmp] body)`. +fn tmp_fn(tmp: &Value, body: Value) -> Value { + ucall("fn", vec![vecv(vec![tmp.clone()]), body]) +} + +/// `for`/`doseq` (`core.clj`'s shared expansion): the first group +/// maps (or mapcats) over its collection; the remaining groups nest +/// as an inner comprehension. A `:when` without `:while` fuses the +/// `:let`/`:when` chain into one mapcat stage; otherwise `:when` +/// filters and `:while` takes-while outside the map. +fn comprehension( + eval: &mut Evaluator, + form: &Value, + seq_exprs: &Value, + body: &[Value], + comp: Comp, +) -> Result { + let items = vector_items(seq_exprs); + assert_args( + eval, + form, + &[ + (items.is_some(), "a vector for its binding"), + ( + items.as_ref().is_none_or(|v| v.len() % 2 == 0), + "an even number of forms in binding vector", + ), + ], + )?; + let groups = binding_groups(&items.unwrap_or_default())?; + let Some((first, rest)) = groups.split_first() else { + return Ok(match comp { + Comp::For => ucall("list", vec![body[0].clone()]), + Comp::Doseq => do_body(body), + }); + }; + let stage = Stage { + comp, + pat: &first[0].0, + xs: &first[0].1, + mods: &first[1..], + tmp: Value::Symbol(gs(eval, "G__")), + nested: !rest.is_empty(), + }; + let core = comprehension_core(comp, rest, body); + let has_when = stage.mods.iter().any(|(k, _)| is_kw(k, "when")); + let has_while = stage.mods.iter().any(|(k, _)| is_kw(k, "while")); + let expansion = if has_when && !has_while { + stage.fused(core)? + } else { + stage.chained(eval, core)? + }; + Ok(match comp { + Comp::For => expansion, + Comp::Doseq => ucall("dorun", vec![expansion]), + }) +} + +/// The innermost expression: the body, or the remaining groups as a +/// nested comprehension. +fn comprehension_core(comp: Comp, rest: &[Group], body: &[Value]) -> Value { + if rest.is_empty() { + return match comp { + Comp::For => body[0].clone(), + Comp::Doseq => do_body(body), + }; + } + let flat: Vec = rest + .iter() + .flat_map(|g| g.iter().flat_map(|(k, v)| [k.clone(), v.clone()])) + .collect(); + let op = if comp == Comp::For { "for" } else { "doseq" }; + let mut nested = vec![sym(op), vecv(flat)]; + nested.extend(body.iter().cloned()); + lst(nested) +} + +/// The first binding group of a comprehension: its pattern, +/// collection, and modifiers, over a fresh temp symbol. +struct Stage<'a> { + comp: Comp, + pat: &'a Value, + xs: &'a Value, + mods: &'a [(Value, Value)], + tmp: Value, + nested: bool, +} + +impl Stage<'_> { + /// `(fn [tmp] (let [pat tmp] inner))` — or `(fn [pat] inner)` + /// when the pattern is a plain symbol (#226: no rebinding per + /// item; the same arity for a `recur` in the body). + fn mapper(&self, inner: Value) -> Value { + if matches!(self.pat, Value::Symbol(s) if s.namespace().is_none() && s.name() != "&") { + return tmp_fn(self.pat, inner); + } + tmp_fn(&self.tmp, bind_tmp(self.pat, &self.tmp, inner)) + } + + /// `:when` without `:while`: one mapcat stage over the + /// `:let`/`:when` chain (a nested `for` core is already a seq; + /// `doseq` always lists). + /// + /// An innermost `for` stage keeps instead (#226): the chain + /// answers the item, or the `__for-keep*` native itself — a + /// sentinel no item can be — when a `:when` rejects it, so no + /// one-item list is built and spliced per item. + fn fused(&self, core: Value) -> Result { + let keep = self.comp == Comp::For && !self.nested; + let skip = if keep { + Value::Symbol(Symbol::new(Some("clojure.core"), "__for-keep*")) + } else { + Value::Nil + }; + let mut inner = if keep || self.nested && self.comp == Comp::For { + core + } else { + ucall("list", vec![core]) + }; + for (k, v) in self.mods.iter().rev() { + inner = if is_kw(k, "let") { + ucall("let", vec![v.clone(), inner]) + } else if is_kw(k, "when") { + ucall("if", vec![v.clone(), inner, skip.clone()]) + } else { + return Err(bad_modifier(self.comp, k)); + }; + } + if keep { + return Ok(qcall( + "__for-keep*", + vec![self.mapper(inner), self.xs.clone(), skip], + )); + } + Ok(ucall("mapcat", vec![self.mapper(inner), self.xs.clone()])) + } + + /// Otherwise: `:when` filters and `:while` takes-while over the + /// collection (each test seeing the `:let`s so far), then a map + /// (mapcat for a nested `for`). + fn chained(&self, eval: &mut Evaluator, core: Value) -> Result { + let mut se = self.xs.clone(); + let mut ls = Vec::new(); + let wrap = |ls: &[Value], inner: Value| { + if ls.is_empty() { + inner + } else { + ucall("let", vec![vecv(ls.to_vec()), inner]) + } + }; + for (k, v) in self.mods { + if is_kw(k, "let") { + into_lets(eval, &mut ls, v)?; + continue; + } + let op = if is_kw(k, "when") { + "filter" + } else if is_kw(k, "while") { + "take-while" + } else { + return Err(bad_modifier(self.comp, k)); + }; + se = ucall(op, vec![self.mapper(wrap(&ls, v.clone())), se]); + } + let op = if self.comp == Comp::For && self.nested { + "mapcat" + } else { + "map" + }; + Ok(ucall(op, vec![self.mapper(wrap(&ls, core)), se])) + } +} + +/// `[seq-exprs body-expr]`. +fn expand_for(eval: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/for", args, 2)?; + if rest.len() > 2 { + return Err(NativeError::arity("clojure.core/for", args.len())); + } + comprehension(eval, &args[0], &rest[0], &rest[1..], Comp::For) +} + +/// `[seq-exprs & body]`. +fn expand_doseq(eval: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/doseq", args, 1)?; + comprehension(eval, &args[0], &rest[0], &rest[1..], Comp::Doseq) +} + +/// `[x form & more]`: each step is a `clojure.core/__meth*` call +/// (a seq form calls its head with its args; a symbol is a 0-arg +/// member), chaining through `clojure.core/..` while forms remain. +fn expand_dotdot(eval: &mut Evaluator, args: &[Value]) -> Result { + let rest = user_args("clojure.core/..", args, 2)?; + let (x, form, more) = (&rest[0], &rest[1], &rest[2..]); + let one = match seq_form_items(eval, form)? { + Some(items) => { + let mut items = items.into_iter(); + let member = items.next().unwrap_or(Value::Nil); + let mut call = vec![ucall("quote", vec![member]), x.clone()]; + call.extend(items); + qcall("__meth*", call) + } + None => qcall( + "__meth*", + vec![ucall("quote", vec![form.clone()]), x.clone()], + ), + }; + if more.is_empty() { + return Ok(one); + } + let mut chain = vec![one]; + chain.extend(more.iter().cloned()); + Ok(qcall("..", chain)) +} diff --git a/rust/crates/clj-eval/src/env.rs b/rust/crates/clj-eval/src/env.rs index 8f685be92b..465f1b591c 100644 --- a/rust/crates/clj-eval/src/env.rs +++ b/rust/crates/clj-eval/src/env.rs @@ -11,18 +11,44 @@ //! for ANY `Unbound` root (so `(def x x)` stays unbound, probe25). use std::collections::HashMap; -use std::collections::hash_map::Entry; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; -use clj_value::{ClassVal, FxBuildHasher, Keyword, Map, Symbol, Unbound, Value, Var, new_map}; +use clj_value::{ + ClassVal, FxBuildHasher, Keyword, Map, SymKey, Symbol, Unbound, Value, Var, new_map, +}; +use crate::core_lazy::{LazyGroup, LazyIndex}; use crate::ext::NsHandle; +/// Lazy index segments: boot's `core.clj` plus the lazily loaded +/// libraries (a full table loads further libraries eagerly). +const LAZY_SEGMENTS: usize = 8; + /// Registry maps hash with Fx (not SipHash): these sit on the var-deref /// hot path, and the keys are not attacker-controlled. pub(crate) type FxHashMap = HashMap; +/// A namespace's refers: simple name → target var symbol and the +/// var id pinned at install. Shared copy-on-write (#226): a fresh +/// namespace referring every public of another installs that +/// namespace's cached [`PublicsTable`] itself, and the reader reads +/// the table in place (`user`, `clojure.string` and each script's +/// `ns` refer all ~700 `clojure.core` publics on every run). +pub(crate) type ReferTable = FxHashMap, (Symbol, u64)>; + +/// Every public intern of a namespace as a [`ReferTable`] (`refer` +/// without `:only`/`:refer`/`:rename`), cached in the namespace until +/// a mapping or var state there changes. +#[derive(Clone, Debug)] +pub(crate) struct PublicsTable { + pub(crate) table: Arc, + /// Some public shares its name with a default import (a refer + /// drops the same-named import, so the table cannot be shared + /// by a namespace holding the defaults). + shadows_default_import: bool, +} + /// A var's evaluator-side state: the handle in [`Value::Var`] is /// just `(id, sym)`; everything mutable lives here. #[derive(Clone, Debug)] @@ -61,9 +87,46 @@ pub struct Namespace { vars: FxHashMap, ids: FxHashMap, aliases: FxHashMap, - refers: FxHashMap, - imports: FxHashMap>, + refers: Arc, + /// Shared copy-on-write: every namespace starts from the same + /// default imports (see `classemu::default_import_map`). + imports: Arc>>, + /// This namespace's publics as a refer table, built on demand and + /// dropped by every intern, unmap, and var state write (privacy + /// is var meta). + publics_table: Option, meta: Option, + /// This namespace as a value (every fresh var's `:ns` meta + /// shares it, instead of minting one per var). + handle: Arc, + /// The reader's view of the mappings, built on first read and + /// dropped by every alias/refer/import change: `user` refers + /// every `clojure.core` public, and each load used to rebuild it. + reader_maps: Option, +} + +/// A namespace's mappings as the reader takes them (`::alias/kw`, +/// syntax-quote refers and class names), shared between reads. +#[derive(Clone, Debug)] +pub struct ReaderMaps { + pub aliases: Arc>, + pub refers: Arc, + pub class_map: Arc>, +} + +/// The reader's class map over the default imports (shared by every +/// namespace still holding them). +fn default_class_map() -> Arc> { + static MAP: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Arc::new(class_map_of(&crate::classemu::default_import_map()))); + Arc::clone(&MAP) +} + +fn class_map_of(imports: &FxHashMap>) -> HashMap { + imports + .iter() + .map(|(simple, class)| (simple.clone(), class.name().to_owned())) + .collect() } impl Namespace { @@ -80,12 +143,38 @@ impl Namespace { vars: FxHashMap::default(), ids: FxHashMap::default(), aliases: FxHashMap::default(), - refers: FxHashMap::default(), - imports: crate::classemu::default_imports().collect(), + refers: Arc::default(), + imports: crate::classemu::default_import_map(), + publics_table: None, meta: None, + handle: Arc::new(NsHandle { + name: Arc::from(name), + id, + }), + reader_maps: None, } } + /// [`ReaderMaps`] for this namespace (cached until a mapping + /// changes). + pub fn reader_maps(&mut self) -> ReaderMaps { + if let Some(m) = &self.reader_maps { + return m.clone(); + } + let defaults = crate::classemu::default_import_map(); + let maps = ReaderMaps { + aliases: Arc::new(self.alias_map()), + refers: self.refers.clone(), + class_map: if Arc::ptr_eq(&self.imports, &defaults) { + default_class_map() + } else { + Arc::new(class_map_of(&self.imports)) + }, + }; + self.reader_maps = Some(maps.clone()); + maps + } + /// This namespace's name. #[must_use] pub fn name(&self) -> &str { @@ -98,24 +187,18 @@ impl Namespace { self.id } - /// Qualify an unqualified symbol into this namespace. - #[must_use] - pub fn qualify(&self, sym: &Symbol) -> Symbol { - if sym.namespace().is_some() { - sym.clone() - } else { - Symbol::new(Some(&self.name), sym.name()) - } - } - /// Look up a var id by symbol (unqualified resolves here). #[must_use] pub fn lookup(&self, sym: &Symbol) -> Option { - let q = self.qualify(sym); - if q.namespace() == Some(self.name.as_str()) { - self.ids.get(&q).copied() - } else { - None + // Borrowed probe: no qualified symbol is built (the analyzer + // resolves through here per symbol). + match sym.namespace() { + None => self + .ids + .get(&(Some(self.name.as_str()), sym.name()) as &dyn SymKey) + .copied(), + Some(ns) if ns == self.name => self.ids.get(sym).copied(), + Some(_) => None, } } @@ -132,21 +215,36 @@ impl Namespace { let id = *next_id; *next_id += 1; let mut state = VarState::fresh(); - let ns_key = Value::Keyword(Keyword::new(None, "ns")); - let ns_val = Value::Extension(std::sync::Arc::new(NsHandle { - name: std::sync::Arc::from(self.name.as_str()), - id: self.id, - })); - let name_key = Value::Keyword(Keyword::new(None, "name")); - let name_val = Value::Symbol(Symbol::new(None, sym.name())); - state.meta = Some(new_map().insert(ns_key, ns_val).insert(name_key, name_val)); + let ns_val: Arc = self.handle.clone(); + static KEYS: std::sync::LazyLock<[Value; 2]> = std::sync::LazyLock::new(|| { + [ + Value::Keyword(clj_value::static_keyword!("ns")), + Value::Keyword(clj_value::static_keyword!("name")), + ] + }); + state.meta = Some(Map::from_distinct_keywords( + &*KEYS, + vec![ + Value::Extension(ns_val), + Value::Symbol(Symbol::from_shared(None, Arc::clone(sym.name_arc()))), + ], + )); self.vars.insert(id, state); self.ids.insert(sym.clone(), id); - self.refers.remove(sym.name()); - self.imports.remove(sym.name()); + self.publics_table = None; + let unrefer = self.drop_refer(sym.name()); + if self.drop_import(sym.name()) || unrefer { + self.reader_maps = None; + } id } + /// Drop the refer of a simple name (`true` when there was one), + /// copying a shared table only then. + fn drop_refer(&mut self, name: &str) -> bool { + self.refers.contains_key(name) && Arc::make_mut(&mut self.refers).remove(name).is_some() + } + /// Alias → target-namespace-name (for reader options). #[must_use] pub fn alias_map(&self) -> HashMap { @@ -156,20 +254,6 @@ impl Namespace { .collect() } - /// Referred name → (home namespace, name) (for reader - /// options; syntax-quote qualifies refers home). - #[must_use] - pub fn refer_map(&self) -> HashMap { - self.refers - .iter() - .filter_map(|(name, (target, _))| { - target - .namespace() - .map(|ns| (name.clone(), (ns.to_owned(), target.name().to_owned()))) - }) - .collect() - } - /// This namespace's metadata (`None` = `nil`, like the JVM). #[must_use] pub fn meta(&self) -> Option<&Map> { @@ -192,6 +276,7 @@ impl Namespace { None => { self.aliases .insert(alias.to_owned(), (target.to_owned(), target_id)); + self.reader_maps = None; Ok(()) } Some((old, _)) if old == target => Ok(()), @@ -202,6 +287,7 @@ impl Namespace { /// Drop an alias (missing is a silent no-op). pub fn remove_alias(&mut self, alias: &str) { self.aliases.remove(alias); + self.reader_maps = None; } /// Install a refer (replaces silently; callers keep interns — @@ -211,8 +297,49 @@ impl Namespace { /// of the same name goes (imports live under refers, like the /// JVM's single mapping). pub fn add_refer(&mut self, name: &str, target: Symbol, target_id: u64) { - self.refers.insert(name.to_owned(), (target, target_id)); - self.imports.remove(name); + Arc::make_mut(&mut self.refers).insert(Arc::from(name), (target, target_id)); + self.drop_import(name); + self.reader_maps = None; + } + + /// Refer every public in `publics` except the `exclude`d simple + /// names. A namespace with no mappings of its own yet (each `ns` + /// form's `refer-clojure`) takes the table itself — or a trimmed + /// copy when names are excluded; anything else installs entry by + /// entry like [`add_refer`](Self::add_refer), interns winning. + pub(crate) fn refer_all(&mut self, publics: &PublicsTable, exclude: &[&str]) { + if Arc::ptr_eq(&self.refers, &publics.table) && exclude.is_empty() { + return; + } + let fresh = self.ids.is_empty() + && self.refers.is_empty() + && !publics.shadows_default_import + && Arc::ptr_eq(&self.imports, &crate::classemu::default_import_map()); + if fresh { + self.refers = if exclude.is_empty() { + Arc::clone(&publics.table) + } else { + let mut table = (*publics.table).clone(); + for name in exclude { + table.remove(*name); + } + Arc::new(table) + }; + self.reader_maps = None; + return; + } + for (name, (target, id)) in publics.table.iter() { + if exclude.contains(&&**name) + || self + .ids + .contains_key(&(Some(self.name.as_str()), &**name) as &dyn SymKey) + { + continue; + } + Arc::make_mut(&mut self.refers).insert(Arc::clone(name), (target.clone(), *id)); + self.drop_import(name); + } + self.reader_maps = None; } /// Drop the intern, refer, and import mappings for a simple name @@ -221,13 +348,22 @@ impl Namespace { pub fn unmap(&mut self, name: &str) { let q = Symbol::new(Some(&self.name), name); self.ids.remove(&q); - self.refers.remove(name); - self.imports.remove(name); + self.publics_table = None; + self.drop_refer(name); + self.drop_import(name); + self.reader_maps = None; } /// Install an import: simple name → class (S16 `import`). pub fn import_class(&mut self, simple: &str, class: Arc) { - self.imports.insert(simple.to_owned(), class); + Arc::make_mut(&mut self.imports).insert(simple.to_owned(), class); + self.reader_maps = None; + } + + /// Drop the import of a simple name (`true` when there was one), + /// copying the shared table only then. + fn drop_import(&mut self, name: &str) -> bool { + self.imports.contains_key(name) && Arc::make_mut(&mut self.imports).remove(name).is_some() } /// The import for a simple name, if any. @@ -249,7 +385,7 @@ impl Namespace { /// Refer mappings: simple name to qualified target and the /// var id pinned at install. pub fn refers(&self) -> impl Iterator + '_ { - self.refers.iter().map(|(n, (s, i))| (n.as_str(), s, *i)) + self.refers.iter().map(|(n, (s, i))| (&**n, s, *i)) } /// Aliases: alias to target namespace name and the id pinned @@ -266,8 +402,10 @@ impl Namespace { self.vars.get(&id) } - /// The var's state, mutably. + /// The var's state, mutably (any write may change privacy, so the + /// publics table goes). pub fn state_mut(&mut self, id: u64) -> Option<&mut VarState> { + self.publics_table = None; self.vars.get_mut(&id) } } @@ -280,14 +418,13 @@ fn ensure_ns_in<'a>( next_ns_id: &mut u64, name: &str, ) -> &'a mut Namespace { - match namespaces.entry(name.to_owned()) { - Entry::Occupied(e) => e.into_mut(), - Entry::Vacant(e) => { - let id = *next_ns_id; - *next_ns_id += 1; - e.insert(Namespace::new(name, id)) - } + // Probe first: hits (nearly every call) allocate no key. + if !namespaces.contains_key(name) { + let id = *next_ns_id; + *next_ns_id += 1; + namespaces.insert(name.to_owned(), Namespace::new(name, id)); } + namespaces.get_mut(name).expect("namespace ensured above") } /// The namespace registry (S5A): all namespaces plus the current @@ -300,7 +437,9 @@ pub struct NamespaceRegistry { current: String, next_id: u64, next_ns_id: u64, - by_id: FxHashMap, + /// Each var's home namespace name (shared with the namespace's + /// handle — state lookups clone it per write). + by_id: FxHashMap>, removed: FxHashMap, /// Anonymous var states (S4b `with-local-vars`): ids with no /// namespace home, so `all-ns`/`ns-interns` never see them. @@ -380,6 +519,39 @@ impl NamespaceRegistry { self.namespaces.get(name).or_else(|| self.removed.get(name)) } + /// A live namespace's publics as a [`PublicsTable`] (`None` when + /// missing), cached in the namespace. An intern's state always + /// lives beside its mapping ([`Namespace::intern`] inserts both, + /// and states are never dropped), so the namespace sees every + /// privacy change. + pub(crate) fn publics_table(&mut self, ns_name: &str) -> Option { + let ns = self.namespaces.get_mut(ns_name)?; + if let Some(cached) = &ns.publics_table { + return Some(cached.clone()); + } + let private = Value::Keyword(clj_value::static_keyword!("private")); + let mut table = + ReferTable::with_capacity_and_hasher(ns.ids.len(), FxBuildHasher::default()); + for (sym, &id) in &ns.ids { + let private = ns + .vars + .get(&id) + .and_then(|st| st.meta.as_ref()) + .and_then(|m| m.get(&private)) + .is_some_and(Value::is_truthy); + if !private { + table.insert(Arc::clone(sym.name_arc()), (sym.clone(), id)); + } + } + let defaults = crate::classemu::default_import_map(); + let publics = PublicsTable { + shadows_default_import: defaults.keys().any(|k| table.contains_key(k.as_str())), + table: Arc::new(table), + }; + ns.publics_table = Some(publics.clone()); + Some(publics) + } + /// A live or removed namespace by name, mutably (name-based /// callers only; handle-pinned writers use /// [`NamespaceRegistry::ns_for_handle_mut`]). @@ -489,17 +661,21 @@ impl NamespaceRegistry { // removed one — so only alias targets read frozen // state here. let ns = self.ns_live_or_removed(target)?; - let q = Symbol::new(Some(target), sym.name()); - ns.ids.contains_key(&q).then_some(q) + let key: &dyn SymKey = &(Some(target), sym.name()); + ns.ids + .contains_key(key) + .then(|| Symbol::new(Some(target), sym.name())) } None => { // A removed current namespace still resolves its own // mappings (like the JVM's detached object — the // `*ns*` handle outlives the name). let home = self.ns_live_or_removed(ns_name)?; - let q = Symbol::new(Some(ns_name), sym.name()); - if home.ids.contains_key(&q) { - return Some(q); + if home + .ids + .contains_key(&(Some(ns_name), sym.name()) as &dyn SymKey) + { + return Some(Symbol::new(Some(ns_name), sym.name())); } // Refers validate live: a refer into a removed // namespace stops resolving (the JVM keeps the var @@ -552,20 +728,59 @@ impl NamespaceRegistry { /// when missing — internal callers always hit existing ones). pub fn intern(&mut self, sym: &Symbol) -> u64 { let q = self.qualify(sym); - let ns_name = q.namespace().unwrap_or(&self.current).to_owned(); let Self { namespaces, next_id, next_ns_id, by_id, + current, .. } = self; - let ns = ensure_ns_in(namespaces, next_ns_id, &ns_name); + let ns_name = q.namespace().unwrap_or(current.as_str()); + let ns = ensure_ns_in(namespaces, next_ns_id, ns_name); + let before = *next_id; let id = ns.intern(&q, next_id); - by_id.insert(id, ns_name); + // Only a fresh var needs its home recorded (re-interns hit). + if *next_id != before { + by_id.insert(id, Arc::clone(&ns.handle.name)); + } id } + /// [`intern`](Self::intern) and set the root in the same step + /// (boot installs every native this way). + pub fn intern_with_root(&mut self, sym: &Symbol, root: Value) -> u64 { + let q = &self.qualify(sym); + let Self { + namespaces, + next_id, + next_ns_id, + by_id, + current, + .. + } = self; + let ns_name = q.namespace().unwrap_or(current.as_str()); + let ns = ensure_ns_in(namespaces, next_ns_id, ns_name); + let before = *next_id; + let id = ns.intern(q, next_id); + if *next_id != before { + by_id.insert(id, Arc::clone(&ns.handle.name)); + } + if let Some(st) = ns.state_mut(id) { + st.root = Some(root); + } + id + } + + /// Pre-size a namespace's var tables (boot knows its native + /// count). + pub(crate) fn reserve_vars(&mut self, ns_name: &str, additional: usize) { + if let Some(ns) = self.namespaces.get_mut(ns_name) { + ns.vars.reserve(additional); + ns.ids.reserve(additional); + } + } + /// Intern a `def`-family target: qualify into the current /// namespace, then intern live-or-removed (a removed current /// namespace's tombstone absorbs the `def` — the JVM's @@ -596,7 +811,7 @@ impl NamespaceRegistry { .get_mut(ns_name) .or_else(|| removed.get_mut(ns_name))?; let id = ns.intern(sym, next_id); - by_id.insert(id, ns_name.to_owned()); + by_id.insert(id, Arc::clone(&ns.handle.name)); Some(id) } @@ -623,7 +838,7 @@ impl NamespaceRegistry { }; let home = ns.id(); let id = ns.intern(sym, next_id); - by_id.insert(id, ns_name.to_owned()); + by_id.insert(id, Arc::clone(&ns.handle.name)); Some((id, home)) } @@ -636,9 +851,9 @@ impl NamespaceRegistry { .get(&id) .and_then(|name| { self.namespaces - .get(name) + .get(&**name) .and_then(|ns| ns.state(id)) - .or_else(|| self.removed.get(name).and_then(|ns| ns.state(id))) + .or_else(|| self.removed.get(&**name).and_then(|ns| ns.state(id))) }) .or_else(|| self.anon.get(&id)) } @@ -670,16 +885,16 @@ impl NamespaceRegistry { if self.anon.contains_key(&id) { return self.anon.get_mut(&id); } - let name = self.by_id.get(&id)?.clone(); + let name = Arc::clone(self.by_id.get(&id)?); if self .namespaces - .get(&name) + .get(&*name) .and_then(|ns| ns.state(id)) .is_some() { - return self.namespaces.get_mut(&name)?.state_mut(id); + return self.namespaces.get_mut(&*name)?.state_mut(id); } - self.removed.get_mut(&name)?.state_mut(id) + self.removed.get_mut(&*name)?.state_mut(id) } /// `Var.isBound` (minus thread bindings): a root that is absent @@ -705,6 +920,19 @@ impl NamespaceRegistry { self.state(id).is_some_and(|s| s.dynamic) } + /// Whether the var's metadata maps `key` to a truthy value + /// (`:macro`, `:private`; missing or cleared metadata reads + /// false). No clone at all. + #[must_use] + pub fn meta_flag(&self, id: u64, key: &Keyword) -> bool { + self.state(id).is_some_and(|s| { + s.meta + .as_ref() + .and_then(|m| m.get(&Value::Keyword(key.clone()))) + .is_some_and(Value::is_truthy) + }) + } + /// The var's metadata, cloned (`None` when uninterned or cleared). /// Narrower than [`state`](Self::state): no root clone. #[must_use] @@ -759,6 +987,16 @@ pub struct SharedRegistry { /// the per-evaluator root cache reads it lock-free (see /// `Evaluator::deref_var_cached`). epoch: Arc, + /// The next throwable/closure/native identity (lock-free: closure + /// creation draws one per evaluation — #226). + ids: Arc, + /// Deferred vars (see `core_lazy`): boot's `core.clj` segment, + /// then one per lazily loaded library — a lock-free membership + /// test on every analyzer resolution. + lazy: Arc<[OnceLock; LAZY_SEGMENTS]>, + /// Serializes lazy forcing (reentrant: a forced form may force + /// another), so no thread observes a var mid-force as unbound. + lazy_force: Arc, } /// The lineage-global contents: the registry plus the evaluator @@ -768,12 +1006,21 @@ pub struct SharedRegistry { #[derive(Debug)] struct SharedInner { registry: NamespaceRegistry, - next_id: u64, fn_seq: u64, gensym: u64, eval_seq: u64, alter_locks: FxHashMap>, protocol_fn_methods: FxHashMap, + /// Unforced lazy `core.clj` groups, by each var they define. + lazy_groups: FxHashMap>, + /// Native-backed vars' file forms, kept as their metadata + /// source (forced by a metadata read only). + meta_groups: FxHashMap>, + /// The same forms, kept for good: a native's slow path evaluates + /// one into its interpreted definition (`core_lazy::interpreted`). + native_sources: FxHashMap>, + /// Those interpreted definitions, once evaluated. + fallbacks: FxHashMap, } /// Per-var reentrant alter lock (T1192 FX1): mirrors the JVM's @@ -793,6 +1040,39 @@ struct AlterOwner { depth: u32, } +impl VarAlterLock { + fn new() -> Self { + Self { + state: Mutex::new(AlterOwner { + owner: None, + depth: 0, + }), + cvar: Condvar::new(), + } + } + + /// Take the lock (same-thread reentry nests). + fn acquire(self: Arc) -> VarAlterGuard { + let current = std::thread::current().id(); + let mut owner = self.state.lock().unwrap_or_else(|e| e.into_inner()); + loop { + if matches!(&owner.owner, Some(held) if held == ¤t) { + owner.depth += 1; + break; + } + if owner.owner.is_some() { + owner = self.cvar.wait(owner).unwrap_or_else(|e| e.into_inner()); + } else { + owner.owner = Some(current); + owner.depth = 1; + break; + } + } + drop(owner); + VarAlterGuard { lock: self } + } +} + /// RAII release for [`SharedRegistry::lock_var_for_alter`]. #[derive(Debug)] pub(crate) struct VarAlterGuard { @@ -816,14 +1096,20 @@ impl Default for SharedRegistry { Self { inner: Arc::new(Mutex::new(SharedInner { registry: NamespaceRegistry::new(), - next_id: 1, fn_seq: 0, gensym: 1, eval_seq: 0, alter_locks: FxHashMap::default(), protocol_fn_methods: FxHashMap::default(), + lazy_groups: FxHashMap::default(), + meta_groups: FxHashMap::default(), + native_sources: FxHashMap::default(), + fallbacks: FxHashMap::default(), })), epoch: Arc::new(AtomicU64::new(0)), + ids: Arc::new(AtomicU64::new(1)), + lazy: Arc::new(std::array::from_fn(|_| OnceLock::new())), + lazy_force: Arc::new(VarAlterLock::new()), } } } @@ -857,10 +1143,7 @@ impl SharedRegistry { /// The next throwable/closure/native identity. pub fn next_id(&self) -> u64 { - let mut inner = self.lock(); - let id = inner.next_id; - inner.next_id += 1; - id + self.ids.fetch_add(1, Ordering::Relaxed) } /// The next `fn` display sequence number. @@ -920,15 +1203,10 @@ impl SharedRegistry { self.lock().registry.set_current(name); } - /// Find or create a namespace, returning the (owned) namespace. - pub fn ensure_ns(&self, name: &str) -> Namespace { - self.lock().registry.ensure_ns(name).clone() - } - - /// Find or create a namespace, returning its id (like - /// [`ensure_ns`](Self::ensure_ns), without cloning the - /// namespace). - pub fn ensure_ns_id(&self, name: &str) -> u64 { + /// Find or create a namespace, returning its id. Never the + /// namespace itself: a clone copies every var, refer, and + /// import table, and `refer` calls this once per referred var. + pub fn ensure_ns(&self, name: &str) -> u64 { self.lock().registry.ensure_ns(name).id() } @@ -974,6 +1252,44 @@ impl SharedRegistry { self.lock().registry.ns_live_or_removed(name).cloned() } + /// A live namespace's publics as a shareable refer table (see + /// [`NamespaceRegistry::publics_table`]). + #[must_use] + pub(crate) fn publics_table(&self, ns_name: &str) -> Option { + self.lock().registry.publics_table(ns_name) + } + + /// A live or removed namespace's import of a simple name (a read: + /// no epoch bump; a removed `*ns*` keeps resolving its imports, + /// like the JVM's detached namespace object). + #[must_use] + pub fn import_in(&self, ns_name: &str, simple: &str) -> Option> { + self.lock() + .registry + .ns_live_or_removed(ns_name)? + .get_import(simple) + } + + /// A live or removed namespace's id (no namespace clone). + #[must_use] + pub fn ns_live_or_removed_id(&self, name: &str) -> Option { + self.lock() + .registry + .ns_live_or_removed(name) + .map(Namespace::id) + } + + /// A live or removed namespace's [`ReaderMaps`] (cached in the + /// namespace; filling the cache bumps no epoch — it is not var + /// state). + #[must_use] + pub fn reader_maps(&self, name: &str) -> Option { + self.lock() + .registry + .ns_live_or_removed_mut(name) + .map(Namespace::reader_maps) + } + /// A namespace through a handle-pinned id (see /// [`NamespaceRegistry::ns_for_handle`]). #[must_use] @@ -1032,6 +1348,20 @@ impl SharedRegistry { self.lock().registry.intern(sym) } + /// Intern a var and install its root under one lock (native + /// installs: one lock and one generation bump instead of two). + pub fn intern_with_root(&self, sym: &Symbol, root: Value) -> u64 { + let id = self.lock().registry.intern_with_root(sym, root); + self.bump_epoch(); + id + } + + /// Pre-size a namespace's var tables (see + /// [`NamespaceRegistry::reserve_vars`]). + pub(crate) fn reserve_vars(&self, ns_name: &str, additional: usize) { + self.lock().registry.reserve_vars(ns_name, additional); + } + /// Allocate an anonymous dynamic var (see /// [`NamespaceRegistry::fresh_anonymous`]). pub fn fresh_anonymous(&self) -> u64 { @@ -1084,6 +1414,12 @@ impl SharedRegistry { self.lock().registry.is_dynamic(id) } + /// See [`NamespaceRegistry::meta_flag`] (one lock, no clone). + #[must_use] + pub fn meta_flag(&self, id: u64, key: &Keyword) -> bool { + self.lock().registry.meta_flag(id, key) + } + /// The var's metadata, cloned (see /// [`NamespaceRegistry::meta_cloned`]). #[must_use] @@ -1114,34 +1450,119 @@ impl SharedRegistry { inner .alter_locks .entry(id) - .or_insert_with(|| { - Arc::new(VarAlterLock { - state: Mutex::new(AlterOwner { - owner: None, - depth: 0, - }), - cvar: Condvar::new(), - }) - }) + .or_insert_with(|| Arc::new(VarAlterLock::new())) .clone() }; - let current = std::thread::current().id(); - let mut owner = lock.state.lock().unwrap_or_else(|e| e.into_inner()); - loop { - if matches!(&owner.owner, Some(held) if held == ¤t) { - owner.depth += 1; - break; + lock.acquire() + } + + /// Whether `id` is an unforced deferred var (lock-free). + pub(crate) fn is_lazy(&self, id: u64) -> bool { + self.lazy + .iter() + .map_while(OnceLock::get) + .any(|ix| ix.test(id)) + } + + /// Whether a lazy index segment is still free. + pub(crate) fn lazy_room(&self) -> bool { + self.lazy.iter().any(|seg| seg.get().is_none()) + } + + /// Install one file's deferred groups in the next free segment + /// (`false` when none is left; a later group claims a var an + /// earlier one also defined, like a redefinition). + pub(crate) fn install_lazy(&self, groups: Vec) -> bool { + let mut ids = Vec::new(); + let mut inner = self.lock(); + for g in groups { + let g = Arc::new(g); + for &id in &g.ids { + inner.lazy_groups.insert(id, Arc::clone(&g)); + ids.push(id); } - if owner.owner.is_some() { - owner = lock.cvar.wait(owner).unwrap_or_else(|e| e.into_inner()); - } else { - owner.owner = Some(current); - owner.depth = 1; - break; + } + // Under the registry lock, so concurrent loads claim + // distinct segments. + let index = LazyIndex::new(&ids); + self.lazy + .iter() + .find(|seg| seg.get().is_none()) + .is_some_and(|seg| seg.set(index).is_ok()) + } + + /// Serialize a lazy force (see [`lazy_force`](Self::lazy_force)). + pub(crate) fn lock_lazy_force(&self) -> VarAlterGuard { + Arc::clone(&self.lazy_force).acquire() + } + + /// Claim the unforced group defining `id`, unlisting all its + /// vars (`None` once claimed). The index bits stay set until + /// [`finish_lazy`](Self::finish_lazy), so other threads wait on + /// the force lock instead of reading an unbound root. + pub(crate) fn take_lazy(&self, id: u64) -> Option> { + let mut inner = self.lock(); + let group = inner.lazy_groups.remove(&id)?; + for other in &group.ids { + inner.lazy_groups.remove(other); + } + Some(group) + } + + /// Install metadata-source groups for native-backed vars. + pub(crate) fn install_meta_lazy(&self, groups: Vec) { + let mut inner = self.lock(); + for g in groups { + let g = Arc::new(g); + for &id in &g.ids { + inner.meta_groups.insert(id, Arc::clone(&g)); + inner.native_sources.insert(id, Arc::clone(&g)); } } - drop(owner); - VarAlterGuard { lock } + } + + /// A native-backed var's file form (never consumed). + pub(crate) fn native_source(&self, id: u64) -> Option> { + self.lock().native_sources.get(&id).cloned() + } + + /// A native-backed var's interpreted definition, once evaluated. + pub(crate) fn fallback(&self, id: u64) -> Option { + self.lock().fallbacks.get(&id).cloned() + } + + /// Record [`fallback`](Self::fallback). + pub(crate) fn set_fallback(&self, id: u64, f: Value) { + self.lock().fallbacks.insert(id, f); + } + + /// Claim `id`'s metadata-source group (`None` when absent). + pub(crate) fn take_meta_lazy(&self, id: u64) -> Option> { + self.lock().meta_groups.remove(&id) + } + + /// Publish a forced group: its vars stop testing lazy. + pub(crate) fn finish_lazy(&self, group: &LazyGroup) { + for ix in self.lazy.iter().map_while(OnceLock::get) { + for &id in &group.ids { + ix.clear(id); + } + } + } + + /// The gensym, top-level form, and `fn` display counters (lazy + /// forcing restores them, so it never shifts user numbering). + pub(crate) fn counters(&self) -> (u64, u64, u64) { + let inner = self.lock(); + (inner.gensym, inner.eval_seq, inner.fn_seq) + } + + /// Restore [`counters`](Self::counters). + pub(crate) fn restore_counters(&self, (gensym, eval_seq, fn_seq): (u64, u64, u64)) { + let mut inner = self.lock(); + inner.gensym = gensym; + inner.eval_seq = eval_seq; + inner.fn_seq = fn_seq; } /// Run `f` over a var's state (`None` when uninterned). The @@ -1221,8 +1642,16 @@ pub struct Bindings { /// var has no thread binding (the common case on the var-deref /// hot path). counts: FxHashMap, + /// The content version: fresh (process-unique) after every + /// change, copied by clones, 0 for never-changed (empty) + /// stacks — equal versions mean equal bindings, so the VM's + /// per-site caches validate against it. + version: u64, } +/// The [`Bindings::version`] source. +static BINDINGS_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + /// Drop one frame's refcounts (split out so [`Bindings::truncate`] /// can drain frames and counts under separate borrows). fn unbind_counts(counts: &mut FxHashMap, frame: &[(Var, Value)]) { @@ -1251,12 +1680,23 @@ impl Bindings { self.frames.len() } + /// The content version (see the field). + #[must_use] + pub fn version(&self) -> u64 { + self.version + } + + fn bump(&mut self) { + self.version = BINDINGS_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + /// Push one frame (possibly empty, like the JVM). pub fn push_frame(&mut self, entries: Vec<(Var, Value)>) { for (var, _) in &entries { *self.counts.entry(var.id).or_default() += 1; } self.frames.push(entries); + self.bump(); } /// Pop one frame (`None` when the stack is empty — @@ -1264,6 +1704,7 @@ impl Bindings { pub fn pop_frame(&mut self) -> Option> { let frame = self.frames.pop()?; unbind_counts(&mut self.counts, &frame); + self.bump(); Some(frame) } @@ -1273,7 +1714,8 @@ impl Bindings { if depth >= self.frames.len() { return; } - let Self { frames, counts } = self; + self.bump(); + let Self { frames, counts, .. } = self; for frame in frames.drain(depth..) { unbind_counts(counts, &frame); } @@ -1321,6 +1763,7 @@ impl Bindings { { Some((_, slot)) => { *slot = value; + self.bump(); true } None => false, diff --git a/rust/crates/clj-eval/src/eval.rs b/rust/crates/clj-eval/src/eval.rs index 08c5e55dbf..de4a1e51ab 100644 --- a/rust/crates/clj-eval/src/eval.rs +++ b/rust/crates/clj-eval/src/eval.rs @@ -31,6 +31,17 @@ use crate::expr::{ }; use crate::ext::{Closure, FastArith, NativeFn, NsHandle, as_closure, as_native}; +/// A form-by-form live read of one source text (see +/// [`Evaluator::live_forms`]). +pub struct LiveForms { + reader: clj_reader::Reader, + opts: ReaderOpts, + fresh: bool, + /// The entry failure (the load-path resolver cast), reported by + /// the first read. + failed: Option, +} + /// `eval_source` failures: reader errors, analyzer errors, uncaught /// throwables, and (unreachable by construction) escaping `recur`s. #[derive(Debug)] @@ -72,13 +83,44 @@ fn uncaught(s: Signal) -> SourceError { /// The throwable of a signal leaving a `try` region (`None` /// for `recur`, which the analyzer fences from crossing `try` /// — expr.rs `no_recur` — so `try` bodies only ever throw). -fn try_thrown(signal: Signal) -> Option { +/// One VM site's cached answer (see `Evaluator::vm_sites`); the +/// default never matches a real epoch. +#[derive(Clone)] +struct SiteEntry { + epoch: u64, + bind: u64, + value: Value, +} + +impl Default for SiteEntry { + fn default() -> Self { + Self { + epoch: u64::MAX, + bind: u64::MAX, + value: Value::Nil, + } + } +} + +pub(crate) fn try_thrown(signal: Signal) -> Option { match signal { Signal::Throw(t) => Some(t), Signal::Recur(_) => None, } } +/// A list headed by a symbol whose name starts with `def` (the +/// `Compiler.eval` test, after expansion, for forms that skip the +/// `eval` wrapper). +fn is_def_headed(form: &Value) -> bool { + match form { + Value::List(list) => { + matches!(list.first(), Some(Value::Symbol(sym)) if sym.name().starts_with("def")) + } + _ => false, + } +} + /// The body of a top-level `do`-headed list (head unqualified, /// mirroring the analyzer's special-form check), `None` for any /// other form or a bare `(do)`. Shared with AOT planning, which @@ -126,6 +168,9 @@ pub struct Evaluator { pub registry: SharedRegistry, bindings: Bindings, source: Arc, + /// `clojure.core/*ns*`'s var id once [`current_ns_name`] found it + /// (0 = not yet; var ids start at 1). + star_ns_id: std::sync::atomic::AtomicU64, future_threads: Arc>>>, agent_system: Arc, tap_system: Arc, @@ -170,6 +215,14 @@ pub struct Evaluator { root_cache: FxHashMap, /// The lineage epoch [`root_cache`](Self::root_cache) was filled under. root_epoch: u64, + /// The VM's per-site caches (`vm` numbers its global reads and + /// direct calls): a value or a validation verdict, valid while + /// both the lineage epoch and the bindings version match. + vm_sites: Vec, + /// Direct-call sites last validated under `(epoch, bindings + /// version)` (only valid verdicts are recorded; a mismatch + /// revalidates). + vm_direct: Vec<(u64, u64)>, /// Pending closure applications (#205): the JVM's bounded /// stack as a catchable `StackOverflowError` instead of a /// process abort. STM retries restore the entry depth (see @@ -215,6 +268,13 @@ const STACK_RED_ZONE: usize = 1024 * 1024; /// often as a native-sized stack would exhaust. const STACK_SEGMENT: usize = 8 * 1024 * 1024; +/// Guarded calls probe the native stack at every this-many-th +/// level only (#226: the probe reads thread-local state on each +/// call). Between probes at most 15 levels run — ~120 KiB at the +/// measured ~8 KiB per level — so a probe that found +/// [`STACK_RED_ZONE`] free still leaves 8x margin at the next. +const STACK_PROBE_EVERY: usize = 16; + /// A `defmacro` definition's parts (bundles `eval_defmacro`'s /// parameters). #[derive(Clone, Copy)] @@ -249,6 +309,7 @@ impl Evaluator { registry: SharedRegistry::new(), bindings: Bindings::new(), source: Arc::from(source), + star_ns_id: std::sync::atomic::AtomicU64::new(0), future_threads: Arc::new(std::sync::Mutex::new(Vec::new())), agent_system: Arc::new(crate::concurrency::AgentSystem::new()), tap_system: Arc::new(crate::concurrency::TapSystem::new()), @@ -262,6 +323,8 @@ impl Evaluator { loaded_data_reader_files: HashSet::new(), canonical_default_readers: None, root_cache: FxHashMap::default(), + vm_sites: Vec::new(), + vm_direct: Vec::new(), root_epoch: 0, call_depth: 0, recur_pool: Vec::new(), @@ -283,6 +346,9 @@ impl Evaluator { registry: self.registry.clone(), bindings, source: Arc::clone(&self.source), + star_ns_id: std::sync::atomic::AtomicU64::new( + self.star_ns_id.load(std::sync::atomic::Ordering::Relaxed), + ), future_threads: Arc::clone(&self.future_threads), agent_system: Arc::clone(&self.agent_system), tap_system: Arc::clone(&self.tap_system), @@ -312,6 +378,8 @@ impl Evaluator { // lineage epoch (sibling writes end its generation like // any other evaluator's). root_cache: FxHashMap::default(), + vm_sites: Vec::new(), + vm_direct: Vec::new(), root_epoch: self.registry.epoch(), // A fork starts unwound (its stack is its own). call_depth: 0, @@ -446,22 +514,47 @@ impl Evaluator { /// `in-ns` keeps both in sync), else the registry current. /// Public for S7 drivers (the REPL prompt tracks this). pub fn current_ns_name(&self) -> String { - // Static lookup key (equality/hash ignore the id/meta, so - // sharing one symbol is sound): this runs per analyzed - // symbol, and the direct id lookup skips the whole-namespace - // clone `get` + `lookup` paid before. + // The `*ns*` id lookup is static and cached (equality/hash + // ignore the id/meta, so sharing one symbol is sound; vars + // never change ids; an absent `*ns*` re-probes). + let id = self.star_ns_var(); + self.bound_ns_name(id) + .map_or_else(|| self.registry.current(), |n| n.to_string()) + } + + /// [`current_ns_name`](Self::current_ns_name) as the bound + /// namespace handle's shared name (no copy on the common path: + /// the analyzer asks per symbol). + pub(crate) fn current_ns_arc(&self) -> Arc { + let id = self.star_ns_var(); + self.bound_ns_name(id) + .map_or_else(|| Arc::from(self.registry.current()), Arc::clone) + } + + /// The `*ns*` var id (cached once found). + fn star_ns_var(&self) -> Option { static STAR_NS: LazyLock = LazyLock::new(|| Symbol::new(Some("clojure.core"), "*ns*")); - let bound = self - .registry - .lookup_in_ns("clojure.core", &STAR_NS) - .and_then(|id| self.bindings.get(id)) + let cached = self.star_ns_id.load(std::sync::atomic::Ordering::Relaxed); + if cached != 0 { + return Some(cached); + } + let found = self.registry.lookup_in_ns("clojure.core", &STAR_NS); + if let Some(id) = found { + self.star_ns_id + .store(id, std::sync::atomic::Ordering::Relaxed); + } + found + } + + /// The thread-bound `*ns*` handle's name, if any. + fn bound_ns_name(&self, id: Option) -> Option<&Arc> { + id.and_then(|id| self.bindings.get(id)) .and_then(|v| match v { Value::Extension(h) => h.as_any().downcast_ref::(), _ => None, }) - .map(|ns| ns.name.to_string()); - bound.unwrap_or_else(|| self.registry.current().to_owned()) + .map(|ns| &ns.name) } /// The next lineage identity (S7 drivers stamp @@ -487,7 +580,11 @@ impl Evaluator { pub fn analyze(&mut self, form: &Value) -> Result { let source = Arc::clone(&self.source); let seq = self.registry.eval_seq(); - Analyzer::new(&source, self, seq).analyze(form, Span::new(1, 1), true) + let mut analyzer = Analyzer::new(&source, self, seq); + // `Compiler.eval` wraps every form but `def…`-headed ones + // in an `eval{N}` fn, which names the fns inside it. + analyzer.top_wrapped = !is_def_headed(form); + analyzer.analyze(form, Span::new(1, 1), true) } /// Read every form in `text` against the synced current @@ -508,11 +605,10 @@ impl Evaluator { text: &str, hook: &mut dyn clj_reader::ReadHooks, ) -> Result, SourceError> { - let (reader, seed) = Self::read_forms_pre(self, text); - match seed { - Ok(seed) => { + let (reader, opts) = Self::read_forms_pre(self, text, 1); + match opts { + Ok(mut opts) => { let mut reader = reader; - let mut opts = Self::read_forms_opts(self, &seed); Self::read_forms_loop(self, &mut reader, hook, &mut opts) } Err(cce) => Err(crate::reader::executing_wrap( @@ -531,69 +627,67 @@ impl Evaluator { /// evaluator, which the per-form consult also needs mutably, /// so each attempt scopes its own hooks. pub fn read_forms_live(&mut self, text: &str) -> Result, SourceError> { - let (reader, seed) = Self::read_forms_pre(self, text); - match seed { - Ok(seed) => { - let mut reader = reader; - let mut opts = Self::read_forms_opts(self, &seed); - let mut forms = Vec::new(); - loop { - Self::snap_data_readers(self, &mut opts)?; - reader.set_opts(&opts); - let outcome = { - let mut hooks = crate::reader::LiveReadHooks::new(self); - reader.read_form(false, &mut hooks) - }; - match outcome { - Ok(Some(v)) => { - Self::drain_data_readers(self, &mut reader, opts.suppress_read)?; - forms.push(Self::resolve_data_readers(self, v, opts.suppress_read)?); - } - Ok(None) => { - Self::drain_data_readers(self, &mut reader, opts.suppress_read)?; - return Ok(forms); - } - Err(e) => { - // Effects precede the failure (fired tags ran - // during the read), so drain first. - Self::drain_data_readers(self, &mut reader, opts.suppress_read)?; - return Err(SourceError::Reader(e)); - } - } - } - } - Err(cce) => { - let outer = self.next_id(); - let cause = self.next_id(); - Err(crate::reader::executing_wrap( - reader.line(), - reader.col(), - &cce, - outer, - cause, - )) - } + self.read_forms_live_at(text, 1) + } + + /// [`read_forms_live`](Self::read_forms_live) of a slice that + /// starts at `line` of its file. + fn read_forms_live_at(&mut self, text: &str, line: u32) -> Result, SourceError> { + let mut forms = self.live_forms_at(text, line); + let mut out = Vec::new(); + while let Some(form) = self.next_live_form(&mut forms)? { + out.push(form); + } + Ok(out) + } + + /// One live read: the next form with its data readers resolved + /// (`None` at EOF). + fn read_next_live( + &mut self, + reader: &mut clj_reader::Reader, + opts: &mut ReaderOpts, + ) -> Result, SourceError> { + Self::snap_data_readers(self, opts)?; + reader.set_opts(opts); + let outcome = { + let mut hooks = crate::reader::LiveReadHooks::new(self); + reader.read_form(false, &mut hooks) + }; + // Effects precede every exit (fired tags ran during the + // read, failures included), so drain first. + Self::drain_data_readers(self, reader, opts.suppress_read)?; + match outcome { + Ok(Some(v)) => Ok(Some(Self::resolve_data_readers( + self, + v, + opts.suppress_read, + )?)), + Ok(None) => Ok(None), + Err(e) => Err(SourceError::Reader(e)), } } /// The shared read prologue: synced options, leading /// whitespace consumed, gates seeded (entry failures keep the /// post-skip reader, so callers blame the position like - /// `Compiler.load`). + /// `Compiler.load`). Hands back the installed options so the + /// per-form loops start from them instead of re-syncing the + /// namespace maps. fn read_forms_pre( eval: &mut Evaluator, text: &str, - ) -> ( - clj_reader::Reader, - Result, - ) { + line: u32, + ) -> (clj_reader::Reader, Result) { let mut reader = clj_reader::Reader::new(text, &ReaderOpts::default()); + reader.set_start_line(line); reader.consume_whitespaces(); - let seed = crate::reader::seed_load_gates(eval); - if let Ok(seed) = &seed { - reader.set_opts(&Self::read_forms_opts(eval, seed)); - } - (reader, seed) + let opts = crate::reader::seed_load_gates(eval).map(|seed| { + let opts = Self::read_forms_opts(eval, &seed); + reader.set_opts(&opts); + opts + }); + (reader, opts) } /// The synced base options for [`read_forms`](Self::read_forms): @@ -683,14 +777,70 @@ impl Evaluator { /// read form. Uncaught throwables (including bare macro-arity /// failures) abort the load. pub fn eval_source(&mut self, text: &str) -> Result, SourceError> { - let forms = self.read_forms_live(text)?; - let mut out = Vec::with_capacity(forms.len()); - for form in &forms { - out.push(self.eval_top_form(form)?); + self.eval_source_at(text, 1) + } + + /// [`eval_source`](Self::eval_source) of a slice that starts at + /// `line` of its file (embedded forms evaluated one by one keep + /// their file lines). + pub(crate) fn eval_source_at( + &mut self, + text: &str, + line: u32, + ) -> Result, SourceError> { + let mut forms = self.live_forms_at(text, line); + let mut out = Vec::new(); + while let Some(form) = self.next_live_form(&mut forms)? { + out.push(self.eval_top_form(&form)?); } Ok(out) } + /// Start reading `text` form by form with the live hooks, for + /// callers that evaluate each form before reading the next + /// (`load`, `-e`): see [`next_live_form`](Self::next_live_form). + pub fn live_forms(&mut self, text: &str) -> LiveForms { + self.live_forms_at(text, 1) + } + + /// An entry failure (the load-path resolver cast) is the + /// `Syntax error executing` wrap at the post-skip position, + /// answered by the first read. + fn live_forms_at(&mut self, text: &str, line: u32) -> LiveForms { + let (reader, opts) = Self::read_forms_pre(self, text, line); + let (opts, failed) = match opts { + Ok(opts) => (opts, None), + Err(cce) => { + let outer = self.next_id(); + let cause = self.next_id(); + let wrap = + crate::reader::executing_wrap(reader.line(), reader.col(), &cce, outer, cause); + (ReaderOpts::default(), Some(wrap)) + } + }; + LiveForms { + reader, + opts, + fresh: true, + failed, + } + } + + /// The next form of a [`LiveForms`] read (`None` at EOF). Like + /// `Compiler.load`, each read after the first re-syncs the + /// namespace view, so an `ns`/`in-ns` switch and new aliases, + /// refers and imports from the forms evaluated so far reach the + /// next one (`::kw` after `(ns foo)` reads `:foo/kw`). + pub fn next_live_form(&mut self, forms: &mut LiveForms) -> Result, SourceError> { + if let Some(failed) = forms.failed.take() { + return Err(failed); + } + if !std::mem::replace(&mut forms.fresh, false) { + crate::reader::sync_reader_ns(self, &mut forms.opts); + } + self.read_next_live(&mut forms.reader, &mut forms.opts) + } + /// Deep-realize a value for printing (like the JVM, whose /// print walks — forcing errors surface here, not in `eval`). pub fn realize_deep(&mut self, v: &Value) -> Result { @@ -718,7 +868,8 @@ impl Evaluator { } self.registry.bump_eval_seq(); let expr = self.analyze(&expanded).map_err(|e| self.compile_err(e))?; - match self.eval(&expr, &Env::new()) { + let code = crate::vm::compile_top(&expr); + match crate::vm::run_top(self, &code) { Ok(v) => Ok(v), Err(s) => Err(uncaught(s)), } @@ -850,18 +1001,21 @@ impl Evaluator { ) -> Value { let id = self.next_id(); let sym = Symbol::new(Some(ns), name); - let var_id = self.registry.intern(&sym); + // Built by hand: boot installs ~700 of these, and `format!` + // costs several times the copy. + let mut display = String::with_capacity(ns.len() + 1 + name.len()); + display.push_str(ns); + display.push('/'); + display.push_str(name); let native = Value::Extension(Arc::new(NativeFn { id, - display: Arc::from(format!("{ns}/{name}").as_str()), + display: Arc::from(display), func, owned: crate::ns::owned_entry(name), meta: None, fast, })); - self.registry.with_state_mut(var_id, |state| { - state.root = Some(native.clone()); - }); + self.registry.intern_with_root(&sym, native.clone()); native } @@ -895,13 +1049,88 @@ impl Evaluator { /// the var's own `Unbound` sentinel, never a throw). Public /// for S7 drivers (the REPL reads the current `*1`/`*2` to /// shift the window). - pub fn deref_var(&self, id: u64, sym: &Symbol) -> Value { + pub fn deref_var(&mut self, id: u64, sym: &Symbol) -> Value { + if let Some(v) = self.bindings.get(id) { + return v.clone(); + } + self.force_lazy(id); + self.registry.root_or_unbound(id, sym) + } + + /// [`deref_var`](Self::deref_var) for shared-borrow readers of + /// boot-installed vars (never lazy `core.clj` ones, so nothing + /// to force). + pub(crate) fn deref_var_unforced(&self, id: u64, sym: &Symbol) -> Value { if let Some(v) = self.bindings.get(id) { return v.clone(); } self.registry.root_or_unbound(id, sym) } + /// A VM global read at `site`: the cached value while nothing + /// changed (no root write anywhere, the same thread bindings), + /// else [`deref_var_cached`](Self::deref_var_cached), recorded. + #[inline(always)] + pub(crate) fn site_global(&mut self, site: usize, var: &Var) -> Value { + if let Some(e) = self.vm_sites.get(site) { + if e.epoch == self.registry.epoch() && e.bind == self.bindings.version() { + return crate::vm::clone_value(&e.value); + } + } + self.site_global_miss(site, var) + } + + /// [`site_global`](Self::site_global)'s miss: read and record. + #[cold] + #[inline(never)] + fn site_global_miss(&mut self, site: usize, var: &Var) -> Value { + let value = self.deref_var_cached(var.id, &var.sym); + self.site_store(site, value.clone()); + value + } + + /// A VM direct call's validation at `site` (cached like + /// [`site_global`](Self::site_global)): no thread binding + /// shadows the var and its root is still the analyzed native. + /// The cached verdict checks inline; a miss re-validates out of + /// line. + #[inline(always)] + pub(crate) fn site_direct_valid(&mut self, site: usize, call: &DirectCall) -> bool { + let now = (self.registry.epoch(), self.bindings.version()); + self.vm_direct.get(site) == Some(&now) || self.site_direct_check(site, call) + } + + /// [`site_direct_valid`](Self::site_direct_valid)'s miss: check + /// the var and record a valid verdict under the current epochs. + #[cold] + #[inline(never)] + fn site_direct_check(&mut self, site: usize, call: &DirectCall) -> bool { + let valid = self.bindings.get(call.var.id).is_none() + && matches!(self.deref_var_cached(call.var.id, &call.var.sym), + Value::Extension(e) if Arc::ptr_eq(&e, &call.expect)); + if valid { + if self.vm_direct.len() <= site { + self.vm_direct.resize(site + 1, (u64::MAX, u64::MAX)); + } + // Read after the check, which may have forced a lazy var. + self.vm_direct[site] = (self.registry.epoch(), self.bindings.version()); + } + valid + } + + /// Record a site answer under the current epochs (read after the + /// computation, which may have forced a lazy var). + fn site_store(&mut self, site: usize, value: Value) { + if self.vm_sites.len() <= site { + self.vm_sites.resize(site + 1, SiteEntry::default()); + } + self.vm_sites[site] = SiteEntry { + epoch: self.registry.epoch(), + bind: self.bindings.version(), + value, + }; + } + /// The visible value of a var through the per-evaluator root /// cache (the `Expr::Global` fast path): thread bindings win /// (checked first, like [`deref_var`](Self::deref_var)); @@ -927,12 +1156,44 @@ impl Evaluator { } else if let Some(v) = self.root_cache.get(&id) { return v.clone(); } - let value = self.registry.root_or_unbound(id, sym); + let mut value = self.registry.root_or_unbound(id, sym); + // Only unbound roots can be lazy `core.clj` vars (forcing + // bumps the epoch, so the stale-epoch insert below misses + // once and refills). + if matches!(value, Value::Unbound(_)) && self.force_lazy(id) { + value = self.registry.root_or_unbound(id, sym); + } self.root_cache.insert(id, value.clone()); self.root_epoch = epoch; value } + /// Force a lazy `core.clj` var (see `core_lazy`): `true` when + /// this call evaluated its form. A lock-free miss for every + /// other var. + pub(crate) fn force_lazy(&mut self, id: u64) -> bool { + self.registry.is_lazy(id) && crate::core_lazy::force(self, id) + } + + /// Swap the source name (spans and `:file` metadata), returning + /// the old one. + pub(crate) fn swap_source(&mut self, source: Arc) -> Arc { + std::mem::replace(&mut self.source, source) + } + + /// Swap the binding stack and source name (lazy forcing runs + /// isolated from the caller's bindings), returning the old pair. + pub(crate) fn swap_context( + &mut self, + bindings: Bindings, + source: Arc, + ) -> (Bindings, Arc) { + ( + std::mem::replace(&mut self.bindings, bindings), + std::mem::replace(&mut self.source, source), + ) + } + /// Evaluate an expression to a value (or a control signal). pub fn eval(&mut self, expr: &Expr, env: &Env) -> Result { match expr { @@ -947,8 +1208,16 @@ impl Evaluator { sym, doc, init, + meta, span, - } => self.eval_def(sym, doc.clone(), init.as_deref(), *span, env), + } => self.eval_def( + sym, + doc.clone(), + init.as_deref(), + meta.as_deref(), + *span, + env, + ), Expr::If { test, then, els } => self.eval_if(test, then, els, env), Expr::Do(exprs) => self.eval_body(exprs, env), Expr::Let { bindings, body } | Expr::Loop { bindings, body } => { @@ -1108,6 +1377,11 @@ impl Evaluator { meta: Option, env: &Env, ) -> Result { + Ok(Value::Map(self.eval_map_pairs(pairs, env)?.with_meta(meta))) + } + + /// A map literal's pairs, evaluated left to right. + fn eval_map_pairs(&mut self, pairs: &[(Expr, Expr)], env: &Env) -> Result { let mut pairs_out = Vec::with_capacity(pairs.len()); for (k, v) in pairs { let key = self.eval(k, env)?; @@ -1115,9 +1389,7 @@ impl Evaluator { let key = crate::lazy::realize_key(self, &key)?; pairs_out.push((key, val)); } - Ok(Value::Map( - Map::from_kvs_last_wins(pairs_out).with_meta(meta), - )) + Ok(Map::from_kvs_last_wins(pairs_out)) } /// A set literal (elements evaluate left to right). @@ -1157,18 +1429,24 @@ impl Evaluator { /// `Throwable` cast error. fn eval_throw(&mut self, inner: &Expr, env: &Env) -> Result { let value = self.eval(inner, env)?; + Err(self.throw_value(value)) + } + + /// The signal `throw` raises for a value: throwables as-is, + /// anything else the cast (or `nil`) error. + pub(crate) fn throw_value(&mut self, value: Value) -> Signal { match value { - Value::Throwable(t) => Err(Signal::Throw(t)), + Value::Throwable(t) => Signal::Throw(t), // `nil` is not the cast error: the JVM throws a // Clojure-constructed NPE with stable text (probe29). - Value::Nil => Err(self.throw(NativeError::new( + Value::Nil => self.throw(NativeError::new( ThrowClass::NullPointerException, "Cannot throw exception because \"null\" is null".to_owned(), - ))), - other => Err(self.throw(NativeError::cast( + )), + other => self.throw(NativeError::cast( &jvm_class_of(&other), "java.lang.Throwable", - ))), + )), } } @@ -1240,16 +1518,27 @@ impl Evaluator { if self.eval(test, env)?.is_truthy() { return Ok(Value::Nil); } + let message = match message { + None => None, + Some(m) => Some(self.eval(m, env)?), + }; + Err(self.assert_failed(message, form)) + } + + /// The failed `assert`'s `AssertionError` (the message value, + /// when given, renders like `str`). + pub(crate) fn assert_failed(&mut self, message: Option, form: &str) -> Signal { let text = match message { None => format!("Assert failed: {form}"), - Some(m) => { - let mv = self.eval(m, env)?; + Some(mv) => { // `native_str` of one arg is `str_of_one`. - let rendered = crate::core::str_of_one(self, &mv).map_err(|e| self.throw(e))?; - format!("Assert failed: {rendered}\n{form}") + match crate::core::str_of_one(self, &mv) { + Ok(rendered) => format!("Assert failed: {rendered}\n{form}"), + Err(e) => return self.throw(e), + } } }; - Err(self.throw(NativeError::new(ThrowClass::AssertionError, text))) + self.throw(NativeError::new(ThrowClass::AssertionError, text)) } /// `(time expr)`: wall-clock the expr, `prn` the JVM line to @@ -1323,6 +1612,31 @@ impl Evaluator { self.eval_binding(&pairs, body, env) } + /// The `case*` dispatch probe for an evaluated target: the value + /// the hit's post-check compares (realized for hash tests) and + /// the shift-masked key — `None` when an `:int` test misses + /// outright (non-numbers). + pub(crate) fn case_probe( + &mut self, + v: Value, + shift: i32, + mask: i32, + test: CaseTest, + ) -> Result, Signal> { + if test == CaseTest::Int { + return Ok(case_int_value(&v).map(|key| (v, case_shift_mask(key, shift, mask)))); + } + if clj_value::seq_hash_unsupported(&v) { + return Err(self.unsupported_hash()); + } + let r = crate::lazy::realize_deep(self, &v)?; + if clj_value::seq_hash_unsupported(&r) { + return Err(self.unsupported_hash()); + } + let key = case_shift_mask(clj_value::hash_code(&r), shift, mask); + Ok(Some((r, key))) + } + /// `(case* …)`: the interpreter's `CaseExpr.doEmit` on the boxed /// path (the only one here — no primitives). `:int` funnels /// numbers through `intValue` (non-numbers miss, then an `equiv` @@ -1585,7 +1899,7 @@ impl Evaluator { Value::Bool(true), ); let sym = sym.clone().with_meta(Some(meta)); - last = self.eval_def(&sym, None, None, span, env)?; + last = self.eval_def(&sym, None, None, None, span, env)?; } Ok(last) } @@ -1668,50 +1982,116 @@ impl Evaluator { sym: &Symbol, doc: Option, init: Option<&Expr>, + meta: Option<&[(Expr, Expr)]>, span: Span, env: &Env, ) -> Result { - let id = self.registry.intern_def_target(sym); + let id = self.def_begin(sym); let value = match init { Some(init) => Some(self.eval(init, env)?), None => None, }; + // The evaluated name metadata (after the init, like + // `DefExpr.eval`), else the constant map on the name. + let name_meta = match meta { + Some(pairs) => Some(self.eval_map_pairs(pairs, env)?), + None => sym.meta().cloned(), + }; + Ok(self.def_finish(id, sym, doc, value, name_meta, span)) + } + + /// A `def`'s first step, before the init evaluates: the target + /// var's id. Redefining a lazy `core.clj` var forces it first, so + /// a later force can never overwrite this def (a native-backed + /// macro's pending metadata source is dropped for the same + /// reason). + pub(crate) fn def_begin(&mut self, sym: &Symbol) -> u64 { + let id = self.registry.intern_def_target(sym); + self.force_lazy(id); + self.registry.take_meta_lazy(id); + id + } + + /// A `def`'s last step: the root (when an init ran), the var + /// metadata, and the dynamic flag. + pub(crate) fn def_finish( + &mut self, + id: u64, + sym: &Symbol, + doc: Option, + value: Option, + name_meta: Option, + span: Span, + ) -> Value { let dynamic = Self::dynamic_of(sym); + let meta = self.def_var_meta(sym, name_meta.as_ref(), doc, span); + self.registry.with_state_mut(id, |state| { + if let Some(v) = value { + state.root = Some(v); + } + state.meta = Some(meta); + state.dynamic = dynamic; + }); + Value::Var(Var { + id, + sym: sym.clone(), + }) + } + + /// A `def`'s var metadata: the name's map, then `:doc`, then the + /// auto keys — one build with the `insert` chain's exact layout + /// (first occurrence keeps its position, the last value wins). + fn def_var_meta( + &self, + sym: &Symbol, + name_meta: Option<&clj_value::Map>, + doc: Option, + span: Span, + ) -> clj_value::Map { let cur = self.current_ns_name(); let cur_id = self.registry.ns_id(&cur).unwrap_or(0); - let mut meta = sym.meta().cloned().unwrap_or_else(new_map); + let mut pairs: Vec<(Value, Value)> = name_meta + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); if let Some(doc) = doc { - let key = Value::Keyword(Keyword::new(None, "doc")); - meta = meta.insert(key, Value::Str(Arc::from(doc.as_str()))); + pairs.push(( + Value::Keyword(clj_value::static_keyword!("doc")), + Value::Str(Arc::from(doc.as_str())), + )); } - let auto = [ - ("line", Value::Int(i64::from(span.line))), - ("column", Value::Int(i64::from(span.col))), - ("file", Value::Str(Arc::clone(&self.source))), - ("name", Value::Symbol(Symbol::new(None, sym.name()))), + pairs.extend([ ( - "ns", + Value::Keyword(clj_value::static_keyword!("line")), + Value::Int(i64::from(span.line)), + ), + ( + Value::Keyword(clj_value::static_keyword!("column")), + Value::Int(i64::from(span.col)), + ), + ( + Value::Keyword(clj_value::static_keyword!("file")), + Value::Str(Arc::clone(&self.source)), + ), + ( + Value::Keyword(clj_value::static_keyword!("name")), + Value::Symbol(Symbol::new(None, sym.name())), + ), + ( + Value::Keyword(clj_value::static_keyword!("ns")), Value::Extension(Arc::new(NsHandle { name: Arc::from(cur.as_str()), id: cur_id, })), ), - ]; - for (key, val) in auto { - let key = Value::Keyword(Keyword::new(None, key)); - meta = meta.insert(key, val); + ]); + match name_meta { + // Keep a hashy (or sorted) name map's own class. + Some(m) if m.is_hashy() => pairs + .into_iter() + .skip(m.size()) + .fold(m.clone(), |acc, (k, v)| acc.insert(k, v)), + _ => clj_value::Map::from_kvs_last_wins(pairs), } - self.registry.with_state_mut(id, |state| { - if let Some(v) = value { - state.root = Some(v); - } - state.meta = Some(meta); - state.dynamic = dynamic; - }); - Ok(Value::Var(Var { - id, - sym: sym.clone(), - })) } /// `(loop …)`: bind once, then run the body, rebinding through @@ -1832,6 +2212,7 @@ impl Evaluator { env: env.clone(), meta: None, conveyed: None, + vm: None, } } @@ -1877,6 +2258,11 @@ impl Evaluator { /// Invoke any value (natives reuse this for `:rename` /// fallbacks and other higher-order calls). pub(crate) fn call(&mut self, callee: &Value, args: &[Value]) -> Result { + // A VM closure's fixed arity runs directly (#226: natives + // call back per item; no apply dispatch, no arg buffer). + if let Some((code, arity)) = crate::vm::fixed_target(callee, args.len()) { + return crate::vm::call_borrowed(self, code, arity, args); + } self.invoke(callee, args) } @@ -1897,12 +2283,20 @@ impl Evaluator { /// memoized seq chunks free as they advance, like the JVM's /// dead locals); everything else borrows it (the buffer drops /// at return, as before). - fn invoke_args( + pub(crate) fn invoke_args( &mut self, callee: &Value, args: SmallVec<[Value; 4]>, span: Span, ) -> Result { + // VM closures take the buffer by move (accumulators stay + // uniquely owned through `reduce`-style step calls). + if let Some(closure) = crate::ext::as_closure(callee) { + if let (Some(code), None) = (&closure.vm, &closure.conveyed) { + let mut args = args; + return crate::vm::apply_moved(self, closure, code, &mut args); + } + } // The call site stays visible to natives for the // duration of the call (nesting saves/restores). let saved = self.native_call_span.replace(span); @@ -1946,7 +2340,7 @@ impl Evaluator { /// the lineage epoch still matches (no root write since the /// cache generation), and the cached root is still the /// analyzed native by identity. - fn direct_valid(&self, call: &DirectCall) -> bool { + pub(crate) fn direct_valid(&self, call: &DirectCall) -> bool { if self.bindings.get(call.var.id).is_some() { return false; } @@ -1985,10 +2379,20 @@ impl Evaluator { for arg in &call.args { values.push(self.eval(arg, env)?); } + self.call_direct_values(call, &mut values) + } + + /// A validated direct call over evaluated args (the snapshot + /// entries — owned-args loopers take the buffer). + pub(crate) fn call_direct_values( + &mut self, + call: &DirectCall, + values: &mut SmallVec<[Value; 4]>, + ) -> Result { let saved = self.native_call_span.replace(call.span); let out = match &call.owned { - Some(owned) => owned(self, values).map_err(|e| self.throw(e)), - None => (call.func)(self, &values).map_err(|e| self.throw(e)), + Some(owned) => owned(self, std::mem::take(values)).map_err(|e| self.throw(e)), + None => (call.func)(self, values).map_err(|e| self.throw(e)), }; self.native_call_span = saved; out @@ -2068,7 +2472,7 @@ impl Evaluator { /// `*unchecked-math*` and every error text stay the native's; /// the trailing arm only serves direct unit tests — dispatch /// never routes those tags here). - fn fast_int2(op: FastArith, x: i64, y: i64) -> Option { + pub(crate) fn fast_int2(op: FastArith, x: i64, y: i64) -> Option { match op { FastArith::Add => x.checked_add(y).map(Value::Int), FastArith::Sub => x.checked_sub(y).map(Value::Int), @@ -2086,7 +2490,11 @@ impl Evaluator { /// native, so errors and tower results stay exact — and every /// tagged native installs with `owned: None`, so the borrow /// entry is the whole story). - fn call_direct_fallback(&mut self, call: &DirectCall, args: &[Value]) -> Result { + pub(crate) fn call_direct_fallback( + &mut self, + call: &DirectCall, + args: &[Value], + ) -> Result { let saved = self.native_call_span.replace(call.span); let out = (call.func)(self, args).map_err(|e| self.throw(e)); self.native_call_span = saved; @@ -2188,8 +2596,8 @@ impl Evaluator { /// callee pays one extra failed downcast instead). fn invoke_extension(&mut self, callee: &Value, args: &[Value]) -> Result { if let Some(native) = as_native(callee) { - let func = Arc::clone(&native.func); - return func(self, args).map_err(|e| self.throw(e)); + // The caller holds `callee` for the call (no `Arc` bump). + return (native.func)(self, args).map_err(|e| self.throw(e)); } if let Some(closure) = as_closure(callee) { return self.apply_closure(closure, args); @@ -2394,6 +2802,16 @@ impl Evaluator { // every thread stack size — and `maybe_grow` inlines // when stack suffices, keeping the hot path // allocation-free. + self.guarded_call(|ev| ev.apply_closure_frame(closure, args)) + } + + /// Run one unbounded application under the #205 call-depth + /// guard, on a grown stack segment when the native stack runs + /// low (see [`apply_closure`](Self::apply_closure)). + pub(crate) fn guarded_call( + &mut self, + f: impl FnOnce(&mut Self) -> Result, + ) -> Result { if self.call_depth >= MAX_CALL_DEPTH { return Err(self.throw(NativeError::new( ThrowClass::StackOverflowError, @@ -2401,9 +2819,11 @@ impl Evaluator { ))); } self.call_depth += 1; - let result = stacker::maybe_grow(STACK_RED_ZONE, STACK_SEGMENT, || { - self.apply_closure_frame(closure, args) - }); + let result = if self.call_depth % STACK_PROBE_EVERY == 0 { + stacker::maybe_grow(STACK_RED_ZONE, STACK_SEGMENT, || f(self)) + } else { + f(self) + }; self.call_depth -= 1; result } @@ -2426,6 +2846,9 @@ impl Evaluator { /// The arity dispatch behind [`apply_closure`](Self::apply_closure). fn apply_closure_inner(&mut self, closure: &Closure, args: &[Value]) -> Result { + if let Some(code) = &closure.vm { + return crate::vm::apply(self, closure, code, args); + } let mut variadic: Option<&crate::expr::Arity> = None; for arity in closure.arities.iter() { if arity.rest.is_none() && arity.fixed.len() == args.len() { @@ -2508,6 +2931,19 @@ impl Evaluator { } } + /// [`destructure`](Self::destructure) for a binding (`:or` + /// defaults evaluate against `env`). + pub(crate) fn destructure_in( + &mut self, + pat: &Pattern, + value: &Value, + env: &Env, + out: &mut Vec, + ) -> Result<(), Signal> { + self.destructure(pat, value, env, out, ForceSet::default()) + .map(|_| ()) + } + /// Destructure a value against a pattern, pushing bound values /// in [`pattern_names`] order. Returns the computed outlet /// values (requested or forced) for `subs` projection. @@ -3246,7 +3682,7 @@ fn pr_of(v: &Value) -> String { } /// Variadic rest: `nil` when empty, else the list (§E5). -fn rest_list(args: &[Value]) -> Value { +pub(crate) fn rest_list(args: &[Value]) -> Value { if args.is_empty() { Value::Nil } else { diff --git a/rust/crates/clj-eval/src/expr.rs b/rust/crates/clj-eval/src/expr.rs index 299d1128de..7da4a09400 100644 --- a/rust/crates/clj-eval/src/expr.rs +++ b/rust/crates/clj-eval/src/expr.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use clj_reader::Span; use clj_value::{ ClassVal, ExtensionValue, HostVal, Keyword, List, Map, PrintOpts, Symbol, Value, Var, Vector, - map_entry, new_map, pr_to_string, print_to_string, + map_entry, pr_to_string, print_to_string, }; use crate::core::cce_message; @@ -145,6 +145,10 @@ pub enum Expr { sym: Symbol, doc: Option, init: Option>, + /// The name's metadata pairs when they hold forms to evaluate + /// (`^{:arglists '([x])}`, `^String` — the JVM's `DefExpr` + /// evaluates the map; constant maps stay on `sym`). + meta: Option>, span: Span, }, If { @@ -706,6 +710,29 @@ pub struct Analyzer<'a> { /// Plan-path recursion budget (`None` on the run path, which /// keeps today's unbounded behavior). limit: Option, + /// The display of the innermost enclosing fn (`None` at the + /// top level): nested fns name themselves under it, like the + /// JVM's `enclosingMethod.objx.name` class prefix. + fn_base: Option, + /// Whether the top-level form runs inside the JVM's `eval` + /// wrapper fn (every form not headed by a `def…` symbol): + /// its fns name under `{ns}/eval{N}`, a `def`'s under `{ns}`. + pub(crate) top_wrapped: bool, + /// A `def`/`let` binding name waiting for the init form: a fn + /// form (or a macro's expansion) takes it, like the JVM's + /// `analyze(context, form, name)`. + hint: Option, + /// The hint handed to the `fn`/`fn*` parser of this form. + fn_hint: Option, +} + +/// How a new fn names itself (see [`Analyzer::fn_display`]). +enum FnLabel<'s> { + Anon, + /// `(fn name …)`, `letfn`: always the id suffix. + Own(&'s str), + /// A `def`/`let` binding name: suffixed below the top level. + Hint(&'s str), } impl<'a> Analyzer<'a> { @@ -723,14 +750,48 @@ impl<'a> Analyzer<'a> { eval_seq, depth: 0, limit, + fn_base: None, + top_wrapped: true, + hint: None, + fn_hint: None, + } + } + + /// The display for a new fn — the JVM class name, demunged: + /// under the enclosing fn's name, else the namespace (plus the + /// `eval{N}` wrapper for non-`def` top-level forms). Own names + /// always take the id suffix; a `def`/`let` name only below the + /// top level (`(defn bar …)` is `foo/bar`); + /// anonymous fns are `fn--{id}`. + fn fn_display(&mut self, label: FnLabel<'_>) -> String { + let seq = self.eval.next_fn_seq(); + let nested = self.fn_base.is_some() || self.top_wrapped; + let base = match &self.fn_base { + Some(base) => base.clone(), + None if self.top_wrapped => format!("{}/eval{}", self.current_ns(), self.eval_seq), + None => self.current_ns().to_string(), + }; + match label { + FnLabel::Own(name) => format!("{base}/{name}--{seq}"), + FnLabel::Hint(name) if nested => format!("{base}/{name}--{seq}"), + FnLabel::Hint(name) => format!("{base}/{name}"), + FnLabel::Anon => format!("{base}/fn--{seq}"), } } + /// Run `f` with `display` as the enclosing fn name. + fn under_fn(&mut self, display: &str, f: impl FnOnce(&mut Self) -> T) -> T { + let saved = self.fn_base.replace(display.to_owned()); + let out = f(self); + self.fn_base = saved; + out + } + /// The `*ns*`-synced current namespace name (every resolve /// check and installation reads this, never the raw registry /// current). - fn current_ns(&self) -> String { - self.eval.current_ns_name() + fn current_ns(&self) -> Arc { + self.eval.current_ns_arc() } fn err( @@ -782,13 +843,17 @@ impl<'a> Analyzer<'a> { tail: bool, ) -> Result { let span = analyze_span(form, fallback); + // A pending `def`/`let` name is for this form only. + let hint = self.hint.take(); match form { Value::Symbol(sym) => self.analyze_symbol(sym, span), Value::List(list) if !list.is_empty() => { let items: Vec<&Value> = list.iter().collect(); - self.analyze_list(form, &items, span, tail) + self.analyze_list(form, &items, span, tail, hint) + } + form if crate::lazy::is_seq_value(form) => { + self.analyze_seq_form(form, span, tail, hint) } - form if crate::lazy::is_seq_value(form) => self.analyze_seq_form(form, span, tail), Value::Vector(v) => self.analyze_vec_lit(v, span), // Entries in code (syntax-quote can embed them) analyze // like vector literals, answering plain vectors (Oracle @@ -814,6 +879,7 @@ impl<'a> Analyzer<'a> { form: &Value, span: Span, tail: bool, + hint: Option, ) -> Result { let items = match crate::lazy::collect_all(self.eval, form.clone()) { Ok(items) => items, @@ -824,7 +890,7 @@ impl<'a> Analyzer<'a> { return Ok(Expr::Literal(form.clone())); } let refs: Vec<&Value> = items.iter().collect(); - self.analyze_list(form, &refs, span, tail) + self.analyze_list(form, &refs, span, tail, hint) } /// A vector literal (elements analyze left to right). @@ -838,6 +904,18 @@ impl<'a> Analyzer<'a> { /// A map literal (keys and values analyze left to right). fn analyze_map_lit(&mut self, m: &Map, span: Span) -> Result { + Ok(Expr::MapLit( + self.analyze_map_pairs(m, span)?, + m.meta().cloned(), + )) + } + + /// A map literal's pairs (keys and values analyze left to right). + fn analyze_map_pairs( + &mut self, + m: &Map, + span: Span, + ) -> Result, CompileError> { let mut out = Vec::with_capacity(m.size()); for (k, val) in m.iter() { out.push(( @@ -845,7 +923,7 @@ impl<'a> Analyzer<'a> { self.analyze(val, span, false)?, )); } - Ok(Expr::MapLit(out, m.meta().cloned())) + Ok(out) } /// A set literal (elements analyze left to right). @@ -948,6 +1026,7 @@ impl<'a> Analyzer<'a> { } let cur = self.current_ns(); if let Some((target, id)) = self.eval.registry.resolve_target_and_id(&cur, sym) { + self.eval.force_lazy(id); // Value-position checks (private, then macro — see // `global_value_expr`). return self.global_value_expr(sym, span, &cur, target, id); @@ -1056,14 +1135,16 @@ impl<'a> Analyzer<'a> { items: &[&Value], span: Span, tail: bool, + mut hint: Option, ) -> Result { let head = items[0]; - if let Some(expr) = self.try_head_intercept(head, items, span, tail)? { + if let Some(expr) = self.intercept_with_hint(head, items, span, tail, &mut hint)? { return Ok(expr); } // Macro expansion (`analyzeSeq` order: specials, then // macros): an expanded form re-analyzes from the top. if let Some(expanded) = self.try_expand_macro(form, head, &items[1..], span)? { + self.hint = hint; let out = self.analyze(&expanded, span, tail); // B1: macro outputs are the one unbounded-depth class // (30k threading steps build 30k-deep output). Dropping @@ -1142,6 +1223,25 @@ impl<'a> Analyzer<'a> { })) } + /// [`try_head_intercept`](Self::try_head_intercept) with a + /// binding name: a `fn` form's parser takes it (a macro's + /// expansion takes what is left). + fn intercept_with_hint( + &mut self, + head: &Value, + items: &[&Value], + span: Span, + tail: bool, + hint: &mut Option, + ) -> Result, CompileError> { + if is_fn_head(head) { + self.fn_hint = hint.take(); + } + let intercepted = self.try_head_intercept(head, items, span, tail); + self.fn_hint = None; + intercepted + } + /// Head interceptions before macro expansion (split from /// [`analyze_list`] for the complexity budget): FX conditionals, /// then dot forms, then special/builtin heads. @@ -1398,7 +1498,7 @@ impl<'a> Analyzer<'a> { } let sym = Symbol::new(None, ns_part); let (target, id) = self.eval.registry.resolve_target_and_id(cur, &sym)?; - let root = self.eval.deref_var(id, &target); + let root = self.eval.deref_var_unforced(id, &target); let Value::HostType(h) = root else { return None; }; @@ -1607,11 +1707,15 @@ impl<'a> Analyzer<'a> { if self.locals.is_empty() { Value::Nil } else { - let mut out = new_map(); - for name in &self.locals { - out = out.insert(Value::Symbol(Symbol::new(None, name)), Value::Bool(true)); - } - Value::Map(out) + // One build (each chained `insert` interned a fresh shape + // per prefix — quadratic in the locals, per expansion); + // shadowed names keep their first key, as `insert` did. + Value::Map(Map::from_kvs_last_wins( + self.locals + .iter() + .map(|name| (Value::Symbol(Symbol::new(None, name)), Value::Bool(true))) + .collect(), + )) } } @@ -1628,8 +1732,7 @@ impl<'a> Analyzer<'a> { } self.eval .registry - .get("clojure.core") - .and_then(|ns| ns.lookup(&target)) + .lookup_in_ns("clojure.core", &target) .is_some_and(|id| crate::macros::is_macro_var(self.eval, id)) } @@ -1642,7 +1745,7 @@ impl<'a> Analyzer<'a> { return Ok(()); }; let home = target.namespace().unwrap_or(""); - if home == cur { + if home == &*cur { return Ok(()); } if crate::macros::is_macro_var(self.eval, id) { @@ -1720,7 +1823,14 @@ impl<'a> Analyzer<'a> { let Some(mac) = mac else { return Ok(None); }; - let env = self.macro_env(); + // Native expanders (the `core_macros`/FX4 ports) never read + // `&env`: only interpreted macros get the locals map, built + // per expansion. + let env = if crate::ext::as_native(&mac.root).is_some() { + Value::Nil + } else { + self.macro_env() + }; let arg_vals: Vec = args.iter().map(|a| (*a).clone()).collect(); let expanded = crate::macros::expand1(self.eval, head, &mac.root, form, &env, &arg_vals, span)?; @@ -1737,11 +1847,7 @@ impl<'a> Analyzer<'a> { return false; }; if target.namespace() == Some("clojure.core") && target.name() == sym.name() { - let id = self - .eval - .registry - .get("clojure.core") - .and_then(|ns| ns.lookup(&target)); + let id = self.eval.registry.lookup_in_ns("clojure.core", &target); return id.is_none_or(|id| !crate::macros::is_macro_var(self.eval, id)); } true @@ -1831,7 +1937,7 @@ impl<'a> Analyzer<'a> { /// `declare-qualified`). fn check_def_target(&self, sym: &Symbol, span: Span) -> Result<(), CompileError> { let cur = self.current_ns(); - if sym.namespace().is_some_and(|ns| ns != cur) { + if sym.namespace().is_some_and(|ns| ns != &*cur) { let message = if self.eval.registry.resolve_in(&cur, sym).is_some() { "Can't create defs outside of current ns" } else { @@ -1871,13 +1977,24 @@ impl<'a> Analyzer<'a> { .qualify_in(&cur, sym) .with_meta(sym.meta().cloned()); self.eval.registry.intern_def_target(&qualified); + // The metadata analyzes before the init (`DefExpr.Parser`). + let meta = match sym.meta() { + Some(m) if m.iter().any(|(k, v)| needs_eval(k) || needs_eval(v)) => { + Some(self.analyze_map_pairs(m, span)?) + } + _ => None, + }; let init = init - .map(|form| self.analyze(form, span, false).map(Box::new)) + .map(|form| { + self.hint = Some(sym.name().to_owned()); + self.analyze(form, span, false).map(Box::new) + }) .transpose()?; Ok(Expr::Def { sym: qualified, doc, init, + meta, span, }) } @@ -2175,6 +2292,27 @@ fn is_ident(v: &Value) -> bool { /// Bound names of a pattern in canonical slot order (entries, then /// keys, then `:as`/outlets; fixed, then rest, then `:as`). The /// builder and the evaluator share this order. +/// How many names [`pattern_names`] lists (no allocation). +pub(crate) fn pattern_count(pat: &Pattern) -> usize { + match pat { + Pattern::Symbol(_) => 1, + Pattern::Vec(v) => { + v.fixed.iter().map(pattern_count).sum::() + + v.rest.as_deref().map_or(0, pattern_count) + + usize::from(v.as_sym.is_some()) + } + Pattern::Map(m) => { + let outlets = [&m.as_sym, &m.select, &m.all, &m.excess, &m.missing]; + m.entries + .iter() + .map(|(p, ..)| pattern_count(p)) + .sum::() + + m.keys.len() + + outlets.iter().filter(|o| o.is_some()).count() + } + } +} + pub(crate) fn pattern_names(pat: &Pattern) -> Vec { match pat { Pattern::Symbol(name) => vec![name.clone()], @@ -2217,6 +2355,22 @@ impl<'a> Analyzer<'a> { items: &[&Value], span: Span, tail: bool, + ) -> Result { + match self.lift_seq_clauses(items, span)? { + Some(forms) => { + let refs: Vec<&Value> = forms.iter().collect(); + self.parse_try_items(&refs, span, tail) + } + None => self.parse_try_items(items, span, tail), + } + } + + /// [`parse_try`](Self::parse_try) over list-shaped clauses. + fn parse_try_items( + &mut self, + items: &[&Value], + span: Span, + tail: bool, ) -> Result { let mut splits = items.len(); for (i, form) in items.iter().enumerate().skip(1) { @@ -2301,6 +2455,33 @@ impl<'a> Analyzer<'a> { /// `try` (the clause is parsed inline, without rebinding — /// probe25 `catch-nonsym`, probe32 `catch-*-nope`); only the /// clause body descends (via [`Analyzer::analyze`]). + /// Macro-built `catch`/`finally` clauses arrive as seqs (`Cons`, + /// `LazySeq`, ...): the JVM tests `ISeq` and reads the first + /// item, so seq clauses read as lists here (`None` when no item + /// needs it; non-clause seqs stay as they are). + fn lift_seq_clauses( + &mut self, + items: &[&Value], + span: Span, + ) -> Result>, CompileError> { + let mut out: Option> = None; + for (i, form) in items.iter().enumerate().skip(1) { + if matches!(form, Value::List(_)) || !crate::lazy::is_seq_value(form) { + continue; + } + let head = crate::lazy::first_value(self.eval, form) + .map_err(|s| form_force_err(&self.source, s, span))?; + if !is_clause_head(&head) { + continue; + } + let all = crate::lazy::collect_all(self.eval, (*form).clone()) + .map_err(|s| form_force_err(&self.source, s, span))?; + let forms = out.get_or_insert_with(|| items.iter().map(|f| (*f).clone()).collect()); + forms[i] = Value::List(List::from_vec(all)); + } + Ok(out) + } + fn parse_catch(&mut self, list: &List, span: Span) -> Result { let parts: Vec<&Value> = list.iter().collect(); let class = match parts.get(1) { @@ -2622,7 +2803,7 @@ impl<'a> Analyzer<'a> { /// evaluator replays with a conveyed closure. fn parse_bound_fn(&mut self, items: &[&Value], span: Span) -> Result { let (name, raw) = self.parse_fn_front(&items[1..], span)?; - let (fun, _) = self.build_fun(name.clone(), &raw, span)?; + let (fun, _) = self.build_fun(name.clone(), None, &raw, span)?; Ok(Expr::BoundFn { name, fun }) } @@ -2703,7 +2884,7 @@ impl<'a> Analyzer<'a> { clause.fixed.insert(0, ampenv.clone()); clause.fixed.insert(0, ampform.clone()); } - let (mut fun, _) = self.build_fun(None, &raw, span)?; + let (mut fun, _) = self.build_fun(None, None, &raw, span)?; let cur = self.current_ns(); fun.display = format!("{cur}/{}", name_sym.name()); let qualified = @@ -2836,16 +3017,43 @@ fn form_force_err(source: &Arc, s: Signal, span: Span) -> CompileError { fn as_clause(form: &Value) -> Option<(&str, &List)> { match form { Value::List(list) => match list.first() { - Some(Value::Symbol(sym)) if sym.namespace().is_none() => match sym.name() { - "catch" | "finally" => Some((sym.name(), list)), - _ => None, - }, + Some(Value::Symbol(sym)) if is_clause_sym(sym) => Some((sym.name(), list)), _ => None, }, _ => None, } } +/// Whether a metadata form evaluates to something other than +/// itself (symbols, non-empty lists, and collections holding them). +fn needs_eval(v: &Value) -> bool { + match v { + Value::Symbol(_) => true, + Value::List(l) => !l.is_empty(), + Value::Vector(items) => items.iter().any(|x| needs_eval(&x)), + Value::Set(items) => items.iter().any(needs_eval), + Value::Map(m) => m.iter().any(|(k, x)| needs_eval(k) || needs_eval(x)), + other => crate::lazy::is_seq_value(other), + } +} + +/// A `fn`/`fn*` head (a binding name hint reaches its parser). +fn is_fn_head(head: &Value) -> bool { + matches!(head, Value::Symbol(sym) if match sym.namespace() { + None => matches!(sym.name(), "fn" | "fn*"), + Some(ns) => ns == "clojure.core" && sym.name() == "fn", + }) +} + +/// A clause head: unqualified `catch` or `finally`. +fn is_clause_head(head: &Value) -> bool { + matches!(head, Value::Symbol(sym) if is_clause_sym(sym)) +} + +fn is_clause_sym(sym: &Symbol) -> bool { + sym.namespace().is_none() && matches!(sym.name(), "catch" | "finally") +} + /// A binding-pattern shape (spec- or star-validated before building). enum Pat<'v> { Symbol(&'v Symbol), @@ -3663,8 +3871,7 @@ impl<'a> Analyzer<'a> { /// `lazy-seq*` — the body runs at force time, as the JVM's /// `(fn* [] …)` does). The display names the trapped closure. fn parse_delay_star(&mut self, items: &[&Value], span: Span) -> Result { - let seq = self.eval.next_fn_seq(); - let display = format!("user/eval{}/delay--{seq}", self.eval_seq); + let display = self.fn_display(FnLabel::Own("delay")); let saved_recur = self.recur; let saved_no_recur = self.no_recur; self.recur = Some(0); @@ -3948,6 +4155,9 @@ impl<'a> Analyzer<'a> { let base = self.locals.len(); let mut bindings_out = Vec::with_capacity(pats.len()); for i in (0..forms.len()).step_by(2) { + if let Value::Symbol(name) = &forms[i] { + self.hint = Some(name.name().to_owned()); + } let init = self.analyze(&forms[i + 1], span, false)?; let validated = as_pat(&forms[i]).expect("patterns validated before building"); let (pat, names) = self.build_pattern(validated, span)?; @@ -4412,8 +4622,9 @@ impl<'a> Analyzer<'a> { /// (20-count, parameter walk under the emitted `let`'s wrapper, /// body, dup checks; fixed-vs-variadic last). fn parse_fn(&mut self, items: &[&Value], span: Span) -> Result { + let hint = self.fn_hint.take(); let (name, raw) = self.parse_fn_front(&items[1..], span)?; - self.finish_fn(name, &raw, span) + self.finish_fn(name, hint, &raw, span) } /// `(fn …)`/`(bound-fn …)` front: the optional name plus the @@ -4454,6 +4665,7 @@ impl<'a> Analyzer<'a> { /// seq cast and dies at the arglist-NPE like `()` (probe33 /// `fn*-nil`); anything else is the `ISeq` CCE. fn parse_fn_star(&mut self, items: &[&Value], span: Span) -> Result { + let hint = self.fn_hint.take(); let mut rest = &items[1..]; let mut name: Option = None; if let Some((Value::Symbol(sym), tail)) = rest.split_first() { @@ -4488,7 +4700,7 @@ impl<'a> Analyzer<'a> { }; let slices: Vec<&[&Value]> = owned.iter().map(Vec::as_slice).collect(); let raw = self.parse_star_clauses(&slices, span)?; - self.finish_fn(name, &raw, span) + self.finish_fn(name, hint, &raw, span) } /// Shared `fn`/`fn*` tail: display name, the self-recursion @@ -4499,14 +4711,16 @@ impl<'a> Analyzer<'a> { fn build_fun( &mut self, name: Option, + hint: Option<&str>, raw: &[RawClause], span: Span, ) -> Result<(FunExpr, Option<(String, usize)>), CompileError> { - let seq = self.eval.next_fn_seq(); - let display = match &name { - Some(n) => format!("user/eval{}/{}--{seq}", self.eval_seq, n), - None => format!("user/eval{}/fn--{seq}", self.eval_seq), + let label = match (&name, hint) { + (Some(n), _) => FnLabel::Own(n), + (None, Some(h)) => FnLabel::Hint(h), + (None, None) => FnLabel::Anon, }; + let display = self.fn_display(label); let base = self.locals.len(); let name_slot = if let Some(n) = &name { self.locals.push(n.clone()); @@ -4514,7 +4728,7 @@ impl<'a> Analyzer<'a> { } else { None }; - let arities = self.analyze_arities(raw, span)?; + let arities = self.under_fn(&display, |a| a.analyze_arities(raw, span))?; self.locals.truncate(base); let fun = FunExpr { display, @@ -4526,14 +4740,31 @@ impl<'a> Analyzer<'a> { } } + /// A fn displayed under its own `name` without a self-recursion + /// slot (`letfn*` binds the names itself). + fn build_labeled( + &mut self, + name: Option<&str>, + raw: &[RawClause], + span: Span, + ) -> Result { + let display = self.fn_display(name.map_or(FnLabel::Anon, FnLabel::Own)); + let arities = self.under_fn(&display, |a| a.analyze_arities(raw, span))?; + Ok(FunExpr { + display, + arities: arities.into(), + }) + } + /// Shared `fn`/`fn*` tail: the `LetFn` wrap for names. fn finish_fn( &mut self, name: Option, + hint: Option, raw: &[RawClause], span: Span, ) -> Result { - let (fun, bound) = self.build_fun(name, raw, span)?; + let (fun, bound) = self.build_fun(name, hint.as_deref(), raw, span)?; // A named `fn` is a one-function `letfn`: the frame extends // first, the closure captures it, then the slot fills // (self-recursion). A `let` wrap would capture the @@ -4858,13 +5089,12 @@ impl<'a> Analyzer<'a> { } let mut fns = Vec::with_capacity(validated.len()); for spec in &validated { - let seq = self.eval.next_fn_seq(); - let display = format!("user/eval{}/{}--{seq}", self.eval_seq, spec.name); + let display = self.fn_display(FnLabel::Own(&spec.name)); let tail_refs: Vec<&Value> = spec.tail.iter().collect(); let owned = self.collect_fn_clauses(&tail_refs, span)?; let slices: Vec<&[&Value]> = owned.iter().map(Vec::as_slice).collect(); let raw = self.parse_clause_slices(&slices, span)?; - let arities = self.analyze_arities(&raw, span)?; + let arities = self.under_fn(&display, |a| a.analyze_arities(&raw, span))?; fns.push(( spec.name.clone(), FunExpr { @@ -5279,10 +5509,7 @@ impl<'a> Analyzer<'a> { if rest.len() != items.len() { let refs: Vec<&Value> = rest.iter().collect(); let (name, raw) = self.parse_fn_front(&refs, span)?; - let (mut fun, _) = self.build_fun(None, &raw, span)?; - if let Some(n) = name { - fun.display = fun.display.replacen("/fn--", &format!("/{n}--"), 1); - } + let fun = self.build_labeled(name.as_deref(), &raw, span)?; return Ok(LetFnInit::Fun(fun)); } } @@ -5343,6 +5570,7 @@ fn seq_refs(v: &Value) -> Vec { #[cfg(test)] mod tests { use super::*; + use clj_value::new_map; #[test] fn seq_values_non_seqable_is_empty() { diff --git a/rust/crates/clj-eval/src/ext.rs b/rust/crates/clj-eval/src/ext.rs index 54a0256457..4750973362 100644 --- a/rust/crates/clj-eval/src/ext.rs +++ b/rust/crates/clj-eval/src/ext.rs @@ -138,6 +138,10 @@ pub struct Closure { pub env: Env, pub meta: Option, pub conveyed: Option, + /// The register VM's code and captured values (`None` for + /// closures the tree evaluator made, which run `arities` over + /// `env`). + pub(crate) vm: Option, } impl ExtensionValue for Closure { @@ -315,6 +319,20 @@ impl ExtensionValue for NsHandle { ) } + /// `print-object` writes the `toString` unquoted when not + /// readably. + fn print_str(&self) -> Option { + Some(format!( + "#object[clojure.lang.Namespace 0x{:x} {}]", + self.id, self.name + )) + } + + /// `Namespace.toString` is the name. + fn java_str(&self) -> Option { + Some(self.name.to_string()) + } + fn dyn_eq(&self, other: &dyn ExtensionValue) -> bool { other .as_any() diff --git a/rust/crates/clj-eval/src/hierarchy.rs b/rust/crates/clj-eval/src/hierarchy.rs index 1ca33bf2c7..e444b42e25 100644 --- a/rust/crates/clj-eval/src/hierarchy.rs +++ b/rust/crates/clj-eval/src/hierarchy.rs @@ -68,7 +68,7 @@ pub(crate) fn read_global(eval: &Evaluator) -> Result { let Some(id) = global_id(eval) else { return Err(unresolvable_global()); }; - Ok(eval.deref_var(id, &Symbol::new(Some("clojure.core"), "global-hierarchy"))) + Ok(eval.deref_var_unforced(id, &Symbol::new(Some("clojure.core"), "global-hierarchy"))) } /// Alter the global hierarchy under the var-alter lock diff --git a/rust/crates/clj-eval/src/io.rs b/rust/crates/clj-eval/src/io.rs index d49c7cd466..9a8ffb1fa4 100644 --- a/rust/crates/clj-eval/src/io.rs +++ b/rust/crates/clj-eval/src/io.rs @@ -769,9 +769,9 @@ fn native_read_string(eval: &mut Evaluator, args: &[Value]) -> Result>, env: Env, }, + /// The VM's `lazy-seq*`: the body compiled as a zero-arity + /// closure over its captured locals (`recur` loops inside it). + Call0 { + f: Value, + }, Repeatedly { f: Value, }, @@ -218,16 +222,23 @@ pub(crate) enum Thunk { input: Value, }, Distinct { - seen: HashSet, + // Persistent: `force_pending` clones the thunk per step (to + // restore it when a step fails), so an owned set made + // `distinct` quadratic (recipes `convex_hull`). + seen: clj_value::MemberSet, input: Value, }, Dedupe { last: Option, input: Value, }, + /// `keep` (`skip: None`, nil results drop) and a `for` stage's + /// `:when` chain (`__for-keep*`: only results identical to + /// `skip` drop — nil results stay). Keep { f: Value, input: Value, + skip: Option, }, KeepIndexed { f: Value, @@ -441,6 +452,7 @@ fn drop_thunk_link(thunk: Thunk) -> Option { Thunk::MapCat { outer } => Some(outer), Thunk::Chain { tail, .. } | Thunk::ChunkHead { tail, .. } => Some(tail), Thunk::Thunk0 { .. } + | Thunk::Call0 { .. } | Thunk::Repeatedly { .. } | Thunk::Seque { .. } | Thunk::FileSeq { .. } @@ -1023,40 +1035,70 @@ fn step_realized(items: &[Value], chunked: bool) -> Option { /// `BigDec`/ratio step with no exact decimal raises the JVM /// `ArithmeticException` (oracle `range-mixed`). fn step_range_value(eval: &mut Evaluator, r: &RangeValue) -> Result { - let mut items = Vec::new(); + if let Some(unit) = step_long_range(r) { + return Ok(unit); + } + let mut items = Vec::with_capacity(CHUNK_SIZE); let mut cur = r.clone(); - let mut tail = Value::Nil; + // One tail per chunk: `cur` advances in place, boxed once below. + let mut live = true; while items.len() < CHUNK_SIZE { - let Some((head, next)) = cur.stepped() else { + let Some((head, advanced)) = cur.step_in_place() else { break; }; items.push(head); - match next { - Some(nxt) => { - tail = Value::Extension(Arc::new(nxt.clone())); - cur = nxt; - } - None => { - if cur.step_inexact() { - return Err(eval.throw(range_step_error())); - } - tail = Value::Nil; - break; + if !advanced { + if cur.step_inexact() { + return Err(eval.throw(range_step_error())); } + live = false; + break; } } // Empty ranges never exist as values (`range` nil-puns), so // stepping always yields at least one head. debug_assert!(!items.is_empty(), "stepped ranges are non-empty"); - if cur.done() { - tail = Value::Nil; - } + let tail = if live && !cur.done() { + Value::Extension(Arc::new(cur)) + } else { + Value::Nil + }; Ok(StepUnit::Chunk { items: Arc::from(items), tail, }) } +/// A counted `long` range's next chunk built directly (#226): the +/// items are the range's own (every one is in `i64` range — the +/// count was exact at construction), and the tail is a fresh range +/// past them (no metadata, like `step_in_place`). +fn step_long_range(r: &RangeValue) -> Option { + let (RangeNum::Int(cur), RangeNum::Int(step), Some(count)) = ( + r.cur(), + r.step(), + r.count().filter(|c| r.is_long() && *c > 0), + ) else { + return None; + }; + let n = count.min(CHUNK_SIZE as i64); + let items: Arc<[Value]> = (0..n).map(|k| Value::Int(cur + k * step)).collect(); + let rest = count - n; + let tail = if rest > 0 { + let next = RangeValue::new( + RangeNum::Int(cur + n * step), + r.end(), + RangeNum::Int(step), + true, + Some(rest), + ); + Value::Extension(Arc::new(next)) + } else { + Value::Nil + }; + Some(StepUnit::Chunk { items, tail }) +} + /// Step a repeat: one head plus the decremented repeat (infinite /// repeats return themselves; tails drop metadata like /// `Repeat.next`). @@ -1241,6 +1283,7 @@ fn step_thunk(eval: &mut Evaluator, thunk: Thunk) -> Result { Thunk::MapCat { outer } => step_mapcat(eval, outer), Thunk::Chain { head, tail } => step_chain(eval, head, tail), Thunk::Thunk0 { body, env } => step_thunk0(eval, &body, &env), + Thunk::Call0 { f } => step_call0(eval, &f), Thunk::Repeatedly { f } => step_repeatedly(eval, &f), Thunk::Interleave { queue, inputs } => step_interleave(eval, queue, inputs), Thunk::Interpose { sep, input, first } => step_interpose(eval, sep, input, first), @@ -1270,7 +1313,7 @@ fn step_thunk_mid(eval: &mut Evaluator, thunk: Thunk) -> Result Thunk::PartitionRun { f, fv, input } => step_partition_run(eval, &f, &fv, input), Thunk::Distinct { seen, input } => step_distinct(eval, seen, input), Thunk::Dedupe { last, input } => step_dedupe(eval, last, input), - Thunk::Keep { f, input } => step_keep(eval, &f, input), + Thunk::Keep { f, input, skip } => step_keep(eval, &f, input, skip), Thunk::KeepIndexed { f, input, idx } => step_keep_indexed(eval, &f, input, idx), other => step_thunk_rest(eval, other), } @@ -1691,15 +1734,26 @@ fn step_map(eval: &mut Evaluator, f: &Value, inputs: Vec) -> Result Result { - let (items, tail) = match step_unit(eval, &input)? { + let (items, tail): (Arc<[Value]>, Value) = match step_unit(eval, &input)? { StepUnit::Empty => return Ok(Forced::Empty), - StepUnit::One { head, tail } => (vec![head], tail), - StepUnit::Chunk { items, tail } => (items.to_vec(), tail), + StepUnit::One { head, tail } => (Arc::from([head]), tail), + StepUnit::Chunk { items, tail } => (items, tail), }; crate::core::check_ifn(eval, f)?; let mut out = Vec::with_capacity(items.len()); - for item in &items { - out.push(eval.call(f, std::slice::from_ref(item))?); + // The fn resolves once per chunk (#226): a VM fn takes each item + // straight into its register, a native runs its entry. + match crate::vm::Prepared::of(f, 1) { + Some(p) => { + for item in items.iter() { + out.push(p.call1(eval, crate::vm::clone_value(item))?); + } + } + None => { + for item in items.iter() { + out.push(eval.call(f, std::slice::from_ref(item))?); + } + } } Ok(Forced::Chunk { items: Arc::from(out), @@ -1932,6 +1986,21 @@ fn step_mapcat(eval: &mut Evaluator, outer: Value) -> Result { match force_pending(eval, pending)? { Forced::Empty => return Ok(Forced::Empty), Forced::Chunk { items, tail } => { + let (out, used) = splice_realized(&items); + if used > 0 { + // The leading realized results splice whole; the + // rest of the chunk (if any) continues the outer. + let rest = items[used..].to_vec(); + let next = pending_value(Thunk::Chain { head: rest, tail }); + if out.is_empty() { + outer = next; + continue; + } + return Ok(Forced::Chunk { + items: Arc::from(out), + tail: pending_value(Thunk::MapCat { outer: next }), + }); + } let mut it = items.iter(); let head = it.next().expect("step chunks are non-empty"); let rest: Vec = it.cloned().collect(); @@ -1948,6 +2017,27 @@ fn step_mapcat(eval: &mut Evaluator, outer: Value) -> Result { } } +/// The leading results of a forced `mapcat` chunk that are already +/// realized (nil, lists, vectors), their items in order, and how +/// many results that took. Splicing them forces nothing (the +/// chunked outer `map` has run `f` over the whole chunk already, as +/// on the JVM), and one output chunk replaces a concat step per item +/// — `for` with `:when` yields a one-item list or nil per element. +fn splice_realized(items: &[Value]) -> (Vec, usize) { + let mut out = Vec::new(); + let mut used = 0; + for item in items { + match item { + Value::Nil => {} + Value::List(l) => out.extend(l.iter().cloned()), + Value::Vector(v) => out.extend(v.iter()), + _ => break, + } + used += 1; + } + (out, used) +} + /// Yield the first unit of a spliced seq, chaining its tail ahead /// of the mapcat continuation. fn splice_first(eval: &mut Evaluator, s: &Value, cont: Value) -> Result { @@ -2399,7 +2489,7 @@ fn seq_opt(eval: &mut Evaluator, v: &Value) -> Result, Signal> { #[allow(clippy::mutable_key_type)] fn step_distinct( eval: &mut Evaluator, - mut seen: HashSet, + mut seen: clj_value::MemberSet, mut input: Value, ) -> Result { loop { @@ -2507,17 +2597,32 @@ fn step_sequence( }) } -/// `keep`: whole units, dropping nil results. -fn step_keep(eval: &mut Evaluator, f: &Value, mut input: Value) -> Result { +/// `keep`: whole units, dropping nil results — or, under a `skip` +/// sentinel (a `for` stage), dropping only results identical to it. +fn step_keep( + eval: &mut Evaluator, + f: &Value, + mut input: Value, + skip: Option, +) -> Result { loop { let Some((items, tail)) = step_items(eval, &input)? else { return Ok(Forced::Empty); }; crate::core::check_ifn(eval, f)?; + let prepared = crate::vm::Prepared::of(f, 1); let mut kept = Vec::new(); for item in &items { - let kept_item = eval.call(f, std::slice::from_ref(item))?; - if !matches!(kept_item, Value::Nil) { + let kept_item = match &prepared { + Some(p) => p.call1(eval, crate::vm::clone_value(item))?, + None => eval.call(f, std::slice::from_ref(item))?, + }; + let dropped = match &skip { + None => matches!(kept_item, Value::Nil), + Some(s) => matches!((&kept_item, s), + (Value::Extension(a), Value::Extension(b)) if Arc::ptr_eq(a, b)), + }; + if !dropped { kept.push(kept_item); } } @@ -2529,12 +2634,26 @@ fn step_keep(eval: &mut Evaluator, f: &Value, mut input: Value) -> Result Result { + check_arity("clojure.core/__for-keep*", args, &[3])?; + Ok(pending_value(Thunk::Keep { + f: args[0].clone(), + input: args[1].clone(), + skip: Some(args[2].clone()), + })) +} + /// `keep-indexed`: whole units with running indices, dropping nil /// results. fn step_keep_indexed( @@ -2692,6 +2811,14 @@ fn step_thunk0(eval: &mut Evaluator, body: &[Expr], env: &Env) -> Result Result { + let value = eval.call(f, &[])?; + let forced = seq_value(eval, &value)?; + step_value_forced(eval, &forced) +} + /// The empty list value (`()`). pub(crate) fn empty_list_value() -> Value { Value::List(List::new_sync()) @@ -3261,12 +3388,24 @@ pub(crate) fn first_value(eval: &mut Evaluator, v: &Value) -> Result Option { + as_range(v) + .filter(|r| !r.step_inexact() && !r.done()) + .map(|r| r.cur().value()) +} + /// A range shifted past its head (`()` when spent; metadata /// dropped, like `Range.next`/`LongRange.next`). A `BigDec`/ratio /// step with no exact decimal raises (oracle `range-mixed`). @@ -3476,6 +3615,9 @@ pub(crate) fn take_walk_coll(args: &mut SmallVec<[Value; 4]>, idx: usize) -> Val /// without ever materializing a `Vec` (8 bytes per element /// peak instead of 72, single copy). pub(crate) fn collect_packed(eval: &mut Evaluator, v: Value) -> Result { + if let Some(direct) = collect_direct(&v) { + return Ok(direct); + } let mut tv = TransientVector::new(); // Owned: the walk advances `cur` off the head (owned callers // nilled the arg slot, so memoized chunks free as it walks). @@ -3499,6 +3641,33 @@ pub(crate) fn collect_packed(eval: &mut Evaluator, v: Value) -> Result Option { + let Value::Extension(e) = v else { + return None; + }; + let any = e.as_any(); + if let Some(r) = any.downcast_ref::() { + let n = usize::try_from(r.n()?.max(0)).ok()?; + return Some(match r.x() { + Value::Int(x) => Vector::from_ints(vec![*x; n]), + x => Vector::from_vec(vec![x.clone(); n]), + }); + } + let r = any.downcast_ref::()?; + let (clj_value::RangeNum::Int(cur), clj_value::RangeNum::Int(step), Some(n)) = + (r.cur(), r.step(), r.count().filter(|_| r.is_long())) + else { + return None; + }; + let ints = (0..n.max(0)) + .map(|k| k.checked_mul(step).and_then(|d| cur.checked_add(d))) + .collect::>>()?; + Some(Vector::from_ints(ints)) +} + pub(crate) fn collect_all(eval: &mut Evaluator, v: Value) -> Result, Signal> { let mut out = Vec::new(); // Owned: the walk advances `cur` off the head (owned callers @@ -4160,6 +4329,11 @@ pub(crate) fn thunk0_value(body: Arc>, env: Env) -> Value { pending_value(Thunk::Thunk0 { body, env }) } +/// The VM's `lazy-seq*` value over a zero-arity body closure. +pub(crate) fn call0_value(f: Value) -> Value { + pending_value(Thunk::Call0 { f }) +} + /// A `repeat` value: non-positive counts answer `()` (like /// `Repeat.create`), else a strict `Repeat` (`None` is infinite). pub(crate) fn repeat_thunk(x: Value, n: Option) -> Value { @@ -4267,7 +4441,7 @@ pub(crate) fn partition_by_thunk(f: Value, input: Value) -> Value { /// A `Thunk::Distinct` tail value (nothing seen yet). pub(crate) fn distinct_thunk(input: Value) -> Value { pending_value(Thunk::Distinct { - seen: HashSet::new(), + seen: clj_value::MemberSet::default(), input, }) } @@ -4285,7 +4459,11 @@ pub(crate) fn sequence_thunk(step: Value, srcs: Vec) -> Value { /// A `Thunk::Keep` tail value. pub(crate) fn keep_thunk(f: Value, input: Value) -> Value { - pending_value(Thunk::Keep { f, input }) + pending_value(Thunk::Keep { + f, + input, + skip: None, + }) } /// A `Thunk::KeepIndexed` tail value (indices from zero). @@ -4352,6 +4530,7 @@ pub(crate) fn native_cons(eval: &mut Evaluator, args: &[Value]) -> Result bool { - let probe = Value::Keyword(Keyword::new(None, "macro")); - eval.registry.state(id).is_some_and(|s| { - s.meta - .as_ref() - .and_then(|m| m.get(&probe)) - .is_some_and(Value::is_truthy) - }) + eval.registry + .meta_flag(id, &clj_value::static_keyword!("macro")) } /// Whether a var id is private (`:private` meta truthy; cleared /// metas read public). pub(crate) fn is_private_var(eval: &Evaluator, id: u64) -> bool { - let probe = Value::Keyword(Keyword::new(None, "private")); - eval.registry.state(id).is_some_and(|s| { - s.meta - .as_ref() - .and_then(|m| m.get(&probe)) - .is_some_and(Value::is_truthy) - }) + eval.registry + .meta_flag(id, &clj_value::static_keyword!("private")) } /// Fence macros that stay identity (real macros on the JVM; the @@ -136,7 +126,10 @@ fn is_identity_head(eval: &Evaluator, op: &Value) -> bool { /// macro var. Private cross-namespace macros are the bare /// `IllegalStateException` (callers wrap per position); anything /// unresolvable or non-macro is `Ok(None)`. -pub(crate) fn lookup_macro(eval: &Evaluator, op: &Value) -> Result, NativeError> { +pub(crate) fn lookup_macro( + eval: &mut Evaluator, + op: &Value, +) -> Result, NativeError> { let (id, target) = match op { Value::Symbol(sym) => { let cur = eval.current_ns_name(); @@ -148,6 +141,9 @@ pub(crate) fn lookup_macro(eval: &Evaluator, op: &Value) -> Result (v.id, v.sym.clone()), _ => return Ok(None), }; + // Every list head resolves here first: a lazy `core.clj` var + // (fn or macro) forces before any metadata decides its role. + eval.force_lazy(id); if !is_macro_var(eval, id) { return Ok(None); } diff --git a/rust/crates/clj-eval/src/ns.rs b/rust/crates/clj-eval/src/ns.rs index a486daaa67..d496d7a9df 100644 --- a/rust/crates/clj-eval/src/ns.rs +++ b/rust/crates/clj-eval/src/ns.rs @@ -23,7 +23,7 @@ use clj_value::{ use crate::concurrency::DEREF_NIL; use crate::core::cce_err; -use crate::env::{Env, Namespace, SharedRegistry}; +use crate::env::{Env, Namespace, ReferTable, SharedRegistry}; use crate::error::{NativeError, Signal, jvm_class_of, site_of}; use crate::eval::{Evaluator, SourceError}; use crate::expr::{Expr, NsClause}; @@ -49,7 +49,7 @@ const NIL_NAME_NPE: &str = "Cannot invoke \"clojure.lang.Named.getName()\" becau /// The embedded `clojure.core` composites: `boot` loads this with /// the current namespace already `clojure.core` (so the file carries /// intentionally no `ns` form), then refers the publics into `user`. -const CORE_CLJ: &str = include_str!("core.clj"); +pub(crate) static CORE_CLJ: &str = include_str!("core.clj"); /// The embedded `clojure.spec.alpha` slice-1 source (gap-122): /// `resolve_source` serves this for `clojure/spec/alpha` (unlike @@ -80,7 +80,7 @@ const JAVA_SHELL_CLJ: &str = include_str!("java_shell.clj"); /// `clojure/string` + `clojure/set` through the ordinary /// `require` path (the `spec_alpha.clj` shape), so scripts work /// with zero install steps. -const STDLIB_STRING_CLJ: &str = include_str!("stdlib/clojure/string.clj"); +pub(crate) static STDLIB_STRING_CLJ: &str = include_str!("stdlib/clojure/string.clj"); const STDLIB_SET_CLJ: &str = include_str!("stdlib/clojure/set.clj"); // W1 (verbatim+pure) — do not merge by hand, integrator resolves. const STDLIB_W1_TEMPLATE_CLJ: &str = include_str!("stdlib/clojure/template.clj"); @@ -213,7 +213,7 @@ fn the_ns_name(reg: &SharedRegistry, v: &Value) -> Result { } /// A namespace handle value for a live-or-removed namespace. -fn ns_value(name: &str, id: u64) -> Value { +pub(crate) fn ns_value(name: &str, id: u64) -> Value { Value::Extension(Arc::new(NsHandle { name: Arc::from(name), id, @@ -346,14 +346,14 @@ pub(crate) fn signal_to_native(s: Signal) -> NativeError { fn native_create_ns(eval: &mut Evaluator, args: &[Value]) -> Result { check_arity("clojure.core/create-ns", args, &[1])?; let key = sym_str(strict_sym(&args[0])?); - let id = eval.registry.ensure_ns_id(&key); + let id = eval.registry.ensure_ns(&key); Ok(ns_value(&key, id)) } fn native_in_ns(eval: &mut Evaluator, args: &[Value]) -> Result { check_arity("clojure.core/in-ns", args, &[1])?; let key = sym_str(strict_sym(&args[0])?); - let id = eval.registry.ensure_ns_id(&key); + let id = eval.registry.ensure_ns(&key); eval.registry.set_current(&key); // `RT.inNamespace` is a thread-local `set`: the top frame when // bound (boot pushes a base frame, so the root never moves), @@ -543,9 +543,8 @@ fn native_alias(eval: &mut Evaluator, args: &[Value]) -> Result return Err(npe("Expecting Symbol + Namespace")), Value::Symbol(s) => s, @@ -611,17 +610,6 @@ fn check_sequential(v: &Value) -> Result, NativeError> { /// source's publics into the current namespace, subject to filters. /// A namespace's public vars, sorted by name: qualified symbol /// plus the id pinned for each refer. -fn refer_publics(eval: &Evaluator, home: &Namespace) -> Vec<(Symbol, u64)> { - let mut publics = Vec::new(); - for (s, i) in home.interns() { - if is_public(&eval.registry, i) { - publics.push((s.clone(), i)); - } - } - publics.sort_by(|a, b| a.0.name().cmp(b.0.name())); - publics -} - /// The `refer` work list: `(or (:refer fs) (:only fs) (keys /// nspublics))`, with nils falling through and `:all` meaning every /// public (core.clj `refer`). @@ -656,7 +644,7 @@ fn refer_exclude(pairs: &[(&Value, &Value)]) -> Result, NativeError> fn refer_target( eval: &Evaluator, lib_key: &str, - publics: &[(Symbol, u64)], + publics: &ReferTable, item: &Value, ) -> Result<(Symbol, Symbol, u64), NativeError> { let item_sym = match item { @@ -668,13 +656,12 @@ fn refer_target( )); } }; - match publics.iter().find(|(s, _)| s.name() == item_sym.name()) { - Some((s, i)) => Ok(((*item_sym).clone(), s.clone(), *i)), + match publics.get(item_sym.name()) { + Some((target, id)) => Ok(((*item_sym).clone(), target.clone(), *id)), None => { let interned = eval .registry - .get(lib_key) - .and_then(|home| home.lookup(&Symbol::new(Some(lib_key), item_sym.name()))) + .lookup_in_ns(lib_key, &Symbol::new(Some(lib_key), item_sym.name())) .is_some(); let why = if interned { "is not public" @@ -722,39 +709,121 @@ fn refer_renamed( fn refer_into(eval: &mut Evaluator, lib: &Value, filters: &[Value]) -> Result { let lib_key = sym_str(strict_sym(lib)?); - let Some(home) = eval.registry.get(&lib_key) else { + let Some(publics) = eval.registry.publics_table(&lib_key) else { return Err(ex(format!("No namespace: {lib_key}"))); }; let pairs = opt_pairs(filters)?; - let publics = refer_publics(eval, &home); - let public_names: Vec = publics + match refer_all_exclude(&pairs)? { + Some(exclude) => { + refer_all_into_current(eval, &publics, &exclude); + Ok(Value::Nil) + } + None => refer_filtered(eval, &lib_key, &publics.table, &pairs), + } +} + +/// Every public minus `:exclude` (boot's `user`, each `ns` form's +/// `refer-clojure`): the lib's shared table. +fn refer_all_into_current( + eval: &mut Evaluator, + publics: &crate::env::PublicsTable, + exclude: &[Value], +) { + let exclude: Vec<&str> = exclude .iter() - .map(|(s, _)| Value::Symbol(Symbol::new(None, s.name()))) + .filter_map(|e| match e { + Value::Symbol(x) if x.namespace().is_none() => Some(x.name()), + _ => None, + }) .collect(); - let to_do = refer_todo(public_names, &pairs)?; - let exclude = refer_exclude(&pairs)?; - let rename = match opt_get(&pairs, "rename") { + let cur = eval.current_ns_name(); + eval.registry.ensure_ns(&cur); + eval.registry + .with_ns_mut(&cur, |home| home.refer_all(publics, &exclude)); +} + +/// `refer` with `:only`/`:refer`/`:rename`: item by item, in name +/// order (the per-item order — `:rename` fn calls, the stop at a +/// failing item — stays deterministic). +fn refer_filtered( + eval: &mut Evaluator, + lib_key: &str, + publics: &ReferTable, + pairs: &[(&Value, &Value)], +) -> Result { + let mut names: Vec<&Arc> = publics.keys().collect(); + names.sort_unstable(); + let public_names: Vec = names + .into_iter() + .map(|n| Value::Symbol(Symbol::new(None, n))) + .collect(); + let to_do = refer_todo(public_names, pairs)?; + let exclude = refer_exclude(pairs)?; + let rename = match opt_get(pairs, "rename") { None | Some(Value::Nil) | Some(Value::Bool(false)) => None, Some(v) => Some(v), }; - for item in &to_do { - if exclude.contains(item) { - continue; + // A `:rename` fn runs user code per item, which may observe the + // refers installed so far; otherwise one batch installs them + // (`user` refers every `clojure.core` public at boot). Items + // before a failing one stay installed either way. + let per_item = rename.is_some_and(|r| !matches!(r, Value::Map(_))); + let mut pending = Vec::new(); + let mut failure = None; + for item in to_do.iter().filter(|item| !exclude.contains(item)) { + let step = refer_target(eval, lib_key, publics, item).and_then(|(item_sym, target, id)| { + Ok((refer_renamed(eval, rename, &item_sym, item)?, target, id)) + }); + match step { + Ok(refer) => pending.push(refer), + Err(e) => { + failure = Some(e); + break; + } } - let (item_sym, target_q, var_id) = refer_target(eval, &lib_key, &publics, item)?; - let renamed = refer_renamed(eval, rename, &item_sym, item)?; - // Interns win over refers (the JVM's REJECTED warning waits - // for `*err*` in S5D). - let cur = eval.current_ns_name(); - eval.registry.ensure_ns(&cur); - let q = Symbol::new(Some(cur.as_str()), renamed.name()); - eval.registry.with_ns_mut(&cur, |home| { - if home.lookup(&q).is_none() { - home.add_refer(renamed.name(), target_q, var_id); + if per_item { + install_refers(eval, &mut pending); + } + } + install_refers(eval, &mut pending); + failure.map_or(Ok(Value::Nil), Err) +} + +/// Install pending `(name, target, var id)` refers into the current +/// namespace (only the first symbol's name is read). Interns win +/// over refers (the JVM's REJECTED warning waits for `*err*` in +/// S5D); a namespace without interns skips the check. +fn install_refers(eval: &mut Evaluator, pending: &mut Vec<(Symbol, Symbol, u64)>) { + if pending.is_empty() { + return; + } + let cur = eval.current_ns_name(); + eval.registry.ensure_ns(&cur); + eval.registry.with_ns_mut(&cur, |home| { + let check = home.interns().next().is_some(); + for (renamed, target, id) in pending.drain(..) { + if check + && home + .lookup(&Symbol::new(Some(home.name()), renamed.name())) + .is_some() + { + continue; } - }); + home.add_refer(renamed.name(), target, id); + } + }); +} + +/// `refer`'s options when they select every public (no `:refer`, +/// `:only`, or `:rename`): the `:exclude` list, else `None`. +fn refer_all_exclude(pairs: &[(&Value, &Value)]) -> Result>, NativeError> { + let unset = |k| opt_get(pairs, k).is_none_or(|v| matches!(v, Value::Nil)); + let no_rename = + opt_get(pairs, "rename").is_none_or(|v| matches!(v, Value::Nil | Value::Bool(false))); + if unset("refer") && unset("only") && no_rename { + return refer_exclude(pairs).map(Some); } - Ok(Value::Nil) + Ok(None) } fn native_refer(eval: &mut Evaluator, args: &[Value]) -> Result { @@ -811,6 +880,10 @@ fn native_intern(eval: &mut Evaluator, args: &[Value]) -> Result Value { match core_var(eval, name) { - Some(id) => eval.deref_var(id, &Symbol::new(Some("clojure.core"), name)), + Some(id) => eval.deref_var_unforced(id, &Symbol::new(Some("clojure.core"), name)), None => Value::Nil, } } @@ -1579,6 +1652,19 @@ fn run_loaded( text: &str, isolate_ns: bool, ) -> Result, SourceError> { + in_load_frame(eval, file_star, isolate_ns, |eval| { + eval.load_source(spans, text) + }) +} + +/// [`run_loaded`]'s frame around any load body (the lazy library +/// load shares it). +fn in_load_frame( + eval: &mut Evaluator, + file_star: Value, + isolate_ns: bool, + body: impl FnOnce(&mut Evaluator) -> R, +) -> R { let cur = eval.registry.current().to_owned(); let depth = eval.bindings_depth(); let source_path = source_path_value(&file_star); @@ -1603,12 +1689,41 @@ fn run_loaded( if let Some(id) = core_var(eval, "*read-eval*") { eval.push_binding(core_var_handle(id, "*read-eval*"), Value::Bool(true)); } - let out = eval.load_source(spans, text); + let out = body(eval); eval.registry.set_current(&cur); eval.pop_bindings(depth); out } +/// Embedded libraries that load lazily (see `core_lazy::load_lazy`): +/// every `run` requires `clojure.string`, and its defs are plain. +const LAZY_LIBS: &[&str] = &["clojure/string"]; + +/// Load an allow-listed embedded library lazily (`None` for any +/// other root): its prefix and non-def forms run in the ordinary +/// load frame, its defs defer. +fn load_lazy_lib( + eval: &mut Evaluator, + root: &str, + file_star: &str, +) -> Option, SourceError>> { + if !LAZY_LIBS.contains(&root) { + return None; + } + let &(_, _, text) = EMBEDDED_SOURCES.iter().find(|(r, _, _)| *r == root)?; + let file = Value::Str(Arc::from(file_star)); + Some(in_load_frame(eval, file, true, |eval| { + let prev = eval.swap_source(Arc::from(file_star)); + let lazy = crate::core_lazy::load_lazy(eval, file_star, text); + eval.swap_source(prev); + match lazy { + Ok(true) => Ok(Vec::new()), + Ok(false) => eval.load_source(file_star, text), + Err(e) => Err(e), + } + })) +} + /// The `*source-path*` value for a load (S18): file loads bind the /// last path segment (even `load-file` absolutes); `load-string` /// (`*file*` nil) binds the root. @@ -1699,7 +1814,7 @@ pub(crate) fn load_one( eval, std::slice::from_ref(&Value::Str(Arc::from(root.as_str()))), )?; - if need_ns && eval.registry.get(&sym_str(lib)).is_none() { + if need_ns && eval.registry.ns_id(&sym_str(lib)).is_none() { return Err(ex(format!( "namespace '{}' not found after loading '{}'", sym_str(lib), @@ -1827,7 +1942,7 @@ fn load_lib_phase( } } None => { - if opts.need_ns && eval.registry.get(&sym_str(joined_sym)).is_none() { + if opts.need_ns && eval.registry.ns_id(&sym_str(joined_sym)).is_none() { Err(ex(format!("namespace '{}' not found", sym_str(joined_sym)))) } else { Ok(()) @@ -1889,7 +2004,7 @@ fn load_lib( let opts = lib_opts(&pairs); let loaded = libs_set(eval)?.contains(&joined); let joined_sym = strict_sym(&joined)?.clone(); - let undefined_on_entry = eval.registry.get(&sym_str(&joined_sym)).is_none(); + let undefined_on_entry = eval.registry.ns_id(&sym_str(&joined_sym)).is_none(); let load = select_load(&opts, loaded); load_lib_phase(eval, &joined_sym, &opts, load, undefined_on_entry)?; load_lib_finish(eval, &joined_sym, &pairs, &opts) @@ -2259,13 +2374,16 @@ fn load_one_path(eval: &mut Evaluator, full: &str, pending: &List) -> Result<(), core_var_handle(id, "*pending-paths*"), Value::List(pending.clone().push_front(Value::Str(Arc::from(full)))), ); - let out = run_loaded( - eval, - Value::Str(Arc::from(file_star.as_str())), - &file_star, - &text, - true, - ); + let out = match load_lazy_lib(eval, &root, &file_star) { + Some(out) => out, + None => run_loaded( + eval, + Value::Str(Arc::from(file_star.as_str())), + &file_star, + &text, + true, + ), + }; eval.pop_bindings(pending_depth); match out { Ok(_) => { @@ -2389,17 +2507,15 @@ fn load_reader_arg(args: &[Value]) -> Result<&crate::reader::StreamReader, Nativ } } -/// `load-reader`: read every form from a reader and evaluate -/// them, returning the last value (nil when empty — S7; probes -/// `l-*`). `*file*` is nil, `*ns*` is isolated, `*read-eval*` is -/// forced true (visibly), and `*pending-paths*` is untouched. +/// `load-reader`: read and evaluate every form from a reader, +/// returning the last value (nil when empty — S7; probes `l-*`). +/// `*file*` is nil, `*ns*` is isolated, `*read-eval*` is forced true +/// (visibly), and `*pending-paths*` is untouched. fn native_load_reader(eval: &mut Evaluator, args: &[Value]) -> Result { let reader = load_reader_arg(args)?; crate::reader::check_reader_open(reader)?; // `Compiler.load` consumes leading whitespace before its entry - // checks, so entry failures blame the post-skip position; the - // parser stays out of its lock for the whole read phase (the - // hook runs user code, which may read the same reader again). + // checks, so entry failures blame the post-skip position. let mut opts = clj_reader::ReaderOpts { read_cond: clj_reader::ReadCond::Allow, ..clj_reader::ReaderOpts::default() @@ -2407,58 +2523,74 @@ fn native_load_reader(eval: &mut Evaluator, args: &[Value]) -> Result seed, Err(cce) => { let wrap = crate::reader::executing_wrap(line, col, &cce, eval.next_id(), eval.next_id()); - crate::reader::restore_parser(reader, parser); return Err(source_to_native(eval, "", wrap)); } }; - crate::reader::sync_reader_ns(eval, &mut opts); opts.read_eval = seed.read_eval; opts.suppress_read = seed.suppress_read; opts.resolver_bound = seed.resolver_bound; - parser.set_opts(&opts); - let mut forms = Vec::new(); - loop { - // Per-form S6 consult (B1 — mirrors `read_forms_loop`): - // snapshot, read, drain, resolve. Data-reader failures - // surface like `load`'s (raw uncaught throwables, not - // `CompilerException` wraps). - let (outcome, suppressed) = match load_reader_step(eval, &mut opts, &mut parser) { - Ok(step) => step, - Err(e) => { - crate::reader::restore_parser(reader, parser); - let thrown = eval.throwable(e); - return Err(source_to_native(eval, "", SourceError::Uncaught(thrown))); - } - }; - match outcome { - Ok(Some(form)) => match crate::core::resolve_data_readers(eval, form, suppressed) { - Ok(form) => forms.push(form), - Err(e) => { - crate::reader::restore_parser(reader, parser); - let thrown = eval.throwable(e); - return Err(source_to_native(eval, "", SourceError::Uncaught(thrown))); - } - }, - Ok(None) => break, - Err(e) => { - crate::reader::restore_parser(reader, parser); - return Err(source_to_native(eval, "", SourceError::Reader(e))); - } - } + let frame = push_load_frame(eval); + let out = load_reader_forms(eval, reader, &mut opts); + pop_load_frame(eval, frame); + out.map_err(|e| source_to_native(eval, "", e)) +} + +/// Read and evaluate one form at a time, like `Compiler.load`: each +/// form reads after the previous one evaluates (a later read error +/// leaves the earlier defs standing — oracle `l-err-read-defined`). +fn load_reader_forms( + eval: &mut Evaluator, + reader: &crate::reader::StreamReader, + opts: &mut clj_reader::ReaderOpts, +) -> Result { + let mut out = Value::Nil; + while let Some(form) = load_reader_next(eval, reader, opts)? { + out = eval.eval_top_form(&form)?; } + Ok(out) +} + +/// One `load-reader` read: the namespace view re-synced, the parser +/// out of its lock for the read (the hook runs user code, which may +/// read the same reader again), the per-form S6 consult (B1 — +/// snapshot, read, drain, resolve). Data-reader failures surface +/// like `load`'s (raw uncaught throwables, not `CompilerException` +/// wraps). +fn load_reader_next( + eval: &mut Evaluator, + reader: &crate::reader::StreamReader, + opts: &mut clj_reader::ReaderOpts, +) -> Result, SourceError> { + crate::reader::sync_reader_ns(eval, opts); + let (mut parser, _) = crate::reader::take_parser(reader, opts); + let read = match load_reader_step(eval, opts, &mut parser) { + Ok((Ok(Some(form)), suppressed)) => { + // `Compiler.load` skips the whitespace after a form before + // evaluating it (a `read-line` there starts past it). + parser.consume_whitespaces(); + crate::core::resolve_data_readers(eval, form, suppressed).map(Some) + } + Ok((Ok(None), _)) => Ok(None), + Ok((Err(e), _)) => { + crate::reader::restore_parser(reader, parser); + return Err(SourceError::Reader(e)); + } + Err(e) => Err(e), + }; crate::reader::restore_parser(reader, parser); - run_loaded_values(eval, forms) + read.map_err(|e| SourceError::Uncaught(eval.throwable(e))) } -/// Evaluate loaded forms under the `load-reader` frame (`*file*` -/// nil, `*ns*` isolated, `*read-eval*` forced true) and return the -/// last value (nil when empty). -fn run_loaded_values(eval: &mut Evaluator, forms: Vec) -> Result { +/// The `load-reader` evaluation frame (`*file*` nil, `*ns*` +/// isolated, `*read-eval*` forced true): the namespace and binding +/// depth [`pop_load_frame`] restores. +fn push_load_frame(eval: &mut Evaluator) -> (String, usize) { let cur = eval.registry.current().to_owned(); let depth = eval.bindings_depth(); if let Some(id) = core_var(eval, "*file*") { @@ -2481,20 +2613,12 @@ fn run_loaded_values(eval: &mut Evaluator, forms: Vec) -> Result out = v, - Err(e) => { - eval.registry.set_current(&cur); - eval.pop_bindings(depth); - return Err(source_to_native(eval, "", e)); - } - } - } + (cur, depth) +} + +fn pop_load_frame(eval: &mut Evaluator, (cur, depth): (String, usize)) { eval.registry.set_current(&cur); eval.pop_bindings(depth); - Ok(out) } fn native_loaded_libs(eval: &mut Evaluator, args: &[Value]) -> Result { @@ -2748,15 +2872,6 @@ fn def_var(eval: &mut Evaluator, name: &str, root: Value, dynamic: bool, private }); } -/// Evaluate one boot form: the embedded boot sequence is fixed, so -/// any failure is a port bug (pinned by `core_loads`), never user -/// input. -fn boot_eval(eval: &mut Evaluator, src: &str) { - if let Err(e) = eval.eval_source(src) { - unreachable!("boot form failed ({src}): {e:?}"); - } -} - /// Boot a fresh evaluator: `clojure.core` plus `user` (current), /// the §N natives and vars installed, every public install referred /// into `user` (JVM boot refers `clojure.core`), a permanent `*ns*` @@ -2768,7 +2883,7 @@ fn boot_eval(eval: &mut Evaluator, src: &str) { pub fn boot() -> Evaluator { let mut eval = Evaluator::new_bare("user"); eval.registry.ensure_ns("clojure.core"); - let user_ns = eval.registry.ensure_ns("user").id(); + let user_ns = eval.registry.ensure_ns("user"); eval.registry.set_current("user"); boot_install_natives(&mut eval); boot_install_builtin_macros(&mut eval); @@ -2777,26 +2892,38 @@ pub fn boot() -> Evaluator { boot_core_vars(&mut eval, user_handle.clone()); crate::printsys::boot_print_system(&mut eval); crate::reader::boot_read_arglists(&mut eval); - boot_refer_user(&mut eval); if let Some(id) = core_var(&eval, "*ns*") { eval.push_binding(core_var_handle(id, "*ns*"), user_handle); } // Embedded `core.clj` composites (their privates resolve in // `clojure.core`), then `user` refers the publics — the file - // carries no `ns` form by design. - boot_eval(&mut eval, "(in-ns 'clojure.core)"); - boot_eval(&mut eval, CORE_CLJ); - boot_eval(&mut eval, "(in-ns 'user)"); - boot_eval(&mut eval, "(refer 'clojure.core)"); + // carries no `ns` form by design. Its defs register lazily: the + // first resolution of each var evaluates its form (#226). The + // namespace switches and the refer run directly (evaluating + // `(in-ns …)`/`(refer …)` needed `user` to refer them first). + boot_in_ns(&mut eval, "clojure.core"); + crate::core_lazy::boot_lazy(&mut eval, CORE_CLJ); + boot_in_ns(&mut eval, "user"); + let core = Value::Symbol(Symbol::new(None, "clojure.core")); + refer_into(&mut eval, &core, &[]).expect("boot refers clojure.core into user"); // The load's macroexpansions consume `gensym` numbers; the // user-visible counter restarts from 1 (S5C pins `G__1` first). eval.reset_gensym(); eval } +/// `in-ns` at boot (see [`native_in_ns`]), without evaluating a form. +fn boot_in_ns(eval: &mut Evaluator, name: &str) { + let id = eval.registry.ensure_ns(name); + eval.registry.set_current(name); + if let Some(var_id) = core_var(eval, "*ns*") { + set_ns_current(eval, var_id, ns_value(name, id)); + } +} + /// Install the §N natives into `clojure.core`. fn boot_install_natives(eval: &mut Evaluator) { - for (name, f) in NATIVES + let natives = NATIVES .iter() .chain(crate::vars::VAR_NATIVES.iter()) .chain(crate::macros::MACRO_NATIVES.iter()) @@ -2826,7 +2953,9 @@ fn boot_install_natives(eval: &mut Evaluator) { .chain(crate::classemu::CLASS_EMU_NATIVES.iter()) .chain(crate::classemu::CLASS_EMU_HIDDEN.iter()) .chain(crate::reflect::REFLECT_HIDDEN.iter()) - { + .collect::>(); + eval.registry.reserve_vars("clojure.core", natives.len()); + for (name, f) in natives { eval.install_native_ptr("clojure.core", name, *f); } } @@ -2934,100 +3063,6 @@ fn boot_core_vars(eval: &mut Evaluator, user_handle: Value) { ); } -/// Refer every public install into `user` (JVM boot refers -/// `clojure.core`). -fn boot_refer_user(eval: &mut Evaluator) { - let mut names: Vec = NATIVES - .iter() - .chain(crate::vars::VAR_NATIVES.iter()) - .chain(crate::macros::MACRO_NATIVES.iter()) - .chain(crate::lazy::LAZY_NATIVES.iter()) - .chain(crate::core::CORE_NATIVES.iter()) - .chain(crate::seqs::SEQS_NATIVES.iter()) - .chain(crate::coll::COLL_NATIVES.iter()) - .chain(crate::re::RE_NATIVES.iter()) - .chain(crate::rng::RNG_NATIVES.iter()) - .chain(crate::io::IO_NATIVES.iter()) - .chain(crate::reader::READER_NATIVES.iter()) - .chain(crate::concurrency::CONC_NATIVES.iter()) - .chain(crate::stm::STM_NATIVES.iter()) - .chain(crate::fx4_host::FX4_NATIVES.iter()) - .chain(crate::collmisc::COLLMISC_NATIVES.iter()) - .chain(crate::printsys::PRINTSYS_NATIVES.iter()) - .chain(crate::classemu::CLASS_EMU_NATIVES.iter()) - .map(|(n, _)| (*n).to_owned()) - .collect(); - names.extend( - crate::macros::BUILTIN_MACROS - .iter() - .map(|s| (*s).to_owned()), - ); - names.extend( - [ - "*ns*", - "*file*", - "*loaded-libs*", - "*pending-paths*", - "*loading-verbosely*", - "*load-path*", - "*out*", - "*err*", - "*in*", - "*assert*", - "*unchecked-math*", - "*warn-on-reflection*", - "*allow-unresolved-vars*", - "*print-meta*", - "*print-readably*", - "*print-dup*", - "*read-eval*", - "*suppress-read*", - "*reader-resolver*", - "*flush-on-newline*", - "print-method", - "print-dup", - "char-escape-string", - "char-name-string", - "*agent*", - "*math-context*", - "*print-length*", - "*print-level*", - "*print-namespace-maps*", - "*1", - "*2", - "*3", - "*e", - "*command-line-args*", - "*clojure-version*", - "*data-readers*", - "*default-data-reader-fn*", - "default-data-readers", - "*compile-path*", - "*compile-files*", - "*compiler-options*", - "*source-path*", - "*fn-loader*", - "*use-context-classloader*", - "*verbose-defrecords*", - "primitives-classnames", - ] - .iter() - .map(|s| (*s).to_owned()), - ); - let mut refers: Vec<(String, Symbol, u64)> = Vec::new(); - for name in &names { - let q = Symbol::new(Some("clojure.core"), name); - if let Some(id) = id_of(&eval.registry, &q).filter(|id| is_public(&eval.registry, *id)) { - refers.push((name.clone(), q, id)); - } - } - eval.registry.with_ns_mut("user", |user| { - for (name, q, id) in refers { - user.add_refer(&name, q, id); - } - }); -} - #[cfg(test)] mod tests { use super::*; @@ -3054,13 +3089,6 @@ mod tests { ); } - #[test] - #[should_panic(expected = "boot form failed")] - fn boot_eval_panics_loudly() { - let mut eval = boot(); - boot_eval(&mut eval, "(defn broken [)"); - } - #[test] #[should_panic(expected = "import specs validate before use")] fn symbol_of_non_symbol_panics_loudly() { @@ -3154,13 +3182,17 @@ mod tests { } #[test] - fn run_loaded_values_without_vars_is_idle() { - // S18 `load-reader`: a bare evaluator binds nothing and - // answers nil for empty input (the missing-var arms are the - // pin — every `if let` here must tolerate absence). + fn load_frame_without_vars_is_idle() { + // S18 `load-reader`: a bare evaluator binds nothing (the + // missing-var arms are the pin — every `if let` here must + // tolerate absence). let mut eval = Evaluator::new_bare("user"); - let out = run_loaded_values(&mut eval, Vec::new()).expect("bare empty load-reader"); - assert_eq!(out, Value::Nil); + let depth = eval.bindings_depth(); + let cur = eval.registry.current().to_owned(); + let frame = push_load_frame(&mut eval); + assert_eq!(eval.bindings_depth(), depth); + pop_load_frame(&mut eval, frame); + assert_eq!(eval.registry.current(), cur); } #[test] diff --git a/rust/crates/clj-eval/src/portdeps.rs b/rust/crates/clj-eval/src/portdeps.rs index 0810c71758..894189ce6e 100644 --- a/rust/crates/clj-eval/src/portdeps.rs +++ b/rust/crates/clj-eval/src/portdeps.rs @@ -93,9 +93,9 @@ fn edn_reader_opts(eval: &mut Evaluator) -> Result { read_eval: false, ..Default::default() }; - if let Some(ns) = eval.registry.ns_live_or_removed(&cur) { - opts.aliases = ns.alias_map(); - opts.refers = ns.refer_map(); + if let Some(maps) = eval.registry.reader_maps(&cur) { + opts.aliases = maps.aliases; + opts.refers = maps.refers; } crate::core::snapshot_data_readers(eval, &mut opts)?; Ok(opts) diff --git a/rust/crates/clj-eval/src/printsys.rs b/rust/crates/clj-eval/src/printsys.rs index 9f2f2fe95b..6eef6df587 100644 --- a/rust/crates/clj-eval/src/printsys.rs +++ b/rust/crates/clj-eval/src/printsys.rs @@ -1502,22 +1502,24 @@ pub(crate) const PRINTSYS_NATIVES: &[(&str, NativeFnPtr)] = &[ /// char-map vars, and the writer-fn arglists. Called from `boot` /// after the natives install. pub(crate) fn boot_print_system(eval: &mut Evaluator) { - let mut methods = new_map(); - methods = methods.insert( + // One build per table (chained `insert`s interned a fresh shape + // per prefix); the pair order is the insertion order. + let mut pairs = vec![( Value::Nil, method_fn_value(eval, "print-method", scalar_default), - ); + )]; for (token, f) in METHOD_TABLE { - methods = methods.insert( + pairs.push(( Value::Str(Arc::from(*token)), method_fn_value(eval, "print-method", *f), - ); + )); } let default_key = Value::Keyword(Keyword::new(None, "default")); - methods = methods.insert( + pairs.push(( default_key.clone(), method_fn_value(eval, "print-method", method_default), - ); + )); + let methods = Map::from_kvs_last_wins(pairs); let method_dispatch = method_fn_value(eval, "print-method", dispatch_method); install_plain_root( eval, @@ -1531,14 +1533,14 @@ pub(crate) fn boot_print_system(eval: &mut Evaluator) { hierarchy: None, })), ); - let mut dup_methods = new_map(); - dup_methods = dup_methods.insert(Value::Nil, method_fn_value(eval, "print-dup", dup_delegate)); + let mut dup_pairs = vec![(Value::Nil, method_fn_value(eval, "print-dup", dup_delegate))]; for (token, f) in DUP_TABLE { - dup_methods = dup_methods.insert( + dup_pairs.push(( Value::Str(Arc::from(*token)), method_fn_value(eval, "print-dup", *f), - ); + )); } + let dup_methods = Map::from_kvs_last_wins(dup_pairs); let dup_dispatch = method_fn_value(eval, "print-dup", dispatch_dup); install_plain_root( eval, @@ -1588,11 +1590,12 @@ fn set_arglists(eval: &mut Evaluator, name: &str, params: &[&str]) { /// A char-map var root from a single-source table. fn char_map_value(table: &[(char, &str)]) -> Value { - let mut map = new_map(); - for (c, s) in table { - map = map.insert(Value::Char(*c), Value::Str(Arc::from(*s))); - } - Value::Map(map) + Value::Map(Map::from_kvs_last_wins( + table + .iter() + .map(|(c, s)| (Value::Char(*c), Value::Str(Arc::from(*s)))) + .collect(), + )) } #[cfg(test)] @@ -1825,6 +1828,7 @@ mod tests { env: Env::new(), meta: Some(with_type.clone()), conveyed: None, + vm: None, }; // `:type`-only fn meta strips to nothing ... let stripped = strip_type_meta(&Value::Extension(Arc::new(closure.clone()))); diff --git a/rust/crates/clj-eval/src/re.rs b/rust/crates/clj-eval/src/re.rs index 1b5147ab71..07d7a6084c 100644 --- a/rust/crates/clj-eval/src/re.rs +++ b/rust/crates/clj-eval/src/re.rs @@ -49,8 +49,30 @@ fn pattern_syntax(err: regex::Error) -> NativeError { /// stale value still reports `PatternSyntaxException`). /// `\Q...\E` quotations translate first (the crate has no /// quotation syntax — `Pattern/quote` output must compile). -pub(crate) fn compile_source(source: &str) -> Result { - Regex::new(&translate_quotations(source)).map_err(pattern_syntax) +pub(crate) fn compile_source(source: &str) -> Result, NativeError> { + // Memoized per thread: a compile costs far more than the matches + // one call runs (a `split` per input line recompiled its literal + // every line). Handed out shared, never cloned: each `Regex` + // clone starts an empty search-cache pool, so a clone per call + // rebuilt the lazy DFA cache on every match. A full table + // clears, bounding programs that mint patterns. Failures are not + // cached (each use reports its own syntax error). + thread_local! { + static COMPILED: std::cell::RefCell>> = + std::cell::RefCell::new(crate::env::FxHashMap::default()); + } + if let Some(re) = COMPILED.with(|c| c.borrow().get(source).cloned()) { + return Ok(re); + } + let re = Arc::new(Regex::new(&translate_quotations(source)).map_err(pattern_syntax)?); + COMPILED.with(|c| { + let mut c = c.borrow_mut(); + if c.len() >= 256 { + c.clear(); + } + c.insert(source.to_owned(), Arc::clone(&re)); + }); + Ok(re) } /// Translate `\Q...\E` quotation spans to escaped literals diff --git a/rust/crates/clj-eval/src/reader.rs b/rust/crates/clj-eval/src/reader.rs index 89e5d59e0e..01383510a0 100644 --- a/rust/crates/clj-eval/src/reader.rs +++ b/rust/crates/clj-eval/src/reader.rs @@ -533,6 +533,7 @@ impl<'a> LiveReadHooks<'a> { var: clj_value::Var, rest: Vec, ) -> Result { + self.eval.force_lazy(var.id); let root = self.eval.deref_var(var.id, &var.sym); if !is_invokable(&root) { return Err(cce_err(&root, "clojure.lang.IFn")); @@ -567,7 +568,7 @@ impl<'a> LiveReadHooks<'a> { /// aliased in the current namespace (probes `c-qq`, /// `c-alias-head`). fn ns_or_alias_known(&self, ns_part: &str) -> bool { - if self.eval.registry.get(ns_part).is_some() { + if self.eval.registry.ns_id(ns_part).is_some() { return true; } let cur = self.eval.current_ns_name(); @@ -797,16 +798,13 @@ pub(crate) fn sync_reader_ns(eval: &Evaluator, opts: &mut ReaderOpts) { // Live-or-removed: a removed current namespace's tombstone keeps // its aliases/refers (the detached `*ns*` object, like the JVM), // so syntax-quote still resolves through refers (#80). - if let Some(ns) = eval.registry.ns_live_or_removed(&cur) { - opts.aliases = ns.alias_map(); - opts.refers = ns.refer_map(); - // #120: imports feed syntax-quote's class map, so `` `String `` - // and `` `String/m `` qualify to the imported class (oracle - // `rsq-imported`, `rsq-qual-imported`). - opts.class_map = ns - .imports() - .map(|(simple, class)| (simple.to_owned(), class.name().to_owned())) - .collect(); + // #120: imports feed syntax-quote's class map, so `` `String `` + // and `` `String/m `` qualify to the imported class (oracle + // `rsq-imported`, `rsq-qual-imported`). + if let Some(maps) = eval.registry.reader_maps(&cur) { + opts.aliases = maps.aliases; + opts.refers = maps.refers; + opts.class_map = maps.class_map; } } diff --git a/rust/crates/clj-eval/src/seqs.rs b/rust/crates/clj-eval/src/seqs.rs index 3bd9dd6ba7..db7fa7a099 100644 --- a/rust/crates/clj-eval/src/seqs.rs +++ b/rust/crates/clj-eval/src/seqs.rs @@ -23,12 +23,12 @@ use crate::error::{NativeError, jvm_class_of}; use crate::eval::Evaluator; use crate::ext::{FastArith, NativeFn, NativeImpl}; use crate::lazy::{ - as_subvector, collect_all, collect_packed, concat_thunk, cycle_thunk, dedupe_thunk, - distinct_thunk, drop_value, drop_while_thunk, empty_list_value, filter_thunk, first_value, - interleave_thunk, interpose_thunk, iterate_thunk, keep_indexed_thunk, keep_thunk, keys_thunk, - map_indexed_thunk, map_thunk, mapcat_thunk, next_value, nthrest_value, partition_all_thunk, - partition_by_thunk, partition_thunk, range1, range2, range3, realized_value, reductions_thunk, - repeat_thunk, repeatedly_thunk, seq_value, take_nth_thunk, take_thunk, take_walk_coll, + StepUnit, as_subvector, collect_all, collect_packed, concat_thunk, cycle_thunk, dedupe_thunk, + distinct_thunk, drop_value, drop_while_thunk, empty_list_value, filter_thunk, interleave_thunk, + interpose_thunk, iterate_thunk, keep_indexed_thunk, keep_thunk, keys_thunk, map_indexed_thunk, + map_thunk, mapcat_thunk, next_value, nthrest_value, partition_all_thunk, partition_by_thunk, + partition_thunk, range1, range2, range3, realized_value, reductions_thunk, repeat_thunk, + repeatedly_thunk, seq_value, step_unit, take_nth_thunk, take_thunk, take_walk_coll, take_while_thunk, }; use crate::ns::{NativeFnPtr, check_arity, deref_core, signal_to_native}; @@ -88,20 +88,89 @@ fn walk_seq( coll: Value, mut visit: impl FnMut(&mut Evaluator, &Value) -> Result, ) -> Result<(), NativeError> { + if let Some(done) = walk_direct(eval, &coll, &mut visit) { + return done; + } let mut seq = seq_value(eval, &coll).map_err(signal_to_native)?; // The walk holds `seq`, never `coll`: owned callers nilled the // arg slot (`take_walk_coll`), so dropping the head here frees // memoized chunks as the walk advances (borrow callers pass a // cheap clone, retaining as before). drop(coll); - while !matches!(seq, Value::Nil) { - let head = first_value(eval, &seq).map_err(signal_to_native)?; - if matches!(visit(eval, &head)?, Walk::Stop) { - return Ok(()); + // Step-wise, not `first`/`next` per head: a chunked step hands + // over its whole chunk (a range rebuilt its 32-item chunk for + // every `first`). Forcing order is unchanged: each step forces + // exactly what `first` on that seq would, after the previous + // head's visit. + loop { + match step_unit(eval, &seq).map_err(signal_to_native)? { + StepUnit::Empty => return Ok(()), + StepUnit::One { head, tail } => { + if matches!(visit(eval, &head)?, Walk::Stop) { + return Ok(()); + } + seq = tail; + } + StepUnit::Chunk { items, tail } => { + for head in items.iter() { + if matches!(visit(eval, head)?, Walk::Stop) { + return Ok(()); + } + } + seq = tail; + } } - seq = next_value(eval, &seq).map_err(signal_to_native)?; } - Ok(()) +} + +/// Walk a vector, a finite repeat, or a counted `long` range in +/// place (#226): no chunk seq copies the items 32 at a time — the +/// order and items are the seq's (`None`: walk the seq). +fn walk_direct( + eval: &mut Evaluator, + coll: &Value, + visit: &mut impl FnMut(&mut Evaluator, &Value) -> Result, +) -> Option> { + let mut each = |items: &mut dyn Iterator| { + for head in items { + if matches!(visit(eval, &head)?, Walk::Stop) { + break; + } + } + Ok(()) + }; + if let Value::Vector(v) = coll { + return Some(each(&mut v.iter())); + } + if let Some((x, n)) = finite_repeat(coll) { + return Some(each(&mut (0..n).map(|_| x.clone()))); + } + let (cur, step, n) = long_range(coll)?; + Some(each(&mut (0..n).map(|k| Value::Int(cur + k * step)))) +} + +/// A finite `repeat`'s item and count. +fn finite_repeat(coll: &Value) -> Option<(Value, i64)> { + let Value::Extension(e) = coll else { + return None; + }; + let r = e.as_any().downcast_ref::()?; + Some((r.x().clone(), r.n()?.max(0))) +} + +/// A counted `long` range's start, step, and count (items stay in +/// `i64`: the range's own bound checked its last item). +fn long_range(coll: &Value) -> Option<(i64, i64, i64)> { + let Value::Extension(e) = coll else { + return None; + }; + let r = e.as_any().downcast_ref::()?; + match (r.cur(), r.step(), r.count().filter(|_| r.is_long())) { + (clj_value::RangeNum::Int(cur), clj_value::RangeNum::Int(step), Some(n)) => { + Some((cur, step, n.max(0))) + } + _ => None, + } } /// Force one step plus `n` heads (mirrors `dorun`'s walk: the @@ -417,8 +486,11 @@ pub(crate) fn native_reduce_owned( ecoll.clone(), ); } + // The init moves out (nothing reads `args[1]` again): a kept + // copy would pin every subtree the first step leaves untouched, + // so later steps would path-copy instead of updating in place. let (mut acc, has_acc) = if args.len() == 3 { - (args[1].clone(), true) + (std::mem::replace(&mut args[1], Value::Nil), true) } else { (Value::Nil, false) }; @@ -426,6 +498,9 @@ pub(crate) fn native_reduce_owned( let coll = take_walk_coll(&mut args, coll_idx); let mut first = true; let mut empty = true; + // The step fn resolves once (#226): a VM fn takes each step's + // args straight into registers, a native runs its entry. + let prepared = crate::vm::Prepared::of(&f, 2); walk_seq(eval, coll, |eval, head| { empty = false; if !has_acc && first { @@ -439,10 +514,7 @@ pub(crate) fn native_reduce_owned( // mutates in place inside an owned `conj` instead of // path-copying per step. let acc_taken = std::mem::replace(&mut acc, Value::Nil); - let mut step_args = SmallVec::new(); - step_args.push(acc_taken); - step_args.push(head.clone()); - acc = call_checked_owned(eval, &f, step_args)?; + acc = reduce_step(eval, &f, prepared.as_ref(), acc_taken, head)?; if let Some(inner) = as_reduced(&acc) { acc = inner.clone(); return Ok(Walk::Stop); @@ -455,6 +527,26 @@ pub(crate) fn native_reduce_owned( Ok(acc) } +/// One `reduce` step over the moved accumulator: through the +/// prepared callee, else the checked general call. +fn reduce_step( + eval: &mut Evaluator, + f: &Value, + prepared: Option<&crate::vm::Prepared<'_>>, + acc: Value, + head: &Value, +) -> Result { + match prepared { + Some(p) => p.call2(eval, acc, head.clone()).map_err(signal_to_native), + None => { + let mut step_args = SmallVec::new(); + step_args.push(acc); + step_args.push(head.clone()); + call_checked_owned(eval, f, step_args) + } + } +} + fn native_take_last(eval: &mut Evaluator, args: &[Value]) -> Result { // Borrow-path compat shim (see [`OwnedNativeImpl`]). native_take_last_owned(eval, args.iter().cloned().collect()) @@ -791,6 +883,19 @@ pub(crate) fn native_mapv_owned( // walk holds the only handles and input heads free as it // advances (#221). let inputs: Vec = args.drain(1..).collect(); + // One input under a VM fn walks it in place (#226): no lazy map + // in between, the fn resolved once. + if let ([_], Some(p)) = (&inputs[..], crate::vm::Prepared::of(&args[0], 1)) { + let input = inputs.into_iter().next().unwrap_or(Value::Nil); + let mut tv = clj_value::TransientVector::new(); + walk_seq(eval, input, |eval, head| { + let out = p.call1(eval, head.clone()).map_err(signal_to_native)?; + tv.conj(out).expect("fresh transient accepts conj"); + Ok(Walk::Continue) + })?; + let vec = tv.persistent().expect("fresh transient is valid at freeze"); + return Ok(Value::Vector(vec)); + } let mapped = map_thunk(args[0].clone(), inputs); // Packed streaming (#227): no intermediate `Vec`, and // all-`Int` results freeze to packed storage zero-copy. diff --git a/rust/crates/clj-eval/src/stdlib_natives.rs b/rust/crates/clj-eval/src/stdlib_natives.rs new file mode 100644 index 0000000000..a95f277a44 --- /dev/null +++ b/rust/crates/clj-eval/src/stdlib_natives.rs @@ -0,0 +1,176 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! Native fast paths for the embedded `clojure.string` fns nearly +//! every script calls (#226): `split`, `join`, `trim`, `blank?`, +//! `split-lines`. Each answers its common arguments (strings, a +//! regex) exactly as the upstream definition would through the +//! port's interop — the same `Pattern.split`, the same Java +//! whitespace predicate — and hands anything else (nil, other +//! `CharSequence`s, odd arities) to the interpreted upstream +//! definition, so errors and edge cases stay the file's own. The +//! upstream `string.clj` loads unchanged: its forms remain the +//! vars' metadata source and the slow path (`core_lazy`). + +use std::sync::Arc; + +use clj_value::{Symbol, ThrowClass, Value}; + +use crate::error::NativeError; +use crate::eval::Evaluator; +use crate::lazy::{StepUnit, is_seqable, seq_value, step_unit}; +use crate::ns::signal_to_native; + +type Native = fn(&mut Evaluator, &[Value]) -> Result; + +/// The native-backed `clojure.string` fns, by name. +pub(crate) const STRING_NATIVES: &[(&str, Native)] = &[ + ("split", native_split), + ("split-lines", native_split_lines), + ("join", native_join), + ("trim", native_trim), + ("blank?", native_blank), +]; + +/// Call the interpreted upstream definition of `clojure.string/name` +/// (the slow path for arguments outside a fast path). The var is +/// found live or in a `remove-ns` tombstone, so a captured native +/// keeps working like the JVM's self-contained fn; only an +/// `ns-unmap`ped name has no definition left to run. +fn upstream(eval: &mut Evaluator, name: &str, args: &[Value]) -> Result { + let sym = Symbol::new(Some("clojure.string"), name); + let f = eval + .registry + .lookup_in_live_or_removed("clojure.string", &sym) + .and_then(|id| crate::core_lazy::interpreted(eval, id)); + match f { + Some(f) => eval.call(&f, args).map_err(signal_to_native), + None => Err(NativeError::new( + clj_value::ThrowClass::IllegalStateException, + format!("No definition behind clojure.string/{name}"), + )), + } +} + +/// `(split s re)` / `(split s re limit)`: `Pattern.split`, with +/// the compiled checks of `string.clj` in bytecode order — the +/// `^Pattern` and `^CharSequence` casts, the `limit` `Number` cast +/// plus `intCast`, then the null-target NPEs. +fn native_split(eval: &mut Evaluator, args: &[Value]) -> Result { + let (s, re, limit) = match args { + [s, re] => (s, re, None), + [s, re, limit] => (s, re, Some(limit)), + _ => return upstream(eval, "split", args), + }; + if !matches!(re, Value::Regex(_) | Value::Nil) { + return Err(crate::core::cce_err(re, "java.util.regex.Pattern")); + } + let text = match s { + Value::Nil => None, + other => Some( + crate::classemu::char_sequence_text(other) + .ok_or_else(|| crate::core::cce_err(other, "java.lang.CharSequence"))?, + ), + }; + let n = limit.map(crate::core::intcast).transpose()?; + let Value::Regex(re) = re else { + let params = if n.is_some() { + "java.lang.CharSequence, int" + } else { + "java.lang.CharSequence" + }; + return Err(NativeError::new( + ThrowClass::NullPointerException, + format!( + "Cannot invoke \"java.util.regex.Pattern.split({params})\" because \"re\" is null" + ), + )); + }; + let Some(text) = text else { + return Err(NativeError::new( + ThrowClass::NullPointerException, + "Cannot invoke \"java.lang.CharSequence.length()\" because \"this.text\" is null" + .to_owned(), + )); + }; + crate::re::pattern_split(&text, re, n.unwrap_or(0)) +} + +/// `(split-lines s)`: `(split s #"\r?\n")`. +fn native_split_lines(eval: &mut Evaluator, args: &[Value]) -> Result { + match args { + [Value::Str(s)] => crate::re::pattern_split(s, "\r?\n", 0), + _ => upstream(eval, "split-lines", args), + } +} + +/// `(trim s)`: Java whitespace off both ends (whitespace is BMP-only +/// and surrogates never match, so trimming code points equals the +/// upstream `charAt` scan). +fn native_trim(eval: &mut Evaluator, args: &[Value]) -> Result { + match args { + [Value::Str(s)] => Ok(Value::Str(Arc::from( + s.trim_matches(crate::classemu::java_is_whitespace), + ))), + _ => upstream(eval, "trim", args), + } +} + +/// `(blank? s)`: true for nil/false or all-Java-whitespace strings. +fn native_blank(eval: &mut Evaluator, args: &[Value]) -> Result { + match args { + [Value::Nil | Value::Bool(false)] => Ok(Value::Bool(true)), + [Value::Str(s)] => Ok(Value::Bool( + s.chars().all(crate::classemu::java_is_whitespace), + )), + _ => upstream(eval, "blank?", args), + } +} + +/// `(join sep coll)`: each item's `str` between `(str sep)`, walking +/// `coll` step by step like the upstream `first`/`next` loop (each +/// step realizes before the items it yields are stringified). A +/// separator whose `str` could run user code, a 1-arity call, or an +/// unseqable coll takes the upstream path. +fn native_join(eval: &mut Evaluator, args: &[Value]) -> Result { + let [sep, coll] = args else { + return upstream(eval, "join", args); + }; + let sep = match sep { + Value::Str(s) => s.to_string(), + Value::Char(c) => c.to_string(), + Value::Nil => String::new(), + _ => return upstream(eval, "join", args), + }; + if !is_seqable(coll) { + return upstream(eval, "join", args); + } + let mut out = String::new(); + let mut first = true; + let mut cur = seq_value(eval, coll).map_err(signal_to_native)?; + loop { + let items: Vec = match step_unit(eval, &cur).map_err(signal_to_native)? { + StepUnit::Empty => break, + StepUnit::One { head, tail } => { + cur = tail; + vec![head] + } + StepUnit::Chunk { items, tail } => { + cur = tail; + items.to_vec() + } + }; + for item in &items { + if !first { + out.push_str(&sep); + } + first = false; + out.push_str(&crate::core::str_of_one(eval, item)?); + } + } + Ok(Value::Str(Arc::from(out))) +} diff --git a/rust/crates/clj-eval/src/types.rs b/rust/crates/clj-eval/src/types.rs index e83a04f1ff..fc8f6ff039 100644 --- a/rust/crates/clj-eval/src/types.rs +++ b/rust/crates/clj-eval/src/types.rs @@ -54,7 +54,7 @@ pub const TYPES_NATIVES: &[(&str, NativeFnPtr)] = &[ fn var_root(eval: &Evaluator, sym: &Symbol) -> Option { let cur = eval.current_ns_name(); let (_, id) = eval.registry.resolve_target_and_id(&cur, sym)?; - Some(eval.deref_var(id, sym)) + Some(eval.deref_var_unforced(id, sym)) } /// The `Unable to resolve symbol` runtime error (the analyzer's @@ -115,7 +115,7 @@ fn lookup_root(eval: &Evaluator, sym: &Symbol) -> Option { .registry .resolve_in(ns_part, &simple) .and_then(|q| eval.registry.lookup(&q).map(|id| (id, q))) - .map(|(id, q)| eval.deref_var(id, &q)); + .map(|(id, q)| eval.deref_var_unforced(id, &q)); } if let Some(root) = var_root(eval, sym) { return Some(root); diff --git a/rust/crates/clj-eval/src/vars.rs b/rust/crates/clj-eval/src/vars.rs index 52135c92b9..df83effeeb 100644 --- a/rust/crates/clj-eval/src/vars.rs +++ b/rust/crates/clj-eval/src/vars.rs @@ -69,6 +69,7 @@ fn is_bound(eval: &Evaluator, id: u64) -> bool { fn native_alter_var_root(eval: &mut Evaluator, args: &[Value]) -> Result { check_min_arity("clojure.core/alter-var-root", args, 2)?; let var = as_var(&args[0])?.clone(); + eval.force_lazy(var.id); // Per-var reentrant exclusion across read-f-install (T1192 // FX1), like the JVM's var monitor: concurrent alters // serialize (no lost update), same-thread reentry nests. @@ -92,6 +93,7 @@ fn native_bound(eval: &mut Evaluator, args: &[Value]) -> Result Result Result { check_arity("clojure.core/var-get", args, &[1])?; let var = as_var(&args[0])?; + eval.force_lazy(var.id); Ok(eval.deref_var(var.id, &var.sym)) } @@ -261,6 +264,7 @@ fn extension_meta(eval: &Evaluator, v: &Value) -> Option { fn native_meta(eval: &mut Evaluator, args: &[Value]) -> Result { check_arity("clojure.core/meta", args, &[1])?; + force_var_arg(eval, &args[0]); Ok(meta_of(eval, &args[0]).map_or(Value::Nil, Value::Map)) } @@ -502,6 +506,7 @@ fn push_checked_entry( fn native_alter_meta(eval: &mut Evaluator, args: &[Value]) -> Result { check_min_arity("clojure.core/alter-meta!", args, 2)?; + force_var_arg(eval, &args[0]); let target = as_ref_target(&args[0])?; let current = ref_meta(eval, &target).map_or(Value::Nil, Value::Map); let mut f_args = Vec::with_capacity(args.len() - 1); @@ -516,12 +521,22 @@ fn native_alter_meta(eval: &mut Evaluator, args: &[Value]) -> Result Result { check_arity("clojure.core/reset-meta!", args, &[2])?; + force_var_arg(eval, &args[0]); let target = as_ref_target(&args[0])?; let meta = as_meta_value(&args[1])?; install_ref_meta(eval, &target, meta.clone()); Ok(meta.map_or(Value::Nil, Value::Map)) } +/// Force a lazy `core.clj` var argument before a native reads or +/// replaces its metadata (see `core_lazy`). +fn force_var_arg(eval: &mut Evaluator, v: &Value) { + if let Value::Var(var) = v { + eval.force_lazy(var.id); + crate::core_lazy::force_meta(eval, var.id); + } +} + /// The S5B natives installed into `clojure.core` by [`boot`](crate::ns::boot). pub(crate) const VAR_NATIVES: &[(&str, NativeFnPtr)] = &[ ("alter-var-root", native_alter_var_root), diff --git a/rust/crates/clj-eval/src/vm.rs b/rust/crates/clj-eval/src/vm.rs new file mode 100644 index 0000000000..ec54b297ea --- /dev/null +++ b/rust/crates/clj-eval/src/vm.rs @@ -0,0 +1,1670 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! The register VM (#226): analyzed [`Expr`] trees compile to nested +//! closures ([`Exec`]) over flat per-call register frames. +//! +//! The tree evaluator keeps every local in a linked chain of shared +//! frames (one allocation per `let`/call, a chain walk per read) so +//! closures can capture the chain. Here each fn arity runs over one +//! flat register array sized at compile time: params, `let` and +//! `loop` locals are register writes, `recur` rewrites the loop's +//! registers in place, and closures capture the values they use +//! (Clojure locals are immutable, so a value snapshot is the same +//! observable state the shared frame gave). An analyzed local is an +//! absolute lexical index; the compiler maps each to a register, a +//! captured slot ([`Loc::Up`]), or a `letfn` sibling ([`Loc::Rec`]). +//! +//! Forms the VM does not compile run on the tree evaluator +//! ([`escape`]): the visible locals are copied into a fresh [`Env`] +//! at their lexical indexes, so the tree evaluator sees exactly the +//! frame it would have built. A `recur` from such a subtree arrives +//! as the ordinary `Signal::Recur(values)`; the VM's own `recur` +//! writes the target registers first and signals with an empty +//! vector (no allocation). + +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; +use std::sync::{Arc, OnceLock}; + +use clj_value::{Map, Symbol, ThrowClass, Throwable, Value, Var, Vector}; +use smallvec::SmallVec; + +use crate::env::Env; +use crate::error::Signal; +use crate::eval::Evaluator; +use crate::expr::{Arity, CaseTest, Catch, DirectCall, Expr, FunExpr, Pattern, pattern_count}; +use crate::ext::Closure; +use clj_reader::Span; + +/// Compiled code producing a `T` against the running frame. +pub(crate) type Code = + Box Fn(&mut Evaluator, &mut Frame<'f>) -> Result + Send + Sync>; + +/// One compiled node: evaluate against the running frame. +pub(crate) type Exec = Code; + +/// A compiled branch test: a form's truthiness (comparisons and +/// boolean intrinsics answer without a boolean `Value`). +pub(crate) type Test = Code; + +/// A running frame: this call's registers, the closure's captured +/// values, and its `letfn` group (empty when none). +pub(crate) struct Frame<'f> { + pub(crate) regs: &'f mut [Value], + pub(crate) up: &'f [Value], + pub(crate) rec: &'f [Value], +} + +impl Frame<'_> { + pub(crate) fn get(&self, loc: Loc) -> &Value { + match loc { + Loc::Reg(r) => &self.regs[r], + Loc::Up(u) => &self.up[u], + Loc::Rec(j) => &self.rec[j], + } + } +} + +/// Where a lexical local lives at run time. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Loc { + Reg(usize), + Up(usize), + Rec(usize), +} + +/// A compiled fn: the closure's code plus the analyzed source (the +/// `Closure::arities` every closure over it shares). +pub(crate) struct CompiledFun { + pub(crate) display: Arc, + pub(crate) source: Arc<[Arity]>, + arities: Vec, +} + +impl std::fmt::Debug for CompiledFun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompiledFun") + .field("display", &self.display) + .finish_non_exhaustive() + } +} + +/// One compiled arity: params arrive in registers `0..fixed` (the +/// rest seq at `fixed` when variadic); `bind` destructures them into +/// their names' registers before every body run (initial and after +/// each `recur`). +pub(crate) struct CompiledArity { + fixed: usize, + variadic: bool, + nregs: usize, + bind: Option, + body: Exec, +} + +/// A VM closure's run-time half: the code, the captured values, and +/// the `letfn` group (set once every sibling exists). +#[derive(Clone)] +pub(crate) struct VmCode { + pub(crate) fun: Arc, + pub(crate) captured: Arc<[Value]>, + pub(crate) rec: Option>>>, +} + +impl std::fmt::Debug for VmCode { + /// The group is cyclic (a named fn is its own sibling), so only + /// its size shows. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VmCode") + .field("fun", &self.fun) + .field("captured", &self.captured) + .field( + "rec", + &self.rec.as_ref().and_then(|g| g.get()).map(|g| g.len()), + ) + .finish() + } +} + +/// A compiled top-level form and its register count. +pub(crate) struct TopCode { + nregs: usize, + exec: Exec, +} + +/// Compile one analyzed top-level form. +pub(crate) fn compile_top(expr: &Expr) -> TopCode { + let mut c = Compiler { + scopes: vec![Scope::new(0, Vec::new())], + }; + c.scopes[0].last = crate::vm_live::last_uses(std::slice::from_ref(expr), 0, 0); + let exec = c.compile(expr); + TopCode { + nregs: c.scopes[0].nregs, + exec, + } +} + +/// Run a compiled top-level form (no captured values, no group). +pub(crate) fn run_top(eval: &mut Evaluator, code: &TopCode) -> Result { + let mut regs: SmallVec<[Value; 8]> = SmallVec::new(); + regs.resize(code.nregs, Value::Nil); + let mut frame = Frame { + regs: &mut regs, + up: &[], + rec: &[], + }; + (code.exec)(eval, &mut frame) +} + +/// The arity a call with `argc` args runs (an exact fixed arity +/// first, else the variadic one), like the tree evaluator. +fn select(code: &VmCode, argc: usize) -> Option<&CompiledArity> { + let mut variadic: Option<&CompiledArity> = None; + for arity in &code.fun.arities { + if !arity.variadic && arity.fixed == argc { + return Some(arity); + } + if arity.variadic { + variadic = Some(arity); + } + } + variadic.filter(|a| argc >= a.fixed) +} + +fn arity_error(eval: &mut Evaluator, closure: &Closure, argc: usize) -> Signal { + eval.throw(crate::error::NativeError::arity(&closure.display, argc)) +} + +/// Apply a VM closure to borrowed args (natives calling back). +pub(crate) fn apply( + eval: &mut Evaluator, + closure: &Closure, + code: &VmCode, + args: &[Value], +) -> Result { + let Some(arity) = select(code, args.len()) else { + return Err(arity_error(eval, closure, args.len())); + }; + let mut regs: SmallVec<[Value; 8]> = SmallVec::new(); + regs.extend(args[..arity.fixed].iter().cloned()); + if arity.variadic { + regs.push(crate::eval::rest_list(&args[arity.fixed..])); + } + run_arity(eval, code, arity, &mut regs) +} + +/// Apply a VM closure to owned args, moving them into registers +/// (the VM's own calls; depth-guarded like `apply_closure`). +pub(crate) fn apply_moved( + eval: &mut Evaluator, + closure: &Closure, + code: &VmCode, + args: &mut SmallVec<[Value; 4]>, +) -> Result { + let Some(arity) = select(code, args.len()) else { + return Err(arity_error(eval, closure, args.len())); + }; + let mut regs: SmallVec<[Value; 8]> = SmallVec::new(); + reserve_regs(&mut regs, arity.nregs); + if arity.variadic { + let rest: Vec = args.drain(arity.fixed..).collect(); + move_into(&mut regs, args); + regs.push(if rest.is_empty() { + Value::Nil + } else { + Value::List(clj_value::List::from_vec(rest)) + }); + } else { + move_into(&mut regs, args); + } + eval.guarded_call(|ev| run_arity(ev, code, arity, &mut regs)) +} + +/// Make room for `nregs` in a fresh register file. Callers build +/// the file in place (`SmallVec::new()` then this): returning one +/// from a helper — or `with_capacity` — moves the inline buffer by +/// value, a 528-byte copy per call. +#[inline(always)] +fn reserve_regs(regs: &mut SmallVec<[Value; 8]>, nregs: usize) { + if nregs > regs.inline_size() { + regs.reserve_exact(nregs); + } +} + +/// Move every arg into the next register (the buffer keeps `nil`s). +#[inline(always)] +fn move_into(regs: &mut SmallVec<[Value; 8]>, args: &mut SmallVec<[Value; 4]>) { + for v in args.iter_mut() { + regs.push(std::mem::replace(v, Value::Nil)); + } +} + +/// Call a fixed arity over borrowed args (natives calling back: +/// each arg clones straight into its register). +pub(crate) fn call_borrowed( + ev: &mut Evaluator, + code: &VmCode, + arity: &CompiledArity, + args: &[Value], +) -> Result { + let mut regs: SmallVec<[Value; 8]> = SmallVec::new(); + reserve_regs(&mut regs, arity.nregs); + for a in args { + regs.push(clone_value(a)); + } + ev.guarded_call(|ev| run_arity(ev, code, arity, &mut regs)) +} + +/// A callee resolved once, for natives that call the same fn per +/// item (`reduce`, `mapv`, …): a VM closure's fixed arity takes its +/// args straight into registers; a native runs its entry directly +/// (an `Int` pair through a tagged arith native computes inline). +pub(crate) enum Prepared<'c> { + Vm { + code: &'c VmCode, + arity: &'c CompiledArity, + }, + Native(&'c crate::ext::NativeFn), +} + +impl<'c> Prepared<'c> { + /// `callee` prepared for `argc` args: a VM closure without + /// conveyed bindings with a fixed arity for the count, or a + /// native (`None`: call generally). + pub(crate) fn of(callee: &'c Value, argc: usize) -> Option { + if let Some((code, arity)) = fixed_target(callee, argc) { + return Some(Prepared::Vm { code, arity }); + } + crate::ext::as_native(callee).map(Prepared::Native) + } + + /// Call with one arg. + pub(crate) fn call1(&self, ev: &mut Evaluator, a: Value) -> Result { + match self { + Prepared::Vm { code, arity } => { + let mut regs: SmallVec<[Value; 8]> = SmallVec::new(); + reserve_regs(&mut regs, arity.nregs); + regs.push(a); + ev.guarded_call(|ev| run_arity(ev, code, arity, &mut regs)) + } + Prepared::Native(n) => call_native(ev, n, smallvec::smallvec![a]), + } + } + + /// Call with two args. + pub(crate) fn call2(&self, ev: &mut Evaluator, a: Value, b: Value) -> Result { + match self { + Prepared::Vm { code, arity } => { + let mut regs: SmallVec<[Value; 8]> = SmallVec::new(); + reserve_regs(&mut regs, arity.nregs); + regs.push(a); + regs.push(b); + ev.guarded_call(|ev| run_arity(ev, code, arity, &mut regs)) + } + Prepared::Native(n) => { + if let (Value::Int(x), Value::Int(y)) = (&a, &b) { + if let Some(v) = Evaluator::fast_int2(n.fast, *x, *y) { + return Ok(v); + } + } + call_native(ev, n, smallvec::smallvec![a, b]) + } + } + } +} + +/// A native over owned args: the owned entry takes the buffer, the +/// borrow entry borrows it (like `invoke_args`; the native call +/// span is the caller's already). +fn call_native( + ev: &mut Evaluator, + n: &crate::ext::NativeFn, + args: SmallVec<[Value; 4]>, +) -> Result { + match &n.owned { + Some(owned) => owned(ev, args).map_err(|e| ev.throw(e)), + None => (n.func)(ev, &args).map_err(|e| ev.throw(e)), + } +} + +/// Run an arity over its loaded params (`regs` grows to the +/// arity's register count), looping on `recur`. +fn run_arity( + eval: &mut Evaluator, + code: &VmCode, + arity: &CompiledArity, + regs: &mut SmallVec<[Value; 8]>, +) -> Result { + // A push loop, not `resize` (an out-of-line call per run). + while regs.len() < arity.nregs { + regs.push(Value::Nil); + } + let rec = code + .rec + .as_ref() + .and_then(|g| g.get()) + .map_or(&[][..], |g| &g[..]); + let mut frame = Frame { + regs, + up: &code.captured, + rec, + }; + let params = arity.fixed + usize::from(arity.variadic); + loop { + if let Some(bind) = &arity.bind { + bind(eval, &mut frame)?; + } + match (arity.body)(eval, &mut frame) { + // The VM's own `recur` wrote the params already (empty + // values); a tree-evaluated one carries them. + Err(Signal::Recur(values)) if values.is_empty() => {} + Err(Signal::Recur(values)) => { + (0..params).zip(values).for_each(|(r, v)| frame.regs[r] = v); + } + other => return other, + } + } +} + +/// A VM closure's fixed arity for `argc` args (the one [`select`] +/// picks first), when the callee is a VM closure without conveyed +/// bindings. +#[inline] +pub(crate) fn fixed_target(callee: &Value, argc: usize) -> Option<(&VmCode, &CompiledArity)> { + let closure = crate::ext::as_closure(callee)?; + let code = closure.vm.as_ref().filter(|_| closure.conveyed.is_none())?; + let arity = code + .fun + .arities + .iter() + .find(|a| !a.variadic && a.fixed == argc)?; + Some((code, arity)) +} + +/// Call a fixed arity: each arg evaluates straight into the +/// callee's next register (depth-guarded like `apply_moved`). +fn call_fixed( + ev: &mut Evaluator, + f: &mut Frame<'_>, + code: &VmCode, + arity: &CompiledArity, + args: &[Operand], +) -> Result { + let mut regs: SmallVec<[Value; 8]> = SmallVec::new(); + reserve_regs(&mut regs, arity.nregs); + for a in args { + regs.push(a.eval(ev, f)?); + } + ev.guarded_call(|ev| run_arity(ev, code, arity, &mut regs)) +} + +/// Invoke an evaluated callee with owned args: VM closures (without +/// conveyed bindings) take them by move, everything else goes the +/// general road. +fn call_value( + ev: &mut Evaluator, + callee: &Value, + args: &mut SmallVec<[Value; 4]>, + span: Span, +) -> Result { + if let Some(closure) = crate::ext::as_closure(callee) { + if let (Some(code), None) = (&closure.vm, &closure.conveyed) { + return apply_moved(ev, closure, code, args); + } + } + ev.invoke_args(callee, std::mem::take(args), span) +} + +/// A fresh VM closure value over `fun`. +fn make_closure( + eval: &mut Evaluator, + fun: &Arc, + captured: Arc<[Value]>, + rec: Option>>>, +) -> Value { + closure_value(eval.next_id(), fun, captured, rec) +} + +/// A VM closure value with identity `id` (`lazy-seq` bodies take 0: +/// they never reach user code, so they skip the shared counter). +fn closure_value( + id: u64, + fun: &Arc, + captured: Arc<[Value]>, + rec: Option>>>, +) -> Value { + Value::Extension(Arc::new(Closure { + id, + display: Arc::clone(&fun.display), + arities: Arc::clone(&fun.source), + env: Env::new(), + meta: None, + conveyed: None, + vm: Some(VmCode { + fun: Arc::clone(fun), + captured, + rec, + }), + })) +} + +/// The capture list's current values. +fn capture(frame: &Frame<'_>, locs: &[Loc]) -> Arc<[Value]> { + locs.iter().map(|l| frame.get(*l).clone()).collect() +} + +/// One fn's compile scope. Lexical indexes below `base` are outer +/// locals (the `letfn` group's names, else captures); `locals` maps +/// the rest (minus `base`) to registers. +struct Scope { + base: usize, + locals: Vec, + next: usize, + nregs: usize, + captures: Vec<(usize, Loc)>, + rec: Vec, + /// The innermost `loop`/arity's raw registers (`recur` targets). + recur: Arc<[usize]>, + /// The body's last-use reads (see `vm_live`): those move their + /// register's value out instead of cloning it. + last: crate::vm_live::LastUses, +} + +impl Scope { + fn new(base: usize, rec: Vec) -> Self { + Self { + base, + locals: Vec::new(), + next: 0, + nregs: 0, + captures: Vec::new(), + rec, + recur: Arc::from([]), + last: crate::vm_live::LastUses::new(), + } + } +} + +/// A scope position to restore after a binding form. +#[derive(Clone, Copy)] +struct Mark { + locals: usize, + next: usize, +} + +struct Compiler { + scopes: Vec, +} + +impl Compiler { + fn scope(&mut self) -> &mut Scope { + self.scopes.last_mut().expect("a scope is always open") + } + + /// The lexical locals visible here (outer ones included). + fn visible(&self) -> usize { + let s = self.scopes.last().expect("a scope is always open"); + s.base + s.locals.len() + } + + fn alloc(&mut self) -> usize { + let s = self.scope(); + let r = s.next; + s.next += 1; + s.nregs = s.nregs.max(s.next); + r + } + + /// Allocate a register for the next lexical local. + fn push_local(&mut self) -> usize { + let r = self.alloc(); + self.scope().locals.push(r); + r + } + + fn mark(&mut self) -> Mark { + let s = self.scope(); + Mark { + locals: s.locals.len(), + next: s.next, + } + } + + fn reset(&mut self, mark: Mark) { + let s = self.scope(); + s.locals.truncate(mark.locals); + s.next = mark.next; + } + + /// Whether `expr` (a local read) is its register's last use. + fn is_last(&self, expr: &Expr) -> bool { + let s = self.scopes.last().expect("a scope is always open"); + s.last.contains(&(expr as *const Expr)) + } + + fn resolve(&mut self, old: usize) -> Loc { + self.resolve_at(self.scopes.len() - 1, old) + } + + /// A lexical index's location in scope `depth`, capturing it + /// through every fn between its home and here. + fn resolve_at(&mut self, depth: usize, old: usize) -> Loc { + let s = &self.scopes[depth]; + if old >= s.base { + return Loc::Reg(s.locals[old - s.base]); + } + if let Some(j) = s.rec.iter().position(|i| *i == old) { + return Loc::Rec(j); + } + if let Some(u) = s.captures.iter().position(|(i, _)| *i == old) { + return Loc::Up(u); + } + let parent = self.resolve_at(depth - 1, old); + let s = &mut self.scopes[depth]; + s.captures.push((old, parent)); + Loc::Up(s.captures.len() - 1) + } + + fn compile(&mut self, expr: &Expr) -> Exec { + match expr { + Expr::Literal(v) | Expr::Quote(v) => konst(v.clone()), + Expr::VarRef(var) => konst(Value::Var(var.clone())), + Expr::Local(idx) => match (self.resolve(*idx), self.is_last(expr)) { + (Loc::Reg(r), true) => take_local(r), + (loc, _) => local(loc), + }, + Expr::Global(var) => global(var.clone()), + Expr::If { test, then, els } => self.compile_if(test, then, els), + Expr::Do(body) => self.compile_body(body), + Expr::Invoke { head, args, span } => self.compile_invoke(head, args, *span), + Expr::DirectNative(call) => self.compile_direct(call), + _ => self.compile_binding_form(expr), + } + } + + /// The binding and closure forms (split from [`compile`] for the + /// complexity budget). + fn compile_binding_form(&mut self, expr: &Expr) -> Exec { + match expr { + Expr::Let { bindings, body } => self.compile_let(bindings, body), + Expr::Loop { bindings, body } => self.compile_loop(bindings, body), + Expr::Recur(args) => self.compile_recur(args), + Expr::Fn(fun) => self.compile_fn(fun), + Expr::LetFn { fns, body } => self.compile_letfn(fns, body), + Expr::LazySeqStar { body } => self.compile_lazy_seq(body), + Expr::DelayStar { display, body } => self.compile_delay(display, body), + Expr::Try { + body, + catches, + finally_body, + } => self.compile_try(body, catches, finally_body), + _ => self.compile_data_form(expr), + } + } + + /// Literals, `throw`, `def` (split from [`compile`] for the + /// complexity budget). + fn compile_data_form(&mut self, expr: &Expr) -> Exec { + match expr { + Expr::VecLit(elems, meta) => self.compile_vec(elems, meta.clone()), + Expr::MapLit(pairs, meta) => self.compile_map(pairs, meta.clone()), + Expr::SetLit(elems, meta) => self.compile_set(elems, meta.clone()), + Expr::Throw(inner) => { + let inner = self.compile(inner); + Box::new(move |ev, f| { + let v = inner(ev, f)?; + Err(ev.throw_value(v)) + }) + } + Expr::Def { + sym, + doc, + init, + meta, + span, + } => self.compile_def(sym, doc.clone(), init.as_deref(), meta.as_deref(), *span), + Expr::CaseStar { + expr, + shift, + mask, + default, + cases, + skip, + test, + } => { + let shape = CaseShape { + shift: *shift, + mask: *mask, + skip: skip.clone(), + test: *test, + }; + self.compile_case(expr, default, cases, shape) + } + Expr::Assert { + test, + message, + form, + } => self.compile_assert(test, message.as_deref(), form), + Expr::NewThrowable { class, message } => { + let (class, message) = (*class, Arc::::from(message.as_str())); + Box::new(move |ev, _| Ok(new_throwable(ev, class, &message))) + } + _ => self.escape(expr), + } + } + + /// Run `expr` on the tree evaluator over a fresh frame holding + /// every visible local at its lexical index. + fn escape(&mut self, expr: &Expr) -> Exec { + let locs = self.visible_locs(); + let expr = expr.clone(); + Box::new(move |ev, f| { + let env = frame_env(f, &locs); + ev.eval(&expr, &env) + }) + } + + /// Every visible local's location, in lexical order (what a + /// tree-evaluator frame at this point holds). + fn visible_locs(&mut self) -> Vec { + (0..self.visible()).map(|i| self.resolve(i)).collect() + } + + fn compile_all(&mut self, exprs: &[Expr]) -> Vec { + exprs.iter().map(|e| self.compile(e)).collect() + } + + fn compile_body(&mut self, body: &[Expr]) -> Exec { + let mut execs = self.compile_all(body); + match execs.len() { + 0 => konst(Value::Nil), + 1 => execs.pop().expect("one form"), + _ => { + let last = execs.pop().expect("a last form"); + Box::new(move |ev, f| { + for e in &execs { + e(ev, f)?; + } + last(ev, f) + }) + } + } + } + + fn compile_if(&mut self, test: &Expr, then: &Expr, els: &Expr) -> Exec { + let test = self.compile_test(test); + let then = self.compile(then); + let els = self.compile(els); + Box::new(move |ev, f| { + if test(ev, f)? { + then(ev, f) + } else { + els(ev, f) + } + }) + } + + /// A branch test: comparisons and predicates compute the truth + /// value directly. + fn compile_test(&mut self, expr: &Expr) -> Test { + if let Some(test) = self.short_circuit_test(expr) { + return test; + } + if let Expr::DirectNative(call) = expr { + if let Some(ctor) = crate::vm_ops::test_ctor(call) { + let ops = self.in_place_operands(call); + return ctor.code(site_of(call), ops); + } + if let (None, Some(op)) = ( + crate::vm_ops::value_ctor(call), + crate::vm_intrinsics::intrinsic_of(call), + ) { + return self.intrinsic_test(call, op); + } + } + let e = self.compile(expr); + Box::new(move |ev, f| Ok(e(ev, f)?.is_truthy())) + } + + /// An `and`/`or` step in test position (#226): `(let* [g a] (if + /// g b g))` is `a`'s truth and then `b`'s, `(let* [g a] (if g g + /// b))` `a`'s or else `b`'s — when `b` never reads `g`, `g`'s + /// value is never needed, so neither step builds one. + fn short_circuit_test(&mut self, expr: &Expr) -> Option { + let g = self.visible(); + let (a, b, and) = and_or_shape(expr, g)?; + let base = self.scopes.last().expect("a scope is always open").base; + if crate::vm_live::may_read(b, base, g + 1, g) { + return None; + } + let ta = self.compile_test(a); + let mark = self.mark(); + // `g`'s lexical slot (its register is never written: no read). + self.push_local(); + let tb = self.compile_test(b); + self.reset(mark); + Some(if and { + Box::new(move |ev, f| Ok(ta(ev, f)? && tb(ev, f)?)) + } else { + Box::new(move |ev, f| Ok(ta(ev, f)? || tb(ev, f)?)) + }) + } + + /// A specialized or intrinsic call's operands: locals read in + /// place (never moved out). + fn in_place_operands(&mut self, call: &DirectCall) -> Vec { + call.args.iter().map(|a| self.operand(a, false)).collect() + } + + fn intrinsic_test(&mut self, call: &DirectCall, fast: crate::vm_intrinsics::Fast) -> Test { + let ops = self.in_place_operands(call); + intrinsic_test_exec(site_of(call), ops, fast) + } + + /// `let`: a symbol's init writes its register directly (no + /// step closure); destructuring binds through its step. + fn compile_let(&mut self, bindings: &[(Pattern, Expr)], body: &[Expr]) -> Exec { + let mark = self.mark(); + let mut steps: Vec = Vec::with_capacity(bindings.len()); + for (pat, init) in bindings { + let init = self.compile(init); + steps.push(if matches!(pat, Pattern::Symbol(_)) { + LetStep::Name(self.push_local(), init) + } else { + LetStep::Unpack(self.bind_step(pat, init)) + }); + } + let body = self.compile_body(body); + self.reset(mark); + Box::new(move |ev, f| { + for step in &steps { + match step { + LetStep::Name(r, init) => { + let v = init(ev, f)?; + set_reg(&mut f.regs[*r], v); + } + LetStep::Unpack(step) => { + step(ev, f)?; + } + } + } + body(ev, f) + }) + } + + /// Bind one evaluated init to a destructuring pattern's registers + /// (allocated in [`crate::expr::pattern_names`] order, like the + /// analyzer's locals). + fn bind_step(&mut self, pat: &Pattern, init: Exec) -> Exec { + let raw = self.alloc(); + let step = self.destructure_step(pat, raw); + Box::new(move |ev, f| { + let v = init(ev, f)?; + f.regs[raw] = v; + step(ev, f) + }) + } + + /// Destructure the value in register `raw` into the pattern's + /// names (registers allocated here, in [`crate::expr::pattern_names`] order). + /// `:or` defaults evaluate on the tree evaluator against the + /// frame visible before the names, like `bind_pattern`. + fn destructure_step(&mut self, pat: &Pattern, raw: usize) -> Exec { + let env_locs = crate::vm_live::has_defaults(pat).then(|| self.visible_locs()); + let regs: Vec = (0..pattern_count(pat)).map(|_| self.push_local()).collect(); + let pat = pat.clone(); + Box::new(move |ev, f| { + let v = f.regs[raw].clone(); + let env = env_locs + .as_ref() + .map_or_else(Env::new, |locs| frame_env(f, locs)); + let mut out = Vec::with_capacity(regs.len()); + ev.destructure_in(&pat, &v, &env, &mut out)?; + for (r, v) in regs.iter().zip(out) { + f.regs[*r] = v; + } + Ok(Value::Nil) + }) + } + + /// `loop`: each binding's raw value takes a register (a symbol's + /// is its own); destructured bindings unpack after every write, + /// initial and `recur`, in order. + fn compile_loop(&mut self, bindings: &[(Pattern, Expr)], body: &[Expr]) -> Exec { + let mark = self.mark(); + let mut steps: Vec = Vec::with_capacity(bindings.len()); + for (pat, init) in bindings { + let init = self.compile(init); + steps.push(if matches!(pat, Pattern::Symbol(_)) { + (self.push_local(), init, None) + } else { + let raw = self.alloc(); + (raw, init, Some(self.destructure_step(pat, raw))) + }); + } + let targets: Arc<[usize]> = steps.iter().map(|(r, ..)| *r).collect(); + let saved = std::mem::replace(&mut self.scope().recur, Arc::clone(&targets)); + let body = self.compile_body(body); + self.scope().recur = saved; + self.reset(mark); + let unpacks = steps.iter().any(|(_, _, u)| u.is_some()); + let code = LoopCode { + steps, + targets, + body, + unpacks, + }; + Box::new(move |ev, f| run_loop(ev, f, &code)) + } + + /// `recur`: every arg evaluates (in order), then the target + /// registers take them; the empty signal says they are written. + fn compile_recur(&mut self, args: &[Expr]) -> Exec { + let targets = Arc::clone(&self.scope().recur); + let ops: Vec = args.iter().map(|a| self.operand(a, true)).collect(); + match (&targets[..], ops.len()) { + (&[r], 1) => recur1(r, ops), + (&[r0, r1], 2) => recur2([r0, r1], ops), + (&[r0, r1, r2], 3) => recur3([r0, r1, r2], ops), + (&[r0, r1, r2, r3], 4) => recur4([r0, r1, r2, r3], ops), + _ => recur_n(targets, ops), + } + } + + /// Compile a fn's arities in a fresh scope whose params start at + /// lexical index `base`; the result's capture list reads the + /// enclosing scope. + fn compile_fun( + &mut self, + fun: &FunExpr, + base: usize, + rec: Vec, + ) -> (Arc, Vec) { + self.scopes.push(Scope::new(base, rec)); + let arities = fun.arities.iter().map(|a| self.compile_arity(a)).collect(); + let scope = self.scopes.pop().expect("the fn scope"); + let captures = scope.captures.iter().map(|(_, loc)| *loc).collect(); + let compiled = CompiledFun { + display: Arc::from(fun.display.as_str()), + source: Arc::clone(&fun.arities), + arities, + }; + (Arc::new(compiled), captures) + } + + fn compile_arity(&mut self, arity: &Arity) -> CompiledArity { + let pats: Vec<&Pattern> = arity.fixed.iter().chain(arity.rest.iter()).collect(); + let s = self.scope(); + s.locals.clear(); + s.next = pats.len(); + s.nregs = pats.len(); + s.recur = (0..pats.len()).collect(); + let mut binds: Vec = Vec::new(); + for (raw, pat) in pats.iter().enumerate() { + if matches!(pat, Pattern::Symbol(_)) { + self.scope().locals.push(raw); + } else { + binds.push(self.destructure_step(pat, raw)); + } + } + let (base, visible) = { + let s = self.scopes.last().expect("the fn scope"); + (s.base, s.base + s.locals.len()) + }; + self.scope().last = crate::vm_live::last_uses(&arity.body, base, visible); + let body = self.compile_body(&arity.body); + let bind: Option = match binds.len() { + 0 => None, + _ => Some(Box::new(move |ev, f| { + for b in &binds { + b(ev, f)?; + } + Ok(Value::Nil) + })), + }; + CompiledArity { + fixed: arity.fixed.len(), + variadic: arity.rest.is_some(), + nregs: self.scope().nregs, + bind, + body, + } + } + + fn compile_fn(&mut self, fun: &FunExpr) -> Exec { + let base = self.visible(); + let (compiled, captures) = self.compile_fun(fun, base, Vec::new()); + Box::new(move |ev, f| { + let captured = capture(f, &captures); + Ok(make_closure(ev, &compiled, captured, None)) + }) + } + + /// `letfn` (and named `fn`s, the analyzer's one-fn `letfn`): the + /// names take registers here; inside the fns they are the group + /// slots, filled once every sibling exists. + fn compile_letfn(&mut self, fns: &[(String, FunExpr)], body: &[Expr]) -> Exec { + let mark = self.mark(); + let visible = self.visible(); + let names: Vec = fns.iter().map(|_| self.push_local()).collect(); + let rec: Vec = (visible..visible + fns.len()).collect(); + let funs: Vec<(Arc, Vec)> = fns + .iter() + .map(|(_, fun)| self.compile_fun(fun, visible + fns.len(), rec.clone())) + .collect(); + let body = self.compile_body(body); + self.reset(mark); + Box::new(move |ev, f| { + let group = Arc::new(OnceLock::new()); + let mut made: Vec = Vec::with_capacity(funs.len()); + for (fun, captures) in &funs { + let captured = capture(f, captures); + made.push(make_closure(ev, fun, captured, Some(Arc::clone(&group)))); + } + for (r, v) in names.iter().zip(&made) { + f.regs[*r] = v.clone(); + } + let _ = group.set(made.into_boxed_slice()); + body(ev, f) + }) + } + + /// `lazy-seq*`: the body as a zero-arity closure (the JVM's + /// `(fn* [] body)`, a `recur` target), forced by the seq. + fn compile_lazy_seq(&mut self, body: &[Expr]) -> Exec { + let (compiled, captures) = self.compile_thunk("lazy-seq", body); + Box::new(move |_, f| { + let captured = capture(f, &captures); + let thunk = closure_value(0, &compiled, captured, None); + Ok(crate::lazy::call0_value(thunk)) + }) + } + + /// `delay*`: a zero-arity closure (named like the tree + /// evaluator's) trapped in an unrealized delay. + fn compile_delay(&mut self, display: &str, body: &[Expr]) -> Exec { + let (compiled, captures) = self.compile_thunk(display, body); + Box::new(move |ev, f| { + let captured = capture(f, &captures); + let thunk = make_closure(ev, &compiled, captured, None); + Ok(Value::Delay(clj_value::Delay::new(thunk))) + }) + } + + fn compile_thunk(&mut self, display: &str, body: &[Expr]) -> (Arc, Vec) { + let fun = FunExpr { + display: display.to_owned(), + arities: Arc::from([Arity { + fixed: Vec::new(), + rest: None, + body: body.to_vec(), + }]), + }; + let base = self.visible(); + self.compile_fun(&fun, base, Vec::new()) + } + + /// `try`: like the tree evaluator's — a panic unwinding through + /// the body still runs `finally`; the first matching `catch` + /// (by class hierarchy) binds the throwable in its register. + fn compile_try(&mut self, body: &[Expr], catches: &[Catch], finally_body: &[Expr]) -> Exec { + let body = self.compile_body(body); + let handlers: Vec<(String, usize, Exec)> = catches + .iter() + .map(|c| { + let mark = self.mark(); + let r = self.push_local(); + let handler = self.compile_body(&c.body); + self.reset(mark); + (c.class.clone(), r, handler) + }) + .collect(); + let fin = self.compile_body(finally_body); + let code = TryCode { + body, + handlers, + fin, + }; + Box::new(move |ev, f| run_try(ev, f, &code)) + } + + fn compile_case( + &mut self, + expr: &Expr, + default: &Expr, + cases: &[(i32, Expr, Expr)], + shape: CaseShape, + ) -> Exec { + let target = self.compile(expr); + let default = self.compile(default); + let cases: Vec<(i32, Exec, Exec)> = cases + .iter() + .map(|(k, t, then)| (*k, self.compile(t), self.compile(then))) + .collect(); + Box::new(move |ev, f| { + let v = target(ev, f)?; + let Some((probe, key)) = ev.case_probe(v, shape.shift, shape.mask, shape.test)? else { + return default(ev, f); + }; + match cases.iter().find(|(k, ..)| *k == key) { + None => default(ev, f), + Some((k, _, then)) if shape.test != CaseTest::Int && shape.skip.contains(k) => { + then(ev, f) + } + Some((_, t, then)) => { + let tv = t(ev, f)?; + if crate::lazy::equiv_vals(ev, &probe, &tv)? { + then(ev, f) + } else { + default(ev, f) + } + } + } + }) + } + + fn compile_assert(&mut self, test: &Expr, message: Option<&Expr>, form: &str) -> Exec { + let test = self.compile(test); + let message = message.map(|m| self.compile(m)); + let form = form.to_owned(); + Box::new(move |ev, f| { + if test(ev, f)?.is_truthy() { + return Ok(Value::Nil); + } + let message = match &message { + Some(m) => Some(m(ev, f)?), + None => None, + }; + Err(ev.assert_failed(message, &form)) + }) + } + + /// An invocation: the head, then the args in order. A VM closure + /// with a fixed arity for the arg count takes them straight into + /// its registers. + fn compile_invoke(&mut self, head: &Expr, args: &[Expr], span: Span) -> Exec { + let head = self.compile(head); + let args: Vec = args.iter().map(|a| self.operand(a, true)).collect(); + Box::new(move |ev, f| { + let callee = head(ev, f)?; + if let Some((code, arity)) = fixed_target(&callee, args.len()) { + return call_fixed(ev, f, code, arity, &args); + } + let mut values: SmallVec<[Value; 4]> = SmallVec::new(); + eval_operands(ev, f, &args, &mut values)?; + call_value(ev, &callee, &mut values, span) + }) + } + + /// A direct-call operand; `may_move` lets a last-use local move + /// out (not for intrinsics, which read locals in place). + fn operand(&mut self, expr: &Expr, may_move: bool) -> Operand { + match expr { + Expr::Literal(v) => Operand::Const(v.clone()), + Expr::Local(idx) => match (self.resolve(*idx), may_move && self.is_last(expr)) { + (loc @ Loc::Reg(_), true) => Operand::Loc(loc, true), + (loc, _) => Operand::Loc(loc, false), + }, + other => Operand::Exec(self.compile(other)), + } + } + + /// A direct-threaded native call: validated like the tree + /// evaluator's `DirectNative`; the hot core ops compile per + /// operand shape ([`crate::vm_ops`]), the other intrinsics read + /// registers and literals in place. + fn compile_direct(&mut self, call: &DirectCall) -> Exec { + if let Some(ctor) = crate::vm_ops::value_ctor(call) { + let ops = self.in_place_operands(call); + return ctor.code(site_of(call), ops); + } + let site = site_of(call); + if let Some(ctor) = crate::vm_ops::update_ctor(call) { + let ops: Vec = call.args.iter().map(|a| self.operand(a, true)).collect(); + return crate::vm_ops::update_code(ctor, site, ops); + } + if let Some(op) = crate::vm_intrinsics::intrinsic_of(call) { + let ops = self.in_place_operands(call); + return direct_intrinsic(site, ops, op); + } + let ops: Vec = call.args.iter().map(|a| self.operand(a, true)).collect(); + direct_general(site, ops) + } + + fn compile_vec(&mut self, elems: &[Expr], meta: Option) -> Exec { + let elems = self.compile_all(elems); + Box::new(move |ev, f| { + let mut out = Vec::with_capacity(elems.len()); + for e in &elems { + out.push(e(ev, f)?); + } + let v = Vector::from_vec(out); + // Metadata re-mints the identity: only a literal with any. + Ok(Value::Vector(match &meta { + Some(m) => v.with_meta(Some(m.clone())), + None => v, + })) + }) + } + + fn compile_pairs(&mut self, pairs: &[(Expr, Expr)]) -> Vec<(Exec, Exec)> { + pairs + .iter() + .map(|(k, v)| (self.compile(k), self.compile(v))) + .collect() + } + + fn compile_map(&mut self, pairs: &[(Expr, Expr)], meta: Option) -> Exec { + let pairs = self.compile_pairs(pairs); + Box::new(move |ev, f| { + Ok(Value::Map( + eval_pairs(ev, f, &pairs)?.with_meta(meta.clone()), + )) + }) + } + + fn compile_set(&mut self, elems: &[Expr], meta: Option) -> Exec { + let elems = self.compile_all(elems); + Box::new(move |ev, f| { + let mut out = clj_value::new_set(); + for e in &elems { + let elem = e(ev, f)?; + let elem = crate::lazy::realize_key(ev, &elem)?; + out = out.insert(elem); + } + Ok(Value::Set(out.with_meta(meta.clone()))) + }) + } + + fn compile_def( + &mut self, + sym: &Symbol, + doc: Option, + init: Option<&Expr>, + meta: Option<&[(Expr, Expr)]>, + span: Span, + ) -> Exec { + let meta = meta.map(|pairs| self.compile_pairs(pairs)); + let init = init.map(|e| self.compile(e)); + let sym = sym.clone(); + Box::new(move |ev, f| { + let id = ev.def_begin(&sym); + let value = match &init { + Some(init) => Some(init(ev, f)?), + None => None, + }; + let name_meta = match &meta { + Some(pairs) => Some(eval_pairs(ev, f, pairs)?), + None => sym.meta().cloned(), + }; + Ok(ev.def_finish(id, &sym, doc.clone(), value, name_meta, span)) + }) + } +} + +/// A map literal's pairs, evaluated left to right (keys realized). +fn eval_pairs( + ev: &mut Evaluator, + f: &mut Frame<'_>, + pairs: &[(Exec, Exec)], +) -> Result { + let mut out = Vec::with_capacity(pairs.len()); + for (k, v) in pairs { + let key = k(ev, f)?; + let val = v(ev, f)?; + let key = crate::lazy::realize_key(ev, &key)?; + out.push((key, val)); + } + Ok(Map::from_kvs_last_wins(out)) +} + +/// A compiled `try`: the body, `(class, register, handler)` per +/// `catch`, and the `finally` body. +struct TryCode { + body: Exec, + handlers: Vec<(String, usize, Exec)>, + fin: Exec, +} + +/// Run a `try` like the tree evaluator's: a panic unwinding through +/// the body still runs `finally`; a throw runs the first matching +/// handler (by class hierarchy) with the throwable in its register. +fn run_try(ev: &mut Evaluator, f: &mut Frame<'_>, code: &TryCode) -> Result { + let result = match catch_unwind(AssertUnwindSafe(|| (code.body)(&mut *ev, &mut *f))) { + Ok(result) => result, + Err(payload) => { + if let Err(signal @ Signal::Throw(_)) = (code.fin)(ev, f) { + return Err(signal); + } + resume_unwind(payload); + } + }; + let thrown = match result { + Ok(value) => { + (code.fin)(ev, f)?; + return Ok(value); + } + Err(signal) => crate::eval::try_thrown(signal).expect("recur cannot cross try"), + }; + let handled = code + .handlers + .iter() + .find(|(class, ..)| thrown.class.matches(class)); + let result = match handled { + Some((_, r, handler)) => { + f.regs[*r] = Value::Throwable(thrown); + handler(ev, f) + } + None => Err(Signal::Throw(thrown)), + }; + finish_try(ev, f, &code.fin, result) +} + +/// `finally` after the handled (or rethrown) outcome: a throwing +/// `finally` masks the in-flight exception (probe29). +fn finish_try( + ev: &mut Evaluator, + f: &mut Frame<'_>, + fin: &Exec, + result: Result, +) -> Result { + match result { + Ok(value) => { + fin(ev, f)?; + Ok(value) + } + Err(_) => match fin(ev, f) { + Err(signal @ Signal::Throw(_)) => Err(signal), + _ => result, + }, + } +} + +/// A one-arg `recur` (the empty signal says the register is +/// written; see `run_loop`). +fn recur1(r: usize, mut ops: Vec) -> Exec { + let a = ops.pop().expect("one arg"); + Box::new(move |ev, f| { + let v = a.eval(ev, f)?; + set_reg(&mut f.regs[r], v); + Err(Signal::Recur(Vec::new())) + }) +} + +/// A two-arg `recur` (every arg evaluates before any register is +/// written). +fn recur2([r0, r1]: [usize; 2], ops: Vec) -> Exec { + let [a, b] = <[Operand; 2]>::try_from(ops).ok().expect("two args"); + Box::new(move |ev, f| { + let (va, vb) = (a.eval(ev, f)?, b.eval(ev, f)?); + set_reg(&mut f.regs[r0], va); + set_reg(&mut f.regs[r1], vb); + Err(Signal::Recur(Vec::new())) + }) +} + +fn recur3([r0, r1, r2]: [usize; 3], ops: Vec) -> Exec { + let [a, b, c] = <[Operand; 3]>::try_from(ops).ok().expect("three args"); + Box::new(move |ev, f| { + let (va, vb, vc) = (a.eval(ev, f)?, b.eval(ev, f)?, c.eval(ev, f)?); + set_reg(&mut f.regs[r0], va); + set_reg(&mut f.regs[r1], vb); + set_reg(&mut f.regs[r2], vc); + Err(Signal::Recur(Vec::new())) + }) +} + +fn recur4([r0, r1, r2, r3]: [usize; 4], ops: Vec) -> Exec { + let [a, b, c, d] = <[Operand; 4]>::try_from(ops).ok().expect("four args"); + Box::new(move |ev, f| { + let (va, vb) = (a.eval(ev, f)?, b.eval(ev, f)?); + let (vc, vd) = (c.eval(ev, f)?, d.eval(ev, f)?); + set_reg(&mut f.regs[r0], va); + set_reg(&mut f.regs[r1], vb); + set_reg(&mut f.regs[r2], vc); + set_reg(&mut f.regs[r3], vd); + Err(Signal::Recur(Vec::new())) + }) +} + +/// Any other `recur`: each value moves straight to its register +/// once every arg has evaluated (no buffer-to-iterator copy). +fn recur_n(targets: Arc<[usize]>, ops: Vec) -> Exec { + Box::new(move |ev, f| { + let mut values: SmallVec<[Value; 8]> = SmallVec::new(); + for op in &ops { + values.push(op.eval(ev, f)?); + } + for (r, v) in targets.iter().zip(values.iter_mut()) { + set_reg(&mut f.regs[*r], std::mem::replace(v, Value::Nil)); + } + Err(Signal::Recur(Vec::new())) + }) +} + +/// An `and`/`or` step over the let-bound local `g`: `(let* [g a] +/// (if g b g))` answers `(a, b, true)`, `(let* [g a] (if g g b))` +/// `(a, b, false)`. +fn and_or_shape(expr: &Expr, g: usize) -> Option<(&Expr, &Expr, bool)> { + let Expr::Let { bindings, body } = expr else { + return None; + }; + let ([(Pattern::Symbol(_), a)], [Expr::If { test, then, els }]) = + (bindings.as_slice(), body.as_slice()) + else { + return None; + }; + let is_g = |e: &Expr| matches!(e, Expr::Local(i) if *i == g); + match (is_g(test), is_g(then), is_g(els)) { + (true, false, true) => Some((a, then, true)), + (true, true, false) => Some((a, els, false)), + _ => None, + } +} + +/// One `loop` binding: the raw value's register, its init, and the +/// destructuring unpack (none for a symbol). +type LoopStep = (usize, Exec, Option); + +/// One `let` binding: a symbol's register and init, or a +/// destructuring step. +enum LetStep { + Name(usize, Exec), + Unpack(Exec), +} + +/// A compiled `loop`. +struct LoopCode { + steps: Vec, + targets: Arc<[usize]>, + body: Exec, + /// Whether any binding destructures (unpacks after each `recur`). + unpacks: bool, +} + +/// Run a `loop`: bind in order (each init sees the earlier names), +/// then run the body, rewriting the raw registers (and unpacking +/// again) on every `recur`. +fn run_loop(ev: &mut Evaluator, f: &mut Frame<'_>, code: &LoopCode) -> Result { + for (r, init, unpack) in &code.steps { + let v = init(ev, f)?; + set_reg(&mut f.regs[*r], v); + if let Some(unpack) = unpack { + unpack(ev, f)?; + } + } + loop { + match (code.body)(ev, f) { + // The VM's own `recur` wrote the registers already. + Err(Signal::Recur(values)) if values.is_empty() => {} + // A tree-evaluated one carries the values. + Err(Signal::Recur(values)) => { + for (r, v) in code.targets.iter().zip(values) { + f.regs[*r] = v; + } + } + other => return other, + } + if code.unpacks { + for unpack in code.steps.iter().filter_map(|(_, _, u)| u.as_ref()) { + unpack(ev, f)?; + } + } + } +} + +/// A tree-evaluator frame holding `locs`' values at their lexical +/// indexes. +fn frame_env(f: &Frame<'_>, locs: &[Loc]) -> Env { + if locs.is_empty() { + Env::new() + } else { + Env::new().extend(locs.iter().map(|l| f.get(*l).clone()).collect()) + } +} + +/// `case*`'s dispatch shape. +struct CaseShape { + shift: i32, + mask: i32, + skip: Vec, + test: CaseTest, +} + +/// A constant-message throwable (a fresh identity per evaluation). +fn new_throwable(ev: &mut Evaluator, class: ThrowClass, message: &Arc) -> Value { + Value::Throwable(Throwable { + id: ev.next_id(), + class, + message: Some(Arc::clone(message)), + data: None, + cause: None, + }) +} + +/// Clone with the scalar kinds inline (no call into the clone glue). +#[inline(always)] +pub(crate) fn clone_value(v: &Value) -> Value { + match v { + Value::Int(n) => Value::Int(*n), + Value::Nil => Value::Nil, + Value::Bool(b) => Value::Bool(*b), + other => other.clone(), + } +} + +/// Overwrite a register, skipping the drop glue for scalars. +#[inline(always)] +fn set_reg(slot: &mut Value, v: Value) { + let old = std::mem::replace(slot, v); + if matches!(old, Value::Int(_) | Value::Nil | Value::Bool(_)) { + std::mem::forget(old); + } +} + +fn konst(v: Value) -> Exec { + Box::new(move |_, _| Ok(v.clone())) +} + +/// A last-use read: the value moves out (the register is dead). +fn take_local(r: usize) -> Exec { + Box::new(move |_, f| Ok(std::mem::replace(&mut f.regs[r], Value::Nil))) +} + +pub(crate) fn local(loc: Loc) -> Exec { + match loc { + Loc::Reg(r) => Box::new(move |_, f| Ok(clone_value(&f.regs[r]))), + Loc::Up(u) => Box::new(move |_, f| Ok(f.up[u].clone())), + Loc::Rec(j) => Box::new(move |_, f| Ok(f.rec[j].clone())), + } +} + +/// The next VM cache site (process-wide numbering: every +/// evaluator indexes its own cache by it). +pub(crate) fn next_site() -> usize { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +fn global(var: Var) -> Exec { + let site = next_site(); + Box::new(move |ev, _| Ok(ev.site_global(site, &var))) +} + +/// A direct-call argument: literals and locals read in place (no +/// clone for the `Int` fast paths). +pub(crate) enum Operand { + Const(Value), + /// A local; `true` marks a last-use register read, which moves + /// out on evaluation. + Loc(Loc, bool), + Exec(Exec), +} + +impl Operand { + /// A computed operand's value (`None` for in-place operands). + fn computed(&self, ev: &mut Evaluator, f: &mut Frame<'_>) -> Result, Signal> { + match self { + Operand::Exec(e) => e(ev, f).map(Some), + _ => Ok(None), + } + } + + /// The operand in place (`computed` holds an `Exec`'s value). + fn read<'v>(&'v self, f: &'v Frame<'_>, computed: Option<&'v Value>) -> &'v Value { + match self { + Operand::Const(v) => v, + Operand::Loc(l, _) => f.get(*l), + Operand::Exec(_) => computed.expect("computed operands are evaluated first"), + } + } + + #[inline] + pub(crate) fn eval(&self, ev: &mut Evaluator, f: &mut Frame<'_>) -> Result { + match self { + Operand::Const(v) => Ok(v.clone()), + Operand::Loc(Loc::Reg(r), true) => Ok(std::mem::replace(&mut f.regs[*r], Value::Nil)), + Operand::Loc(l, _) => Ok(clone_value(f.get(*l))), + Operand::Exec(e) => e(ev, f), + } + } +} + +fn eval_operands( + ev: &mut Evaluator, + f: &mut Frame<'_>, + ops: &[Operand], + out: &mut SmallVec<[Value; 4]>, +) -> Result<(), Signal> { + for op in ops { + out.push(op.eval(ev, f)?); + } + Ok(()) +} + +/// The slow road (validation miss): re-resolve the head and invoke +/// generally, like the tree evaluator's `invoke_direct_slow`. +fn direct_slow( + ev: &mut Evaluator, + f: &mut Frame<'_>, + site: &DirectCall, + ops: &[Operand], +) -> Result { + let callee = ev.deref_var_cached(site.var.id, &site.var.sym); + let mut values: SmallVec<[Value; 4]> = SmallVec::new(); + eval_operands(ev, f, ops, &mut values)?; + call_value(ev, &callee, &mut values, site.span) +} + +fn direct_general(site: Arc, ops: Vec) -> Exec { + let sid = next_site(); + Box::new(move |ev, f| { + if !ev.site_direct_valid(sid, &site) { + return direct_slow(ev, f, &site, &ops); + } + let mut values: SmallVec<[Value; 4]> = SmallVec::new(); + eval_operands(ev, f, &ops, &mut values)?; + ev.call_direct_values(&site, &mut values) + }) +} + +/// A direct call's site record (the analyzed call minus its +/// argument forms, which compile to operands). +fn site_of(call: &DirectCall) -> Arc { + Arc::new(DirectCall { + var: call.var.clone(), + expect: Arc::clone(&call.expect), + func: Arc::clone(&call.func), + owned: call.owned.clone(), + fast: call.fast, + args: Vec::new(), + span: call.span, + }) +} + +/// A three-argument intrinsic direct call: computed operands +/// evaluate first (in order — locals and literals have no effects, +/// and no argument rewrites a live local's register), then the fast +/// path reads every operand in place; a miss runs the native over +/// clones. +fn direct_intrinsic( + site: Arc, + ops: Vec, + fast: crate::vm_intrinsics::Fast, +) -> Exec { + let sid = next_site(); + let [a, b, c] = <[Operand; 3]>::try_from(ops).ok().expect("three operands"); + Box::new(move |ev, f| intrinsic3(ev, f, sid, &site, [&a, &b, &c], fast)) +} + +/// [`direct_intrinsic`] as a branch test (the same call, truth +/// taken directly). +fn intrinsic_test_exec( + site: Arc, + ops: Vec, + fast: crate::vm_intrinsics::Fast, +) -> Test { + let sid = next_site(); + let [a, b, c] = <[Operand; 3]>::try_from(ops).ok().expect("three operands"); + Box::new(move |ev, f| Ok(intrinsic3(ev, f, sid, &site, [&a, &b, &c], fast)?.is_truthy())) +} + +#[inline(always)] +fn intrinsic3( + ev: &mut Evaluator, + f: &mut Frame<'_>, + sid: usize, + site: &DirectCall, + [a, b, c]: [&Operand; 3], + fast: crate::vm_intrinsics::Fast, +) -> Result { + if !ev.site_direct_valid(sid, site) { + let values = smallvec::smallvec![a.eval(ev, f)?, b.eval(ev, f)?, c.eval(ev, f)?]; + return direct_slow_values(ev, site, values); + } + let (ta, tb, tc) = (a.computed(ev, f)?, b.computed(ev, f)?, c.computed(ev, f)?); + let (va, vb, vc) = ( + a.read(f, ta.as_ref()), + b.read(f, tb.as_ref()), + c.read(f, tc.as_ref()), + ); + match fast(&[va, vb, vc]) { + Some(v) => Ok(v), + None => { + let mut values = smallvec::smallvec![va.clone(), vb.clone(), vc.clone()]; + ev.call_direct_values(site, &mut values) + } + } +} + +/// [`direct_slow`] over already-evaluated args. +pub(crate) fn direct_slow_values( + ev: &mut Evaluator, + site: &DirectCall, + mut values: SmallVec<[Value; 4]>, +) -> Result { + let callee = ev.deref_var_cached(site.var.id, &site.var.sym); + call_value(ev, &callee, &mut values, site.span) +} + +#[cfg(test)] +mod tests { + use clj_value::Value; + + #[test] + fn compiled_closures_debug_their_display() { + // `Closure`'s derived `Debug` reaches the compiled fn, which + // shows its display name only (its code is closures). + let mut eval = crate::boot(); + let v = eval.eval_source("(fn named-for-debug [x] x)").unwrap(); + assert!(matches!(v.last(), Some(Value::Extension(_)))); + let shown = format!("{:?}", v.last()); + assert!(shown.contains("CompiledFun"), "{shown}"); + assert!(shown.contains("named-for-debug"), "{shown}"); + assert!(shown.contains("rec: Some(1)"), "{shown}"); + } +} diff --git a/rust/crates/clj-eval/src/vm_intrinsics.rs b/rust/crates/clj-eval/src/vm_intrinsics.rs new file mode 100644 index 0000000000..f1b15575a0 --- /dev/null +++ b/rust/crates/clj-eval/src/vm_intrinsics.rs @@ -0,0 +1,197 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! VM intrinsics (#226): the three-argument hot `clojure.core` +//! calls (`nth`/`get` with a not-found value, `assoc!`) computed in +//! place, plus the shared fast-path helpers behind the +//! shape-specialized ops (`crate::vm_ops`). +//! +//! A direct call to one of these (still validated per site like any +//! direct native) reads its local and literal operands by reference +//! — no argument buffer, no clone of the collection — and answers +//! inline when every argument has a type the fast path covers. Each +//! covered case returns exactly what the native returns; anything +//! else (other types, out-of-range indexes, errors) runs the native +//! itself over the same values, so every error text and edge case +//! stays the native's. + +use clj_value::{TransientKind, Value}; + +/// An intrinsic's fast path: the inline answer for the args, or +/// `None` to run the native. +pub(crate) type Fast = fn(&[&Value]) -> Option; + +/// The `clojure.core` three-argument calls with inline fast paths +/// (the one- and two-argument ops compile in `crate::vm_ops`). +const TABLE: &[(&str, Fast)] = &[("nth", nth), ("get", get), ("assoc!", assoc_bang)]; + +/// The fast path for a three-argument direct call to the +/// `clojure.core` native `call` analyzed (by the native's own name, +/// never the var's; `None`: the general direct call). +pub(crate) fn intrinsic_of(call: &crate::expr::DirectCall) -> Option { + let name = crate::vm_ops::core_name(call)?; + let (_, fast) = TABLE + .iter() + .find(|(n, _)| *n == name && call.args.len() == 3)?; + Some(*fast) +} + +/// `mod` over `Int`s (`None` for a zero divisor): Java `%` +/// adjusted to the divisor's sign (`(mod MIN -1)` is 0). +pub(crate) fn java_mod(x: i64, y: i64) -> Option { + if y == 0 { + return None; + } + let r = x.wrapping_rem(y); + if r == 0 || (r > 0) == (y > 0) { + Some(r) + } else { + r.checked_add(y) + } +} + +/// An index that every `int`-coercing reader takes as-is +/// (`0..=i32::MAX`). +fn index(v: &Value) -> Option { + match v { + Value::Int(n) if (0..=i64::from(i32::MAX)).contains(n) => usize::try_from(*n).ok(), + _ => None, + } +} + +fn nth(args: &[&Value]) -> Option { + nth_of(args[0], args[1], args.get(2).copied()) +} + +/// `nth` of a vector, transient vector, or string at an `int` +/// index: in range answers the item; with `not-found`, past either +/// end answers that (the native's `int` coercion fails wider +/// indexes). +pub(crate) fn nth_of(coll: &Value, idx: &Value, not_found: Option<&Value>) -> Option { + let Value::Int(n) = idx else { + return None; + }; + let n = i32::try_from(*n).ok()?; + let i = usize::try_from(n).ok(); + let found = match coll { + Value::Vector(v) => i.and_then(|i| v.get(i)), + Value::Str(s) => i.and_then(|i| crate::coll::str_char_at(s, i).map(Value::Char)), + Value::Transient(t) => transient_nth(t, i)?, + _ => return None, + }; + match (found, not_found) { + (Some(v), _) => Some(v), + (None, Some(nf)) => Some(nf.clone()), + (None, None) => None, + } +} + +/// `nth` of a transient vector (`None` for other transients — the +/// native's error — or a frozen one). +fn transient_nth(t: &clj_value::Transient, i: Option) -> Option> { + match &*t.lock() { + TransientKind::Vector(tv) => match i { + Some(i) => tv.nth(i).ok(), + None => Some(None), + }, + _ => None, + } +} + +fn get(args: &[&Value]) -> Option { + get_of(args[0], args[1], args.get(2).copied()) +} + +/// `get` of a map (scalar keys) or a vector (in-range `int` keys), +/// `nil` included. +pub(crate) fn get_of(coll: &Value, key: &Value, not_found: Option<&Value>) -> Option { + let missing = || not_found.map_or(Value::Nil, Value::clone); + match coll { + Value::Nil => Some(missing()), + Value::Map(m) if !m.is_sorted() && is_scalar_key(key) => { + Some(m.get(key).cloned().unwrap_or_else(missing)) + } + Value::Vector(v) => { + let i = index(key)?; + Some(v.get(i).unwrap_or_else(missing)) + } + _ => None, + } +} + +/// Keys whose lookup needs no realization (equality is structural +/// and cheap). +pub(crate) fn is_scalar_key(k: &Value) -> bool { + matches!( + k, + Value::Keyword(_) + | Value::Int(_) + | Value::Str(_) + | Value::Char(_) + | Value::Symbol(_) + | Value::Bool(_) + | Value::Nil + ) +} + +/// `count` of `nil`, a vector, map, set, or string (UTF-16 units, +/// like the native). +pub(crate) fn count_of(v: &Value) -> Option { + let n = match v { + Value::Nil => 0, + Value::Vector(v) => v.len(), + Value::Map(m) => m.size(), + Value::Set(s) => s.size(), + Value::Str(s) => crate::coll::str_utf16_len(s), + _ => return None, + }; + i64::try_from(n).ok().map(Value::Int) +} + +/// `contains?` of an unsorted map or set at a scalar key. +pub(crate) fn contains_test(coll: &Value, key: &Value) -> Option { + match coll { + Value::Map(m) if !m.is_sorted() && is_scalar_key(key) => Some(m.contains_key(key)), + Value::Set(s) if !s.is_sorted() && is_scalar_key(key) => Some(s.contains(key)), + _ => None, + } +} + +/// `=` over same-kind scalars (`None` for every other pair: numeric +/// categories, collections, and mixed kinds take the native). +#[inline] +pub(crate) fn scalar_eq(a: &Value, b: &Value) -> Option { + Some(match (a, b) { + (Value::Int(x), Value::Int(y)) => x == y, + (Value::Char(x), Value::Char(y)) => x == y, + (Value::Bool(x), Value::Bool(y)) => x == y, + (Value::Nil, Value::Nil) => true, + (Value::Keyword(x), Value::Keyword(y)) => x == y, + (Value::Str(x), Value::Str(y)) => x == y, + _ => return None, + }) +} + +/// `(assoc! tv i x)` on a live transient vector with `i` at most +/// its length. +fn assoc_bang(args: &[&Value]) -> Option { + let Value::Transient(t) = args[0] else { + return None; + }; + let i = index(args[1])?; + { + let mut guard = t.lock(); + let TransientKind::Vector(tv) = &mut *guard else { + return None; + }; + if i > tv.len().ok()? { + return None; + } + tv.assoc(i, args[2].clone()).ok()?; + } + Some(args[0].clone()) +} diff --git a/rust/crates/clj-eval/src/vm_live.rs b/rust/crates/clj-eval/src/vm_live.rs new file mode 100644 index 0000000000..7faea0b00a --- /dev/null +++ b/rust/crates/clj-eval/src/vm_live.rs @@ -0,0 +1,358 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! Last-use analysis for the register VM (#226). +//! +//! A local read that no later evaluation of the same frame can +//! observe may move the value out of its register instead of +//! cloning it. Persistent collections then arrive uniquely owned at +//! the natives that consume their argument (`conj`, `assoc`, …), +//! which update in place instead of path-copying — the loop +//! accumulator `(recur (conj acc x))` stops copying per step. +//! +//! The analysis walks one fn body backward in the VM's evaluation +//! order, tracking which of the fn's own lexical locals (indexes at +//! or past its `base`) are still read later. `recur` jumps to its +//! target's head, whose live set comes from a fixed point; every +//! form the analysis does not model precisely — `try` (a handler may +//! still read anything), closure creation (captures copy), escapes +//! to the tree evaluator, `:or` defaults — reads every visible local, +//! so no read before it is a last use. + +use std::collections::HashSet; + +use crate::expr::{DirectCall, Expr, Pattern, pattern_count}; + +/// The last-use reads of one body, by node address (the analyzed +/// tree outlives its compilation). +pub(crate) type LastUses = HashSet<*const Expr>; + +/// Lexical locals at or past `base`, as a bit set. +#[derive(Clone, Default, PartialEq, Eq, Debug)] +struct Live(Vec); + +impl Live { + fn contains(&self, i: usize) -> bool { + self.0.get(i / 64).is_some_and(|w| w & (1 << (i % 64)) != 0) + } + + fn insert(&mut self, i: usize) { + if self.0.len() <= i / 64 { + self.0.resize(i / 64 + 1, 0); + } + self.0[i / 64] |= 1 << (i % 64); + } + + fn remove(&mut self, i: usize) { + if let Some(w) = self.0.get_mut(i / 64) { + *w &= !(1 << (i % 64)); + } + } + + fn union(&mut self, other: &Live) { + for (i, w) in other.0.iter().enumerate() { + if self.0.len() <= i { + self.0.push(0); + } + self.0[i] |= w; + } + } + + fn normalized(mut self) -> Self { + while self.0.last() == Some(&0) { + self.0.pop(); + } + self + } +} + +/// The last uses in a body whose locals start at lexical index +/// `base`, with `visible` locals in scope at its start. +pub(crate) fn last_uses(body: &[Expr], base: usize, visible: usize) -> LastUses { + let mut w = Walk { + base, + visible, + recur_after: Live::default(), + record: true, + last: HashSet::new(), + }; + let mut live = Live::default(); + w.seq(body, &mut live); + w.last +} + +/// Whether evaluating `expr` may read lexical local `local` (at or +/// past `base`, with `visible` locals in scope): conservative — +/// every form the analysis does not model reads every visible local. +pub(crate) fn may_read(expr: &Expr, base: usize, visible: usize, local: usize) -> bool { + let mut w = Walk { + base, + visible, + recur_after: Live::default(), + record: false, + last: HashSet::new(), + }; + let mut live = Live::default(); + w.walk(expr, &mut live); + local >= base && live.contains(local - base) +} + +struct Walk { + base: usize, + visible: usize, + /// What a `recur` here leaves live (the target head's set minus + /// the rebound locals). + recur_after: Live, + record: bool, + last: LastUses, +} + +impl Walk { + fn read(&mut self, e: &Expr, i: usize, live: &mut Live) { + if i < self.base { + return; + } + let rel = i - self.base; + if self.record && !live.contains(rel) { + self.last.insert(e as *const Expr); + } + live.insert(rel); + } + + /// Every visible local is read here. + fn read_all(&self, live: &mut Live) { + for i in self.base..self.visible.max(self.base) { + live.insert(i - self.base); + } + } + + fn kill(&self, start: usize, count: usize, live: &mut Live) { + for i in start..start + count { + if i >= self.base { + live.remove(i - self.base); + } + } + } + + /// Forms in order (walked last to first). + fn seq(&mut self, forms: &[Expr], live: &mut Live) { + for f in forms.iter().rev() { + self.walk(f, live); + } + } + + fn walk(&mut self, e: &Expr, live: &mut Live) { + match e { + Expr::Literal(_) + | Expr::Quote(_) + | Expr::VarRef(_) + | Expr::Global(_) + | Expr::NewThrowable { .. } => {} + Expr::Local(i) => self.read(e, *i, live), + Expr::If { test, then, els } => { + let mut other = live.clone(); + self.walk(els, &mut other); + self.walk(then, live); + live.union(&other); + self.walk(test, live); + } + Expr::Do(body) => self.seq(body, live), + Expr::Invoke { head, args, .. } => { + self.seq(args, live); + self.walk(head, live); + } + Expr::DirectNative(call) => self.direct(call, live), + _ => self.walk_form(e, live), + } + } + + /// The binding, data, and control forms (split from [`walk`] + /// for the complexity budget). + fn walk_form(&mut self, e: &Expr, live: &mut Live) { + match e { + Expr::Let { bindings, body } => self.bindings(bindings, body, live, false), + Expr::Loop { bindings, body } => self.bindings(bindings, body, live, true), + Expr::Recur(args) => { + *live = self.recur_after.clone(); + self.seq(args, live); + } + Expr::VecLit(elems, _) | Expr::SetLit(elems, _) => self.seq(elems, live), + Expr::MapLit(pairs, _) => self.pairs(pairs, live), + Expr::Throw(inner) => { + *live = Live::default(); + self.walk(inner, live); + } + Expr::Def { init, meta, .. } => { + if let Some(pairs) = meta { + self.pairs(pairs, live); + } + if let Some(init) = init { + self.walk(init, live); + } + } + _ => self.walk_control(e, live), + } + } + + /// `case*`, `assert`, and the conservative rest (split from + /// [`walk_form`] for the complexity budget). + fn walk_control(&mut self, e: &Expr, live: &mut Live) { + match e { + Expr::CaseStar { + expr, + default, + cases, + .. + } => { + let mut out = live.clone(); + self.walk(default, &mut out); + for (_, test, then) in cases { + let mut branch = live.clone(); + self.walk(then, &mut branch); + self.walk(test, &mut branch); + out.union(&branch); + } + *live = out; + self.walk(expr, live); + } + Expr::Assert { test, message, .. } => { + // A failing assert throws right after its message. + let mut failing = Live::default(); + if let Some(m) = message { + self.walk(m, &mut failing); + } + live.union(&failing); + self.walk(test, live); + } + _ => self.read_all(live), + } + } + + /// Map pairs in order (key, then value). + fn pairs(&mut self, pairs: &[(Expr, Expr)], live: &mut Live) { + for (k, v) in pairs.iter().rev() { + self.walk(v, live); + self.walk(k, live); + } + } + + /// A direct call in the VM's order: specialized ops and + /// intrinsics evaluate their computed operands first and read + /// locals in place after (so those reads come last, and never + /// move); every other direct call evaluates its operands in + /// order. + fn direct(&mut self, call: &DirectCall, live: &mut Live) { + if crate::vm_ops::update_order(call) { + return self.update(call, live); + } + let in_place = + crate::vm_ops::in_place(call) || crate::vm_intrinsics::intrinsic_of(call).is_some(); + if !in_place { + self.seq(&call.args, live); + return; + } + for a in &call.args { + if let Expr::Local(i) = a { + if *i >= self.base { + live.insert(*i - self.base); + } + } + } + for a in call.args.iter().rev() { + if !matches!(a, Expr::Local(_) | Expr::Literal(_)) { + self.walk(a, live); + } + } + } + + /// An update op in the VM's order (see `vm_ops::upd`): a + /// computed collection first, then the other operands in order, + /// then a local collection's read — last, so it can be the last + /// use even when the other operands read the same local. + fn update(&mut self, call: &DirectCall, live: &mut Live) { + let (coll, rest) = call + .args + .split_first() + .expect("update ops take a collection"); + let local = matches!(coll, Expr::Local(_)); + if local { + self.walk(coll, live); + } + self.seq(rest, live); + if !local { + self.walk(coll, live); + } + } + + /// `let`/`loop` bindings: each init runs with the earlier names + /// in scope, then its own names bind (destructuring `:or` + /// defaults read every local visible before them). + fn bindings( + &mut self, + bindings: &[(Pattern, Expr)], + body: &[Expr], + live: &mut Live, + is_loop: bool, + ) { + let v0 = self.visible; + let counts: Vec = bindings.iter().map(|(p, _)| pattern_count(p)).collect(); + let total: usize = counts.iter().sum(); + self.visible = v0 + total; + if is_loop { + self.loop_body(body, v0, total, live); + } else { + self.seq(body, live); + } + let mut start = v0 + total; + for ((pat, init), count) in bindings.iter().zip(&counts).rev() { + start -= count; + self.kill(start, *count, live); + self.visible = start; + if has_defaults(pat) { + self.read_all(live); + } + self.walk(init, live); + } + self.visible = v0; + } + + /// A `loop` body: the head's live set is the fixed point of + /// walking the body with `recur` leaving the head's set minus + /// the loop locals (`[v0, v0 + total)`). + fn loop_body(&mut self, body: &[Expr], v0: usize, total: usize, live: &mut Live) { + let record = std::mem::replace(&mut self.record, false); + let saved = std::mem::take(&mut self.recur_after); + let mut head = Live::default(); + let out = live.clone(); + loop { + let mut after = head.clone(); + self.kill(v0, total, &mut after); + self.recur_after = after; + let mut l = out.clone(); + self.seq(body, &mut l); + let l = l.normalized(); + if l == head { + break; + } + head = l; + } + self.record = record; + let mut l = out; + self.seq(body, &mut l); + self.recur_after = saved; + *live = l; + } +} + +/// Whether a pattern (or any nested one) has `:or` defaults. +pub(crate) fn has_defaults(pat: &Pattern) -> bool { + match pat { + Pattern::Symbol(_) => false, + Pattern::Vec(v) => v.fixed.iter().chain(v.rest.as_deref()).any(has_defaults), + Pattern::Map(m) => !m.or.is_empty() || m.entries.iter().any(|(p, ..)| has_defaults(p)), + } +} diff --git a/rust/crates/clj-eval/src/vm_ops.rs b/rust/crates/clj-eval/src/vm_ops.rs new file mode 100644 index 0000000000..9d9a311436 --- /dev/null +++ b/rust/crates/clj-eval/src/vm_ops.rs @@ -0,0 +1,1028 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! Shape-specialized direct calls for the register VM (#226). +//! +//! The general intrinsic call (see [`crate::vm_intrinsics`]) matches +//! each operand's kind at run time and reaches its inline answer +//! through a function pointer. The hottest one- and two-argument +//! `clojure.core` ops compile here per operand shape instead — a +//! register, a captured value, a constant, or a computed form — into +//! closures whose fast path is the op itself, inline. Every miss +//! (another argument type, an overflow, a zero divisor, an +//! invalidated site) leaves through one cold function that runs the +//! native over the same values, so results and errors stay the +//! native's. +//! +//! The op comes from the analyzed native's own name (its +//! `clojure.core/…` display), never the var's: a var re-rooted to +//! another native keeps that native's semantics. Operands read in +//! place (locals, constants) are read after the computed ones, like +//! the intrinsics: locals have no effects, and the last-use analysis +//! never moves them out ([`in_place`]). + +use std::sync::Arc; + +use clj_value::Value; +use smallvec::{SmallVec, smallvec}; + +use crate::error::Signal; +use crate::eval::Evaluator; +use crate::expr::DirectCall; +use crate::vm::{Code, Exec, Frame, Loc, Operand, direct_slow_values, local, next_site}; + +/// A two-argument op's inline answer (`None` runs the native). +pub(crate) trait Op2: Send + Sync + 'static { + fn apply(a: &Value, b: &Value) -> Option; + + /// The answer's truth, for a branch test. + #[inline(always)] + fn test(a: &Value, b: &Value) -> Option { + Self::apply(a, b).map(|v| v.is_truthy()) + } +} + +/// A one-argument op's inline answer (`None` runs the native). +pub(crate) trait Op1: Send + Sync + 'static { + fn apply(a: &Value) -> Option; + + /// The answer's truth, for a branch test. + #[inline(always)] + fn test(a: &Value) -> Option { + Self::apply(a).map(|v| v.is_truthy()) + } +} + +/// What a specialized call produces: a value ([`AsValue`]) or a +/// branch test's truth ([`AsTest`]). +pub(crate) trait Out: Send + Sync + 'static { + type T: 'static; + fn hit2(a: &Value, b: &Value) -> Option; + fn hit1(a: &Value) -> Option; + fn of(v: Value) -> Self::T; + fn of_bool(b: bool) -> Self::T; +} + +pub(crate) struct AsValue; + +impl Out for AsValue { + type T = Value; + + #[inline(always)] + fn hit2(a: &Value, b: &Value) -> Option { + O::apply(a, b) + } + + #[inline(always)] + fn hit1(a: &Value) -> Option { + O::apply(a) + } + + #[inline(always)] + fn of(v: Value) -> Value { + v + } + + #[inline(always)] + fn of_bool(b: bool) -> Value { + Value::Bool(b) + } +} + +pub(crate) struct AsTest; + +impl Out for AsTest { + type T = bool; + + #[inline(always)] + fn hit2(a: &Value, b: &Value) -> Option { + O::test(a, b) + } + + #[inline(always)] + fn hit1(a: &Value) -> Option { + O::test(a) + } + + #[inline(always)] + fn of(v: Value) -> bool { + v.is_truthy() + } + + #[inline(always)] + fn of_bool(b: bool) -> bool { + b + } +} + +/// An operand read in place. +trait Src: Send + Sync + 'static { + fn read<'v>(&'v self, f: &'v Frame<'_>) -> &'v Value; +} + +/// A register of the running frame. +struct Reg(usize); + +/// A captured value of the running closure. +struct Up(usize); + +/// A literal. +struct Kst(Value); + +impl Src for Reg { + #[inline(always)] + fn read<'v>(&'v self, f: &'v Frame<'_>) -> &'v Value { + &f.regs[self.0] + } +} + +impl Src for Up { + #[inline(always)] + fn read<'v>(&'v self, f: &'v Frame<'_>) -> &'v Value { + &f.up[self.0] + } +} + +impl Src for Kst { + #[inline(always)] + fn read<'v>(&'v self, _: &'v Frame<'_>) -> &'v Value { + &self.0 + } +} + +/// An operand's shape (`letfn` group members compute: they are +/// never arithmetic operands in practice). +enum Shape { + Reg(usize), + Up(usize), + Kst(Value), + Exec(Exec), +} + +fn shape(op: Operand) -> Shape { + match op { + Operand::Const(v) => Shape::Kst(v), + Operand::Loc(Loc::Reg(r), _) => Shape::Reg(r), + Operand::Loc(Loc::Up(u), _) => Shape::Up(u), + Operand::Loc(loc, _) => Shape::Exec(local(loc)), + Operand::Exec(e) => Shape::Exec(e), + } +} + +/// A specialized call site: its validation slot and record. +pub(crate) struct Site { + sid: usize, + call: Arc, +} + +impl Site { + pub(crate) fn new(call: Arc) -> Self { + Self { + sid: next_site(), + call, + } + } +} + +/// The miss: the native over the operands' values (re-validated: +/// an invalid site re-resolves its head, like the general call). +#[cold] +#[inline(never)] +fn native(ev: &mut Evaluator, c: &Site, mut values: SmallVec<[Value; 4]>) -> Result { + if ev.site_direct_valid(c.sid, &c.call) { + ev.call_direct_values(&c.call, &mut values) + } else { + direct_slow_values(ev, &c.call, values) + } +} + +#[cold] +#[inline(never)] +fn native2(ev: &mut Evaluator, c: &Site, a: &Value, b: &Value) -> Result { + native(ev, c, smallvec![a.clone(), b.clone()]) +} + +#[cold] +#[inline(never)] +fn native1(ev: &mut Evaluator, c: &Site, a: &Value) -> Result { + native(ev, c, smallvec![a.clone()]) +} + +/// Both operands in place. +fn ss(c: Site, a: A, b: B) -> Code { + Box::new(move |ev, f| { + if ev.site_direct_valid(c.sid, &c.call) { + if let Some(v) = T::hit2::(a.read(f), b.read(f)) { + return Ok(v); + } + } + native2(ev, &c, a.read(f), b.read(f)).map(T::of) + }) +} + +/// A computed first operand (validated before it runs). +fn xs(c: Site, a: Exec, b: B) -> Code { + Box::new(move |ev, f| { + let valid = ev.site_direct_valid(c.sid, &c.call); + let va = a(ev, f)?; + if valid { + if let Some(v) = T::hit2::(&va, b.read(f)) { + return Ok(v); + } + } + native2(ev, &c, &va, b.read(f)).map(T::of) + }) +} + +/// A computed second operand. +fn sx(c: Site, a: A, b: Exec) -> Code { + Box::new(move |ev, f| { + let valid = ev.site_direct_valid(c.sid, &c.call); + let vb = b(ev, f)?; + if valid { + if let Some(v) = T::hit2::(a.read(f), &vb) { + return Ok(v); + } + } + native2(ev, &c, a.read(f), &vb).map(T::of) + }) +} + +/// Both operands computed (in order). +fn xx(c: Site, a: Exec, b: Exec) -> Code { + Box::new(move |ev, f| { + let valid = ev.site_direct_valid(c.sid, &c.call); + let va = a(ev, f)?; + let vb = b(ev, f)?; + if valid { + if let Some(v) = T::hit2::(&va, &vb) { + return Ok(v); + } + } + native2(ev, &c, &va, &vb).map(T::of) + }) +} + +/// The one operand in place. +fn s1(c: Site, a: A) -> Code { + Box::new(move |ev, f| { + if ev.site_direct_valid(c.sid, &c.call) { + if let Some(v) = T::hit1::(a.read(f)) { + return Ok(v); + } + } + native1(ev, &c, a.read(f)).map(T::of) + }) +} + +/// The one operand computed. +fn x1(c: Site, a: Exec) -> Code { + Box::new(move |ev, f| { + let valid = ev.site_direct_valid(c.sid, &c.call); + let va = a(ev, f)?; + if valid { + if let Some(v) = T::hit1::(&va) { + return Ok(v); + } + } + native1(ev, &c, &va).map(T::of) + }) +} + +fn shape2(c: Site, a: Operand, b: Operand) -> Code { + match shape(a) { + Shape::Reg(r) => with_first::(c, Reg(r), b), + Shape::Up(u) => with_first::(c, Up(u), b), + Shape::Kst(v) => with_first::(c, Kst(v), b), + Shape::Exec(e) => with_computed::(c, e, b), + } +} + +fn with_first(c: Site, a: A, b: Operand) -> Code { + match shape(b) { + Shape::Reg(r) => ss::(c, a, Reg(r)), + Shape::Up(u) => ss::(c, a, Up(u)), + Shape::Kst(v) => ss::(c, a, Kst(v)), + Shape::Exec(e) => sx::(c, a, e), + } +} + +fn with_computed(c: Site, a: Exec, b: Operand) -> Code { + match shape(b) { + Shape::Reg(r) => xs::(c, a, Reg(r)), + Shape::Up(u) => xs::(c, a, Up(u)), + Shape::Kst(v) => xs::(c, a, Kst(v)), + Shape::Exec(e) => xx::(c, a, e), + } +} + +fn shape1(c: Site, a: Operand) -> Code { + match shape(a) { + Shape::Reg(r) => s1::(c, Reg(r)), + Shape::Up(u) => s1::(c, Up(u)), + Shape::Kst(v) => s1::(c, Kst(v)), + Shape::Exec(e) => x1::(c, e), + } +} + +/// An n-ary call's computed operands, evaluated in source order +/// into `out` (filled in place: a returned buffer would copy its +/// inline storage per call). +fn computed( + ops: &[Operand], + ev: &mut Evaluator, + f: &mut Frame<'_>, + out: &mut SmallVec<[Value; 4]>, +) -> Result<(), Signal> { + for op in ops { + if let Operand::Exec(e) = op { + out.push(e(ev, f)?); + } + } + Ok(()) +} + +/// Every operand in order: locals and literals in place, computed +/// ones from `comp`. +fn operand_refs<'v>( + ops: &'v [Operand], + f: &'v Frame<'_>, + comp: &'v [Value], +) -> SmallVec<[&'v Value; 6]> { + let mut comp = comp.iter(); + ops.iter() + .map(|op| match op { + Operand::Const(v) => v, + Operand::Loc(loc, _) => f.get(*loc), + Operand::Exec(_) => comp.next().expect("one value per computed operand"), + }) + .collect() +} + +/// A left fold of a binary op (`(+ a b c)` is `(+ (+ a b) c)`, every +/// step checked like the native's). +#[inline(always)] +fn fold(args: &[&Value]) -> Option { + let mut acc = O::apply(args[0], args[1])?; + for a in &args[2..] { + acc = O::apply(&acc, a)?; + } + Some(acc) +} + +/// A comparison chain (`(< a b c)`: every adjacent pair holds; the +/// first failing pair answers, like the native, before any later +/// operand is checked). +#[inline(always)] +fn chain(args: &[&Value]) -> Option { + for pair in args.windows(2) { + if !O::test(pair[0], pair[1])? { + return Some(false); + } + } + Some(true) +} + +/// An n-ary op's code: validated once, computed operands first, +/// then `step` over every operand in place; a miss runs the native +/// over all the values (one call, like the general road). +fn nary(c: Site, ops: Vec, step: fn(&[&Value]) -> Option) -> Code { + Box::new(move |ev, f| { + let valid = ev.site_direct_valid(c.sid, &c.call); + let mut comp: SmallVec<[Value; 4]> = SmallVec::new(); + computed(&ops, ev, f, &mut comp)?; + let args = operand_refs(&ops, f, &comp); + if valid { + if let Some(v) = step(&args) { + return Ok(v); + } + } + let values: SmallVec<[Value; 4]> = args.iter().map(|v| (*v).clone()).collect(); + native(ev, &c, values).map(T::of) + }) +} + +fn fold_n(c: Site, ops: Vec) -> Code { + nary::(c, ops, |args| fold::(args).map(T::of)) +} + +fn chain_n(c: Site, ops: Vec) -> Code { + nary::(c, ops, |args| chain::(args).map(T::of_bool)) +} + +type CtorN = fn(Site, Vec) -> Code; + +/// An op table row: the core name, the value code, and the test +/// code (predicates only) — by operand count. +type RowN = (&'static str, CtorN, Option>); + +/// The ops with a 3+-argument form by core name: folds of their +/// binary op, and comparison chains (value and test code). +const NARY: &[RowN] = &[ + ("+", fold_n::, None), + ("-", fold_n::, None), + ("*", fold_n::, None), + ("min", fold_n::, None), + ("max", fold_n::, None), + ("bit-and", fold_n::, None), + ("bit-or", fold_n::, None), + ("bit-xor", fold_n::, None), + ("<", chain_n::, Some(chain_n::)), + ("<=", chain_n::, Some(chain_n::)), + (">", chain_n::, Some(chain_n::)), + (">=", chain_n::, Some(chain_n::)), + ( + "==", + chain_n::, + Some(chain_n::), + ), +]; + +type Ctor2 = fn(Site, Operand, Operand) -> Code; +type Ctor1 = fn(Site, Operand) -> Code; + +/// See [`RowN`]. +type Row2 = (&'static str, Ctor2, Option>); + +/// See [`RowN`]. +type Row1 = (&'static str, Ctor1, Option>); + +/// The two-argument ops by core name: the value code, and the +/// test code for the predicates. +const BINARY: &[Row2] = &[ + ("+", shape2::, None), + ("-", shape2::, None), + ("*", shape2::, None), + ("<", shape2::, Some(shape2::)), + ("<=", shape2::, Some(shape2::)), + (">", shape2::, Some(shape2::)), + (">=", shape2::, Some(shape2::)), + ( + "==", + shape2::, + Some(shape2::), + ), + ("=", shape2::, Some(shape2::)), + ( + "not=", + shape2::, + Some(shape2::), + ), + ("quot", shape2::, None), + ("rem", shape2::, None), + ("mod", shape2::, None), + ("bit-and", shape2::, None), + ("bit-or", shape2::, None), + ("bit-xor", shape2::, None), + ("bit-shift-left", shape2::, None), + ("bit-shift-right", shape2::, None), + ("unsigned-bit-shift-right", shape2::, None), + ("min", shape2::, None), + ("max", shape2::, None), + ("nth", shape2::, None), + ("get", shape2::, None), + ( + "contains?", + shape2::, + Some(shape2::), + ), + ("conj!", shape2::, None), +]; + +/// The one-argument ops by core name (see [`BINARY`]). +const UNARY: &[Row1] = &[ + ("inc", shape1::, None), + ("-", shape1::, None), + ("dec", shape1::, None), + ( + "zero?", + shape1::, + Some(shape1::), + ), + ("pos?", shape1::, Some(shape1::)), + ("neg?", shape1::, Some(shape1::)), + ( + "even?", + shape1::, + Some(shape1::), + ), + ("odd?", shape1::, Some(shape1::)), + ("not", shape1::, Some(shape1::)), + ( + "nil?", + shape1::, + Some(shape1::), + ), + ( + "some?", + shape1::, + Some(shape1::), + ), + ("count", shape1::, None), + ("int", shape1::, None), + ("long", shape1::, None), + ("double", shape1::, None), +]; + +/// The analyzed native's `clojure.core` name (its display). +pub(crate) fn core_name(call: &DirectCall) -> Option<&str> { + let native = call + .expect + .as_any() + .downcast_ref::()?; + native.display.strip_prefix("clojure.core/") +} + +/// A specialized op's code constructor (by operand count). +#[derive(Clone, Copy)] +pub(crate) enum Ctor { + One(Ctor1), + Two(Ctor2), + N(CtorN), +} + +impl Ctor { + /// The code over the call's operands (one per argument). + pub(crate) fn code(self, site: Arc, ops: Vec) -> Code { + let site = Site::new(site); + let mut ops = ops.into_iter(); + let mut next = || ops.next().expect("one operand per argument"); + match self { + Ctor::One(c) => c(site, next()), + Ctor::Two(c) => { + let a = next(); + c(site, a, next()) + } + Ctor::N(c) => c(site, ops.collect()), + } + } +} + +/// The specialized value code for `call`, if its op has one. Its +/// operands are then read in place, after the computed ones — the +/// last-use analysis must not move those locals. +pub(crate) fn value_ctor(call: &DirectCall) -> Option> { + let name = core_name(call)?; + match call.args.len() { + 1 => UNARY + .iter() + .find(|(n, ..)| *n == name) + .map(|(_, v, _)| Ctor::One(*v)), + 2 => BINARY + .iter() + .find(|(n, ..)| *n == name) + .map(|(_, v, _)| Ctor::Two(*v)), + 3.. => NARY + .iter() + .find(|(n, ..)| *n == name) + .map(|(_, v, _)| Ctor::N(*v)), + _ => None, + } +} + +/// The specialized test code for `call`, if its op is a predicate. +pub(crate) fn test_ctor(call: &DirectCall) -> Option> { + let name = core_name(call)?; + match call.args.len() { + 1 => UNARY.iter().find(|(n, ..)| *n == name)?.2.map(Ctor::One), + 2 => BINARY.iter().find(|(n, ..)| *n == name)?.2.map(Ctor::Two), + 3.. => NARY.iter().find(|(n, ..)| *n == name)?.2.map(Ctor::N), + _ => None, + } +} + +/// Whether `call` compiles to specialized code (see [`value_ctor`]). +pub(crate) fn in_place(call: &DirectCall) -> bool { + value_ctor(call).is_some() +} + +/// An update op's inline answer over its owned operands (the +/// collection first): `None` leaves them in place for the native. +pub(crate) trait Upd: Send + Sync + 'static { + fn apply(args: &mut [Value]) -> Option; +} + +/// Where an update op's collection comes from. +pub(crate) enum Coll { + /// A last-use register: moves out (unique storage updates in + /// place). + Take(usize), + /// A computed form (runs first, in source order). + Exec(Exec), + /// Any other local or literal (cloned). + Read(Operand), +} + +/// An update op (`assoc`, `conj`): a computed collection runs +/// first, then the other operands in order, then a local +/// collection is read — last, so the last-use analysis can move it +/// even when the other operands read it ([`update_order`]). +fn upd(c: Site, coll: Coll, rest: Vec) -> Exec { + Box::new(move |ev, f| { + let valid = ev.site_direct_valid(c.sid, &c.call); + let mut args: SmallVec<[Value; 4]> = SmallVec::new(); + args.push(match &coll { + Coll::Exec(e) => e(ev, f)?, + _ => Value::Nil, + }); + for o in &rest { + args.push(o.eval(ev, f)?); + } + match &coll { + Coll::Take(r) => args[0] = std::mem::replace(&mut f.regs[*r], Value::Nil), + Coll::Read(o) => args[0] = o.eval(ev, f)?, + Coll::Exec(_) => {} + } + if valid { + if let Some(v) = O::apply(&mut args) { + return Ok(v); + } + } + native(ev, &c, args) + }) +} + +/// The update ops by core name and arg count. +/// An update-op table row: core name, arg count, code constructor. +type UpdRow = (&'static str, usize, UpdCtor); + +const UPDATES: &[UpdRow] = &[("assoc", 3, upd::), ("conj", 2, upd::)]; + +/// An update op's code constructor (see [`upd`]). +pub(crate) type UpdCtor = fn(Site, Coll, Vec) -> Exec; + +/// The update op for `call`, if it is one: its collection operand +/// is then read after the others (see [`upd`]). +pub(crate) fn update_ctor(call: &DirectCall) -> Option { + let name = core_name(call)?; + UPDATES + .iter() + .find(|(n, argc, _)| *n == name && *argc == call.args.len()) + .map(|(.., ctor)| *ctor) +} + +/// Whether `call` compiles as an update op (see [`update_ctor`]). +pub(crate) fn update_order(call: &DirectCall) -> bool { + update_ctor(call).is_some() +} + +/// An update op's code over its compiled operands (the collection +/// operand a last-use register moves out). +pub(crate) fn update_code(ctor: UpdCtor, site: Arc, mut ops: Vec) -> Exec { + let rest = ops.split_off(1); + let coll = match ops.pop().expect("the collection operand") { + Operand::Loc(Loc::Reg(r), true) => Coll::Take(r), + Operand::Exec(e) => Coll::Exec(e), + other => Coll::Read(other), + }; + ctor(Site::new(site), coll, rest) +} + +/// Move a value out of its slot (leaving `nil`). +#[inline(always)] +fn take(slot: &mut Value) -> Value { + std::mem::replace(slot, Value::Nil) +} + +/// `(assoc coll k v)`: vectors at an `int` index up to their count, +/// unsorted maps at scalar keys. +pub(crate) struct Assoc; + +impl Upd for Assoc { + #[inline(always)] + fn apply(args: &mut [Value]) -> Option { + let [coll, k, v] = args else { return None }; + match (std::mem::replace(coll, Value::Nil), &*k) { + (Value::Vector(x), Value::Int(i)) => match usize::try_from(*i as i32) { + Ok(n) if n < x.len() => x.set_nth_owned(n, take(v)).map(Value::Vector), + Ok(n) if n == x.len() => Some(Value::Vector(x.push_back(take(v)))), + _ => { + *coll = Value::Vector(x); + None + } + }, + (Value::Map(m), k) if !m.is_sorted() && crate::vm_intrinsics::is_scalar_key(k) => { + Some(Value::Map(m.insert(k.clone(), take(v)))) + } + (other, _) => { + *coll = other; + None + } + } + } +} + +/// `(conj coll x)`: vectors, lists, `nil`, and unsorted sets of +/// scalars. +pub(crate) struct Conj; + +impl Upd for Conj { + #[inline(always)] + fn apply(args: &mut [Value]) -> Option { + let [coll, x] = args else { return None }; + match std::mem::replace(coll, Value::Nil) { + Value::Vector(v) => Some(Value::Vector(v.push_back(take(x)))), + Value::List(l) => Some(Value::List(l.push_front(take(x)))), + Value::Nil => Some(Value::List(clj_value::List::from_vec(vec![take(x)]))), + Value::Set(s) if !s.is_sorted() && crate::vm_intrinsics::is_scalar_key(x) => { + Some(Value::Set(s.insert(take(x)))) + } + other => { + *coll = other; + None + } + } + } +} + +/// `+`/`-`/`*` over numbers the inline path covers: checked `Int` +/// pairs, and `Double` with either side (`Int` widened, like the +/// native's `to_f64`). +#[inline(always)] +fn arith( + a: &Value, + b: &Value, + int: fn(i64, i64) -> Option, + dbl: fn(f64, f64) -> f64, +) -> Option { + match (a, b) { + (Value::Int(x), Value::Int(y)) => int(*x, *y).map(Value::Int), + (Value::Double(x), Value::Double(y)) => Some(Value::Double(dbl(*x, *y))), + (Value::Int(x), Value::Double(y)) => Some(Value::Double(dbl(*x as f64, *y))), + (Value::Double(x), Value::Int(y)) => Some(Value::Double(dbl(*x, *y as f64))), + _ => None, + } +} + +/// A numeric comparison (`Int` pairs exact, any `Double` as `f64`). +#[inline(always)] +fn compare( + a: &Value, + b: &Value, + int: fn(i64, i64) -> bool, + dbl: fn(f64, f64) -> bool, +) -> Option { + match (a, b) { + (Value::Int(x), Value::Int(y)) => Some(int(*x, *y)), + (Value::Double(x), Value::Double(y)) => Some(dbl(*x, *y)), + (Value::Int(x), Value::Double(y)) => Some(dbl(*x as f64, *y)), + (Value::Double(x), Value::Int(y)) => Some(dbl(*x, *y as f64)), + _ => None, + } +} + +/// An `Int`-pair op (`None` for any other pair). +#[inline(always)] +fn ints(a: &Value, b: &Value, op: fn(i64, i64) -> Option) -> Option { + match (a, b) { + (Value::Int(x), Value::Int(y)) => op(*x, *y).map(Value::Int), + _ => None, + } +} + +/// A numeric predicate over `Int` and `Double`. +#[inline(always)] +fn sign(a: &Value, int: fn(i64) -> bool, dbl: fn(f64) -> bool) -> Option { + match a { + Value::Int(x) => Some(int(*x)), + Value::Double(x) => Some(dbl(*x)), + _ => None, + } +} + +macro_rules! op2 { + ($name:ident, |$a:ident, $b:ident| $body:expr) => { + pub(crate) struct $name; + + impl Op2 for $name { + #[inline(always)] + fn apply($a: &Value, $b: &Value) -> Option { + $body + } + } + }; +} + +macro_rules! cmp2 { + ($name:ident, |$a:ident, $b:ident| $body:expr) => { + pub(crate) struct $name; + + impl Op2 for $name { + #[inline(always)] + fn apply($a: &Value, $b: &Value) -> Option { + Self::test($a, $b).map(Value::Bool) + } + + #[inline(always)] + fn test($a: &Value, $b: &Value) -> Option { + $body + } + } + }; +} + +macro_rules! pred1 { + ($name:ident, |$a:ident| $body:expr) => { + pub(crate) struct $name; + + impl Op1 for $name { + #[inline(always)] + fn apply($a: &Value) -> Option { + Self::test($a).map(Value::Bool) + } + + #[inline(always)] + fn test($a: &Value) -> Option { + $body + } + } + }; +} + +op2!(Add, |a, b| arith(a, b, i64::checked_add, |x, y| x + y)); +op2!(Sub, |a, b| arith(a, b, i64::checked_sub, |x, y| x - y)); +op2!(Mul, |a, b| arith(a, b, i64::checked_mul, |x, y| x * y)); +cmp2!(Lt, |a, b| compare(a, b, |x, y| x < y, |x, y| x < y)); +cmp2!(Lte, |a, b| compare(a, b, |x, y| x <= y, |x, y| x <= y)); +cmp2!(Gt, |a, b| compare(a, b, |x, y| x > y, |x, y| x > y)); +cmp2!(Gte, |a, b| compare(a, b, |x, y| x >= y, |x, y| x >= y)); +cmp2!(NumEq, |a, b| compare(a, b, |x, y| x == y, |x, y| x == y)); +cmp2!(Eq, |a, b| crate::vm_intrinsics::scalar_eq(a, b)); +cmp2!(NotEq, |a, b| crate::vm_intrinsics::scalar_eq(a, b) + .map(|e| !e)); +cmp2!(Contains, |a, b| crate::vm_intrinsics::contains_test(a, b)); +// `quot`/`rem` are raw Java `/`/`%` (`MIN/-1` wraps); `mod` adjusts +// to the divisor's sign. A zero divisor runs the native's error. +op2!(Quot, |a, b| ints(a, b, |x, y| (y != 0) + .then(|| x.wrapping_div(y)))); +op2!(Rem, |a, b| ints(a, b, |x, y| (y != 0) + .then(|| x.wrapping_rem(y)))); +op2!(Mod, |a, b| ints(a, b, crate::vm_intrinsics::java_mod)); +op2!(BitAnd, |a, b| ints(a, b, |x, y| Some(x & y))); +op2!(BitOr, |a, b| ints(a, b, |x, y| Some(x | y))); +op2!(BitXor, |a, b| ints(a, b, |x, y| Some(x ^ y))); +// Shift counts mask to 6 bits like Java's (the natives' +// `wrapping_*` over the count's low 32 bits). +op2!(Shl, |a, b| ints(a, b, |x, n| Some( + x.wrapping_shl(n as u32) +))); +op2!(Shr, |a, b| ints(a, b, |x, n| Some( + x.wrapping_shr(n as u32) +))); +op2!(Ushr, |a, b| ints(a, b, |x, n| Some( + (x as u64).wrapping_shr(n as u32) as i64 +))); +op2!(Min, |a, b| ints(a, b, |x, y| Some(x.min(y)))); +op2!(Max, |a, b| ints(a, b, |x, y| Some(x.max(y)))); +op2!(Nth, |a, b| crate::vm_intrinsics::nth_of(a, b, None)); +op2!(Get, |a, b| crate::vm_intrinsics::get_of(a, b, None)); + +pub(crate) struct Inc; + +impl Op1 for Inc { + #[inline(always)] + fn apply(a: &Value) -> Option { + match a { + Value::Int(x) => x.checked_add(1).map(Value::Int), + Value::Double(x) => Some(Value::Double(x + 1.0)), + _ => None, + } + } +} + +/// `(- x)`: a checked `Int` negation (`MIN` runs the native's +/// overflow), or a `Double`'s. +pub(crate) struct Negate; + +impl Op1 for Negate { + #[inline(always)] + fn apply(a: &Value) -> Option { + match a { + Value::Int(x) => x.checked_neg().map(Value::Int), + Value::Double(x) => Some(Value::Double(-x)), + _ => None, + } + } +} + +pub(crate) struct Dec; + +impl Op1 for Dec { + #[inline(always)] + fn apply(a: &Value) -> Option { + match a { + Value::Int(x) => x.checked_sub(1).map(Value::Int), + Value::Double(x) => Some(Value::Double(x - 1.0)), + _ => None, + } + } +} + +pub(crate) struct Count; + +impl Op1 for Count { + #[inline(always)] + fn apply(a: &Value) -> Option { + crate::vm_intrinsics::count_of(a) + } +} + +/// `(int x)`: an `Int` in `int` range or a character (the native +/// keeps longs, checking the range). +pub(crate) struct IntCast; + +impl Op1 for IntCast { + #[inline(always)] + fn apply(a: &Value) -> Option { + match a { + Value::Int(x) if i32::try_from(*x).is_ok() => Some(Value::Int(*x)), + Value::Char(c) => Some(Value::Int(i64::from(u32::from(*c)))), + _ => None, + } + } +} + +/// `(long x)` of an `Int` or a character. +pub(crate) struct LongCast; + +impl Op1 for LongCast { + #[inline(always)] + fn apply(a: &Value) -> Option { + match a { + Value::Int(x) => Some(Value::Int(*x)), + Value::Char(c) => Some(Value::Int(i64::from(u32::from(*c)))), + _ => None, + } + } +} + +/// `(double x)` of an `Int` or a `Double`. +pub(crate) struct DoubleCast; + +impl Op1 for DoubleCast { + #[inline(always)] + fn apply(a: &Value) -> Option { + match a { + Value::Int(x) => Some(Value::Double(*x as f64)), + Value::Double(x) => Some(Value::Double(*x)), + _ => None, + } + } +} + +/// `(conj! tv x)` onto a live transient vector (the cell updates in +/// place; anything else — or a frozen transient — runs the native). +pub(crate) struct ConjBang; + +impl Op2 for ConjBang { + #[inline(always)] + fn apply(a: &Value, b: &Value) -> Option { + let Value::Transient(t) = a else { + return None; + }; + let mut guard = t.lock(); + let clj_value::TransientKind::Vector(tv) = &mut *guard else { + return None; + }; + tv.conj(b.clone()).ok()?; + drop(guard); + Some(a.clone()) + } +} + +pred1!(Zero, |a| sign(a, |x| x == 0, |x| x == 0.0)); +pred1!(Pos, |a| sign(a, |x| x > 0, |x| x > 0.0)); +pred1!(Neg, |a| sign(a, |x| x < 0, |x| x < 0.0)); +pred1!(Even, |a| match a { + Value::Int(x) => Some(x % 2 == 0), + _ => None, +}); +pred1!(Odd, |a| match a { + Value::Int(x) => Some(x % 2 != 0), + _ => None, +}); +pred1!(Not, |a| Some(!a.is_truthy())); +pred1!(IsNil, |a| Some(matches!(a, Value::Nil))); +pred1!(IsSome, |a| Some(!matches!(a, Value::Nil))); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_predicates_answer_their_truth_by_default() { + // Only predicates compile as branch tests (each overrides + // `test`); the defaults still answer a value's truth. + assert_eq!( + ::test(&Value::Int(1), &Value::Int(2)), + Some(true) + ); + assert_eq!(::test(&Value::Nil, &Value::Int(0)), Some(false)); + assert_eq!(::test(&Value::Int(1)), Some(true)); + assert_eq!(::test(&Value::Bool(true)), None); + } +} diff --git a/rust/crates/clj-eval/tests/cellstaps.rs b/rust/crates/clj-eval/tests/cellstaps.rs index 9cdffe2b8d..4e15bca152 100644 --- a/rust/crates/clj-eval/tests/cellstaps.rs +++ b/rust/crates/clj-eval/tests/cellstaps.rs @@ -656,22 +656,15 @@ fn comparator_sort() { #[test] fn comparator_errors() { - // k-arity, k-nonfn-pred. The arity display carries boot-order - // `evalN`/`fn--N` counters, so the shape (not the numbers) is - // pinned: `Wrong number of args (1) passed to: user/evalN/fn--M`. + // k-arity, k-nonfn-pred. The closure names under `comparator` + // like the JVM's `clojure.core/comparator/fn--N`; only the + // counter differs, so the shape is pinned. let mut eval = boot(); let msg = uncaught(&mut eval, "((comparator <) 1)"); - let prefix = "clojure.lang.ArityException | Wrong number of args (1) passed to: user/eval"; - let tail = msg + let prefix = "clojure.lang.ArityException | Wrong number of args (1) passed to: clojure.core/comparator/fn--"; + let fn_n = msg .strip_prefix(prefix) .unwrap_or_else(|| panic!("bad arity shape: {msg}")); - let (eval_n, fn_n) = tail - .split_once("/fn--") - .unwrap_or_else(|| panic!("bad arity shape: {msg}")); - assert!( - !eval_n.is_empty() && eval_n.chars().all(|c| c.is_ascii_digit()), - "bad arity shape: {msg}" - ); assert!( !fn_n.is_empty() && fn_n.chars().all(|c| c.is_ascii_digit()), "bad arity shape: {msg}" diff --git a/rust/crates/clj-eval/tests/classemu.rs b/rust/crates/clj-eval/tests/classemu.rs index 66eca4da15..98463d6a70 100644 --- a/rust/crates/clj-eval/tests/classemu.rs +++ b/rust/crates/clj-eval/tests/classemu.rs @@ -3683,10 +3683,8 @@ fn surface_tostring_divergent() { normboth(&run(&mut eval, "(.. (fn [x] x) (toString))")), "\"#object[user$evalN$fn__N 0xH \\\"user$evalN$fn__N@L\\\"]\"" ); - assert_eq!( - normhex(&run(&mut eval, "(.. *ns* (toString))")), - "\"#object[clojure.lang.Namespace 0xH \\\"user\\\"]\"" - ); + // `Namespace.toString` is the name (no longer divergent). + assert_eq!(run(&mut eval, "(.. *ns* (toString))"), "\"user\""); assert_eq!( run(&mut eval, "(.. (ex-info \"m\" {}) (toString))"), "\"#error {:cause \\\"m\\\" :data {} :via [{:type clojure.lang.ExceptionInfo :message \\\"m\\\" :data {}}] :trace []}\"" diff --git a/rust/crates/clj-eval/tests/compiler.rs b/rust/crates/clj-eval/tests/compiler.rs index faa10c36ac..597924deb6 100644 --- a/rust/crates/clj-eval/tests/compiler.rs +++ b/rust/crates/clj-eval/tests/compiler.rs @@ -814,7 +814,10 @@ fn broken_plan_diagnostic() { uncaught_chain(&mut eval, &fx.within("(load \"/my/rd\")")), rd_chain ); - assert_eq!(run_last(&mut eval, "(loaded-libs)"), "#{my.broken}"); + // The load evaluates `(ns my.rd)` before the later form fails + // to read (form by form, like `Compiler.load`), so it self-marks + // too. + assert_eq!(run_last(&mut eval, "(loaded-libs)"), "#{my.broken my.rd}"); } /// Eval failures reuse the load's exact shape with no artifact diff --git a/rust/crates/clj-eval/tests/jdkio.rs b/rust/crates/clj-eval/tests/jdkio.rs index dc633976c4..7fe18e5f27 100644 --- a/rust/crates/clj-eval/tests/jdkio.rs +++ b/rust/crates/clj-eval/tests/jdkio.rs @@ -909,18 +909,17 @@ fn xml_seq_rows() { "\"A tree seq on the xml elements as per xml/parse\"" ); assert_eq!(run_last(&mut eval, "(-> #'xml-seq meta :added)"), "\"1.0\""); - // `g-xml-arity*` (prefix-pinned: `core.clj` closures name - // the eval site — the S3/S4a convention). + // `g-xml-arity*`: `core.clj` defns name their var, like the JVM. for (src, count) in [ ("(xml-seq)".to_owned(), 0), (format!("(xml-seq {tree} {tree})"), 2), ] { - let got = uncaught(&mut eval, &src); - assert!( - got.starts_with(&format!( - "clojure.lang.ArityException | Wrong number of args ({count}) passed to: user/" - )) && got.contains("/fn--"), - "for {src}: {got}" + assert_eq!( + uncaught(&mut eval, &src), + format!( + "clojure.lang.ArityException | Wrong number of args ({count}) passed to: clojure.core/xml-seq" + ), + "for {src}" ); } } diff --git a/rust/crates/clj-eval/tests/jvm_parity_226.rs b/rust/crates/clj-eval/tests/jvm_parity_226.rs new file mode 100644 index 0000000000..6435d296f8 --- /dev/null +++ b/rust/crates/clj-eval/tests/jvm_parity_226.rs @@ -0,0 +1,246 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! #226 JVM-parity fixes: `try` clauses built by macros (seq forms), +//! form-by-form loading (`::kw` after `ns`, `load-reader`), +//! namespace and `StringBuilder` display, JVM fn display names, +//! `clojure.string/split` argument checks, evaluated `def` +//! metadata, and `deftype`/`defrecord` factories. Every expected +//! value is the JVM's (Clojure 1.12 `clojure.main`; `evalN`/`--N` +//! stand for the JVM's own counters). + +use clj_eval::{Evaluator, boot}; +use clj_value::{PrintOpts, pr_to_string}; + +/// `pr-str` of the form's value, or `Class: message` when it throws, +/// with fn-name counters normalized (`eval12` → `evalN`, `--7` → `--N`). +fn show(eval: &mut Evaluator, form: &str) -> String { + let src = format!( + "(try (pr-str {form}) (catch Throwable t (str (.getName (class t)) \": \" (.getMessage t))))" + ); + let vs = eval + .eval_source(&src) + .unwrap_or_else(|e| panic!("{form}: {e}")); + let s = pr_to_string(vs.last().unwrap(), &PrintOpts::print()); + normalize(&s) +} + +fn normalize(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find(|c: char| c.is_ascii_digit()) { + let (head, tail) = rest.split_at(i); + let digits = tail + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(tail.len()); + out.push_str(head); + if head.ends_with("--") || head.ends_with("eval") { + out.push('N'); + } else { + out.push_str(&tail[..digits]); + } + rest = &tail[digits..]; + } + out.push_str(rest); + out +} + +const SETUP: &str = r#"(defmacro safely [& body] `(try ~@body (catch Exception e# (str "caught " (.getMessage e#))))) +(defmacro safely2 [& body] (list* 'try (concat body [(cons 'catch '(Exception e (str "c2 " (.getMessage e))))]))) +(defmacro safely3 [& body] (concat (list 'try) body (list (concat '(catch Exception e) '((str "c3 " (.getMessage e))))))) +(def fin-log (atom nil)) +(defmacro fin [& body] `(try ~@body (finally (reset! fin-log :ran)))) +(defmacro body-seq [] (list 'try (cons 'inc '(1)) (cons 'catch '(Exception e :c)))) +(load-string "(ns parity.kw) (def k ::kw) (require '[clojure.set :as cs]) (def j ::cs/x) (in-ns 'user)") +(defn bar [x] x) +(def f (fn [x] x)) +(def h11 #(inc %)) +(defn- priv [x] x) +(defn multi ([x] x) ([x y] y)) +(defn outer [] (fn [x] x)) +(defn outer2 [] (fn inner [x] x)) +(defn outer3 [] (let [g (fn [x] x)] g)) +(defn outer19 [] (letfn [(lf19 [x] x)] lf19)) +(def ^:dynamic *d23* (fn [x] x)) +(def x25 (fn* [x] x)) +(defn outer22 [] #(inc %)) +(defrecord Pt [x y]) +(deftype Ty [a]) +(def ^String x1 "a") +(def ^long x2 1) +(def ^{:tag 'long} x3 1) +(def ^{:a [1 (inc 1)] :b #{(inc 2)} :c {:d (inc 3)} :e '(1)} x5 1) +(def ^{:k :v} x7 1) +(def ^{:b #{(inc 2)}} x10 1) +(def ^{:c {:d (inc 3)}} x11 1) +(def ^{:k #{:v} :m {:n [1]}} x12 1) +(defmacro mk8 [] (list 'def (with-meta 'x8 {:q (concat '(+) '(1 2))}) 1)) +(mk8) +(def order (atom [])) +(def ^{:m (swap! order conj :meta)} x9 (swap! order conj :init)) +(with-in-str "(def rl (read-line)) :after\n42" (load-reader *in*)) +"#; + +const CASES: &[(&str, &str)] = &[ + ( + r#"(safely (throw (Exception. "boom")))"#, + r#""caught boom""#, + ), + (r#"(safely2 (throw (Exception. "boom2")))"#, r#""c2 boom2""#), + (r#"(safely3 (throw (Exception. "boom3")))"#, r#""c3 boom3""#), + (r#"(do (fin 1) @fin-log)"#, r#":ran"#), + (r#"(body-seq)"#, r#"2"#), + ( + r#"[parity.kw/k parity.kw/j]"#, + r#"[:parity.kw/kw :clojure.set/x]"#, + ), + ( + r#"[(str *ns*) (format "%s|%s" *ns* (find-ns (quote clojure.core))) (.toString *ns*) (str "ns:" *ns*)]"#, + r#"["user" "user|clojure.core" "user" "ns:user"]"#, + ), + ( + r##"(boolean (re-matches #"#object\[clojure.lang.Namespace 0x[0-9a-f]+ user\]" (print-str *ns*)))"##, + r#"true"#, + ), + ( + r##"(boolean (re-matches #"#object\[clojure.lang.Namespace 0x[0-9a-f]+ \"user\"\]" (pr-str *ns*)))"##, + r#"true"#, + ), + ( + r##"(let [sb (StringBuilder. "a \"b")] [(str sb) (boolean (re-matches #"#object\[java.lang.StringBuilder 0x[0-9a-f]+ \"a \\\"b\"\]" (pr-str sb))) (boolean (re-matches #"#object\[java.lang.StringBuilder 0x[0-9a-f]+ a \"b\]" (print-str sb)))])"##, + r#"["a \"b" true true]"#, + ), + ( + r#"(bar)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/bar"#, + ), + ( + r#"(f)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/f"#, + ), + ( + r#"(h11)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/h11"#, + ), + ( + r#"(priv)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/priv"#, + ), + ( + r#"(multi)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/multi"#, + ), + ( + r#"((outer))"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/outer/fn--N"#, + ), + ( + r#"((outer2))"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/outer2/inner--N"#, + ), + ( + r#"((outer3))"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/outer3/g--N"#, + ), + ( + r#"((outer19))"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/outer19/lf19--N"#, + ), + ( + r#"(*d23*)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/*d23*"#, + ), + ( + r#"(x25)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/x25"#, + ), + ( + r#"((outer22))"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/outer22/fn--N"#, + ), + ( + r#"((comparator <) 1)"#, + r#"clojure.lang.ArityException: Wrong number of args (1) passed to: clojure.core/comparator/fn--N"#, + ), + ( + r#"(->Pt 1)"#, + r#"clojure.lang.ArityException: Wrong number of args (1) passed to: user/evalN/->Pt--N"#, + ), + ( + r#"(map->Pt)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/evalN/map->Pt--N"#, + ), + ( + r#"(->Ty)"#, + r#"clojure.lang.ArityException: Wrong number of args (0) passed to: user/evalN/->Ty--N"#, + ), + (r#"(.-a (->Ty 5))"#, r#"5"#), + ( + r#"(:doc (meta #'->Ty))"#, + r#""Positional factory function for class user.Ty.""#, + ), + (r#"(:arglists (meta #'->Ty))"#, r#"([a])"#), + ( + r#"(:doc (meta #'map->Pt))"#, + r#""Factory function for class user.Pt, taking a map of keywords to field values.""#, + ), + (r#"(:arglists (meta #'->Pt))"#, r#"([x y])"#), + ( + r#"(str/split "a b" nil)"#, + r#"java.lang.NullPointerException: Cannot invoke "java.util.regex.Pattern.split(java.lang.CharSequence)" because "re" is null"#, + ), + ( + r#"(str/split "a b" 1)"#, + r#"java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.util.regex.Pattern (java.lang.Long and java.util.regex.Pattern are in module java.base of loader 'bootstrap')"#, + ), + ( + r#"(str/split "a b" #" " "x")"#, + r#"java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Number (java.lang.String and java.lang.Number are in module java.base of loader 'bootstrap')"#, + ), + ( + r#"(str/split "a b c" #" " nil)"#, + r#"java.lang.NullPointerException: Cannot invoke "java.lang.Character.charValue()" because "x" is null"#, + ), + ( + r#"(str/split 1 nil)"#, + r#"java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.CharSequence (java.lang.Long and java.lang.CharSequence are in module java.base of loader 'bootstrap')"#, + ), + (r#"(:tag (meta #'x1))"#, r#"java.lang.String"#), + (r#"(fn? (:tag (meta #'x2)))"#, r#"true"#), + (r#"(:tag (meta #'x3))"#, r#"long"#), + ( + r#"((juxt :a :b :c :e) (meta #'x5))"#, + r#"[[1 2] #{3} {:d 4} (1)]"#, + ), + (r#"(:k (meta #'x7))"#, r#":v"#), + (r#"(:b (meta #'x10))"#, r#"#{3}"#), + (r#"(:c (meta #'x11))"#, r#"{:d 4}"#), + (r#"((juxt :k :m) (meta #'x12))"#, r#"[#{:v} {:n [1]}]"#), + (r#"(:q (meta #'x8))"#, r#"3"#), + (r#"@order"#, r#"[:init :meta]"#), + (r#"rl"#, r#"":after""#), + ( + r#"(with-in-str "(ns lr.x) (def y ::k) (in-ns 'user) 42" [(load-reader *in*) (str *ns*)])"#, + r#"[42 "user"]"#, + ), + (r#"lr.x/y"#, r#":lr.x/k"#), +]; + +#[test] +fn jvm_parity_rows() { + let mut eval = boot(); + // `clojure.main` runs scripts inside a `*warn-on-reflection*` frame. + eval.eval_source( + "(binding [*warn-on-reflection* *warn-on-reflection*] (require '[clojure.string :as str]))", + ) + .unwrap(); + eval.eval_source(SETUP) + .unwrap_or_else(|e| panic!("setup: {e}")); + for (form, want) in CASES { + assert_eq!(&show(&mut eval, form), want, "{form}"); + } +} diff --git a/rust/crates/clj-eval/tests/lazy_core.rs b/rust/crates/clj-eval/tests/lazy_core.rs new file mode 100644 index 0000000000..4e111b12d6 --- /dev/null +++ b/rust/crates/clj-eval/tests/lazy_core.rs @@ -0,0 +1,463 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! #226 startup work: lazily loaded `core.clj`/`clojure.string`, the +//! native `clojure.string` fast paths (and their interpreted upstream +//! fallbacks), the native `core.clj` macro expanders, shared refer +//! tables, and the compiled-regex cache. Expected values are the +//! JVM's (or, where the port already diverged before #226, the +//! port's prior output, noted per case). + +use clj_eval::{Evaluator, boot}; +use clj_value::{PrintOpts, pr_to_string}; + +/// A booted evaluator with `clojure.string` aliased as `str` (inside +/// the `*warn-on-reflection*` frame `clojure.main` provides). +fn booted() -> Evaluator { + let mut eval = boot(); + eval.eval_source( + "(binding [*warn-on-reflection* *warn-on-reflection*] (require '[clojure.string :as str]))", + ) + .unwrap(); + eval +} + +/// `pr-str` of the form's value, or `Class: message` when it throws. +fn show(eval: &mut Evaluator, form: &str) -> String { + let src = format!( + "(try (pr-str {form}) (catch Throwable t (str (.getName (class t)) \": \" (.getMessage t))))" + ); + let vs = eval + .eval_source(&src) + .unwrap_or_else(|e| panic!("{form}: {e}")); + let s = pr_to_string(vs.last().unwrap(), &PrintOpts::print()); + s.to_owned() +} + +fn check(eval: &mut Evaluator, cases: &[(&str, &str)]) { + for (form, want) in cases { + assert_eq!(&show(eval, form), want, "{form}"); + } +} + +#[test] +fn string_natives_answer_strings_and_regexes() { + let mut eval = booted(); + check( + &mut eval, + &[ + (r#"(str/split "a,b,,c,," #",")"#, r#"["a" "b" "" "c"]"#), + (r#"(str/split "a,b,,c,," #"," 2)"#, r#"["a" "b,,c,,"]"#), + ( + r#"(str/split "a,b,,c,," #"," -1)"#, + r#"["a" "b" "" "c" "" ""]"#, + ), + (r#"(str/split-lines "a\nb\r\nc\n\n")"#, r#"["a" "b" "c"]"#), + (r#"(str/join ", " [1 nil :k "s" \c])"#, r#""1, , :k, s, c""#), + (r#"(str/join \, [1 2])"#, r#""1,2""#), + (r#"(str/join nil [1 2])"#, r#""12""#), + (r#"(str/join ", " "abc")"#, r#""a, b, c""#), + (r#"(str/join ", " nil)"#, r#""""#), + ( + r#"(str/join "" (range 40))"#, + r#""0123456789101112131415161718192021222324252627282930313233343536373839""#, + ), + (r#"(str/join ", " (map inc (range 3)))"#, r#""1, 2, 3""#), + (r#"(str/trim " a b \t\n")"#, r#""a b""#), + (r#"(str/trim " x ")"#, r#""x""#), + ("(str/blank? nil)", "true"), + ("(str/blank? false)", "true"), + (r#"(str/blank? "")"#, "true"), + (r#"(str/blank? " \t\n\u000b\u001c")"#, "true"), + (r#"(str/blank? " ")"#, "false"), + ], + ); +} + +#[test] +fn string_natives_hand_other_arguments_to_upstream() { + // `split` checks its arguments like the compiled `string.clj` + // (JVM messages); outside the other fast paths the interpreted + // definition answers (the results the port gave before #226). + let mut eval = booted(); + check( + &mut eval, + &[ + ( + r#"(str/split "a,b" #"," 9999999999)"#, + "java.lang.ArithmeticException: integer overflow", + ), + ( + r#"(str/split nil #" ")"#, + "java.lang.NullPointerException: Cannot invoke \"java.lang.CharSequence.length()\" because \"this.text\" is null", + ), + ( + r#"(str/split "a b" " ")"#, + "java.lang.ClassCastException: class java.lang.String cannot be cast to class java.util.regex.Pattern (java.lang.String and java.util.regex.Pattern are in module java.base of loader 'bootstrap')", + ), + ( + r#"(str/split "a b" nil 2)"#, + "java.lang.NullPointerException: Cannot invoke \"java.util.regex.Pattern.split(java.lang.CharSequence, int)\" because \"re\" is null", + ), + ( + r#"(str/split :k #" ")"#, + "java.lang.ClassCastException: class clojure.lang.Keyword cannot be cast to class java.lang.CharSequence (clojure.lang.Keyword is in unnamed module of loader 'app'; java.lang.CharSequence is in module java.base of loader 'bootstrap')", + ), + (r#"(str/split "a b c" #" " 2.7)"#, r#"["a" "b c"]"#), + (r#"(str/split (StringBuilder. "x y") #" ")"#, r#"["x" "y"]"#), + ( + "(str/split-lines nil)", + "java.lang.NullPointerException: Cannot invoke \"java.lang.CharSequence.length()\" because \"this.text\" is null", + ), + ("(str/join [1 2 3])", r#""123""#), + (r#"(str/join :sep [1 2])"#, r#""1:sep2""#), + ( + r#"(str/join ", " 5)"#, + "java.lang.IllegalArgumentException: Don't know how to create ISeq from: java.lang.Long", + ), + ( + "(str/trim nil)", + "java.lang.NullPointerException: Cannot invoke \"Object.getClass()\" because \"target\" is null", + ), + ( + "(str/blank? 5)", + "java.lang.IllegalArgumentException: No matching field found: length for class java.lang.Long", + ), + ], + ); + // Arity errors come from the upstream fn, named like the JVM's. + for (form, want) in [ + (r#"(str/split "a")"#, "(1) passed to: clojure.string/split"), + ( + r#"(str/split "a" #"," 0 1)"#, + "(4) passed to: clojure.string/split", + ), + ("(str/join)", "(0) passed to: clojure.string/join"), + ] { + assert_eq!( + show(&mut eval, form), + format!("clojure.lang.ArityException: Wrong number of args {want}"), + "{form}" + ); + } + // The fallback is cached: a second slow call reuses it. + assert_eq!(show(&mut eval, "(str/join [4 5])"), r#""45""#); +} + +#[test] +fn comprehension_modifiers_expand_like_core_clj() { + let mut eval = booted(); + check( + &mut eval, + &[ + ("(for [] 1)", "(1)"), + ( + "(for [x (range 10) :let [y (* x x)] :when (odd? x) :while (< y 50)] [x y])", + "([1 1] [3 9] [5 25] [7 49])", + ), + ( + "(for [x (range 10) :while (< x 6) :let [y (inc x)] :when (even? y)] y)", + "(2 4 6)", + ), + ("(for [x (range 6) :when (even? x)] x)", "(0 2 4)"), + ( + "(for [x (range 3) :let [y (* 2 x) z (inc y)]] [y z])", + "([0 1] [2 3] [4 5])", + ), + ("(for [x (range 3) :let nil] x)", "(0 1 2)"), + ( + "(for [x (range 4) :when (odd? x) y (range x) :let [z (+ x y)] :when (even? z)] [x y z])", + "([3 1 4])", + ), + ( + "(for [x (range 4) y (range 4) :while (< y x)] [x y])", + "([1 0] [2 0] [2 1] [3 0] [3 1] [3 2])", + ), + ( + "(for [x (range 3) :let [a x] :when true y [a]] y)", + "(0 1 2)", + ), + ( + "(with-out-str (doseq [x (range 6) :let [y (* x 10)] :when (odd? x) :while (< x 5)] (print y \\space)))", + r#""10 30 ""#, + ), + ( + "(with-out-str (doseq [x (range 6) :when (odd? x)] (print x)))", + r#""135""#, + ), + ( + "(with-out-str (doseq [x (range 3) y (range x)] (print [x y])))", + r#""[1 0][2 0][2 1]""#, + ), + ("(with-out-str (doseq [] (print 1) (print 2)))", r#""12""#), + ], + ); +} + +#[test] +fn comprehension_errors_match_core_clj() { + let mut eval = booted(); + for (form, want) in [ + ("(for [:when true] 1)", "Can't pop empty vector"), + ("(for [x [1] :bogus 1] x)", "Invalid 'for' keyword :bogus"), + ("(doseq [x [1] :bogus 1] x)", "\"steppair\" is null"), + ( + "(for [x [1] :let [y 1] :bogus 2] x)", + "Invalid 'for' keyword :bogus", + ), + ( + "(for [x [1]] 1 2)", + "Wrong number of args (3) passed to: clojure.core/for", + ), + ("(for [x [1] :let (list y 1)] y)", "did not conform to spec"), + ] { + let err = eval.eval_source(form).expect_err(form).to_string(); + assert!(err.contains(want), "{form}: {err}"); + } +} + +#[test] +fn defn_and_simple_macros_expand_like_core_clj() { + let mut eval = booted(); + eval.eval_source( + "(defn f1 \"doc\" {:a 1} ([x] x) {:b 2}) + (defn f2 [x] {:pre [(pos? x)] :post [(pos? %)]} x) + (defn f3 ([] 0) ([x] {:pre [x]} x) ([x & more] (count more))) + (defn- f4 [] 4)", + ) + .unwrap(); + check( + &mut eval, + &[ + ( + "(select-keys (meta #'f1) [:a :b :doc :arglists])", + r#"{:a 1, :b 2, :doc "doc", :arglists ([x])}"#, + ), + ("(:arglists (meta #'f2))", "([x])"), + ("(f2 3)", "3"), + ( + "[(f3) (f3 1) (f3 1 2 3) (:arglists (meta #'f3))]", + "[0 1 2 ([] [x] [x & more])]", + ), + ("[(f4) (:private (meta #'f4))]", "[4 true]"), + ("(if-not false 1 2)", "1"), + ("(if-not true 1)", "nil"), + ("[(and) (or) (cond)]", "[true nil nil]"), + ("(-> 1 inc (* 2) (- 1))", "3"), + ("(->> (range 5) (map inc) (reduce +))", "15"), + (r#"(.. "abc" (toUpperCase) (substring 1))"#, r#""BC""#), + ("(comment 1 2 3)", "nil"), + ("(let [a (atom 0)] (while (< @a 5) (swap! a inc)) @a)", "5"), + ("(when-not false 1 2)", "2"), + ], + ); + for (form, want) in [ + ( + "(if-not)", + "Wrong number of args (0) passed to: clojure.core/if-not", + ), + ( + "(defn- 5 [] 1)", + "cannot be cast to class clojure.lang.IObj", + ), + ] { + let err = eval.eval_source(form).expect_err(form).to_string(); + assert!(err.contains(want), "{form}: {err}"); + } +} + +#[test] +fn lazy_core_vars_force_on_first_use() { + let mut eval = booted(); + // A var invoked through `#'` resolves at run time, not analysis. + check( + &mut eval, + &[ + ("(#'clojure.core/println-str 1 2)", "\"1 2\\n\""), + ("(:arglists (meta #'clojure.core/rand-nth))", "([coll])"), + ("(bound? #'clojure.core/prn-str)", "true"), + ], + ); +} + +#[test] +fn reloading_clojure_string_past_the_lazy_segments_loads_eagerly() { + // Each lazy library load claims an index segment; once they run + // out, reloads evaluate the file eagerly — same vars either way. + let mut eval = booted(); + for i in 0..10 { + let got = show( + &mut eval, + "(do (binding [*warn-on-reflection* *warn-on-reflection*] + (require 'clojure.string :reload)) + [(clojure.string/join \"-\" [1 2]) (clojure.string/upper-case \"x\")])", + ); + assert_eq!(got, r#"["1-2" "X"]"#, "reload {i}"); + } +} + +#[test] +fn refer_shares_the_publics_table_and_copies_on_change() { + let mut eval = booted(); + check( + &mut eval, + &[ + ( + "(do (ns r1) (clojure.core/refer 'clojure.core :exclude '[a/b 5 first]) + (clojure.core/in-ns 'user) + [(contains? (ns-refers 'r1) 'first) (contains? (ns-refers 'r1) 'map)])", + "[true true]", + ), + ( + "(do (create-ns 'r2) (binding [*ns* (the-ns 'r2)] (clojure.core/refer 'clojure.core :exclude '[map])) + [(contains? (ns-refers 'r2) 'map) (contains? (ns-refers 'r2) 'filter)])", + "[false true]", + ), + ( + "(do (create-ns 'r3) (intern 'r3 'filter 1) + (binding [*ns* (the-ns 'r3)] (clojure.core/refer 'clojure.core)) + [(contains? (ns-refers 'r3) 'filter) (contains? (ns-refers 'r3) 'map)])", + "[false true]", + ), + ], + ); +} + +#[test] +fn regex_cache_stays_bounded() { + // Past 256 distinct patterns the per-thread cache clears and keeps + // compiling correctly. + let mut eval = booted(); + let got = show( + &mut eval, + "(count (filter identity (for [i (range 300)] (re-matches (re-pattern (str \"a\" i)) (str \"a\" i)))))", + ); + assert_eq!(got, "300"); +} + +#[test] +fn captured_string_natives_survive_redefinition_and_removal() { + // A captured native's slow path still runs the original upstream + // definition after the var is redefined (its pending metadata + // source was dropped by the redefinition) or its namespace removed + // (the tombstone keeps the var); an unmapped name has none left. + let mut eval = booted(); + eval.eval_source( + "(def sp clojure.string/split) (def jn clojure.string/join) (def bl clojure.string/blank?) + (in-ns 'clojure.string) + (clojure.core/defn split [s re] :redefined) + (clojure.core/in-ns 'user)", + ) + .unwrap(); + check( + &mut eval, + &[ + (r#"(clojure.string/split "a b" #" ")"#, ":redefined"), + (r#"(sp "a b" #" ")"#, r#"["a" "b"]"#), + (r#"(sp (StringBuilder. "a b") #" ")"#, r#"["a" "b"]"#), + // A shape outside the native (an arity miss) runs the + // file's original definition, not the redefinition. + ( + r#"(sp "a")"#, + "clojure.lang.ArityException: Wrong number of args (1) passed to: clojure.string/split", + ), + ], + ); + eval.eval_source("(remove-ns 'clojure.string)").unwrap(); + check(&mut eval, &[(r#"(jn :sep [1 2])"#, r#""1:sep2""#)]); + let mut eval = booted(); + eval.eval_source("(def bl clojure.string/blank?) (ns-unmap 'clojure.string 'blank?)") + .unwrap(); + check( + &mut eval, + &[( + "(bl 5)", + "java.lang.IllegalStateException: No definition behind clojure.string/blank?", + )], + ); +} + +#[test] +fn macro_expanders_take_seq_forms_and_odd_signatures() { + let mut eval = booted(); + eval.eval_source( + "(defmacro thr [] `(-> 1 ~(cons 'inc ()))) + (defmacro thr2 [] `(->> 1 ~(cons 'list (cons 0 ())))) + (defn ff [&form &env x] x)", + ) + .unwrap(); + check( + &mut eval, + &[ + ("(thr)", "2"), + ("(thr2)", "(0 1)"), + ("[(:arglists (meta #'ff)) (ff 1 2 3)]", "[([x]) 3]"), + (r#"(str/join "," '(1 2 3))"#, r#""1,2,3""#), + ], + ); + for (form, want) in [ + ( + "(if-not 1 2 3 4)", + "Wrong number of args (4) passed to: clojure.core/if-not", + ), + ( + "(for [x [1] :when true :bogus 1] x)", + "Invalid 'for' keyword :bogus", + ), + // The JVM's spec rejects this signature first; without spec, + // `core.clj`'s `defn` fails taking the params' seq. + ( + "(defn fg ([x] x) [y])", + "Don't know how to create ISeq from: clojure.lang.Symbol", + ), + ] { + let err = eval.eval_source(form).expect_err(form).to_string(); + assert!(err.contains(want), "{form}: {err}"); + } +} + +#[test] +fn an_old_var_forced_after_a_reload_keeps_its_native() { + // The old var's metadata source now defines into the recreated + // namespace, so the old root stays the native — which must never + // become its own slow path. + let mut eval = booted(); + check( + &mut eval, + &[( + "(do (def v #'clojure.string/split) + (remove-ns 'clojure.string) + (binding [*warn-on-reflection* *warn-on-reflection*] + (require 'clojure.string :reload)) + [(:name (meta v)) (v \"a b\" #\" \") (clojure.string/split \"c d\" #\" \")])", + r#"[split ["a" "b"] ["c" "d"]]"#, + )], + ); +} + +#[test] +fn natives_boot_no_longer_reaches_still_answer() { + // Paths only eager `core.clj` evaluation used to exercise: the + // empty-arg arities, short non-ASCII `nth`, and the port-internal + // NPE helper behind `core.clj`'s (now metadata-only) `doseq`. + let mut eval = booted(); + check( + &mut eval, + &[ + ("(*)", "1"), + ( + "(=)", + "clojure.lang.ArityException: Wrong number of args (0) passed to: clojure.core/=", + ), + (r#"(nth "héllo" 1)"#, r"\é"), + ( + r#"(#'clojure.core/throw-npe! "steppair")"#, + "java.lang.NullPointerException: steppair", + ), + ], + ); +} diff --git a/rust/crates/clj-eval/tests/stm.rs b/rust/crates/clj-eval/tests/stm.rs index 2e8c383fb6..e274cf2685 100644 --- a/rust/crates/clj-eval/tests/stm.rs +++ b/rust/crates/clj-eval/tests/stm.rs @@ -210,8 +210,28 @@ fn arity_error(name: &str, argc: usize) -> String { ) } +/// History-count pins need no other transaction in flight: a +/// concurrent attempt's read point pins the oldest entry against +/// trimming (`trim_history`'s sole-pinned rule), and the test +/// harness runs this binary's tests on parallel threads. The pinning +/// tests take the gate exclusively; every other test shares it. +static TXN_GATE: std::sync::RwLock<()> = std::sync::RwLock::new(()); + +fn shared_gate() -> std::sync::RwLockReadGuard<'static, ()> { + TXN_GATE + .read() + .expect("an STM test panicked holding the gate") +} + +fn exclusive_gate() -> std::sync::RwLockWriteGuard<'static, ()> { + TXN_GATE + .write() + .expect("an STM test panicked holding the gate") +} + #[test] fn ref_ctor_opts() { + let _gate = shared_gate(); let mut eval = boot(); // r-odd-opts (shared `atom_options` shape). assert_eq!( @@ -319,6 +339,7 @@ fn ref_ctor_opts() { #[test] fn invoke_and_predicates() { + let _gate = shared_gate(); let mut eval = boot(); // r-deref, r-deref-reader. assert_eq!(run_last(&mut eval, "(deref (ref 5))"), "5"); @@ -375,6 +396,7 @@ fn invoke_and_predicates() { #[test] fn atomic_commit() { + let _gate = shared_gate(); let mut eval = boot_stm(); // t-body-value, t-empty. assert_eq!(run_last(&mut eval, "(dosync 1 2)"), "2"); @@ -431,6 +453,7 @@ fn atomic_commit() { #[test] fn commute_compose() { + let _gate = shared_gate(); let mut eval = boot(); // t-alter-returns, t-refset-returns, t-commute-returns. assert_eq!(run_last(&mut eval, "(dosync (alter (ref 0) + 1 2))"), "3"); @@ -519,6 +542,7 @@ fn commute_compose() { #[test] fn nesting_join() { + let _gate = shared_gate(); let mut eval = boot(); // t-nested (inner joins: one commit). assert_eq!( @@ -548,6 +572,7 @@ fn nesting_join() { #[test] fn validator_watch_timing() { + let _gate = shared_gate(); let mut eval = boot_stm(); // t-validator-calls (construction over the initial value, then // once at commit over the final — intermediates never). @@ -750,6 +775,7 @@ fn validator_watch_timing() { /// re-runs execute before the lock phase). #[test] fn validator_may_read_ensured_ref() { + let _gate = shared_gate(); let mut eval = boot_stm(); run_last(&mut eval, "(def r1 (ref 1)) (def r2 (ref 2))"); run_last(&mut eval, "(set-validator! r2 (fn [x] (deref r1) true))"); @@ -765,6 +791,7 @@ fn validator_may_read_ensured_ref() { /// watches fire before sends, in the same terminal step). #[test] fn watch_throw_drops_outbox() { + let _gate = shared_gate(); let mut eval = boot_stm(); assert_eq!( run_last( @@ -777,6 +804,7 @@ fn watch_throw_drops_outbox() { #[test] fn send_deferral() { + let _gate = shared_gate(); let mut eval = boot_stm(); // t-send-defer (the action observes the committed value). assert_eq!( @@ -857,6 +885,7 @@ fn send_deferral() { #[test] fn installed_field_send_defers_through_txn() { + let _gate = shared_gate(); // Gap-96: an in-txn `send` captures the installed field at // send time (like the JVM's per-`Action` executor). The fixed // pools shut first, so only the fresh custom can run the @@ -882,6 +911,7 @@ fn installed_field_send_defers_through_txn() { #[test] fn await_gates() { + let _gate = shared_gate(); let mut eval = boot(); // t-await-noargs-in, t-await-noargs-out (the gate fires even with // zero agents; outside, the zero-agent call is nil). @@ -919,6 +949,7 @@ fn await_gates() { #[test] fn io_gate() { + let _gate = shared_gate(); let mut eval = boot(); // t-io-in, t-io-out, t-io-multi, t-io-empty. assert_eq!( @@ -948,6 +979,7 @@ fn io_gate() { #[test] fn macro_shapes() { + let _gate = shared_gate(); let mut eval = boot(); // t-shape-dosync (byte-identical to the JVM expansion). assert_eq!( @@ -987,6 +1019,7 @@ fn macro_shapes() { #[test] fn boundfn_conveyance() { + let _gate = shared_gate(); let mut eval = boot(); // t-bound-fn (same-thread calls observe the transaction). assert_eq!( @@ -1008,6 +1041,7 @@ fn boundfn_conveyance() { #[test] fn outside_tx_gate() { + let _gate = exclusive_gate(); let mut eval = boot(); // e-alter-outside, e-refset-outside, e-commute-outside, // e-ensure-outside. @@ -1153,6 +1187,7 @@ fn outside_tx_gate() { #[test] fn print_and_str() { + let _gate = shared_gate(); let mut eval = boot(); // Divergence (r-new, r-new-nil, r-new-coll): the JVM renders // `{:status :ready, :val …}`; the port prints every transient @@ -1191,6 +1226,7 @@ fn print_and_str() { #[test] fn history_trim() { + let _gate = exclusive_gate(); let mut eval = boot(); // r-history-init, r-history-after, r-history-after2 (defaults // trim to zero). @@ -1243,6 +1279,7 @@ fn history_trim() { #[test] fn history_accessors() { + let _gate = exclusive_gate(); let mut eval = boot(); // e-min-nonref, e-max-nonref, e-count-nonref, e-count-nil, // e-min-get-nil, e-max-get-nil. @@ -1408,6 +1445,7 @@ fn history_accessors() { #[test] fn stm_portside_pins() { + let _gate = exclusive_gate(); let mut eval = boot_stm(); // A send to a failed agent throws EAGERLY in-txn (the body // aborts before later forms run — oracle `u-send-failed-pos`). @@ -1761,6 +1799,7 @@ fn stm_portside_pins() { #[test] fn stm_retry_under_contention() { + let _gate = shared_gate(); // A genuine conflict retry, deterministically: the reader opens // (fixing its start point), a writer commits twice past // `max-history 0` (force-trimming the reader's version), the @@ -1778,6 +1817,7 @@ fn stm_retry_under_contention() { #[test] fn stm_revalidate_retry() { + let _gate = shared_gate(); // A genuine revalidation retry, deterministically: the reader // records version 0, a writer commits past it (`min-history 1` // keeps the version resolvable, so the attempt reaches commit @@ -1795,6 +1835,7 @@ fn stm_revalidate_retry() { #[test] fn seam_suites_green() { + let _gate = shared_gate(); // Agent/future/atom smoke through the §1f seam: outside a // transaction every send path is byte-identical (these rows // mirror the `concurrency.rs` suite's core shapes). diff --git a/rust/crates/clj-eval/tests/vm_engine.rs b/rust/crates/clj-eval/tests/vm_engine.rs new file mode 100644 index 0000000000..aef1a5b733 --- /dev/null +++ b/rust/crates/clj-eval/tests/vm_engine.rs @@ -0,0 +1,530 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! #226 register VM: every row runs twice — compiled by the VM (a +//! top-level form) and on the tree evaluator (inside `(binding [] …)`, +//! which the VM hands to it whole) — and both must give the expected +//! value: the JVM's (Clojure 1.12) for [`JVM_ROWS`], the port's own +//! pre-existing semantics for [`PORT_ROWS`] (direct calls see a +//! redefined core fn; the JVM compiler inlines those fns instead). + +use clj_eval::{Evaluator, boot}; +use clj_value::{PrintOpts, pr_to_string}; + +/// `pr-str` of the form's value, or `Class: message` when it throws. +fn show(eval: &mut Evaluator, form: &str) -> String { + let src = format!( + "(try (pr-str {form}) (catch Throwable t (str (.getName (class t)) \": \" (.getMessage t))))" + ); + let vs = eval + .eval_source(&src) + .unwrap_or_else(|e| panic!("{form}: {e}")); + pr_to_string(vs.last().unwrap(), &PrintOpts::print()) +} + +const SETUP: &str = r#"(def ^:dynamic myplus +) +(defn g-plus [a b] (myplus a b)) +(defn t-lt [a b] (if (< a b) :lt :ge)) +(defn t-count [x] (count x)) +(defn t-get [m k] (get m k)) +(defn t-nth3 [v i nf] (nth v i nf)) +(defn t-max4 [a b c d] (max a b c d)) +(defn t-zero [x] (if (zero? x) :z :nz)) +(defn t-eq [a b] (if (= a b) :same :diff)) +(defn t-nth-test [v i] (if (nth v i nil) :hit :miss)) +(defn t-max-test [a b c] (if (max a b c) :yes :no)) +(defn fact [n acc] (if (zero? n) acc (recur (dec n) (* acc n)))) +(defn sum-destr [[a & r] acc] (if a (recur r (+ acc a)) acc)) +(defn vari ([] :none) ([a] [:one a]) ([a & more] [:many a more])) +(defn opt-arg [{:keys [x y] :or {x 10 y 20}}] [x y]) +(defn t-inc [x] (inc x)) +"#; + +const JVM_ROWS: &[(&str, &str)] = &[ + ( + r#"(mapv (fn [x] (case x 1 :one 2 :two :other)) [1 2 3 1.0 nil :k])"#, + r#"[:one :two :other :other :other :other]"#, + ), + ( + r#"(mapv (fn [x] (case x "Aa" 1 "BB" 2 :k 3 [1 2] 4 :none)) ["Aa" "BB" :k [1 2] "x" nil])"#, + r#"[1 2 3 4 :none :none]"#, + ), + (r#"(case (map inc [0 1]) (1 2) :yes :no)"#, r#":no"#), + (r#"(case 5 (1 2 3) :small (4 5 6) :mid :big)"#, r#":mid"#), + ( + r#"(let [a (atom 0)] [(try (/ 1 0) (catch ArithmeticException e :caught) (finally (swap! a inc))) @a])"#, + r#"[:caught 1]"#, + ), + ( + r#"(let [a (atom 0)] [(try 7 (finally (swap! a inc))) @a])"#, + r#"[7 1]"#, + ), + ( + r#"(let [a (atom 0)] (try (try (/ 1 0) (catch ArithmeticException e (throw (ex-info "h" {}))) (finally (swap! a inc))) (catch Exception e [(ex-message e) @a])))"#, + r#"["h" 1]"#, + ), + ( + r#"(try (try (/ 1 0) (finally (throw (ex-info "fin" {})))) (catch Exception e (ex-message e)))"#, + r#""fin""#, + ), + ( + r#"(try (try (/ 1 0) (catch IllegalStateException e :no)) (catch ArithmeticException e :outer))"#, + r#":outer"#, + ), + ( + r#"(try (try (throw (ex-info "x" {})) (catch Exception e (throw (ex-info "y" {}))) (finally (throw (ex-info "z" {})))) (catch Exception e (ex-message e)))"#, + r#""z""#, + ), + ( + r#"(try (throw (Exception. "boom")) (catch Exception e (.getMessage e)))"#, + r#""boom""#, + ), + ( + r#"(try (throw nil) (catch NullPointerException e :npe))"#, + r#":npe"#, + ), + ( + r#"(try (throw 5) (catch ClassCastException e :cce))"#, + r#":cce"#, + ), + ( + r#"(let [x Long/MAX_VALUE] (try (inc x) (catch ArithmeticException e (ex-message e))))"#, + r#""long overflow""#, + ), + ( + r#"(let [x Long/MIN_VALUE] (try (dec x) (catch ArithmeticException e (ex-message e))))"#, + r#""long overflow""#, + ), + ( + r#"(let [x 1.5 y 2] [(inc x) (dec x) (+ x y) (- x y) (* x y) (< x y) (> x y) (<= x y) (>= x y)])"#, + r#"[2.5 0.5 3.5 -0.5 3.0 true false true false]"#, + ), + ( + r#"(let [x 3000000000 y 4000000000] (try (* x y) (catch ArithmeticException e (ex-message e))))"#, + r#""long overflow""#, + ), + ( + r#"(let [x 1N] [(inc x) (dec x) (+ x 1) (< x 2)])"#, + r#"[2N 0N 2N true]"#, + ), + (r#"(let [x 1.5] (if (< x 2) :y :n))"#, r#":y"#), + ( + r#"(let [x 3 y 3] [(if (< x y) 1 2) (if (<= x y) 1 2) (if (> x y) 1 2) (if (>= x y) 1 2)])"#, + r#"[2 1 2 1]"#, + ), + (r#"(let [a (atom 1)] (if (< @a 2.5) :y :n))"#, r#":y"#), + (r#"(binding [myplus -] (g-plus 5 2))"#, r#"3"#), + (r#"(g-plus 5 2)"#, r#"7"#), + ( + r#"[(t-nth-test [1 2] 1) (t-nth-test [1 2] 5) (t-max-test 1 2 3) (t-max4 4 9 2 7) (t-max4 1 2.5 3 0)]"#, + r#"[:hit :miss :yes 9 3]"#, + ), + ( + r#"(let [v [10 20 30] i 1 j 5 k -1] [(nth v i) (nth v j :nf) (nth v k :nf) (try (nth v j) (catch IndexOutOfBoundsException e :oob))])"#, + r#"[20 :nf :nf :oob]"#, + ), + ( + r#"(let [t (transient [1 2 3]) i 1 j 9] [(nth t i) (nth t j :nf) (nth t -1 :nf)])"#, + r#"[2 :nf :nf]"#, + ), + ( + r#"(let [s "abc" i 1] [(nth s i) (nth s 9 :nf)])"#, + r#"[\b :nf]"#, + ), + ( + r#"(let [v [1 2] i 5000000000] (try (nth v i) (catch Exception e (.getName (class e)))))"#, + r#""java.lang.ArithmeticException""#, + ), + ( + r#"(let [v [1 2] i 5000000000] (try (nth v i :nf) (catch Exception e (.getName (class e)))))"#, + r#""java.lang.ArithmeticException""#, + ), + ( + r#"(let [m (transient {:a 1})] (try (nth m 0) (catch Exception e (.getName (class e)))))"#, + r#""java.lang.UnsupportedOperationException""#, + ), + (r#"(let [l (list 1 2 3) i 2] (nth l i))"#, r#"3"#), + (r#"(let [v [1 2] i 1.0] (nth v i))"#, r#"2"#), + ( + r#"(let [m {:a 1 "s" 2 \c 3 1 4 nil 5 true 6 (quote sym) 7} k :a] [(get m k) (get m "s") (get m \c) (get m 1) (get m nil) (get m true) (get m (quote sym)) (get m :zz) (get m :zz :d)])"#, + r#"[1 2 3 4 5 6 7 nil :d]"#, + ), + (r#"(let [m {[1] :v} k [1]] (get m k))"#, r#":v"#), + ( + r#"(let [m (sorted-map 1 :a 2 :b) k 2] [(get m k) (get m 3 :d)])"#, + r#"[:b :d]"#, + ), + ( + r#"(let [v [5 6] i 1 j 7] [(get v i) (get v j) (get v j :d) (get v :x) (get v -1 :neg)])"#, + r#"[6 nil :d nil :neg]"#, + ), + ( + r#"(let [s #{1 2} k 2] [(get s k) (get s 9)])"#, + r#"[2 nil]"#, + ), + ( + r#"(let [c nil k :a] [(get c k) (get c k :d)])"#, + r#"[nil :d]"#, + ), + ( + r#"(let [m {:a 1} f (fn [] (keys m))] (get m (do (f) :a)))"#, + r#"1"#, + ), + ( + r#"(let [a nil b [1 2] c {:a 1} d #{1 2 3} e "hello" f (list 1 2) g (range 3)] [(count a) (count b) (count c) (count d) (count e) (count f) (count g)])"#, + r#"[0 2 1 3 5 2 3]"#, + ), + ( + r#"(let [m {:a 1} s #{:x} ss (sorted-set 1 2) v [1 2] k :a] [(contains? m k) (contains? m :b) (contains? s :x) (contains? s :y) (contains? ss 2) (contains? v 1) (contains? v 5)])"#, + r#"[true false true false true true false]"#, + ), + ( + r#"(let [a 1 b 1 c 2 d \x e "s" f :k g true] [(= a b) (= a c) (= d \x) (= e "s") (= f :k) (= g true) (= nil nil) (= a 1.0) (= [1] [1]) (= a nil) (not= a c) (not= a b) (not= [1] [1])])"#, + r#"[true false true true true true true false true false true false false]"#, + ), + ( + r#"(let [a 3 b 7 c -2] [(min a b) (max a b) (min a b c) (max a b c) (min a 2.5) (max a 7.5) (max 1 2 3 4 5)])"#, + r#"[3 7 -2 7 2.5 7.5 5]"#, + ), + ( + r#"(let [a 17 b 5 c -17 z 0] [(quot a b) (rem a b) (mod a b) (quot c b) (rem c b) (mod c b) (mod a -5) (mod c -5) (mod 10 5) (quot Long/MIN_VALUE -1) (rem Long/MIN_VALUE -1) (mod Long/MIN_VALUE -1)])"#, + r#"[3 2 2 -3 -2 3 -3 -2 0 -9223372036854775808 0 0]"#, + ), + ( + r#"(let [a 17 z 0] [(try (quot a z) (catch ArithmeticException e (ex-message e))) (try (rem a z) (catch ArithmeticException e (ex-message e))) (try (mod a z) (catch ArithmeticException e (ex-message e)))])"#, + r#"["/ by zero" "/ by zero" "Divide by zero"]"#, + ), + ( + r#"(let [a 17.5 b 5] [(quot a b) (rem a b) (mod a b)])"#, + r#"[3.0 2.5 2.5]"#, + ), + ( + r#"[(apply rem [7 2]) (apply mod [-7 2]) (apply quot [7 2])]"#, + r#"[1 1 3]"#, + ), + ( + r#"(let [a 0 b 5 c -3 d 2.0] [(zero? a) (zero? b) (pos? b) (pos? c) (neg? c) (neg? b) (even? b) (odd? b) (even? a) (zero? d) (pos? d)])"#, + r#"[true false true false true false false true true false true]"#, + ), + ( + r#"(let [s "x"] (try (zero? s) (catch ClassCastException e :cce)))"#, + r#":cce"#, + ), + ( + r#"(let [d 2.5] (try (even? d) (catch IllegalArgumentException e (ex-message e))))"#, + r#""Argument must be an integer: 2.5""#, + ), + ( + r#"(let [a nil b false c 0 d []] [(not a) (not b) (not c) (nil? a) (nil? c) (some? a) (some? d)])"#, + r#"[true true false true false false true]"#, + ), + ( + r#"(let [t (transient [1 2]) i 0 j 2] (persistent! (assoc! (assoc! t i :a) j :b)))"#, + r#"[:a 2 :b]"#, + ), + ( + r#"(let [t (transient [1 2]) j 5] (try (assoc! t j :x) (catch IndexOutOfBoundsException e :oob)))"#, + r#":oob"#, + ), + ( + r#"(let [t (transient {:a 1})] (persistent! (assoc! t :b 2)))"#, + r#"{:a 1, :b 2}"#, + ), + ( + r#"(let [t (transient [1 2])] (try (assoc! t :k 2) (catch IllegalArgumentException e (ex-message e))))"#, + r#""Key must be integer""#, + ), + ( + r#"(let [t (transient [1])] (persistent! t) (try (assoc! t 0 2) (catch Throwable e (ex-message e))))"#, + r#""Transient used after persistent! call""#, + ), + ( + r#"[(apply nth [(transient [1]) -1 :nf]) (apply count [[1 2]])]"#, + r#"[:nf 2]"#, + ), + ( + r#"(loop [i 0 v []] (if (< i 5) (recur (inc i) (conj v i)) v))"#, + r#"[0 1 2 3 4]"#, + ), + (r#"(let [v [1 2]] [(conj v 3) v])"#, r#"[[1 2 3] [1 2]]"#), + ( + r#"(let [v [1 2] w (conj v 3)] [v w])"#, + r#"[[1 2] [1 2 3]]"#, + ), + ( + r#"(let [m {:a 1}] (let [m2 (assoc m :b 2)] [m m2]))"#, + r#"[{:a 1} {:a 1, :b 2}]"#, + ), + ( + r#"(reduce (fn [acc x] (conj acc x)) [] (range 5))"#, + r#"[0 1 2 3 4]"#, + ), + ( + r#"(reduce (fn [acc x] (assoc acc x (* x x))) {} (range 4))"#, + r#"{0 0, 1 1, 2 4, 3 9}"#, + ), + ( + r#"(let [v (vec (range 40))] (loop [i 0 v v] (if (< i 40) (recur (inc i) (assoc v i (- i))) (reduce + v))))"#, + r#"-780"#, + ), + ( + r#"(let [x [1] y (if (seq x) (conj x 2) x)] [x y])"#, + r#"[[1] [1 2]]"#, + ), + ( + r#"(mapv #(%) (loop [i 0 acc []] (if (< i 3) (recur (inc i) (conj acc (fn [] i))) acc)))"#, + r#"[0 1 2]"#, + ), + ( + r#"(letfn [(ev? [n] (if (zero? n) true (od? (dec n)))) (od? [n] (if (zero? n) false (ev? (dec n))))] [(ev? 10) (od? 7) (ev? 3)])"#, + r#"[true true false]"#, + ), + ( + r#"((fn f [n] (if (< n 2) n (+ (f (- n 1)) (f (- n 2))))) 10)"#, + r#"55"#, + ), + ( + r#"(let [f (fn self [] self)] (identical? (f) (f)))"#, + r#"true"#, + ), + ( + r#"(let [base 5 add (fn [x] (+ x base))] (map add [1 2 3]))"#, + r#"(6 7 8)"#, + ), + ( + r#"(let [x 1] (let [f (fn [] (let [g (fn [] x)] (g)))] (f)))"#, + r#"1"#, + ), + ( + r#"(take 5 ((fn step [n] (lazy-seq (cons n (step (inc n))))) 0))"#, + r#"(0 1 2 3 4)"#, + ), + ( + r#"(lazy-seq (loop [i 0] (if (< i 3) (recur (inc i)) (list i))))"#, + r#"(3)"#, + ), + ( + r#"(let [d (delay (+ 1 2))] [(realized? d) @d (realized? d)])"#, + r#"[false 3 true]"#, + ), + ( + r#"(let [n 3] (doall (for [i (range n) j (range i) :when (odd? (+ i j))] [i j])))"#, + r#"([1 0] [2 1])"#, + ), + ( + r#"[(fact 10 1) (sum-destr [1 2 3 4] 0) (vari) (vari 1) (vari 1 2 3) (opt-arg {}) (opt-arg {:x 1})]"#, + r#"[3628800 10 :none [:one 1] [:many 1 (2 3)] [10 20] [1 20]]"#, + ), + ( + r#"(let [{:keys [a b] :or {a 1 b 2}} {:b 5} [x & ys :as all] [1 2 3]] [a b x ys all])"#, + r#"[1 5 1 (2 3) [1 2 3]]"#, + ), + ( + r#"(loop [[a b] [1 2] n 0] (if (< n 3) (recur [b (+ a b)] (inc n)) [a b]))"#, + r#"[5 8]"#, + ), + ( + r#"(loop [{:keys [x] :or {x 0}} {} n 0] (if (< n 2) (recur {:x (+ x 10)} (inc n)) x))"#, + r#"20"#, + ), + ( + r#"((fn [a & {:keys [k] :or {k :dflt}}] [a k]) 1)"#, + r#"[1 :dflt]"#, + ), + ( + r#"(try (vari 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21) (catch Exception e :ok))"#, + r#"[:many 1 (2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21)]"#, + ), + ( + r#"(try ((fn [a b] a) 1) (catch clojure.lang.ArityException e :arity))"#, + r#":arity"#, + ), + ( + r#"(try (assert (= 1 1)) :passed (catch AssertionError e :failed))"#, + r#":passed"#, + ), + ( + r#"(try (assert false) (catch AssertionError e (ex-message e)))"#, + r#""Assert failed: false""#, + ), + ( + r#"(try (assert false "custom msg") (catch AssertionError e (ex-message e)))"#, + r#""Assert failed: custom msg\nfalse""#, + ), + ( + r#"(try (assert false (map #(/ 1 %) [0])) (catch ArithmeticException e :str-threw))"#, + r#":str-threw"#, + ), + ( + r#"(let [a 1 b 2] [[a b] {a b :k (+ a b)} #{a b} {:nested [a {b a}]}])"#, + r#"[[1 2] {1 2, :k 3} #{1 2} {:nested [1 {2 1}]}]"#, + ), + ( + r#"(do (def ^{:tag String :k (inc 1)} dm-x "v") [(:k (meta (var dm-x))) (.getName (:tag (meta (var dm-x))))])"#, + r#"[2 "java.lang.String"]"#, + ), + ( + r#"(let [m {(list 1 2) :lazy}] (get m (map identity [1 2])))"#, + r#":lazy"#, + ), + ( + r#"(try ((fn [x] {:pre [(pos? x)]} x) -1) (catch AssertionError e (ex-message e)))"#, + r#""Assert failed: (pos? x)""#, + ), + ( + r#"((fn [x] {:pre [(pos? x)] :post [(> % 1)]} (inc x)) 3)"#, + r#"4"#, + ), + ( + r#"(let [a 3 b 9 c 1 d 4] [(max a b (inc c) d) (min (dec a) b c d 0)])"#, + r#"[9 0]"#, + ), + ( + r#"(mapv (fn [x] (case x "Aa" 1 :k 2 :none)) ["BB" "Aa" :k])"#, + r#"[:none 1 2]"#, + ), + ( + r#"(try (loop [i 0] (if (< i 2) (recur (inc i)) (throw (ex-info "L" {})))) (catch Exception e (ex-message e)))"#, + r#""L""#, + ), + ( + r#"((fn [n acc] (if (zero? n) acc (recur (dec n) (* acc n)))) 5 1)"#, + r#"120"#, + ), + ( + r#"(mapv #(%) ((fn [n acc] (if (zero? n) acc (recur (dec n) (conj acc (fn [] n))))) 3 []))"#, + r#"[3 2 1]"#, + ), + ( + r#"(let [a (atom 0)] (doall (lazy-seq (if (< (swap! a inc) 3) (recur) (list @a)))))"#, + r#"(3)"#, + ), + ( + r#"(let [t (transient {})] (persistent! (assoc! t 0 :x)))"#, + r#"{0 :x}"#, + ), + ( + r#"(binding [myplus -] ((fn [a b] (myplus a b)) 5 2))"#, + r#"3"#, + ), + ( + r#"(loop [[a b] [1 2]] (if (< a 10) (recur [b (+ a b)]) [a b]))"#, + r#"[13 21]"#, + ), + ( + r#"(try (doall (lazy-seq (throw (ex-info "lz" {})))) (catch Exception e (ex-message e)))"#, + r#""lz""#, + ), +]; + +const PORT_ROWS: &[(&str, &str)] = &[ + ( + r#"(try (case (map identity [(range)]) :x :a :b) (catch UnsupportedOperationException e :unsupported))"#, + r#":unsupported"#, + ), + ( + r#"(try (inc 1 2) (catch clojure.lang.ArityException e :arity))"#, + r#":arity"#, + ), + (r#"(with-redefs [inc dec] (t-inc 5))"#, r#"4"#), + ( + r#"(try (case (range) :x :a :b) (catch UnsupportedOperationException e :unsupported))"#, + r#":unsupported"#, + ), + ( + r#"(try (case (list (range)) :x :a :b) (catch UnsupportedOperationException e :unsupported))"#, + r#":unsupported"#, + ), + ( + r#"(with-redefs [< >] ((fn [a b] (if (< a b) :lt :ge)) 1 2))"#, + r#":ge"#, + ), + (r#"(with-redefs [< >] (t-lt 1 2))"#, r#":ge"#), + ( + r#"(with-redefs [count (fn [_] 42)] (t-count [1 2]))"#, + r#"42"#, + ), + ( + r#"(with-redefs [get (fn [_ _] :redef)] (t-get {:a 1} :a))"#, + r#":redef"#, + ), + ( + r#"(with-redefs [nth (fn [_ _ _] :redef)] (t-nth3 [1] 0 :nf))"#, + r#":redef"#, + ), + ( + r#"(with-redefs [max (fn [& _] :redef)] (t-max4 1 2 3 4))"#, + r#":redef"#, + ), + (r#"(with-redefs [zero? (fn [_] true)] (t-zero 5))"#, r#":z"#), + ( + r#"(with-redefs [= (fn [_ _] true)] (t-eq 1 2))"#, + r#":same"#, + ), + ( + r#"(with-redefs [nth (fn [& _] nil)] (t-nth-test [1] 0))"#, + r#":miss"#, + ), + ( + r#"(with-redefs [max (fn [& _] nil)] (t-max-test 1 2 3))"#, + r#":no"#, + ), +]; + +fn booted() -> Evaluator { + let mut eval = boot(); + eval.eval_source(SETUP) + .unwrap_or_else(|e| panic!("setup: {e}")); + eval +} + +fn check_both(rows: &[(&str, &str)]) { + let mut eval = booted(); + for (form, want) in rows { + assert_eq!(&show(&mut eval, form), want, "vm: {form}"); + let tree = format!("(binding [] {form})"); + assert_eq!(&show(&mut eval, &tree), want, "tree: {form}"); + } +} + +#[test] +fn vm_and_tree_match_the_jvm() { + check_both(JVM_ROWS); +} + +#[test] +fn vm_and_tree_share_port_semantics() { + check_both(PORT_ROWS); +} + +/// A deterministic conflict retry (the `stm_retry_under_contention` +/// shape) whose stranded deref sits inside a VM-compiled `try`. +const RETRY: &str = "(let [r (ref 0 :min-history 1 :max-history 0) c (atom 0) fin (atom 0) p1 (promise) p2 (promise)] (future (deref p1) (dosync (alter r inc)) (dosync (alter r inc)) (deliver p2 true)) [(try (dosync (guarded c p1 p2 r fin)) (catch Exception e (ex-message e))) @c @fin @r])"; + +#[test] +fn stm_retry_unwinds_through_a_vm_try() { + // The retry's unwind runs `finally` and resumes; the fresh + // attempt runs the body (and `finally`) again. + let mut eval = booted(); + eval.eval_source( + "(defn guarded [c p1 p2 r fin] (try (swap! c inc) (deliver p1 true) (deref p2) (deref r) (finally (swap! fin inc))))", + ) + .unwrap(); + assert_eq!(show(&mut eval, RETRY), "[2 2 2 2]"); +} + +#[test] +fn a_throwing_finally_masks_the_retry_unwind() { + // `finally` throwing during the retry's unwind replaces it (like + // the JVM's `RetryEx`): the transaction fails with that throw. + let mut eval = booted(); + eval.eval_source( + "(defn guarded [c p1 p2 r fin] (try (swap! c inc) (deliver p1 true) (deref p2) (deref r) (finally (when (= 1 (swap! fin inc)) (throw (ex-info \"masked\" {}))))))", + ) + .unwrap(); + assert_eq!(show(&mut eval, RETRY), "[\"masked\" 1 1 2]"); +} diff --git a/rust/crates/clj-eval/tests/vm_ops.rs b/rust/crates/clj-eval/tests/vm_ops.rs new file mode 100644 index 0000000000..0630e6059e --- /dev/null +++ b/rust/crates/clj-eval/tests/vm_ops.rs @@ -0,0 +1,279 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! #226 shape-specialized VM ops (`vm_ops`): every op across operand +//! shapes (registers, captured values, literals, computed forms), +//! number kinds, and native fallbacks; n-ary folds and comparison +//! chains; update ops; the `for` keep stage; prepared callees and the +//! direct seq walks. Every row runs twice — compiled by the VM (a +//! top-level form) and on the tree evaluator (inside `(binding [] …)`, +//! which the VM hands to it whole) — and both must give the JVM's +//! value (Clojure 1.12) for [`JVM_ROWS`], the port's own semantics for +//! [`PORT_ROWS`] (direct calls see a redefined core fn; the JVM +//! compiler inlines those fns instead). + +use clj_eval::{Evaluator, boot}; +use clj_value::{PrintOpts, pr_to_string}; + +/// `pr-str` of the form's value, or `Class: message` when it throws. +fn show(eval: &mut Evaluator, form: &str) -> String { + let src = format!( + "(try (pr-str {form}) (catch Throwable t (str (.getName (class t)) \": \" (.getMessage t))))" + ); + let vs = eval + .eval_source(&src) + .unwrap_or_else(|e| panic!("{form}: {e}")); + pr_to_string(vs.last().unwrap(), &PrintOpts::print()) +} + +const JVM_ROWS: &[(&str, &str)] = &[ + ( + r#"((fn [a] [(try (+ (identity a) 1) (catch Exception e :xs)) (try (+ 1 (identity a)) (catch Exception e :sx)) (try (+ (identity a) (identity a)) (catch Exception e :xx)) (if (+) :t :f)]) "s")"#, + r#"[:xs :sx :xx :t]"#, + ), + ( + r#"((fn [a b] [(+ a b) (- a b) (* a b) (quot a b) (rem a b) (mod a b) (mod (- a) b) (min a b) (max a b)]) 17 5)"#, + r#"[22 12 85 3 2 2 3 5 17]"#, + ), + ( + r#"((fn [a b] [(< a b) (<= a b) (> a b) (>= a b) (== a b) (= a b) (not= a b)]) 3 3)"#, + r#"[false true false true true true false]"#, + ), + ( + r#"((fn [a b] [(bit-and a b) (bit-or a b) (bit-xor a b) (bit-shift-left a b) (bit-shift-right a b) (unsigned-bit-shift-right (- a) b)]) 12 2)"#, + r#"[0 14 14 48 3 4611686018427387901]"#, + ), + ( + r#"((fn [a] [(+ a 1) (+ 1 a) (- a 1) (- 10 a) (* a 2) (< a 10) (> 10 a) (inc a) (dec a) (- a)]) 7)"#, + r#"[8 8 6 3 14 true true 8 6 -7]"#, + ), + ( + r#"(let [k 5] ((fn [a] [(+ a k) (+ k a) (* k k) (< k a) (inc k) (zero? k)]) 7))"#, + r#"[12 12 25 true 6 false]"#, + ), + ( + r#"((fn [a] [(+ (inc a) 1) (+ 1 (inc a)) (+ (inc a) (dec a)) (* a (inc a)) (- (inc a)) (inc (inc a))]) 7)"#, + r#"[9 9 14 56 -8 9]"#, + ), + ( + r#"((fn [a b] [(+ a b) (- a b) (* a b) (< a b) (> b a) (== a 2.0) (+ b a) (inc a) (dec a) (- a)]) 1.5 2)"#, + r#"[3.5 -0.5 3.0 true true false 3.5 2.5 0.5 -1.5]"#, + ), + ( + r#"((fn [a b] [(+ a b) (< a b) (<= b a)]) 2 0.5)"#, + r#"[2.5 false true]"#, + ), + ( + r#"((fn [a] [(try (+ a 1) (catch Exception e :err)) (try (< a 1) (catch Exception e :cerr)) (try (inc a) (catch Exception e :ierr))]) "x")"#, + r#"[:err :cerr :ierr]"#, + ), + ( + r#"((fn [a] [(try (+ a 1) (catch ArithmeticException e :ovf)) (try (inc a) (catch ArithmeticException e :iovf)) (try (* a 2) (catch ArithmeticException e :movf))]) Long/MAX_VALUE)"#, + r#"[:ovf :iovf :movf]"#, + ), + ( + r#"((fn [a] [(try (- a) (catch ArithmeticException e :novf)) (try (dec a) (catch ArithmeticException e :dovf))]) Long/MIN_VALUE)"#, + r#"[:novf :dovf]"#, + ), + ( + r#"((fn [a b] [(try (quot a b) (catch ArithmeticException e :q0)) (try (rem a b) (catch ArithmeticException e :r0)) (try (mod a b) (catch ArithmeticException e :m0))]) 5 0)"#, + r#"[:q0 :r0 :m0]"#, + ), + ( + r#"((fn [a b] [(quot a b) (rem a b) (mod a b) (bit-and 7 a)]) -7 2)"#, + r#"[-3 -1 1 1]"#, + ), + ( + r#"((fn [a b] [(if (< a b) :lt :ge) (if (zero? a) :z :nz) (if (pos? b) :p :np) (if (neg? a) :n :nn) (if (even? b) :e :o) (if (odd? b) :o :e) (if (= a b) :eq :ne) (if (not= a b) :ne :eq) (if (== a b) :eq :ne) (if (nil? a) :nil :some) (if (some? a) :some :nil) (if (not a) :f :t)]) 1 2)"#, + r#"[:lt :nz :p :nn :e :e :ne :ne :ne :some :some :t]"#, + ), + ( + r#"((fn [a b] [(if (< (inc a) b) :lt :ge) (if (> b (inc a)) :y :n) (if (< (inc a) (dec b)) :y :n) (if (zero? (dec a)) :z :nz) (if (contains? #{1 2} (inc a)) :in :out)]) 1 3)"#, + r#"[:lt :y :n :z :in]"#, + ), + ( + r#"((fn [x] [(zero? x) (pos? x) (neg? x) (zero? 0.0) (pos? -1.5) (neg? -1.5)]) 0)"#, + r#"[true false false true false true]"#, + ), + ( + r#"((fn [x] [(try (even? x) (catch Exception e :ee)) (try (zero? x) (catch Exception e :ze)) (not x) (nil? x) (some? x)]) 1.5)"#, + r#"[:ee false false false true]"#, + ), + ( + r#"((fn [v m s] [(nth v 1) (nth "abc" 2) (get m :a) (get v 0) (get nil :a) (get m :zz) (contains? m :a) (contains? s 3) (contains? s 9) (count v) (count m) (count s) (count "héllo") (count nil)]) [10 20 30] {:a 1} #{3 4})"#, + r#"[20 \c 1 10 nil nil true true false 3 1 2 5 0]"#, + ), + ( + r#"((fn [v] [(try (nth v 9) (catch IndexOutOfBoundsException e :oob)) (nth v -1 :nf) (nth v 9 :nf) (try (nth "ab" 5) (catch Exception e :soob)) (get v 9) (get (sorted-map :a 1) :a) (contains? (sorted-set 1) 1) (count '(1 2)) (nth '(5 6) 1)]) [1 2])"#, + r#"[:oob :nf :nf :soob nil 1 true 2 6]"#, + ), + ( + r#"((fn [t] (let [t (conj! t 1) t (conj! t 2)] (persistent! t))) (transient []))"#, + r#"[1 2]"#, + ), + ( + r#"((fn [t] (persistent! (conj! t [:a 1]))) (transient {}))"#, + r#"{:a 1}"#, + ), + ( + r#"((fn [x] [(int x) (long x) (double x) (int \a) (long \b) (double 2.5)]) 7)"#, + r#"[7 7 7.0 97 98 2.5]"#, + ), + ( + r#"((fn [x] [(try (int x) (catch Exception e :ibig)) (long 2.9) (int 2.9) (try (double nil) (catch Exception e :dnil))]) 3000000000)"#, + r#"[:ibig 2 2 :dnil]"#, + ), + ( + r#"((fn [a b c d] [(+ a b c) (+ a b c d) (- a b c) (* a b c) (min a b c) (max a b c d) (bit-and a b c) (bit-or a b c) (bit-xor a b c)]) 12 10 6 3)"#, + r#"[28 31 -4 720 6 12 0 14 0]"#, + ), + ( + r#"((fn [a b c] [(< a b c) (< a c b) (<= a a b) (> c b a) (>= c c a) (== a a a) (< 1 0 :x)]) 1 2 3)"#, + r#"[true false true true true true false]"#, + ), + ( + r#"((fn [a b c] [(if (< a b c) :in :out) (if (<= 0 a 10) :in :out) (+ a 1.5 c) (try (+ a b :x) (catch Exception e :cce)) (try (< a :x 0) (catch Exception e :ccc))]) 1 2 3)"#, + r#"[:in :in 5.5 :cce :ccc]"#, + ), + ( + r#"((fn [a b] [(+ a b (inc a)) (+ (inc a) b 1) (* 2 a b)]) 4 5)"#, + r#"[14 11 40]"#, + ), + ( + r#"((fn [a] (try (+ a 1 1) (catch ArithmeticException e :novf))) Long/MAX_VALUE)"#, + r#":novf"#, + ), + ( + r#"((fn [v] (let [v (assoc v 0 :x) v (assoc v 3 :y)] [v (try (assoc v 9 :z) (catch IndexOutOfBoundsException e :oob))])) [1 2 3])"#, + r#"[[:x 2 3 :y] :oob]"#, + ), + ( + r#"((fn [m] [(assoc m :b 2) (assoc m [1] 3) (assoc (sorted-map :b 1) :a 2) (assoc nil :k 1)]) {:a 1})"#, + r#"[{:a 1, :b 2} {:a 1, [1] 3} {:a 2, :b 1} {:k 1}]"#, + ), + ( + r#"((fn [v] (let [w (assoc v 0 :changed)] [v w])) [1 2])"#, + r#"[[1 2] [:changed 2]]"#, + ), + ( + r#"((fn [] (let [v [1 2 3]] [(assoc v 1 (inc (nth v 1))) v])))"#, + r#"[[1 3 3] [1 2 3]]"#, + ), + ( + r#"((fn [x] [(conj [1] x) (conj '(1) x) (conj nil x) (conj #{1} x) (contains? (conj #{[1]} [x]) [2]) (conj {:a 1} [:b x]) (conj (sorted-set 3) x)]) 2)"#, + r#"[[1 2] (2 1) (2) #{1 2} true {:a 1, :b 2} #{2 3}]"#, + ), + ( + r#"((fn [m] (reduce (fn [acc k] (assoc acc k (inc (get acc k 0)))) m [:a :b :a])) {})"#, + r#"{:a 2, :b 1}"#, + ), + ( + r#"(let [k 3] ((fn [v] [(assoc v 0 k) (conj v k)]) [9]))"#, + r#"[[3] [9 3]]"#, + ), + ( + r#"((fn [] [(conj (vec (range 3)) 9) (assoc (vec (range 40)) 39 :end)]))"#, + r#"[[0 1 2 9] [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 :end]]"#, + ), + (r#"(letfn [(f [x] (if (nil? f) 0 x))] (f 5))"#, r#"5"#), + ( + r#"((fn [m] (if (get m :a nil) :hit :miss)) {:a 1})"#, + r#":hit"#, + ), + ( + r#"((fn [v] [(nth v 1 :nf) (get v 5 :dflt) (get {:a 1} :b :nf)]) [1 2])"#, + r#"[2 :dflt :nf]"#, + ), + ( + r#"((fn [t] (persistent! (assoc! t 0 :z))) (transient [1 2]))"#, + r#"[:z 2]"#, + ), + (r#"(if (let [x 1] (if x 2 3)) :a :b)"#, r#":a"#), + ( + r#"(let [and1 5] (if (let [and1 and1] (if and1 (= and1 5) and1)) :y :n))"#, + r#":y"#, + ), + ( + r#"[(if (and (pos? 1) (odd? 3) (< 1 2)) :all :not) (if (or (neg? 1) (zero? 0)) :any :none) (if (and (pos? 1) (neg? 1)) :all :not) (if (or (neg? 1) (neg? 2)) :any :none)]"#, + r#"[:all :any :not :none]"#, + ), + ( + r#"(do (defn io-rec [n] (if (pos? n) (io! (recur (dec n))) :done)) (io-rec 3))"#, + r#":done"#, + ), + ( + r#"[(mapv inc [1 2]) (reduce + [1.5 2]) (reduce conj [] [1 2]) (reduce (fn [& xs] (apply + xs)) [1 2 3]) (reduce + (repeat 3 2)) (reduce + (range 4)) (reduce + (range 0.5 3)) (reduce (fn [a x] (if (> a 5) (reduced a) (+ a x))) 0 (repeat 1))]"#, + r#"[[2 3] 3.5 [1 2] 6 6 6 4.5 6]"#, + ), + ( + r#"[(vec (repeat 3 0)) (vec (repeat 2 :a)) (vec (range 5)) (vec (range 0.5 2)) (vec (range 2 11 3)) (keep :a [{:a 1} {}]) (into [] (map inc) (range 3))]"#, + r#"[[0 0 0] [:a :a] [0 1 2 3 4] [0.5 1.5] [2 5 8] (1) [1 2 3]]"#, + ), + ( + r#"(let [s (apply str (repeat 300 "é"))] [(count s) (count s) (nth s 5) (nth s 299)])"#, + r#"[300 300 \é \é]"#, + ), + ( + r#"(let [s (apply str (repeat 100 "ab")) t (apply str (repeat 100 "cd"))] (loop [i 0 acc []] (if (< i 4) (recur (inc i) (conj acc (nth s i) (nth t i))) acc)))"#, + r#"[\a \c \b \d \a \c \b \d]"#, + ), + ( + r#"[(meta ^{:m 1} [1 2]) (for [x (range 10) :when (odd? x)] (when (> x 4) x)) (for [x [1 2 3] :let [y (* x x)] :when (odd? y)] [x y]) (take 3 (for [x (range) :when (zero? (mod x 7))] x)) (for [x [1 2] y [3 4] :when (not= x 4)] [x y]) (for [[a b] [[1 2] [3 4]] :when (odd? a)] (+ a b))]"#, + r#"[{:m 1} (nil nil 5 7 9) ([1 1] [3 9]) (0 7 14) ([1 3] [1 4] [2 3] [2 4]) (3 7)]"#, + ), + ( + r#"((fn [a b c d e] [(loop [i 0 a a b b c c] (if (< i 3) (recur (inc i) (+ a 1) (+ b 2) (+ c 3)) [a b c])) (loop [i 0 a a b b c c d d] (if (< i 2) (recur (inc i) (inc a) (inc b) (inc c) (inc d)) [a b c d])) (loop [i 0 a a b b c c d d e e] (if (< i 2) (recur (inc i) a b c d (inc e)) e))]) 1 2 3 4 5)"#, + r#"[[4 8 12] [3 4 5 6] 7]"#, + ), +]; + +const PORT_ROWS: &[(&str, &str)] = &[ + ( + r#"(do (defn r-xs [a] [(+ (identity a) 1) (+ 1 (identity a)) (+ (identity a) (identity a))]) (with-redefs [+ *] (r-xs 3)))"#, + r#"[3 3 9]"#, + ), + ( + r#"(do (defn r-add [a b] (+ a b)) (with-redefs [+ *] (r-add 3 4)))"#, + r#"12"#, + ), + ( + r#"(do (defn r-inc [a] (inc (inc a))) (with-redefs [inc dec] (r-inc 3)))"#, + r#"1"#, + ), + ( + r#"(do (defn r-add3 [a b c] (+ a b c)) (with-redefs [+ (fn [& xs] (count xs))] (r-add3 1 2 3)))"#, + r#"3"#, + ), + ( + r#"(do (defn r-asc [m] (assoc m :a 1)) (with-redefs [assoc (fn [m k v] :redef)] (r-asc {})))"#, + r#":redef"#, + ), + ( + r#"(do (defn r-lt [a b] (if (< a b) :lt :ge)) (with-redefs [< >] (r-lt 1 2)))"#, + r#":ge"#, + ), +]; + +fn check(rows: &[(&str, &str)]) { + for (form, want) in rows { + let mut eval = boot(); + assert_eq!(show(&mut eval, form), *want, "VM: {form}"); + let mut eval = boot(); + let tree = format!("(binding [] {form})"); + assert_eq!(show(&mut eval, &tree), *want, "tree: {form}"); + } +} + +#[test] +fn vm_ops_match_the_jvm() { + check(JVM_ROWS); +} + +#[test] +fn vm_ops_share_port_semantics() { + check(PORT_ROWS); +} diff --git a/rust/crates/clj-eval/tests/zero_oos_s3_seqs.rs b/rust/crates/clj-eval/tests/zero_oos_s3_seqs.rs index bea4accd82..ffb8c1b402 100644 --- a/rust/crates/clj-eval/tests/zero_oos_s3_seqs.rs +++ b/rust/crates/clj-eval/tests/zero_oos_s3_seqs.rs @@ -500,12 +500,10 @@ fn update_keys_rows() { #[test] fn arity_miss_rows() { let mut eval = boot(); - // Oracle seqwalks.out (shape only — the house `defn` emits - // anonymous fns, so arity misses name the volatile - // `user/evalN/fn--M` display where the JVM names - // `clojure.core/NAME`): bc-arity1 bc-arity3 ts-arity2 fl-arity0 - // fl-arity2 mdw-arity0 pv-arity1 pva-arity0 rk-arity2 run-arity1 - // sv-arity1 uv-arity1 uk-arity1. + // Oracle seqwalks.out: `core.clj` defns name their var + // (`clojure.core/NAME`, like the JVM): bc-arity1 bc-arity3 + // ts-arity2 fl-arity0 fl-arity2 mdw-arity0 pv-arity1 pva-arity0 + // rk-arity2 run-arity1 sv-arity1 uv-arity1 uk-arity1. for (src, count) in [ ("(bounded-count 1)", 1), ("(bounded-count 1 2 3)", 3), @@ -521,13 +519,13 @@ fn arity_miss_rows() { ("(update-vals {:a 1})", 1), ("(update-keys {:a 1})", 1), ] { - let got = uncaught(&mut eval, src); - let prefix = format!( - "clojure.lang.ArityException | Wrong number of args ({count}) passed to: user/" - ); - assert!( - got.starts_with(&prefix) && got.contains("/fn--"), - "for {src}: {got}" + let name = src[1..].split([' ', ')']).next().expect("a head"); + assert_eq!( + uncaught(&mut eval, src), + format!( + "clojure.lang.ArityException | Wrong number of args ({count}) passed to: clojure.core/{name}" + ), + "for {src}" ); } } diff --git a/rust/crates/clj-eval/tests/zero_oos_s7_readgates.rs b/rust/crates/clj-eval/tests/zero_oos_s7_readgates.rs index 4d3df3decb..c23c3b1cb2 100644 --- a/rust/crates/clj-eval/tests/zero_oos_s7_readgates.rs +++ b/rust/crates/clj-eval/tests/zero_oos_s7_readgates.rs @@ -1317,10 +1317,10 @@ fn load_reader_errors() { ), "clojure.lang.Compiler$CompilerException | Syntax error reading source at (2:1)." ); - // The port reads all forms before evaluating (pre-existing - // load-loop semantics — the JVM interleaves, probes - // `l-err-read-defined`, `l-err-trunc-defined`). - assert_eq!(run_last(&mut eval, "(resolve 'evlr2)"), "nil"); + // Form by form, like the JVM (probes `l-err-read-defined`, + // `l-err-trunc-defined`): the defs before the bad reads stand. + assert_eq!(run_last(&mut eval, "(resolve 'evlr2)"), "#'user/evlr2"); + assert_eq!(run_last(&mut eval, "(resolve 'evlr3)"), "#'user/evlr3"); } #[test] @@ -1411,8 +1411,9 @@ fn load_string_eval_read_errors() { uncaught(&mut eval, "(load-string \"(def evw 1) (w\")"), "clojure.lang.Compiler$CompilerException | Syntax error reading source at (2:1)." ); - // Read-all-first again (probe `m-loadstring-trunc-defined`). - assert_eq!(run_last(&mut eval, "(resolve 'evw)"), "nil"); + // Form by form, like the JVM (probe `m-loadstring-trunc-defined`): + // the def before the truncated form stands. + assert_eq!(run_last(&mut eval, "(resolve 'evw)"), "#'user/evw"); } #[test] diff --git a/rust/crates/clj-reader/src/coll.rs b/rust/crates/clj-reader/src/coll.rs index 200b4efecb..e1a134e27e 100644 --- a/rust/crates/clj-reader/src/coll.rs +++ b/rust/crates/clj-reader/src/coll.rs @@ -13,7 +13,7 @@ //! first line (bare without line numbers). Maps check oddness after //! the full read, then duplicate-check; sets duplicate-check. -use clj_value::{Keyword, List, Map, Set, Value, Vector, java_debug}; +use clj_value::{List, Map, Set, Value, Vector, java_debug}; use crate::Reader; use crate::error::ReaderError; @@ -59,15 +59,18 @@ impl Reader { } let mut list = List::from_vec(items); if let Some((line, col)) = meta_pos { - let meta = clj_value::new_map() - .insert( - Value::Keyword(Keyword::new(None, "line")), + // One shape intern for both keys (every list read pays + // this; the `insert` chain built two versions). + let meta = clj_value::Map::from_kvs_last_wins(vec![ + ( + Value::Keyword(clj_value::static_keyword!("line")), Value::Int(i64::from(line)), - ) - .insert( - Value::Keyword(Keyword::new(None, "column")), + ), + ( + Value::Keyword(clj_value::static_keyword!("column")), Value::Int(i64::from(col)), - ); + ), + ]); list = list.with_meta(Some(meta)); } Ok(Some(Value::List(list))) diff --git a/rust/crates/clj-reader/src/lib.rs b/rust/crates/clj-reader/src/lib.rs index 5acad56556..23070fe42b 100644 --- a/rust/crates/clj-reader/src/lib.rs +++ b/rust/crates/clj-reader/src/lib.rs @@ -110,6 +110,12 @@ impl Reader { } } + /// Report lines from `line` on (see [`CharStream::set_line`]): + /// an embedded file's form evaluated alone keeps its file lines. + pub fn set_start_line(&mut self, line: u32) { + self.stream.set_line(line); + } + /// Take the fired-discards log (S6): tags read under `#_` in /// defer mode, oldest first. The evaluator drains this before /// resolving each form (invoking for effect, in read order). diff --git a/rust/crates/clj-reader/src/metaform.rs b/rust/crates/clj-reader/src/metaform.rs index a9cf0303f6..2fb3182117 100644 --- a/rust/crates/clj-reader/src/metaform.rs +++ b/rust/crates/clj-reader/src/metaform.rs @@ -391,7 +391,7 @@ impl Reader { } if let Some(dotted) = self.opts.class_map.get(&name) { sym = Symbol::new(None, dotted); - } else if let Some((ns, name)) = self.opts.refers.get(&name) { + } else if let Some((ns, name)) = self.opts.refers.target(&name) { sym = Symbol::new(Some(ns), name); } else { let current = self.opts.current_ns.clone(); @@ -664,9 +664,9 @@ mod tests { ); // ... but a class namespace still rewrites first. let mut opts = ReaderOpts::default(); - opts.class_map + std::sync::Arc::make_mut(&mut opts.class_map) .insert("String".to_owned(), "java.lang.String".to_owned()); - opts.aliases + std::sync::Arc::make_mut(&mut opts.aliases) .insert("str".to_owned(), "clojure.string".to_owned()); assert_eq!( read_quoted("`String/b.c", &opts), diff --git a/rust/crates/clj-reader/src/opts.rs b/rust/crates/clj-reader/src/opts.rs index 0af4412cf2..5ac719e2f5 100644 --- a/rust/crates/clj-reader/src/opts.rs +++ b/rust/crates/clj-reader/src/opts.rs @@ -14,9 +14,43 @@ //! harness supplies live `*ns*` maps). use std::collections::{HashMap, HashSet}; +use std::hash::BuildHasher; +use std::sync::Arc; use clj_value::{Keyword, Symbol}; +/// Referred-var lookup for syntax-quote: a simple name's qualified +/// target `(ns, name)`. The evaluator hands over its namespace's own +/// refer table (shared, never copied per read — `user` refers every +/// `clojure.core` public); plain string maps serve tests. +pub trait Refers: Send + Sync + std::fmt::Debug { + /// The target `(ns, name)` a simple name refers to, if any. + fn target(&self, name: &str) -> Option<(&str, &str)>; +} + +impl Refers + for HashMap +{ + fn target(&self, name: &str) -> Option<(&str, &str)> { + self.get(name).map(|(ns, n)| (ns.as_str(), n.as_str())) + } +} + +/// A host refer table: simple name → target symbol plus whatever +/// the host pins beside it (the evaluator's var id). An unqualified +/// target is no refer to syntax-quote (it qualifies to the current +/// namespace instead). +impl Refers for HashMap, (Symbol, X), S> +where + X: Send + Sync + std::fmt::Debug, + S: BuildHasher + Send + Sync + std::fmt::Debug, +{ + fn target(&self, name: &str) -> Option<(&str, &str)> { + let (sym, _) = self.get(name)?; + Some((sym.namespace()?, sym.name())) + } +} + /// `:read-cond` handling: process conditionals, preserve them as /// [`Value::ReaderConditional`](clj_value::Value::ReaderConditional), /// or reject them. @@ -44,12 +78,17 @@ pub struct ReaderOpts { pub line_numbers: bool, /// Current namespace name (for `::` and syntax-quote). pub current_ns: String, - /// Namespace aliases (`str` → `clojure.string`). - pub aliases: HashMap, + /// Namespace aliases (`str` → `clojure.string`). The three + /// resolver maps are shared, not owned: [`Reader::set_opts`] + /// clones the options once per form, and `user` refers every + /// `clojure.core` public. + /// + /// [`Reader::set_opts`]: crate::Reader::set_opts + pub aliases: Arc>, /// Referred vars (`map` → `(clojure.core, map)`). - pub refers: HashMap, + pub refers: Arc, /// Imported classes (`String` → `java.lang.String`). - pub class_map: HashMap, + pub class_map: Arc>, /// Eval-side data-reader resolution (S6): the tag consult /// runs read-side (table keys, then the default-layer keys, /// then the default-fn flag, else the `UnknownTag` throw), while @@ -106,9 +145,9 @@ impl Default for ReaderOpts { features: HashSet::from([Keyword::new(None, "clj")]), line_numbers: true, current_ns: "user".to_owned(), - aliases: HashMap::new(), - refers: HashMap::new(), - class_map: HashMap::new(), + aliases: Arc::default(), + refers: Arc::new(HashMap::::new()), + class_map: Arc::default(), data_readers: false, data_reader_keys: HashSet::new(), data_readers_borked: None, diff --git a/rust/crates/clj-reader/src/stream.rs b/rust/crates/clj-reader/src/stream.rs index 00eb45298c..8dc1518af9 100644 --- a/rust/crates/clj-reader/src/stream.rs +++ b/rust/crates/clj-reader/src/stream.rs @@ -49,6 +49,13 @@ impl CharStream { this } + /// Count lines from `line` instead of 1 (a source slice that + /// begins at that line of its file reports the file's lines). + /// Only before the first read. + pub fn set_line(&mut self, line: u32) { + self.line = line; + } + /// Append `chunk` with the same normalization as [`Self::new`]. /// A chunk-trailing `\r` stages instead of pushing (a later leading /// `\n` completes the split `\r\n`); empty chunks preserve the flag. diff --git a/rust/crates/clj-reader/src/symbol.rs b/rust/crates/clj-reader/src/symbol.rs index 10ba318be4..44559a63ed 100644 --- a/rust/crates/clj-reader/src/symbol.rs +++ b/rust/crates/clj-reader/src/symbol.rs @@ -386,7 +386,7 @@ mod tests { #[test] fn auto_resolve() { let mut opts = ReaderOpts::default(); - opts.aliases + std::sync::Arc::make_mut(&mut opts.aliases) .insert("str".to_owned(), "clojure.string".to_owned()); assert_eq!( interpret_token("::foo", &opts, &mut NoHooks) diff --git a/rust/crates/clj-reader/tests/harness.rs b/rust/crates/clj-reader/tests/harness.rs index 97d94ef327..08fdf56d38 100644 --- a/rust/crates/clj-reader/tests/harness.rs +++ b/rust/crates/clj-reader/tests/harness.rs @@ -22,6 +22,7 @@ use std::borrow::Borrow; use std::collections::HashMap; +use std::sync::Arc; use clj_reader::{NoHooks, ReadCond, ReaderOpts, read_all}; use clj_value::{Keyword, PrintOpts, Symbol, Value, pr_to_string}; @@ -38,9 +39,9 @@ pub struct Case { /// The oracle resolution context (from `expectations.edn`). pub struct Resolver { pub current_ns: String, - pub aliases: HashMap, - pub refers: HashMap, - pub class_map: HashMap, + pub aliases: Arc>, + pub refers: Arc>, + pub class_map: Arc>, } /// One read form's oracle/snapshot renderings (`None` when @@ -180,9 +181,9 @@ pub fn parse_resolver(v: &Value) -> Resolver { .collect(); Resolver { current_ns: expect_string(map_get(r, "current-ns").unwrap()), - aliases: str_map("aliases"), - refers, - class_map: str_map("class-map"), + aliases: Arc::new(str_map("aliases")), + refers: Arc::new(refers), + class_map: Arc::new(str_map("class-map")), } } diff --git a/rust/crates/clj-value/src/extension.rs b/rust/crates/clj-value/src/extension.rs index 3384692e2a..16dd573c57 100644 --- a/rust/crates/clj-value/src/extension.rs +++ b/rust/crates/clj-value/src/extension.rs @@ -367,11 +367,15 @@ fn throw_parent_format(jvm_name: &str) -> Option<&'static str> { } /// The array/error branch of [`throw_parent`] (S15, split for the -/// complexity budget): `AIOOBE` under `IOOBE`, store/size under +/// complexity budget): `AIOOBE` (and the string `SIOOBE`, #226) under +/// `IOOBE`, store/size under /// `RuntimeException`, `OOM` through `VirtualMachineError`. fn throw_parent_array(jvm_name: &str) -> Option<&'static str> { match jvm_name { - "java.lang.ArrayIndexOutOfBoundsException" => Some("java.lang.IndexOutOfBoundsException"), + "java.lang.ArrayIndexOutOfBoundsException" + | "java.lang.StringIndexOutOfBoundsException" => { + Some("java.lang.IndexOutOfBoundsException") + } "java.lang.ArrayStoreException" | "java.lang.NegativeArraySizeException" => { Some("java.lang.RuntimeException") } @@ -431,10 +435,19 @@ pub trait ExtensionValue: std::fmt::Debug + Send + Sync { /// The `str`/`print` display (used when not `readably`): /// `None` keeps [`print`](ExtensionValue::print) (the default - /// — only `MathContext` overrides it, S2 oracle `wp-str`). + /// — only `MathContext` and namespaces override it, S2 oracle + /// `wp-str`). fn print_str(&self) -> Option { None } + + /// The Java `toString` (`str`, `%s`, `.toString`) when it + /// differs from the non-readable display: `None` keeps + /// [`print_str`](ExtensionValue::print_str) (only namespaces + /// override it — their name). + fn java_str(&self) -> Option { + None + } } #[cfg(test)] diff --git a/rust/crates/clj-value/src/keyword.rs b/rust/crates/clj-value/src/keyword.rs index 26a392f4ea..94b84fc471 100644 --- a/rust/crates/clj-value/src/keyword.rs +++ b/rust/crates/clj-value/src/keyword.rs @@ -11,7 +11,7 @@ //! its arcs, so `identical?` is pointer equality. use std::cmp::Ordering; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::sync::{Arc, LazyLock, Mutex}; @@ -21,13 +21,34 @@ use crate::FxBuildHasher; /// One interned keyword's canonical arcs. type CanonicalKeyword = (Option>, Arc); -/// The intern table (fence 4a): `(ns, name)` arcs canonicalized -/// across every construction, like the JVM's `KeywordTable` (strong -/// refs — entries live as long as the process, same as interned -/// keywords on the JVM). Fx-hashed: every keyword construction -/// probes this table. -static KEYWORD_TABLE: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::default())); +/// The intern tables (fence 4a): arcs canonicalized across every +/// construction, like the JVM's `KeywordTable` (strong refs — +/// entries live as long as the process, same as interned keywords +/// on the JVM). Fx-hashed: every keyword construction probes them. +/// Plain keywords (most: `:line` on every read list, `:macro` on +/// every analyzed head) probe by `&str`, allocating only on first +/// interning; qualified ones probe by their `(ns, name)` pair. +#[derive(Default)] +struct KeywordTable { + plain: HashSet, FxBuildHasher>, + qualified: HashMap, +} + +static KEYWORD_TABLE: LazyLock> = + LazyLock::new(|| Mutex::new(KeywordTable::default())); + +/// A plain keyword interned once per call site +/// (`static_keyword!("line")`): later uses clone the cached arcs +/// instead of probing the intern table under its lock. The cached +/// value came from [`Keyword::new`], so identity is canonical. +#[macro_export] +macro_rules! static_keyword { + ($name:literal) => {{ + static KW: ::std::sync::LazyLock<$crate::Keyword> = + ::std::sync::LazyLock::new(|| $crate::Keyword::new(None, $name)); + KW.clone() + }}; +} /// A Clojure keyword such as `:ok` or `:user/id`. #[derive(Clone, Debug)] @@ -43,8 +64,20 @@ impl Keyword { #[must_use] pub fn new(ns: Option<&str>, name: &str) -> Self { let mut table = KEYWORD_TABLE.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ns) = ns else { + let name = match table.plain.get(name) { + Some(arc) => Arc::clone(arc), + None => { + let arc: Arc = Arc::from(name); + table.plain.insert(Arc::clone(&arc)); + arc + } + }; + return Self { ns: None, name }; + }; let entry = table - .entry((ns.map(Arc::from), Arc::from(name))) + .qualified + .entry((Some(Arc::from(ns)), Arc::from(name))) .or_insert_with_key(|k| k.clone()); Self { ns: entry.0.clone(), diff --git a/rust/crates/clj-value/src/lib.rs b/rust/crates/clj-value/src/lib.rs index 2f03c29543..b06cfe542f 100644 --- a/rust/crates/clj-value/src/lib.rs +++ b/rust/crates/clj-value/src/lib.rs @@ -38,6 +38,7 @@ mod seq; mod symbol; mod tower; mod transient; +mod trie; mod value; mod volatile; @@ -83,7 +84,7 @@ pub use seq::{ CHUNK_SIZE, ConsValue, CycleValue, IterateValue, KeySeqValue, RangeNum, RangeValue, RealizedSeq, RepeatValue, Seq, SeqKind, SubVectorValue, ValSeqValue, seq_vec, }; -pub use symbol::Symbol; +pub use symbol::{SymKey, Symbol}; pub use tower::{ BAD_CTX_FORMAT, BigDec, DIGITS_LT_0, MathContext, NON_TERMINATING, ROUNDING_NECESSARY, RoundingMode, bigint_parse, bigint_parse_radix, @@ -92,8 +93,8 @@ pub use transient::{ Transient, TransientError, TransientKind, TransientMap, TransientSet, TransientVector, }; pub use value::{ - FxBuildHasher, List, Map, Set, SortSpec, Value, Vector, bigdec_result, extension_supports_meta, - extension_with_meta, intern_literal_strings, intern_string, intern_string_value, map_entry, - new_map, new_set, reader_conditional, tagged_literal, + FxBuildHasher, List, Map, MemberSet, Set, SortSpec, Value, Vector, bigdec_result, + extension_supports_meta, extension_with_meta, intern_literal_strings, intern_string, + intern_string_value, map_entry, new_map, new_set, reader_conditional, tagged_literal, }; pub use volatile::Volatile; diff --git a/rust/crates/clj-value/src/pvec.rs b/rust/crates/clj-value/src/pvec.rs index f8c3d911e7..5f57f083e2 100644 --- a/rust/crates/clj-value/src/pvec.rs +++ b/rust/crates/clj-value/src/pvec.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use crate::{Map, Value}; -type RpdsVector = rpds::VectorSync; +type RpdsVector = crate::trie::PVec; /// The eight `vector-of` element types. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/rust/crates/clj-value/src/seq.rs b/rust/crates/clj-value/src/seq.rs index 5ffe5e22f2..918fdd1110 100644 --- a/rust/crates/clj-value/src/seq.rs +++ b/rust/crates/clj-value/src/seq.rs @@ -285,6 +285,47 @@ impl Seq { self.next().unwrap_or_else(Self::empty) } + /// The remaining item count, read off the backing arrays without + /// cloning items (the JVM's counted seqs count in O(1), and + /// `empty?` counts counted seqs). Cons cells and lazy tails still + /// step, forcing like a [`first`](Self::first)/[`next`](Self::next) + /// walk, which this matches item for item. + #[must_use] + pub fn count(&self) -> usize { + let mut n = 0; + let mut cur = self.forced(); + while let Some(s) = cur { + cur = match s { + Seq::Eager { items, pos } => { + n += items.len().saturating_sub(*pos); + None + } + Seq::Chunked { chunks, chunk, pos } => { + if let Some(c) = chunks.get(*chunk).filter(|c| *pos < c.len()) { + n += c.len() - pos; + // A walk stops at an empty chunk (`first` is `None`). + n += chunks[chunk + 1..] + .iter() + .take_while(|c| !c.is_empty()) + .map(|c| c.len()) + .sum::(); + } + None + } + Seq::ChunkCons { chunk, pos, tail } => { + n += chunk.len().saturating_sub(*pos); + tail.forced() + } + Seq::Cons { tail, .. } => { + n += 1; + tail.as_deref().and_then(Seq::forced) + } + l @ Seq::Lazy { .. } => l.forced(), + }; + } + n + } + /// The current chunk plus the seq after it (`None` unless this /// seq is chunked here). The rest is the empty seq past the last /// chunk. @@ -989,6 +1030,27 @@ impl RangeValue { Some((head, next)) } + /// [`stepped`](Self::stepped) in place: the head, with this range + /// advanced past it and its metadata dropped (`None` when done). + /// The flag is `false` on `Int`+`Int` overflow, which leaves the + /// range unadvanced (the walk ends there). Chunk fills step 32 + /// heads per tail, so they skip `stepped`'s fresh tail per head. + pub fn step_in_place(&mut self) -> Option<(Value, bool)> { + if self.done() { + return None; + } + let head = self.cur.value(); + match self.advance() { + Some(nxt) => { + self.cur = nxt; + self.count = self.count.map(|c| c - 1); + self.meta = None; + Some((head, true)) + } + None => Some((head, false)), + } + } + /// Call `f` per item (pure walk; infinite ranges never return, /// like the JVM's `hashCode` walk; a `long overflow` ends the /// walk early — the `hash` fn realizes first and reports it). @@ -1525,6 +1587,54 @@ mod tests { h.finish() } + fn ints(r: std::ops::Range) -> Vec { + r.map(int).collect() + } + + /// `count` must equal the `first`/`next` walk length (what it + /// replaced) on every shape, crafted cursor states included. + #[test] + fn count_matches_walk_on_every_shape() { + let walked = |s: &Seq| RealizedSeq::new(s.clone()).items().len(); + let chunked = Seq::chunked(ints(0..70)).expect("chunked"); + let mut cases = vec![ + Seq::empty(), + Seq::eager(ints(0..5)).expect("eager"), + chunked.clone(), + Seq::chunk_cons(ints(0..3), Seq::eager(ints(3..5)).expect("tail")), + Seq::cons(int(0), Some(Seq::cons(int(1), None))), + Seq::cons(int(0), Some(Seq::empty())), + Seq::lazy(|| None), + Seq::lazy(|| Some(Seq::lazy(|| Seq::eager(ints(0..4))))), + // A spent cursor and an empty middle chunk end the walk. + Seq::Chunked { + chunks: Arc::from(vec![Arc::from(ints(0..2))]), + chunk: 0, + pos: 5, + }, + Seq::Chunked { + chunks: Arc::from(vec![ + Arc::from(ints(0..2)), + Arc::from(Vec::new()), + Arc::from(ints(2..3)), + ]), + chunk: 0, + pos: 0, + }, + ]; + // Mid-walk cursors: inside and across chunk boundaries. + let mut cur = chunked; + for _ in 0..69 { + cur = cur.next().expect("70 items"); + cases.push(cur.clone()); + } + for s in &cases { + assert_eq!(s.count(), walked(s), "{s:?}"); + } + assert_eq!(cases[2].count(), 70); + assert_eq!(cases[9].count(), 2); + } + #[test] fn seq_debug_names_shapes() { assert_eq!(format!("{:?}", Seq::empty()), "Eager { len: 0, pos: 0 }"); diff --git a/rust/crates/clj-value/src/symbol.rs b/rust/crates/clj-value/src/symbol.rs index 4696e5e309..e1051aed93 100644 --- a/rust/crates/clj-value/src/symbol.rs +++ b/rust/crates/clj-value/src/symbol.rs @@ -19,6 +19,7 @@ //! a `Symbol` per read, so distinct-but-equal symbols are never //! identical. +use std::borrow::Borrow; use std::cmp::Ordering; use std::fmt; use std::hash::{Hash, Hasher}; @@ -57,6 +58,18 @@ impl Symbol { } } + /// Build a symbol from already-shared parts (no copies; tables + /// keyed by name hand theirs over). + #[must_use] + pub fn from_shared(ns: Option>, name: Arc) -> Self { + Self { + ns, + name, + meta: None, + id: next_symbol_id(), + } + } + /// `Symbol::intern` semantics (`docs/spec/t1174/reader-contract.md` §4): /// split on the FIRST `/` (`a/b/c` ⇒ ns `a`, name `b/c`; `/` /// alone ⇒ no-ns name `/`). Used by the reader; API users who @@ -82,6 +95,13 @@ impl Symbol { &self.name } + /// The name part's shared string (tables keyed by simple name + /// share it instead of copying). + #[must_use] + pub fn name_arc(&self) -> &Arc { + &self.name + } + /// The metadata map, if any. #[must_use] pub fn meta(&self) -> Option<&Map> { @@ -122,6 +142,61 @@ impl Hash for Symbol { } } +/// A borrowed `(ns, name)` view of a symbol: hashes and compares +/// exactly like [`Symbol`] (metadata and id ignored), so a +/// symbol-keyed map probes `(Some("ns"), "name")` without building +/// (and allocating) a symbol — `map.get(&(ns, name) as &dyn SymKey)`. +pub trait SymKey { + /// The namespace part, if any. + fn key_ns(&self) -> Option<&str>; + /// The name part. + fn key_name(&self) -> &str; +} + +impl SymKey for Symbol { + fn key_ns(&self) -> Option<&str> { + self.ns.as_deref() + } + + fn key_name(&self) -> &str { + &self.name + } +} + +impl SymKey for (Option<&str>, &str) { + fn key_ns(&self) -> Option<&str> { + self.0 + } + + fn key_name(&self) -> &str { + self.1 + } +} + +// Byte-identical to `Symbol`'s hash: `Option>` and +// `Option<&str>` hash the same discriminant, and `Arc`/`&str` +// both hash as `str`. +impl Hash for dyn SymKey + '_ { + fn hash(&self, state: &mut H) { + self.key_ns().hash(state); + self.key_name().hash(state); + } +} + +impl PartialEq for dyn SymKey + '_ { + fn eq(&self, other: &Self) -> bool { + self.key_ns() == other.key_ns() && self.key_name() == other.key_name() + } +} + +impl Eq for dyn SymKey + '_ {} + +impl<'a> Borrow for Symbol { + fn borrow(&self) -> &(dyn SymKey + 'a) { + self + } +} + impl PartialOrd for Symbol { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) @@ -145,6 +220,34 @@ impl fmt::Display for Symbol { #[cfg(test)] mod tests { + #[test] + // Symbol keys carry (immutable) metadata maps; hashing ignores it. + #[allow(clippy::mutable_key_type)] + fn sym_key_probes_like_a_symbol() { + use super::{SymKey, Symbol}; + use std::collections::HashMap; + use std::hash::{BuildHasher, BuildHasherDefault}; + let fx = BuildHasherDefault::::default(); + let std_hash = std::collections::hash_map::RandomState::new(); + for (ns, name) in [ + (Some("user"), "x"), + (None, "x"), + (Some(""), ""), + (None, "a/b"), + ] { + let sym = Symbol::new(ns, name); + let key: &dyn SymKey = &(ns, name); + assert_eq!(fx.hash_one(&sym), fx.hash_one(key)); + assert_eq!(std_hash.hash_one(&sym), std_hash.hash_one(key)); + } + let mut map: HashMap = HashMap::new(); + map.insert(Symbol::new(Some("user"), "x"), 1); + map.insert(Symbol::new(None, "x"), 2); + assert_eq!(map.get(&(Some("user"), "x") as &dyn SymKey), Some(&1)); + assert_eq!(map.get(&(None, "x") as &dyn SymKey), Some(&2)); + assert_eq!(map.get(&(Some("other"), "x") as &dyn SymKey), None); + } + use super::Symbol; #[test] diff --git a/rust/crates/clj-value/src/trie.rs b/rust/crates/clj-value/src/trie.rs new file mode 100644 index 0000000000..5f17134a4e --- /dev/null +++ b/rust/crates/clj-value/src/trie.rs @@ -0,0 +1,424 @@ +// Copyright (c) Hao Hu. All rights reserved. +// The use and distribution terms for this software are covered by the +// Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +// which can be found in the file epl-v10.html at the root of this distribution. +// By using this software in any fashion, you are agreeing to be bound by +// the terms of this license. + +//! The persistent vector trie (#226): Clojure's `PersistentVector` +//! layout — a 32-way trie of full leaves plus a tail of the last 1–32 +//! items — with the items stored inline in the leaves. +//! +//! Every handle shares structure through `Arc`s; the `*_mut` writers +//! update through `Arc::make_mut`, so a uniquely owned vector mutates +//! in place while one sharing nodes with other versions path-copies +//! only the shared nodes (the `rpds` contract this replaces, without +//! its per-item allocation and with an O(1) tail for appends and +//! reads near the end). + +use std::sync::Arc; + +use crate::Value; + +const BITS: u32 = 5; +const WIDTH: usize = 1 << BITS; +const MASK: usize = WIDTH - 1; + +/// A trie node: a branch's children, or a full leaf's items (the +/// other side empty — the node's level says which, by construction). +#[derive(Clone, Debug, Default)] +struct Node { + kids: Vec>, + items: Vec, +} + +impl Node { + fn branch(kids: Vec>) -> Self { + Self { + kids, + items: Vec::new(), + } + } + + fn leaf(items: Vec) -> Self { + Self { + kids: Vec::new(), + items, + } + } +} + +/// A persistent vector of values. +#[derive(Clone, Debug)] +pub struct PVec { + len: usize, + /// The root's level: its children index by `(i >> shift) & MASK` + /// (`BITS` when the root's children are leaves). + shift: u32, + /// The trie of full leaves (`None` while every item is in the + /// tail). + root: Option>, + tail: Arc>, +} + +impl Default for PVec { + fn default() -> Self { + Self::new_sync() + } +} + +impl PVec { + /// An empty vector. + #[must_use] + pub fn new_sync() -> Self { + Self { + len: 0, + shift: BITS, + root: None, + tail: Arc::new(Vec::new()), + } + } + + /// A vector over `items` in order (up to 32 become the tail as + /// is — no per-item push). + #[must_use] + pub fn from_vec(items: Vec) -> Self { + if items.len() <= WIDTH { + return Self { + len: items.len(), + shift: BITS, + root: None, + tail: Arc::new(items), + }; + } + items.into_iter().collect() + } + + /// The item count. + #[must_use] + pub fn len(&self) -> usize { + self.len + } + + /// Whether the vector is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Where the tail starts. + fn tail_offset(&self) -> usize { + self.len - self.tail.len() + } + + /// The full leaf holding index `i` (below the tail offset). + fn leaf(&self, i: usize) -> &[Value] { + let mut node = self + .root + .as_deref() + .expect("indexes below the tail have a trie"); + let mut level = self.shift; + while level > 0 { + node = &node.kids[(i >> level) & MASK]; + level -= BITS; + } + &node.items + } + + /// The item at `i` (`None` past the end). + #[must_use] + pub fn get(&self, i: usize) -> Option<&Value> { + if i >= self.len { + return None; + } + let off = self.tail_offset(); + if i >= off { + return self.tail.get(i - off); + } + self.leaf(i).get(i & MASK) + } + + /// Append an item. + pub fn push_back_mut(&mut self, v: Value) { + if self.tail.len() < WIDTH { + Arc::make_mut(&mut self.tail).push(v); + self.len += 1; + return; + } + // The full tail moves into the trie as a leaf. + let full = std::mem::replace(&mut self.tail, Arc::new(Vec::with_capacity(WIDTH))); + let leaf = Arc::new(Node::leaf(Arc::unwrap_or_clone(full))); + let at = self.len - WIDTH; + match self.root.take() { + None => self.root = Some(Arc::new(Node::branch(vec![leaf]))), + Some(root) if (at >> BITS) >= (1 << self.shift) => { + // The root is full: a new level over it. + let path = new_path(self.shift, leaf); + self.root = Some(Arc::new(Node::branch(vec![root, path]))); + self.shift += BITS; + } + Some(mut root) => { + push_leaf(Arc::make_mut(&mut root), self.shift, at, leaf); + self.root = Some(root); + } + } + Arc::make_mut(&mut self.tail).push(v); + self.len += 1; + } + + /// Replace the item at `i` (`false` when out of bounds). + pub fn set_mut(&mut self, i: usize, v: Value) -> bool { + if i >= self.len { + return false; + } + let off = self.tail_offset(); + if i >= off { + Arc::make_mut(&mut self.tail)[i - off] = v; + return true; + } + let mut node = Arc::make_mut( + self.root + .as_mut() + .expect("indexes below the tail have a trie"), + ); + let mut level = self.shift; + while level > 0 { + node = Arc::make_mut(&mut node.kids[(i >> level) & MASK]); + level -= BITS; + } + node.items[i & MASK] = v; + true + } + + /// Drop the last item (`false` when empty). + pub fn drop_last_mut(&mut self) -> bool { + if self.len == 0 { + return false; + } + if self.tail.len() > 1 || self.root.is_none() { + Arc::make_mut(&mut self.tail).pop(); + self.len -= 1; + return true; + } + // The tail empties: the trie's last leaf becomes the tail. + let mut root = self.root.take().expect("guarded above"); + let leaf = pop_leaf(Arc::make_mut(&mut root), self.shift); + self.tail = Arc::new(leaf); + self.len -= 1; + // A root left with one child sheds a level (no root at all + // when the trie emptied). + self.root = match root.kids.len() { + 0 => None, + 1 if self.shift > BITS => { + self.shift -= BITS; + Some(Arc::clone(&root.kids[0])) + } + _ => Some(root), + }; + true + } + + /// Items front to back. + #[must_use] + pub fn iter(&self) -> Iter<'_> { + Iter { + vec: self, + front: 0, + back: self.len, + front_leaf: &[], + front_base: 0, + back_leaf: &[], + back_base: 0, + } + } +} + +/// A path of branches from `level` down to `leaf`. +fn new_path(level: u32, leaf: Arc) -> Arc { + if level == 0 { + return leaf; + } + Arc::new(Node::branch(vec![new_path(level - BITS, leaf)])) +} + +/// Push a full leaf for index `at` into `node` (a branch at `level` +/// with room below it). +fn push_leaf(node: &mut Node, level: u32, at: usize, leaf: Arc) { + let kids = &mut node.kids; + let slot = (at >> level) & MASK; + if level == BITS { + kids.push(leaf); + } else if slot < kids.len() { + push_leaf(Arc::make_mut(&mut kids[slot]), level - BITS, at, leaf); + } else { + kids.push(new_path(level - BITS, leaf)); + } +} + +/// Remove the last leaf under `node` (a branch at `level`), pruning +/// branches it empties; answers the leaf's items. +fn pop_leaf(node: &mut Node, level: u32) -> Vec { + let kids = &mut node.kids; + if level == BITS { + let leaf = kids.pop().expect("a branch holds at least one child"); + return Arc::unwrap_or_clone(leaf).items; + } + let last = kids.last_mut().expect("a branch holds at least one child"); + let items = pop_leaf(Arc::make_mut(last), level - BITS); + if last.kids.is_empty() { + kids.pop(); + } + items +} + +/// Front-to-back item iteration (double-ended), one leaf lookup per +/// 32 items. +pub struct Iter<'a> { + vec: &'a PVec, + front: usize, + back: usize, + front_leaf: &'a [Value], + front_base: usize, + back_leaf: &'a [Value], + back_base: usize, +} + +impl<'a> Iter<'a> { + /// The leaf (or tail) holding index `i`, with its first index. + fn chunk(&self, i: usize) -> (&'a [Value], usize) { + let off = self.vec.tail_offset(); + if i >= off { + (&self.vec.tail[..], off) + } else { + (self.vec.leaf(i), i & !MASK) + } + } +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a Value; + + fn next(&mut self) -> Option<&'a Value> { + if self.front >= self.back { + return None; + } + let i = self.front; + if i < self.front_base || i >= self.front_base + self.front_leaf.len() { + (self.front_leaf, self.front_base) = self.chunk(i); + } + self.front += 1; + Some(&self.front_leaf[i - self.front_base]) + } + + fn size_hint(&self) -> (usize, Option) { + let n = self.back - self.front; + (n, Some(n)) + } +} + +impl DoubleEndedIterator for Iter<'_> { + fn next_back(&mut self) -> Option { + if self.front >= self.back { + return None; + } + let i = self.back - 1; + if i < self.back_base || i >= self.back_base + self.back_leaf.len() { + (self.back_leaf, self.back_base) = self.chunk(i); + } + self.back = i; + Some(&self.back_leaf[i - self.back_base]) + } +} + +impl ExactSizeIterator for Iter<'_> {} + +impl FromIterator for PVec { + fn from_iter>(items: I) -> Self { + let mut out = PVec::new_sync(); + for v in items { + out.push_back_mut(v); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ints(v: &PVec) -> Vec { + v.iter() + .filter_map(|x| match x { + Value::Int(n) => Some(*n), + _ => None, + }) + .collect() + } + + #[test] + fn grows_reads_and_shrinks_across_levels() { + // Past 32 + 32*32 + 32*32*32 items: three trie levels plus + // the tail, then all the way back down. + let n = 40_000usize; + let mut v = PVec::new_sync(); + let mut versions = Vec::new(); + for i in 0..n { + if i % 9_973 == 0 { + versions.push((i, v.clone())); + } + v.push_back_mut(Value::Int(i as i64)); + } + assert_eq!(v.len(), n); + assert!(!v.is_empty()); + for i in [0, 31, 32, 1_055, 1_056, 33_823, 33_824, n - 1] { + assert!(matches!(v.get(i), Some(Value::Int(x)) if *x == i as i64)); + } + assert!(v.get(n).is_none()); + assert_eq!(ints(&v), (0..n as i64).collect::>()); + let back: Vec = ints(&v.iter().rev().cloned().collect()); + assert_eq!(back, (0..n as i64).rev().collect::>()); + // Older versions are untouched by later pushes. + for (len, old) in &versions { + assert_eq!(old.len(), *len); + assert_eq!(ints(old), (0..*len as i64).collect::>()); + } + let shared = v.clone(); + assert!(v.set_mut(5, Value::Int(-5))); + assert!(v.set_mut(n - 1, Value::Int(-1))); + assert!(!v.set_mut(n, Value::Nil)); + assert!(matches!(v.get(5), Some(Value::Int(-5)))); + assert!(matches!(shared.get(5), Some(Value::Int(5)))); + while v.drop_last_mut() {} + assert!(v.is_empty()); + assert!(!v.drop_last_mut()); + assert_eq!(ints(&shared).len(), n); + } + + #[test] + fn iterates_from_both_ends() { + let v: PVec = (0..100).map(Value::Int).collect(); + let mut it = v.iter(); + assert_eq!(it.len(), 100); + assert!(matches!(it.next(), Some(Value::Int(0)))); + assert!(matches!(it.next_back(), Some(Value::Int(99)))); + let rest: Vec<_> = it.cloned().collect(); + assert_eq!(rest.len(), 98); + let mut it = v.iter(); + for _ in 0..50 { + it.next_back(); + } + for _ in 0..50 { + it.next(); + } + assert!(it.next().is_none()); + assert!(it.next_back().is_none()); + assert_eq!(PVec::default().len(), 0); + assert!(format!("{:?}", PVec::new_sync()).contains("PVec")); + let small = PVec::from_vec(vec![Value::Int(1), Value::Int(2)]); + assert_eq!(ints(&small), vec![1, 2]); + let big = PVec::from_vec((0..40).map(Value::Int).collect()); + assert_eq!(ints(&big), (0..40).collect::>()); + let mixed = PVec::from_vec(vec![Value::Nil, Value::Int(3)]); + assert_eq!(ints(&mixed), vec![3]); + } +} diff --git a/rust/crates/clj-value/src/value.rs b/rust/crates/clj-value/src/value.rs index 3c63cfd5a5..fc60e7f952 100644 --- a/rust/crates/clj-value/src/value.rs +++ b/rust/crates/clj-value/src/value.rs @@ -52,7 +52,8 @@ use crate::{ pub type FxBuildHasher = std::hash::BuildHasherDefault; type RpdsList = rpds::ListSync; -type RpdsVector = rpds::VectorSync; +/// The persistent vector trie (#226: items inline, an O(1) tail). +type RpdsVector = crate::trie::PVec; type RpdsMap = rpds::HashTrieMapSync; type RpdsSet = rpds::HashTrieSetSync; @@ -273,7 +274,7 @@ pub struct VectorIter<'a> { } enum VectorIterInner<'a> { - Generic(rpds::vector::Iter<'a, Value, archery::ArcTK>), + Generic(crate::trie::Iter<'a>), Packed(std::slice::Iter<'a, i64>), } @@ -376,6 +377,10 @@ struct SmallMap { struct ShapeRegistry { map: HashMap, Weak, FxBuildHasher>, since_cleanup: usize, + /// The last all-keyword shape interned: runs of same-shape maps + /// (every fresh var's `{:ns … :name …}`) skip the hash probe. + /// Kept alive here, so its table entry never goes stale. + last: Option>, } /// Dead-entry cleanup cadence: a cleanup scans the whole table, @@ -394,6 +399,7 @@ thread_local! { static SHAPE_REGISTRY: RefCell = RefCell::new(ShapeRegistry { map: HashMap::default(), since_cleanup: 0, + last: None, }); } @@ -405,13 +411,27 @@ thread_local! { fn intern_shape(keys: &[Value]) -> Arc { SHAPE_REGISTRY.with(|reg| { let mut reg = reg.borrow_mut(); + // Keyword keys compare unambiguously (no cross-type `=`), so + // the shortcut agrees with the table's own `Eq`. + let keywords = keys.iter().all(|k| matches!(k, Value::Keyword(_))); + if keywords { + if let Some(last) = reg.last.as_ref().filter(|l| *l.keys == *keys) { + return Arc::clone(last); + } + } if let Some(shape) = reg.map.get(keys).and_then(Weak::upgrade) { + if keywords { + reg.last = Some(Arc::clone(&shape)); + } return shape; } let shape = Arc::new(MapShape { keys: keys.to_vec().into_boxed_slice(), }); reg.map.insert(keys.to_vec(), Arc::downgrade(&shape)); + if keywords { + reg.last = Some(Arc::clone(&shape)); + } reg.since_cleanup += 1; if reg.since_cleanup >= SHAPE_CLEANUP_EVERY && reg.since_cleanup.saturating_mul(SHAPE_CLEANUP_AMORT) >= reg.map.len() @@ -612,14 +632,11 @@ impl Vector { None => return Self::from_ints(ints), Some(Value::Int(n)) => ints.push(n), Some(v) => { - let mut trie = RpdsVector::new_sync(); - for n in ints { - trie.push_back_mut(Value::Int(n)); - } - trie.push_back_mut(v); - for v in items { - trie.push_back_mut(v); - } + let mut all = Vec::with_capacity(ints.len() + 1 + items.len()); + all.extend(ints.into_iter().map(Value::Int)); + all.push(v); + all.extend(items); + let trie = RpdsVector::from_vec(all); return Self { inner: VectorInner::Generic(trie), meta: None, @@ -1019,6 +1036,28 @@ impl Map { Self::from_parts(keys, vals, hashy) } + /// A small map over distinct keyword `keys` (in order) and their + /// `vals`: no dedupe scan, and a run of same-keyed maps reuses + /// the last interned shape without probing (every fresh var's + /// `{:ns … :name …}`). Callers guarantee the keys are distinct + /// keywords, at most 8 (array-classed, like the JVM's). + #[must_use] + pub fn from_distinct_keywords(keys: &[Value], vals: Vec) -> Self { + debug_assert!(keys.len() == vals.len() && keys.len() <= 8 && !keys.is_empty()); + debug_assert!(keys.iter().all(|k| matches!(k, Value::Keyword(_)))); + Self { + inner: None, + small: Some(Arc::new(SmallMap { + shape: intern_shape(keys), + vals: vals.into_boxed_slice(), + })), + keys: EMPTY_MAP_KEYS.clone(), + meta: None, + hashy: false, + sort: None, + } + } + /// Build from deduped parallel keys/vals (big maps take the /// hash trie directly; small ones intern one shape). fn from_parts(keys: Vec, vals: Vec, hashy: bool) -> Self { @@ -1427,6 +1466,23 @@ impl Map { } } +/// A persistent membership set (no order, no metadata, no +/// identity): O(1) clone, O(log n) insert even when shared — for +/// lazy-seq state that must survive per-step clones (`distinct`). +#[derive(Clone, Debug, Default)] +pub struct MemberSet(RpdsSet); + +impl MemberSet { + /// Insert `v`; `false` when it was already a member. + pub fn insert(&mut self, v: Value) -> bool { + if self.0.contains(&v) { + return false; + } + self.0.insert_mut(v); + true + } +} + impl Set { /// Build a set from items, duplicate-checking them with the JVM /// literal rules: `Err` is the NEW (repeated) item. diff --git a/rust/src/bin/clojure-rs/main.rs b/rust/src/bin/clojure-rs/main.rs index dd271e7d63..a0655a9c73 100644 --- a/rust/src/bin/clojure-rs/main.rs +++ b/rust/src/bin/clojure-rs/main.rs @@ -12,6 +12,12 @@ //! `docs/spec/t1174/`), `jit` in gap-121 slice 1 (spec //! `docs/spec/gap-121/slice-1.md`). +/// mimalloc (#226): the interpreter allocates many small, short-lived +/// blocks (reader lists, analyzed forms, seq steps), where it is +/// markedly cheaper than the platform allocators. +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + use clap::{Parser, Subcommand}; use clj_eval::{Evaluator, SourceError, boot, compile_diagnostic, compile_head}; use clj_value::Value; @@ -154,16 +160,20 @@ fn cmd_eval(form: &str, edn: bool) -> ExitCode { /// Evaluate and print each form (split from `cmd_eval` so the exit /// join runs on every path, success and failure alike). fn cmd_eval_forms(eval: &mut Evaluator, form: &str) -> ExitCode { - // Live hooks (S7): `#=` evaluates during the read, like `eval_source`. - let forms = match eval.read_forms_live(form) { - Ok(forms) => forms, - Err(e) => { - eprintln!("{}", eval_diagnostic(&e)); - return ExitCode::from(1); - } - }; - for form in &forms { - let value = match eval.eval_top_form(form) { + // Live hooks (S7): `#=` evaluates during the read, like + // `eval_source`; each form reads after the previous evaluates + // (clojure.main's `-e` loop), so `(ns foo) ::kw` is `:foo/kw`. + let mut forms = eval.live_forms(form); + loop { + let form = match eval.next_live_form(&mut forms) { + Ok(Some(form)) => form, + Ok(None) => break, + Err(e) => { + eprintln!("{}", eval_diagnostic(&e)); + return ExitCode::from(1); + } + }; + let value = match eval.eval_top_form(&form) { Ok(value) => value, Err(e) => { eprintln!("{}", eval_diagnostic(&e)); @@ -312,6 +322,12 @@ fn cmd_run(file: &Path, args: &[String]) -> ExitCode { // Exit join (see `cmd_eval`): drained agents and futures print // before the process goes away, like the JVM's non-daemon wait. eval.join_drainers(); + // The process ends here: tearing the runtime down var by var + // (every namespace, fn and value still reachable) is pure exit + // cost (#226), and the JVM runs no finalizers at exit either. + // Nothing buffers behind it: stdout is std's (flushed at exit) + // and file writers write through. + std::mem::forget(eval); code } diff --git a/rust/tests/cli.rs b/rust/tests/cli.rs index 5b7db9cc62..26a8d5639c 100644 --- a/rust/tests/cli.rs +++ b/rust/tests/cli.rs @@ -154,6 +154,20 @@ fn eval_multi_form_prints_each_value() { assert_eq!(stdout(&out), "1\n2\n"); } +#[test] +fn eval_reads_each_form_after_the_previous_evaluates() { + // JVM `-e` parity: `clojure.main` reads each form after the + // previous one evaluates, so `ns` reaches a later `::kw` and + // earlier values print before a later read error. + let out = run(["eval", "(ns cli.kw) ::kw"]); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(stdout(&out), ":cli.kw/kw\n"); + let out = run(["eval", "1 )"]); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(stdout(&out), "1\n"); + assert_eq!(stderr(&out), ":1:4: Unmatched delimiter: )\n"); +} + #[test] fn eval_suppresses_nil_results() { // JVM `-e` prints nothing for nil (pins P5). From 43d073315e4a5ba48663b10a488d1d0afa47e5db Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:29:54 +0100 Subject: [PATCH 03/20] Gate PRs on the sharded JVM-vs-native CPU/RSS head-to-head PR225 proved the 4-leg sweep (recipes + algorithms, JVM vs clojure-rs, per-case median cpu_s/maxrss_kb) on a workstation; without CI the signal rots the moment the next perf PR lands. Encode the session recipe as a required workflow: 8 shards x 2 runtimes x 2 suites (32 measure jobs) plus a strict fan-in gate that fails the PR on ANY loss. The runtime under test builds from the PR itself (interpreter-only release, once per run, fanned out as an artifact); suite revisions pin to master SHAs with dispatch overrides for bisection. No Bencher reporting by design: this gates, it does not track history. AI-Tool: Muse Code (Meta Muse Spark --- .github/workflows/h2h-cpu-mem.yml | 393 ++++++++++++++++++++++++++++++ scripts/ci/h2h_compare.py | 303 +++++++++++++++++++++++ 2 files changed, 696 insertions(+) create mode 100644 .github/workflows/h2h-cpu-mem.yml create mode 100755 scripts/ci/h2h_compare.py diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml new file mode 100644 index 0000000000..71046e79bb --- /dev/null +++ b/.github/workflows/h2h-cpu-mem.yml @@ -0,0 +1,393 @@ +# Head-to-head CPU/RSS gate: clojure-rs built from THIS PR vs JVM +# Clojure, across the snippet suite (hhh-pa/clojure-code-snippet) +# and the algorithms suite (hhh-pa/clojure-algorithms). Each suite +# runs a JVM leg and a clojure-rs leg, sharded 8 ways (32 measure +# jobs); the h2h-compare job unions the shards per leg and gates on +# per-case median cpu_s + maxrss_kb: ANY loss fails the PR. +# +# This is a gate, not a tracker: it reports no Bencher history, it +# just reds the PR when the native runtime regresses against the +# JVM on any case. Triage mode: dispatch with report_only=true (or +# flip the reviewer note below) to get the verdict tables green. +# +# Session recipe: the flags below encode the workstation h2h sweep +# (PR225 test plan): --repeat 3 --no-alloc --no-perf --jobs 3, GNU +# time as the cpu/maxrss signal, temurin 21 + Clojure 1.12.0 jars +# for the JVM legs. +# +# Suite revisions pin to master SHAs (env defaults below); dispatch +# inputs override either pin for bisection, mirroring the +# clojure_rs_sha pattern of the snippet repos. +# +# REQUIRED SECRET: RESOURCES_PASSPHRASE (the snippet resource-bundle +# passphrase) must exist on hhh-pa/clojure too — copy the value over +# from hhh-pa/clojure-code-snippet (Settings -> Secrets and +# variables -> Actions). The recipes legs fail loud without it. +# (The algorithms suite carries its resources in-repo and needs no +# decrypt.) + +name: h2h-cpu-mem + +on: + pull_request: + workflow_dispatch: + inputs: + snippets_sha: + description: 'Pin clojure-code-snippet to this revision (40-char SHA; empty = pinned default)' + required: false + default: '' + algorithms_sha: + description: 'Pin clojure-algorithms to this revision (40-char SHA; empty = pinned default)' + required: false + default: '' + report_only: + description: 'Triage mode: print verdicts but keep the gate green' + required: false + default: false + type: boolean + +# One run per branch at a time, like rust.yml: a new push +# supersedes the in-flight sweep instead of queueing behind it. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Pinned suite revisions (master SHAs at authoring time). + SNIPPETS_SHA_DEFAULT: 0450f453e4fbf6059a4adaa2ea149b09298558de + ALGORITHMS_SHA_DEFAULT: ab26ab930f1b35497d2e7cef030023777f436786 + # Pinned (not floating "21"): JDK micro versions move cpu numbers + # and the sweep must stay comparable run to run. Bump deliberately. + JAVA_VERSION: '21.0.13' + JARS_CACHE_KEY: clojure-jars-1.12.0-spec0.5.238-core0.4.74-v1 + +jobs: + # Resolve the suite revisions ONCE per run so all 32 measure legs + # sweep the same commits. Defaults: the pinned SHAs above; a + # manual dispatch may pin either input instead (bisection). + resolve-suites: + runs-on: ubuntu-latest + outputs: + snippets_sha: ${{ steps.resolve.outputs.snippets_sha }} + algorithms_sha: ${{ steps.resolve.outputs.algorithms_sha }} + steps: + - id: resolve + run: | + snippets="${{ inputs.snippets_sha }}" + algos="${{ inputs.algorithms_sha }}" + [ -z "$snippets" ] && snippets="${{ env.SNIPPETS_SHA_DEFAULT }}" + [ -z "$algos" ] && algos="${{ env.ALGORITHMS_SHA_DEFAULT }}" + for sha in "$snippets" "$algos"; do + if ! printf '%s' "$sha" | grep -Eq '^[0-9a-f]{40}$'; then + echo "error: suite revision is not a 40-char SHA: '$sha'" >&2 + exit 1 + fi + done + echo "snippets_sha=$snippets" >> "$GITHUB_OUTPUT" + echo "algorithms_sha=$algos" >> "$GITHUB_OUTPUT" + { + echo "suite revisions:" + echo " clojure-code-snippet: \`$snippets\`" + echo " clojure-algorithms: \`$algos\`" + } >> "$GITHUB_STEP_SUMMARY" + + # The runtime under test, built once per run FROM THE PR ITSELF + # (interpreter-only release: `run` needs no LLVM) and fanned out + # to the 16 native legs as an artifact. + build-rs: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # checkout + actions: write # cargo cache + binary artifact + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + save-if: 'true' + - name: Build clojure-rs + run: | + cd rust + cargo build --release --locked --no-default-features + mkdir -p "$GITHUB_WORKSPACE/cljrs-bin" + cp target/release/clojure-rs "$GITHUB_WORKSPACE/cljrs-bin/" + echo "clojure-rs under test: \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" + - name: Upload clojure-rs binary + uses: actions/upload-artifact@v6 + with: + name: cljrs-bin + path: cljrs-bin/ + retention-days: 1 + + # Recipes legs (session legs 1-2): 8 shards x 2 runtimes. + # Full-suite workstation time was ~65min JVM / ~30min RS at + # --jobs 3, so ~10min/shard; the 60min cap is generous headroom. + measure-recipes: + needs: [resolve-suites, build-rs] + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # suite checkout + actions: write # jars cache + shard artifact + strategy: + # Let every shard finish so partial artifacts upload for diagnosis. + fail-fast: false + matrix: + # 0-based K/8: snip measure --shard needs 0 <= K < N. + shard: [0, 1, 2, 3, 4, 5, 6, 7] + runtime: [jvm, clojure-rs] + name: recipes ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) + steps: + - name: Check out snippet suite + uses: actions/checkout@v5 + with: + repository: hhh-pa/clojure-code-snippet + ref: ${{ needs.resolve-suites.outputs.snippets_sha }} + path: snippets + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: snippets/scripts/ci/go/go.mod + cache: false # stdlib-only module: nothing to cache but the build below + + - name: Build snip + run: | + cd snippets/scripts/ci/go + go build -o snip ./cmd/snip + + - name: Set up JDK 21 + if: matrix.runtime == 'jvm' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Cache Clojure jars + if: matrix.runtime == 'jvm' + id: jars-cache + uses: actions/cache@v5 + with: + path: snippets/.ci/jars + key: ${{ env.JARS_CACHE_KEY }} + + - name: Download Clojure jars + if: matrix.runtime == 'jvm' && steps.jars-cache.outputs.cache-hit != 'true' + run: | + mkdir -p snippets/.ci/jars + base=https://repo1.maven.org/maven2 + curl -fsSL -o snippets/.ci/jars/clojure.jar "$base/org/clojure/clojure/1.12.0/clojure-1.12.0.jar" + curl -fsSL -o snippets/.ci/jars/spec.alpha.jar "$base/org/clojure/spec.alpha/0.5.238/spec.alpha-0.5.238.jar" + curl -fsSL -o snippets/.ci/jars/core.specs.alpha.jar "$base/org/clojure/core.specs.alpha/0.4.74/core.specs.alpha-0.4.74.jar" + + - name: Decrypt and extract resources + env: + RESOURCES_PASSPHRASE: ${{ secrets.RESOURCES_PASSPHRASE }} + working-directory: snippets + run: | + if [ -z "$RESOURCES_PASSPHRASE" ]; then + echo "error: the RESOURCES_PASSPHRASE repository secret is not configured on hhh-pa/clojure." >&2 + echo "Copy the value over from hhh-pa/clojure-code-snippet (Settings -> Secrets and variables -> Actions)." >&2 + exit 1 + fi + mkdir -p tests/resources + gpg --batch --yes --pinentry-mode loopback \ + --passphrase "$RESOURCES_PASSPHRASE" \ + -d tests/resources.tar.xz.asc | tar -xJ -C tests/resources --strip-components=1 + + - name: Install GNU time + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time + + - name: Use PR-built clojure-rs + if: matrix.runtime == 'clojure-rs' + uses: actions/download-artifact@v6 + with: + name: cljrs-bin + path: cljrs-bin + + - name: Add clojure-rs to PATH + if: matrix.runtime == 'clojure-rs' + run: echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + + - name: Measure head-to-head shard + working-directory: snippets + run: | + flags=(--runtime "${{ matrix.runtime }}") + if [ "${{ matrix.runtime }}" = "clojure-rs" ]; then + flags+=(--command-template 'clojure-rs run {script}') + fi + ./scripts/ci/go/snip measure --resources tests/resources \ + --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 300 \ + "${flags[@]}" \ + --shard ${{ matrix.shard }}/8 \ + --output "h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --summary-file "$GITHUB_STEP_SUMMARY" + + - name: Upload shard results + if: always() + uses: actions/upload-artifact@v6 + with: + name: h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: snippets/h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + retention-days: 7 + + # Algos legs (session legs 3-4): 8 shards x 2 runtimes. The + # per-case timeout is 1800s (vs 300s for recipes), hence the + # roomier job cap. No decrypt step: this suite carries its + # resources/ in-repo. + measure-algos: + needs: [resolve-suites, build-rs] + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read # suite checkout + actions: write # jars cache + shard artifact + strategy: + # Let every shard finish so partial artifacts upload for diagnosis. + fail-fast: false + matrix: + # 0-based K/8: algoperf measure --shard needs 0 <= K < N. + shard: [0, 1, 2, 3, 4, 5, 6, 7] + runtime: [jvm, clojure-rs] + name: algos ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) + steps: + - name: Check out algorithms suite + uses: actions/checkout@v5 + with: + repository: hhh-pa/clojure-algorithms + ref: ${{ needs.resolve-suites.outputs.algorithms_sha }} + path: algos + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: algos/scripts/ci/go/go.mod + cache: false # stdlib-only module: nothing to cache but the build below + + - name: Build algoperf + run: | + cd algos/scripts/ci/go + go build -o algoperf ./cmd/algoperf + + - name: Set up JDK 21 + if: matrix.runtime == 'jvm' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Cache Clojure jars + if: matrix.runtime == 'jvm' + id: jars-cache + uses: actions/cache@v5 + with: + path: algos/.ci/jars + key: ${{ env.JARS_CACHE_KEY }} + + - name: Download Clojure jars + if: matrix.runtime == 'jvm' && steps.jars-cache.outputs.cache-hit != 'true' + run: | + mkdir -p algos/.ci/jars + base=https://repo1.maven.org/maven2 + curl -fsSL -o algos/.ci/jars/clojure.jar "$base/org/clojure/clojure/1.12.0/clojure-1.12.0.jar" + curl -fsSL -o algos/.ci/jars/spec.alpha.jar "$base/org/clojure/spec.alpha/0.5.238/spec.alpha-0.5.238.jar" + curl -fsSL -o algos/.ci/jars/core.specs.alpha.jar "$base/org/clojure/core.specs.alpha/0.4.74/core.specs.alpha-0.4.74.jar" + + - name: Install GNU time + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time + + - name: Use PR-built clojure-rs + if: matrix.runtime == 'clojure-rs' + uses: actions/download-artifact@v6 + with: + name: cljrs-bin + path: cljrs-bin + + - name: Add clojure-rs to PATH + if: matrix.runtime == 'clojure-rs' + run: echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + + - name: Measure head-to-head shard + working-directory: algos + run: | + flags=(--runtime "${{ matrix.runtime }}") + if [ "${{ matrix.runtime }}" = "clojure-rs" ]; then + flags+=(--command-template 'clojure-rs run perf/driver.clj {ns} src test resources') + else + jars=".ci/jars/clojure.jar:.ci/jars/spec.alpha.jar:.ci/jars/core.specs.alpha.jar" + flags+=(--cp "$jars:src:test:resources") + fi + ./scripts/ci/go/algoperf measure \ + --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ + "${flags[@]}" \ + --shard ${{ matrix.shard }}/8 \ + --output "h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --summary-file "$GITHUB_STEP_SUMMARY" + + - name: Upload shard results + if: always() + uses: actions/upload-artifact@v6 + with: + name: h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + retention-days: 7 + + # Fan-in: union the 8 shards per leg and diff JVM vs native. + # Strict across-the-board gate — any CPU/RSS loss (or failure, + # missing, not-ok, drift, leg mismatch) fails the PR. No Bencher + # reporting by design: this job gates, it does not track history. + h2h-compare: + name: h2h cpu/mem gate + needs: [measure-recipes, measure-algos] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # compare script checkout + actions: write # shard download + merged upload + steps: + - uses: actions/checkout@v5 + + - name: Download shard results + uses: actions/download-artifact@v6 + with: + pattern: h2h-*-shard-* + + - name: Compare JVM vs clojure-rs (strict gate) + run: | + # Both suites always compare (both verdict tables land in + # the summary) and the job fails if either gate fails. + report_only="" + if [ "${{ inputs.report_only }}" = "true" ]; then report_only="--report-only"; fi + rc=0 + python3 scripts/ci/h2h_compare.py \ + --label recipes \ + --jvm h2h-recipes-jvm-shard-*/h2h-recipes-jvm-shard-*.json \ + --rs h2h-recipes-clojure-rs-shard-*/h2h-recipes-clojure-rs-shard-*.json \ + --merged-jvm h2h-recipes-jvm-merged.json \ + --merged-rs h2h-recipes-clojure-rs-merged.json \ + --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? + python3 scripts/ci/h2h_compare.py \ + --label algos \ + --jvm h2h-algos-jvm-shard-*/h2h-algos-jvm-shard-*.json \ + --rs h2h-algos-clojure-rs-shard-*/h2h-algos-clojure-rs-shard-*.json \ + --merged-jvm h2h-algos-jvm-merged.json \ + --merged-rs h2h-algos-clojure-rs-merged.json \ + --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? + exit $rc + + - name: Upload merged leg JSONs + if: always() + uses: actions/upload-artifact@v6 + with: + name: h2h-merged + path: h2h-*-merged.json + retention-days: 14 diff --git a/scripts/ci/h2h_compare.py b/scripts/ci/h2h_compare.py new file mode 100755 index 0000000000..aea0f9688f --- /dev/null +++ b/scripts/ci/h2h_compare.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Head-to-head gate: clojure-rs (this PR) vs JVM Clojure on cpu_s + maxrss_kb. + +Usage: + h2h_compare.py --jvm JVM.json [JVM.json ...] --rs RS.json [RS.json ...] + --label SUITE [--summary-file FILE] [--report-only] + [--merged-jvm FILE] [--merged-rs FILE] + +Compares per-case medians (primary) and cross-checks the README +convention (cpu min-of-repeats, rss max-of-repeats). Multiple JSONs +per leg are unioned (one per measure shard); duplicate case keys +across shards are a hard error (sharding is cohesive, so a dupe +means misconfiguration, not data). + +Strict gate: exits non-zero on any CPU/RSS loss, failure, missing +case, not-ok case, behavioral drift, or leg-coverage mismatch, +unless --report-only (triage mode: always exits 0). + +Stdlib only (python3 on the GitHub runner). +""" +import argparse +import json +import sys + +# Namespaces whose :pass count varies run to run by design (they +# assert per edge of a random graph); :test/:fail/:error still +# compare exactly. Mirrors the report-compare job of the +# clojure-algorithms perf-callgrind workflow. +VARY_PASS_LABELS = frozenset([ + "clojure-algorithms.graphs.karger-test", + "clojure-algorithms.graphs.random-graph-generator-test", +]) + + +def load(path): + with open(path) as f: + return json.load(f) + + +def key(r): + return (r["label"], r.get("stem", ""), r.get("case", "")) + + +def union(paths, leg): + """Union shard reports into (results-by-key, failures, missing, files).""" + merged = {} + failures = set() + missing = set() + for path in paths: + doc = load(path) + for r in doc["results"]: + k = key(r) + if k in merged: + raise ValueError( + f"duplicate case {k[0]}/{k[1]}/{k[2]} across {leg} shards " + f"(shards must partition cases; check --shard flags)") + merged[k] = r + failures.update(doc.get("failures", [])) + missing.update(doc.get("missing", [])) + return merged, sorted(failures), sorted(missing) + + +def norm_counts(label, counts): + if counts is None: + return None + counts = dict(counts) + if label in VARY_PASS_LABELS: + counts["pass"] = 0 + return counts + + +def parse_args(argv): + p = argparse.ArgumentParser(description="JVM vs clojure-rs CPU/RSS gate") + p.add_argument("--jvm", nargs="+", required=True, metavar="JVM.json", + help="one merged leg JSON, or one JSON per shard") + p.add_argument("--rs", nargs="+", required=True, metavar="RS.json", + help="one merged leg JSON, or one JSON per shard") + p.add_argument("--label", required=True, help="suite name (recipes, algos)") + p.add_argument("--summary-file", default=None, + help="append a Markdown verdict table (GITHUB_STEP_SUMMARY)") + p.add_argument("--report-only", action="store_true", + help="triage mode: print the verdict but always exit 0") + p.add_argument("--merged-jvm", default=None, + help="write the unioned JVM leg JSON here") + p.add_argument("--merged-rs", default=None, + help="write the unioned clojure-rs leg JSON here") + return p.parse_args(argv) + + +def short(k): + return f"{k[0]}/{k[2]}" if k[2] != "run" else k[0] + + +def write_merged(path, first_doc, results, failures, missing): + doc = dict(first_doc) + doc["results"] = [results[k] for k in sorted(results)] + doc["failures"] = failures + doc["missing"] = missing + config = dict(doc.get("config", {})) + config["shard"] = "merged" + doc["config"] = config + with open(path, "w") as f: + json.dump(doc, f, indent=2) + f.write("\n") + + +def main(argv): + args = parse_args(argv) + try: + jm, j_fail, j_miss = union(args.jvm, "jvm") + rm, r_fail, r_miss = union(args.rs, "rs") + except (OSError, ValueError, KeyError, json.JSONDecodeError) as e: + print(f"h2h_compare: error loading leg JSONs: {e}", file=sys.stderr) + return 2 + if args.merged_jvm: + write_merged(args.merged_jvm, load(args.jvm[0]), jm, j_fail, j_miss) + if args.merged_rs: + write_merged(args.merged_rs, load(args.rs[0]), rm, r_fail, r_miss) + + print(f"== {args.label} ==") + print(f"jvm: shards={len(args.jvm)} results={len(jm)} " + f"failures={len(j_fail)} missing={len(j_miss)}") + print(f"rs : shards={len(args.rs)} results={len(rm)} " + f"failures={len(r_fail)} missing={len(r_miss)}") + only_jvm = sorted(set(jm) - set(rm)) + only_rs = sorted(set(rm) - set(jm)) + print(f"common cases: {len(set(jm) & set(rm))} " + f"only-jvm: {len(only_jvm)} only-rs: {len(only_rs)}") + for k in only_jvm[:10]: + print(" only-jvm:", "/".join(k)) + for k in only_rs[:10]: + print(" only-rs:", "/".join(k)) + + n_cpu_win = n_cpu_loss = n_cpu_tie = 0 + n_mem_win = n_mem_loss = n_mem_tie = 0 + n_both_win = 0 + cpu_losses, mem_losses = [], [] + not_ok, drift = [], [] + worst_cpu_margin, worst_mem_margin = None, None + for k in sorted(set(jm) & set(rm)): + a, b = jm[k], rm[k] + name = short(k) + if not a["ok"] or not b["ok"]: + not_ok.append((name, a.get("error"), b.get("error"))) + continue + # behavioral drift gate (algos carry test_counts) + if "test_counts" in a or "test_counts" in b: + if norm_counts(k[0], a.get("test_counts")) != \ + norm_counts(k[0], b.get("test_counts")): + drift.append((name, a.get("test_counts"), b.get("test_counts"))) + jc, rc = a["summary"]["cpu_s"], b["summary"]["cpu_s"] + jr, rr = a["summary"]["maxrss_kb"], b["summary"]["maxrss_kb"] + cpu_ratio = rc["median"] / jc["median"] if jc["median"] else float("inf") + mem_ratio = rr["median"] / jr["median"] if jr["median"] else float("inf") + if rc["median"] < jc["median"]: + n_cpu_win += 1 + elif rc["median"] > jc["median"]: + n_cpu_loss += 1 + cpu_losses.append((cpu_ratio, name, jc["median"], rc["median"])) + else: + n_cpu_tie += 1 + if rr["median"] < jr["median"]: + n_mem_win += 1 + elif rr["median"] > jr["median"]: + n_mem_loss += 1 + mem_losses.append((mem_ratio, name, jr["median"], rr["median"])) + else: + n_mem_tie += 1 + if rc["median"] < jc["median"] and rr["median"] < jr["median"]: + n_both_win += 1 + # README-convention cross-check + rc_readme = rc["min"] < jc["min"] + rm_readme = rr["max"] < jr["max"] + if rc_readme != (rc["median"] < jc["median"]) or \ + rm_readme != (rr["median"] < jr["median"]): + print(f" NOTE {name}: median verdict differs from README convention " + f"(cpu min {rc['min']:.3f} vs {jc['min']:.3f}; " + f"rss max {rr['max']} vs {jr['max']})") + if worst_cpu_margin is None or cpu_ratio > worst_cpu_margin[0]: + worst_cpu_margin = (cpu_ratio, name) + if worst_mem_margin is None or mem_ratio > worst_mem_margin[0]: + worst_mem_margin = (mem_ratio, name) + + n = n_cpu_win + n_cpu_loss + n_cpu_tie + print(f"compared ok-cases: {n}") + print(f"CPU (median): RS wins {n_cpu_win}, losses {n_cpu_loss}, ties {n_cpu_tie}") + print(f"MEM (median): RS wins {n_mem_win}, losses {n_mem_loss}, ties {n_mem_tie}") + print(f"both-win cases: {n_both_win}/{n}") + if worst_cpu_margin is not None: + print(f"worst RS cpu ratio: {worst_cpu_margin[0]:.3f}x ({worst_cpu_margin[1]})") + print(f"worst RS mem ratio: {worst_mem_margin[0]:.3f}x ({worst_mem_margin[1]})") + else: + print("worst RS cpu ratio: n/a (no compared cases)") + print("worst RS mem ratio: n/a (no compared cases)") + if cpu_losses: + print(f"--- CPU LOSSES ({len(cpu_losses)}) ---") + for ratio, name, j, r in sorted(cpu_losses, reverse=True)[:50]: + print(f" {ratio:.3f}x {name} jvm={j:.3f}s rs={r:.3f}s") + if mem_losses: + print(f"--- MEM LOSSES ({len(mem_losses)}) ---") + for ratio, name, j, r in sorted(mem_losses, reverse=True)[:50]: + print(f" {ratio:.3f}x {name} jvm={j:.0f}K rs={r:.0f}K") + if not_ok: + print(f"--- NOT-OK ({len(not_ok)}) ---") + for name, je, re in not_ok[:50]: + print(f" {name} jvm_err={je} rs_err={re}") + if drift: + print(f"--- TEST-COUNT DRIFT ({len(drift)}) ---") + for name, j, r in drift[:20]: + print(f" {name} jvm={j} rs={r}") + if j_fail: + print(f"--- JVM failures ({len(j_fail)}) ---") + for f in j_fail[:20]: + print(" ", f) + if r_fail: + print(f"--- RS failures ({len(r_fail)}) ---") + for f in r_fail[:20]: + print(" ", f) + if j_miss: + print(f"--- JVM missing ({len(j_miss)}) ---") + for f in j_miss[:20]: + print(" ", f) + if r_miss: + print(f"--- RS missing ({len(r_miss)}) ---") + for f in r_miss[:20]: + print(" ", f) + + problems = [] + if n_cpu_loss: + problems.append(f"{n_cpu_loss} CPU losses") + if n_mem_loss: + problems.append(f"{n_mem_loss} MEM losses") + if not_ok: + problems.append(f"{len(not_ok)} not-ok cases") + if drift: + problems.append(f"{len(drift)} test-count drifts") + if only_jvm or only_rs: + problems.append(f"leg mismatch (only-jvm {len(only_jvm)}, " + f"only-rs {len(only_rs)})") + if j_fail or r_fail: + problems.append(f"sweep failures (jvm {len(j_fail)}, rs {len(r_fail)})") + if j_miss or r_miss: + problems.append(f"missing cases (jvm {len(j_miss)}, rs {len(r_miss)})") + if n == 0: + problems.append("no compared cases") + + gate_pass = not problems + if args.report_only: + verdict = "PASS (report-only)" if gate_pass else \ + f"FAIL ignored (--report-only): {'; '.join(problems)}" + else: + verdict = "PASS" if gate_pass else f"FAIL: {'; '.join(problems)}" + print(f"GATE {args.label}: {verdict}") + + if args.summary_file: + worst_cpu = ("n/a" if worst_cpu_margin is None else + f"{worst_cpu_margin[0]:.3f}x ({worst_cpu_margin[1]})") + worst_mem = ("n/a" if worst_mem_margin is None else + f"{worst_mem_margin[0]:.3f}x ({worst_mem_margin[1]})") + lines = [ + f"## h2h {args.label}: JVM vs clojure-rs", + "", + f"common cases: {n} (jvm shards {len(args.jvm)}, " + f"rs shards {len(args.rs)}); both-win {n_both_win}/{n}", + "", + "| metric | RS wins | losses | ties |", + "| --- | ---: | ---: | ---: |", + f"| CPU (median cpu_s) | {n_cpu_win} | {n_cpu_loss} | {n_cpu_tie} |", + f"| MEM (median maxrss_kb) | {n_mem_win} | {n_mem_loss} | {n_mem_tie} |", + "", + f"worst RS cpu ratio: {worst_cpu}; worst RS mem ratio: {worst_mem}", + "", + f"### GATE: {'PASS' if gate_pass else 'FAIL'}", + ] + if problems: + lines.append("") + lines.append("; ".join(problems)) + if cpu_losses: + lines += ["", "
CPU losses " + f"({len(cpu_losses)}, worst first)", "", + "| ratio | case | jvm | rs |", + "| ---: | --- | ---: | ---: |"] + for ratio, name, j, r in sorted(cpu_losses, reverse=True)[:30]: + lines.append(f"| {ratio:.3f}x | {name} | {j:.3f}s | {r:.3f}s |") + lines.append("
") + if mem_losses: + lines += ["", "
MEM losses " + f"({len(mem_losses)}, worst first)", "", + "| ratio | case | jvm | rs |", + "| ---: | --- | ---: | ---: |"] + for ratio, name, j, r in sorted(mem_losses, reverse=True)[:30]: + lines.append(f"| {ratio:.3f}x | {name} | {j:.0f}K | {r:.0f}K |") + lines.append("
") + with open(args.summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + if args.report_only: + return 0 + return 0 if gate_pass else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From 0e0fe32b2f120521920b5e55edb203f941ee9490 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:31:59 +0100 Subject: [PATCH 04/20] h2h-cpu-mem: chmod +x the PR-built clojure-rs after artifact download actions/download-artifact does not preserve the executable bit, so every clojure-rs leg failed with '/usr/bin/time: cannot run clojure-rs: Permission denied' (exit 126). Restore the bit before adding the directory to PATH, in both the recipes and algos legs. --- .github/workflows/h2h-cpu-mem.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 71046e79bb..e77c9beead 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -214,7 +214,10 @@ jobs: - name: Add clojure-rs to PATH if: matrix.runtime == 'clojure-rs' - run: echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + run: | + # download-artifact does not preserve the executable bit. + chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" + echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - name: Measure head-to-head shard working-directory: snippets @@ -314,7 +317,10 @@ jobs: - name: Add clojure-rs to PATH if: matrix.runtime == 'clojure-rs' - run: echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + run: | + # download-artifact does not preserve the executable bit. + chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" + echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - name: Measure head-to-head shard working-directory: algos From 3a7ee59e4c63b814e81b5da9ebdc06f6804be747 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:40:18 +0100 Subject: [PATCH 05/20] h2h-cpu-mem: pin snippets to 5cc7903 (bundle re-encrypted for org secret) The org-level RESOURCES_PASSPHRASE decrypts only the bundle rebuilt in hhh-pa/clojure-code-snippet#51; the old pin predates the rotation, so every recipes leg failed in gpg with 'Bad session key'. AI-Tool: Muse Code (Meta Muse Spark) --- .github/workflows/h2h-cpu-mem.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index e77c9beead..4de48ffd2d 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -57,7 +57,7 @@ permissions: env: # Pinned suite revisions (master SHAs at authoring time). - SNIPPETS_SHA_DEFAULT: 0450f453e4fbf6059a4adaa2ea149b09298558de + SNIPPETS_SHA_DEFAULT: 5cc7903c827563f914f9a4f23e118bca5ea103dc ALGORITHMS_SHA_DEFAULT: ab26ab930f1b35497d2e7cef030023777f436786 # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. From d5ab07c96cd5fc0e13b563dc8d093c10289ab6b1 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:29:27 +0100 Subject: [PATCH 06/20] h2h_compare: tolerate repeated sweep-skip markers across shards Sweep-skipped namespaces are recorded run-less in EVERY shard by design; the shard union treated the repeats as conflicting dupes and aborted the algos gate with exit 2. Dedupe identical run-less markers (and report them as SKIPPED-NO-RUNS instead of crashing the median comparison); genuine duplicate timed cases still fail loudly. AI-Tool: Muse Code (Meta Muse Spark) --- scripts/ci/h2h_compare.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/ci/h2h_compare.py b/scripts/ci/h2h_compare.py index aea0f9688f..4f65807db5 100755 --- a/scripts/ci/h2h_compare.py +++ b/scripts/ci/h2h_compare.py @@ -9,8 +9,8 @@ Compares per-case medians (primary) and cross-checks the README convention (cpu min-of-repeats, rss max-of-repeats). Multiple JSONs per leg are unioned (one per measure shard); duplicate case keys -across shards are a hard error (sharding is cohesive, so a dupe -means misconfiguration, not data). +across shards are a hard error, except identical run-less +sweep-skip markers, which every shard records by design. Strict gate: exits non-zero on any CPU/RSS loss, failure, missing case, not-ok case, behavioral drift, or leg-coverage mismatch, @@ -41,6 +41,14 @@ def key(r): return (r["label"], r.get("stem", ""), r.get("case", "")) +def _is_skip_marker(r): + """True for a run-less sweep-skip marker (no timed runs at all).""" + if r.get("runs"): + return False + summary = r.get("summary") or {} + return not summary.get("cpu_s") + + def union(paths, leg): """Union shard reports into (results-by-key, failures, missing, files).""" merged = {} @@ -51,6 +59,12 @@ def union(paths, leg): for r in doc["results"]: k = key(r) if k in merged: + # Sweep-skipped namespaces (e.g. algos sweep_skips.json) + # are recorded run-less in EVERY shard by design; dedupe + # identical markers, keep the hard error for real dupes. + prev = merged[k] + if _is_skip_marker(prev) and _is_skip_marker(r) and prev == r: + continue raise ValueError( f"duplicate case {k[0]}/{k[1]}/{k[2]} across {leg} shards " f"(shards must partition cases; check --shard flags)") @@ -135,7 +149,7 @@ def main(argv): n_mem_win = n_mem_loss = n_mem_tie = 0 n_both_win = 0 cpu_losses, mem_losses = [], [] - not_ok, drift = [], [] + not_ok, drift, skipped = [], [], [] worst_cpu_margin, worst_mem_margin = None, None for k in sorted(set(jm) & set(rm)): a, b = jm[k], rm[k] @@ -143,6 +157,10 @@ def main(argv): if not a["ok"] or not b["ok"]: not_ok.append((name, a.get("error"), b.get("error"))) continue + if _is_skip_marker(a) or _is_skip_marker(b): + skipped.append((name, len(a.get("runs", [])), + len(b.get("runs", [])))) + continue # behavioral drift gate (algos carry test_counts) if "test_counts" in a or "test_counts" in b: if norm_counts(k[0], a.get("test_counts")) != \ @@ -200,6 +218,10 @@ def main(argv): print(f"--- MEM LOSSES ({len(mem_losses)}) ---") for ratio, name, j, r in sorted(mem_losses, reverse=True)[:50]: print(f" {ratio:.3f}x {name} jvm={j:.0f}K rs={r:.0f}K") + if skipped: + print(f"--- SKIPPED-NO-RUNS ({len(skipped)}) ---") + for name, aj, bj in skipped[:20]: + print(f" {name} jvm_runs={aj} rs_runs={bj}") if not_ok: print(f"--- NOT-OK ({len(not_ok)}) ---") for name, je, re in not_ok[:50]: From 9b70b34dc4943343e1c217a0098968f690b4c44d Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:44:56 +0100 Subject: [PATCH 07/20] h2h-cpu-mem: add informational native-vs-native comparison Build both ends of the change (PR base/head, or master/dispatched ref) and sweep each suite under jvm + rs-from + rs-to (48 legs). The gate still fails only on JVM losses; the rs-from-vs-rs-to verdict tables are report-only (near-tie wobble), showing what the PR or branch changed against its base. rs_from/rs_to dispatch inputs take a SHA or branch/tag for bisection. AI-Tool: Muse Code (Meta Muse Spark) --- .github/workflows/h2h-cpu-mem.yml | 192 +++++++++++++++++++++--------- scripts/ci/h2h_compare.py | 87 ++++++++------ 2 files changed, 183 insertions(+), 96 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 4de48ffd2d..10806d20e9 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -1,14 +1,25 @@ -# Head-to-head CPU/RSS gate: clojure-rs built from THIS PR vs JVM -# Clojure, across the snippet suite (hhh-pa/clojure-code-snippet) +# Head-to-head CPU/RSS gate: clojure-rs revisions vs JVM Clojure +# and vs each other, across the snippet suite (hhh-pa/clojure-code-snippet) # and the algorithms suite (hhh-pa/clojure-algorithms). Each suite -# runs a JVM leg and a clojure-rs leg, sharded 8 ways (32 measure -# jobs); the h2h-compare job unions the shards per leg and gates on -# per-case median cpu_s + maxrss_kb: ANY loss fails the PR. +# runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), sharded +# 8 ways (48 measure jobs); the h2h-compare job unions the shards +# per leg and gates on per-case median cpu_s + maxrss_kb: ANY JVM +# loss fails the PR. The rs-from-vs-rs-to tables are informational +# (report-only): same-machine medians still wobble on near-tie +# cases, so native-vs-native is not a hard gate (one-line flip in +# the compare job if that ever changes). # -# This is a gate, not a tracker: it reports no Bencher history, it -# just reds the PR when the native runtime regresses against the -# JVM on any case. Triage mode: dispatch with report_only=true (or -# flip the reviewer note below) to get the verdict tables green. +# Revision pairs: on pull_request, from = PR base SHA, to = PR head +# (github.sha, the merge commit CI tests) — stacked PRs compare +# against their parent branch automatically. On workflow_dispatch, +# from defaults to master and to to the dispatched ref, so +# dispatching on a branch compares vs ; the +# rs_from/rs_to inputs override either side (40-char SHA or any +# branch/tag, for bisection). +# +# This is a gate, not a tracker: it reports no Bencher history. +# Triage mode: dispatch with report_only=true to get the verdict +# tables green. # # Session recipe: the flags below encode the workstation h2h sweep # (PR225 test plan): --repeat 3 --no-alloc --no-perf --jobs 3, GNU @@ -20,11 +31,10 @@ # clojure_rs_sha pattern of the snippet repos. # # REQUIRED SECRET: RESOURCES_PASSPHRASE (the snippet resource-bundle -# passphrase) must exist on hhh-pa/clojure too — copy the value over -# from hhh-pa/clojure-code-snippet (Settings -> Secrets and -# variables -> Actions). The recipes legs fail loud without it. -# (The algorithms suite carries its resources in-repo and needs no -# decrypt.) +# passphrase) at organization level with access to hhh-pa/clojure +# (visibility chosen when the secret was moved). The recipes legs +# fail loud without it. (The algorithms suite carries its +# resources/ in-repo and needs no decrypt.) name: h2h-cpu-mem @@ -40,6 +50,14 @@ on: description: 'Pin clojure-algorithms to this revision (40-char SHA; empty = pinned default)' required: false default: '' + rs_from: + description: 'Native-vs-native base: 40-char SHA or branch/tag (empty = PR base, or master on dispatch)' + required: false + default: '' + rs_to: + description: 'Native-vs-native head: 40-char SHA or branch/tag (empty = PR head, or dispatched ref)' + required: false + default: '' report_only: description: 'Triage mode: print verdicts but keep the gate green' required: false @@ -65,14 +83,20 @@ env: JARS_CACHE_KEY: clojure-jars-1.12.0-spec0.5.238-core0.4.74-v1 jobs: - # Resolve the suite revisions ONCE per run so all 32 measure legs - # sweep the same commits. Defaults: the pinned SHAs above; a - # manual dispatch may pin either input instead (bisection). - resolve-suites: + # Resolve the suite revisions AND the native-vs-native revision + # pair ONCE per run so all 48 measure legs sweep the same commits. + # Suite defaults: the pinned SHAs above; a manual dispatch may pin + # either input instead (bisection). Native pair: PR base/head by + # default on pull_request (stacked PRs diff against their parent), + # master/dispatched-ref on workflow_dispatch; rs_from/rs_to take a + # 40-char SHA or any branch/tag. + resolve: runs-on: ubuntu-latest outputs: snippets_sha: ${{ steps.resolve.outputs.snippets_sha }} algorithms_sha: ${{ steps.resolve.outputs.algorithms_sha }} + rs_from_sha: ${{ steps.resolve.outputs.rs_from_sha }} + rs_to_sha: ${{ steps.resolve.outputs.rs_to_sha }} steps: - id: resolve run: | @@ -80,31 +104,65 @@ jobs: algos="${{ inputs.algorithms_sha }}" [ -z "$snippets" ] && snippets="${{ env.SNIPPETS_SHA_DEFAULT }}" [ -z "$algos" ] && algos="${{ env.ALGORITHMS_SHA_DEFAULT }}" - for sha in "$snippets" "$algos"; do + from="${{ inputs.rs_from }}" + to="${{ inputs.rs_to }}" + if [ -z "$from" ]; then + if [ "${{ github.event_name }}" = "pull_request" ]; then + from="${{ github.event.pull_request.base.sha }}" + else + from="master" + fi + fi + [ -z "$to" ] && to="${{ github.sha }}" + to_sha() { + # Print the 40-char SHA for a SHA-or-ref input. + if printf '%s' "$1" | grep -Eq '^[0-9a-f]{40}$'; then + printf '%s' "$1" + else + git ls-remote https://github.com/hhh-pa/clojure "$1" | awk '{print $1}' | head -n 1 + fi + } + from_sha="$(to_sha "$from")" + to_sha="$(to_sha "$to")" + for sha in "$snippets" "$algos" "$from_sha" "$to_sha"; do if ! printf '%s' "$sha" | grep -Eq '^[0-9a-f]{40}$'; then - echo "error: suite revision is not a 40-char SHA: '$sha'" >&2 + echo "error: resolved revision is not a 40-char SHA: '$sha'" >&2 exit 1 fi done echo "snippets_sha=$snippets" >> "$GITHUB_OUTPUT" echo "algorithms_sha=$algos" >> "$GITHUB_OUTPUT" + echo "rs_from_sha=$from_sha" >> "$GITHUB_OUTPUT" + echo "rs_to_sha=$to_sha" >> "$GITHUB_OUTPUT" { echo "suite revisions:" echo " clojure-code-snippet: \`$snippets\`" echo " clojure-algorithms: \`$algos\`" + echo "native-vs-native revisions:" + echo " from: \`$from_sha\` ($from)" + echo " to: \`$to_sha\` ($to)" } >> "$GITHUB_STEP_SUMMARY" - # The runtime under test, built once per run FROM THE PR ITSELF - # (interpreter-only release: `run` needs no LLVM) and fanned out - # to the 16 native legs as an artifact. + # The two native revisions under test (interpreter-only release: + # `run` needs no LLVM), each fanned out to its 16 measure legs as + # an artifact. rs-to is what the JVM gate tests; rs-from is its + # comparison base (PR base, or master on dispatch). build-rs: + needs: [resolve] runs-on: ubuntu-latest timeout-minutes: 60 permissions: contents: read # checkout actions: write # cargo cache + binary artifact + strategy: + fail-fast: false + matrix: + rev: [rs-from, rs-to] + name: build-rs ${{ matrix.rev }} steps: - uses: actions/checkout@v5 + with: + ref: ${{ matrix.rev == 'rs-from' && needs.resolve.outputs.rs_from_sha || needs.resolve.outputs.rs_to_sha }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -116,19 +174,19 @@ jobs: cargo build --release --locked --no-default-features mkdir -p "$GITHUB_WORKSPACE/cljrs-bin" cp target/release/clojure-rs "$GITHUB_WORKSPACE/cljrs-bin/" - echo "clojure-rs under test: \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "clojure-rs ${{ matrix.rev }}: \`${{ matrix.rev == 'rs-from' && needs.resolve.outputs.rs_from_sha || needs.resolve.outputs.rs_to_sha }}\`" >> "$GITHUB_STEP_SUMMARY" - name: Upload clojure-rs binary uses: actions/upload-artifact@v6 with: - name: cljrs-bin + name: cljrs-${{ matrix.rev }} path: cljrs-bin/ retention-days: 1 - # Recipes legs (session legs 1-2): 8 shards x 2 runtimes. + # Recipes legs: 8 shards x 3 runtimes (jvm, rs-from, rs-to). # Full-suite workstation time was ~65min JVM / ~30min RS at # --jobs 3, so ~10min/shard; the 60min cap is generous headroom. measure-recipes: - needs: [resolve-suites, build-rs] + needs: [resolve, build-rs] runs-on: ubuntu-latest timeout-minutes: 60 permissions: @@ -140,14 +198,14 @@ jobs: matrix: # 0-based K/8: snip measure --shard needs 0 <= K < N. shard: [0, 1, 2, 3, 4, 5, 6, 7] - runtime: [jvm, clojure-rs] + runtime: [jvm, rs-from, rs-to] name: recipes ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) steps: - name: Check out snippet suite uses: actions/checkout@v5 with: repository: hhh-pa/clojure-code-snippet - ref: ${{ needs.resolve-suites.outputs.snippets_sha }} + ref: ${{ needs.resolve.outputs.snippets_sha }} path: snippets - name: Set up Go @@ -191,8 +249,7 @@ jobs: working-directory: snippets run: | if [ -z "$RESOURCES_PASSPHRASE" ]; then - echo "error: the RESOURCES_PASSPHRASE repository secret is not configured on hhh-pa/clojure." >&2 - echo "Copy the value over from hhh-pa/clojure-code-snippet (Settings -> Secrets and variables -> Actions)." >&2 + echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 exit 1 fi mkdir -p tests/resources @@ -205,15 +262,15 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - - name: Use PR-built clojure-rs - if: matrix.runtime == 'clojure-rs' + - name: Use built clojure-rs + if: matrix.runtime != 'jvm' uses: actions/download-artifact@v6 with: - name: cljrs-bin + name: cljrs-${{ matrix.runtime }} path: cljrs-bin - name: Add clojure-rs to PATH - if: matrix.runtime == 'clojure-rs' + if: matrix.runtime != 'jvm' run: | # download-artifact does not preserve the executable bit. chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" @@ -223,7 +280,7 @@ jobs: working-directory: snippets run: | flags=(--runtime "${{ matrix.runtime }}") - if [ "${{ matrix.runtime }}" = "clojure-rs" ]; then + if [ "${{ matrix.runtime }}" != "jvm" ]; then flags+=(--command-template 'clojure-rs run {script}') fi ./scripts/ci/go/snip measure --resources tests/resources \ @@ -241,12 +298,12 @@ jobs: path: snippets/h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 - # Algos legs (session legs 3-4): 8 shards x 2 runtimes. The + # Algos legs: 8 shards x 3 runtimes (jvm, rs-from, rs-to). The # per-case timeout is 1800s (vs 300s for recipes), hence the # roomier job cap. No decrypt step: this suite carries its # resources/ in-repo. measure-algos: - needs: [resolve-suites, build-rs] + needs: [resolve, build-rs] runs-on: ubuntu-latest timeout-minutes: 90 permissions: @@ -258,14 +315,14 @@ jobs: matrix: # 0-based K/8: algoperf measure --shard needs 0 <= K < N. shard: [0, 1, 2, 3, 4, 5, 6, 7] - runtime: [jvm, clojure-rs] + runtime: [jvm, rs-from, rs-to] name: algos ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) steps: - name: Check out algorithms suite uses: actions/checkout@v5 with: repository: hhh-pa/clojure-algorithms - ref: ${{ needs.resolve-suites.outputs.algorithms_sha }} + ref: ${{ needs.resolve.outputs.algorithms_sha }} path: algos - name: Set up Go @@ -308,15 +365,15 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - - name: Use PR-built clojure-rs - if: matrix.runtime == 'clojure-rs' + - name: Use built clojure-rs + if: matrix.runtime != 'jvm' uses: actions/download-artifact@v6 with: - name: cljrs-bin + name: cljrs-${{ matrix.runtime }} path: cljrs-bin - name: Add clojure-rs to PATH - if: matrix.runtime == 'clojure-rs' + if: matrix.runtime != 'jvm' run: | # download-artifact does not preserve the executable bit. chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" @@ -326,11 +383,11 @@ jobs: working-directory: algos run: | flags=(--runtime "${{ matrix.runtime }}") - if [ "${{ matrix.runtime }}" = "clojure-rs" ]; then - flags+=(--command-template 'clojure-rs run perf/driver.clj {ns} src test resources') - else + if [ "${{ matrix.runtime }}" = "jvm" ]; then jars=".ci/jars/clojure.jar:.ci/jars/spec.alpha.jar:.ci/jars/core.specs.alpha.jar" flags+=(--cp "$jars:src:test:resources") + else + flags+=(--command-template 'clojure-rs run perf/driver.clj {ns} src test resources') fi ./scripts/ci/go/algoperf measure \ --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ @@ -347,10 +404,13 @@ jobs: path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 - # Fan-in: union the 8 shards per leg and diff JVM vs native. - # Strict across-the-board gate — any CPU/RSS loss (or failure, - # missing, not-ok, drift, leg mismatch) fails the PR. No Bencher - # reporting by design: this job gates, it does not track history. + # Fan-in: union the 8 shards per leg and diff the legs. Strict + # across-the-board gate on JVM vs rs-to — any CPU/RSS loss (or + # failure, missing, not-ok, drift, leg mismatch) fails the PR. The + # rs-from-vs-rs-to tables are informational (report-only): they + # show what the PR (or branch) changed against its base without + # failing on near-tie wobble. No Bencher reporting by design: + # this job gates, it does not track history. h2h-compare: name: h2h cpu/mem gate needs: [measure-recipes, measure-algos] @@ -367,27 +427,43 @@ jobs: with: pattern: h2h-*-shard-* - - name: Compare JVM vs clojure-rs (strict gate) + - name: Compare legs (strict JVM gate + informational native diff) run: | - # Both suites always compare (both verdict tables land in - # the summary) and the job fails if either gate fails. + # All four comparisons always run (all verdict tables land + # in the summary); only the JVM gates can fail the job. report_only="" if [ "${{ inputs.report_only }}" = "true" ]; then report_only="--report-only"; fi rc=0 python3 scripts/ci/h2h_compare.py \ --label recipes \ --jvm h2h-recipes-jvm-shard-*/h2h-recipes-jvm-shard-*.json \ - --rs h2h-recipes-clojure-rs-shard-*/h2h-recipes-clojure-rs-shard-*.json \ + --rs h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ --merged-jvm h2h-recipes-jvm-merged.json \ - --merged-rs h2h-recipes-clojure-rs-merged.json \ + --merged-rs h2h-recipes-rs-to-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? python3 scripts/ci/h2h_compare.py \ --label algos \ --jvm h2h-algos-jvm-shard-*/h2h-algos-jvm-shard-*.json \ - --rs h2h-algos-clojure-rs-shard-*/h2h-algos-clojure-rs-shard-*.json \ + --rs h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ --merged-jvm h2h-algos-jvm-merged.json \ - --merged-rs h2h-algos-clojure-rs-merged.json \ + --merged-rs h2h-algos-rs-to-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? + python3 scripts/ci/h2h_compare.py \ + --label recipes-rsrs \ + --base-name rs-from --challenger-name rs-to \ + --jvm h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ + --rs h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ + --merged-jvm h2h-recipes-rs-from-merged.json \ + --merged-rs h2h-recipes-rs-to-merged.json \ + --summary-file "$GITHUB_STEP_SUMMARY" --report-only || rc=$? + python3 scripts/ci/h2h_compare.py \ + --label algos-rsrs \ + --base-name rs-from --challenger-name rs-to \ + --jvm h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ + --rs h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ + --merged-jvm h2h-algos-rs-from-merged.json \ + --merged-rs h2h-algos-rs-to-merged.json \ + --summary-file "$GITHUB_STEP_SUMMARY" --report-only || rc=$? exit $rc - name: Upload merged leg JSONs diff --git a/scripts/ci/h2h_compare.py b/scripts/ci/h2h_compare.py index 4f65807db5..126cdf6975 100755 --- a/scripts/ci/h2h_compare.py +++ b/scripts/ci/h2h_compare.py @@ -1,10 +1,16 @@ #!/usr/bin/env python3 -"""Head-to-head gate: clojure-rs (this PR) vs JVM Clojure on cpu_s + maxrss_kb. +"""Head-to-head gate: reference leg vs challenger leg on cpu_s + maxrss_kb. Usage: h2h_compare.py --jvm JVM.json [JVM.json ...] --rs RS.json [RS.json ...] --label SUITE [--summary-file FILE] [--report-only] [--merged-jvm FILE] [--merged-rs FILE] + [--base-name NAME] [--challenger-name NAME] + +--jvm takes the reference leg, --rs the challenger leg (a "win" is +challenger-faster). The JVM-vs-native gate uses the defaults; the +native-vs-native comparison passes two clojure-rs legs with +--base-name/--challenger-name (e.g. rs-from/rs-to). Compares per-case medians (primary) and cross-checks the README convention (cpu min-of-repeats, rss max-of-repeats). Multiple JSONs @@ -84,11 +90,15 @@ def norm_counts(label, counts): def parse_args(argv): - p = argparse.ArgumentParser(description="JVM vs clojure-rs CPU/RSS gate") - p.add_argument("--jvm", nargs="+", required=True, metavar="JVM.json", - help="one merged leg JSON, or one JSON per shard") - p.add_argument("--rs", nargs="+", required=True, metavar="RS.json", - help="one merged leg JSON, or one JSON per shard") + p = argparse.ArgumentParser(description="Reference-vs-challenger CPU/RSS gate") + p.add_argument("--jvm", nargs="+", required=True, metavar="BASE.json", + help="reference leg: one merged JSON, or one JSON per shard") + p.add_argument("--rs", nargs="+", required=True, metavar="CHALLENGER.json", + help="challenger leg: one merged JSON, or one JSON per shard") + p.add_argument("--base-name", default="jvm", + help="display name of the reference leg (default: jvm)") + p.add_argument("--challenger-name", default="rs", + help="display name of the challenger leg (default: rs)") p.add_argument("--label", required=True, help="suite name (recipes, algos)") p.add_argument("--summary-file", default=None, help="append a Markdown verdict table (GITHUB_STEP_SUMMARY)") @@ -121,8 +131,8 @@ def write_merged(path, first_doc, results, failures, missing): def main(argv): args = parse_args(argv) try: - jm, j_fail, j_miss = union(args.jvm, "jvm") - rm, r_fail, r_miss = union(args.rs, "rs") + jm, j_fail, j_miss = union(args.jvm, args.base_name) + rm, r_fail, r_miss = union(args.rs, args.challenger_name) except (OSError, ValueError, KeyError, json.JSONDecodeError) as e: print(f"h2h_compare: error loading leg JSONs: {e}", file=sys.stderr) return 2 @@ -131,19 +141,20 @@ def main(argv): if args.merged_rs: write_merged(args.merged_rs, load(args.rs[0]), rm, r_fail, r_miss) + an, bn = args.base_name, args.challenger_name print(f"== {args.label} ==") - print(f"jvm: shards={len(args.jvm)} results={len(jm)} " + print(f"{an}: shards={len(args.jvm)} results={len(jm)} " f"failures={len(j_fail)} missing={len(j_miss)}") - print(f"rs : shards={len(args.rs)} results={len(rm)} " + print(f"{bn}: shards={len(args.rs)} results={len(rm)} " f"failures={len(r_fail)} missing={len(r_miss)}") only_jvm = sorted(set(jm) - set(rm)) only_rs = sorted(set(rm) - set(jm)) print(f"common cases: {len(set(jm) & set(rm))} " - f"only-jvm: {len(only_jvm)} only-rs: {len(only_rs)}") + f"only-{an}: {len(only_jvm)} only-{bn}: {len(only_rs)}") for k in only_jvm[:10]: - print(" only-jvm:", "/".join(k)) + print(f" only-{an}:", "/".join(k)) for k in only_rs[:10]: - print(" only-rs:", "/".join(k)) + print(f" only-{bn}:", "/".join(k)) n_cpu_win = n_cpu_loss = n_cpu_tie = 0 n_mem_win = n_mem_loss = n_mem_tie = 0 @@ -201,49 +212,49 @@ def main(argv): n = n_cpu_win + n_cpu_loss + n_cpu_tie print(f"compared ok-cases: {n}") - print(f"CPU (median): RS wins {n_cpu_win}, losses {n_cpu_loss}, ties {n_cpu_tie}") - print(f"MEM (median): RS wins {n_mem_win}, losses {n_mem_loss}, ties {n_mem_tie}") + print(f"CPU (median): {bn} wins {n_cpu_win}, losses {n_cpu_loss}, ties {n_cpu_tie}") + print(f"MEM (median): {bn} wins {n_mem_win}, losses {n_mem_loss}, ties {n_mem_tie}") print(f"both-win cases: {n_both_win}/{n}") if worst_cpu_margin is not None: - print(f"worst RS cpu ratio: {worst_cpu_margin[0]:.3f}x ({worst_cpu_margin[1]})") - print(f"worst RS mem ratio: {worst_mem_margin[0]:.3f}x ({worst_mem_margin[1]})") + print(f"worst {bn} cpu ratio: {worst_cpu_margin[0]:.3f}x ({worst_cpu_margin[1]})") + print(f"worst {bn} mem ratio: {worst_mem_margin[0]:.3f}x ({worst_mem_margin[1]})") else: - print("worst RS cpu ratio: n/a (no compared cases)") - print("worst RS mem ratio: n/a (no compared cases)") + print(f"worst {bn} cpu ratio: n/a (no compared cases)") + print(f"worst {bn} mem ratio: n/a (no compared cases)") if cpu_losses: print(f"--- CPU LOSSES ({len(cpu_losses)}) ---") for ratio, name, j, r in sorted(cpu_losses, reverse=True)[:50]: - print(f" {ratio:.3f}x {name} jvm={j:.3f}s rs={r:.3f}s") + print(f" {ratio:.3f}x {name} {an}={j:.3f}s {bn}={r:.3f}s") if mem_losses: print(f"--- MEM LOSSES ({len(mem_losses)}) ---") for ratio, name, j, r in sorted(mem_losses, reverse=True)[:50]: - print(f" {ratio:.3f}x {name} jvm={j:.0f}K rs={r:.0f}K") + print(f" {ratio:.3f}x {name} {an}={j:.0f}K {bn}={r:.0f}K") if skipped: print(f"--- SKIPPED-NO-RUNS ({len(skipped)}) ---") for name, aj, bj in skipped[:20]: - print(f" {name} jvm_runs={aj} rs_runs={bj}") + print(f" {name} {an}_runs={aj} {bn}_runs={bj}") if not_ok: print(f"--- NOT-OK ({len(not_ok)}) ---") for name, je, re in not_ok[:50]: - print(f" {name} jvm_err={je} rs_err={re}") + print(f" {name} {an}_err={je} {bn}_err={re}") if drift: print(f"--- TEST-COUNT DRIFT ({len(drift)}) ---") for name, j, r in drift[:20]: print(f" {name} jvm={j} rs={r}") if j_fail: - print(f"--- JVM failures ({len(j_fail)}) ---") + print(f"--- {an.upper()} failures ({len(j_fail)}) ---") for f in j_fail[:20]: print(" ", f) if r_fail: - print(f"--- RS failures ({len(r_fail)}) ---") + print(f"--- {bn.upper()} failures ({len(r_fail)}) ---") for f in r_fail[:20]: print(" ", f) if j_miss: - print(f"--- JVM missing ({len(j_miss)}) ---") + print(f"--- {an.upper()} missing ({len(j_miss)}) ---") for f in j_miss[:20]: print(" ", f) if r_miss: - print(f"--- RS missing ({len(r_miss)}) ---") + print(f"--- {bn.upper()} missing ({len(r_miss)}) ---") for f in r_miss[:20]: print(" ", f) @@ -257,12 +268,12 @@ def main(argv): if drift: problems.append(f"{len(drift)} test-count drifts") if only_jvm or only_rs: - problems.append(f"leg mismatch (only-jvm {len(only_jvm)}, " - f"only-rs {len(only_rs)})") + problems.append(f"leg mismatch (only-{an} {len(only_jvm)}, " + f"only-{bn} {len(only_rs)})") if j_fail or r_fail: - problems.append(f"sweep failures (jvm {len(j_fail)}, rs {len(r_fail)})") + problems.append(f"sweep failures ({an} {len(j_fail)}, {bn} {len(r_fail)})") if j_miss or r_miss: - problems.append(f"missing cases (jvm {len(j_miss)}, rs {len(r_miss)})") + problems.append(f"missing cases ({an} {len(j_miss)}, {bn} {len(r_miss)})") if n == 0: problems.append("no compared cases") @@ -280,17 +291,17 @@ def main(argv): worst_mem = ("n/a" if worst_mem_margin is None else f"{worst_mem_margin[0]:.3f}x ({worst_mem_margin[1]})") lines = [ - f"## h2h {args.label}: JVM vs clojure-rs", + f"## h2h {args.label}: {an} vs {bn}", "", - f"common cases: {n} (jvm shards {len(args.jvm)}, " - f"rs shards {len(args.rs)}); both-win {n_both_win}/{n}", + f"common cases: {n} ({an} shards {len(args.jvm)}, " + f"{bn} shards {len(args.rs)}); both-win {n_both_win}/{n}", "", - "| metric | RS wins | losses | ties |", + f"| metric | {bn} wins | losses | ties |", "| --- | ---: | ---: | ---: |", f"| CPU (median cpu_s) | {n_cpu_win} | {n_cpu_loss} | {n_cpu_tie} |", f"| MEM (median maxrss_kb) | {n_mem_win} | {n_mem_loss} | {n_mem_tie} |", "", - f"worst RS cpu ratio: {worst_cpu}; worst RS mem ratio: {worst_mem}", + f"worst {bn} cpu ratio: {worst_cpu}; worst {bn} mem ratio: {worst_mem}", "", f"### GATE: {'PASS' if gate_pass else 'FAIL'}", ] @@ -300,7 +311,7 @@ def main(argv): if cpu_losses: lines += ["", "
CPU losses " f"({len(cpu_losses)}, worst first)", "", - "| ratio | case | jvm | rs |", + f"| ratio | case | {an} | {bn} |", "| ---: | --- | ---: | ---: |"] for ratio, name, j, r in sorted(cpu_losses, reverse=True)[:30]: lines.append(f"| {ratio:.3f}x | {name} | {j:.3f}s | {r:.3f}s |") @@ -308,7 +319,7 @@ def main(argv): if mem_losses: lines += ["", "
MEM losses " f"({len(mem_losses)}, worst first)", "", - "| ratio | case | jvm | rs |", + f"| ratio | case | {an} | {bn} |", "| ---: | --- | ---: | ---: |"] for ratio, name, j, r in sorted(mem_losses, reverse=True)[:30]: lines.append(f"| {ratio:.3f}x | {name} | {j:.0f}K | {r:.0f}K |") From ad54533460d2f83a938a292fb938aebce97a50b4 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:57:54 +0100 Subject: [PATCH 08/20] h2h-cpu-mem: gate strictly in both directions rs-to must win-or-tie vs rs-from on every case, same as the JVM rule: any native-vs-native CPU/RSS loss now fails the PR (triage mode still greens everything). PRs diff base-vs-head; dispatched branches diff master-vs-branch. AI-Tool: Muse Code (Meta Muse Spark) --- .github/workflows/h2h-cpu-mem.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 10806d20e9..bdacf42d8e 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -3,11 +3,12 @@ # and the algorithms suite (hhh-pa/clojure-algorithms). Each suite # runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), sharded # 8 ways (48 measure jobs); the h2h-compare job unions the shards -# per leg and gates on per-case median cpu_s + maxrss_kb: ANY JVM -# loss fails the PR. The rs-from-vs-rs-to tables are informational -# (report-only): same-machine medians still wobble on near-tie -# cases, so native-vs-native is not a hard gate (one-line flip in -# the compare job if that ever changes). +# per leg and gates on per-case median cpu_s + maxrss_kb. The gate +# rule: clojure-rs must beat JVM Clojure, AND rs-to must +# win-or-tie vs rs-from on every case — ANY loss fails the PR. +# (Near-tie wobble can in principle red the native leg on a no-op +# change; if the tables show only marginal losses, consider a +# noise margin rather than weakening the rule silently.) # # Revision pairs: on pull_request, from = PR base SHA, to = PR head # (github.sha, the merge commit CI tests) — stacked PRs compare @@ -405,12 +406,10 @@ jobs: retention-days: 7 # Fan-in: union the 8 shards per leg and diff the legs. Strict - # across-the-board gate on JVM vs rs-to — any CPU/RSS loss (or - # failure, missing, not-ok, drift, leg mismatch) fails the PR. The - # rs-from-vs-rs-to tables are informational (report-only): they - # show what the PR (or branch) changed against its base without - # failing on near-tie wobble. No Bencher reporting by design: - # this job gates, it does not track history. + # across-the-board gate, both directions: JVM vs rs-to AND + # rs-from vs rs-to — any CPU/RSS loss (or failure, missing, + # not-ok, drift, leg mismatch) fails the PR. No Bencher reporting + # by design: this job gates, it does not track history. h2h-compare: name: h2h cpu/mem gate needs: [measure-recipes, measure-algos] @@ -427,10 +426,11 @@ jobs: with: pattern: h2h-*-shard-* - - name: Compare legs (strict JVM gate + informational native diff) + - name: Compare legs (strict gate, both directions) run: | # All four comparisons always run (all verdict tables land - # in the summary); only the JVM gates can fail the job. + # in the summary) and any of them can fail the job, unless + # triage mode greens everything. report_only="" if [ "${{ inputs.report_only }}" = "true" ]; then report_only="--report-only"; fi rc=0 @@ -455,7 +455,7 @@ jobs: --rs h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ --merged-jvm h2h-recipes-rs-from-merged.json \ --merged-rs h2h-recipes-rs-to-merged.json \ - --summary-file "$GITHUB_STEP_SUMMARY" --report-only || rc=$? + --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? python3 scripts/ci/h2h_compare.py \ --label algos-rsrs \ --base-name rs-from --challenger-name rs-to \ @@ -463,7 +463,7 @@ jobs: --rs h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ --merged-jvm h2h-algos-rs-from-merged.json \ --merged-rs h2h-algos-rs-to-merged.json \ - --summary-file "$GITHUB_STEP_SUMMARY" --report-only || rc=$? + --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? exit $rc - name: Upload merged leg JSONs From c7a5d1143b57a9770f17c0bf3a944e9eb1746a06 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:02:18 +0100 Subject: [PATCH 09/20] h2h-cpu-mem: from=head, to=base (PR parlance) rs-from is the change under test (PR head / dispatched ref), rs-to its baseline (PR base / master); the gate is from-wins-or- ties-to in both suites. The previous commit had the pair backwards (from=base, to=head). AI-Tool: Muse Code (Meta Muse Spark) --- .github/workflows/h2h-cpu-mem.yml | 65 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index bdacf42d8e..02a2c968b1 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -4,19 +4,20 @@ # runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), sharded # 8 ways (48 measure jobs); the h2h-compare job unions the shards # per leg and gates on per-case median cpu_s + maxrss_kb. The gate -# rule: clojure-rs must beat JVM Clojure, AND rs-to must -# win-or-tie vs rs-from on every case — ANY loss fails the PR. +# rule: clojure-rs must beat JVM Clojure, AND rs-from must +# win-or-tie vs rs-to on every case — ANY loss fails the PR. # (Near-tie wobble can in principle red the native leg on a no-op # change; if the tables show only marginal losses, consider a # noise margin rather than weakening the rule silently.) # -# Revision pairs: on pull_request, from = PR base SHA, to = PR head -# (github.sha, the merge commit CI tests) — stacked PRs compare -# against their parent branch automatically. On workflow_dispatch, -# from defaults to master and to to the dispatched ref, so -# dispatching on a branch compares vs ; the -# rs_from/rs_to inputs override either side (40-char SHA or any -# branch/tag, for bisection). +# Revision pairs ("PR from head to base" parlance): from is the +# change under test, to is its baseline. On pull_request, from = +# PR head (github.sha, the merge commit CI tests), to = PR base +# SHA — stacked PRs compare against their parent branch +# automatically. On workflow_dispatch, from defaults to the +# dispatched ref and to to master, so dispatching on a branch +# compares vs ; the rs_from/rs_to inputs override +# either side (40-char SHA or any branch/tag, for bisection). # # This is a gate, not a tracker: it reports no Bencher history. # Triage mode: dispatch with report_only=true to get the verdict @@ -52,11 +53,11 @@ on: required: false default: '' rs_from: - description: 'Native-vs-native base: 40-char SHA or branch/tag (empty = PR base, or master on dispatch)' + description: 'Native-vs-native head: 40-char SHA or branch/tag (empty = PR head, or dispatched ref)' required: false default: '' rs_to: - description: 'Native-vs-native head: 40-char SHA or branch/tag (empty = PR head, or dispatched ref)' + description: 'Native-vs-native base: 40-char SHA or branch/tag (empty = PR base, or master on dispatch)' required: false default: '' report_only: @@ -87,9 +88,9 @@ jobs: # Resolve the suite revisions AND the native-vs-native revision # pair ONCE per run so all 48 measure legs sweep the same commits. # Suite defaults: the pinned SHAs above; a manual dispatch may pin - # either input instead (bisection). Native pair: PR base/head by + # either input instead (bisection). Native pair: PR head/base by # default on pull_request (stacked PRs diff against their parent), - # master/dispatched-ref on workflow_dispatch; rs_from/rs_to take a + # dispatched-ref/master on workflow_dispatch; rs_from/rs_to take a # 40-char SHA or any branch/tag. resolve: runs-on: ubuntu-latest @@ -107,14 +108,14 @@ jobs: [ -z "$algos" ] && algos="${{ env.ALGORITHMS_SHA_DEFAULT }}" from="${{ inputs.rs_from }}" to="${{ inputs.rs_to }}" - if [ -z "$from" ]; then + [ -z "$from" ] && from="${{ github.sha }}" + if [ -z "$to" ]; then if [ "${{ github.event_name }}" = "pull_request" ]; then - from="${{ github.event.pull_request.base.sha }}" + to="${{ github.event.pull_request.base.sha }}" else - from="master" + to="master" fi fi - [ -z "$to" ] && to="${{ github.sha }}" to_sha() { # Print the 40-char SHA for a SHA-or-ref input. if printf '%s' "$1" | grep -Eq '^[0-9a-f]{40}$'; then @@ -146,7 +147,7 @@ jobs: # The two native revisions under test (interpreter-only release: # `run` needs no LLVM), each fanned out to its 16 measure legs as - # an artifact. rs-to is what the JVM gate tests; rs-from is its + # an artifact. rs-from is what the JVM gate tests; rs-to is its # comparison base (PR base, or master on dispatch). build-rs: needs: [resolve] @@ -437,32 +438,32 @@ jobs: python3 scripts/ci/h2h_compare.py \ --label recipes \ --jvm h2h-recipes-jvm-shard-*/h2h-recipes-jvm-shard-*.json \ - --rs h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ + --rs h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ --merged-jvm h2h-recipes-jvm-merged.json \ - --merged-rs h2h-recipes-rs-to-merged.json \ + --merged-rs h2h-recipes-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? python3 scripts/ci/h2h_compare.py \ --label algos \ --jvm h2h-algos-jvm-shard-*/h2h-algos-jvm-shard-*.json \ - --rs h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ + --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ --merged-jvm h2h-algos-jvm-merged.json \ - --merged-rs h2h-algos-rs-to-merged.json \ + --merged-rs h2h-algos-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? python3 scripts/ci/h2h_compare.py \ --label recipes-rsrs \ - --base-name rs-from --challenger-name rs-to \ - --jvm h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ - --rs h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ - --merged-jvm h2h-recipes-rs-from-merged.json \ - --merged-rs h2h-recipes-rs-to-merged.json \ + --base-name rs-to --challenger-name rs-from \ + --jvm h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ + --rs h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ + --merged-jvm h2h-recipes-rs-to-merged.json \ + --merged-rs h2h-recipes-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? python3 scripts/ci/h2h_compare.py \ --label algos-rsrs \ - --base-name rs-from --challenger-name rs-to \ - --jvm h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ - --rs h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ - --merged-jvm h2h-algos-rs-from-merged.json \ - --merged-rs h2h-algos-rs-to-merged.json \ + --base-name rs-to --challenger-name rs-from \ + --jvm h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ + --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ + --merged-jvm h2h-algos-rs-to-merged.json \ + --merged-rs h2h-algos-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? exit $rc From f799fe2d804989575fa8d71335aff8fbfbb78adc Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:18:29 +0100 Subject: [PATCH 10/20] h2h-cpu-mem: Pin inscount legs + Go h2h-compare - Port scripts/ci/h2h_compare.py to scripts/ci/go/cmd/h2h-compare (byte-identical stdout/summary, golden tests from the oracle); the gate job builds and runs the binary. - Vendor scripts/ci/pintool/inscount.cpp + setup-pin.sh (Pin 4.4 kit from Intel, sha256-pinned, cached across runs). - Add native-only Pin legs (16 shards x rs-from/rs-to, both suites) and ins rsrs gate comparisons (1% margin). - Point suite pins at the pin-inscount branches (snippet PR#52, algos PR#7) until they merge. --- .github/workflows/h2h-cpu-mem.yml | 290 +++++- scripts/ci/go/cmd/h2h-compare/compare.go | 941 ++++++++++++++++++ scripts/ci/go/cmd/h2h-compare/main.go | 178 ++++ scripts/ci/go/cmd/h2h-compare/main_test.go | 212 ++++ .../h2h-compare/testdata/base-shard-0.json | 8 + .../h2h-compare/testdata/base-shard-1.json | 8 + .../ci/go/cmd/h2h-compare/testdata/chal.json | 13 + .../h2h-compare/testdata/golden-default.txt | 34 + .../cmd/h2h-compare/testdata/golden-ins.txt | 28 + .../testdata/golden-merged-base.json | 394 ++++++++ .../testdata/golden-merged-chal.json | 368 +++++++ .../testdata/golden-reportonly.txt | 34 + .../testdata/golden-summary-default.md | 29 + .../testdata/golden-summary-ins.md | 20 + scripts/ci/h2h_compare.py | 336 ------- scripts/ci/pintool/inscount.cpp | 49 + scripts/ci/pintool/makefile | 11 + scripts/ci/pintool/makefile.rules | 1 + scripts/ci/setup-pin.sh | 63 ++ 19 files changed, 2656 insertions(+), 361 deletions(-) create mode 100644 scripts/ci/go/cmd/h2h-compare/compare.go create mode 100644 scripts/ci/go/cmd/h2h-compare/main.go create mode 100644 scripts/ci/go/cmd/h2h-compare/main_test.go create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/chal.json create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-default.txt create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-ins.txt create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-reportonly.txt create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-default.md create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-ins.md delete mode 100755 scripts/ci/h2h_compare.py create mode 100644 scripts/ci/pintool/inscount.cpp create mode 100644 scripts/ci/pintool/makefile create mode 100644 scripts/ci/pintool/makefile.rules create mode 100644 scripts/ci/setup-pin.sh diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 02a2c968b1..ef6b090938 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -2,13 +2,14 @@ # and vs each other, across the snippet suite (hhh-pa/clojure-code-snippet) # and the algorithms suite (hhh-pa/clojure-algorithms). Each suite # runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), sharded -# 8 ways (48 measure jobs); the h2h-compare job unions the shards -# per leg and gates on per-case median cpu_s + maxrss_kb. The gate -# rule: clojure-rs must beat JVM Clojure, AND rs-from must -# win-or-tie vs rs-to on every case — ANY loss fails the PR. -# (Near-tie wobble can in principle red the native leg on a no-op -# change; if the tables show only marginal losses, consider a -# noise margin rather than weakening the rule silently.) +# 8 ways (48 measure jobs), plus native-only Pin inscount legs for +# the rs pair, sharded 16 ways (64 jobs); the h2h-compare job +# unions the shards per leg and gates on per-case median cpu_s + +# maxrss_kb. The gate rule: clojure-rs must beat JVM Clojure, AND +# rs-from must win-or-tie vs rs-to on every case — ANY loss fails +# the PR. The Pin legs add a deterministic instruction-count gate +# (1% margin) for rs-from vs rs-to, immune to the wall-time wobble +# that reds the cpu/rsrs leg on no-op changes. # # Revision pairs ("PR from head to base" parlance): from is the # change under test, to is its baseline. On pull_request, from = @@ -76,9 +77,12 @@ permissions: contents: read env: - # Pinned suite revisions (master SHAs at authoring time). - SNIPPETS_SHA_DEFAULT: 5cc7903c827563f914f9a4f23e118bca5ea103dc - ALGORITHMS_SHA_DEFAULT: ab26ab930f1b35497d2e7cef030023777f436786 + # Pinned suite revisions: the pin-inscount branches (snippet PR#52, + # algos PR#7) until they merge, then back to master SHAs. The --pin + # legs below need harness support, so old-SHA bisection of the pin + # legs fails loud on the unknown flag by design. + SNIPPETS_SHA_DEFAULT: 87f74811daa0a42d6c750914179bc6bbae3bbf2a + ALGORITHMS_SHA_DEFAULT: cb630ab7f35724c6173ee881a352fb283dd2b89f # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. JAVA_VERSION: '21.0.13' @@ -86,7 +90,7 @@ env: jobs: # Resolve the suite revisions AND the native-vs-native revision - # pair ONCE per run so all 48 measure legs sweep the same commits. + # pair ONCE per run so all 112 measure legs sweep the same commits. # Suite defaults: the pinned SHAs above; a manual dispatch may pin # either input instead (bisection). Native pair: PR head/base by # default on pull_request (stacked PRs diff against their parent), @@ -406,14 +410,214 @@ jobs: path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 - # Fan-in: union the 8 shards per leg and diff the legs. Strict - # across-the-board gate, both directions: JVM vs rs-to AND - # rs-from vs rs-to — any CPU/RSS loss (or failure, missing, - # not-ok, drift, leg mismatch) fails the PR. No Bencher reporting - # by design: this job gates, it does not track history. + # Pin legs: deterministic instruction counts for the native pair + # (rs-from, rs-to), 16 shards each (64 jobs). One timed rep per + # case (correctness + wall baseline) plus two Pin reps; the + # ins gate compares pin_instructions medians with a 1% margin. + # Native-only by design: JVM-under-Pin trips Pin 4.4's memory + # manager (mmap-overlap exit 127) and costs ~40x wall, while the + # wall-time cpu/mem gates already cover JVM-vs-RS at ~10x + # margins. The monster recipes cases (bfs t5: 146s native, ~20min + # under Pin) set the 1500s pin timeout and 16-way sharding. + measure-recipes-pin: + needs: [resolve, build-rs] + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # suite + tool checkouts + actions: write # pin cache + shard artifact + strategy: + fail-fast: false + matrix: + shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + runtime: [rs-from, rs-to] + name: recipes-pin ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) + steps: + - name: Check out snippet suite + uses: actions/checkout@v5 + with: + repository: hhh-pa/clojure-code-snippet + ref: ${{ needs.resolve.outputs.snippets_sha }} + path: snippets + + - name: Check out Pin tooling + uses: actions/checkout@v5 + with: + path: h2h + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: snippets/scripts/ci/go/go.mod + cache: false # stdlib-only module: nothing to cache but the build below + + - name: Build snip + run: | + cd snippets/scripts/ci/go + go build -o snip ./cmd/snip + + - name: Cache Pin kit + id: pin-cache + uses: actions/cache@v5 + with: + path: ~/pin + key: pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-pin.sh', 'h2h/scripts/ci/pintool/inscount.cpp') }} + + - name: Provision Pin + run: | + bash h2h/scripts/ci/setup-pin.sh + + - name: Decrypt and extract resources + env: + RESOURCES_PASSPHRASE: ${{ secrets.RESOURCES_PASSPHRASE }} + working-directory: snippets + run: | + if [ -z "$RESOURCES_PASSPHRASE" ]; then + echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 + exit 1 + fi + mkdir -p tests/resources + gpg --batch --yes --pinentry-mode loopback \ + --passphrase "$RESOURCES_PASSPHRASE" \ + -d tests/resources.tar.xz.asc | tar -xJ -C tests/resources --strip-components=1 + + - name: Install GNU time + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time + + - name: Use built clojure-rs + uses: actions/download-artifact@v6 + with: + name: cljrs-${{ matrix.runtime }} + path: cljrs-bin + + - name: Add clojure-rs to PATH + run: | + # download-artifact does not preserve the executable bit. + chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" + echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + + - name: Measure Pin shard + working-directory: snippets + run: | + ./scripts/ci/go/snip measure --resources tests/resources \ + --repeat 1 --no-alloc --no-perf --jobs 3 --timeout 300 \ + --runtime "${{ matrix.runtime }}" \ + --command-template 'clojure-rs run {script}' \ + --pin --pin-bin "$PIN_BIN" --pin-tool "$PIN_TOOL" \ + --pin-repeat 2 --pin-timeout 1500 \ + --shard ${{ matrix.shard }}/16 \ + --output "pin-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --summary-file "$GITHUB_STEP_SUMMARY" + + - name: Upload shard results + if: always() + uses: actions/upload-artifact@v6 + with: + name: pin-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: snippets/pin-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + retention-days: 7 + + # Algos Pin legs: same shape as recipes-pin (16 shards x the + # native pair). No decrypt step: this suite carries its + # resources/ in-repo. + measure-algos-pin: + needs: [resolve, build-rs] + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read # suite + tool checkouts + actions: write # pin cache + shard artifact + strategy: + fail-fast: false + matrix: + shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + runtime: [rs-from, rs-to] + name: algos-pin ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) + steps: + - name: Check out algorithms suite + uses: actions/checkout@v5 + with: + repository: hhh-pa/clojure-algorithms + ref: ${{ needs.resolve.outputs.algorithms_sha }} + path: algos + + - name: Check out Pin tooling + uses: actions/checkout@v5 + with: + path: h2h + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: algos/scripts/ci/go/go.mod + cache: false # stdlib-only module: nothing to cache but the build below + + - name: Build algoperf + run: | + cd algos/scripts/ci/go + go build -o algoperf ./cmd/algoperf + + - name: Cache Pin kit + id: pin-cache + uses: actions/cache@v5 + with: + path: ~/pin + key: pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-pin.sh', 'h2h/scripts/ci/pintool/inscount.cpp') }} + + - name: Provision Pin + run: | + bash h2h/scripts/ci/setup-pin.sh + + - name: Install GNU time + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time + + - name: Use built clojure-rs + uses: actions/download-artifact@v6 + with: + name: cljrs-${{ matrix.runtime }} + path: cljrs-bin + + - name: Add clojure-rs to PATH + run: | + # download-artifact does not preserve the executable bit. + chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" + echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + + - name: Measure Pin shard + working-directory: algos + run: | + ./scripts/ci/go/algoperf measure \ + --repeat 1 --no-alloc --no-perf --jobs 3 --timeout 1800 \ + --runtime "${{ matrix.runtime }}" \ + --command-template 'clojure-rs run perf/driver.clj {ns} src test resources' \ + --pin --pin-bin "$PIN_BIN" --pin-tool "$PIN_TOOL" \ + --pin-repeat 2 --pin-timeout 1200 \ + --shard ${{ matrix.shard }}/16 \ + --output "pin-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --summary-file "$GITHUB_STEP_SUMMARY" + + - name: Upload shard results + if: always() + uses: actions/upload-artifact@v6 + with: + name: pin-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: algos/pin-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + retention-days: 7 + + # Fan-in: union the shards per leg and diff the legs. Strict + # across-the-board gate, both directions: JVM vs rs-from AND + # rs-from vs rs-to on cpu/mem, plus rs-from vs rs-to on Pin + # instruction counts (1% margin) — any loss (or failure, + # missing, not-ok, drift, leg mismatch) fails the PR. No + # Bencher reporting by design: this job gates, it does not + # track history. h2h-compare: name: h2h cpu/mem gate - needs: [measure-recipes, measure-algos] + needs: [measure-recipes, measure-algos, measure-recipes-pin, measure-algos-pin] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -427,29 +631,45 @@ jobs: with: pattern: h2h-*-shard-* + - name: Download Pin shard results + uses: actions/download-artifact@v6 + with: + pattern: pin-*-shard-* + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: scripts/ci/go/go.mod + cache: false # stdlib-only module: nothing to cache but the build below + + - name: Build h2h-compare + run: | + cd scripts/ci/go + go build -o h2h-compare ./cmd/h2h-compare + - name: Compare legs (strict gate, both directions) run: | - # All four comparisons always run (all verdict tables land - # in the summary) and any of them can fail the job, unless - # triage mode greens everything. + compare=scripts/ci/go/h2h-compare report_only="" if [ "${{ inputs.report_only }}" = "true" ]; then report_only="--report-only"; fi rc=0 - python3 scripts/ci/h2h_compare.py \ + # Six comparisons, all verdict tables in the summary; any + # can fail the job unless triage mode greens everything. + $compare \ --label recipes \ --jvm h2h-recipes-jvm-shard-*/h2h-recipes-jvm-shard-*.json \ --rs h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ --merged-jvm h2h-recipes-jvm-merged.json \ --merged-rs h2h-recipes-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? - python3 scripts/ci/h2h_compare.py \ + $compare \ --label algos \ --jvm h2h-algos-jvm-shard-*/h2h-algos-jvm-shard-*.json \ --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ --merged-jvm h2h-algos-jvm-merged.json \ --merged-rs h2h-algos-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? - python3 scripts/ci/h2h_compare.py \ + $compare \ --label recipes-rsrs \ --base-name rs-to --challenger-name rs-from \ --jvm h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ @@ -457,7 +677,7 @@ jobs: --merged-jvm h2h-recipes-rs-to-merged.json \ --merged-rs h2h-recipes-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? - python3 scripts/ci/h2h_compare.py \ + $compare \ --label algos-rsrs \ --base-name rs-to --challenger-name rs-from \ --jvm h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ @@ -465,6 +685,24 @@ jobs: --merged-jvm h2h-algos-rs-to-merged.json \ --merged-rs h2h-algos-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? + $compare \ + --label recipes-rsrs-ins \ + --base-name rs-to --challenger-name rs-from \ + --jvm pin-recipes-rs-to-shard-*/pin-recipes-rs-to-shard-*.json \ + --rs pin-recipes-rs-from-shard-*/pin-recipes-rs-from-shard-*.json \ + --metrics ins --margin 0.01 \ + --merged-jvm pin-recipes-rs-to-merged.json \ + --merged-rs pin-recipes-rs-from-merged.json \ + --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? + $compare \ + --label algos-rsrs-ins \ + --base-name rs-to --challenger-name rs-from \ + --jvm pin-algos-rs-to-shard-*/pin-algos-rs-to-shard-*.json \ + --rs pin-algos-rs-from-shard-*/pin-algos-rs-from-shard-*.json \ + --metrics ins --margin 0.01 \ + --merged-jvm pin-algos-rs-to-merged.json \ + --merged-rs pin-algos-rs-from-merged.json \ + --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? exit $rc - name: Upload merged leg JSONs @@ -472,5 +710,7 @@ jobs: uses: actions/upload-artifact@v6 with: name: h2h-merged - path: h2h-*-merged.json + path: | + h2h-*-merged.json + pin-*-merged.json retention-days: 14 diff --git a/scripts/ci/go/cmd/h2h-compare/compare.go b/scripts/ci/go/cmd/h2h-compare/compare.go new file mode 100644 index 0000000000..740e27ace7 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/compare.go @@ -0,0 +1,941 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "os" + "reflect" + "sort" + "strconv" + "strings" +) + +// varyPassLabels mirrors the report-compare job of the +// clojure-algorithms perf-callgrind workflow: namespaces whose +// :pass count varies run to run by design (per-edge asserts on a +// random graph); :test/:fail/:error still compare exactly. +var varyPassLabels = map[string]bool{ + "clojure-algorithms.graphs.karger-test": true, + "clojure-algorithms.graphs.random-graph-generator-test": true, +} + +// metric describes one gated signal: the summary key, the display +// tag, and the loss-table value format. +type metric struct { + key string // summary key (ins tries pinKey first) + tag string + unit string + summary string // summary-table row label + format func(float64) string +} + +const pinKey = "pin_instructions" + +var metrics = map[string]metric{ + "cpu": {key: "cpu_s", tag: "CPU", unit: "s", summary: "median cpu_s", format: fmt3}, + "mem": {key: "maxrss_kb", tag: "MEM", unit: "K", summary: "median maxrss_kb", format: fmt0}, + "ins": {key: "instructions", tag: "INS", unit: "", summary: "median instructions", format: fmtComma}, +} + +// fmt3 renders a ratio like Python's f"{x:.3f}" (including inf). +func fmt3(x float64) string { + if math.IsInf(x, 1) { + return "inf" + } + if math.IsInf(x, -1) { + return "-inf" + } + return strconv.FormatFloat(x, 'f', 3, 64) +} + +// fmt0 renders like Python's f"{x:.0f}". +func fmt0(x float64) string { + return strconv.FormatFloat(x, 'f', 0, 64) +} + +// fmtComma renders like Python's f"{x:,.0f}" (thousands separators). +func fmtComma(x float64) string { + s := fmt0(x) + neg := strings.HasPrefix(s, "-") + s = strings.TrimPrefix(s, "-") + n := len(s) + if n <= 3 { + if neg { + return "-" + s + } + return s + } + var b strings.Builder + if neg { + b.WriteByte('-') + } + lead := n % 3 + if lead == 0 { + lead = 3 + } + b.WriteString(s[:lead]) + for i := lead; i < n; i += 3 { + b.WriteByte(',') + b.WriteString(s[i : i+3]) + } + return b.String() +} + +// caseKey identifies one case across shards. +type caseKey [3]string // label, stem, case + +// lessKey orders cases lexicographically (Go arrays lack <). +func lessKey(a, b caseKey) bool { + if a[0] != b[0] { + return a[0] < b[0] + } + if a[1] != b[1] { + return a[1] < b[1] + } + return a[2] < b[2] +} + +// countPair is one test_counts entry in document order. +type countPair struct { + key string + value string // raw JSON number literal +} + +// entry is one decoded result object plus its ordered test_counts. +type entry struct { + raw map[string]any + counts []countPair // nil unless test_counts is an object + hasTC bool // test_counts key present (any type) +} + +func strOf(v any, dflt string) string { + if s, ok := v.(string); ok { + return s + } + return dflt +} + +func numOf(v any) (float64, bool) { + if n, ok := v.(json.Number); ok { + f, err := n.Float64() + return f, err == nil + } + return 0, false +} + +// keyOf builds the union key: (label, stem|"", case|""). +func keyOf(raw map[string]any) caseKey { + return caseKey{strOf(raw["label"], ""), strOf(raw["stem"], ""), strOf(raw["case"], "")} +} + +// short renders label/case, or just label for algos "run" cases. +func short(k caseKey) string { + if k[2] != "run" { + return k[0] + "/" + k[2] + } + return k[0] +} + +// runsLen is len(runs), 0 when runs is missing or not a list. +func (e entry) runsLen() int { + if runs, ok := e.raw["runs"].([]any); ok { + return len(runs) + } + return 0 +} + +// summaryMap is the summary object, or nil when absent/not an object. +func (e entry) summaryMap() map[string]any { + if s, ok := e.raw["summary"].(map[string]any); ok { + return s + } + return nil +} + +// isSkipMarker ports _is_skip_marker: run-less with no usable +// summary (no summary dict carrying a non-null median). +func (e entry) isSkipMarker() bool { + if e.runsLen() > 0 { + return false + } + for _, v := range e.summaryMap() { + if d, ok := v.(map[string]any); ok { + if m, present := d["median"]; present && m != nil { + return false + } + } + } + return true +} + +// normCounts ports norm_counts: comparable test_counts with pass +// zeroed on the known-varying labels. +func (e entry) normCounts(label string) map[string]string { + if !e.hasTC { + return nil + } + out := make(map[string]string, len(e.counts)) + for _, p := range e.counts { + out[p.key] = p.value + } + if varyPassLabels[label] { + out["pass"] = "0" + } + return out +} + +// pyRepr renders test_counts like Python's str(dict): document +// order, single-quoted keys, raw number literals. +func (e entry) pyRepr() string { + var b strings.Builder + b.WriteByte('{') + for i, p := range e.counts { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "'%s': %s", p.key, p.value) + } + b.WriteByte('}') + return b.String() +} + +// normNum canonicalizes decoded JSON for marker comparison: +// numbers become float64 (Python 1 == 1.0), maps/slices recurse. +func normNum(v any) any { + switch t := v.(type) { + case json.Number: + if f, err := t.Float64(); err == nil { + return f + } + return t.String() + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = normNum(val) + } + return out + case []any: + out := make([]any, len(t)) + for i, val := range t { + out[i] = normNum(val) + } + return out + default: + return v + } +} + +// decodeDoc parses one leg JSON with number-preserving literals. +func decodeDoc(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var doc map[string]any + if err := dec.Decode(&doc); err != nil { + return nil, err + } + return doc, nil +} + +// collectTestCounts re-scans one leg JSON for the ordered +// test_counts pairs of each result (encoding/json maps lose +// document order, which the drift report prints). +func collectTestCounts(path string) ([][]countPair, []bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + skip := func() error { + var v any + return dec.Decode(&v) + } + tok, err := dec.Token() + if err != nil || tok != json.Delim('{') { + return nil, nil, fmt.Errorf("top-level object expected") + } + var counts [][]countPair + var present []bool + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, nil, err + } + if keyTok != "results" { + if err := skip(); err != nil { + return nil, nil, err + } + continue + } + if tok, err := dec.Token(); err != nil || tok != json.Delim('[') { + return nil, nil, fmt.Errorf("results list expected") + } + for dec.More() { + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { + return nil, nil, fmt.Errorf("result object expected") + } + var pairs []countPair + has := false + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, nil, err + } + if keyTok != "test_counts" { + if err := skip(); err != nil { + return nil, nil, err + } + continue + } + has = true + if tok, err := dec.Token(); err != nil { + return nil, nil, err + } else if tok == json.Delim('{') { + for dec.More() { + kt, err := dec.Token() + if err != nil { + return nil, nil, err + } + ks, _ := kt.(string) + var v any + if err := dec.Decode(&v); err != nil { + return nil, nil, err + } + val := "" + if n, ok := v.(json.Number); ok { + val = n.String() + } + pairs = append(pairs, countPair{ks, val}) + } + if _, err := dec.Token(); err != nil { // closing } + return nil, nil, err + } + } + // A null/non-object test_counts stays has=true + // with no pairs; normCounts then yields {}. + } + if _, err := dec.Token(); err != nil { // closing } + return nil, nil, err + } + counts = append(counts, pairs) + present = append(present, has) + } + if _, err := dec.Token(); err != nil { // closing ] + return nil, nil, err + } + } + return counts, present, nil +} + +// union ports union(): shard reports become results-by-key plus +// sorted failures/missing; firstDoc feeds the merged output. +func union(paths []string, leg string) (map[caseKey]entry, []string, []string, map[string]any, error) { + merged := map[caseKey]entry{} + failures := map[string]bool{} + missing := map[string]bool{} + var firstDoc map[string]any + for _, path := range paths { + doc, err := decodeDoc(path) + if err != nil { + return nil, nil, nil, nil, err + } + results, ok := doc["results"].([]any) + if !ok { + return nil, nil, nil, nil, fmt.Errorf("%s: no results list", path) + } + counts, present, err := collectTestCounts(path) + if err != nil || len(counts) != len(results) { + if err == nil { + err = fmt.Errorf("test_counts scan diverged") + } + return nil, nil, nil, nil, fmt.Errorf("%s: %v", path, err) + } + if firstDoc == nil { + firstDoc = doc + } + for i, rv := range results { + raw, ok := rv.(map[string]any) + if !ok { + return nil, nil, nil, nil, fmt.Errorf("%s: result %d not an object", path, i) + } + k := keyOf(raw) + e := entry{raw: raw, counts: counts[i], hasTC: present[i]} + if prev, dup := merged[k]; dup { + // Sweep-skipped namespaces are recorded + // run-less in EVERY shard by design; dedupe + // identical markers, keep the hard error + // for real dupes. + if prev.isSkipMarker() && e.isSkipMarker() && + reflect.DeepEqual(normNum(prev.raw), normNum(e.raw)) { + continue + } + return nil, nil, nil, nil, fmt.Errorf( + "duplicate case %s/%s/%s across %s shards (shards must partition cases; check --shard flags)", + k[0], k[1], k[2], leg) + } + merged[k] = e + } + for _, f := range strList(doc["failures"]) { + failures[f] = true + } + for _, m := range strList(doc["missing"]) { + missing[m] = true + } + } + return merged, sortedKeys(failures), sortedKeys(missing), firstDoc, nil +} + +func strList(v any) []string { + items, _ := v.([]any) + var out []string + for _, it := range items { + if s, ok := it.(string); ok { + out = append(out, s) + } + } + return out +} + +func sortedKeys(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// writeMerged ports write_merged: the unioned leg as one JSON +// document (results in key order, config shard "merged"). +func writeMerged(path string, firstDoc map[string]any, results map[caseKey]entry, failures, missing []string) error { + doc := make(map[string]any, len(firstDoc)+1) + for k, v := range firstDoc { + doc[k] = v + } + keys := make([]caseKey, 0, len(results)) + for k := range results { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return lessKey(keys[i], keys[j]) }) + arr := make([]any, 0, len(keys)) + for _, k := range keys { + arr = append(arr, results[k].raw) + } + doc["results"] = arr + doc["failures"] = failures + doc["missing"] = missing + config := map[string]any{} + if c, ok := firstDoc["config"].(map[string]any); ok { + for k, v := range c { + config[k] = v + } + } + config["shard"] = "merged" + doc["config"] = config + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(doc) +} + +// lossRow is one gated loss: challenger/base ratio plus medians. +type lossRow struct { + ratio float64 + name string + base float64 + chal float64 +} + +// metricStat accumulates one metric's verdicts. +type metricStat struct { + win, loss, tie int + losses []lossRow + worst *lossRow // ratio+name only +} + +// medianOf extracts one summary median; the ins metric prefers +// pin_instructions over the legacy instructions key. +func medianOf(e entry, m metric, name string) (float64, error) { + summary := e.summaryMap() + if summary == nil { + return 0, fmt.Errorf("h2h_compare: missing %s summary for %s", m.key, name) + } + key := m.key + if m.tag == "INS" { + if _, ok := summary[pinKey]; ok { + key = pinKey + } + } + block, ok := summary[key].(map[string]any) + if !ok { + return 0, fmt.Errorf("h2h_compare: missing %s summary for %s", m.key, name) + } + med, ok := numOf(block["median"]) + if !ok { + return 0, fmt.Errorf("h2h_compare: missing %s summary for %s", m.key, name) + } + return med, nil +} + +// boundOf extracts a min/max bound for the README cross-check. +func boundOf(e entry, key, bound string) (float64, bool) { + summary := e.summaryMap() + if summary == nil { + return 0, false + } + block, ok := summary[key].(map[string]any) + if !ok { + return 0, false + } + return numOf(block[bound]) +} + +// boundRaw renders a min/max bound like Python's {}: integer +// literals print verbatim, floats take the shortest form. +func boundRaw(e entry, key, bound string) string { + summary := e.summaryMap() + if summary == nil { + return "?" + } + block, ok := summary[key].(map[string]any) + if !ok { + return "?" + } + if n, ok := block[bound].(json.Number); ok { + if !strings.ContainsAny(n.String(), ".eE") { + return n.String() + } + if f, err := n.Float64(); err == nil { + return strconv.FormatFloat(f, 'f', -1, 64) + } + return n.String() + } + return "?" +} + +// okOf reads the ok flag; malformed entries are a load error +// (the oracle would crash with a KeyError traceback here). +func okOf(e entry, name string) (bool, error) { + ok, present := e.raw["ok"] + if !present { + return false, fmt.Errorf("h2h_compare: missing ok flag for %s", name) + } + b, isBool := ok.(bool) + if !isBool { + return false, fmt.Errorf("h2h_compare: missing ok flag for %s", name) + } + return b, nil +} + +// errOf renders an entry's error like Python: string verbatim, +// null/missing as None. +func errOf(e entry) string { + if s, ok := e.raw["error"].(string); ok { + return s + } + return "None" +} + +// compare runs the gate and returns the process exit code. +func compare(o options) int { + jm, jFail, jMiss, firstJVM, err := union(o.jvm, o.baseName) + if err == nil { + var rm map[caseKey]entry + var rFail, rMiss []string + var firstRS map[string]any + rm, rFail, rMiss, firstRS, err = union(o.rs, o.challengerName) + if err == nil { + if o.mergedJVM != "" { + err = writeMerged(o.mergedJVM, firstJVM, jm, jFail, jMiss) + } + if err == nil && o.mergedRS != "" { + err = writeMerged(o.mergedRS, firstRS, rm, rFail, rMiss) + } + if err == nil { + return compareLegs(o, jm, rm, jFail, rFail, jMiss, rMiss) + } + } + } + if err != nil { + fmt.Fprintf(os.Stderr, "h2h_compare: error loading leg JSONs: %v\n", err) + return 2 + } + return 0 +} + +func compareLegs(o options, jm, rm map[caseKey]entry, jFail, rFail, jMiss, rMiss []string) int { + an, bn := o.baseName, o.challengerName + var out strings.Builder + fmt.Fprintf(&out, "== %s ==\n", o.label) + fmt.Fprintf(&out, "%s: shards=%d results=%d failures=%d missing=%d\n", + an, len(o.jvm), len(jm), len(jFail), len(jMiss)) + fmt.Fprintf(&out, "%s: shards=%d results=%d failures=%d missing=%d\n", + bn, len(o.rs), len(rm), len(rFail), len(rMiss)) + var onlyBase, onlyChal, common []caseKey + for k := range jm { + if _, ok := rm[k]; ok { + common = append(common, k) + } else { + onlyBase = append(onlyBase, k) + } + } + for k := range rm { + if _, ok := jm[k]; !ok { + onlyChal = append(onlyChal, k) + } + } + sort.Slice(common, func(i, j int) bool { return lessKey(common[i], common[j]) }) + sort.Slice(onlyBase, func(i, j int) bool { return lessKey(onlyBase[i], onlyBase[j]) }) + sort.Slice(onlyChal, func(i, j int) bool { return lessKey(onlyChal[i], onlyChal[j]) }) + fmt.Fprintf(&out, "common cases: %d only-%s: %d only-%s: %d\n", + len(common), an, len(onlyBase), bn, len(onlyChal)) + for i, k := range onlyBase { + if i >= 10 { + break + } + fmt.Fprintf(&out, " only-%s: %s/%s/%s\n", an, k[0], k[1], k[2]) + } + for i, k := range onlyChal { + if i >= 10 { + break + } + fmt.Fprintf(&out, " only-%s: %s/%s/%s\n", bn, k[0], k[1], k[2]) + } + + var selected []string + for _, m := range strings.Split(o.metrics, ",") { + selected = append(selected, strings.TrimSpace(m)) + } + for _, m := range selected { + if _, ok := metrics[m]; !ok { + fmt.Fprintf(os.Stderr, "h2h_compare: unknown metric '%s' (want csv of cpu,mem,ins)\n", m) + return 2 + } + } + stat := map[string]*metricStat{} + for _, m := range selected { + stat[m] = &metricStat{} + } + nBothWin := 0 + var notOK [][3]string // name, base err, chal err + var skipped [][3]string + var drift [][3]string // name, base counts, chal counts + inSelected := func(m string) bool { + for _, s := range selected { + if s == m { + return true + } + } + return false + } + for _, k := range common { + a, b := jm[k], rm[k] + name := short(k) + aOK, err := okOf(a, name) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + bOK, err := okOf(b, name) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + if !aOK || !bOK { + notOK = append(notOK, [3]string{name, errOf(a), errOf(b)}) + continue + } + if a.isSkipMarker() || b.isSkipMarker() { + skipped = append(skipped, [3]string{name, strconv.Itoa(a.runsLen()), strconv.Itoa(b.runsLen())}) + continue + } + // Behavioral drift gate (algos carry test_counts). + if a.hasTC || b.hasTC { + if !reflect.DeepEqual(a.normCounts(k[0]), b.normCounts(k[0])) { + drift = append(drift, [3]string{name, a.pyRepr(), b.pyRepr()}) + } + } + verdicts := map[string]string{} + for _, m := range selected { + mm := metrics[m] + jmed, err := medianOf(a, mm, name) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + rmed, err := medianOf(b, mm, name) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + ratio := math.Inf(1) + if jmed != 0 { + ratio = rmed / jmed + } + s := stat[m] + switch { + case jmed == 0 && rmed == 0: + s.tie++ + verdicts[m] = "tie" + case ratio > 1+o.margin: + s.loss++ + s.losses = append(s.losses, lossRow{ratio, name, jmed, rmed}) + verdicts[m] = "loss" + case rmed < jmed: + s.win++ + verdicts[m] = "win" + default: + s.tie++ + verdicts[m] = "tie" + } + if s.worst == nil || ratio > s.worst.ratio { + s.worst = &lossRow{ratio: ratio, name: name} + } + } + allWin := true + for _, m := range selected { + if verdicts[m] != "win" { + allWin = false + } + } + if allWin { + nBothWin++ + } + // README-convention cross-check (cpu: min-of-repeats, rss: max). + var notes []string + if inSelected("cpu") { + jmin, jok := boundOf(a, "cpu_s", "min") + rmin, rok := boundOf(b, "cpu_s", "min") + if jok && rok && (rmin < jmin) != (verdicts["cpu"] == "win") { + notes = append(notes, fmt.Sprintf("cpu min %s vs %s", + fmt3(rmin), fmt3(jmin))) + } + } + if inSelected("mem") { + jmax, jok := boundOf(a, "maxrss_kb", "max") + rmax, rok := boundOf(b, "maxrss_kb", "max") + if jok && rok && (rmax < jmax) != (verdicts["mem"] == "win") { + notes = append(notes, fmt.Sprintf("rss max %s vs %s", + boundRaw(b, "maxrss_kb", "max"), boundRaw(a, "maxrss_kb", "max"))) + } + } + if len(notes) > 0 { + fmt.Fprintf(&out, " NOTE %s: median verdict differs from README convention (%s)\n", + name, strings.Join(notes, "; ")) + } + } + + n := stat[selected[0]].win + stat[selected[0]].loss + stat[selected[0]].tie + fmt.Fprintf(&out, "compared ok-cases: %d\n", n) + for _, m := range selected { + s := stat[m] + fmt.Fprintf(&out, "%s (median): %s wins %d, losses %d, ties %d\n", + metrics[m].tag, bn, s.win, s.loss, s.tie) + } + fmt.Fprintf(&out, "both-win cases: %d/%d\n", nBothWin, n) + for _, m := range selected { + if w := stat[m].worst; w == nil { + fmt.Fprintf(&out, "worst %s %s ratio: n/a (no compared cases)\n", bn, m) + } else { + fmt.Fprintf(&out, "worst %s %s ratio: %sx (%s)\n", bn, m, fmt3(w.ratio), w.name) + } + } + for _, m := range selected { + s := stat[m] + if len(s.losses) == 0 { + continue + } + mm := metrics[m] + fmt.Fprintf(&out, "--- %s LOSSES (%d) ---\n", mm.tag, len(s.losses)) + sort.Slice(s.losses, func(i, j int) bool { + x, y := s.losses[i], s.losses[j] + if x.ratio != y.ratio { + return x.ratio > y.ratio + } + if x.name != y.name { + return x.name > y.name + } + if x.base != y.base { + return x.base > y.base + } + return x.chal > y.chal + }) + for i, l := range s.losses { + if i >= 50 { + break + } + fmt.Fprintf(&out, " %sx %s %s=%s%s %s=%s%s\n", + fmt3(l.ratio), l.name, an, mm.format(l.base), mm.unit, + bn, mm.format(l.chal), mm.unit) + } + } + if len(skipped) > 0 { + fmt.Fprintf(&out, "--- SKIPPED-NO-RUNS (%d) ---\n", len(skipped)) + for i, sk := range skipped { + if i >= 20 { + break + } + fmt.Fprintf(&out, " %s %s_runs=%s %s_runs=%s\n", sk[0], an, sk[1], bn, sk[2]) + } + } + if len(notOK) > 0 { + fmt.Fprintf(&out, "--- NOT-OK (%d) ---\n", len(notOK)) + for i, no := range notOK { + if i >= 50 { + break + } + fmt.Fprintf(&out, " %s %s_err=%s %s_err=%s\n", no[0], an, no[1], bn, no[2]) + } + } + if len(drift) > 0 { + fmt.Fprintf(&out, "--- TEST-COUNT DRIFT (%d) ---\n", len(drift)) + for i, d := range drift { + if i >= 20 { + break + } + fmt.Fprintf(&out, " %s jvm=%s rs=%s\n", d[0], d[1], d[2]) + } + } + printList := func(kind string, leg string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(&out, "--- %s %s (%d) ---\n", strings.ToUpper(leg), kind, len(items)) + for i, f := range items { + if i >= 20 { + break + } + fmt.Fprintf(&out, " %s\n", f) + } + } + printList("failures", an, jFail) + printList("failures", bn, rFail) + printList("missing", an, jMiss) + printList("missing", bn, rMiss) + + var problems []string + for _, m := range selected { + if stat[m].loss > 0 { + problems = append(problems, fmt.Sprintf("%d %s losses", stat[m].loss, metrics[m].tag)) + } + } + if len(notOK) > 0 { + problems = append(problems, fmt.Sprintf("%d not-ok cases", len(notOK))) + } + if len(drift) > 0 { + problems = append(problems, fmt.Sprintf("%d test-count drifts", len(drift))) + } + if len(onlyBase) > 0 || len(onlyChal) > 0 { + problems = append(problems, fmt.Sprintf("leg mismatch (only-%s %d, only-%s %d)", + an, len(onlyBase), bn, len(onlyChal))) + } + if len(jFail) > 0 || len(rFail) > 0 { + problems = append(problems, fmt.Sprintf("sweep failures (%s %d, %s %d)", + an, len(jFail), bn, len(rFail))) + } + if len(jMiss) > 0 || len(rMiss) > 0 { + problems = append(problems, fmt.Sprintf("missing cases (%s %d, %s %d)", + an, len(jMiss), bn, len(rMiss))) + } + if n == 0 { + problems = append(problems, "no compared cases") + } + + gatePass := len(problems) == 0 + var verdict string + if o.reportOnly { + verdict = "PASS (report-only)" + if !gatePass { + verdict = fmt.Sprintf("FAIL ignored (--report-only): %s", strings.Join(problems, "; ")) + } + } else if gatePass { + verdict = "PASS" + } else { + verdict = fmt.Sprintf("FAIL: %s", strings.Join(problems, "; ")) + } + fmt.Fprintf(&out, "GATE %s: %s\n", o.label, verdict) + + if o.summaryFile != "" { + worstStr := func(m string) string { + if w := stat[m].worst; w != nil { + return fmt.Sprintf("%sx (%s)", fmt3(w.ratio), w.name) + } + return "n/a" + } + var lines []string + lines = append(lines, + fmt.Sprintf("## h2h %s: %s vs %s", o.label, an, bn), + "", + fmt.Sprintf("common cases: %d (%s shards %d, %s shards %d); both-win %d/%d", + n, an, len(o.jvm), bn, len(o.rs), nBothWin, n), + "", + fmt.Sprintf("| metric | %s wins | losses | ties |", bn), + "| --- | ---: | ---: | ---: |", + ) + for _, m := range selected { + s := stat[m] + lines = append(lines, fmt.Sprintf("| %s (%s) | %d | %d | %d |", + metrics[m].tag, metrics[m].summary, s.win, s.loss, s.tie)) + } + var worsts []string + for _, m := range selected { + worsts = append(worsts, fmt.Sprintf("worst %s %s ratio: %s", bn, m, worstStr(m))) + } + verdictWord := "PASS" + if !gatePass { + verdictWord = "FAIL" + } + lines = append(lines, "", strings.Join(worsts, "; "), "", "### GATE: "+verdictWord) + if len(problems) > 0 { + lines = append(lines, "", strings.Join(problems, "; ")) + } + for _, m := range selected { + s := stat[m] + if len(s.losses) == 0 { + continue + } + mm := metrics[m] + lines = append(lines, "", + fmt.Sprintf("
%s losses (%d, worst first)", mm.tag, len(s.losses)), + "", + fmt.Sprintf("| ratio | case | %s | %s |", an, bn), + "| ---: | --- | ---: | ---: |") + for i, l := range s.losses { + if i >= 30 { + break + } + lines = append(lines, fmt.Sprintf("| %sx | %s | %s%s | %s%s |", + fmt3(l.ratio), l.name, mm.format(l.base), mm.unit, + mm.format(l.chal), mm.unit)) + } + lines = append(lines, "
") + } + f, err := os.OpenFile(o.summaryFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o666) + if err != nil { + fmt.Fprintf(os.Stderr, "h2h_compare: cannot write summary: %v\n", err) + return 2 + } + fmt.Fprintln(f, strings.Join(lines, "\n")) + f.Close() + } + + fmt.Print(out.String()) + if o.reportOnly { + return 0 + } + if gatePass { + return 0 + } + return 1 +} diff --git a/scripts/ci/go/cmd/h2h-compare/main.go b/scripts/ci/go/cmd/h2h-compare/main.go new file mode 100644 index 0000000000..275e713bb3 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/main.go @@ -0,0 +1,178 @@ +// Command h2h-compare is the head-to-head gate: reference leg vs +// challenger leg on cpu_s + maxrss_kb (+ pin_instructions). +// +// Usage: +// +// h2h-compare --jvm BASE.json [BASE.json ...] --rs CHALLENGER.json [...] +// --label SUITE [--summary-file FILE] [--report-only] +// [--merged-jvm FILE] [--merged-rs FILE] +// [--base-name NAME] [--challenger-name NAME] +// [--metrics cpu,mem[,ins]] [--margin F] +// +// This is a Go port of scripts/ci/h2h_compare.py: stdout, the +// summary Markdown, verdicts, and exit codes (0 pass, 1 gate fail, +// 2 usage/load error) match the Python oracle byte-for-byte. Two +// deliberate differences: --help/usage errors print a short +// h2h_compare-prefixed line instead of argparse's usage text, and +// the merged leg JSONs may order object keys differently (they are +// consumed as JSON, so only the parsed content matters). +// +// The ins metric reads summary.pin_instructions (the native --pin +// signal), falling back to summary.instructions (the retired +// pin_sweep.py shape). +package main + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// options are the parsed CLI flags. +type options struct { + jvm []string + rs []string + baseName string + challengerName string + label string + summaryFile string + reportOnly bool + mergedJVM string + mergedRS string + metrics string + margin float64 +} + +const usage = `h2h-compare: reference-vs-challenger CPU/RSS gate + +Usage: h2h-compare --jvm BASE.json [BASE.json ...] --rs CHALLENGER.json [...] + --label SUITE [--summary-file FILE] [--report-only] + [--merged-jvm FILE] [--merged-rs FILE] + [--base-name NAME] [--challenger-name NAME] + [--metrics CSv] [--margin F] +` + +// parseArgs ports argparse's flags: --jvm/--rs collect every +// following token until the next --flag (repeatable, appends); +// scalars take the next token or the --flag=value form. +func parseArgs(argv []string) (options, error) { + o := options{baseName: "jvm", challengerName: "rs", metrics: "cpu,mem"} + setScalar := func(name, value string) error { + switch name { + case "base-name": + o.baseName = value + case "challenger-name": + o.challengerName = value + case "label": + o.label = value + case "summary-file": + o.summaryFile = value + case "merged-jvm": + o.mergedJVM = value + case "merged-rs": + o.mergedRS = value + case "metrics": + o.metrics = value + case "margin": + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid --margin %q", value) + } + o.margin = f + default: + return fmt.Errorf("unknown flag '--%s'", name) + } + return nil + } + i := 0 + for i < len(argv) { + tok := argv[i] + name := tok + inline := "" + hasInline := false + if v, ok := strings.CutPrefix(tok, "--"); ok { + name = v + if n, v, found := strings.Cut(v, "="); found { + name, inline, hasInline = n, v, true + } + } else { + return o, fmt.Errorf("unexpected argument %q", tok) + } + switch name { + case "jvm", "rs": + var paths []string + if hasInline { + paths = append(paths, inline) + i++ + } else { + i++ + for i < len(argv) && !strings.HasPrefix(argv[i], "--") { + paths = append(paths, argv[i]) + i++ + } + } + if len(paths) == 0 { + return o, fmt.Errorf("--%s needs at least one JSON", name) + } + if name == "jvm" { + o.jvm = append(o.jvm, paths...) + } else { + o.rs = append(o.rs, paths...) + } + case "report-only": + if hasInline { + return o, fmt.Errorf("--report-only takes no value") + } + o.reportOnly = true + i++ + case "help": + return o, errHelp + default: + if hasInline { + if err := setScalar(name, inline); err != nil { + return o, err + } + i++ + continue + } + if i+1 >= len(argv) { + return o, fmt.Errorf("--%s needs a value", name) + } + if err := setScalar(name, argv[i+1]); err != nil { + return o, err + } + i += 2 + } + } + if len(o.jvm) == 0 { + return o, fmt.Errorf("missing required --jvm") + } + if len(o.rs) == 0 { + return o, fmt.Errorf("missing required --rs") + } + if o.label == "" { + return o, fmt.Errorf("missing required --label") + } + return o, nil +} + +// errHelp requests the usage text on stdout with exit 0. +var errHelp = fmt.Errorf("help") + +func run(argv []string) int { + o, err := parseArgs(argv) + if err == errHelp { + fmt.Print(usage) + return 0 + } + if err != nil { + fmt.Fprintf(os.Stderr, "h2h_compare: %v\n", err) + return 2 + } + return compare(o) +} + +func main() { + os.Exit(run(os.Args[1:])) +} diff --git a/scripts/ci/go/cmd/h2h-compare/main_test.go b/scripts/ci/go/cmd/h2h-compare/main_test.go new file mode 100644 index 0000000000..91a1844ee7 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/main_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/hhh-pa/clojure/scripts/ci/go/internal/cmdtest" +) + +// Goldens were generated from the Python oracle +// (scripts/ci/h2h_compare.py, now removed) and diffed byte-for-byte +// at generation time: +// +// python3 h2h_compare.py --label recipes \ +// --jvm testdata/base-shard-0.json testdata/base-shard-1.json \ +// --rs testdata/chal.json \ +// --merged-jvm testdata/golden-merged-base.json \ +// --merged-rs testdata/golden-merged-chal.json \ +// --summary-file testdata/golden-summary-default.md \ +// > testdata/golden-default.txt +// +// The ins/report-only goldens use the same fixtures with +// --metrics ins --margin 0.01 --base-name rs-to +// --challenger-name rs-from and --report-only respectively. + +func TestGoldenDefault(t *testing.T) { + bin := cmdtest.Build(t, ".") + work := t.TempDir() + summary := filepath.Join(work, "summary.md") + mergedJVM := filepath.Join(work, "merged-jvm.json") + mergedRS := filepath.Join(work, "merged-rs.json") + stdout, stderr, code := cmdtest.RunBothEnv(t, bin, ".", nil, + "--label", "recipes", + "--jvm", "testdata/base-shard-0.json", "testdata/base-shard-1.json", + "--rs", "testdata/chal.json", + "--merged-jvm", mergedJVM, "--merged-rs", mergedRS, + "--summary-file", summary) + if stderr != "" { + t.Fatalf("stderr:\n%s", stderr) + } + if code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + if stdout != cmdtest.Golden(t, "testdata/golden-default.txt") { + t.Fatalf("stdout differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-default.txt"), stdout)) + } + gotSummary, err := os.ReadFile(summary) + if err != nil { + t.Fatal(err) + } + if string(gotSummary) != cmdtest.Golden(t, "testdata/golden-summary-default.md") { + t.Fatalf("summary differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-summary-default.md"), string(gotSummary))) + } + assertMergedEqual(t, "testdata/golden-merged-base.json", mergedJVM) + assertMergedEqual(t, "testdata/golden-merged-chal.json", mergedRS) +} + +func TestGoldenIns(t *testing.T) { + bin := cmdtest.Build(t, ".") + work := t.TempDir() + summary := filepath.Join(work, "summary.md") + stdout, code := cmdtest.Run(t, bin, ".", + "--label", "recipes-rsrs", + "--base-name", "rs-to", "--challenger-name", "rs-from", + "--jvm", "testdata/base-shard-0.json", "testdata/base-shard-1.json", + "--rs", "testdata/chal.json", + "--metrics", "ins", "--margin", "0.01", + "--summary-file", summary) + if code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + if stdout != cmdtest.Golden(t, "testdata/golden-ins.txt") { + t.Fatalf("stdout differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-ins.txt"), stdout)) + } + gotSummary, err := os.ReadFile(summary) + if err != nil { + t.Fatal(err) + } + if string(gotSummary) != cmdtest.Golden(t, "testdata/golden-summary-ins.md") { + t.Fatalf("summary differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-summary-ins.md"), string(gotSummary))) + } +} + +func TestGoldenReportOnly(t *testing.T) { + bin := cmdtest.Build(t, ".") + stdout, code := cmdtest.Run(t, bin, ".", + "--label", "recipes", + "--jvm", "testdata/base-shard-0.json", "testdata/base-shard-1.json", + "--rs", "testdata/chal.json", + "--report-only") + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + if stdout != cmdtest.Golden(t, "testdata/golden-reportonly.txt") { + t.Fatalf("stdout differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-reportonly.txt"), stdout)) + } +} + +func TestUsageErrors(t *testing.T) { + bin := cmdtest.Build(t, ".") + for _, args := range [][]string{ + {}, + {"--label", "x"}, + {"--label", "x", "--jvm", "testdata/chal.json"}, + {"--label", "x", "--jvm", "testdata/chal.json", "--rs", "testdata/chal.json", "--bogus"}, + {"--label", "x", "--jvm", "testdata/chal.json", "--rs", "testdata/chal.json", "--margin", "abc"}, + {"--label", "x", "--jvm", "testdata/chal.json", "--rs", "testdata/chal.json", "--metrics", "cpu,bogus"}, + {"--label", "x", "--jvm", "testdata/does-not-exist.json", "--rs", "testdata/chal.json"}, + } { + _, stderr, code := cmdtest.RunBothEnv(t, bin, ".", nil, args...) + if code != 2 { + t.Fatalf("%v: exit = %d, want 2", args, code) + } + if !strings.HasPrefix(stderr, "h2h_compare: ") { + t.Fatalf("%v: stderr = %q, want h2h_compare prefix", args, stderr) + } + } +} + +// assertMergedEqual compares merged leg JSONs semantically: Go sorts +// object keys where Python preserves insertion order, so only the +// parsed content must match. +func assertMergedEqual(t *testing.T, goldenPath, gotPath string) { + t.Helper() + decode := func(path string) any { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("%s: %v", path, err) + } + return v + } + if !reflect.DeepEqual(decode(goldenPath), decode(gotPath)) { + t.Fatalf("merged %s differs semantically from %s", gotPath, goldenPath) + } +} + +func diffText(want, got string) string { + w, g := strings.Split(want, "\n"), strings.Split(got, "\n") + var b strings.Builder + for i := 0; i < len(w) || i < len(g); i++ { + var x, y string + if i < len(w) { + x = w[i] + } + if i < len(g) { + y = g[i] + } + mark := " " + if x != y { + mark = "!" + } + b.WriteString(mark + " want: " + x + "\n") + b.WriteString(mark + " got: " + y + "\n") + if i > 40 { + b.WriteString("... (truncated)\n") + break + } + } + return b.String() +} + +func TestParseArgs(t *testing.T) { + o, err := parseArgs([]string{"--label", "r", "--jvm", "a.json", "b.json", "--rs", "c.json"}) + if err != nil { + t.Fatal(err) + } + if len(o.jvm) != 2 || o.jvm[1] != "b.json" || len(o.rs) != 1 { + t.Fatalf("nargs collect: %+v", o) + } + o, err = parseArgs([]string{"--label=x", "--jvm=a.json", "--jvm=b.json", "--rs=c.json", "--margin=0.01", "--metrics=ins"}) + if err != nil { + t.Fatal(err) + } + if o.label != "x" || len(o.jvm) != 2 || o.margin != 0.01 || o.metrics != "ins" { + t.Fatalf("= form: %+v", o) + } + if _, err := parseArgs([]string{"--label", "x", "--jvm", "a", "--rs"}); err == nil { + t.Fatal("dangling --rs must fail") + } +} + +func TestFormats(t *testing.T) { + if got := fmtComma(1000000); got != "1,000,000" { + t.Fatalf("fmtComma(1e6) = %q", got) + } + if got := fmtComma(999); got != "999" { + t.Fatalf("fmtComma(999) = %q", got) + } + if got := fmtComma(0); got != "0" { + t.Fatalf("fmtComma(0) = %q", got) + } + if got := fmt3(2.5); got != "2.500" { + t.Fatalf("fmt3(2.5) = %q", got) + } +} + +func TestShort(t *testing.T) { + if got := short(caseKey{"l", "s", "run"}); got != "l" { + t.Fatalf("short run = %q", got) + } + if got := short(caseKey{"l", "s", "c"}); got != "l/c" { + t.Fatalf("short case = %q", got) + } +} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json new file mode 100644 index 0000000000..cc3725fdee --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json @@ -0,0 +1,8 @@ +{"failures": ["snap-fail-b"], "missing": ["m-base-1"], "config": {"shard": "0/2", "repeat": 3}, "results": [ +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_1", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_2", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 1.4, "median": 1.5, "max": 1.6}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 2990000, "median": 3000000, "max": 3010000}, "instructions": {"min": 2990000, "median": 3000000, "max": 3010000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_3", "ok": true, "error": null, "runs": [{"wall_s": 0.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.0, "median": 0.0, "max": 0.0}, "maxrss_kb": {"min": 49000, "median": 50000, "max": 51000}, "pin_instructions": {"min": 100, "median": 100, "max": 100}, "instructions": {"min": 100, "median": 100, "max": 100}}}, +{"label": "zzz/skipped_thing", "stem": "solution", "script": "src/zzz/skipped_thing/solution.clj", "case": "test_case_9", "ok": true, "error": null, "runs": [], "alloc_runs": [], "summary": {}}, +{"label": "aaa/only_here_base", "stem": "solution", "script": "src/aaa/only_here_base/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990, "median": 1000, "max": 1010}, "instructions": {"min": 990, "median": 1000, "max": 1010}}} +]} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json new file mode 100644 index 0000000000..cfa2b18ad5 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json @@ -0,0 +1,8 @@ +{"failures": ["aardvark-fail-b"], "missing": [], "config": {"shard": "1/2", "repeat": 3}, "results": [ +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_4", "ok": true, "error": null, "runs": [{"wall_s": 2.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.5, "median": 2.0, "max": 2.5}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 490000, "median": 500000, "max": 510000}, "instructions": {"min": 490000, "median": 500000, "max": 510000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_5", "ok": true, "error": null, "runs": [{"wall_s": 2.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 1.9, "median": 2.0, "max": 2.1}, "maxrss_kb": {"min": 79000, "median": 80000, "max": 81000}, "pin_instructions": {"min": 99000, "median": 100000, "max": 101000}, "instructions": {"min": 99000, "median": 100000, "max": 101000}}}, +{"label": "zzz/skipped_thing", "stem": "solution", "script": "src/zzz/skipped_thing/solution.clj", "case": "test_case_9", "ok": true, "error": null, "runs": [], "alloc_runs": [], "summary": {}}, +{"label": "sort/broken_sort", "stem": "solution", "script": "src/sort/broken_sort/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, +{"label": "my.ns/foo-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 3.0}], "alloc_runs": [], "test_counts": {"pass": 10, "test": 10, "fail": 0, "error": 0}, "summary": {"cpu_s": {"min": 2.9, "median": 3.0, "max": 3.1}, "maxrss_kb": {"min": 190000, "median": 200000, "max": 210000}, "pin_instructions": {"min": 1990000, "median": 2000000, "max": 2010000}, "instructions": {"min": 1990000, "median": 2000000, "max": 2010000}}}, +{"label": "clojure-algorithms.graphs.karger-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 4.0}], "alloc_runs": [], "test_counts": {"pass": 100, "test": 50, "fail": 0, "error": 0}, "summary": {"cpu_s": {"min": 3.9, "median": 4.0, "max": 4.1}, "maxrss_kb": {"min": 290000, "median": 300000, "max": 310000}, "pin_instructions": {"min": 3990000, "median": 4000000, "max": 4010000}, "instructions": {"min": 3990000, "median": 4000000, "max": 4010000}}} +]} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/chal.json b/scripts/ci/go/cmd/h2h-compare/testdata/chal.json new file mode 100644 index 0000000000..f0c9ccfab2 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/chal.json @@ -0,0 +1,13 @@ +{"failures": ["snap-fail-c"], "missing": ["m-chal-1"], "config": {"shard": "0/1", "repeat": 3}, "results": [ +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 0.1}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.09, "median": 0.1, "max": 0.11}, "maxrss_kb": {"min": 19000, "median": 20000, "max": 21000}, "pin_instructions": {"min": 99000, "median": 100000, "max": 101000}, "instructions": {"min": 99000, "median": 100000, "max": 101000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_1", "ok": true, "error": null, "runs": [{"wall_s": 2.5}], "alloc_runs": [], "summary": {"cpu_s": {"min": 2.4, "median": 2.5, "max": 2.6}, "maxrss_kb": {"min": 140000, "median": 150000, "max": 160000}, "pin_instructions": {"min": 2490000, "median": 2500000, "max": 2510000}, "instructions": {"min": 2490000, "median": 2500000, "max": 2510000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_2", "ok": true, "error": null, "runs": [{"wall_s": 1.5}], "alloc_runs": [], "summary": {"cpu_s": {"min": 1.4, "median": 1.5, "max": 1.6}, "maxrss_kb": {"min": 89000, "median": 90000, "max": 95000}, "pin_instructions": {"min": 2990000, "median": 3000000, "max": 3010000}, "instructions": {"min": 2990000, "median": 3000000, "max": 3010000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_3", "ok": true, "error": null, "runs": [{"wall_s": 0.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.0, "median": 0.0, "max": 0.0}, "maxrss_kb": {"min": 39000, "median": 40000, "max": 41000}, "pin_instructions": {"min": 100, "median": 100, "max": 100}, "instructions": {"min": 100, "median": 100, "max": 100}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_4", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.6, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 89000, "median": 90000, "max": 120000}, "pin_instructions": {"min": 390000, "median": 400000, "max": 410000}, "instructions": {"min": 390000, "median": 400000, "max": 410000}}}, +{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_5", "ok": true, "error": null, "runs": [{"wall_s": 2.01}], "alloc_runs": [], "summary": {"cpu_s": {"min": 2.0, "median": 2.01, "max": 2.02}, "maxrss_kb": {"min": 79000, "median": 80000, "max": 81000}, "pin_instructions": {"min": 100000, "median": 100500, "max": 101000}, "instructions": {"min": 100000, "median": 100500, "max": 101000}}}, +{"label": "zzz/skipped_thing", "stem": "solution", "script": "src/zzz/skipped_thing/solution.clj", "case": "test_case_9", "ok": true, "error": null, "runs": [], "alloc_runs": [], "summary": {}}, +{"label": "sort/broken_sort", "stem": "solution", "script": "src/sort/broken_sort/solution.clj", "case": "test_case_0", "ok": false, "error": "exit 1: boom", "runs": [], "alloc_runs": [], "summary": {}}, +{"label": "my.ns/foo-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "test_counts": {"pass": 9, "test": 10, "fail": 1, "error": 0}, "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, +{"label": "clojure-algorithms.graphs.karger-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 2.0}], "alloc_runs": [], "test_counts": {"pass": 97, "test": 50, "fail": 0, "error": 0}, "summary": {"cpu_s": {"min": 1.9, "median": 2.0, "max": 2.1}, "maxrss_kb": {"min": 140000, "median": 150000, "max": 160000}, "pin_instructions": {"min": 1990000, "median": 2000000, "max": 2010000}, "instructions": {"min": 1990000, "median": 2000000, "max": 2010000}}}, +{"label": "aaa/only_here_chal", "stem": "solution", "script": "src/aaa/only_here_chal/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 0.5}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.4, "median": 0.5, "max": 0.6}, "maxrss_kb": {"min": 49000, "median": 50000, "max": 51000}, "pin_instructions": {"min": 490, "median": 500, "max": 510}, "instructions": {"min": 490, "median": 500, "max": 510}}} +]} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-default.txt b/scripts/ci/go/cmd/h2h-compare/testdata/golden-default.txt new file mode 100644 index 0000000000..ac917c61f9 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-default.txt @@ -0,0 +1,34 @@ +== recipes == +jvm: shards=2 results=11 failures=2 missing=1 +rs: shards=1 results=11 failures=1 missing=1 +common cases: 10 only-jvm: 1 only-rs: 1 + only-jvm: aaa/only_here_base/solution/test_case_0 + only-rs: aaa/only_here_chal/solution/test_case_0 + NOTE sort/bubble_sort/test_case_4: median verdict differs from README convention (cpu min 0.600 vs 0.500; rss max 120000 vs 110000) +compared ok-cases: 8 +CPU (median): rs wins 4, losses 2, ties 2 +MEM (median): rs wins 6, losses 1, ties 1 +both-win cases: 4/8 +worst rs cpu ratio: infx (sort/bubble_sort/test_case_3) +worst rs mem ratio: 1.500x (sort/bubble_sort/test_case_1) +--- CPU LOSSES (2) --- + 2.500x sort/bubble_sort/test_case_1 jvm=1.000s rs=2.500s + 1.005x sort/bubble_sort/test_case_5 jvm=2.000s rs=2.010s +--- MEM LOSSES (1) --- + 1.500x sort/bubble_sort/test_case_1 jvm=100000K rs=150000K +--- SKIPPED-NO-RUNS (1) --- + zzz/skipped_thing/test_case_9 jvm_runs=0 rs_runs=0 +--- NOT-OK (1) --- + sort/broken_sort/test_case_0 jvm_err=None rs_err=exit 1: boom +--- TEST-COUNT DRIFT (1) --- + my.ns/foo-test jvm={'pass': 10, 'test': 10, 'fail': 0, 'error': 0} rs={'pass': 9, 'test': 10, 'fail': 1, 'error': 0} +--- JVM failures (2) --- + aardvark-fail-b + snap-fail-b +--- RS failures (1) --- + snap-fail-c +--- JVM missing (1) --- + m-base-1 +--- RS missing (1) --- + m-chal-1 +GATE recipes: FAIL: 2 CPU losses; 1 MEM losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-jvm 1, only-rs 1); sweep failures (jvm 2, rs 1); missing cases (jvm 1, rs 1) diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-ins.txt b/scripts/ci/go/cmd/h2h-compare/testdata/golden-ins.txt new file mode 100644 index 0000000000..d66397c4d7 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-ins.txt @@ -0,0 +1,28 @@ +== recipes-rsrs == +rs-to: shards=2 results=11 failures=2 missing=1 +rs-from: shards=1 results=11 failures=1 missing=1 +common cases: 10 only-rs-to: 1 only-rs-from: 1 + only-rs-to: aaa/only_here_base/solution/test_case_0 + only-rs-from: aaa/only_here_chal/solution/test_case_0 +compared ok-cases: 8 +INS (median): rs-from wins 4, losses 1, ties 3 +both-win cases: 4/8 +worst rs-from ins ratio: 2.500x (sort/bubble_sort/test_case_1) +--- INS LOSSES (1) --- + 2.500x sort/bubble_sort/test_case_1 rs-to=1,000,000 rs-from=2,500,000 +--- SKIPPED-NO-RUNS (1) --- + zzz/skipped_thing/test_case_9 rs-to_runs=0 rs-from_runs=0 +--- NOT-OK (1) --- + sort/broken_sort/test_case_0 rs-to_err=None rs-from_err=exit 1: boom +--- TEST-COUNT DRIFT (1) --- + my.ns/foo-test jvm={'pass': 10, 'test': 10, 'fail': 0, 'error': 0} rs={'pass': 9, 'test': 10, 'fail': 1, 'error': 0} +--- RS-TO failures (2) --- + aardvark-fail-b + snap-fail-b +--- RS-FROM failures (1) --- + snap-fail-c +--- RS-TO missing (1) --- + m-base-1 +--- RS-FROM missing (1) --- + m-chal-1 +GATE recipes-rsrs: FAIL: 1 INS losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-rs-to 1, only-rs-from 1); sweep failures (rs-to 2, rs-from 1); missing cases (rs-to 1, rs-from 1) diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json new file mode 100644 index 0000000000..ccb67d819c --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json @@ -0,0 +1,394 @@ +{ + "failures": [ + "aardvark-fail-b", + "snap-fail-b" + ], + "missing": [ + "m-base-1" + ], + "config": { + "shard": "merged", + "repeat": 3 + }, + "results": [ + { + "label": "aaa/only_here_base", + "stem": "solution", + "script": "src/aaa/only_here_base/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990, + "median": 1000, + "max": 1010 + }, + "instructions": { + "min": 990, + "median": 1000, + "max": 1010 + } + } + }, + { + "label": "clojure-algorithms.graphs.karger-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 4.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 100, + "test": 50, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 3.9, + "median": 4.0, + "max": 4.1 + }, + "maxrss_kb": { + "min": 290000, + "median": 300000, + "max": 310000 + }, + "pin_instructions": { + "min": 3990000, + "median": 4000000, + "max": 4010000 + }, + "instructions": { + "min": 3990000, + "median": 4000000, + "max": 4010000 + } + } + }, + { + "label": "my.ns/foo-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 3.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 10, + "test": 10, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 2.9, + "median": 3.0, + "max": 3.1 + }, + "maxrss_kb": { + "min": 190000, + "median": 200000, + "max": 210000 + }, + "pin_instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + } + } + }, + { + "label": "sort/broken_sort", + "stem": "solution", + "script": "src/sort/broken_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_1", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_2", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.4, + "median": 1.5, + "max": 1.6 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_3", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.0, + "median": 0.0, + "max": 0.0 + }, + "maxrss_kb": { + "min": 49000, + "median": 50000, + "max": 51000 + }, + "pin_instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "instructions": { + "min": 100, + "median": 100, + "max": 100 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_4", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.5, + "median": 2.0, + "max": 2.5 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 490000, + "median": 500000, + "max": 510000 + }, + "instructions": { + "min": 490000, + "median": 500000, + "max": 510000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_5", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.9, + "median": 2.0, + "max": 2.1 + }, + "maxrss_kb": { + "min": 79000, + "median": 80000, + "max": 81000 + }, + "pin_instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "zzz/skipped_thing", + "stem": "solution", + "script": "src/zzz/skipped_thing/solution.clj", + "case": "test_case_9", + "ok": true, + "error": null, + "runs": [], + "alloc_runs": [], + "summary": {} + } + ] +} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json new file mode 100644 index 0000000000..c0c5b1328e --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json @@ -0,0 +1,368 @@ +{ + "failures": [ + "snap-fail-c" + ], + "missing": [ + "m-chal-1" + ], + "config": { + "shard": "merged", + "repeat": 3 + }, + "results": [ + { + "label": "aaa/only_here_chal", + "stem": "solution", + "script": "src/aaa/only_here_chal/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.4, + "median": 0.5, + "max": 0.6 + }, + "maxrss_kb": { + "min": 49000, + "median": 50000, + "max": 51000 + }, + "pin_instructions": { + "min": 490, + "median": 500, + "max": 510 + }, + "instructions": { + "min": 490, + "median": 500, + "max": 510 + } + } + }, + { + "label": "clojure-algorithms.graphs.karger-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 97, + "test": 50, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 1.9, + "median": 2.0, + "max": 2.1 + }, + "maxrss_kb": { + "min": 140000, + "median": 150000, + "max": 160000 + }, + "pin_instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + } + } + }, + { + "label": "my.ns/foo-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 9, + "test": 10, + "fail": 1, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + } + } + }, + { + "label": "sort/broken_sort", + "stem": "solution", + "script": "src/sort/broken_sort/solution.clj", + "case": "test_case_0", + "ok": false, + "error": "exit 1: boom", + "runs": [], + "alloc_runs": [], + "summary": {} + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.1 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.09, + "median": 0.1, + "max": 0.11 + }, + "maxrss_kb": { + "min": 19000, + "median": 20000, + "max": 21000 + }, + "pin_instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_1", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 2.4, + "median": 2.5, + "max": 2.6 + }, + "maxrss_kb": { + "min": 140000, + "median": 150000, + "max": 160000 + }, + "pin_instructions": { + "min": 2490000, + "median": 2500000, + "max": 2510000 + }, + "instructions": { + "min": 2490000, + "median": 2500000, + "max": 2510000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_2", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.4, + "median": 1.5, + "max": 1.6 + }, + "maxrss_kb": { + "min": 89000, + "median": 90000, + "max": 95000 + }, + "pin_instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_3", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.0, + "median": 0.0, + "max": 0.0 + }, + "maxrss_kb": { + "min": 39000, + "median": 40000, + "max": 41000 + }, + "pin_instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "instructions": { + "min": 100, + "median": 100, + "max": 100 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_4", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.6, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 89000, + "median": 90000, + "max": 120000 + }, + "pin_instructions": { + "min": 390000, + "median": 400000, + "max": 410000 + }, + "instructions": { + "min": 390000, + "median": 400000, + "max": 410000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_5", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.01 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 2.0, + "median": 2.01, + "max": 2.02 + }, + "maxrss_kb": { + "min": 79000, + "median": 80000, + "max": 81000 + }, + "pin_instructions": { + "min": 100000, + "median": 100500, + "max": 101000 + }, + "instructions": { + "min": 100000, + "median": 100500, + "max": 101000 + } + } + }, + { + "label": "zzz/skipped_thing", + "stem": "solution", + "script": "src/zzz/skipped_thing/solution.clj", + "case": "test_case_9", + "ok": true, + "error": null, + "runs": [], + "alloc_runs": [], + "summary": {} + } + ] +} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-reportonly.txt b/scripts/ci/go/cmd/h2h-compare/testdata/golden-reportonly.txt new file mode 100644 index 0000000000..341e061be6 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-reportonly.txt @@ -0,0 +1,34 @@ +== recipes == +jvm: shards=2 results=11 failures=2 missing=1 +rs: shards=1 results=11 failures=1 missing=1 +common cases: 10 only-jvm: 1 only-rs: 1 + only-jvm: aaa/only_here_base/solution/test_case_0 + only-rs: aaa/only_here_chal/solution/test_case_0 + NOTE sort/bubble_sort/test_case_4: median verdict differs from README convention (cpu min 0.600 vs 0.500; rss max 120000 vs 110000) +compared ok-cases: 8 +CPU (median): rs wins 4, losses 2, ties 2 +MEM (median): rs wins 6, losses 1, ties 1 +both-win cases: 4/8 +worst rs cpu ratio: infx (sort/bubble_sort/test_case_3) +worst rs mem ratio: 1.500x (sort/bubble_sort/test_case_1) +--- CPU LOSSES (2) --- + 2.500x sort/bubble_sort/test_case_1 jvm=1.000s rs=2.500s + 1.005x sort/bubble_sort/test_case_5 jvm=2.000s rs=2.010s +--- MEM LOSSES (1) --- + 1.500x sort/bubble_sort/test_case_1 jvm=100000K rs=150000K +--- SKIPPED-NO-RUNS (1) --- + zzz/skipped_thing/test_case_9 jvm_runs=0 rs_runs=0 +--- NOT-OK (1) --- + sort/broken_sort/test_case_0 jvm_err=None rs_err=exit 1: boom +--- TEST-COUNT DRIFT (1) --- + my.ns/foo-test jvm={'pass': 10, 'test': 10, 'fail': 0, 'error': 0} rs={'pass': 9, 'test': 10, 'fail': 1, 'error': 0} +--- JVM failures (2) --- + aardvark-fail-b + snap-fail-b +--- RS failures (1) --- + snap-fail-c +--- JVM missing (1) --- + m-base-1 +--- RS missing (1) --- + m-chal-1 +GATE recipes: FAIL ignored (--report-only): 2 CPU losses; 1 MEM losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-jvm 1, only-rs 1); sweep failures (jvm 2, rs 1); missing cases (jvm 1, rs 1) diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-default.md b/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-default.md new file mode 100644 index 0000000000..2f5a1587cd --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-default.md @@ -0,0 +1,29 @@ +## h2h recipes: jvm vs rs + +common cases: 8 (jvm shards 2, rs shards 1); both-win 4/8 + +| metric | rs wins | losses | ties | +| --- | ---: | ---: | ---: | +| CPU (median cpu_s) | 4 | 2 | 2 | +| MEM (median maxrss_kb) | 6 | 1 | 1 | + +worst rs cpu ratio: infx (sort/bubble_sort/test_case_3); worst rs mem ratio: 1.500x (sort/bubble_sort/test_case_1) + +### GATE: FAIL + +2 CPU losses; 1 MEM losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-jvm 1, only-rs 1); sweep failures (jvm 2, rs 1); missing cases (jvm 1, rs 1) + +
CPU losses (2, worst first) + +| ratio | case | jvm | rs | +| ---: | --- | ---: | ---: | +| 2.500x | sort/bubble_sort/test_case_1 | 1.000s | 2.500s | +| 1.005x | sort/bubble_sort/test_case_5 | 2.000s | 2.010s | +
+ +
MEM losses (1, worst first) + +| ratio | case | jvm | rs | +| ---: | --- | ---: | ---: | +| 1.500x | sort/bubble_sort/test_case_1 | 100000K | 150000K | +
diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-ins.md b/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-ins.md new file mode 100644 index 0000000000..434fffdb00 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-ins.md @@ -0,0 +1,20 @@ +## h2h recipes-rsrs: rs-to vs rs-from + +common cases: 8 (rs-to shards 2, rs-from shards 1); both-win 4/8 + +| metric | rs-from wins | losses | ties | +| --- | ---: | ---: | ---: | +| INS (median instructions) | 4 | 1 | 3 | + +worst rs-from ins ratio: 2.500x (sort/bubble_sort/test_case_1) + +### GATE: FAIL + +1 INS losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-rs-to 1, only-rs-from 1); sweep failures (rs-to 2, rs-from 1); missing cases (rs-to 1, rs-from 1) + +
INS losses (1, worst first) + +| ratio | case | rs-to | rs-from | +| ---: | --- | ---: | ---: | +| 2.500x | sort/bubble_sort/test_case_1 | 1,000,000 | 2,500,000 | +
diff --git a/scripts/ci/h2h_compare.py b/scripts/ci/h2h_compare.py deleted file mode 100755 index 126cdf6975..0000000000 --- a/scripts/ci/h2h_compare.py +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env python3 -"""Head-to-head gate: reference leg vs challenger leg on cpu_s + maxrss_kb. - -Usage: - h2h_compare.py --jvm JVM.json [JVM.json ...] --rs RS.json [RS.json ...] - --label SUITE [--summary-file FILE] [--report-only] - [--merged-jvm FILE] [--merged-rs FILE] - [--base-name NAME] [--challenger-name NAME] - ---jvm takes the reference leg, --rs the challenger leg (a "win" is -challenger-faster). The JVM-vs-native gate uses the defaults; the -native-vs-native comparison passes two clojure-rs legs with ---base-name/--challenger-name (e.g. rs-from/rs-to). - -Compares per-case medians (primary) and cross-checks the README -convention (cpu min-of-repeats, rss max-of-repeats). Multiple JSONs -per leg are unioned (one per measure shard); duplicate case keys -across shards are a hard error, except identical run-less -sweep-skip markers, which every shard records by design. - -Strict gate: exits non-zero on any CPU/RSS loss, failure, missing -case, not-ok case, behavioral drift, or leg-coverage mismatch, -unless --report-only (triage mode: always exits 0). - -Stdlib only (python3 on the GitHub runner). -""" -import argparse -import json -import sys - -# Namespaces whose :pass count varies run to run by design (they -# assert per edge of a random graph); :test/:fail/:error still -# compare exactly. Mirrors the report-compare job of the -# clojure-algorithms perf-callgrind workflow. -VARY_PASS_LABELS = frozenset([ - "clojure-algorithms.graphs.karger-test", - "clojure-algorithms.graphs.random-graph-generator-test", -]) - - -def load(path): - with open(path) as f: - return json.load(f) - - -def key(r): - return (r["label"], r.get("stem", ""), r.get("case", "")) - - -def _is_skip_marker(r): - """True for a run-less sweep-skip marker (no timed runs at all).""" - if r.get("runs"): - return False - summary = r.get("summary") or {} - return not summary.get("cpu_s") - - -def union(paths, leg): - """Union shard reports into (results-by-key, failures, missing, files).""" - merged = {} - failures = set() - missing = set() - for path in paths: - doc = load(path) - for r in doc["results"]: - k = key(r) - if k in merged: - # Sweep-skipped namespaces (e.g. algos sweep_skips.json) - # are recorded run-less in EVERY shard by design; dedupe - # identical markers, keep the hard error for real dupes. - prev = merged[k] - if _is_skip_marker(prev) and _is_skip_marker(r) and prev == r: - continue - raise ValueError( - f"duplicate case {k[0]}/{k[1]}/{k[2]} across {leg} shards " - f"(shards must partition cases; check --shard flags)") - merged[k] = r - failures.update(doc.get("failures", [])) - missing.update(doc.get("missing", [])) - return merged, sorted(failures), sorted(missing) - - -def norm_counts(label, counts): - if counts is None: - return None - counts = dict(counts) - if label in VARY_PASS_LABELS: - counts["pass"] = 0 - return counts - - -def parse_args(argv): - p = argparse.ArgumentParser(description="Reference-vs-challenger CPU/RSS gate") - p.add_argument("--jvm", nargs="+", required=True, metavar="BASE.json", - help="reference leg: one merged JSON, or one JSON per shard") - p.add_argument("--rs", nargs="+", required=True, metavar="CHALLENGER.json", - help="challenger leg: one merged JSON, or one JSON per shard") - p.add_argument("--base-name", default="jvm", - help="display name of the reference leg (default: jvm)") - p.add_argument("--challenger-name", default="rs", - help="display name of the challenger leg (default: rs)") - p.add_argument("--label", required=True, help="suite name (recipes, algos)") - p.add_argument("--summary-file", default=None, - help="append a Markdown verdict table (GITHUB_STEP_SUMMARY)") - p.add_argument("--report-only", action="store_true", - help="triage mode: print the verdict but always exit 0") - p.add_argument("--merged-jvm", default=None, - help="write the unioned JVM leg JSON here") - p.add_argument("--merged-rs", default=None, - help="write the unioned clojure-rs leg JSON here") - return p.parse_args(argv) - - -def short(k): - return f"{k[0]}/{k[2]}" if k[2] != "run" else k[0] - - -def write_merged(path, first_doc, results, failures, missing): - doc = dict(first_doc) - doc["results"] = [results[k] for k in sorted(results)] - doc["failures"] = failures - doc["missing"] = missing - config = dict(doc.get("config", {})) - config["shard"] = "merged" - doc["config"] = config - with open(path, "w") as f: - json.dump(doc, f, indent=2) - f.write("\n") - - -def main(argv): - args = parse_args(argv) - try: - jm, j_fail, j_miss = union(args.jvm, args.base_name) - rm, r_fail, r_miss = union(args.rs, args.challenger_name) - except (OSError, ValueError, KeyError, json.JSONDecodeError) as e: - print(f"h2h_compare: error loading leg JSONs: {e}", file=sys.stderr) - return 2 - if args.merged_jvm: - write_merged(args.merged_jvm, load(args.jvm[0]), jm, j_fail, j_miss) - if args.merged_rs: - write_merged(args.merged_rs, load(args.rs[0]), rm, r_fail, r_miss) - - an, bn = args.base_name, args.challenger_name - print(f"== {args.label} ==") - print(f"{an}: shards={len(args.jvm)} results={len(jm)} " - f"failures={len(j_fail)} missing={len(j_miss)}") - print(f"{bn}: shards={len(args.rs)} results={len(rm)} " - f"failures={len(r_fail)} missing={len(r_miss)}") - only_jvm = sorted(set(jm) - set(rm)) - only_rs = sorted(set(rm) - set(jm)) - print(f"common cases: {len(set(jm) & set(rm))} " - f"only-{an}: {len(only_jvm)} only-{bn}: {len(only_rs)}") - for k in only_jvm[:10]: - print(f" only-{an}:", "/".join(k)) - for k in only_rs[:10]: - print(f" only-{bn}:", "/".join(k)) - - n_cpu_win = n_cpu_loss = n_cpu_tie = 0 - n_mem_win = n_mem_loss = n_mem_tie = 0 - n_both_win = 0 - cpu_losses, mem_losses = [], [] - not_ok, drift, skipped = [], [], [] - worst_cpu_margin, worst_mem_margin = None, None - for k in sorted(set(jm) & set(rm)): - a, b = jm[k], rm[k] - name = short(k) - if not a["ok"] or not b["ok"]: - not_ok.append((name, a.get("error"), b.get("error"))) - continue - if _is_skip_marker(a) or _is_skip_marker(b): - skipped.append((name, len(a.get("runs", [])), - len(b.get("runs", [])))) - continue - # behavioral drift gate (algos carry test_counts) - if "test_counts" in a or "test_counts" in b: - if norm_counts(k[0], a.get("test_counts")) != \ - norm_counts(k[0], b.get("test_counts")): - drift.append((name, a.get("test_counts"), b.get("test_counts"))) - jc, rc = a["summary"]["cpu_s"], b["summary"]["cpu_s"] - jr, rr = a["summary"]["maxrss_kb"], b["summary"]["maxrss_kb"] - cpu_ratio = rc["median"] / jc["median"] if jc["median"] else float("inf") - mem_ratio = rr["median"] / jr["median"] if jr["median"] else float("inf") - if rc["median"] < jc["median"]: - n_cpu_win += 1 - elif rc["median"] > jc["median"]: - n_cpu_loss += 1 - cpu_losses.append((cpu_ratio, name, jc["median"], rc["median"])) - else: - n_cpu_tie += 1 - if rr["median"] < jr["median"]: - n_mem_win += 1 - elif rr["median"] > jr["median"]: - n_mem_loss += 1 - mem_losses.append((mem_ratio, name, jr["median"], rr["median"])) - else: - n_mem_tie += 1 - if rc["median"] < jc["median"] and rr["median"] < jr["median"]: - n_both_win += 1 - # README-convention cross-check - rc_readme = rc["min"] < jc["min"] - rm_readme = rr["max"] < jr["max"] - if rc_readme != (rc["median"] < jc["median"]) or \ - rm_readme != (rr["median"] < jr["median"]): - print(f" NOTE {name}: median verdict differs from README convention " - f"(cpu min {rc['min']:.3f} vs {jc['min']:.3f}; " - f"rss max {rr['max']} vs {jr['max']})") - if worst_cpu_margin is None or cpu_ratio > worst_cpu_margin[0]: - worst_cpu_margin = (cpu_ratio, name) - if worst_mem_margin is None or mem_ratio > worst_mem_margin[0]: - worst_mem_margin = (mem_ratio, name) - - n = n_cpu_win + n_cpu_loss + n_cpu_tie - print(f"compared ok-cases: {n}") - print(f"CPU (median): {bn} wins {n_cpu_win}, losses {n_cpu_loss}, ties {n_cpu_tie}") - print(f"MEM (median): {bn} wins {n_mem_win}, losses {n_mem_loss}, ties {n_mem_tie}") - print(f"both-win cases: {n_both_win}/{n}") - if worst_cpu_margin is not None: - print(f"worst {bn} cpu ratio: {worst_cpu_margin[0]:.3f}x ({worst_cpu_margin[1]})") - print(f"worst {bn} mem ratio: {worst_mem_margin[0]:.3f}x ({worst_mem_margin[1]})") - else: - print(f"worst {bn} cpu ratio: n/a (no compared cases)") - print(f"worst {bn} mem ratio: n/a (no compared cases)") - if cpu_losses: - print(f"--- CPU LOSSES ({len(cpu_losses)}) ---") - for ratio, name, j, r in sorted(cpu_losses, reverse=True)[:50]: - print(f" {ratio:.3f}x {name} {an}={j:.3f}s {bn}={r:.3f}s") - if mem_losses: - print(f"--- MEM LOSSES ({len(mem_losses)}) ---") - for ratio, name, j, r in sorted(mem_losses, reverse=True)[:50]: - print(f" {ratio:.3f}x {name} {an}={j:.0f}K {bn}={r:.0f}K") - if skipped: - print(f"--- SKIPPED-NO-RUNS ({len(skipped)}) ---") - for name, aj, bj in skipped[:20]: - print(f" {name} {an}_runs={aj} {bn}_runs={bj}") - if not_ok: - print(f"--- NOT-OK ({len(not_ok)}) ---") - for name, je, re in not_ok[:50]: - print(f" {name} {an}_err={je} {bn}_err={re}") - if drift: - print(f"--- TEST-COUNT DRIFT ({len(drift)}) ---") - for name, j, r in drift[:20]: - print(f" {name} jvm={j} rs={r}") - if j_fail: - print(f"--- {an.upper()} failures ({len(j_fail)}) ---") - for f in j_fail[:20]: - print(" ", f) - if r_fail: - print(f"--- {bn.upper()} failures ({len(r_fail)}) ---") - for f in r_fail[:20]: - print(" ", f) - if j_miss: - print(f"--- {an.upper()} missing ({len(j_miss)}) ---") - for f in j_miss[:20]: - print(" ", f) - if r_miss: - print(f"--- {bn.upper()} missing ({len(r_miss)}) ---") - for f in r_miss[:20]: - print(" ", f) - - problems = [] - if n_cpu_loss: - problems.append(f"{n_cpu_loss} CPU losses") - if n_mem_loss: - problems.append(f"{n_mem_loss} MEM losses") - if not_ok: - problems.append(f"{len(not_ok)} not-ok cases") - if drift: - problems.append(f"{len(drift)} test-count drifts") - if only_jvm or only_rs: - problems.append(f"leg mismatch (only-{an} {len(only_jvm)}, " - f"only-{bn} {len(only_rs)})") - if j_fail or r_fail: - problems.append(f"sweep failures ({an} {len(j_fail)}, {bn} {len(r_fail)})") - if j_miss or r_miss: - problems.append(f"missing cases ({an} {len(j_miss)}, {bn} {len(r_miss)})") - if n == 0: - problems.append("no compared cases") - - gate_pass = not problems - if args.report_only: - verdict = "PASS (report-only)" if gate_pass else \ - f"FAIL ignored (--report-only): {'; '.join(problems)}" - else: - verdict = "PASS" if gate_pass else f"FAIL: {'; '.join(problems)}" - print(f"GATE {args.label}: {verdict}") - - if args.summary_file: - worst_cpu = ("n/a" if worst_cpu_margin is None else - f"{worst_cpu_margin[0]:.3f}x ({worst_cpu_margin[1]})") - worst_mem = ("n/a" if worst_mem_margin is None else - f"{worst_mem_margin[0]:.3f}x ({worst_mem_margin[1]})") - lines = [ - f"## h2h {args.label}: {an} vs {bn}", - "", - f"common cases: {n} ({an} shards {len(args.jvm)}, " - f"{bn} shards {len(args.rs)}); both-win {n_both_win}/{n}", - "", - f"| metric | {bn} wins | losses | ties |", - "| --- | ---: | ---: | ---: |", - f"| CPU (median cpu_s) | {n_cpu_win} | {n_cpu_loss} | {n_cpu_tie} |", - f"| MEM (median maxrss_kb) | {n_mem_win} | {n_mem_loss} | {n_mem_tie} |", - "", - f"worst {bn} cpu ratio: {worst_cpu}; worst {bn} mem ratio: {worst_mem}", - "", - f"### GATE: {'PASS' if gate_pass else 'FAIL'}", - ] - if problems: - lines.append("") - lines.append("; ".join(problems)) - if cpu_losses: - lines += ["", "
CPU losses " - f"({len(cpu_losses)}, worst first)", "", - f"| ratio | case | {an} | {bn} |", - "| ---: | --- | ---: | ---: |"] - for ratio, name, j, r in sorted(cpu_losses, reverse=True)[:30]: - lines.append(f"| {ratio:.3f}x | {name} | {j:.3f}s | {r:.3f}s |") - lines.append("
") - if mem_losses: - lines += ["", "
MEM losses " - f"({len(mem_losses)}, worst first)", "", - f"| ratio | case | {an} | {bn} |", - "| ---: | --- | ---: | ---: |"] - for ratio, name, j, r in sorted(mem_losses, reverse=True)[:30]: - lines.append(f"| {ratio:.3f}x | {name} | {j:.0f}K | {r:.0f}K |") - lines.append("
") - with open(args.summary_file, "a") as f: - f.write("\n".join(lines) + "\n") - - if args.report_only: - return 0 - return 0 if gate_pass else 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/scripts/ci/pintool/inscount.cpp b/scripts/ci/pintool/inscount.cpp new file mode 100644 index 0000000000..be2cad43fd --- /dev/null +++ b/scripts/ci/pintool/inscount.cpp @@ -0,0 +1,49 @@ +// inscount: minimal Intel Pin instruction-count tool for the +// h2h cpu/mem gate. Counts every executed instruction (all +// threads, all images including the Pin VM itself is excluded by +// construction: only application instructions are instrumented) +// and prints one line: +// +// Count N +// +// The harness parsers (snip/algoperf ParsePinCount) read the last +// such line. Build out of the kit with: +// +// make PIN_ROOT= -C +// +// which drops obj-intel64/inscount.so next to this file. +#include +#include "pin.H" + +static UINT64 total = 0; +static FILE *out = nullptr; +static KNOB KnobOutput(KNOB_MODE_WRITEONCE, "pintool", "o", "inscount.out", + "output file for the Count line"); + +static VOID docount(UINT32 n) { total += n; } + +static VOID Instruction(INS ins, VOID *) { + INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_UINT32, 1, IARG_END); +} + +static VOID Fini(INT32, VOID *) { + if (out != nullptr) { + fprintf(out, "Count %llu\n", (unsigned long long)total); + fclose(out); + } +} + +int main(int argc, char *argv[]) { + PIN_InitSymbols(); + if (PIN_Init(argc, argv)) { + return 1; + } + out = fopen(KnobOutput.Value().c_str(), "w"); + if (out == nullptr) { + return 1; + } + INS_AddInstrumentFunction(Instruction, nullptr); + PIN_AddFiniFunction(Fini, nullptr); + PIN_StartProgram(); + return 0; +} diff --git a/scripts/ci/pintool/makefile b/scripts/ci/pintool/makefile new file mode 100644 index 0000000000..10c9e7297e --- /dev/null +++ b/scripts/ci/pintool/makefile @@ -0,0 +1,11 @@ +# Out-of-kit build for the vendored inscount tool. The kit's own +# makefile does the heavy lifting; PIN_ROOT must point at the +# extracted kit root (the directory containing the `pin` launcher). +ifdef PIN_ROOT +CONFIG_ROOT := $(PIN_ROOT)/source/tools/Config +else +CONFIG_ROOT := ../Config +endif +include $(CONFIG_ROOT)/makefile.config +include makefile.rules +include $(TOOLS_ROOT)/Config/makefile.default.rules diff --git a/scripts/ci/pintool/makefile.rules b/scripts/ci/pintool/makefile.rules new file mode 100644 index 0000000000..8f2119c15e --- /dev/null +++ b/scripts/ci/pintool/makefile.rules @@ -0,0 +1 @@ +TOOL_ROOTS := inscount diff --git a/scripts/ci/setup-pin.sh b/scripts/ci/setup-pin.sh new file mode 100644 index 0000000000..4ebf7e7012 --- /dev/null +++ b/scripts/ci/setup-pin.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Provision Intel Pin + the vendored inscount tool for the h2h gate. +# +# Idempotent: when PIN_DIR already holds a kit and a built tool +# (the workflow caches PIN_DIR across runs), it just re-exports +# the paths. Otherwise it downloads the kit from Intel, verifies +# its sha256, extracts it, and builds +# scripts/ci/pintool/inscount.cpp against it. +# +# Outputs (stdout for logs, $GITHUB_ENV when present): +# PIN_BIN absolute path to the pin launcher +# PIN_TOOL absolute path to obj-intel64/inscount.so +set -euo pipefail + +PIN_URL="${PIN_URL:-https://software.intel.com/sites/landingpage/pintool/downloads/pin-external-4.4-99977-g54fbb8814-gcc-linux.tar.gz}" +PIN_SHA256="${PIN_SHA256:-3138183a5d2b51ea5c8f29edd148b67ac9c249e2c510cc0e9e2a5880f523a34c}" +PIN_TOPDIR="pin-external-4.4-99977-g54fbb8814-gcc-linux" +PIN_DIR="${PIN_DIR:-$HOME/pin}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOL_SRC="$SCRIPT_DIR/pintool" + +KIT_DIR="$PIN_DIR/$PIN_TOPDIR" +PIN_BIN="$KIT_DIR/pin" +TOOL_BUILD="$PIN_DIR/toolbuild" +PIN_TOOL="$TOOL_BUILD/obj-intel64/inscount.so" + +need_tool() { + [ ! -x "$PIN_BIN" ] || [ ! -f "$PIN_TOOL" ] +} + +if need_tool; then + command -v g++ >/dev/null || { echo "setup-pin: g++ is required to build the inscount tool" >&2; exit 1; } + command -v curl >/dev/null || { echo "setup-pin: curl is required to fetch the Pin kit" >&2; exit 1; } + [ -f "$TOOL_SRC/inscount.cpp" ] || { echo "setup-pin: tool sources missing at $TOOL_SRC" >&2; exit 1; } + mkdir -p "$PIN_DIR" + if [ ! -x "$PIN_BIN" ]; then + echo "setup-pin: downloading Pin 4.4 kit..." >&2 + curl -sSfL --retry 3 --max-time 600 -o "$PIN_DIR/pin-kit.tar.gz" "$PIN_URL" + echo "$PIN_SHA256 $PIN_DIR/pin-kit.tar.gz" | sha256sum -c - || { + echo "setup-pin: kit checksum mismatch" >&2; exit 1 + } + tar xzf "$PIN_DIR/pin-kit.tar.gz" -C "$PIN_DIR" + rm -f "$PIN_DIR/pin-kit.tar.gz" + fi + echo "setup-pin: building inscount tool..." >&2 + rm -rf "$TOOL_BUILD" + mkdir -p "$TOOL_BUILD" + cp "$TOOL_SRC/inscount.cpp" "$TOOL_SRC/makefile" "$TOOL_SRC/makefile.rules" "$TOOL_BUILD/" + make -C "$TOOL_BUILD" PIN_ROOT="$KIT_DIR" >/tmp/pin-tool-build.log 2>&1 || { + tail -n 20 /tmp/pin-tool-build.log >&2; echo "setup-pin: tool build failed" >&2; exit 1 + } +fi + +[ -x "$PIN_BIN" ] || { echo "setup-pin: pin launcher missing at $PIN_BIN" >&2; exit 1; } +[ -f "$PIN_TOOL" ] || { echo "setup-pin: tool missing at $PIN_TOOL" >&2; exit 1; } +"$PIN_BIN" -version >/dev/null 2>&1 || { echo "setup-pin: pin launcher does not run" >&2; exit 1; } + +echo "PIN_BIN=$PIN_BIN" +echo "PIN_TOOL=$PIN_TOOL" +if [ -n "${GITHUB_ENV:-}" ]; then + echo "PIN_BIN=$PIN_BIN" >>"$GITHUB_ENV" + echo "PIN_TOOL=$PIN_TOOL" >>"$GITHUB_ENV" +fi From d9e827c741b90664cfded5f763b0b1d34bf35066 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:52:42 +0100 Subject: [PATCH 11/20] h2h-cpu-mem: pin-only instrumentation legs, pin -> instrumentation - Instrumentation legs measure once, under Pin only (--repeat 0); the first Pin run still gates output correctness, so the timed rep is gone with no signal lost. - Rename CI surface pin -> instrumentation: job ids, artifacts, setup-instrumentation.sh (INSTRUMENTATION_BIN/TOOL/DIR), scripts/ci/instrumentation/. Harness --pin flags and the pin_instructions signal keep their precise names. - Bump suite pins to the --repeat 0 heads (snip 4fe8d86, algos c630c0b, post affine-fix rebase). --- .github/workflows/h2h-cpu-mem.yml | 130 +++++++++--------- .../{pintool => instrumentation}/inscount.cpp | 0 .../ci/{pintool => instrumentation}/makefile | 0 .../makefile.rules | 0 scripts/ci/setup-instrumentation.sh | 66 +++++++++ scripts/ci/setup-pin.sh | 63 --------- 6 files changed, 133 insertions(+), 126 deletions(-) rename scripts/ci/{pintool => instrumentation}/inscount.cpp (100%) rename scripts/ci/{pintool => instrumentation}/makefile (100%) rename scripts/ci/{pintool => instrumentation}/makefile.rules (100%) create mode 100644 scripts/ci/setup-instrumentation.sh delete mode 100644 scripts/ci/setup-pin.sh diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index ef6b090938..ded7371f97 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -2,14 +2,16 @@ # and vs each other, across the snippet suite (hhh-pa/clojure-code-snippet) # and the algorithms suite (hhh-pa/clojure-algorithms). Each suite # runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), sharded -# 8 ways (48 measure jobs), plus native-only Pin inscount legs for -# the rs pair, sharded 16 ways (64 jobs); the h2h-compare job +# 8 ways (48 measure jobs), plus native-only instrumentation legs +# for the rs pair, sharded 16 ways (64 jobs); the h2h-compare job # unions the shards per leg and gates on per-case median cpu_s + # maxrss_kb. The gate rule: clojure-rs must beat JVM Clojure, AND # rs-from must win-or-tie vs rs-to on every case — ANY loss fails -# the PR. The Pin legs add a deterministic instruction-count gate -# (1% margin) for rs-from vs rs-to, immune to the wall-time wobble -# that reds the cpu/rsrs leg on no-op changes. +# the PR. The instrumentation legs measure each case once, under +# Intel Pin only (--repeat 0), and add a deterministic +# instruction-count gate (1% margin) for rs-from vs rs-to, immune +# to the wall-time wobble that reds the cpu/rsrs leg on no-op +# changes. # # Revision pairs ("PR from head to base" parlance): from is the # change under test, to is its baseline. On pull_request, from = @@ -79,10 +81,10 @@ permissions: env: # Pinned suite revisions: the pin-inscount branches (snippet PR#52, # algos PR#7) until they merge, then back to master SHAs. The --pin - # legs below need harness support, so old-SHA bisection of the pin - # legs fails loud on the unknown flag by design. - SNIPPETS_SHA_DEFAULT: 87f74811daa0a42d6c750914179bc6bbae3bbf2a - ALGORITHMS_SHA_DEFAULT: cb630ab7f35724c6173ee881a352fb283dd2b89f + # legs below need harness support, so old-SHA bisection of the + # instrumentation legs fails loud on the unknown flag by design. + SNIPPETS_SHA_DEFAULT: 4fe8d865d275e1e606e459c87e64563845de8d78 + ALGORITHMS_SHA_DEFAULT: c630c0bd223ab9042f474b44383db939d33609bb # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. JAVA_VERSION: '21.0.13' @@ -410,28 +412,30 @@ jobs: path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 - # Pin legs: deterministic instruction counts for the native pair - # (rs-from, rs-to), 16 shards each (64 jobs). One timed rep per - # case (correctness + wall baseline) plus two Pin reps; the - # ins gate compares pin_instructions medians with a 1% margin. + # Instrumentation legs: deterministic instruction counts for + # the native pair (rs-from, rs-to), 16 shards each (64 jobs). + # Each case is measured once, under Pin only (--repeat 0; the + # first Pin run still gates output correctness); the ins gate + # compares pin_instructions medians with a 1% margin. # Native-only by design: JVM-under-Pin trips Pin 4.4's memory # manager (mmap-overlap exit 127) and costs ~40x wall, while the # wall-time cpu/mem gates already cover JVM-vs-RS at ~10x # margins. The monster recipes cases (bfs t5: 146s native, ~20min - # under Pin) set the 1500s pin timeout and 16-way sharding. - measure-recipes-pin: + # under Pin) set the 1500s instrumentation timeout and 16-way + # sharding. + measure-recipes-instrumentation: needs: [resolve, build-rs] runs-on: ubuntu-latest timeout-minutes: 60 permissions: contents: read # suite + tool checkouts - actions: write # pin cache + shard artifact + actions: write # instrumentation cache + shard artifact strategy: fail-fast: false matrix: shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] runtime: [rs-from, rs-to] - name: recipes-pin ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) + name: recipes-instrumentation ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) steps: - name: Check out snippet suite uses: actions/checkout@v5 @@ -440,7 +444,7 @@ jobs: ref: ${{ needs.resolve.outputs.snippets_sha }} path: snippets - - name: Check out Pin tooling + - name: Check out instrumentation tooling uses: actions/checkout@v5 with: path: h2h @@ -456,16 +460,16 @@ jobs: cd snippets/scripts/ci/go go build -o snip ./cmd/snip - - name: Cache Pin kit - id: pin-cache + - name: Cache instrumentation kit + id: instrumentation-cache uses: actions/cache@v5 with: - path: ~/pin - key: pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-pin.sh', 'h2h/scripts/ci/pintool/inscount.cpp') }} + path: ~/instrumentation + key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - - name: Provision Pin + - name: Provision instrumentation run: | - bash h2h/scripts/ci/setup-pin.sh + bash h2h/scripts/ci/setup-instrumentation.sh - name: Decrypt and extract resources env: @@ -498,43 +502,43 @@ jobs: chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - - name: Measure Pin shard + - name: Measure instrumentation shard working-directory: snippets run: | ./scripts/ci/go/snip measure --resources tests/resources \ - --repeat 1 --no-alloc --no-perf --jobs 3 --timeout 300 \ + --repeat 0 --no-alloc --no-perf --jobs 3 \ --runtime "${{ matrix.runtime }}" \ --command-template 'clojure-rs run {script}' \ - --pin --pin-bin "$PIN_BIN" --pin-tool "$PIN_TOOL" \ + --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ --pin-repeat 2 --pin-timeout 1500 \ --shard ${{ matrix.shard }}/16 \ - --output "pin-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --output "instrumentation-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ --summary-file "$GITHUB_STEP_SUMMARY" - name: Upload shard results if: always() uses: actions/upload-artifact@v6 with: - name: pin-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: snippets/pin-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + name: instrumentation-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: snippets/instrumentation-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 - # Algos Pin legs: same shape as recipes-pin (16 shards x the - # native pair). No decrypt step: this suite carries its - # resources/ in-repo. - measure-algos-pin: + # Algos instrumentation legs: same shape as + # recipes-instrumentation (16 shards x the native pair). No + # decrypt step: this suite carries its resources/ in-repo. + measure-algos-instrumentation: needs: [resolve, build-rs] runs-on: ubuntu-latest timeout-minutes: 90 permissions: contents: read # suite + tool checkouts - actions: write # pin cache + shard artifact + actions: write # instrumentation cache + shard artifact strategy: fail-fast: false matrix: shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] runtime: [rs-from, rs-to] - name: algos-pin ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) + name: algos-instrumentation ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) steps: - name: Check out algorithms suite uses: actions/checkout@v5 @@ -543,7 +547,7 @@ jobs: ref: ${{ needs.resolve.outputs.algorithms_sha }} path: algos - - name: Check out Pin tooling + - name: Check out instrumentation tooling uses: actions/checkout@v5 with: path: h2h @@ -559,16 +563,16 @@ jobs: cd algos/scripts/ci/go go build -o algoperf ./cmd/algoperf - - name: Cache Pin kit - id: pin-cache + - name: Cache instrumentation kit + id: instrumentation-cache uses: actions/cache@v5 with: - path: ~/pin - key: pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-pin.sh', 'h2h/scripts/ci/pintool/inscount.cpp') }} + path: ~/instrumentation + key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - - name: Provision Pin + - name: Provision instrumentation run: | - bash h2h/scripts/ci/setup-pin.sh + bash h2h/scripts/ci/setup-instrumentation.sh - name: Install GNU time run: | @@ -587,37 +591,37 @@ jobs: chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - - name: Measure Pin shard + - name: Measure instrumentation shard working-directory: algos run: | ./scripts/ci/go/algoperf measure \ - --repeat 1 --no-alloc --no-perf --jobs 3 --timeout 1800 \ + --repeat 0 --no-alloc --no-perf --jobs 3 \ --runtime "${{ matrix.runtime }}" \ --command-template 'clojure-rs run perf/driver.clj {ns} src test resources' \ - --pin --pin-bin "$PIN_BIN" --pin-tool "$PIN_TOOL" \ + --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ --pin-repeat 2 --pin-timeout 1200 \ --shard ${{ matrix.shard }}/16 \ - --output "pin-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --output "instrumentation-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ --summary-file "$GITHUB_STEP_SUMMARY" - name: Upload shard results if: always() uses: actions/upload-artifact@v6 with: - name: pin-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: algos/pin-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + name: instrumentation-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: algos/instrumentation-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 # Fan-in: union the shards per leg and diff the legs. Strict # across-the-board gate, both directions: JVM vs rs-from AND - # rs-from vs rs-to on cpu/mem, plus rs-from vs rs-to on Pin - # instruction counts (1% margin) — any loss (or failure, + # rs-from vs rs-to on cpu/mem, plus rs-from vs rs-to on + # instrumented instruction counts (1% margin) — any loss (or failure, # missing, not-ok, drift, leg mismatch) fails the PR. No # Bencher reporting by design: this job gates, it does not # track history. h2h-compare: name: h2h cpu/mem gate - needs: [measure-recipes, measure-algos, measure-recipes-pin, measure-algos-pin] + needs: [measure-recipes, measure-algos, measure-recipes-instrumentation, measure-algos-instrumentation] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -631,10 +635,10 @@ jobs: with: pattern: h2h-*-shard-* - - name: Download Pin shard results + - name: Download instrumentation shard results uses: actions/download-artifact@v6 with: - pattern: pin-*-shard-* + pattern: instrumentation-*-shard-* - name: Set up Go uses: actions/setup-go@v6 @@ -688,20 +692,20 @@ jobs: $compare \ --label recipes-rsrs-ins \ --base-name rs-to --challenger-name rs-from \ - --jvm pin-recipes-rs-to-shard-*/pin-recipes-rs-to-shard-*.json \ - --rs pin-recipes-rs-from-shard-*/pin-recipes-rs-from-shard-*.json \ + --jvm instrumentation-recipes-rs-to-shard-*/instrumentation-recipes-rs-to-shard-*.json \ + --rs instrumentation-recipes-rs-from-shard-*/instrumentation-recipes-rs-from-shard-*.json \ --metrics ins --margin 0.01 \ - --merged-jvm pin-recipes-rs-to-merged.json \ - --merged-rs pin-recipes-rs-from-merged.json \ + --merged-jvm instrumentation-recipes-rs-to-merged.json \ + --merged-rs instrumentation-recipes-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? $compare \ --label algos-rsrs-ins \ --base-name rs-to --challenger-name rs-from \ - --jvm pin-algos-rs-to-shard-*/pin-algos-rs-to-shard-*.json \ - --rs pin-algos-rs-from-shard-*/pin-algos-rs-from-shard-*.json \ + --jvm instrumentation-algos-rs-to-shard-*/instrumentation-algos-rs-to-shard-*.json \ + --rs instrumentation-algos-rs-from-shard-*/instrumentation-algos-rs-from-shard-*.json \ --metrics ins --margin 0.01 \ - --merged-jvm pin-algos-rs-to-merged.json \ - --merged-rs pin-algos-rs-from-merged.json \ + --merged-jvm instrumentation-algos-rs-to-merged.json \ + --merged-rs instrumentation-algos-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? exit $rc @@ -712,5 +716,5 @@ jobs: name: h2h-merged path: | h2h-*-merged.json - pin-*-merged.json + instrumentation-*-merged.json retention-days: 14 diff --git a/scripts/ci/pintool/inscount.cpp b/scripts/ci/instrumentation/inscount.cpp similarity index 100% rename from scripts/ci/pintool/inscount.cpp rename to scripts/ci/instrumentation/inscount.cpp diff --git a/scripts/ci/pintool/makefile b/scripts/ci/instrumentation/makefile similarity index 100% rename from scripts/ci/pintool/makefile rename to scripts/ci/instrumentation/makefile diff --git a/scripts/ci/pintool/makefile.rules b/scripts/ci/instrumentation/makefile.rules similarity index 100% rename from scripts/ci/pintool/makefile.rules rename to scripts/ci/instrumentation/makefile.rules diff --git a/scripts/ci/setup-instrumentation.sh b/scripts/ci/setup-instrumentation.sh new file mode 100644 index 0000000000..b9abcb3a11 --- /dev/null +++ b/scripts/ci/setup-instrumentation.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Provision the instrumentation backend (Intel Pin + the vendored +# inscount tool) for the h2h gate. +# +# Idempotent: when INSTRUMENTATION_DIR already holds a kit and a +# built tool (the workflow caches it across runs), it just +# re-exports the paths. Otherwise it downloads the kit from +# Intel, verifies its sha256, extracts it, and builds +# scripts/ci/instrumentation/inscount.cpp against it. +# +# Outputs (stdout for logs, $GITHUB_ENV when present): +# INSTRUMENTATION_BIN absolute path to the pin launcher +# INSTRUMENTATION_TOOL absolute path to obj-intel64/inscount.so +set -euo pipefail + +PIN_URL="${PIN_URL:-https://software.intel.com/sites/landingpage/pintool/downloads/pin-external-4.4-99977-g54fbb8814-gcc-linux.tar.gz}" +PIN_SHA256="${PIN_SHA256:-3138183a5d2b51ea5c8f29edd148b67ac9c249e2c510cc0e9e2a5880f523a34c}" +PIN_TOPDIR="pin-external-4.4-99977-g54fbb8814-gcc-linux" +INSTRUMENTATION_DIR="${INSTRUMENTATION_DIR:-$HOME/instrumentation}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOL_SRC="$SCRIPT_DIR/instrumentation" + +KIT_DIR="$INSTRUMENTATION_DIR/$PIN_TOPDIR" +INSTRUMENTATION_BIN="$KIT_DIR/pin" +TOOL_BUILD="$INSTRUMENTATION_DIR/toolbuild" +INSTRUMENTATION_TOOL="$TOOL_BUILD/obj-intel64/inscount.so" + +need_tool() { + [ ! -x "$INSTRUMENTATION_BIN" ] || [ ! -f "$INSTRUMENTATION_TOOL" ] +} + +err() { echo "setup-instrumentation: $*" >&2; } + +if need_tool; then + command -v g++ >/dev/null || { err "g++ is required to build the inscount tool"; exit 1; } + command -v curl >/dev/null || { err "curl is required to fetch the Pin kit"; exit 1; } + [ -f "$TOOL_SRC/inscount.cpp" ] || { err "tool sources missing at $TOOL_SRC"; exit 1; } + mkdir -p "$INSTRUMENTATION_DIR" + if [ ! -x "$INSTRUMENTATION_BIN" ]; then + err "downloading Pin 4.4 kit..." + curl -sSfL --retry 3 --max-time 600 -o "$INSTRUMENTATION_DIR/pin-kit.tar.gz" "$PIN_URL" + echo "$PIN_SHA256 $INSTRUMENTATION_DIR/pin-kit.tar.gz" | sha256sum -c - || { + err "kit checksum mismatch"; exit 1 + } + tar xzf "$INSTRUMENTATION_DIR/pin-kit.tar.gz" -C "$INSTRUMENTATION_DIR" + rm -f "$INSTRUMENTATION_DIR/pin-kit.tar.gz" + fi + err "building inscount tool..." + rm -rf "$TOOL_BUILD" + mkdir -p "$TOOL_BUILD" + cp "$TOOL_SRC/inscount.cpp" "$TOOL_SRC/makefile" "$TOOL_SRC/makefile.rules" "$TOOL_BUILD/" + make -C "$TOOL_BUILD" PIN_ROOT="$KIT_DIR" >/tmp/instrumentation-tool-build.log 2>&1 || { + tail -n 20 /tmp/instrumentation-tool-build.log >&2; err "tool build failed"; exit 1 + } +fi + +[ -x "$INSTRUMENTATION_BIN" ] || { err "pin launcher missing at $INSTRUMENTATION_BIN"; exit 1; } +[ -f "$INSTRUMENTATION_TOOL" ] || { err "tool missing at $INSTRUMENTATION_TOOL"; exit 1; } +"$INSTRUMENTATION_BIN" -version >/dev/null 2>&1 || { err "pin launcher does not run"; exit 1; } + +echo "INSTRUMENTATION_BIN=$INSTRUMENTATION_BIN" +echo "INSTRUMENTATION_TOOL=$INSTRUMENTATION_TOOL" +if [ -n "${GITHUB_ENV:-}" ]; then + echo "INSTRUMENTATION_BIN=$INSTRUMENTATION_BIN" >>"$GITHUB_ENV" + echo "INSTRUMENTATION_TOOL=$INSTRUMENTATION_TOOL" >>"$GITHUB_ENV" +fi diff --git a/scripts/ci/setup-pin.sh b/scripts/ci/setup-pin.sh deleted file mode 100644 index 4ebf7e7012..0000000000 --- a/scripts/ci/setup-pin.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# Provision Intel Pin + the vendored inscount tool for the h2h gate. -# -# Idempotent: when PIN_DIR already holds a kit and a built tool -# (the workflow caches PIN_DIR across runs), it just re-exports -# the paths. Otherwise it downloads the kit from Intel, verifies -# its sha256, extracts it, and builds -# scripts/ci/pintool/inscount.cpp against it. -# -# Outputs (stdout for logs, $GITHUB_ENV when present): -# PIN_BIN absolute path to the pin launcher -# PIN_TOOL absolute path to obj-intel64/inscount.so -set -euo pipefail - -PIN_URL="${PIN_URL:-https://software.intel.com/sites/landingpage/pintool/downloads/pin-external-4.4-99977-g54fbb8814-gcc-linux.tar.gz}" -PIN_SHA256="${PIN_SHA256:-3138183a5d2b51ea5c8f29edd148b67ac9c249e2c510cc0e9e2a5880f523a34c}" -PIN_TOPDIR="pin-external-4.4-99977-g54fbb8814-gcc-linux" -PIN_DIR="${PIN_DIR:-$HOME/pin}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TOOL_SRC="$SCRIPT_DIR/pintool" - -KIT_DIR="$PIN_DIR/$PIN_TOPDIR" -PIN_BIN="$KIT_DIR/pin" -TOOL_BUILD="$PIN_DIR/toolbuild" -PIN_TOOL="$TOOL_BUILD/obj-intel64/inscount.so" - -need_tool() { - [ ! -x "$PIN_BIN" ] || [ ! -f "$PIN_TOOL" ] -} - -if need_tool; then - command -v g++ >/dev/null || { echo "setup-pin: g++ is required to build the inscount tool" >&2; exit 1; } - command -v curl >/dev/null || { echo "setup-pin: curl is required to fetch the Pin kit" >&2; exit 1; } - [ -f "$TOOL_SRC/inscount.cpp" ] || { echo "setup-pin: tool sources missing at $TOOL_SRC" >&2; exit 1; } - mkdir -p "$PIN_DIR" - if [ ! -x "$PIN_BIN" ]; then - echo "setup-pin: downloading Pin 4.4 kit..." >&2 - curl -sSfL --retry 3 --max-time 600 -o "$PIN_DIR/pin-kit.tar.gz" "$PIN_URL" - echo "$PIN_SHA256 $PIN_DIR/pin-kit.tar.gz" | sha256sum -c - || { - echo "setup-pin: kit checksum mismatch" >&2; exit 1 - } - tar xzf "$PIN_DIR/pin-kit.tar.gz" -C "$PIN_DIR" - rm -f "$PIN_DIR/pin-kit.tar.gz" - fi - echo "setup-pin: building inscount tool..." >&2 - rm -rf "$TOOL_BUILD" - mkdir -p "$TOOL_BUILD" - cp "$TOOL_SRC/inscount.cpp" "$TOOL_SRC/makefile" "$TOOL_SRC/makefile.rules" "$TOOL_BUILD/" - make -C "$TOOL_BUILD" PIN_ROOT="$KIT_DIR" >/tmp/pin-tool-build.log 2>&1 || { - tail -n 20 /tmp/pin-tool-build.log >&2; echo "setup-pin: tool build failed" >&2; exit 1 - } -fi - -[ -x "$PIN_BIN" ] || { echo "setup-pin: pin launcher missing at $PIN_BIN" >&2; exit 1; } -[ -f "$PIN_TOOL" ] || { echo "setup-pin: tool missing at $PIN_TOOL" >&2; exit 1; } -"$PIN_BIN" -version >/dev/null 2>&1 || { echo "setup-pin: pin launcher does not run" >&2; exit 1; } - -echo "PIN_BIN=$PIN_BIN" -echo "PIN_TOOL=$PIN_TOOL" -if [ -n "${GITHUB_ENV:-}" ]; then - echo "PIN_BIN=$PIN_BIN" >>"$GITHUB_ENV" - echo "PIN_TOOL=$PIN_TOOL" >>"$GITHUB_ENV" -fi From abe0c4644e04e8b72f571949b3ef5c277502da52 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:22:43 +0100 Subject: [PATCH 12/20] h2h-cpu-mem: fold instrumentation into the timed legs + alloc signal - One sweep, not two: the native RS legs count instructions and allocated bytes under Pin inside the existing 48 measure jobs; the standalone instrumentation jobs are gone. - inscount tool also reports cumulative malloc-family bytes (Alloc line); harnesses record pin_allocated_bytes. - h2h-compare gains the alloc metric; rsrs-ins comparisons gate ins,alloc at 1% on the same h2h-* legs. - Bump suite pins to the alloc heads (snip acef0694, algos 1fd2e72). --- .github/workflows/h2h-cpu-mem.yml | 311 ++----- scripts/ci/go/cmd/h2h-compare/compare.go | 9 +- scripts/ci/go/cmd/h2h-compare/main.go | 3 +- scripts/ci/go/cmd/h2h-compare/main_test.go | 25 + .../h2h-compare/testdata/base-shard-0.json | 238 +++++- .../h2h-compare/testdata/base-shard-1.json | 244 +++++- .../ci/go/cmd/h2h-compare/testdata/chal.json | 426 +++++++++- .../cmd/h2h-compare/testdata/golden-alloc.txt | 28 + .../testdata/golden-merged-base.json | 792 ++++++++++-------- .../testdata/golden-merged-chal.json | 737 ++++++++-------- .../testdata/golden-summary-alloc.md | 20 + scripts/ci/instrumentation/inscount.cpp | 71 +- 12 files changed, 1907 insertions(+), 997 deletions(-) create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt create mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index ded7371f97..bd16fb4d61 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -2,16 +2,15 @@ # and vs each other, across the snippet suite (hhh-pa/clojure-code-snippet) # and the algorithms suite (hhh-pa/clojure-algorithms). Each suite # runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), sharded -# 8 ways (48 measure jobs), plus native-only instrumentation legs -# for the rs pair, sharded 16 ways (64 jobs); the h2h-compare job -# unions the shards per leg and gates on per-case median cpu_s + +# 8 ways (48 measure jobs, one sweep); the h2h-compare job unions +# the shards per leg and gates on per-case median cpu_s + # maxrss_kb. The gate rule: clojure-rs must beat JVM Clojure, AND # rs-from must win-or-tie vs rs-to on every case — ANY loss fails -# the PR. The instrumentation legs measure each case once, under -# Intel Pin only (--repeat 0), and add a deterministic -# instruction-count gate (1% margin) for rs-from vs rs-to, immune -# to the wall-time wobble that reds the cpu/rsrs leg on no-op -# changes. +# the PR. The native legs additionally count instructions and +# allocated bytes under Intel Pin in the same sweep (no second job +# set), feeding a deterministic instruction/allocation gate (1% +# margin) for rs-from vs rs-to, immune to the wall-time wobble +# that reds the cpu/rsrs leg on no-op changes. # # Revision pairs ("PR from head to base" parlance): from is the # change under test, to is its baseline. On pull_request, from = @@ -83,8 +82,8 @@ env: # algos PR#7) until they merge, then back to master SHAs. The --pin # legs below need harness support, so old-SHA bisection of the # instrumentation legs fails loud on the unknown flag by design. - SNIPPETS_SHA_DEFAULT: 4fe8d865d275e1e606e459c87e64563845de8d78 - ALGORITHMS_SHA_DEFAULT: c630c0bd223ab9042f474b44383db939d33609bb + SNIPPETS_SHA_DEFAULT: acef0694e892c467f574158ffb94c73ff1d916fb + ALGORITHMS_SHA_DEFAULT: 1fd2e7205e670b9ed1c0d2272764c872618f07e1 # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. JAVA_VERSION: '21.0.13' @@ -92,7 +91,7 @@ env: jobs: # Resolve the suite revisions AND the native-vs-native revision - # pair ONCE per run so all 112 measure legs sweep the same commits. + # pair ONCE per run so all 48 measure legs sweep the same commits. # Suite defaults: the pinned SHAs above; a manual dispatch may pin # either input instead (bisection). Native pair: PR head/base by # default on pull_request (stacked PRs diff against their parent), @@ -196,10 +195,10 @@ jobs: measure-recipes: needs: [resolve, build-rs] runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 permissions: - contents: read # suite checkout - actions: write # jars cache + shard artifact + contents: read # suite + instrumentation tooling checkouts + actions: write # jars + instrumentation caches + shard artifact strategy: # Let every shard finish so partial artifacts upload for diagnosis. fail-fast: false @@ -284,12 +283,35 @@ jobs: chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + - name: Check out instrumentation tooling + if: matrix.runtime != 'jvm' + uses: actions/checkout@v5 + with: + path: h2h + + - name: Cache instrumentation kit + if: matrix.runtime != 'jvm' + id: instrumentation-cache + uses: actions/cache@v5 + with: + path: ~/instrumentation + key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} + + - name: Provision instrumentation + if: matrix.runtime != 'jvm' + run: | + bash h2h/scripts/ci/setup-instrumentation.sh + - name: Measure head-to-head shard working-directory: snippets run: | flags=(--runtime "${{ matrix.runtime }}") if [ "${{ matrix.runtime }}" != "jvm" ]; then flags+=(--command-template 'clojure-rs run {script}') + # Native legs also count instructions + allocated bytes + # under Pin (same sweep, no second job set). + flags+=(--pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL") + flags+=(--pin-repeat 2 --pin-timeout 1500) fi ./scripts/ci/go/snip measure --resources tests/resources \ --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 300 \ @@ -315,8 +337,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 permissions: - contents: read # suite checkout - actions: write # jars cache + shard artifact + contents: read # suite + instrumentation tooling checkouts + actions: write # jars + instrumentation caches + shard artifact strategy: # Let every shard finish so partial artifacts upload for diagnosis. fail-fast: false @@ -387,183 +409,14 @@ jobs: chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - - name: Measure head-to-head shard - working-directory: algos - run: | - flags=(--runtime "${{ matrix.runtime }}") - if [ "${{ matrix.runtime }}" = "jvm" ]; then - jars=".ci/jars/clojure.jar:.ci/jars/spec.alpha.jar:.ci/jars/core.specs.alpha.jar" - flags+=(--cp "$jars:src:test:resources") - else - flags+=(--command-template 'clojure-rs run perf/driver.clj {ns} src test resources') - fi - ./scripts/ci/go/algoperf measure \ - --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ - "${flags[@]}" \ - --shard ${{ matrix.shard }}/8 \ - --output "h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ - --summary-file "$GITHUB_STEP_SUMMARY" - - - name: Upload shard results - if: always() - uses: actions/upload-artifact@v6 - with: - name: h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json - retention-days: 7 - - # Instrumentation legs: deterministic instruction counts for - # the native pair (rs-from, rs-to), 16 shards each (64 jobs). - # Each case is measured once, under Pin only (--repeat 0; the - # first Pin run still gates output correctness); the ins gate - # compares pin_instructions medians with a 1% margin. - # Native-only by design: JVM-under-Pin trips Pin 4.4's memory - # manager (mmap-overlap exit 127) and costs ~40x wall, while the - # wall-time cpu/mem gates already cover JVM-vs-RS at ~10x - # margins. The monster recipes cases (bfs t5: 146s native, ~20min - # under Pin) set the 1500s instrumentation timeout and 16-way - # sharding. - measure-recipes-instrumentation: - needs: [resolve, build-rs] - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: read # suite + tool checkouts - actions: write # instrumentation cache + shard artifact - strategy: - fail-fast: false - matrix: - shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] - runtime: [rs-from, rs-to] - name: recipes-instrumentation ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) - steps: - - name: Check out snippet suite - uses: actions/checkout@v5 - with: - repository: hhh-pa/clojure-code-snippet - ref: ${{ needs.resolve.outputs.snippets_sha }} - path: snippets - - - name: Check out instrumentation tooling - uses: actions/checkout@v5 - with: - path: h2h - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: snippets/scripts/ci/go/go.mod - cache: false # stdlib-only module: nothing to cache but the build below - - - name: Build snip - run: | - cd snippets/scripts/ci/go - go build -o snip ./cmd/snip - - - name: Cache instrumentation kit - id: instrumentation-cache - uses: actions/cache@v5 - with: - path: ~/instrumentation - key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - - - name: Provision instrumentation - run: | - bash h2h/scripts/ci/setup-instrumentation.sh - - - name: Decrypt and extract resources - env: - RESOURCES_PASSPHRASE: ${{ secrets.RESOURCES_PASSPHRASE }} - working-directory: snippets - run: | - if [ -z "$RESOURCES_PASSPHRASE" ]; then - echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 - exit 1 - fi - mkdir -p tests/resources - gpg --batch --yes --pinentry-mode loopback \ - --passphrase "$RESOURCES_PASSPHRASE" \ - -d tests/resources.tar.xz.asc | tar -xJ -C tests/resources --strip-components=1 - - - name: Install GNU time - run: | - sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - - - name: Use built clojure-rs - uses: actions/download-artifact@v6 - with: - name: cljrs-${{ matrix.runtime }} - path: cljrs-bin - - - name: Add clojure-rs to PATH - run: | - # download-artifact does not preserve the executable bit. - chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" - echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - - - name: Measure instrumentation shard - working-directory: snippets - run: | - ./scripts/ci/go/snip measure --resources tests/resources \ - --repeat 0 --no-alloc --no-perf --jobs 3 \ - --runtime "${{ matrix.runtime }}" \ - --command-template 'clojure-rs run {script}' \ - --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ - --pin-repeat 2 --pin-timeout 1500 \ - --shard ${{ matrix.shard }}/16 \ - --output "instrumentation-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ - --summary-file "$GITHUB_STEP_SUMMARY" - - - name: Upload shard results - if: always() - uses: actions/upload-artifact@v6 - with: - name: instrumentation-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: snippets/instrumentation-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json - retention-days: 7 - - # Algos instrumentation legs: same shape as - # recipes-instrumentation (16 shards x the native pair). No - # decrypt step: this suite carries its resources/ in-repo. - measure-algos-instrumentation: - needs: [resolve, build-rs] - runs-on: ubuntu-latest - timeout-minutes: 90 - permissions: - contents: read # suite + tool checkouts - actions: write # instrumentation cache + shard artifact - strategy: - fail-fast: false - matrix: - shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] - runtime: [rs-from, rs-to] - name: algos-instrumentation ${{ matrix.runtime }} (shard ${{ matrix.shard }}/16) - steps: - - name: Check out algorithms suite - uses: actions/checkout@v5 - with: - repository: hhh-pa/clojure-algorithms - ref: ${{ needs.resolve.outputs.algorithms_sha }} - path: algos - - name: Check out instrumentation tooling + if: matrix.runtime != 'jvm' uses: actions/checkout@v5 with: path: h2h - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: algos/scripts/ci/go/go.mod - cache: false # stdlib-only module: nothing to cache but the build below - - - name: Build algoperf - run: | - cd algos/scripts/ci/go - go build -o algoperf ./cmd/algoperf - - name: Cache instrumentation kit + if: matrix.runtime != 'jvm' id: instrumentation-cache uses: actions/cache@v5 with: @@ -571,57 +424,52 @@ jobs: key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - name: Provision instrumentation + if: matrix.runtime != 'jvm' run: | bash h2h/scripts/ci/setup-instrumentation.sh - - name: Install GNU time - run: | - sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - - - name: Use built clojure-rs - uses: actions/download-artifact@v6 - with: - name: cljrs-${{ matrix.runtime }} - path: cljrs-bin - - - name: Add clojure-rs to PATH - run: | - # download-artifact does not preserve the executable bit. - chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" - echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - - - name: Measure instrumentation shard + - name: Measure head-to-head shard working-directory: algos run: | + flags=(--runtime "${{ matrix.runtime }}") + if [ "${{ matrix.runtime }}" = "jvm" ]; then + jars=".ci/jars/clojure.jar:.ci/jars/spec.alpha.jar:.ci/jars/core.specs.alpha.jar" + flags+=(--cp "$jars:src:test:resources") + else + flags+=(--command-template 'clojure-rs run perf/driver.clj {ns} src test resources') + # Native legs also count instructions + allocated bytes + # under Pin (same sweep, no second job set). + flags+=(--pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL") + flags+=(--pin-repeat 2 --pin-timeout 1200) + fi ./scripts/ci/go/algoperf measure \ - --repeat 0 --no-alloc --no-perf --jobs 3 \ - --runtime "${{ matrix.runtime }}" \ - --command-template 'clojure-rs run perf/driver.clj {ns} src test resources' \ - --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ - --pin-repeat 2 --pin-timeout 1200 \ - --shard ${{ matrix.shard }}/16 \ - --output "instrumentation-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ + "${flags[@]}" \ + --shard ${{ matrix.shard }}/8 \ + --output "h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ --summary-file "$GITHUB_STEP_SUMMARY" - name: Upload shard results if: always() uses: actions/upload-artifact@v6 with: - name: instrumentation-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: algos/instrumentation-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + name: h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 # Fan-in: union the shards per leg and diff the legs. Strict # across-the-board gate, both directions: JVM vs rs-from AND # rs-from vs rs-to on cpu/mem, plus rs-from vs rs-to on - # instrumented instruction counts (1% margin) — any loss (or failure, - # missing, not-ok, drift, leg mismatch) fails the PR. No - # Bencher reporting by design: this job gates, it does not - # track history. + # instrumented instruction counts and allocated bytes (1% + # margin) — any loss (or failure, missing, not-ok, drift, leg + # mismatch) fails the PR. The native legs carry the instrument + # signals in the same shard JSONs (no second sweep), so the ins + # comparisons below reuse the h2h-* legs and write no extra + # merged files. No Bencher reporting by design: this job gates, + # it does not track history. h2h-compare: name: h2h cpu/mem gate - needs: [measure-recipes, measure-algos, measure-recipes-instrumentation, measure-algos-instrumentation] + needs: [measure-recipes, measure-algos] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -635,11 +483,6 @@ jobs: with: pattern: h2h-*-shard-* - - name: Download instrumentation shard results - uses: actions/download-artifact@v6 - with: - pattern: instrumentation-*-shard-* - - name: Set up Go uses: actions/setup-go@v6 with: @@ -692,20 +535,16 @@ jobs: $compare \ --label recipes-rsrs-ins \ --base-name rs-to --challenger-name rs-from \ - --jvm instrumentation-recipes-rs-to-shard-*/instrumentation-recipes-rs-to-shard-*.json \ - --rs instrumentation-recipes-rs-from-shard-*/instrumentation-recipes-rs-from-shard-*.json \ - --metrics ins --margin 0.01 \ - --merged-jvm instrumentation-recipes-rs-to-merged.json \ - --merged-rs instrumentation-recipes-rs-from-merged.json \ + --jvm h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ + --rs h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ + --metrics ins,alloc --margin 0.01 \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? $compare \ --label algos-rsrs-ins \ --base-name rs-to --challenger-name rs-from \ - --jvm instrumentation-algos-rs-to-shard-*/instrumentation-algos-rs-to-shard-*.json \ - --rs instrumentation-algos-rs-from-shard-*/instrumentation-algos-rs-from-shard-*.json \ - --metrics ins --margin 0.01 \ - --merged-jvm instrumentation-algos-rs-to-merged.json \ - --merged-rs instrumentation-algos-rs-from-merged.json \ + --jvm h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ + --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ + --metrics ins,alloc --margin 0.01 \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? exit $rc @@ -714,7 +553,5 @@ jobs: uses: actions/upload-artifact@v6 with: name: h2h-merged - path: | - h2h-*-merged.json - instrumentation-*-merged.json + path: h2h-*-merged.json retention-days: 14 diff --git a/scripts/ci/go/cmd/h2h-compare/compare.go b/scripts/ci/go/cmd/h2h-compare/compare.go index 740e27ace7..c2671d1a8d 100644 --- a/scripts/ci/go/cmd/h2h-compare/compare.go +++ b/scripts/ci/go/cmd/h2h-compare/compare.go @@ -34,9 +34,10 @@ type metric struct { const pinKey = "pin_instructions" var metrics = map[string]metric{ - "cpu": {key: "cpu_s", tag: "CPU", unit: "s", summary: "median cpu_s", format: fmt3}, - "mem": {key: "maxrss_kb", tag: "MEM", unit: "K", summary: "median maxrss_kb", format: fmt0}, - "ins": {key: "instructions", tag: "INS", unit: "", summary: "median instructions", format: fmtComma}, + "cpu": {key: "cpu_s", tag: "CPU", unit: "s", summary: "median cpu_s", format: fmt3}, + "mem": {key: "maxrss_kb", tag: "MEM", unit: "K", summary: "median maxrss_kb", format: fmt0}, + "ins": {key: "instructions", tag: "INS", unit: "", summary: "median instructions", format: fmtComma}, + "alloc": {key: "pin_allocated_bytes", tag: "ALLOC", unit: "B", summary: "median allocated bytes", format: fmtComma}, } // fmt3 renders a ratio like Python's f"{x:.3f}" (including inf). @@ -619,7 +620,7 @@ func compareLegs(o options, jm, rm map[caseKey]entry, jFail, rFail, jMiss, rMiss } for _, m := range selected { if _, ok := metrics[m]; !ok { - fmt.Fprintf(os.Stderr, "h2h_compare: unknown metric '%s' (want csv of cpu,mem,ins)\n", m) + fmt.Fprintf(os.Stderr, "h2h_compare: unknown metric '%s' (want csv of cpu,mem,ins,alloc)\n", m) return 2 } } diff --git a/scripts/ci/go/cmd/h2h-compare/main.go b/scripts/ci/go/cmd/h2h-compare/main.go index 275e713bb3..42daf98a3c 100644 --- a/scripts/ci/go/cmd/h2h-compare/main.go +++ b/scripts/ci/go/cmd/h2h-compare/main.go @@ -1,5 +1,6 @@ // Command h2h-compare is the head-to-head gate: reference leg vs -// challenger leg on cpu_s + maxrss_kb (+ pin_instructions). +// challenger leg on cpu_s + maxrss_kb (+ pin_instructions and +// pin_allocated_bytes via --metrics). // // Usage: // diff --git a/scripts/ci/go/cmd/h2h-compare/main_test.go b/scripts/ci/go/cmd/h2h-compare/main_test.go index 91a1844ee7..12b0a562f5 100644 --- a/scripts/ci/go/cmd/h2h-compare/main_test.go +++ b/scripts/ci/go/cmd/h2h-compare/main_test.go @@ -85,6 +85,31 @@ func TestGoldenIns(t *testing.T) { } } +func TestGoldenAlloc(t *testing.T) { + bin := cmdtest.Build(t, ".") + work := t.TempDir() + summary := filepath.Join(work, "summary.md") + stdout, code := cmdtest.Run(t, bin, ".", + "--label", "recipes-alloc", + "--jvm", "testdata/base-shard-0.json", "testdata/base-shard-1.json", + "--rs", "testdata/chal.json", + "--metrics", "alloc", "--margin", "0.01", + "--summary-file", summary) + if code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + if stdout != cmdtest.Golden(t, "testdata/golden-alloc.txt") { + t.Fatalf("stdout differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-alloc.txt"), stdout)) + } + gotSummary, err := os.ReadFile(summary) + if err != nil { + t.Fatal(err) + } + if string(gotSummary) != cmdtest.Golden(t, "testdata/golden-summary-alloc.md") { + t.Fatalf("summary differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-summary-alloc.md"), string(gotSummary))) + } +} + func TestGoldenReportOnly(t *testing.T) { bin := cmdtest.Build(t, ".") stdout, code := cmdtest.Run(t, bin, ".", diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json index cc3725fdee..283b77f331 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json @@ -1,8 +1,230 @@ -{"failures": ["snap-fail-b"], "missing": ["m-base-1"], "config": {"shard": "0/2", "repeat": 3}, "results": [ -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_1", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_2", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 1.4, "median": 1.5, "max": 1.6}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 2990000, "median": 3000000, "max": 3010000}, "instructions": {"min": 2990000, "median": 3000000, "max": 3010000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_3", "ok": true, "error": null, "runs": [{"wall_s": 0.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.0, "median": 0.0, "max": 0.0}, "maxrss_kb": {"min": 49000, "median": 50000, "max": 51000}, "pin_instructions": {"min": 100, "median": 100, "max": 100}, "instructions": {"min": 100, "median": 100, "max": 100}}}, -{"label": "zzz/skipped_thing", "stem": "solution", "script": "src/zzz/skipped_thing/solution.clj", "case": "test_case_9", "ok": true, "error": null, "runs": [], "alloc_runs": [], "summary": {}}, -{"label": "aaa/only_here_base", "stem": "solution", "script": "src/aaa/only_here_base/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990, "median": 1000, "max": 1010}, "instructions": {"min": 990, "median": 1000, "max": 1010}}} -]} +{ + "failures": [ + "snap-fail-b" + ], + "missing": [ + "m-base-1" + ], + "config": { + "shard": "0/2", + "repeat": 3 + }, + "results": [ + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_1", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_2", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.4, + "median": 1.5, + "max": 1.6 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "pin_allocated_bytes": { + "min": 297000, + "median": 300000, + "max": 303000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_3", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.0, + "median": 0.0, + "max": 0.0 + }, + "maxrss_kb": { + "min": 49000, + "median": 50000, + "max": 51000 + }, + "pin_instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "zzz/skipped_thing", + "stem": "solution", + "script": "src/zzz/skipped_thing/solution.clj", + "case": "test_case_9", + "ok": true, + "error": null, + "runs": [], + "alloc_runs": [], + "summary": {} + }, + { + "label": "aaa/only_here_base", + "stem": "solution", + "script": "src/aaa/only_here_base/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990, + "median": 1000, + "max": 1010 + }, + "instructions": { + "min": 990, + "median": 1000, + "max": 1010 + }, + "pin_allocated_bytes": { + "min": 990, + "median": 1000, + "max": 1010 + } + } + } + ] +} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json index cfa2b18ad5..7a65e0de6d 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json @@ -1,8 +1,236 @@ -{"failures": ["aardvark-fail-b"], "missing": [], "config": {"shard": "1/2", "repeat": 3}, "results": [ -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_4", "ok": true, "error": null, "runs": [{"wall_s": 2.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.5, "median": 2.0, "max": 2.5}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 490000, "median": 500000, "max": 510000}, "instructions": {"min": 490000, "median": 500000, "max": 510000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_5", "ok": true, "error": null, "runs": [{"wall_s": 2.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 1.9, "median": 2.0, "max": 2.1}, "maxrss_kb": {"min": 79000, "median": 80000, "max": 81000}, "pin_instructions": {"min": 99000, "median": 100000, "max": 101000}, "instructions": {"min": 99000, "median": 100000, "max": 101000}}}, -{"label": "zzz/skipped_thing", "stem": "solution", "script": "src/zzz/skipped_thing/solution.clj", "case": "test_case_9", "ok": true, "error": null, "runs": [], "alloc_runs": [], "summary": {}}, -{"label": "sort/broken_sort", "stem": "solution", "script": "src/sort/broken_sort/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, -{"label": "my.ns/foo-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 3.0}], "alloc_runs": [], "test_counts": {"pass": 10, "test": 10, "fail": 0, "error": 0}, "summary": {"cpu_s": {"min": 2.9, "median": 3.0, "max": 3.1}, "maxrss_kb": {"min": 190000, "median": 200000, "max": 210000}, "pin_instructions": {"min": 1990000, "median": 2000000, "max": 2010000}, "instructions": {"min": 1990000, "median": 2000000, "max": 2010000}}}, -{"label": "clojure-algorithms.graphs.karger-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 4.0}], "alloc_runs": [], "test_counts": {"pass": 100, "test": 50, "fail": 0, "error": 0}, "summary": {"cpu_s": {"min": 3.9, "median": 4.0, "max": 4.1}, "maxrss_kb": {"min": 290000, "median": 300000, "max": 310000}, "pin_instructions": {"min": 3990000, "median": 4000000, "max": 4010000}, "instructions": {"min": 3990000, "median": 4000000, "max": 4010000}}} -]} +{ + "failures": [ + "aardvark-fail-b" + ], + "missing": [], + "config": { + "shard": "1/2", + "repeat": 3 + }, + "results": [ + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_4", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.5, + "median": 2.0, + "max": 2.5 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 490000, + "median": 500000, + "max": 510000 + }, + "instructions": { + "min": 490000, + "median": 500000, + "max": 510000 + }, + "pin_allocated_bytes": { + "min": 49500, + "median": 50000, + "max": 50500 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_5", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.9, + "median": 2.0, + "max": 2.1 + }, + "maxrss_kb": { + "min": 79000, + "median": 80000, + "max": 81000 + }, + "pin_instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "zzz/skipped_thing", + "stem": "solution", + "script": "src/zzz/skipped_thing/solution.clj", + "case": "test_case_9", + "ok": true, + "error": null, + "runs": [], + "alloc_runs": [], + "summary": {} + }, + { + "label": "sort/broken_sort", + "stem": "solution", + "script": "src/sort/broken_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "my.ns/foo-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 3.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 10, + "test": 10, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 2.9, + "median": 3.0, + "max": 3.1 + }, + "maxrss_kb": { + "min": 190000, + "median": 200000, + "max": 210000 + }, + "pin_instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "pin_allocated_bytes": { + "min": 198000, + "median": 200000, + "max": 202000 + } + } + }, + { + "label": "clojure-algorithms.graphs.karger-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 4.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 100, + "test": 50, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 3.9, + "median": 4.0, + "max": 4.1 + }, + "maxrss_kb": { + "min": 290000, + "median": 300000, + "max": 310000 + }, + "pin_instructions": { + "min": 3990000, + "median": 4000000, + "max": 4010000 + }, + "instructions": { + "min": 3990000, + "median": 4000000, + "max": 4010000 + }, + "pin_allocated_bytes": { + "min": 396000, + "median": 400000, + "max": 404000 + } + } + } + ] +} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/chal.json b/scripts/ci/go/cmd/h2h-compare/testdata/chal.json index f0c9ccfab2..803b121991 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/chal.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/chal.json @@ -1,13 +1,413 @@ -{"failures": ["snap-fail-c"], "missing": ["m-chal-1"], "config": {"shard": "0/1", "repeat": 3}, "results": [ -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 0.1}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.09, "median": 0.1, "max": 0.11}, "maxrss_kb": {"min": 19000, "median": 20000, "max": 21000}, "pin_instructions": {"min": 99000, "median": 100000, "max": 101000}, "instructions": {"min": 99000, "median": 100000, "max": 101000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_1", "ok": true, "error": null, "runs": [{"wall_s": 2.5}], "alloc_runs": [], "summary": {"cpu_s": {"min": 2.4, "median": 2.5, "max": 2.6}, "maxrss_kb": {"min": 140000, "median": 150000, "max": 160000}, "pin_instructions": {"min": 2490000, "median": 2500000, "max": 2510000}, "instructions": {"min": 2490000, "median": 2500000, "max": 2510000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_2", "ok": true, "error": null, "runs": [{"wall_s": 1.5}], "alloc_runs": [], "summary": {"cpu_s": {"min": 1.4, "median": 1.5, "max": 1.6}, "maxrss_kb": {"min": 89000, "median": 90000, "max": 95000}, "pin_instructions": {"min": 2990000, "median": 3000000, "max": 3010000}, "instructions": {"min": 2990000, "median": 3000000, "max": 3010000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_3", "ok": true, "error": null, "runs": [{"wall_s": 0.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.0, "median": 0.0, "max": 0.0}, "maxrss_kb": {"min": 39000, "median": 40000, "max": 41000}, "pin_instructions": {"min": 100, "median": 100, "max": 100}, "instructions": {"min": 100, "median": 100, "max": 100}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_4", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.6, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 89000, "median": 90000, "max": 120000}, "pin_instructions": {"min": 390000, "median": 400000, "max": 410000}, "instructions": {"min": 390000, "median": 400000, "max": 410000}}}, -{"label": "sort/bubble_sort", "stem": "solution", "script": "src/sort/bubble_sort/solution.clj", "case": "test_case_5", "ok": true, "error": null, "runs": [{"wall_s": 2.01}], "alloc_runs": [], "summary": {"cpu_s": {"min": 2.0, "median": 2.01, "max": 2.02}, "maxrss_kb": {"min": 79000, "median": 80000, "max": 81000}, "pin_instructions": {"min": 100000, "median": 100500, "max": 101000}, "instructions": {"min": 100000, "median": 100500, "max": 101000}}}, -{"label": "zzz/skipped_thing", "stem": "solution", "script": "src/zzz/skipped_thing/solution.clj", "case": "test_case_9", "ok": true, "error": null, "runs": [], "alloc_runs": [], "summary": {}}, -{"label": "sort/broken_sort", "stem": "solution", "script": "src/sort/broken_sort/solution.clj", "case": "test_case_0", "ok": false, "error": "exit 1: boom", "runs": [], "alloc_runs": [], "summary": {}}, -{"label": "my.ns/foo-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 1.0}], "alloc_runs": [], "test_counts": {"pass": 9, "test": 10, "fail": 1, "error": 0}, "summary": {"cpu_s": {"min": 0.9, "median": 1.0, "max": 1.1}, "maxrss_kb": {"min": 90000, "median": 100000, "max": 110000}, "pin_instructions": {"min": 990000, "median": 1000000, "max": 1010000}, "instructions": {"min": 990000, "median": 1000000, "max": 1010000}}}, -{"label": "clojure-algorithms.graphs.karger-test", "case": "run", "ok": true, "error": null, "runs": [{"wall_s": 2.0}], "alloc_runs": [], "test_counts": {"pass": 97, "test": 50, "fail": 0, "error": 0}, "summary": {"cpu_s": {"min": 1.9, "median": 2.0, "max": 2.1}, "maxrss_kb": {"min": 140000, "median": 150000, "max": 160000}, "pin_instructions": {"min": 1990000, "median": 2000000, "max": 2010000}, "instructions": {"min": 1990000, "median": 2000000, "max": 2010000}}}, -{"label": "aaa/only_here_chal", "stem": "solution", "script": "src/aaa/only_here_chal/solution.clj", "case": "test_case_0", "ok": true, "error": null, "runs": [{"wall_s": 0.5}], "alloc_runs": [], "summary": {"cpu_s": {"min": 0.4, "median": 0.5, "max": 0.6}, "maxrss_kb": {"min": 49000, "median": 50000, "max": 51000}, "pin_instructions": {"min": 490, "median": 500, "max": 510}, "instructions": {"min": 490, "median": 500, "max": 510}}} -]} +{ + "failures": [ + "snap-fail-c" + ], + "missing": [ + "m-chal-1" + ], + "config": { + "shard": "0/1", + "repeat": 3 + }, + "results": [ + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.1 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.09, + "median": 0.1, + "max": 0.11 + }, + "maxrss_kb": { + "min": 19000, + "median": 20000, + "max": 21000 + }, + "pin_instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_1", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 2.4, + "median": 2.5, + "max": 2.6 + }, + "maxrss_kb": { + "min": 140000, + "median": 150000, + "max": 160000 + }, + "pin_instructions": { + "min": 2490000, + "median": 2500000, + "max": 2510000 + }, + "instructions": { + "min": 2490000, + "median": 2500000, + "max": 2510000 + }, + "pin_allocated_bytes": { + "min": 247500, + "median": 250000, + "max": 252500 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_2", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.4, + "median": 1.5, + "max": 1.6 + }, + "maxrss_kb": { + "min": 89000, + "median": 90000, + "max": 95000 + }, + "pin_instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "pin_allocated_bytes": { + "min": 297000, + "median": 300000, + "max": 303000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_3", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.0, + "median": 0.0, + "max": 0.0 + }, + "maxrss_kb": { + "min": 39000, + "median": 40000, + "max": 41000 + }, + "pin_instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_4", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.6, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 89000, + "median": 90000, + "max": 120000 + }, + "pin_instructions": { + "min": 390000, + "median": 400000, + "max": 410000 + }, + "instructions": { + "min": 390000, + "median": 400000, + "max": 410000 + }, + "pin_allocated_bytes": { + "min": 39600, + "median": 40000, + "max": 40400 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_5", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.01 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 2.0, + "median": 2.01, + "max": 2.02 + }, + "maxrss_kb": { + "min": 79000, + "median": 80000, + "max": 81000 + }, + "pin_instructions": { + "min": 100000, + "median": 100500, + "max": 101000 + }, + "instructions": { + "min": 100000, + "median": 100500, + "max": 101000 + }, + "pin_allocated_bytes": { + "min": 9950, + "median": 10050, + "max": 10150 + } + } + }, + { + "label": "zzz/skipped_thing", + "stem": "solution", + "script": "src/zzz/skipped_thing/solution.clj", + "case": "test_case_9", + "ok": true, + "error": null, + "runs": [], + "alloc_runs": [], + "summary": {} + }, + { + "label": "sort/broken_sort", + "stem": "solution", + "script": "src/sort/broken_sort/solution.clj", + "case": "test_case_0", + "ok": false, + "error": "exit 1: boom", + "runs": [], + "alloc_runs": [], + "summary": {} + }, + { + "label": "my.ns/foo-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 9, + "test": 10, + "fail": 1, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "clojure-algorithms.graphs.karger-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 97, + "test": 50, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 1.9, + "median": 2.0, + "max": 2.1 + }, + "maxrss_kb": { + "min": 140000, + "median": 150000, + "max": 160000 + }, + "pin_instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "pin_allocated_bytes": { + "min": 198000, + "median": 200000, + "max": 202000 + } + } + }, + { + "label": "aaa/only_here_chal", + "stem": "solution", + "script": "src/aaa/only_here_chal/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ + { + "wall_s": 0.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.4, + "median": 0.5, + "max": 0.6 + }, + "maxrss_kb": { + "min": 49000, + "median": 50000, + "max": 51000 + }, + "pin_instructions": { + "min": 490, + "median": 500, + "max": 510 + }, + "instructions": { + "min": 490, + "median": 500, + "max": 510 + }, + "pin_allocated_bytes": { + "min": 495, + "median": 500, + "max": 505 + } + } + } + ] +} diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt b/scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt new file mode 100644 index 0000000000..72265b9960 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt @@ -0,0 +1,28 @@ +== recipes-alloc == +jvm: shards=2 results=11 failures=2 missing=1 +rs: shards=1 results=11 failures=1 missing=1 +common cases: 10 only-jvm: 1 only-rs: 1 + only-jvm: aaa/only_here_base/solution/test_case_0 + only-rs: aaa/only_here_chal/solution/test_case_0 +compared ok-cases: 8 +ALLOC (median): rs wins 4, losses 1, ties 3 +both-win cases: 4/8 +worst rs alloc ratio: 2.500x (sort/bubble_sort/test_case_1) +--- ALLOC LOSSES (1) --- + 2.500x sort/bubble_sort/test_case_1 jvm=100,000B rs=250,000B +--- SKIPPED-NO-RUNS (1) --- + zzz/skipped_thing/test_case_9 jvm_runs=0 rs_runs=0 +--- NOT-OK (1) --- + sort/broken_sort/test_case_0 jvm_err=None rs_err=exit 1: boom +--- TEST-COUNT DRIFT (1) --- + my.ns/foo-test jvm={'pass': 10, 'test': 10, 'fail': 0, 'error': 0} rs={'pass': 9, 'test': 10, 'fail': 1, 'error': 0} +--- JVM failures (2) --- + aardvark-fail-b + snap-fail-b +--- RS failures (1) --- + snap-fail-c +--- JVM missing (1) --- + m-base-1 +--- RS missing (1) --- + m-chal-1 +GATE recipes-alloc: FAIL: 1 ALLOC losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-jvm 1, only-rs 1); sweep failures (jvm 2, rs 1); missing cases (jvm 1, rs 1) diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json index ccb67d819c..b424742943 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json @@ -1,394 +1,444 @@ { - "failures": [ - "aardvark-fail-b", - "snap-fail-b" - ], - "missing": [ - "m-base-1" - ], - "config": { - "shard": "merged", - "repeat": 3 - }, - "results": [ + "failures": [ + "aardvark-fail-b", + "snap-fail-b" + ], + "missing": [ + "m-base-1" + ], + "config": { + "shard": "merged", + "repeat": 3 + }, + "results": [ + { + "label": "aaa/only_here_base", + "stem": "solution", + "script": "src/aaa/only_here_base/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ { - "label": "aaa/only_here_base", - "stem": "solution", - "script": "src/aaa/only_here_base/solution.clj", - "case": "test_case_0", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.9, - "median": 1.0, - "max": 1.1 - }, - "maxrss_kb": { - "min": 90000, - "median": 100000, - "max": 110000 - }, - "pin_instructions": { - "min": 990, - "median": 1000, - "max": 1010 - }, - "instructions": { - "min": 990, - "median": 1000, - "max": 1010 - } - } + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 }, - { - "label": "clojure-algorithms.graphs.karger-test", - "case": "run", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 4.0 - } - ], - "alloc_runs": [], - "test_counts": { - "pass": 100, - "test": 50, - "fail": 0, - "error": 0 - }, - "summary": { - "cpu_s": { - "min": 3.9, - "median": 4.0, - "max": 4.1 - }, - "maxrss_kb": { - "min": 290000, - "median": 300000, - "max": 310000 - }, - "pin_instructions": { - "min": 3990000, - "median": 4000000, - "max": 4010000 - }, - "instructions": { - "min": 3990000, - "median": 4000000, - "max": 4010000 - } - } + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990, + "median": 1000, + "max": 1010 }, + "instructions": { + "min": 990, + "median": 1000, + "max": 1010 + }, + "pin_allocated_bytes": { + "min": 990, + "median": 1000, + "max": 1010 + } + } + }, + { + "label": "clojure-algorithms.graphs.karger-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ { - "label": "my.ns/foo-test", - "case": "run", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 3.0 - } - ], - "alloc_runs": [], - "test_counts": { - "pass": 10, - "test": 10, - "fail": 0, - "error": 0 - }, - "summary": { - "cpu_s": { - "min": 2.9, - "median": 3.0, - "max": 3.1 - }, - "maxrss_kb": { - "min": 190000, - "median": 200000, - "max": 210000 - }, - "pin_instructions": { - "min": 1990000, - "median": 2000000, - "max": 2010000 - }, - "instructions": { - "min": 1990000, - "median": 2000000, - "max": 2010000 - } - } + "wall_s": 4.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 100, + "test": 50, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 3.9, + "median": 4.0, + "max": 4.1 }, + "maxrss_kb": { + "min": 290000, + "median": 300000, + "max": 310000 + }, + "pin_instructions": { + "min": 3990000, + "median": 4000000, + "max": 4010000 + }, + "instructions": { + "min": 3990000, + "median": 4000000, + "max": 4010000 + }, + "pin_allocated_bytes": { + "min": 396000, + "median": 400000, + "max": 404000 + } + } + }, + { + "label": "my.ns/foo-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/broken_sort", - "stem": "solution", - "script": "src/sort/broken_sort/solution.clj", - "case": "test_case_0", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.9, - "median": 1.0, - "max": 1.1 - }, - "maxrss_kb": { - "min": 90000, - "median": 100000, - "max": 110000 - }, - "pin_instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - }, - "instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - } - } + "wall_s": 3.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 10, + "test": 10, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 2.9, + "median": 3.0, + "max": 3.1 + }, + "maxrss_kb": { + "min": 190000, + "median": 200000, + "max": 210000 + }, + "pin_instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 }, + "instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "pin_allocated_bytes": { + "min": 198000, + "median": 200000, + "max": 202000 + } + } + }, + { + "label": "sort/broken_sort", + "stem": "solution", + "script": "src/sort/broken_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_0", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.9, - "median": 1.0, - "max": 1.1 - }, - "maxrss_kb": { - "min": 90000, - "median": 100000, - "max": 110000 - }, - "pin_instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - }, - "instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - } - } + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_1", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.9, - "median": 1.0, - "max": 1.1 - }, - "maxrss_kb": { - "min": 90000, - "median": 100000, - "max": 110000 - }, - "pin_instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - }, - "instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - } - } + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_1", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_2", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 1.4, - "median": 1.5, - "max": 1.6 - }, - "maxrss_kb": { - "min": 90000, - "median": 100000, - "max": 110000 - }, - "pin_instructions": { - "min": 2990000, - "median": 3000000, - "max": 3010000 - }, - "instructions": { - "min": 2990000, - "median": 3000000, - "max": 3010000 - } - } + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_2", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_3", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 0.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.0, - "median": 0.0, - "max": 0.0 - }, - "maxrss_kb": { - "min": 49000, - "median": 50000, - "max": 51000 - }, - "pin_instructions": { - "min": 100, - "median": 100, - "max": 100 - }, - "instructions": { - "min": 100, - "median": 100, - "max": 100 - } - } + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.4, + "median": 1.5, + "max": 1.6 + }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 }, + "pin_instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "pin_allocated_bytes": { + "min": 297000, + "median": 300000, + "max": 303000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_3", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_4", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 2.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.5, - "median": 2.0, - "max": 2.5 - }, - "maxrss_kb": { - "min": 90000, - "median": 100000, - "max": 110000 - }, - "pin_instructions": { - "min": 490000, - "median": 500000, - "max": 510000 - }, - "instructions": { - "min": 490000, - "median": 500000, - "max": 510000 - } - } + "wall_s": 0.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.0, + "median": 0.0, + "max": 0.0 + }, + "maxrss_kb": { + "min": 49000, + "median": 50000, + "max": 51000 + }, + "pin_instructions": { + "min": 100, + "median": 100, + "max": 100 }, + "instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_4", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_5", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 2.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 1.9, - "median": 2.0, - "max": 2.1 - }, - "maxrss_kb": { - "min": 79000, - "median": 80000, - "max": 81000 - }, - "pin_instructions": { - "min": 99000, - "median": 100000, - "max": 101000 - }, - "instructions": { - "min": 99000, - "median": 100000, - "max": 101000 - } - } + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.5, + "median": 2.0, + "max": 2.5 }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 490000, + "median": 500000, + "max": 510000 + }, + "instructions": { + "min": 490000, + "median": 500000, + "max": 510000 + }, + "pin_allocated_bytes": { + "min": 49500, + "median": 50000, + "max": 50500 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_5", + "ok": true, + "error": null, + "runs": [ { - "label": "zzz/skipped_thing", - "stem": "solution", - "script": "src/zzz/skipped_thing/solution.clj", - "case": "test_case_9", - "ok": true, - "error": null, - "runs": [], - "alloc_runs": [], - "summary": {} + "wall_s": 2.0 } - ] + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.9, + "median": 2.0, + "max": 2.1 + }, + "maxrss_kb": { + "min": 79000, + "median": 80000, + "max": 81000 + }, + "pin_instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "zzz/skipped_thing", + "stem": "solution", + "script": "src/zzz/skipped_thing/solution.clj", + "case": "test_case_9", + "ok": true, + "error": null, + "runs": [], + "alloc_runs": [], + "summary": {} + } + ] } diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json index c0c5b1328e..38a7d6bc0b 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json @@ -1,368 +1,413 @@ { - "failures": [ - "snap-fail-c" - ], - "missing": [ - "m-chal-1" - ], - "config": { - "shard": "merged", - "repeat": 3 - }, - "results": [ + "failures": [ + "snap-fail-c" + ], + "missing": [ + "m-chal-1" + ], + "config": { + "shard": "merged", + "repeat": 3 + }, + "results": [ + { + "label": "aaa/only_here_chal", + "stem": "solution", + "script": "src/aaa/only_here_chal/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ { - "label": "aaa/only_here_chal", - "stem": "solution", - "script": "src/aaa/only_here_chal/solution.clj", - "case": "test_case_0", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 0.5 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.4, - "median": 0.5, - "max": 0.6 - }, - "maxrss_kb": { - "min": 49000, - "median": 50000, - "max": 51000 - }, - "pin_instructions": { - "min": 490, - "median": 500, - "max": 510 - }, - "instructions": { - "min": 490, - "median": 500, - "max": 510 - } - } + "wall_s": 0.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.4, + "median": 0.5, + "max": 0.6 }, - { - "label": "clojure-algorithms.graphs.karger-test", - "case": "run", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 2.0 - } - ], - "alloc_runs": [], - "test_counts": { - "pass": 97, - "test": 50, - "fail": 0, - "error": 0 - }, - "summary": { - "cpu_s": { - "min": 1.9, - "median": 2.0, - "max": 2.1 - }, - "maxrss_kb": { - "min": 140000, - "median": 150000, - "max": 160000 - }, - "pin_instructions": { - "min": 1990000, - "median": 2000000, - "max": 2010000 - }, - "instructions": { - "min": 1990000, - "median": 2000000, - "max": 2010000 - } - } + "maxrss_kb": { + "min": 49000, + "median": 50000, + "max": 51000 }, - { - "label": "my.ns/foo-test", - "case": "run", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.0 - } - ], - "alloc_runs": [], - "test_counts": { - "pass": 9, - "test": 10, - "fail": 1, - "error": 0 - }, - "summary": { - "cpu_s": { - "min": 0.9, - "median": 1.0, - "max": 1.1 - }, - "maxrss_kb": { - "min": 90000, - "median": 100000, - "max": 110000 - }, - "pin_instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - }, - "instructions": { - "min": 990000, - "median": 1000000, - "max": 1010000 - } - } + "pin_instructions": { + "min": 490, + "median": 500, + "max": 510 }, + "instructions": { + "min": 490, + "median": 500, + "max": 510 + }, + "pin_allocated_bytes": { + "min": 495, + "median": 500, + "max": 505 + } + } + }, + { + "label": "clojure-algorithms.graphs.karger-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/broken_sort", - "stem": "solution", - "script": "src/sort/broken_sort/solution.clj", - "case": "test_case_0", - "ok": false, - "error": "exit 1: boom", - "runs": [], - "alloc_runs": [], - "summary": {} + "wall_s": 2.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 97, + "test": 50, + "fail": 0, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 1.9, + "median": 2.0, + "max": 2.1 + }, + "maxrss_kb": { + "min": 140000, + "median": 150000, + "max": 160000 + }, + "pin_instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 + }, + "instructions": { + "min": 1990000, + "median": 2000000, + "max": 2010000 }, + "pin_allocated_bytes": { + "min": 198000, + "median": 200000, + "max": 202000 + } + } + }, + { + "label": "my.ns/foo-test", + "case": "run", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_0", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 0.1 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.09, - "median": 0.1, - "max": 0.11 - }, - "maxrss_kb": { - "min": 19000, - "median": 20000, - "max": 21000 - }, - "pin_instructions": { - "min": 99000, - "median": 100000, - "max": 101000 - }, - "instructions": { - "min": 99000, - "median": 100000, - "max": 101000 - } - } + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "test_counts": { + "pass": 9, + "test": 10, + "fail": 1, + "error": 0 + }, + "summary": { + "cpu_s": { + "min": 0.9, + "median": 1.0, + "max": 1.1 }, + "maxrss_kb": { + "min": 90000, + "median": 100000, + "max": 110000 + }, + "pin_instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "instructions": { + "min": 990000, + "median": 1000000, + "max": 1010000 + }, + "pin_allocated_bytes": { + "min": 99000, + "median": 100000, + "max": 101000 + } + } + }, + { + "label": "sort/broken_sort", + "stem": "solution", + "script": "src/sort/broken_sort/solution.clj", + "case": "test_case_0", + "ok": false, + "error": "exit 1: boom", + "runs": [], + "alloc_runs": [], + "summary": {} + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_0", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_1", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 2.5 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 2.4, - "median": 2.5, - "max": 2.6 - }, - "maxrss_kb": { - "min": 140000, - "median": 150000, - "max": 160000 - }, - "pin_instructions": { - "min": 2490000, - "median": 2500000, - "max": 2510000 - }, - "instructions": { - "min": 2490000, - "median": 2500000, - "max": 2510000 - } - } + "wall_s": 0.1 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.09, + "median": 0.1, + "max": 0.11 }, + "maxrss_kb": { + "min": 19000, + "median": 20000, + "max": 21000 + }, + "pin_instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "instructions": { + "min": 99000, + "median": 100000, + "max": 101000 + }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_1", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_2", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.5 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 1.4, - "median": 1.5, - "max": 1.6 - }, - "maxrss_kb": { - "min": 89000, - "median": 90000, - "max": 95000 - }, - "pin_instructions": { - "min": 2990000, - "median": 3000000, - "max": 3010000 - }, - "instructions": { - "min": 2990000, - "median": 3000000, - "max": 3010000 - } - } + "wall_s": 2.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 2.4, + "median": 2.5, + "max": 2.6 + }, + "maxrss_kb": { + "min": 140000, + "median": 150000, + "max": 160000 }, + "pin_instructions": { + "min": 2490000, + "median": 2500000, + "max": 2510000 + }, + "instructions": { + "min": 2490000, + "median": 2500000, + "max": 2510000 + }, + "pin_allocated_bytes": { + "min": 247500, + "median": 250000, + "max": 252500 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_2", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_3", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 0.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.0, - "median": 0.0, - "max": 0.0 - }, - "maxrss_kb": { - "min": 39000, - "median": 40000, - "max": 41000 - }, - "pin_instructions": { - "min": 100, - "median": 100, - "max": 100 - }, - "instructions": { - "min": 100, - "median": 100, - "max": 100 - } - } + "wall_s": 1.5 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 1.4, + "median": 1.5, + "max": 1.6 + }, + "maxrss_kb": { + "min": 89000, + "median": 90000, + "max": 95000 + }, + "pin_instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 }, + "instructions": { + "min": 2990000, + "median": 3000000, + "max": 3010000 + }, + "pin_allocated_bytes": { + "min": 297000, + "median": 300000, + "max": 303000 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_3", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_4", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 1.0 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 0.6, - "median": 1.0, - "max": 1.1 - }, - "maxrss_kb": { - "min": 89000, - "median": 90000, - "max": 120000 - }, - "pin_instructions": { - "min": 390000, - "median": 400000, - "max": 410000 - }, - "instructions": { - "min": 390000, - "median": 400000, - "max": 410000 - } - } + "wall_s": 0.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.0, + "median": 0.0, + "max": 0.0 + }, + "maxrss_kb": { + "min": 39000, + "median": 40000, + "max": 41000 + }, + "pin_instructions": { + "min": 100, + "median": 100, + "max": 100 + }, + "instructions": { + "min": 100, + "median": 100, + "max": 100 }, + "pin_allocated_bytes": { + "min": 9900, + "median": 10000, + "max": 10100 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_4", + "ok": true, + "error": null, + "runs": [ { - "label": "sort/bubble_sort", - "stem": "solution", - "script": "src/sort/bubble_sort/solution.clj", - "case": "test_case_5", - "ok": true, - "error": null, - "runs": [ - { - "wall_s": 2.01 - } - ], - "alloc_runs": [], - "summary": { - "cpu_s": { - "min": 2.0, - "median": 2.01, - "max": 2.02 - }, - "maxrss_kb": { - "min": 79000, - "median": 80000, - "max": 81000 - }, - "pin_instructions": { - "min": 100000, - "median": 100500, - "max": 101000 - }, - "instructions": { - "min": 100000, - "median": 100500, - "max": 101000 - } - } + "wall_s": 1.0 + } + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 0.6, + "median": 1.0, + "max": 1.1 + }, + "maxrss_kb": { + "min": 89000, + "median": 90000, + "max": 120000 }, + "pin_instructions": { + "min": 390000, + "median": 400000, + "max": 410000 + }, + "instructions": { + "min": 390000, + "median": 400000, + "max": 410000 + }, + "pin_allocated_bytes": { + "min": 39600, + "median": 40000, + "max": 40400 + } + } + }, + { + "label": "sort/bubble_sort", + "stem": "solution", + "script": "src/sort/bubble_sort/solution.clj", + "case": "test_case_5", + "ok": true, + "error": null, + "runs": [ { - "label": "zzz/skipped_thing", - "stem": "solution", - "script": "src/zzz/skipped_thing/solution.clj", - "case": "test_case_9", - "ok": true, - "error": null, - "runs": [], - "alloc_runs": [], - "summary": {} + "wall_s": 2.01 } - ] + ], + "alloc_runs": [], + "summary": { + "cpu_s": { + "min": 2.0, + "median": 2.01, + "max": 2.02 + }, + "maxrss_kb": { + "min": 79000, + "median": 80000, + "max": 81000 + }, + "pin_instructions": { + "min": 100000, + "median": 100500, + "max": 101000 + }, + "instructions": { + "min": 100000, + "median": 100500, + "max": 101000 + }, + "pin_allocated_bytes": { + "min": 9950, + "median": 10050, + "max": 10150 + } + } + }, + { + "label": "zzz/skipped_thing", + "stem": "solution", + "script": "src/zzz/skipped_thing/solution.clj", + "case": "test_case_9", + "ok": true, + "error": null, + "runs": [], + "alloc_runs": [], + "summary": {} + } + ] } diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md b/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md new file mode 100644 index 0000000000..45f756e821 --- /dev/null +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md @@ -0,0 +1,20 @@ +## h2h recipes-alloc: jvm vs rs + +common cases: 8 (jvm shards 2, rs shards 1); both-win 4/8 + +| metric | rs wins | losses | ties | +| --- | ---: | ---: | ---: | +| ALLOC (median allocated bytes) | 4 | 1 | 3 | + +worst rs alloc ratio: 2.500x (sort/bubble_sort/test_case_1) + +### GATE: FAIL + +1 ALLOC losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-jvm 1, only-rs 1); sweep failures (jvm 2, rs 1); missing cases (jvm 1, rs 1) + +
ALLOC losses (1, worst first) + +| ratio | case | jvm | rs | +| ---: | --- | ---: | ---: | +| 2.500x | sort/bubble_sort/test_case_1 | 100,000B | 250,000B | +
diff --git a/scripts/ci/instrumentation/inscount.cpp b/scripts/ci/instrumentation/inscount.cpp index be2cad43fd..34d6d67cd2 100644 --- a/scripts/ci/instrumentation/inscount.cpp +++ b/scripts/ci/instrumentation/inscount.cpp @@ -1,34 +1,86 @@ -// inscount: minimal Intel Pin instruction-count tool for the -// h2h cpu/mem gate. Counts every executed instruction (all -// threads, all images including the Pin VM itself is excluded by -// construction: only application instructions are instrumented) -// and prints one line: +// inscount: minimal Intel Pin counting tool for the h2h gate. +// Counts every executed instruction and every malloc-family +// requested byte (one run, two signals) and prints: // // Count N +// Alloc M // -// The harness parsers (snip/algoperf ParsePinCount) read the last -// such line. Build out of the kit with: +// The harness parsers (snip/algoperf ParsePinCount/ParsePinAlloc) +// read the last such lines. Build out of the kit with: // // make PIN_ROOT= -C // // which drops obj-intel64/inscount.so next to this file. +// +// Allocation semantics: cumulative requested bytes (malloc size, +// calloc nmemb*size, realloc/memalign/aligned_alloc new size, +// posix_memalign size), counted unconditionally at the call entry +// point: an OOM failure kills the run, which fails loudly rather +// than reporting bad data. Matches the LD_PRELOAD counting-shim +// convention: realloc counts its full new size (over-approximates +// shrink/grow deltas), frees are ignored. Only the standard entry +// names are instrumented, never __libc_* aliases, so one app call +// counts once. #include #include "pin.H" static UINT64 total = 0; +static UINT64 allocated = 0; static FILE *out = nullptr; static KNOB KnobOutput(KNOB_MODE_WRITEONCE, "pintool", "o", "inscount.out", - "output file for the Count line"); + "output file for the Count/Alloc lines"); + +static VOID docount(UINT32 n) { __atomic_add_fetch(&total, n, __ATOMIC_RELAXED); } + +static VOID onAlloc(ADDRINT size) { __atomic_add_fetch(&allocated, (UINT64)size, __ATOMIC_RELAXED); } -static VOID docount(UINT32 n) { total += n; } +static VOID onAllocProduct(ADDRINT a, ADDRINT b) { + __atomic_add_fetch(&allocated, (UINT64)a * (UINT64)b, __ATOMIC_RELAXED); +} static VOID Instruction(INS ins, VOID *) { INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_UINT32, 1, IARG_END); } +// InstrumentAlloc1 counts one size argument (index arg) of the +// named routine at its entry point: malloc(size), +// realloc(ptr, size), memalign(align, size), +// aligned_alloc(align, size). +static VOID InstrumentAlloc1(IMG img, const char *name, int arg) { + RTN rtn = RTN_FindByName(img, name); + if (!RTN_Valid(rtn)) { + return; + } + RTN_Open(rtn); + RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)onAlloc, IARG_FUNCARG_ENTRYPOINT_VALUE, arg, IARG_END); + RTN_Close(rtn); +} + +static VOID Image(IMG img, VOID *) { + InstrumentAlloc1(img, "malloc", 0); + InstrumentAlloc1(img, "realloc", 1); + InstrumentAlloc1(img, "memalign", 1); + InstrumentAlloc1(img, "aligned_alloc", 1); + RTN rtn = RTN_FindByName(img, "calloc"); + if (RTN_Valid(rtn)) { + RTN_Open(rtn); + RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)onAllocProduct, IARG_FUNCARG_ENTRYPOINT_VALUE, 0, + IARG_FUNCARG_ENTRYPOINT_VALUE, 1, IARG_END); + RTN_Close(rtn); + } + // posix_memalign(&ptr, align, size): size is argument 2. + rtn = RTN_FindByName(img, "posix_memalign"); + if (RTN_Valid(rtn)) { + RTN_Open(rtn); + RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)onAlloc, IARG_FUNCARG_ENTRYPOINT_VALUE, 2, IARG_END); + RTN_Close(rtn); + } +} + static VOID Fini(INT32, VOID *) { if (out != nullptr) { fprintf(out, "Count %llu\n", (unsigned long long)total); + fprintf(out, "Alloc %llu\n", (unsigned long long)allocated); fclose(out); } } @@ -43,6 +95,7 @@ int main(int argc, char *argv[]) { return 1; } INS_AddInstrumentFunction(Instruction, nullptr); + IMG_AddInstrumentFunction(Image, nullptr); PIN_AddFiniFunction(Fini, nullptr); PIN_StartProgram(); return 0; From ec51d043bca3d4a3fd639d467c856dd24d2a2b9b Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:17:47 +0100 Subject: [PATCH 13/20] h2h-cpu-mem: split instrumented JVM legs, 32 shards, setarch -R Dedicated measure-recipes-jvm / measure-algos-jvm jobs (32 shards each, --pin-repeat 1): Pin 4.4 slows HotSpot ~40x, so the JVM baseline needs its own fan-out to fit the 6h cap. The pin launcher runs JVM legs under setarch -R with -Xrs -XX:+AllowUserSignalHandlers, which keeps HotSpot mmaps clear of Pin's reserved region (validates clean: no more exit-127 mmap overlap, 4/4 bubble_sort cases with sane counts). All measure jobs now carry instrumentation in the display name (rs-from / rs-to / jvm). No JVM-vs-RS ins gate: JVM counts wobble 1-5% run to run vs 0.1% native, so the deterministic gate stays rs-from vs rs-to only; JVM counts are archived in the merged JSONs. Bump suite pins to the rebased setarch pin-inscount heads. --- .github/workflows/h2h-cpu-mem.yml | 341 +++++++++++++++++++++--------- 1 file changed, 246 insertions(+), 95 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index bd16fb4d61..5eeb34e509 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -1,16 +1,17 @@ # Head-to-head CPU/RSS gate: clojure-rs revisions vs JVM Clojure # and vs each other, across the snippet suite (hhh-pa/clojure-code-snippet) # and the algorithms suite (hhh-pa/clojure-algorithms). Each suite -# runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), sharded -# 8 ways (48 measure jobs, one sweep); the h2h-compare job unions -# the shards per leg and gates on per-case median cpu_s + -# maxrss_kb. The gate rule: clojure-rs must beat JVM Clojure, AND -# rs-from must win-or-tie vs rs-to on every case — ANY loss fails -# the PR. The native legs additionally count instructions and -# allocated bytes under Intel Pin in the same sweep (no second job -# set), feeding a deterministic instruction/allocation gate (1% -# margin) for rs-from vs rs-to, immune to the wall-time wobble -# that reds the cpu/rsrs leg on no-op changes. +# runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), every +# leg instrumented in a single sweep: GNU time (cpu_s, maxrss_kb) +# plus Intel Pin instruction + allocation counts, which feed a +# deterministic ins/alloc gate (1% margin) for rs-from vs rs-to, +# immune to the wall-time wobble that reds the cpu/rsrs leg on +# no-op changes. Sharding is asymmetric because Pin slows HotSpot +# ~40x: 8 ways x 2 native runtimes (16 jobs/suite) vs 32 ways x +# JVM (32 jobs/suite), 96 measure jobs total; the h2h-compare job +# unions the shards per leg and gates on per-case medians. The +# gate rule: clojure-rs must beat JVM Clojure, AND rs-from must +# win-or-tie vs rs-to on every case — ANY loss fails the PR. # # Revision pairs ("PR from head to base" parlance): from is the # change under test, to is its baseline. On pull_request, from = @@ -82,8 +83,8 @@ env: # algos PR#7) until they merge, then back to master SHAs. The --pin # legs below need harness support, so old-SHA bisection of the # instrumentation legs fails loud on the unknown flag by design. - SNIPPETS_SHA_DEFAULT: acef0694e892c467f574158ffb94c73ff1d916fb - ALGORITHMS_SHA_DEFAULT: 1fd2e7205e670b9ed1c0d2272764c872618f07e1 + SNIPPETS_SHA_DEFAULT: 3b9c524f2d19469040b1aef8c6fa99597d1c2a9b + ALGORITHMS_SHA_DEFAULT: 005645115db4331b93637deb941b7c5ef43a88f0 # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. JAVA_VERSION: '21.0.13' @@ -91,7 +92,7 @@ env: jobs: # Resolve the suite revisions AND the native-vs-native revision - # pair ONCE per run so all 48 measure legs sweep the same commits. + # pair ONCE per run so all 96 measure legs sweep the same commits. # Suite defaults: the pinned SHAs above; a manual dispatch may pin # either input instead (bisection). Native pair: PR head/base by # default on pull_request (stacked PRs diff against their parent), @@ -189,7 +190,9 @@ jobs: path: cljrs-bin/ retention-days: 1 - # Recipes legs: 8 shards x 3 runtimes (jvm, rs-from, rs-to). + # Recipes native legs: 8 shards x 2 runtimes (rs-from, rs-to). + # The JVM baseline lives in measure-recipes-jvm below (32 shards: + # Pin slows HotSpot ~40x). # Full-suite workstation time was ~65min JVM / ~30min RS at # --jobs 3, so ~10min/shard; the 60min cap is generous headroom. measure-recipes: @@ -198,15 +201,15 @@ jobs: timeout-minutes: 90 permissions: contents: read # suite + instrumentation tooling checkouts - actions: write # jars + instrumentation caches + shard artifact + actions: write # instrumentation cache + shard artifact strategy: # Let every shard finish so partial artifacts upload for diagnosis. fail-fast: false matrix: # 0-based K/8: snip measure --shard needs 0 <= K < N. shard: [0, 1, 2, 3, 4, 5, 6, 7] - runtime: [jvm, rs-from, rs-to] - name: recipes ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) + runtime: [rs-from, rs-to] + name: recipes instrumentation ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) steps: - name: Check out snippet suite uses: actions/checkout@v5 @@ -226,15 +229,120 @@ jobs: cd snippets/scripts/ci/go go build -o snip ./cmd/snip + - name: Decrypt and extract resources + env: + RESOURCES_PASSPHRASE: ${{ secrets.RESOURCES_PASSPHRASE }} + working-directory: snippets + run: | + if [ -z "$RESOURCES_PASSPHRASE" ]; then + echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 + exit 1 + fi + mkdir -p tests/resources + gpg --batch --yes --pinentry-mode loopback \ + --passphrase "$RESOURCES_PASSPHRASE" \ + -d tests/resources.tar.xz.asc | tar -xJ -C tests/resources --strip-components=1 + + - name: Install GNU time + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time + + - name: Use built clojure-rs + uses: actions/download-artifact@v6 + with: + name: cljrs-${{ matrix.runtime }} + path: cljrs-bin + + - name: Add clojure-rs to PATH + run: | + # download-artifact does not preserve the executable bit. + chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" + echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" + + - name: Check out instrumentation tooling + uses: actions/checkout@v5 + with: + path: h2h + + - name: Cache instrumentation kit + id: instrumentation-cache + uses: actions/cache@v5 + with: + path: ~/instrumentation + key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} + + - name: Provision instrumentation + run: | + bash h2h/scripts/ci/setup-instrumentation.sh + + - name: Measure head-to-head shard + working-directory: snippets + run: | + ./scripts/ci/go/snip measure --resources tests/resources \ + --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 300 \ + --runtime "${{ matrix.runtime }}" \ + --command-template 'clojure-rs run {script}' \ + --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ + --pin-repeat 2 --pin-timeout 1500 \ + --shard ${{ matrix.shard }}/8 \ + --output "h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --summary-file "$GITHUB_STEP_SUMMARY" + + - name: Upload shard results + if: always() + uses: actions/upload-artifact@v6 + with: + name: h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: snippets/h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + retention-days: 7 + + # JVM baseline for the recipes suite. Dedicated job (not a runtime row of + # measure-recipes) because the JVM needs its own shard fan-out: Pin slows + # HotSpot ~40x, so 32 shards and a single pin-repeat keep every shard under + # the 6h cap with margin. setarch -R (inside the pin launcher) keeps the + # JVM mmaps clear of Pin's reserved region under address randomization. + measure-recipes-jvm: + needs: [resolve] + runs-on: ubuntu-latest + timeout-minutes: 360 + permissions: + contents: read # suite + instrumentation tooling checkouts + actions: write # jars + instrumentation caches + shard artifact + strategy: + fail-fast: false + matrix: + # 0-based K/32. + shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] + name: recipes instrumentation jvm (shard ${{ matrix.shard }}/32) + env: + SNIPPETS_SHA: ${{ needs.resolve.outputs.snippets_sha }} + steps: + - name: Check out suite + uses: actions/checkout@v5 + with: + repository: ${{ env.SUITE_REPO }} + ref: ${{ env.SNIPPETS_SHA }} + path: snippets + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: snippets/go.mod + cache-dependency-path: snippets/go.sum + + - name: Build snip + run: | + cd snippets/scripts/ci/go + go build -o snip ./cmd/snip + - name: Set up JDK 21 - if: matrix.runtime == 'jvm' uses: actions/setup-java@v5 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} - name: Cache Clojure jars - if: matrix.runtime == 'jvm' id: jars-cache uses: actions/cache@v5 with: @@ -242,7 +350,7 @@ jobs: key: ${{ env.JARS_CACHE_KEY }} - name: Download Clojure jars - if: matrix.runtime == 'jvm' && steps.jars-cache.outputs.cache-hit != 'true' + if: steps.jars-cache.outputs.cache-hit != 'true' run: | mkdir -p snippets/.ci/jars base=https://repo1.maven.org/maven2 @@ -253,16 +361,100 @@ jobs: - name: Decrypt and extract resources env: RESOURCES_PASSPHRASE: ${{ secrets.RESOURCES_PASSPHRASE }} - working-directory: snippets run: | if [ -z "$RESOURCES_PASSPHRASE" ]; then - echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 + echo "::error::RESOURCES_PASSPHRASE secret is not set" exit 1 fi - mkdir -p tests/resources - gpg --batch --yes --pinentry-mode loopback \ - --passphrase "$RESOURCES_PASSPHRASE" \ - -d tests/resources.tar.xz.asc | tar -xJ -C tests/resources --strip-components=1 + mkdir -p /tmp/snipsrc + tar -xzf "${GITHUB_WORKSPACE}/snippets/resources.tar.gz.enc" -C /tmp/snipsrc + openssl enc -d -aes-256-cbc -pbkdf2 \ + -in /tmp/snipsrc/resources.tar.gz.enc \ + -out /tmp/snipsrc/resources.tar.gz \ + -pass env:RESOURCES_PASSPHRASE + tar -xzf /tmp/snipsrc/resources.tar.gz -C /tmp/snipsrc + mkdir -p snippets/tests/resources + cp -r /tmp/snipsrc/. snippets/tests/resources/ + + - name: Install GNU time + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq time + + - name: Check out instrumentation tooling + uses: actions/checkout@v5 + with: + path: h2h + + - name: Cache instrumentation kit + id: instrumentation-cache + uses: actions/cache@v5 + with: + path: ~/instrumentation + key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} + + - name: Provision instrumentation + run: | + bash h2h/scripts/ci/setup-instrumentation.sh + + - name: Measure head-to-head shard + working-directory: snippets + run: | + ./scripts/ci/go/snip measure --resources tests/resources \ + --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 300 \ + --runtime jvm \ + --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ + --pin-repeat 1 --pin-timeout 1500 \ + --shard ${{ matrix.shard }}/32 \ + --output "h2h-recipes-jvm-shard-${{ matrix.shard }}.json" \ + --summary-file "$GITHUB_STEP_SUMMARY" + + - name: Upload shard results + if: always() + uses: actions/upload-artifact@v6 + with: + name: h2h-recipes-jvm-shard-${{ matrix.shard }} + path: snippets/h2h-recipes-jvm-shard-${{ matrix.shard }}.json + retention-days: 7 + + # Algos native legs: 8 shards x 2 runtimes (rs-from, rs-to). + # The per-case timeout is 1800s (vs 300s for recipes), hence the + # roomier job cap. No decrypt step: this suite carries its + # resources/ in-repo. The JVM baseline lives in + # measure-algos-jvm below (32 shards: Pin slows HotSpot ~40x). + measure-algos: + needs: [resolve, build-rs] + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read # suite + instrumentation tooling checkouts + actions: write # instrumentation cache + shard artifact + strategy: + # Let every shard finish so partial artifacts upload for diagnosis. + fail-fast: false + matrix: + # 0-based K/8: algoperf measure --shard needs 0 <= K < N. + shard: [0, 1, 2, 3, 4, 5, 6, 7] + runtime: [rs-from, rs-to] + name: algos instrumentation ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) + steps: + - name: Check out algorithms suite + uses: actions/checkout@v5 + with: + repository: hhh-pa/clojure-algorithms + ref: ${{ needs.resolve.outputs.algorithms_sha }} + path: algos + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: algos/scripts/ci/go/go.mod + cache: false # stdlib-only module: nothing to cache but the build below + + - name: Build algoperf + run: | + cd algos/scripts/ci/go + go build -o algoperf ./cmd/algoperf - name: Install GNU time run: | @@ -270,27 +462,23 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - name: Use built clojure-rs - if: matrix.runtime != 'jvm' uses: actions/download-artifact@v6 with: name: cljrs-${{ matrix.runtime }} path: cljrs-bin - name: Add clojure-rs to PATH - if: matrix.runtime != 'jvm' run: | # download-artifact does not preserve the executable bit. chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - name: Check out instrumentation tooling - if: matrix.runtime != 'jvm' uses: actions/checkout@v5 with: path: h2h - name: Cache instrumentation kit - if: matrix.runtime != 'jvm' id: instrumentation-cache uses: actions/cache@v5 with: @@ -298,55 +486,45 @@ jobs: key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - name: Provision instrumentation - if: matrix.runtime != 'jvm' run: | bash h2h/scripts/ci/setup-instrumentation.sh - name: Measure head-to-head shard - working-directory: snippets + working-directory: algos run: | - flags=(--runtime "${{ matrix.runtime }}") - if [ "${{ matrix.runtime }}" != "jvm" ]; then - flags+=(--command-template 'clojure-rs run {script}') - # Native legs also count instructions + allocated bytes - # under Pin (same sweep, no second job set). - flags+=(--pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL") - flags+=(--pin-repeat 2 --pin-timeout 1500) - fi - ./scripts/ci/go/snip measure --resources tests/resources \ - --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 300 \ - "${flags[@]}" \ + ./scripts/ci/go/algoperf measure \ + --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ + --runtime "${{ matrix.runtime }}" \ + --command-template 'clojure-rs run perf/driver.clj {ns} src test resources' \ + --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ + --pin-repeat 2 --pin-timeout 1200 \ --shard ${{ matrix.shard }}/8 \ - --output "h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --output "h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ --summary-file "$GITHUB_STEP_SUMMARY" - name: Upload shard results if: always() uses: actions/upload-artifact@v6 with: - name: h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: snippets/h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + name: h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} + path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 - # Algos legs: 8 shards x 3 runtimes (jvm, rs-from, rs-to). The - # per-case timeout is 1800s (vs 300s for recipes), hence the - # roomier job cap. No decrypt step: this suite carries its - # resources/ in-repo. - measure-algos: - needs: [resolve, build-rs] + # JVM baseline for the algos suite. Dedicated 32-shard job for the + # same Pin-on-HotSpot cost reason as measure-recipes-jvm (see above). + measure-algos-jvm: + needs: [resolve] runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 360 permissions: contents: read # suite + instrumentation tooling checkouts actions: write # jars + instrumentation caches + shard artifact strategy: - # Let every shard finish so partial artifacts upload for diagnosis. fail-fast: false matrix: - # 0-based K/8: algoperf measure --shard needs 0 <= K < N. - shard: [0, 1, 2, 3, 4, 5, 6, 7] - runtime: [jvm, rs-from, rs-to] - name: algos ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) + # 0-based K/32. + shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] + name: algos instrumentation jvm (shard ${{ matrix.shard }}/32) steps: - name: Check out algorithms suite uses: actions/checkout@v5 @@ -367,14 +545,12 @@ jobs: go build -o algoperf ./cmd/algoperf - name: Set up JDK 21 - if: matrix.runtime == 'jvm' uses: actions/setup-java@v5 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} - name: Cache Clojure jars - if: matrix.runtime == 'jvm' id: jars-cache uses: actions/cache@v5 with: @@ -382,7 +558,7 @@ jobs: key: ${{ env.JARS_CACHE_KEY }} - name: Download Clojure jars - if: matrix.runtime == 'jvm' && steps.jars-cache.outputs.cache-hit != 'true' + if: steps.jars-cache.outputs.cache-hit != 'true' run: | mkdir -p algos/.ci/jars base=https://repo1.maven.org/maven2 @@ -395,28 +571,12 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - - name: Use built clojure-rs - if: matrix.runtime != 'jvm' - uses: actions/download-artifact@v6 - with: - name: cljrs-${{ matrix.runtime }} - path: cljrs-bin - - - name: Add clojure-rs to PATH - if: matrix.runtime != 'jvm' - run: | - # download-artifact does not preserve the executable bit. - chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" - echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - - name: Check out instrumentation tooling - if: matrix.runtime != 'jvm' uses: actions/checkout@v5 with: path: h2h - name: Cache instrumentation kit - if: matrix.runtime != 'jvm' id: instrumentation-cache uses: actions/cache@v5 with: @@ -424,37 +584,28 @@ jobs: key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - name: Provision instrumentation - if: matrix.runtime != 'jvm' run: | bash h2h/scripts/ci/setup-instrumentation.sh - name: Measure head-to-head shard working-directory: algos run: | - flags=(--runtime "${{ matrix.runtime }}") - if [ "${{ matrix.runtime }}" = "jvm" ]; then - jars=".ci/jars/clojure.jar:.ci/jars/spec.alpha.jar:.ci/jars/core.specs.alpha.jar" - flags+=(--cp "$jars:src:test:resources") - else - flags+=(--command-template 'clojure-rs run perf/driver.clj {ns} src test resources') - # Native legs also count instructions + allocated bytes - # under Pin (same sweep, no second job set). - flags+=(--pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL") - flags+=(--pin-repeat 2 --pin-timeout 1200) - fi + jars=".ci/jars/clojure.jar:.ci/jars/spec.alpha.jar:.ci/jars/core.specs.alpha.jar" ./scripts/ci/go/algoperf measure \ --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ - "${flags[@]}" \ - --shard ${{ matrix.shard }}/8 \ - --output "h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ + --runtime jvm --cp "$jars:src:test:resources" \ + --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ + --pin-repeat 1 --pin-timeout 1200 \ + --shard ${{ matrix.shard }}/32 \ + --output "h2h-algos-jvm-shard-${{ matrix.shard }}.json" \ --summary-file "$GITHUB_STEP_SUMMARY" - name: Upload shard results if: always() uses: actions/upload-artifact@v6 with: - name: h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json + name: h2h-algos-jvm-shard-${{ matrix.shard }} + path: algos/h2h-algos-jvm-shard-${{ matrix.shard }}.json retention-days: 7 # Fan-in: union the shards per leg and diff the legs. Strict @@ -469,7 +620,7 @@ jobs: # it does not track history. h2h-compare: name: h2h cpu/mem gate - needs: [measure-recipes, measure-algos] + needs: [measure-recipes, measure-recipes-jvm, measure-algos, measure-algos-jvm] runs-on: ubuntu-latest timeout-minutes: 15 permissions: From 498fe2577c70487292c0acbe04abf931ff6f2452 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:01:56 +0100 Subject: [PATCH 14/20] h2h-cpu-mem: fix recipes-jvm checkout (literal repo, go.mod path, gpg decrypt) The split-off measure-recipes-jvm job referenced a nonexistent ${{ env.SUITE_REPO }}, so checkout fell back to hhh-pa/clojure and failed on the suite ref. Mirror the native legs exactly: literal suite repository, direct needs.resolve.outputs ref, the real go-version-file path, and the gpg decrypt block. --- .github/workflows/h2h-cpu-mem.yml | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 5eeb34e509..007b7fdde3 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -315,21 +315,19 @@ jobs: # 0-based K/32. shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] name: recipes instrumentation jvm (shard ${{ matrix.shard }}/32) - env: - SNIPPETS_SHA: ${{ needs.resolve.outputs.snippets_sha }} steps: - name: Check out suite uses: actions/checkout@v5 with: - repository: ${{ env.SUITE_REPO }} - ref: ${{ env.SNIPPETS_SHA }} + repository: hhh-pa/clojure-code-snippet + ref: ${{ needs.resolve.outputs.snippets_sha }} path: snippets - name: Set up Go uses: actions/setup-go@v6 with: - go-version-file: snippets/go.mod - cache-dependency-path: snippets/go.sum + go-version-file: snippets/scripts/ci/go/go.mod + cache: false # stdlib-only module: nothing to cache but the build below - name: Build snip run: | @@ -361,20 +359,16 @@ jobs: - name: Decrypt and extract resources env: RESOURCES_PASSPHRASE: ${{ secrets.RESOURCES_PASSPHRASE }} + working-directory: snippets run: | if [ -z "$RESOURCES_PASSPHRASE" ]; then - echo "::error::RESOURCES_PASSPHRASE secret is not set" + echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 exit 1 fi - mkdir -p /tmp/snipsrc - tar -xzf "${GITHUB_WORKSPACE}/snippets/resources.tar.gz.enc" -C /tmp/snipsrc - openssl enc -d -aes-256-cbc -pbkdf2 \ - -in /tmp/snipsrc/resources.tar.gz.enc \ - -out /tmp/snipsrc/resources.tar.gz \ - -pass env:RESOURCES_PASSPHRASE - tar -xzf /tmp/snipsrc/resources.tar.gz -C /tmp/snipsrc - mkdir -p snippets/tests/resources - cp -r /tmp/snipsrc/. snippets/tests/resources/ + mkdir -p tests/resources + gpg --batch --yes --pinentry-mode loopback \ + --passphrase "$RESOURCES_PASSPHRASE" \ + -d tests/resources.tar.xz.asc | tar -xJ -C tests/resources --strip-components=1 - name: Install GNU time run: | From 6f12526e951b9d7fff83f08be07c8ed5d4b87a90 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:55:26 +0100 Subject: [PATCH 15/20] h2h-cpu-mem: Pin counts instructions only, RSS for memory, run after rust Drop the malloc-family tracing from the instrumentation leg: the inscount tool is a pure instruction counter again (lone Count line), the pin_allocated_bytes summary signal and the h2h-compare alloc metric go away with their goldens, and the rs-from/rs-to instrumented gate is --metrics ins. Memory is RSS (maxrss_kb from the timed legs) only. Suite pins move to the ins-only measure-phase heads (snippet pin-inscount-measure, algos pin-inscount). Separate the gate from rust.yml: no more pull_request trigger. h2h-cpu-mem fires on workflow_run when the rust workflow completes green for a PR, so broken revisions never burn the ~100 instrumented legs and the sweep never starves the Rust signal. Non-PR rust completions skip in resolve; checkouts pin the tested PR head (workflow_run defaults to master); the concurrency group keys on the triggering branch. workflow_run subscriptions only exist post-merge, so pre-merge validation uses workflow_dispatch. --- .github/workflows/h2h-cpu-mem.yml | 122 ++++++++++++------ scripts/ci/go/cmd/h2h-compare/compare.go | 9 +- scripts/ci/go/cmd/h2h-compare/main.go | 5 +- scripts/ci/go/cmd/h2h-compare/main_test.go | 25 ---- .../h2h-compare/testdata/base-shard-0.json | 25 ---- .../h2h-compare/testdata/base-shard-1.json | 25 ---- .../ci/go/cmd/h2h-compare/testdata/chal.json | 45 ------- .../cmd/h2h-compare/testdata/golden-alloc.txt | 28 ---- .../testdata/golden-merged-base.json | 50 ------- .../testdata/golden-merged-chal.json | 45 ------- .../testdata/golden-summary-alloc.md | 20 --- scripts/ci/instrumentation/inscount.cpp | 67 +--------- 12 files changed, 92 insertions(+), 374 deletions(-) delete mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt delete mode 100644 scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 007b7fdde3..f6bd277c5e 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -3,8 +3,8 @@ # and the algorithms suite (hhh-pa/clojure-algorithms). Each suite # runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), every # leg instrumented in a single sweep: GNU time (cpu_s, maxrss_kb) -# plus Intel Pin instruction + allocation counts, which feed a -# deterministic ins/alloc gate (1% margin) for rs-from vs rs-to, +# plus Intel Pin instruction counts, which feed a deterministic +# ins gate (1% margin) for rs-from vs rs-to, # immune to the wall-time wobble that reds the cpu/rsrs leg on # no-op changes. Sharding is asymmetric because Pin slows HotSpot # ~40x: 8 ways x 2 native runtimes (16 jobs/suite) vs 32 ways x @@ -13,14 +13,24 @@ # gate rule: clojure-rs must beat JVM Clojure, AND rs-from must # win-or-tie vs rs-to on every case — ANY loss fails the PR. # +# Triggering (separated from rust.yml): this gate does NOT run on +# pull_request. It fires on workflow_run when the rust workflow +# completes green for a PR, so broken revisions never burn the +# ~100 instrumented legs and the sweep never starves the Rust +# signal of runners. Non-PR rust completions (master, schedule) +# resolve to a skipped run: there is no PR to gate. workflow_run +# subscriptions only exist post-merge (GitHub reads triggers from +# the default branch), so pre-merge validation uses +# workflow_dispatch on this branch. +# # Revision pairs ("PR from head to base" parlance): from is the -# change under test, to is its baseline. On pull_request, from = -# PR head (github.sha, the merge commit CI tests), to = PR base -# SHA — stacked PRs compare against their parent branch -# automatically. On workflow_dispatch, from defaults to the -# dispatched ref and to to master, so dispatching on a branch -# compares vs ; the rs_from/rs_to inputs override -# either side (40-char SHA or any branch/tag, for bisection). +# change under test, to is its baseline. On a rust-passed run, +# from = the PR head the rust run tested, to = PR base SHA — +# stacked PRs compare against their parent branch automatically. +# On workflow_dispatch, from defaults to the dispatched ref and +# to to master, so dispatching on a branch compares vs +# ; the rs_from/rs_to inputs override either side +# (40-char SHA or any branch/tag, for bisection). # # This is a gate, not a tracker: it reports no Bencher history. # Triage mode: dispatch with report_only=true to get the verdict @@ -44,47 +54,53 @@ name: h2h-cpu-mem on: - pull_request: + workflow_run: + workflows: [rust] + types: [completed] + branches-ignore: [master] workflow_dispatch: inputs: snippets_sha: - description: 'Pin clojure-code-snippet to this revision (40-char SHA; empty = pinned default)' + description: 'Pin clojure-code-snippet to this revision (dispatch only; 40-char SHA; empty = pinned default)' required: false default: '' algorithms_sha: - description: 'Pin clojure-algorithms to this revision (40-char SHA; empty = pinned default)' + description: 'Pin clojure-algorithms to this revision (dispatch only; 40-char SHA; empty = pinned default)' required: false default: '' rs_from: - description: 'Native-vs-native head: 40-char SHA or branch/tag (empty = PR head, or dispatched ref)' + description: 'Native-vs-native head: 40-char SHA or branch/tag (dispatch only; empty = dispatched ref)' required: false default: '' rs_to: - description: 'Native-vs-native base: 40-char SHA or branch/tag (empty = PR base, or master on dispatch)' + description: 'Native-vs-native base: 40-char SHA or branch/tag (dispatch only; empty = master)' required: false default: '' report_only: - description: 'Triage mode: print verdicts but keep the gate green' + description: 'Triage mode: print verdicts but keep the gate green (dispatch only)' required: false default: false type: boolean -# One run per branch at a time, like rust.yml: a new push -# supersedes the in-flight sweep instead of queueing behind it. +# One run per branch at a time, like rust.yml: a new rust-passed +# run supersedes the in-flight sweep instead of queueing behind +# it. head_ref is empty on workflow_run, so the triggering PR +# branch leads the group (dispatched runs fall back as before). concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} cancel-in-progress: true permissions: contents: read env: - # Pinned suite revisions: the pin-inscount branches (snippet PR#52, - # algos PR#7) until they merge, then back to master SHAs. The --pin - # legs below need harness support, so old-SHA bisection of the - # instrumentation legs fails loud on the unknown flag by design. - SNIPPETS_SHA_DEFAULT: 3b9c524f2d19469040b1aef8c6fa99597d1c2a9b - ALGORITHMS_SHA_DEFAULT: 005645115db4331b93637deb941b7c5ef43a88f0 + # Pinned suite revisions: ins-only measure-phase heads (snippet + # pin-inscount-measure branch, algos pin-inscount) until they + # merge, then back to master SHAs. The --pin legs below need + # harness support, so old-SHA bisection of the instrumentation + # legs fails loud on the unknown flag by design. + SNIPPETS_SHA_DEFAULT: 4d53f3bc665348505c7191497fc9f567abf3085b + ALGORITHMS_SHA_DEFAULT: bc8e7223bfa4c7ba7a940171ecd5d9c8e478eafb # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. JAVA_VERSION: '21.0.13' @@ -94,11 +110,14 @@ jobs: # Resolve the suite revisions AND the native-vs-native revision # pair ONCE per run so all 96 measure legs sweep the same commits. # Suite defaults: the pinned SHAs above; a manual dispatch may pin - # either input instead (bisection). Native pair: PR head/base by - # default on pull_request (stacked PRs diff against their parent), + # either input instead (bisection). Native pair: rust-passed PR + # head/base by default (stacked PRs diff against their parent), # dispatched-ref/master on workflow_dispatch; rs_from/rs_to take a # 40-char SHA or any branch/tag. + # Runs only for green PR rust runs (plus manual dispatch): + # anything else skips here, which skips the whole sweep. resolve: + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest outputs: snippets_sha: ${{ steps.resolve.outputs.snippets_sha }} @@ -114,13 +133,12 @@ jobs: [ -z "$algos" ] && algos="${{ env.ALGORITHMS_SHA_DEFAULT }}" from="${{ inputs.rs_from }}" to="${{ inputs.rs_to }}" - [ -z "$from" ] && from="${{ github.sha }}" - if [ -z "$to" ]; then - if [ "${{ github.event_name }}" = "pull_request" ]; then - to="${{ github.event.pull_request.base.sha }}" - else - to="master" - fi + if [ "${{ github.event_name }}" = "workflow_run" ]; then + [ -z "$from" ] && from="${{ github.event.workflow_run.head_sha }}" + [ -z "$to" ] && to="${{ github.event.workflow_run.pull_requests[0].base.sha }}" + else + [ -z "$from" ] && from="${{ github.sha }}" + [ -z "$to" ] && to="master" fi to_sha() { # Print the 40-char SHA for a SHA-or-ref input. @@ -154,7 +172,8 @@ jobs: # The two native revisions under test (interpreter-only release: # `run` needs no LLVM), each fanned out to its 16 measure legs as # an artifact. rs-from is what the JVM gate tests; rs-to is its - # comparison base (PR base, or master on dispatch). + # comparison base (PR base on rust-passed runs, master on + # dispatch). build-rs: needs: [resolve] runs-on: ubuntu-latest @@ -263,6 +282,9 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: + # workflow_run defaults to master; pin the tested PR head + # (empty on dispatch, which is the dispatched ref). + ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -378,6 +400,9 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: + # workflow_run defaults to master; pin the tested PR head + # (empty on dispatch, which is the dispatched ref). + ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -470,6 +495,9 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: + # workflow_run defaults to master; pin the tested PR head + # (empty on dispatch, which is the dispatched ref). + ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -568,6 +596,9 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: + # workflow_run defaults to master; pin the tested PR head + # (empty on dispatch, which is the dispatched ref). + ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -605,13 +636,14 @@ jobs: # Fan-in: union the shards per leg and diff the legs. Strict # across-the-board gate, both directions: JVM vs rs-from AND # rs-from vs rs-to on cpu/mem, plus rs-from vs rs-to on - # instrumented instruction counts and allocated bytes (1% - # margin) — any loss (or failure, missing, not-ok, drift, leg - # mismatch) fails the PR. The native legs carry the instrument - # signals in the same shard JSONs (no second sweep), so the ins - # comparisons below reuse the h2h-* legs and write no extra - # merged files. No Bencher reporting by design: this job gates, - # it does not track history. + # instrumented instruction counts (1% margin) — any loss (or + # failure, missing, not-ok, drift, leg mismatch) fails the PR. + # Memory is RSS (maxrss_kb) only: the Pin tool counts + # instructions, never allocations. The native legs carry the + # instrument signal in the same shard JSONs (no second sweep), + # so the ins comparisons below reuse the h2h-* legs and write + # no extra merged files. No Bencher reporting by design: this + # job gates, it does not track history. h2h-compare: name: h2h cpu/mem gate needs: [measure-recipes, measure-recipes-jvm, measure-algos, measure-algos-jvm] @@ -622,6 +654,10 @@ jobs: actions: write # shard download + merged upload steps: - uses: actions/checkout@v5 + with: + # workflow_run defaults to master; pin the tested PR head + # (empty on dispatch, which is the dispatched ref). + ref: ${{ github.event.workflow_run.head_sha }} - name: Download shard results uses: actions/download-artifact@v6 @@ -682,14 +718,14 @@ jobs: --base-name rs-to --challenger-name rs-from \ --jvm h2h-recipes-rs-to-shard-*/h2h-recipes-rs-to-shard-*.json \ --rs h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ - --metrics ins,alloc --margin 0.01 \ + --metrics ins --margin 0.01 \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? $compare \ --label algos-rsrs-ins \ --base-name rs-to --challenger-name rs-from \ --jvm h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ - --metrics ins,alloc --margin 0.01 \ + --metrics ins --margin 0.01 \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? exit $rc diff --git a/scripts/ci/go/cmd/h2h-compare/compare.go b/scripts/ci/go/cmd/h2h-compare/compare.go index c2671d1a8d..740e27ace7 100644 --- a/scripts/ci/go/cmd/h2h-compare/compare.go +++ b/scripts/ci/go/cmd/h2h-compare/compare.go @@ -34,10 +34,9 @@ type metric struct { const pinKey = "pin_instructions" var metrics = map[string]metric{ - "cpu": {key: "cpu_s", tag: "CPU", unit: "s", summary: "median cpu_s", format: fmt3}, - "mem": {key: "maxrss_kb", tag: "MEM", unit: "K", summary: "median maxrss_kb", format: fmt0}, - "ins": {key: "instructions", tag: "INS", unit: "", summary: "median instructions", format: fmtComma}, - "alloc": {key: "pin_allocated_bytes", tag: "ALLOC", unit: "B", summary: "median allocated bytes", format: fmtComma}, + "cpu": {key: "cpu_s", tag: "CPU", unit: "s", summary: "median cpu_s", format: fmt3}, + "mem": {key: "maxrss_kb", tag: "MEM", unit: "K", summary: "median maxrss_kb", format: fmt0}, + "ins": {key: "instructions", tag: "INS", unit: "", summary: "median instructions", format: fmtComma}, } // fmt3 renders a ratio like Python's f"{x:.3f}" (including inf). @@ -620,7 +619,7 @@ func compareLegs(o options, jm, rm map[caseKey]entry, jFail, rFail, jMiss, rMiss } for _, m := range selected { if _, ok := metrics[m]; !ok { - fmt.Fprintf(os.Stderr, "h2h_compare: unknown metric '%s' (want csv of cpu,mem,ins,alloc)\n", m) + fmt.Fprintf(os.Stderr, "h2h_compare: unknown metric '%s' (want csv of cpu,mem,ins)\n", m) return 2 } } diff --git a/scripts/ci/go/cmd/h2h-compare/main.go b/scripts/ci/go/cmd/h2h-compare/main.go index 42daf98a3c..53d069700e 100644 --- a/scripts/ci/go/cmd/h2h-compare/main.go +++ b/scripts/ci/go/cmd/h2h-compare/main.go @@ -1,6 +1,7 @@ // Command h2h-compare is the head-to-head gate: reference leg vs -// challenger leg on cpu_s + maxrss_kb (+ pin_instructions and -// pin_allocated_bytes via --metrics). +// challenger leg on cpu_s + maxrss_kb (+ pin_instructions via +// --metrics ins). Memory is RSS only; the Pin tool counts +// instructions, never allocations. // // Usage: // diff --git a/scripts/ci/go/cmd/h2h-compare/main_test.go b/scripts/ci/go/cmd/h2h-compare/main_test.go index 12b0a562f5..91a1844ee7 100644 --- a/scripts/ci/go/cmd/h2h-compare/main_test.go +++ b/scripts/ci/go/cmd/h2h-compare/main_test.go @@ -85,31 +85,6 @@ func TestGoldenIns(t *testing.T) { } } -func TestGoldenAlloc(t *testing.T) { - bin := cmdtest.Build(t, ".") - work := t.TempDir() - summary := filepath.Join(work, "summary.md") - stdout, code := cmdtest.Run(t, bin, ".", - "--label", "recipes-alloc", - "--jvm", "testdata/base-shard-0.json", "testdata/base-shard-1.json", - "--rs", "testdata/chal.json", - "--metrics", "alloc", "--margin", "0.01", - "--summary-file", summary) - if code != 1 { - t.Fatalf("exit = %d, want 1", code) - } - if stdout != cmdtest.Golden(t, "testdata/golden-alloc.txt") { - t.Fatalf("stdout differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-alloc.txt"), stdout)) - } - gotSummary, err := os.ReadFile(summary) - if err != nil { - t.Fatal(err) - } - if string(gotSummary) != cmdtest.Golden(t, "testdata/golden-summary-alloc.md") { - t.Fatalf("summary differs from golden:\n%s", diffText(cmdtest.Golden(t, "testdata/golden-summary-alloc.md"), string(gotSummary))) - } -} - func TestGoldenReportOnly(t *testing.T) { bin := cmdtest.Build(t, ".") stdout, code := cmdtest.Run(t, bin, ".", diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json index 283b77f331..67098a0417 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-0.json @@ -43,11 +43,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -84,11 +79,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -125,11 +115,6 @@ "min": 2990000, "median": 3000000, "max": 3010000 - }, - "pin_allocated_bytes": { - "min": 297000, - "median": 300000, - "max": 303000 } } }, @@ -166,11 +151,6 @@ "min": 100, "median": 100, "max": 100 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, @@ -218,11 +198,6 @@ "min": 990, "median": 1000, "max": 1010 - }, - "pin_allocated_bytes": { - "min": 990, - "median": 1000, - "max": 1010 } } } diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json index 7a65e0de6d..e37e233cfb 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/base-shard-1.json @@ -41,11 +41,6 @@ "min": 490000, "median": 500000, "max": 510000 - }, - "pin_allocated_bytes": { - "min": 49500, - "median": 50000, - "max": 50500 } } }, @@ -82,11 +77,6 @@ "min": 99000, "median": 100000, "max": 101000 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, @@ -134,11 +124,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -179,11 +164,6 @@ "min": 1990000, "median": 2000000, "max": 2010000 - }, - "pin_allocated_bytes": { - "min": 198000, - "median": 200000, - "max": 202000 } } }, @@ -224,11 +204,6 @@ "min": 3990000, "median": 4000000, "max": 4010000 - }, - "pin_allocated_bytes": { - "min": 396000, - "median": 400000, - "max": 404000 } } } diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/chal.json b/scripts/ci/go/cmd/h2h-compare/testdata/chal.json index 803b121991..12a407330d 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/chal.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/chal.json @@ -43,11 +43,6 @@ "min": 99000, "median": 100000, "max": 101000 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, @@ -84,11 +79,6 @@ "min": 2490000, "median": 2500000, "max": 2510000 - }, - "pin_allocated_bytes": { - "min": 247500, - "median": 250000, - "max": 252500 } } }, @@ -125,11 +115,6 @@ "min": 2990000, "median": 3000000, "max": 3010000 - }, - "pin_allocated_bytes": { - "min": 297000, - "median": 300000, - "max": 303000 } } }, @@ -166,11 +151,6 @@ "min": 100, "median": 100, "max": 100 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, @@ -207,11 +187,6 @@ "min": 390000, "median": 400000, "max": 410000 - }, - "pin_allocated_bytes": { - "min": 39600, - "median": 40000, - "max": 40400 } } }, @@ -248,11 +223,6 @@ "min": 100000, "median": 100500, "max": 101000 - }, - "pin_allocated_bytes": { - "min": 9950, - "median": 10050, - "max": 10150 } } }, @@ -315,11 +285,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -360,11 +325,6 @@ "min": 1990000, "median": 2000000, "max": 2010000 - }, - "pin_allocated_bytes": { - "min": 198000, - "median": 200000, - "max": 202000 } } }, @@ -401,11 +361,6 @@ "min": 490, "median": 500, "max": 510 - }, - "pin_allocated_bytes": { - "min": 495, - "median": 500, - "max": 505 } } } diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt b/scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt deleted file mode 100644 index 72265b9960..0000000000 --- a/scripts/ci/go/cmd/h2h-compare/testdata/golden-alloc.txt +++ /dev/null @@ -1,28 +0,0 @@ -== recipes-alloc == -jvm: shards=2 results=11 failures=2 missing=1 -rs: shards=1 results=11 failures=1 missing=1 -common cases: 10 only-jvm: 1 only-rs: 1 - only-jvm: aaa/only_here_base/solution/test_case_0 - only-rs: aaa/only_here_chal/solution/test_case_0 -compared ok-cases: 8 -ALLOC (median): rs wins 4, losses 1, ties 3 -both-win cases: 4/8 -worst rs alloc ratio: 2.500x (sort/bubble_sort/test_case_1) ---- ALLOC LOSSES (1) --- - 2.500x sort/bubble_sort/test_case_1 jvm=100,000B rs=250,000B ---- SKIPPED-NO-RUNS (1) --- - zzz/skipped_thing/test_case_9 jvm_runs=0 rs_runs=0 ---- NOT-OK (1) --- - sort/broken_sort/test_case_0 jvm_err=None rs_err=exit 1: boom ---- TEST-COUNT DRIFT (1) --- - my.ns/foo-test jvm={'pass': 10, 'test': 10, 'fail': 0, 'error': 0} rs={'pass': 9, 'test': 10, 'fail': 1, 'error': 0} ---- JVM failures (2) --- - aardvark-fail-b - snap-fail-b ---- RS failures (1) --- - snap-fail-c ---- JVM missing (1) --- - m-base-1 ---- RS missing (1) --- - m-chal-1 -GATE recipes-alloc: FAIL: 1 ALLOC losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-jvm 1, only-rs 1); sweep failures (jvm 2, rs 1); missing cases (jvm 1, rs 1) diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json index b424742943..865579de6f 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-base.json @@ -44,11 +44,6 @@ "min": 990, "median": 1000, "max": 1010 - }, - "pin_allocated_bytes": { - "min": 990, - "median": 1000, - "max": 1010 } } }, @@ -89,11 +84,6 @@ "min": 3990000, "median": 4000000, "max": 4010000 - }, - "pin_allocated_bytes": { - "min": 396000, - "median": 400000, - "max": 404000 } } }, @@ -134,11 +124,6 @@ "min": 1990000, "median": 2000000, "max": 2010000 - }, - "pin_allocated_bytes": { - "min": 198000, - "median": 200000, - "max": 202000 } } }, @@ -175,11 +160,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -216,11 +196,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -257,11 +232,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -298,11 +268,6 @@ "min": 2990000, "median": 3000000, "max": 3010000 - }, - "pin_allocated_bytes": { - "min": 297000, - "median": 300000, - "max": 303000 } } }, @@ -339,11 +304,6 @@ "min": 100, "median": 100, "max": 100 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, @@ -380,11 +340,6 @@ "min": 490000, "median": 500000, "max": 510000 - }, - "pin_allocated_bytes": { - "min": 49500, - "median": 50000, - "max": 50500 } } }, @@ -421,11 +376,6 @@ "min": 99000, "median": 100000, "max": 101000 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json index 38a7d6bc0b..c8a97cb9ad 100644 --- a/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json +++ b/scripts/ci/go/cmd/h2h-compare/testdata/golden-merged-chal.json @@ -43,11 +43,6 @@ "min": 490, "median": 500, "max": 510 - }, - "pin_allocated_bytes": { - "min": 495, - "median": 500, - "max": 505 } } }, @@ -88,11 +83,6 @@ "min": 1990000, "median": 2000000, "max": 2010000 - }, - "pin_allocated_bytes": { - "min": 198000, - "median": 200000, - "max": 202000 } } }, @@ -133,11 +123,6 @@ "min": 990000, "median": 1000000, "max": 1010000 - }, - "pin_allocated_bytes": { - "min": 99000, - "median": 100000, - "max": 101000 } } }, @@ -185,11 +170,6 @@ "min": 99000, "median": 100000, "max": 101000 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, @@ -226,11 +206,6 @@ "min": 2490000, "median": 2500000, "max": 2510000 - }, - "pin_allocated_bytes": { - "min": 247500, - "median": 250000, - "max": 252500 } } }, @@ -267,11 +242,6 @@ "min": 2990000, "median": 3000000, "max": 3010000 - }, - "pin_allocated_bytes": { - "min": 297000, - "median": 300000, - "max": 303000 } } }, @@ -308,11 +278,6 @@ "min": 100, "median": 100, "max": 100 - }, - "pin_allocated_bytes": { - "min": 9900, - "median": 10000, - "max": 10100 } } }, @@ -349,11 +314,6 @@ "min": 390000, "median": 400000, "max": 410000 - }, - "pin_allocated_bytes": { - "min": 39600, - "median": 40000, - "max": 40400 } } }, @@ -390,11 +350,6 @@ "min": 100000, "median": 100500, "max": 101000 - }, - "pin_allocated_bytes": { - "min": 9950, - "median": 10050, - "max": 10150 } } }, diff --git a/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md b/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md deleted file mode 100644 index 45f756e821..0000000000 --- a/scripts/ci/go/cmd/h2h-compare/testdata/golden-summary-alloc.md +++ /dev/null @@ -1,20 +0,0 @@ -## h2h recipes-alloc: jvm vs rs - -common cases: 8 (jvm shards 2, rs shards 1); both-win 4/8 - -| metric | rs wins | losses | ties | -| --- | ---: | ---: | ---: | -| ALLOC (median allocated bytes) | 4 | 1 | 3 | - -worst rs alloc ratio: 2.500x (sort/bubble_sort/test_case_1) - -### GATE: FAIL - -1 ALLOC losses; 1 not-ok cases; 1 test-count drifts; leg mismatch (only-jvm 1, only-rs 1); sweep failures (jvm 2, rs 1); missing cases (jvm 1, rs 1) - -
ALLOC losses (1, worst first) - -| ratio | case | jvm | rs | -| ---: | --- | ---: | ---: | -| 2.500x | sort/bubble_sort/test_case_1 | 100,000B | 250,000B | -
diff --git a/scripts/ci/instrumentation/inscount.cpp b/scripts/ci/instrumentation/inscount.cpp index 34d6d67cd2..88f8bbc151 100644 --- a/scripts/ci/instrumentation/inscount.cpp +++ b/scripts/ci/instrumentation/inscount.cpp @@ -1,92 +1,38 @@ // inscount: minimal Intel Pin counting tool for the h2h gate. -// Counts every executed instruction and every malloc-family -// requested byte (one run, two signals) and prints: +// Counts every executed instruction and prints: // // Count N -// Alloc M // -// The harness parsers (snip/algoperf ParsePinCount/ParsePinAlloc) -// read the last such lines. Build out of the kit with: +// The harness parsers (snip/algoperf ParsePinCount) read the last +// such line. Memory is benchmarked via RSS (GNU time maxrss_kb), +// never via this tool: it counts instructions only. Build out of +// the kit with: // // make PIN_ROOT= -C // // which drops obj-intel64/inscount.so next to this file. -// -// Allocation semantics: cumulative requested bytes (malloc size, -// calloc nmemb*size, realloc/memalign/aligned_alloc new size, -// posix_memalign size), counted unconditionally at the call entry -// point: an OOM failure kills the run, which fails loudly rather -// than reporting bad data. Matches the LD_PRELOAD counting-shim -// convention: realloc counts its full new size (over-approximates -// shrink/grow deltas), frees are ignored. Only the standard entry -// names are instrumented, never __libc_* aliases, so one app call -// counts once. #include #include "pin.H" static UINT64 total = 0; -static UINT64 allocated = 0; static FILE *out = nullptr; static KNOB KnobOutput(KNOB_MODE_WRITEONCE, "pintool", "o", "inscount.out", - "output file for the Count/Alloc lines"); + "output file for the Count line"); static VOID docount(UINT32 n) { __atomic_add_fetch(&total, n, __ATOMIC_RELAXED); } -static VOID onAlloc(ADDRINT size) { __atomic_add_fetch(&allocated, (UINT64)size, __ATOMIC_RELAXED); } - -static VOID onAllocProduct(ADDRINT a, ADDRINT b) { - __atomic_add_fetch(&allocated, (UINT64)a * (UINT64)b, __ATOMIC_RELAXED); -} - static VOID Instruction(INS ins, VOID *) { INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_UINT32, 1, IARG_END); } -// InstrumentAlloc1 counts one size argument (index arg) of the -// named routine at its entry point: malloc(size), -// realloc(ptr, size), memalign(align, size), -// aligned_alloc(align, size). -static VOID InstrumentAlloc1(IMG img, const char *name, int arg) { - RTN rtn = RTN_FindByName(img, name); - if (!RTN_Valid(rtn)) { - return; - } - RTN_Open(rtn); - RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)onAlloc, IARG_FUNCARG_ENTRYPOINT_VALUE, arg, IARG_END); - RTN_Close(rtn); -} - -static VOID Image(IMG img, VOID *) { - InstrumentAlloc1(img, "malloc", 0); - InstrumentAlloc1(img, "realloc", 1); - InstrumentAlloc1(img, "memalign", 1); - InstrumentAlloc1(img, "aligned_alloc", 1); - RTN rtn = RTN_FindByName(img, "calloc"); - if (RTN_Valid(rtn)) { - RTN_Open(rtn); - RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)onAllocProduct, IARG_FUNCARG_ENTRYPOINT_VALUE, 0, - IARG_FUNCARG_ENTRYPOINT_VALUE, 1, IARG_END); - RTN_Close(rtn); - } - // posix_memalign(&ptr, align, size): size is argument 2. - rtn = RTN_FindByName(img, "posix_memalign"); - if (RTN_Valid(rtn)) { - RTN_Open(rtn); - RTN_InsertCall(rtn, IPOINT_BEFORE, (AFUNPTR)onAlloc, IARG_FUNCARG_ENTRYPOINT_VALUE, 2, IARG_END); - RTN_Close(rtn); - } -} - static VOID Fini(INT32, VOID *) { if (out != nullptr) { fprintf(out, "Count %llu\n", (unsigned long long)total); - fprintf(out, "Alloc %llu\n", (unsigned long long)allocated); fclose(out); } } int main(int argc, char *argv[]) { - PIN_InitSymbols(); if (PIN_Init(argc, argv)) { return 1; } @@ -95,7 +41,6 @@ int main(int argc, char *argv[]) { return 1; } INS_AddInstrumentFunction(Instruction, nullptr); - IMG_AddInstrumentFunction(Image, nullptr); PIN_AddFiniFunction(Fini, nullptr); PIN_StartProgram(); return 0; From 08d3c845724ae15ac94878edcb4fa1d692d43710 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:57:50 +0100 Subject: [PATCH 16/20] h2h-cpu-mem: pin snippets to 8466c12 (BMF/summary pin_insn) --- .github/workflows/h2h-cpu-mem.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index f6bd277c5e..2182f5d643 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -99,7 +99,7 @@ env: # merge, then back to master SHAs. The --pin legs below need # harness support, so old-SHA bisection of the instrumentation # legs fails loud on the unknown flag by design. - SNIPPETS_SHA_DEFAULT: 4d53f3bc665348505c7191497fc9f567abf3085b + SNIPPETS_SHA_DEFAULT: 8466c12b56c059a2f93e264b24aced02b7f448cf ALGORITHMS_SHA_DEFAULT: bc8e7223bfa4c7ba7a940171ecd5d9c8e478eafb # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. From a035202c3805ced26c508d4da9781c4701eba4a2 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:59:25 +0100 Subject: [PATCH 17/20] h2h-cpu-mem: pin snippets to 98612a1 (snip measure summary header) --- .github/workflows/h2h-cpu-mem.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 2182f5d643..da57ce42c6 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -99,7 +99,7 @@ env: # merge, then back to master SHAs. The --pin legs below need # harness support, so old-SHA bisection of the instrumentation # legs fails loud on the unknown flag by design. - SNIPPETS_SHA_DEFAULT: 8466c12b56c059a2f93e264b24aced02b7f448cf + SNIPPETS_SHA_DEFAULT: 98612a1f52cd5fd097904e9ac6dcd9282cad2c20 ALGORITHMS_SHA_DEFAULT: bc8e7223bfa4c7ba7a940171ecd5d9c8e478eafb # Pinned (not floating "21"): JDK micro versions move cpu numbers # and the sweep must stay comparable run to run. Bump deliberately. From 2c65abe34f0f470274bb8aa31716f51834680367 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:02:15 +0100 Subject: [PATCH 18/20] h2h-cpu-mem: gate the sweep on rust.yml via wait-rust Replace the workflow_run trigger (whose subscriptions only exist post-merge, leaving the PR with no chaining at all) with an in-workflow prerequisite: the wait-rust job polls the rust workflow's conclusion for the same SHA and the whole sweep waits behind resolve. Broken revisions fail fast instead of burning ~100 instrumented legs, and every job still reports as a check next to the commit, so a red gate reds the PR. Dispatch skips the wait for ad-hoc SHAs. --- .github/workflows/h2h-cpu-mem.yml | 144 ++++++++++++++++++------------ 1 file changed, 85 insertions(+), 59 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index da57ce42c6..279e8a43d1 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -13,24 +13,26 @@ # gate rule: clojure-rs must beat JVM Clojure, AND rs-from must # win-or-tie vs rs-to on every case — ANY loss fails the PR. # -# Triggering (separated from rust.yml): this gate does NOT run on -# pull_request. It fires on workflow_run when the rust workflow -# completes green for a PR, so broken revisions never burn the -# ~100 instrumented legs and the sweep never starves the Rust -# signal of runners. Non-PR rust completions (master, schedule) -# resolve to a skipped run: there is no PR to gate. workflow_run -# subscriptions only exist post-merge (GitHub reads triggers from -# the default branch), so pre-merge validation uses -# workflow_dispatch on this branch. +# Triggering (rust.yml is the prerequisite): this gate runs on +# pull_request, but every leg waits behind the wait-rust job, +# which polls the rust workflow's conclusion for the same SHA and +# proceeds only on success — broken revisions fail fast instead of +# burning the ~100 instrumented legs, and the sweep never starts +# ahead of the Rust signal. (A workflow_run trigger was tried and +# dropped: its subscriptions only exist post-merge, leaving the PR +# itself with no chaining at all.) If this gate fails, the PR +# fails with it: all jobs report as checks next to the commit. # # Revision pairs ("PR from head to base" parlance): from is the -# change under test, to is its baseline. On a rust-passed run, -# from = the PR head the rust run tested, to = PR base SHA — -# stacked PRs compare against their parent branch automatically. -# On workflow_dispatch, from defaults to the dispatched ref and -# to to master, so dispatching on a branch compares vs -# ; the rs_from/rs_to inputs override either side -# (40-char SHA or any branch/tag, for bisection). +# change under test, to is its baseline. On pull_request, from = +# PR head (github.sha, the merge commit CI tests), to = PR base +# SHA — stacked PRs compare against their parent branch +# automatically. On workflow_dispatch, from defaults to the +# dispatched ref and to to master, so dispatching on a branch +# compares vs ; the rs_from/rs_to inputs override +# either side (40-char SHA or any branch/tag, for bisection). +# Dispatch skips the rust wait (there may be no rust run for an +# ad-hoc SHA); it exists for triage and bisection. # # This is a gate, not a tracker: it reports no Bencher history. # Triage mode: dispatch with report_only=true to get the verdict @@ -54,40 +56,35 @@ name: h2h-cpu-mem on: - workflow_run: - workflows: [rust] - types: [completed] - branches-ignore: [master] + pull_request: workflow_dispatch: inputs: snippets_sha: - description: 'Pin clojure-code-snippet to this revision (dispatch only; 40-char SHA; empty = pinned default)' + description: 'Pin clojure-code-snippet to this revision (40-char SHA; empty = pinned default)' required: false default: '' algorithms_sha: - description: 'Pin clojure-algorithms to this revision (dispatch only; 40-char SHA; empty = pinned default)' + description: 'Pin clojure-algorithms to this revision (40-char SHA; empty = pinned default)' required: false default: '' rs_from: - description: 'Native-vs-native head: 40-char SHA or branch/tag (dispatch only; empty = dispatched ref)' + description: 'Native-vs-native head: 40-char SHA or branch/tag (empty = PR head, or dispatched ref)' required: false default: '' rs_to: - description: 'Native-vs-native base: 40-char SHA or branch/tag (dispatch only; empty = master)' + description: 'Native-vs-native base: 40-char SHA or branch/tag (empty = PR base, or master on dispatch)' required: false default: '' report_only: - description: 'Triage mode: print verdicts but keep the gate green (dispatch only)' + description: 'Triage mode: print verdicts but keep the gate green' required: false default: false type: boolean -# One run per branch at a time, like rust.yml: a new rust-passed -# run supersedes the in-flight sweep instead of queueing behind -# it. head_ref is empty on workflow_run, so the triggering PR -# branch leads the group (dispatched runs fall back as before). +# One run per branch at a time, like rust.yml: a new push +# supersedes the in-flight sweep instead of queueing behind it. concurrency: - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true permissions: @@ -107,17 +104,62 @@ env: JARS_CACHE_KEY: clojure-jars-1.12.0-spec0.5.238-core0.4.74-v1 jobs: + # Prerequisite gate: hold the whole sweep until the rust workflow + # concludes on this exact SHA, and proceed only on success. The + # legs behind resolve queue without consuming runners while this + # polls (one tiny runner, 60s cadence). Re-resolves the newest + # rust run every iteration so a re-run supersedes a stale + # conclusion; any non-success conclusion fails fast. The 600min + # cap matches rust's longest leg (gates-shard). Dispatch skips + # the wait: ad-hoc SHAs may have no rust run at all. + wait-rust: + runs-on: ubuntu-latest + timeout-minutes: 600 + permissions: + contents: read + actions: read # poll the rust workflow conclusion + steps: + - name: Wait for the rust signal + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "manual dispatch: skipping the rust prerequisite wait" + exit 0 + fi + sha="${{ github.sha }}" + echo "waiting for the rust workflow on $sha ..." + i=0 + while [ "$i" -lt 599 ]; do + i=$((i + 1)) + run_id="$(gh run list --repo hhh-pa/clojure --workflow rust.yml --commit "$sha" --limit 1 --json databaseId --jq '.[0].databaseId // empty')" + if [ -n "$run_id" ]; then + state="$(gh run view "$run_id" --repo hhh-pa/clojure --json status,conclusion --jq '"\(.status)/\(.conclusion // "")"')" + case "$state" in + completed/success) + echo "rust run $run_id green; proceeding" + exit 0 + ;; + completed/*) + echo "error: rust run $run_id concluded ${state#completed/}; failing fast" >&2 + exit 1 + ;; + esac + fi + sleep 60 + done + echo "error: timed out waiting for the rust workflow on $sha" >&2 + exit 1 + # Resolve the suite revisions AND the native-vs-native revision # pair ONCE per run so all 96 measure legs sweep the same commits. # Suite defaults: the pinned SHAs above; a manual dispatch may pin - # either input instead (bisection). Native pair: rust-passed PR - # head/base by default (stacked PRs diff against their parent), + # either input instead (bisection). Native pair: PR head/base by + # default on pull_request (stacked PRs diff against their parent), # dispatched-ref/master on workflow_dispatch; rs_from/rs_to take a # 40-char SHA or any branch/tag. - # Runs only for green PR rust runs (plus manual dispatch): - # anything else skips here, which skips the whole sweep. resolve: - if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') + needs: [wait-rust] runs-on: ubuntu-latest outputs: snippets_sha: ${{ steps.resolve.outputs.snippets_sha }} @@ -133,12 +175,13 @@ jobs: [ -z "$algos" ] && algos="${{ env.ALGORITHMS_SHA_DEFAULT }}" from="${{ inputs.rs_from }}" to="${{ inputs.rs_to }}" - if [ "${{ github.event_name }}" = "workflow_run" ]; then - [ -z "$from" ] && from="${{ github.event.workflow_run.head_sha }}" - [ -z "$to" ] && to="${{ github.event.workflow_run.pull_requests[0].base.sha }}" - else - [ -z "$from" ] && from="${{ github.sha }}" - [ -z "$to" ] && to="master" + [ -z "$from" ] && from="${{ github.sha }}" + if [ -z "$to" ]; then + if [ "${{ github.event_name }}" = "pull_request" ]; then + to="${{ github.event.pull_request.base.sha }}" + else + to="master" + fi fi to_sha() { # Print the 40-char SHA for a SHA-or-ref input. @@ -172,8 +215,7 @@ jobs: # The two native revisions under test (interpreter-only release: # `run` needs no LLVM), each fanned out to its 16 measure legs as # an artifact. rs-from is what the JVM gate tests; rs-to is its - # comparison base (PR base on rust-passed runs, master on - # dispatch). + # comparison base (PR base, or master on dispatch). build-rs: needs: [resolve] runs-on: ubuntu-latest @@ -282,9 +324,6 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: - # workflow_run defaults to master; pin the tested PR head - # (empty on dispatch, which is the dispatched ref). - ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -400,9 +439,6 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: - # workflow_run defaults to master; pin the tested PR head - # (empty on dispatch, which is the dispatched ref). - ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -495,9 +531,6 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: - # workflow_run defaults to master; pin the tested PR head - # (empty on dispatch, which is the dispatched ref). - ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -596,9 +629,6 @@ jobs: - name: Check out instrumentation tooling uses: actions/checkout@v5 with: - # workflow_run defaults to master; pin the tested PR head - # (empty on dispatch, which is the dispatched ref). - ref: ${{ github.event.workflow_run.head_sha }} path: h2h - name: Cache instrumentation kit @@ -654,10 +684,6 @@ jobs: actions: write # shard download + merged upload steps: - uses: actions/checkout@v5 - with: - # workflow_run defaults to master; pin the tested PR head - # (empty on dispatch, which is the dispatched ref). - ref: ${{ github.event.workflow_run.head_sha }} - name: Download shard results uses: actions/download-artifact@v6 From 6178586831aea881d175f1d8812e6108c3744567 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:03:01 +0100 Subject: [PATCH 19/20] h2h-cpu-mem: wait-rust polls the PR head SHA, not the merge commit gh run list --commit matches runs by head SHA; github.sha on pull_request is the merge commit, which matches nothing, so the wait looped forever. Also log a heartbeat line per iteration so the wait is visible instead of silent. --- .github/workflows/h2h-cpu-mem.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 279e8a43d1..88bbd2d8be 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -127,13 +127,17 @@ jobs: echo "manual dispatch: skipping the rust prerequisite wait" exit 0 fi - sha="${{ github.sha }}" - echo "waiting for the rust workflow on $sha ..." + # Runs key on the PR head, NOT the merge commit + # (github.sha): polling the merge SHA matches nothing. + sha="${{ github.event.pull_request.head.sha }}" + echo "waiting for the rust workflow on PR head $sha ..." i=0 while [ "$i" -lt 599 ]; do i=$((i + 1)) run_id="$(gh run list --repo hhh-pa/clojure --workflow rust.yml --commit "$sha" --limit 1 --json databaseId --jq '.[0].databaseId // empty')" - if [ -n "$run_id" ]; then + if [ -z "$run_id" ]; then + echo "[$(date -u +%H:%M:%S)] iteration $i: no rust run for $sha yet" + else state="$(gh run view "$run_id" --repo hhh-pa/clojure --json status,conclusion --jq '"\(.status)/\(.conclusion // "")"')" case "$state" in completed/success) @@ -144,6 +148,9 @@ jobs: echo "error: rust run $run_id concluded ${state#completed/}; failing fast" >&2 exit 1 ;; + *) + echo "[$(date -u +%H:%M:%S)] iteration $i: rust run $run_id $state" + ;; esac fi sleep 60 From f84bcaadab99f3e3edbf541270b2337876c292d3 Mon Sep 17 00:00:00 2001 From: Hao Hu <2564015+m-hu@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:33:46 +0100 Subject: [PATCH 20/20] h2h-cpu-mem: CPython baseline replaces JVM, algorithms suite dropped python-code-snippet mirrors clojure-code-snippet case by case, so per-case instruction counts and RSS compare directly: the recipes baseline leg now sweeps the python suite under Pin (32 shards, --label-style snip keys rows identically) instead of the JVM suite, and the gate reads clojure-rs must beat CPython. The algorithms suite legs and comparisons are removed entirely (48 measure legs total). Pins: snippet 98612a1, python b3f6994 (pin-inscount-h2h: GNU time timed phase, h2h-ready labels). wait-rust and revision resolution use the current repo instead of hardcoded hhh-pa/clojure so the gate works on forks. --- .github/workflows/h2h-cpu-mem.yml | 400 ++++++------------------------ 1 file changed, 81 insertions(+), 319 deletions(-) diff --git a/.github/workflows/h2h-cpu-mem.yml b/.github/workflows/h2h-cpu-mem.yml index 88bbd2d8be..59a5b6b714 100644 --- a/.github/workflows/h2h-cpu-mem.yml +++ b/.github/workflows/h2h-cpu-mem.yml @@ -1,23 +1,24 @@ -# Head-to-head CPU/RSS gate: clojure-rs revisions vs JVM Clojure -# and vs each other, across the snippet suite (hhh-pa/clojure-code-snippet) -# and the algorithms suite (hhh-pa/clojure-algorithms). Each suite -# runs a JVM leg plus TWO clojure-rs legs (rs-from, rs-to), every -# leg instrumented in a single sweep: GNU time (cpu_s, maxrss_kb) -# plus Intel Pin instruction counts, which feed a deterministic -# ins gate (1% margin) for rs-from vs rs-to, +# Head-to-head CPU/RSS gate: clojure-rs revisions vs CPython +# and vs each other, across the snippet suite (hhh-pa/clojure-code-snippet). +# The suite runs a CPython baseline leg plus TWO clojure-rs legs +# (rs-from, rs-to), every leg instrumented in a single sweep: GNU +# time (cpu_s, maxrss_kb) plus Intel Pin instruction counts, which +# feed a deterministic ins gate (1% margin) for rs-from vs rs-to, # immune to the wall-time wobble that reds the cpu/rsrs leg on -# no-op changes. Sharding is asymmetric because Pin slows HotSpot -# ~40x: 8 ways x 2 native runtimes (16 jobs/suite) vs 32 ways x -# JVM (32 jobs/suite), 96 measure jobs total; the h2h-compare job -# unions the shards per leg and gates on per-case medians. The -# gate rule: clojure-rs must beat JVM Clojure, AND rs-from must -# win-or-tie vs rs-to on every case — ANY loss fails the PR. +# no-op changes. python-code-snippet mirrors clojure-code-snippet +# case by case, so instruction counts and RSS compare directly. +# Sharding is asymmetric because Pin slows interpreters ~40x: +# 8 ways x 2 native runtimes (16 jobs) vs 32 ways x CPython (32 +# jobs), 48 measure jobs total; the h2h-compare job unions the +# shards per leg and gates on per-case medians. The gate rule: +# clojure-rs must beat CPython, AND rs-from must win-or-tie vs +# rs-to on every case — ANY loss fails the PR. # # Triggering (rust.yml is the prerequisite): this gate runs on # pull_request, but every leg waits behind the wait-rust job, # which polls the rust workflow's conclusion for the same SHA and # proceeds only on success — broken revisions fail fast instead of -# burning the ~100 instrumented legs, and the sweep never starts +# burning the ~50 instrumented legs, and the sweep never starts # ahead of the Rust signal. (A workflow_run trigger was tried and # dropped: its subscriptions only exist post-merge, leaving the PR # itself with no chaining at all.) If this gate fails, the PR @@ -40,18 +41,16 @@ # # Session recipe: the flags below encode the workstation h2h sweep # (PR225 test plan): --repeat 3 --no-alloc --no-perf --jobs 3, GNU -# time as the cpu/maxrss signal, temurin 21 + Clojure 1.12.0 jars -# for the JVM legs. +# time as the cpu/maxrss signal, CPython 3.11 for the baseline legs. # # Suite revisions pin to master SHAs (env defaults below); dispatch # inputs override either pin for bisection, mirroring the # clojure_rs_sha pattern of the snippet repos. # # REQUIRED SECRET: RESOURCES_PASSPHRASE (the snippet resource-bundle -# passphrase) at organization level with access to hhh-pa/clojure -# (visibility chosen when the secret was moved). The recipes legs -# fail loud without it. (The algorithms suite carries its -# resources/ in-repo and needs no decrypt.) +# passphrase), granted to this repo. Every leg decrypts its +# suite's tests/resources.tar.xz.asc with it and fails loud +# without it. name: h2h-cpu-mem @@ -63,8 +62,8 @@ on: description: 'Pin clojure-code-snippet to this revision (40-char SHA; empty = pinned default)' required: false default: '' - algorithms_sha: - description: 'Pin clojure-algorithms to this revision (40-char SHA; empty = pinned default)' + python_snippets_sha: + description: 'Pin python-code-snippet to this revision (40-char SHA; empty = pinned default)' required: false default: '' rs_from: @@ -92,16 +91,16 @@ permissions: env: # Pinned suite revisions: ins-only measure-phase heads (snippet - # pin-inscount-measure branch, algos pin-inscount) until they - # merge, then back to master SHAs. The --pin legs below need - # harness support, so old-SHA bisection of the instrumentation - # legs fails loud on the unknown flag by design. + # pin-inscount-measure branch, python pin-inscount-h2h) until + # they merge, then back to master SHAs. The --pin legs below + # need harness support, so old-SHA bisection of the + # instrumentation legs fails loud on the unknown flag by design. SNIPPETS_SHA_DEFAULT: 98612a1f52cd5fd097904e9ac6dcd9282cad2c20 - ALGORITHMS_SHA_DEFAULT: bc8e7223bfa4c7ba7a940171ecd5d9c8e478eafb - # Pinned (not floating "21"): JDK micro versions move cpu numbers - # and the sweep must stay comparable run to run. Bump deliberately. - JAVA_VERSION: '21.0.13' - JARS_CACHE_KEY: clojure-jars-1.12.0-spec0.5.238-core0.4.74-v1 + PYTHON_SNIPPETS_SHA_DEFAULT: b3f699443936f5b27216e06437f411885460e848 + # Pinned (not floating "3.11"): interpreter micro versions move + # cpu numbers and the sweep must stay comparable run to run. + # Bump deliberately. + PYTHON_VERSION: '3.11' jobs: # Prerequisite gate: hold the whole sweep until the rust workflow @@ -134,11 +133,11 @@ jobs: i=0 while [ "$i" -lt 599 ]; do i=$((i + 1)) - run_id="$(gh run list --repo hhh-pa/clojure --workflow rust.yml --commit "$sha" --limit 1 --json databaseId --jq '.[0].databaseId // empty')" + run_id="$(gh run list --repo ${{ github.repository }} --workflow rust.yml --commit "$sha" --limit 1 --json databaseId --jq '.[0].databaseId // empty')" if [ -z "$run_id" ]; then echo "[$(date -u +%H:%M:%S)] iteration $i: no rust run for $sha yet" else - state="$(gh run view "$run_id" --repo hhh-pa/clojure --json status,conclusion --jq '"\(.status)/\(.conclusion // "")"')" + state="$(gh run view "$run_id" --repo ${{ github.repository }} --json status,conclusion --jq '"\(.status)/\(.conclusion // "")"')" case "$state" in completed/success) echo "rust run $run_id green; proceeding" @@ -159,7 +158,7 @@ jobs: exit 1 # Resolve the suite revisions AND the native-vs-native revision - # pair ONCE per run so all 96 measure legs sweep the same commits. + # pair ONCE per run so all 48 measure legs sweep the same commits. # Suite defaults: the pinned SHAs above; a manual dispatch may pin # either input instead (bisection). Native pair: PR head/base by # default on pull_request (stacked PRs diff against their parent), @@ -170,16 +169,16 @@ jobs: runs-on: ubuntu-latest outputs: snippets_sha: ${{ steps.resolve.outputs.snippets_sha }} - algorithms_sha: ${{ steps.resolve.outputs.algorithms_sha }} + python_snippets_sha: ${{ steps.resolve.outputs.python_snippets_sha }} rs_from_sha: ${{ steps.resolve.outputs.rs_from_sha }} rs_to_sha: ${{ steps.resolve.outputs.rs_to_sha }} steps: - id: resolve run: | snippets="${{ inputs.snippets_sha }}" - algos="${{ inputs.algorithms_sha }}" + pysnippets="${{ inputs.python_snippets_sha }}" [ -z "$snippets" ] && snippets="${{ env.SNIPPETS_SHA_DEFAULT }}" - [ -z "$algos" ] && algos="${{ env.ALGORITHMS_SHA_DEFAULT }}" + [ -z "$pysnippets" ] && pysnippets="${{ env.PYTHON_SNIPPETS_SHA_DEFAULT }}" from="${{ inputs.rs_from }}" to="${{ inputs.rs_to }}" [ -z "$from" ] && from="${{ github.sha }}" @@ -195,25 +194,25 @@ jobs: if printf '%s' "$1" | grep -Eq '^[0-9a-f]{40}$'; then printf '%s' "$1" else - git ls-remote https://github.com/hhh-pa/clojure "$1" | awk '{print $1}' | head -n 1 + git ls-remote https://github.com/${{ github.repository }} "$1" | awk '{print $1}' | head -n 1 fi } from_sha="$(to_sha "$from")" to_sha="$(to_sha "$to")" - for sha in "$snippets" "$algos" "$from_sha" "$to_sha"; do + for sha in "$snippets" "$pysnippets" "$from_sha" "$to_sha"; do if ! printf '%s' "$sha" | grep -Eq '^[0-9a-f]{40}$'; then echo "error: resolved revision is not a 40-char SHA: '$sha'" >&2 exit 1 fi done echo "snippets_sha=$snippets" >> "$GITHUB_OUTPUT" - echo "algorithms_sha=$algos" >> "$GITHUB_OUTPUT" + echo "python_snippets_sha=$pysnippets" >> "$GITHUB_OUTPUT" echo "rs_from_sha=$from_sha" >> "$GITHUB_OUTPUT" echo "rs_to_sha=$to_sha" >> "$GITHUB_OUTPUT" { echo "suite revisions:" echo " clojure-code-snippet: \`$snippets\`" - echo " clojure-algorithms: \`$algos\`" + echo " python-code-snippet: \`$pysnippets\`" echo "native-vs-native revisions:" echo " from: \`$from_sha\` ($from)" echo " to: \`$to_sha\` ($to)" @@ -221,8 +220,8 @@ jobs: # The two native revisions under test (interpreter-only release: # `run` needs no LLVM), each fanned out to its 16 measure legs as - # an artifact. rs-from is what the JVM gate tests; rs-to is its - # comparison base (PR base, or master on dispatch). + # an artifact. rs-from is what the baseline gate tests; rs-to + # is its comparison base (PR base, or master on dispatch). build-rs: needs: [resolve] runs-on: ubuntu-latest @@ -259,10 +258,10 @@ jobs: retention-days: 1 # Recipes native legs: 8 shards x 2 runtimes (rs-from, rs-to). - # The JVM baseline lives in measure-recipes-jvm below (32 shards: - # Pin slows HotSpot ~40x). - # Full-suite workstation time was ~65min JVM / ~30min RS at - # --jobs 3, so ~10min/shard; the 60min cap is generous headroom. + # The CPython baseline lives in measure-recipes-python below (32 + # shards: Pin slows interpreters ~40x). + # Full-suite workstation time was ~30min RS at --jobs 3, so + # ~10min/shard; the 90min cap covers the Pin pass with margin. measure-recipes: needs: [resolve, build-rs] runs-on: ubuntu-latest @@ -303,7 +302,7 @@ jobs: working-directory: snippets run: | if [ -z "$RESOURCES_PASSPHRASE" ]; then - echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 + echo "error: the RESOURCES_PASSPHRASE secret (granted to ${{ github.repository }}) is not configured." >&2 exit 1 fi mkdir -p tests/resources @@ -365,72 +364,48 @@ jobs: path: snippets/h2h-recipes-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json retention-days: 7 - # JVM baseline for the recipes suite. Dedicated job (not a runtime row of - # measure-recipes) because the JVM needs its own shard fan-out: Pin slows - # HotSpot ~40x, so 32 shards and a single pin-repeat keep every shard under - # the 6h cap with margin. setarch -R (inside the pin launcher) keeps the - # JVM mmaps clear of Pin's reserved region under address randomization. - measure-recipes-jvm: + # CPython baseline for the recipes suite. Dedicated job (not a runtime + # row of measure-recipes) because the baseline needs its own shard + # fan-out: Pin slows CPython ~40x, so 32 shards keep every shard + # under the 6h cap with margin; recalibrate after the first green + # run. setarch -R (inside the pin launcher) keeps CPython's mmaps + # clear of Pin's reserved region under address randomization. + # python-code-snippet mirrors clojure-code-snippet case by case; + # --label-style snip keys the rows identically so instruction and + # RSS medians compare directly. + measure-recipes-python: needs: [resolve] runs-on: ubuntu-latest timeout-minutes: 360 permissions: contents: read # suite + instrumentation tooling checkouts - actions: write # jars + instrumentation caches + shard artifact + actions: write # instrumentation cache + shard artifact strategy: fail-fast: false matrix: # 0-based K/32. shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] - name: recipes instrumentation jvm (shard ${{ matrix.shard }}/32) + name: recipes instrumentation python (shard ${{ matrix.shard }}/32) steps: - name: Check out suite uses: actions/checkout@v5 with: - repository: hhh-pa/clojure-code-snippet - ref: ${{ needs.resolve.outputs.snippets_sha }} - path: snippets + repository: hhh-pa/python-code-snippet + ref: ${{ needs.resolve.outputs.python_snippets_sha }} + path: pysnippets - - name: Set up Go - uses: actions/setup-go@v6 + - name: Set up Python + uses: actions/setup-python@v5 with: - go-version-file: snippets/scripts/ci/go/go.mod - cache: false # stdlib-only module: nothing to cache but the build below - - - name: Build snip - run: | - cd snippets/scripts/ci/go - go build -o snip ./cmd/snip - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - - - name: Cache Clojure jars - id: jars-cache - uses: actions/cache@v5 - with: - path: snippets/.ci/jars - key: ${{ env.JARS_CACHE_KEY }} - - - name: Download Clojure jars - if: steps.jars-cache.outputs.cache-hit != 'true' - run: | - mkdir -p snippets/.ci/jars - base=https://repo1.maven.org/maven2 - curl -fsSL -o snippets/.ci/jars/clojure.jar "$base/org/clojure/clojure/1.12.0/clojure-1.12.0.jar" - curl -fsSL -o snippets/.ci/jars/spec.alpha.jar "$base/org/clojure/spec.alpha/0.5.238/spec.alpha-0.5.238.jar" - curl -fsSL -o snippets/.ci/jars/core.specs.alpha.jar "$base/org/clojure/core.specs.alpha/0.4.74/core.specs.alpha-0.4.74.jar" + python-version: ${{ env.PYTHON_VERSION }} - name: Decrypt and extract resources env: RESOURCES_PASSPHRASE: ${{ secrets.RESOURCES_PASSPHRASE }} - working-directory: snippets + working-directory: pysnippets run: | if [ -z "$RESOURCES_PASSPHRASE" ]; then - echo "error: the RESOURCES_PASSPHRASE secret (organization level, with access to hhh-pa/clojure) is not configured." >&2 + echo "error: the RESOURCES_PASSPHRASE secret (granted to ${{ github.repository }}) is not configured." >&2 exit 1 fi mkdir -p tests/resources @@ -460,230 +435,38 @@ jobs: bash h2h/scripts/ci/setup-instrumentation.sh - name: Measure head-to-head shard - working-directory: snippets + working-directory: pysnippets run: | - ./scripts/ci/go/snip measure --resources tests/resources \ - --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 300 \ - --runtime jvm \ + python3 tools/measure.py --resources tests/resources \ + --repeat 3 --jobs 3 --timeout 300 --label-style snip \ --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ - --pin-repeat 1 --pin-timeout 1500 \ + --pin-repeat 2 --pin-timeout 600 \ --shard ${{ matrix.shard }}/32 \ - --output "h2h-recipes-jvm-shard-${{ matrix.shard }}.json" \ + --output "h2h-recipes-python-shard-${{ matrix.shard }}.json" \ --summary-file "$GITHUB_STEP_SUMMARY" - name: Upload shard results if: always() uses: actions/upload-artifact@v6 with: - name: h2h-recipes-jvm-shard-${{ matrix.shard }} - path: snippets/h2h-recipes-jvm-shard-${{ matrix.shard }}.json - retention-days: 7 - - # Algos native legs: 8 shards x 2 runtimes (rs-from, rs-to). - # The per-case timeout is 1800s (vs 300s for recipes), hence the - # roomier job cap. No decrypt step: this suite carries its - # resources/ in-repo. The JVM baseline lives in - # measure-algos-jvm below (32 shards: Pin slows HotSpot ~40x). - measure-algos: - needs: [resolve, build-rs] - runs-on: ubuntu-latest - timeout-minutes: 90 - permissions: - contents: read # suite + instrumentation tooling checkouts - actions: write # instrumentation cache + shard artifact - strategy: - # Let every shard finish so partial artifacts upload for diagnosis. - fail-fast: false - matrix: - # 0-based K/8: algoperf measure --shard needs 0 <= K < N. - shard: [0, 1, 2, 3, 4, 5, 6, 7] - runtime: [rs-from, rs-to] - name: algos instrumentation ${{ matrix.runtime }} (shard ${{ matrix.shard }}/8) - steps: - - name: Check out algorithms suite - uses: actions/checkout@v5 - with: - repository: hhh-pa/clojure-algorithms - ref: ${{ needs.resolve.outputs.algorithms_sha }} - path: algos - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: algos/scripts/ci/go/go.mod - cache: false # stdlib-only module: nothing to cache but the build below - - - name: Build algoperf - run: | - cd algos/scripts/ci/go - go build -o algoperf ./cmd/algoperf - - - name: Install GNU time - run: | - sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - - - name: Use built clojure-rs - uses: actions/download-artifact@v6 - with: - name: cljrs-${{ matrix.runtime }} - path: cljrs-bin - - - name: Add clojure-rs to PATH - run: | - # download-artifact does not preserve the executable bit. - chmod +x "$GITHUB_WORKSPACE/cljrs-bin/clojure-rs" - echo "$GITHUB_WORKSPACE/cljrs-bin" >> "$GITHUB_PATH" - - - name: Check out instrumentation tooling - uses: actions/checkout@v5 - with: - path: h2h - - - name: Cache instrumentation kit - id: instrumentation-cache - uses: actions/cache@v5 - with: - path: ~/instrumentation - key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - - - name: Provision instrumentation - run: | - bash h2h/scripts/ci/setup-instrumentation.sh - - - name: Measure head-to-head shard - working-directory: algos - run: | - ./scripts/ci/go/algoperf measure \ - --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ - --runtime "${{ matrix.runtime }}" \ - --command-template 'clojure-rs run perf/driver.clj {ns} src test resources' \ - --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ - --pin-repeat 2 --pin-timeout 1200 \ - --shard ${{ matrix.shard }}/8 \ - --output "h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json" \ - --summary-file "$GITHUB_STEP_SUMMARY" - - - name: Upload shard results - if: always() - uses: actions/upload-artifact@v6 - with: - name: h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }} - path: algos/h2h-algos-${{ matrix.runtime }}-shard-${{ matrix.shard }}.json - retention-days: 7 - - # JVM baseline for the algos suite. Dedicated 32-shard job for the - # same Pin-on-HotSpot cost reason as measure-recipes-jvm (see above). - measure-algos-jvm: - needs: [resolve] - runs-on: ubuntu-latest - timeout-minutes: 360 - permissions: - contents: read # suite + instrumentation tooling checkouts - actions: write # jars + instrumentation caches + shard artifact - strategy: - fail-fast: false - matrix: - # 0-based K/32. - shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] - name: algos instrumentation jvm (shard ${{ matrix.shard }}/32) - steps: - - name: Check out algorithms suite - uses: actions/checkout@v5 - with: - repository: hhh-pa/clojure-algorithms - ref: ${{ needs.resolve.outputs.algorithms_sha }} - path: algos - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: algos/scripts/ci/go/go.mod - cache: false # stdlib-only module: nothing to cache but the build below - - - name: Build algoperf - run: | - cd algos/scripts/ci/go - go build -o algoperf ./cmd/algoperf - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - - - name: Cache Clojure jars - id: jars-cache - uses: actions/cache@v5 - with: - path: algos/.ci/jars - key: ${{ env.JARS_CACHE_KEY }} - - - name: Download Clojure jars - if: steps.jars-cache.outputs.cache-hit != 'true' - run: | - mkdir -p algos/.ci/jars - base=https://repo1.maven.org/maven2 - curl -fsSL -o algos/.ci/jars/clojure.jar "$base/org/clojure/clojure/1.12.0/clojure-1.12.0.jar" - curl -fsSL -o algos/.ci/jars/spec.alpha.jar "$base/org/clojure/spec.alpha/0.5.238/spec.alpha-0.5.238.jar" - curl -fsSL -o algos/.ci/jars/core.specs.alpha.jar "$base/org/clojure/core.specs.alpha/0.4.74/core.specs.alpha-0.4.74.jar" - - - name: Install GNU time - run: | - sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y time - - - name: Check out instrumentation tooling - uses: actions/checkout@v5 - with: - path: h2h - - - name: Cache instrumentation kit - id: instrumentation-cache - uses: actions/cache@v5 - with: - path: ~/instrumentation - key: instrumentation-pin-4.4-${{ hashFiles('h2h/scripts/ci/setup-instrumentation.sh', 'h2h/scripts/ci/instrumentation/inscount.cpp') }} - - - name: Provision instrumentation - run: | - bash h2h/scripts/ci/setup-instrumentation.sh - - - name: Measure head-to-head shard - working-directory: algos - run: | - jars=".ci/jars/clojure.jar:.ci/jars/spec.alpha.jar:.ci/jars/core.specs.alpha.jar" - ./scripts/ci/go/algoperf measure \ - --repeat 3 --no-alloc --no-perf --jobs 3 --timeout 1800 \ - --runtime jvm --cp "$jars:src:test:resources" \ - --pin --pin-bin "$INSTRUMENTATION_BIN" --pin-tool "$INSTRUMENTATION_TOOL" \ - --pin-repeat 1 --pin-timeout 1200 \ - --shard ${{ matrix.shard }}/32 \ - --output "h2h-algos-jvm-shard-${{ matrix.shard }}.json" \ - --summary-file "$GITHUB_STEP_SUMMARY" - - - name: Upload shard results - if: always() - uses: actions/upload-artifact@v6 - with: - name: h2h-algos-jvm-shard-${{ matrix.shard }} - path: algos/h2h-algos-jvm-shard-${{ matrix.shard }}.json + name: h2h-recipes-python-shard-${{ matrix.shard }} + path: pysnippets/h2h-recipes-python-shard-${{ matrix.shard }}.json retention-days: 7 # Fan-in: union the shards per leg and diff the legs. Strict - # across-the-board gate, both directions: JVM vs rs-from AND + # across-the-board gate, both directions: CPython vs rs-from AND # rs-from vs rs-to on cpu/mem, plus rs-from vs rs-to on # instrumented instruction counts (1% margin) — any loss (or # failure, missing, not-ok, drift, leg mismatch) fails the PR. # Memory is RSS (maxrss_kb) only: the Pin tool counts # instructions, never allocations. The native legs carry the # instrument signal in the same shard JSONs (no second sweep), - # so the ins comparisons below reuse the h2h-* legs and write + # so the ins comparison below reuses the h2h-* legs and writes # no extra merged files. No Bencher reporting by design: this # job gates, it does not track history. h2h-compare: name: h2h cpu/mem gate - needs: [measure-recipes, measure-recipes-jvm, measure-algos, measure-algos-jvm] + needs: [measure-recipes, measure-recipes-python] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -714,22 +497,16 @@ jobs: report_only="" if [ "${{ inputs.report_only }}" = "true" ]; then report_only="--report-only"; fi rc=0 - # Six comparisons, all verdict tables in the summary; any + # Three comparisons, all verdict tables in the summary; any # can fail the job unless triage mode greens everything. $compare \ --label recipes \ - --jvm h2h-recipes-jvm-shard-*/h2h-recipes-jvm-shard-*.json \ + --base-name python \ + --jvm h2h-recipes-python-shard-*/h2h-recipes-python-shard-*.json \ --rs h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ - --merged-jvm h2h-recipes-jvm-merged.json \ + --merged-jvm h2h-recipes-python-merged.json \ --merged-rs h2h-recipes-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? - $compare \ - --label algos \ - --jvm h2h-algos-jvm-shard-*/h2h-algos-jvm-shard-*.json \ - --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ - --merged-jvm h2h-algos-jvm-merged.json \ - --merged-rs h2h-algos-rs-from-merged.json \ - --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? $compare \ --label recipes-rsrs \ --base-name rs-to --challenger-name rs-from \ @@ -738,14 +515,6 @@ jobs: --merged-jvm h2h-recipes-rs-to-merged.json \ --merged-rs h2h-recipes-rs-from-merged.json \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? - $compare \ - --label algos-rsrs \ - --base-name rs-to --challenger-name rs-from \ - --jvm h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ - --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ - --merged-jvm h2h-algos-rs-to-merged.json \ - --merged-rs h2h-algos-rs-from-merged.json \ - --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? $compare \ --label recipes-rsrs-ins \ --base-name rs-to --challenger-name rs-from \ @@ -753,13 +522,6 @@ jobs: --rs h2h-recipes-rs-from-shard-*/h2h-recipes-rs-from-shard-*.json \ --metrics ins --margin 0.01 \ --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? - $compare \ - --label algos-rsrs-ins \ - --base-name rs-to --challenger-name rs-from \ - --jvm h2h-algos-rs-to-shard-*/h2h-algos-rs-to-shard-*.json \ - --rs h2h-algos-rs-from-shard-*/h2h-algos-rs-from-shard-*.json \ - --metrics ins --margin 0.01 \ - --summary-file "$GITHUB_STEP_SUMMARY" $report_only || rc=$? exit $rc - name: Upload merged leg JSONs