Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d6d57b6
Docs: rewrite CONTRIBUTING for clojure-rs, add master-vs-release stab…
m-hu Nov 5, 2026
2905781
Register VM and hot-path redesign: specialized ops, direct calls, inl…
m-hu Nov 5, 2026
709ced8
Merge origin/fix/226-ns-clones (the CONTRIBUTING rewrite landed on ma…
m-hu Nov 5, 2026
43d0733
Gate PRs on the sharded JVM-vs-native CPU/RSS head-to-head
m-hu Nov 4, 2026
0e0fe32
h2h-cpu-mem: chmod +x the PR-built clojure-rs after artifact download
m-hu Nov 4, 2026
3a7ee59
h2h-cpu-mem: pin snippets to 5cc7903 (bundle re-encrypted for org sec…
m-hu Nov 4, 2026
d5ab07c
h2h_compare: tolerate repeated sweep-skip markers across shards
m-hu Nov 4, 2026
9b70b34
h2h-cpu-mem: add informational native-vs-native comparison
m-hu Nov 5, 2026
ad54533
h2h-cpu-mem: gate strictly in both directions
m-hu Nov 5, 2026
c7a5d11
h2h-cpu-mem: from=head, to=base (PR parlance)
m-hu Nov 5, 2026
f799fe2
h2h-cpu-mem: Pin inscount legs + Go h2h-compare
m-hu Nov 5, 2026
d9e827c
h2h-cpu-mem: pin-only instrumentation legs, pin -> instrumentation
m-hu Nov 5, 2026
abe0c46
h2h-cpu-mem: fold instrumentation into the timed legs + alloc signal
m-hu Nov 5, 2026
ec51d04
h2h-cpu-mem: split instrumented JVM legs, 32 shards, setarch -R
m-hu Nov 5, 2026
498fe25
h2h-cpu-mem: fix recipes-jvm checkout (literal repo, go.mod path, gpg…
m-hu Nov 5, 2026
6f12526
h2h-cpu-mem: Pin counts instructions only, RSS for memory, run after …
m-hu Nov 5, 2026
08d3c84
h2h-cpu-mem: pin snippets to 8466c12 (BMF/summary pin_insn)
m-hu Nov 5, 2026
a035202
h2h-cpu-mem: pin snippets to 98612a1 (snip measure summary header)
m-hu Nov 5, 2026
2c65abe
h2h-cpu-mem: gate the sweep on rust.yml via wait-rust
m-hu Nov 5, 2026
6178586
h2h-cpu-mem: wait-rust polls the PR head SHA, not the merge commit
m-hu Nov 5, 2026
f84bcaa
h2h-cpu-mem: CPython baseline replaces JVM, algorithms suite dropped
m-hu Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
533 changes: 533 additions & 0 deletions .github/workflows/h2h-cpu-mem.yml

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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
Expand Down
47 changes: 41 additions & 6 deletions rust/crates/clj-eval/src/classemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::env::FxHashMap<String, Arc<ClassVal>>> {
static MAP: std::sync::LazyLock<Arc<crate::env::FxHashMap<String, Arc<ClassVal>>>> =
std::sync::LazyLock::new(|| Arc::new(default_imports().collect()));
Arc::clone(&MAP)
}

pub(crate) fn default_imports() -> impl Iterator<Item = (String, Arc<ClassVal>)> {
DEFAULT_IMPORTS.iter().map(|fqn| {
let simple = fqn.rsplit('.').next().unwrap_or(fqn).to_owned();
Expand Down Expand Up @@ -3958,7 +3968,7 @@ fn static_character_digit(args: &[Value]) -> Result<Value, NativeError> {
}

/// 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}'))
}
Expand Down Expand Up @@ -5191,9 +5201,7 @@ fn identity_hash_ptr(v: &Value) -> Option<i32> {

/// Read an import mapping (shared with name resolution).
pub(crate) fn import_in(eval: &Evaluator, ns: &str, simple: &str) -> Option<Arc<ClassVal>> {
eval.registry
.with_ns_mut(ns, |ns| ns.get_import(simple))
.flatten()
eval.registry.import_in(ns, simple)
}

/// A bean key.
Expand Down Expand Up @@ -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<Arc<str>> {
match v {
Value::Str(s) => Some(Arc::clone(s)),
Value::Extension(e) => e
.as_any()
.downcast_ref::<StringBuilder>()
.map(|sb| Arc::from(sb.lock().as_str())),
_ => None,
}
}

impl StringBuilder {
pub(crate) fn fresh(content: String) -> Self {
Self {
Expand Down Expand Up @@ -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<String> {
Some(format!(
"#object[java.lang.StringBuilder 0x{:x} {}]",
self.id,
self.lock()
))
}

/// `toString` is the buffer.
fn java_str(&self) -> Option<String> {
Some(self.lock().clone())
}

Expand Down
95 changes: 68 additions & 27 deletions rust/crates/clj-eval/src/coll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@ fn count_fallback(eval: &mut Evaluator, v: &Value) -> Result<i64, NativeError> {
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));
Expand Down Expand Up @@ -1548,34 +1548,47 @@ static STR_SCAN_CACHE: Mutex<Vec<StrScanEntry>> = 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<str>` 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<str>` 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<str>` 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<str>` 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<str>, StrScan)>; LAST_SCAN_SLOTS], usize);

thread_local! {
static LAST_SCAN: std::cell::RefCell<Option<(Arc<str>, StrScan)>> =
const { std::cell::RefCell::new(None) };
static LAST_SCAN: std::cell::RefCell<ScanMemo> =
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<str>) -> Option<StrScan> {
/// 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<T>(s: &Arc<str>, f: impl FnOnce(&StrScan) -> T) -> Option<T> {
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<str>, 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;
});
}

Expand All @@ -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<str>) -> Option<StrScan> {
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
Expand Down Expand Up @@ -1646,22 +1657,30 @@ fn build_str_scan(s: &str) -> Option<StrScan> {
/// 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<str>, idx: usize) -> Option<char> {
pub(crate) fn str_char_at(s: &Arc<str>, idx: usize) -> Option<char> {
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<char> {
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
Expand All @@ -1670,6 +1689,13 @@ pub fn str_utf16_len(s: &Arc<str>) -> 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,
Expand Down Expand Up @@ -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<Arc<str>> = (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
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/clj-eval/src/collmisc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<regex::Regex>,
anchored: bool,
text: Arc<str>,
state: Arc<Mutex<MatcherState>>,
Expand Down
7 changes: 5 additions & 2 deletions rust/crates/clj-eval/src/concurrency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,10 @@ fn native_deref(eval: &mut Evaluator, args: &[Value]) -> Result<Value, NativeErr
Value::Delay(d) => 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")),
}
Expand Down Expand Up @@ -1661,7 +1664,7 @@ fn agent_var(eval: &Evaluator) -> Option<Var> {

/// 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
Expand Down
Loading
Loading