From 49dfe11c2869da2460c17665e85ab983bfa03b8e Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 02:09:57 +0200 Subject: [PATCH 01/11] fix: inline tiny runtime string ops at call sites (I-161, I-181) __ryo_slice/__ryo_bytes_slice, string/bytes literal packing, and short-literal ryo_str_eq are a handful of instructions each but were opaque extern calls: two calls plus a legalized 128-bit-shift pair unpack per scan iteration in benchmarks/string_slicing. - Emit slice bodies inline (bounds + UTF-8 boundary guards branch to the shared cold ryo_panic blocks; null-when-empty ptr preserved) - Materialize literals as pure constants (symbol_value + iconst, cap=0 sentinel); pack_pair was the whole from_literal body - Specialize ==/!= against literals up to 16 bytes as a length check plus gated per-byte compares; general ryo_str_eq stays extern - Delete the packed-u128 pair ABI: emit_rv_pair_call/emit_rv_str_call/ emit_rv_bytes_call/CapRule, the four dead runtime exports and their unit tests, the enable_llvm_abi_extensions flag and its guard tests string_slicing AOT: 3.35x -> 2.14x vs Rust (5.4 ms -> 3.2 ms). --- runtime/src/lib.rs | 138 +---------------- runtime/src/tests.rs | 108 +------------ ryo-backend/src/codegen/bytes.rs | 39 ++--- ryo-backend/src/codegen/expr.rs | 132 +++------------- ryo-backend/src/codegen/mod.rs | 29 +--- ryo-backend/src/codegen/str_ops.rs | 235 +++++++++++++++++++++++++++++ ryo-backend/src/codegen/tests.rs | 89 ----------- ryo/tests/integration_driver.rs | 47 +++--- 8 files changed, 296 insertions(+), 521 deletions(-) create mode 100644 ryo-backend/src/codegen/str_ops.rs diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index c63c344..d529708 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -243,39 +243,6 @@ unsafe fn write_str_slot(out: *mut RyoStrFat, bytes: &[u8]) { } } -/// Return-value packing for the string-producing runtime functions -/// (Phase 0 ABI modernization): `{ptr, len}` is returned as one -/// `u128` (lo = ptr, hi = len). -/// -/// Why packed `u128` instead of a struct: this crate is compiled by -/// rustc, and a 24-byte `RyoStrFat` return lowers to a hidden sret -/// pointer on every supported target, while a 16-byte `{ptr, len}` -/// struct still srets under the MSVC x64 ABI. `u128` under the Rust -/// ABI returns in registers everywhere (rax:rdx on x86-64 SysV and -/// Win64, x0:x1 on aarch64) — the convention Cranelift's -/// SystemV/WindowsFastcall/AppleAarch64 return tables match. These -/// functions are therefore `#[unsafe(no_mangle)] pub fn` (Rust ABI), -/// not `extern "C"`; the build.rs lockstep rebuild keeps this crate -/// and the compiler on the same rustc, so the unstable Rust ABI -/// cannot drift within a build. -/// -/// `cap` is deliberately NOT in the return value: it is derivable at -/// the call site — 0 for `ryo_str_from_literal` (the static .rodata -/// sentinel) and `len` for the remaining packed-u128 allocating -/// producers (the concats; neither over-allocates, and `__ryo_str_push` -/// manages growth capacity through its unchanged slot ABI). Producers -/// that need `cap != len` — or SSO inline results — use the slot-out -/// ABI (`write_str_slot`) instead. -#[inline] -fn pack_pair(ptr: *mut u8, len: u64) -> u128 { - ((len as u128) << 64) | (ptr as usize as u128) -} - -#[cfg(test)] -fn unpack_pair(v: u128) -> (*mut u8, u64) { - (v as u64 as *mut u8, (v >> 64) as u64) -} - #[unsafe(no_mangle)] pub extern "C" fn ryo_str_alloc(cap: u64) -> *mut u8 { if cap == 0 { @@ -352,18 +319,6 @@ fn null_abort() -> ! { unsafe { abort() } } -/// # Safety -/// `data` must point to `len` readable bytes (or be dangling when `len == 0`). -#[unsafe(no_mangle)] -pub unsafe fn ryo_str_from_literal(data: *const u8, len: u64) -> u128 { - if len == 0 { - return pack_pair(core::ptr::null_mut(), 0); - } - // Point directly into rodata; the cap=0 static sentinel is derived - // at the call site (see `pack_pair` docs). - pack_pair(data as *mut u8, len) -} - /// Materialize an owned `str` copy from a `strview` (M8.4.1.2), written /// as a tagged slot: inline when `len <= 23`, else a fresh heap buffer /// with growth headroom. `len == 0` yields the inline-empty slot. @@ -396,55 +351,6 @@ fn slice_fail(msg: &str) -> ! { unsafe { exit(101) } } -/// True when byte offset `i` in `s[..len]` lies on a UTF-8 char -/// boundary (start, end, or a non-continuation byte). -/// -/// # Safety -/// `s` must point to `len` readable bytes (or be null/dangling if `len == 0`). -unsafe fn is_char_boundary(s: *const u8, len: u64, i: u64) -> bool { - if i == 0 || i == len { - return true; - } - // SAFETY: caller contract — s points to len readable bytes; 0 < i < len here. - let b = unsafe { *s.add(i as usize) }; - b & 0xC0 != 0x80 -} - -/// Runtime backing for M8.4 `str` slicing (`s[start:end]`). Out-of-range -/// and non-boundary indices panic at slice creation (final spec §3.1); -/// panic here means stderr message + exit 101, matching `__ryo_panic`. -/// -/// Load-bearing invariant: the returned `ptr` is NULL when the -/// requested range is empty *and* the base is empty, and every consumer -/// guards on `len == 0` before dereferencing — so the packed `ptr` may -/// be null whenever the viewed length is 0. -/// -/// # Safety -/// `ptr` must point to `len` readable bytes (or be null if `len == 0`). -/// Panics (exit 101) when `start > end`, `end > len`, or either bound -/// is not a UTF-8 char boundary. -#[unsafe(no_mangle)] -pub unsafe fn __ryo_slice(ptr: *const u8, len: u64, start: u64, end: u64) -> u128 { - if start > end || end > len { - slice_fail("slice index out of range"); - } - // SAFETY: caller contract — ptr points to len readable bytes. - let bounds_ok = unsafe { is_char_boundary(ptr, len, start) && is_char_boundary(ptr, len, end) }; - if !bounds_ok { - slice_fail("slice index is not a UTF-8 char boundary"); - } - // SAFETY: `start <= end <= len` checked above, so ptr.add(start) - // stays within (or one past) the base allocation. - let out_ptr = unsafe { - if len == 0 { - core::ptr::null() - } else { - ptr.add(start as usize) - } - }; - pack_pair(out_ptr as *mut u8, end - start) -} - /// # Safety /// `out` points to a valid, uninitialized `RyoStrFat`. `l_ptr`/`r_ptr` /// point to `l_len`/`r_len` readable bytes (or are null/dangling when @@ -799,11 +705,9 @@ pub unsafe extern "C" fn ryo_bool_to_str(out: *mut RyoStrFat, value: u8) { // ---------- bytes (M8.4.2) ---------- // -// Owned `bytes` buffers mirror the `str` ABI exactly: literals still -// return `{ptr, len}` packed in one `u128` (see `pack_pair`) with `cap` -// derived at the call site, while concat, from_view, and the -// conversions write tagged slots; `__ryo_bytes_push` manages growth -// through the same 24-byte slot ABI. +// Owned `bytes` buffers mirror the `str` ABI exactly: concat, from_view, +// and the conversions write tagged slots; `__ryo_bytes_push` manages +// growth through the same 24-byte slot ABI. // No UTF-8 invariants anywhere in this family. #[unsafe(no_mangle)] @@ -829,18 +733,6 @@ pub unsafe extern "C" fn ryo_bytes_realloc(ptr: *mut u8, old_cap: u64, new_cap: unsafe { ryo_str_realloc(ptr, old_cap, new_cap) } } -/// # Safety -/// `data` must point to `len` readable bytes (or be dangling when `len == 0`). -#[unsafe(no_mangle)] -pub unsafe fn ryo_bytes_from_literal(data: *const u8, len: u64) -> u128 { - if len == 0 { - return pack_pair(core::ptr::null_mut(), 0); - } - // Point directly into rodata; the cap=0 static sentinel is derived - // at the call site (see `pack_pair` docs). - pack_pair(data as *mut u8, len) -} - /// Materialize an owned `bytes` copy from a `bytesview` (M8.4.2), /// written as a tagged slot: inline when `len <= 23`, else a fresh heap /// buffer with growth headroom. `len == 0` yields the inline-empty slot. @@ -945,30 +837,6 @@ pub unsafe extern "C" fn ryo_bytes_eq( unsafe { ryo_str_eq(a_ptr, a_len, b_ptr, b_len) } } -/// Runtime backing for M8.4.2 `bytes` slicing (`b[start:end]`). -/// Bounds-checked like `__ryo_slice`, but WITHOUT the UTF-8 char-boundary -/// check — the single behavioral divergence from `strview`. -/// -/// # Safety -/// `ptr` must point to `len` readable bytes (or be null if `len == 0`). -/// Panics (exit 101) when `start > end` or `end > len`. -#[unsafe(no_mangle)] -pub unsafe fn __ryo_bytes_slice(ptr: *const u8, len: u64, start: u64, end: u64) -> u128 { - if start > end || end > len { - slice_fail("slice index out of range"); - } - // SAFETY: `start <= end <= len` checked above, so ptr.add(start) - // stays within (or one past) the base allocation. - let out_ptr = unsafe { - if len == 0 { - core::ptr::null() - } else { - ptr.add(start as usize) - } - }; - pack_pair(out_ptr as *mut u8, end - start) -} - /// Runtime backing for `bytes_push(b: inout bytes, x: int)` (M8.4.2 /// stopgap: the byte is an `int`, range-checked here; becomes `u8` at /// M17.1). Appends a SINGLE byte. Panics (exit 101) when `byte > 255`. diff --git a/runtime/src/tests.rs b/runtime/src/tests.rs index 8e60ad8..91f3c11 100644 --- a/runtime/src/tests.rs +++ b/runtime/src/tests.rs @@ -65,53 +65,13 @@ fn test_realloc_to_zero() { } } -#[test] -fn test_from_literal_nonempty() { - let data = b"hello"; - // SAFETY: data points to 5 readable bytes. - let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_ptr as *const u8, data.as_ptr()); - assert_eq!(out_len, 5); - // cap is 0 by ABI convention (the static sentinel never reaches - // the runtime). - // SAFETY: the pair points into the readable literal bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"hello"); -} - -#[test] -fn test_from_literal_returns_static_pointer() { - let data = b"hello"; - // SAFETY: data points to 5 readable bytes. - let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_ptr as *const u8, data.as_ptr()); - assert_eq!(out_len, 5); - // cap is 0 by ABI convention (the static sentinel never reaches - // the runtime). -} - #[test] fn test_free_static_str_is_noop() { let data = b"hello"; - // SAFETY: data points to 5 readable bytes. - let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; - let (out_ptr, _) = unpack_pair(pair); - // Static sentinel: cap = 0 by ABI convention, so free is a noop. - // SAFETY: out_ptr is a static .rodata pointer freed with cap 0. - unsafe { ryo_str_free(out_ptr, 0) }; -} - -#[test] -fn test_from_literal_empty() { - // SAFETY: len == 0, so the data pointer is never dereferenced. - let pair = unsafe { ryo_str_from_literal(b"".as_ptr(), 0) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert!(out_ptr.is_null()); - assert_eq!(out_len, 0); - // cap is 0 by ABI convention (the static sentinel never reaches - // the runtime). + // Static sentinel: cap = 0 by ABI convention, so free is a noop — + // freeing a non-heap .rodata pointer with cap 0 must not touch it. + // SAFETY: cap 0 makes ryo_str_free return before dereferencing. + unsafe { ryo_str_free(data.as_ptr() as *mut u8, 0) }; } #[test] @@ -432,45 +392,6 @@ fn test_concat_static_left_heap_right() { } } -#[test] -fn slice_basic() { - let s = "héllo wörld".as_bytes(); - // "héllo" is 6 bytes (é = 2 bytes) - // SAFETY: s is readable for its byte length; - // the range 0..6 is in-bounds (see above). - let pair = unsafe { __ryo_slice(s.as_ptr(), s.len() as u64, 0, 6) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_len, 6); - // SAFETY: __ryo_slice returned a valid view into s for out_len bytes. - let got = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(got, "héllo".as_bytes()); -} - -#[test] -fn slice_empty_at_len_is_ok() { - let s = "abc".as_bytes(); - // SAFETY: "abc" provides three readable bytes; - // start == end == len is the empty-at-end case the ABI allows. - let pair = unsafe { __ryo_slice(s.as_ptr(), 3, 3, 3) }; - let (_, out_len) = unpack_pair(pair); - assert_eq!(out_len, 0); -} - -#[test] -fn slice_nonzero_offset() { - let s = "héllo wörld".as_bytes(); - // "wörld" starts at byte 7 (h=1, é=2, "llo "=4) and is 6 bytes - // — exercises the non-zero pointer-offset path. - // SAFETY: s is readable for its byte length; - // the range 7..13 is in-bounds (see above). - let pair = unsafe { __ryo_slice(s.as_ptr(), s.len() as u64, 7, 13) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_len, 6); - // SAFETY: __ryo_slice returned a valid view into s for out_len bytes. - let got = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(got, "wörld".as_bytes()); -} - #[test] fn str_from_view_copies_bytes() { let src = b"hello"; @@ -630,27 +551,6 @@ fn bytes_from_view_copies() { unsafe { ryo_bytes_free(big_slot.ptr, big_slot.cap) }; } -#[test] -fn bytes_slice_returns_subrange() { - let src = [0x01u8, 0x02, 0x03, 0x04]; - let v = unsafe { __ryo_bytes_slice(src.as_ptr(), 4, 1, 3) }; - let (p, l) = unpack_pair(v); - assert_eq!(l, 2); - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, &[0x02, 0x03]); - // View into the source — do NOT free. -} - -#[test] -fn bytes_slice_allows_non_char_boundaries() { - // The single behavioral divergence from `__ryo_slice`: no UTF-8 - // boundary check — slicing mid-codepoint is fine for bytes. - let src = "héllo".as_bytes(); // é is two bytes at offsets 1..3 - let v = unsafe { __ryo_bytes_slice(src.as_ptr(), src.len() as u64, 1, 3) }; - let (_, l) = unpack_pair(v); - assert_eq!(l, 2); -} - #[test] fn test_push_inline_fits_no_alloc() { let mut slot = RyoStrFat { diff --git a/ryo-backend/src/codegen/bytes.rs b/ryo-backend/src/codegen/bytes.rs index e90f59e..fa0139c 100644 --- a/ryo-backend/src/codegen/bytes.rs +++ b/ryo-backend/src/codegen/bytes.rs @@ -1,7 +1,7 @@ //! Bytes codegen (M8.4.2) — split from `expr.rs` to keep both files //! under the 2000-line CI cap (`scripts/check_file_length.sh`). //! Everything here mirrors the `str` path: same 24-byte fat-pointer -//! ABI, same packed-u128 literal convention, `ryo_bytes_*` symbols. +//! ABI, `ryo_bytes_*` symbols. //! Also hosts the shared `.rodata` dedup helpers (`store_string` / //! `store_bytes`), displaced from `mod.rs` by the same cap. @@ -12,29 +12,12 @@ use ryo_core::tir::{Tir, TirRef, TirTag}; use ryo_core::types::{StringId, TypeKind}; use std::collections::HashMap; -use super::expr::CapRule; use super::{Codegen, FunctionContext, ValueRepr}; impl Codegen { - /// Bytes-producing variant of `emit_rv_str_call`: appends the - /// derived `cap` word so the triple lands entirely in SSA values. - /// Does NOT touch `ctx.inst_values` — caching is the caller's job. - pub(crate) fn emit_rv_bytes_call( - builder: &mut FunctionBuilder, - ctx: &mut FunctionContext<'_, M>, - fn_name: &str, - args: &[(Type, Value)], - cap_rule: CapRule, - ) -> Result { - let (ptr, len) = Self::emit_rv_pair_call(builder, ctx, fn_name, args)?; - let cap = match cap_rule { - CapRule::Static => builder.ins().iconst(types::I64, 0), - }; - Ok(ValueRepr::Bytes { ptr, len, cap }) - } - - /// Emit a bytes literal as a fat pointer triple (ptr, len, cap=0) - /// by calling `ryo_bytes_from_literal` at runtime. Mirrors + /// Emit a bytes literal as a fat pointer triple (ptr, len, cap=0): + /// the `.rodata` data pointer, the compile-time length, and the + /// static cap-0 sentinel — pure constants, no runtime call. Mirrors /// `emit_str_literal_fat`; the payload is raw bytes (not /// necessarily UTF-8), so it reads through `pool.bytes_payload`. pub(crate) fn emit_bytes_literal_fat( @@ -47,14 +30,12 @@ impl Codegen { let data_ref = ctx.module.declare_data_in_func(data_id, builder.func); let rodata_ptr = builder.ins().symbol_value(ctx.int_type, data_ref); let lit_len = builder.ins().iconst(types::I64, content.len() as i64); - - Self::emit_rv_bytes_call( - builder, - ctx, - "ryo_bytes_from_literal", - &[(ctx.int_type, rodata_ptr), (types::I64, lit_len)], - CapRule::Static, - ) + let cap = builder.ins().iconst(types::I64, 0); + Ok(ValueRepr::Bytes { + ptr: rodata_ptr, + len: lit_len, + cap, + }) } /// Declare `extern "C" fn ryo_bytes_free(ptr: *mut u8, cap: u64)` for diff --git a/ryo-backend/src/codegen/expr.rs b/ryo-backend/src/codegen/expr.rs index ecb39a7..687e0bf 100644 --- a/ryo-backend/src/codegen/expr.rs +++ b/ryo-backend/src/codegen/expr.rs @@ -15,21 +15,6 @@ use ryo_core::tir::{ParamMode, Tir, TirData, TirRef, TirTag}; use ryo_core::types::{InternPool, StringId, TypeKind, ViewKind}; use std::collections::HashMap; -/// Cap derivation for the packed-u128 runtime string/bytes ABI (Phase 0): -/// string-producing runtime functions return `{ptr, len}` packed in -/// one u128 (lo = ptr, hi = len) — a true register return on every -/// supported target (see `pack_pair` in `runtime/src/lib.rs` for why -/// not a struct). The `cap` word is a codegen-side derivation: -/// `Static` (cap = 0, the .rodata sentinel) for -/// `ryo_str_from_literal` / `ryo_bytes_from_literal` — the only -/// remaining packed-u128 producers. The slot-out producers report -/// their own tagged cap, and `__ryo_str_push` / `__ryo_bytes_push` -/// manage growth capacity through their unchanged slot ABI. -#[derive(Clone, Copy)] -pub(crate) enum CapRule { - Static, -} - impl Codegen { /// Materialize an instruction's value, recursively materializing /// operand `TirRef`s as needed. Memoized: a second visit hands @@ -310,28 +295,7 @@ impl Codegen { TirData::BinOp { lhs, rhs } => (lhs, rhs), _ => unreachable!(), }; - // M8.4 §3.3: operands may be owned str triples or strview - // view pairs (mixed equality wraps the owned side in - // ToView); ryo_str_eq only needs (ptr, len). - let (l_ptr, l_len) = Self::eval_str_or_view_parts(builder, ctx, lhs)?; - let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; - - let eq_ref = Self::declare_runtime_fn( - ctx.module, - builder, - "ryo_str_eq", - &[ctx.int_type, types::I64, ctx.int_type, types::I64], - &[types::I8], - )?; - let call = builder.ins().call(eq_ref, &[l_ptr, l_len, r_ptr, r_len]); - let result = builder.inst_results(call)[0]; - - if inst.tag == TirTag::StrCmpNe { - let one = builder.ins().iconst(types::I8, 1); - builder.ins().bxor(result, one) - } else { - result - } + Self::emit_str_eq(builder, ctx, inst.tag, lhs, rhs)? } TirTag::BytesCmpEq | TirTag::BytesCmpNe => { let (lhs, rhs) = match inst.data { @@ -345,8 +309,8 @@ impl Codegen { TirData::BinOp { lhs, rhs } => (lhs, rhs), _ => unreachable!("BytesIndex must carry TirData::BinOp"), }; - // Bounds check + panic are runtime-side, mirroring - // `__ryo_slice` — no Cranelift branch needed. + // Bounds check + panic are runtime-side, mirroring the + // inline slice guards — no Cranelift branch needed. let (ptr, len) = Self::eval_str_or_view_parts(builder, ctx, base)?; let idx = Self::eval_inst(builder, ctx, index)?; let index_ref = Self::declare_runtime_fn( @@ -757,8 +721,8 @@ impl Codegen { /// Declare `extern "C" fn ryo_str_free(ptr: *mut u8, cap: u64)` for /// the function being built. Returns a `FuncRef` callable via /// `builder.ins().call(_, &[ptr, cap])`. `cap == 0` is a runtime - /// no-op (covers static `.rodata` strings emitted by - /// `ryo_str_from_literal`). + /// no-op (covers static `.rodata` strings materialized by + /// `emit_str_literal_fat`). pub(crate) fn declare_str_free( module: &mut M, builder: &mut FunctionBuilder, @@ -773,49 +737,6 @@ impl Codegen { ) } - /// Emit a call to a runtime function that returns a (ptr, len) pair - /// packed as `u128` (lo = ptr, hi = len), and unpack both halves - /// into SSA values — no stack slot, no out-pointer, no reload at - /// the call site. `ushr`'s shift amount is any integer type - /// (masked to the value width), so a plain i64 constant works. - pub(crate) fn emit_rv_pair_call( - builder: &mut FunctionBuilder, - ctx: &mut FunctionContext<'_, M>, - fn_name: &str, - args: &[(Type, Value)], - ) -> Result<(Value, Value), String> { - let param_tys: Vec = args.iter().map(|(ty, _)| *ty).collect(); - let func_ref = - Self::declare_runtime_fn(ctx.module, builder, fn_name, ¶m_tys, &[types::I128])?; - let call_args: Vec = args.iter().map(|(_, val)| *val).collect(); - let call = builder.ins().call(func_ref, &call_args); - let pair = builder.inst_results(call)[0]; - let ptr = builder.ins().ireduce(ctx.int_type, pair); - let shift = builder.ins().iconst(types::I64, 64); - let hi = builder.ins().ushr(pair, shift); - let len = builder.ins().ireduce(types::I64, hi); - Ok((ptr, len)) - } - - /// String-producing variant of `emit_rv_pair_call`: appends the - /// derived `cap` word so the triple lands entirely in SSA values. - /// Shared by every str-producing runtime call site so they cannot - /// drift. Does NOT touch `ctx.inst_values` — caching is the - /// caller's job. - pub(crate) fn emit_rv_str_call( - builder: &mut FunctionBuilder, - ctx: &mut FunctionContext<'_, M>, - fn_name: &str, - args: &[(Type, Value)], - cap_rule: CapRule, - ) -> Result { - let (ptr, len) = Self::emit_rv_pair_call(builder, ctx, fn_name, args)?; - let cap = match cap_rule { - CapRule::Static => builder.ins().iconst(types::I64, 0), - }; - Ok(ValueRepr::Str { ptr, len, cap }) - } - /// Call a slot-out runtime producer: allocate a 24-byte slot, pass /// its address as arg 0, then load the tagged (ptr, len, cap) /// triple. The runtime writes the full slot (SSO tag, headroom @@ -1153,23 +1074,10 @@ impl Codegen { None => base_len, }; // M8.4.2: bytes slices skip the UTF-8 boundary check — - // select the family callee from the result view type. + // the inline emission selects on the result view type. let is_bytes = matches!(ctx.pool.kind(inst.ty), TypeKind::View(ViewKind::Bytes)); - let callee = if is_bytes { - "__ryo_bytes_slice" - } else { - "__ryo_slice" - }; - let (ptr, len) = Self::emit_rv_pair_call( - builder, - ctx, - callee, - &[ - (ctx.int_type, base_ptr), - (types::I64, base_len), - (types::I64, start_v), - (types::I64, end_v), - ], + let (ptr, len) = Self::emit_slice_inline( + builder, ctx, base_ptr, base_len, start_v, end_v, is_bytes, )?; ValueRepr::View { ptr, len } } @@ -1269,9 +1177,8 @@ impl Codegen { /// Materialize every distinct string/bytes literal exactly once, in /// the entry block, and pre-seed the `TirRef → ValueRepr` memo so each /// use reads the hoisted triple. A literal is pure .rodata packing - /// (`symbol_value` + `iconst` + the side-effect-free - /// `ryo_str_from_literal` / `ryo_bytes_from_literal` call), so - /// entry-block materialization is sound — the entry block dominates + /// (`symbol_value` + `iconst` constants — no call), so entry-block + /// materialization is sound — the entry block dominates /// every use — and keeps loop bodies from re-packing the same /// (ptr, len) per iteration. /// @@ -1335,8 +1242,9 @@ impl Codegen { Ok(()) } - /// Emit a string literal as a fat pointer triple (ptr, len, cap) - /// by calling `ryo_str_from_literal` at runtime. + /// Emit a string literal as a fat pointer triple (ptr, len, cap=0): + /// the `.rodata` data pointer, the compile-time length, and the + /// static cap-0 sentinel — pure constants, no runtime call. fn emit_str_literal_fat( builder: &mut FunctionBuilder, ctx: &mut FunctionContext<'_, M>, @@ -1347,14 +1255,12 @@ impl Codegen { let data_ref = ctx.module.declare_data_in_func(data_id, builder.func); let rodata_ptr = builder.ins().symbol_value(ctx.int_type, data_ref); let lit_len = builder.ins().iconst(types::I64, content.len() as i64); - - Self::emit_rv_str_call( - builder, - ctx, - "ryo_str_from_literal", - &[(ctx.int_type, rodata_ptr), (types::I64, lit_len)], - CapRule::Static, - ) + let cap = builder.ins().iconst(types::I64, 0); + Ok(ValueRepr::Str { + ptr: rodata_ptr, + len: lit_len, + cap, + }) } pub(super) fn emit_call( diff --git a/ryo-backend/src/codegen/mod.rs b/ryo-backend/src/codegen/mod.rs index 04f8911..b724579 100644 --- a/ryo-backend/src/codegen/mod.rs +++ b/ryo-backend/src/codegen/mod.rs @@ -41,6 +41,7 @@ mod arith; mod bytes; mod expr; mod ranges; +mod str_ops; mod structs; mod views; @@ -396,15 +397,6 @@ impl Codegen { } /// Shared Cranelift flags for the AOT object pipeline. -/// -/// `enable_llvm_abi_extensions` is required for the packed-u128 string -/// runtime ABI: without it, Cranelift's x64 ABI panics on any -/// signature containing an i128 ("i128 args/return values not supported -/// unless LLVM ABI extensions are enabled", `isa/x64/abi.rs`). With it, -/// an i128 is split into two i64 halves assigned as consecutive -/// register-sized parts — rax:rdx on both SysV and WindowsFastcall, -/// matching the Rust ABI the `#[unsafe(no_mangle)] pub fn` runtime -/// functions use. aarch64 lowers i128 natively and ignores the flag. fn aot_shared_flags() -> Result { let mut shared_builder = settings::builder(); shared_builder @@ -416,9 +408,6 @@ fn aot_shared_flags() -> Result { shared_builder .set("preserve_frame_pointers", "true") .map_err(|e| format!("Error setting preserve_frame_pointers: {}", e))?; - shared_builder - .enable("enable_llvm_abi_extensions") - .map_err(|e| format!("Error enabling enable_llvm_abi_extensions: {}", e))?; // The Cranelift verifier is a compiler-developer aid (it catches // malformed IR our codegen emits); users cannot act on its // failures. Keep it in debug builds and the test suite — where @@ -463,15 +452,12 @@ impl Codegen { impl Codegen { pub fn new_jit() -> Result { - // enable_llvm_abi_extensions: same rationale as `aot_shared_flags` — - // the packed-u128 string runtime ABI requires it on x64. // opt_level=speed: run the egraph optimization pipeline (constant // folding, algebraic simplification, GVN/LICM) like the AOT path. // enable_verifier: debug builds and tests only, same rationale as // `aot_shared_flags`. let mut jit_builder = JITBuilder::with_flags( &[ - ("enable_llvm_abi_extensions", "true"), ("opt_level", "speed"), ( "enable_verifier", @@ -488,10 +474,6 @@ impl Codegen { // Register runtime symbols so the JIT can resolve them. jit_builder.symbols([ - ( - "ryo_str_from_literal", - ryo_runtime::ryo_str_from_literal as *const u8, - ), ("ryo_str_alloc", ryo_runtime::ryo_str_alloc as *const u8), ("ryo_str_concat", ryo_runtime::ryo_str_concat as *const u8), ("__ryo_str_push", ryo_runtime::__ryo_str_push as *const u8), @@ -503,7 +485,6 @@ impl Codegen { "__ryo_bytes_ensure_heap", ryo_runtime::__ryo_bytes_ensure_heap as *const u8, ), - ("__ryo_slice", ryo_runtime::__ryo_slice as *const u8), ("ryo_str_eq", ryo_runtime::ryo_str_eq as *const u8), ("ryo_int_to_str", ryo_runtime::ryo_int_to_str as *const u8), ( @@ -518,10 +499,6 @@ impl Codegen { ("ryo_str_free", ryo_runtime::ryo_str_free as *const u8), // M8.4.2 bytes family — names match the runtime's // `#[unsafe(no_mangle)]` exports verbatim. - ( - "ryo_bytes_from_literal", - ryo_runtime::ryo_bytes_from_literal as *const u8, - ), ("ryo_bytes_alloc", ryo_runtime::ryo_bytes_alloc as *const u8), ( "ryo_bytes_concat", @@ -531,10 +508,6 @@ impl Codegen { "__ryo_bytes_push", ryo_runtime::__ryo_bytes_push as *const u8, ), - ( - "__ryo_bytes_slice", - ryo_runtime::__ryo_bytes_slice as *const u8, - ), ( "__ryo_bytes_index", ryo_runtime::__ryo_bytes_index as *const u8, diff --git a/ryo-backend/src/codegen/str_ops.rs b/ryo-backend/src/codegen/str_ops.rs new file mode 100644 index 0000000..bf484b2 --- /dev/null +++ b/ryo-backend/src/codegen/str_ops.rs @@ -0,0 +1,235 @@ +//! Inlined tiny string ops — split from `expr.rs` to keep every file +//! under the 2000-line CI cap (`scripts/check_file_length.sh`). +//! +//! The bodies of `__ryo_slice` / `__ryo_bytes_slice` and the +//! short-literal specialization of `ryo_str_eq` are emitted as inline +//! Cranelift IR at the call site instead of extern calls: each body is +//! a handful of instructions, and the call boundary cost dominated +//! (`benchmarks/string_slicing` made two such calls per scan +//! iteration). Slice panic paths keep the runtime contract (stderr +//! message + exit 101) by branching to the shared cold `ryo_panic` +//! blocks (`emit_panic_guard`). + +use cranelift::codegen::ir::{BlockArg, MemFlagsData}; +use cranelift::prelude::*; +use cranelift_module::Module; +use ryo_core::tir::{TirData, TirRef, TirTag}; +use ryo_core::types::StringId; + +use super::{Codegen, FunctionContext}; + +/// Upper size bound for the inline byte-compare specialization of +/// `==`/`!=` against a string literal. +const INLINE_LITERAL_MAX: usize = 16; + +impl Codegen { + /// Inline form of `__ryo_slice` / `__ryo_bytes_slice` + /// (`runtime/src/lib.rs`): bounds check, UTF-8 char-boundary checks + /// (str only — bytes slices skip them), then pointer arithmetic. + /// Failures branch to cold `ryo_panic` blocks with the same + /// messages and exit-101 contract the runtime `slice_fail` used. + /// + /// Preserves the null-when-empty invariant: the result pointer is + /// null when the base length is 0, and every consumer guards on + /// `len == 0` before dereferencing. + pub(crate) fn emit_slice_inline( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + base_ptr: Value, + base_len: Value, + start: Value, + end: Value, + is_bytes: bool, + ) -> Result<(Value, Value), String> { + let start_gt_end = builder + .ins() + .icmp(IntCC::UnsignedGreaterThan, start, end); + let end_gt_len = builder + .ins() + .icmp(IntCC::UnsignedGreaterThan, end, base_len); + let out_of_range = builder.ins().bor(start_gt_end, end_gt_len); + Self::emit_panic_guard(builder, ctx, out_of_range, "slice index out of range")?; + + if !is_bytes { + Self::emit_char_boundary_guard(builder, ctx, base_ptr, base_len, start)?; + Self::emit_char_boundary_guard(builder, ctx, base_ptr, base_len, end)?; + } + + let out_len = builder.ins().isub(end, start); + let advanced = builder.ins().iadd(base_ptr, start); + let zero_len = builder.ins().iconst(types::I64, 0); + let base_empty = builder.ins().icmp(IntCC::Equal, base_len, zero_len); + let null = builder.ins().iconst(ctx.int_type, 0); + let out_ptr = builder.ins().select(base_empty, null, advanced); + Ok((out_ptr, out_len)) + } + + /// Panic unless index `i` lies on a UTF-8 char boundary of + /// `ptr[..len]`: boundaries at 0 and `len` pass trivially; + /// otherwise the byte at `i` must not be a continuation byte + /// (`b & 0xC0 != 0x80`). The byte load sits in its own block so it + /// never executes at the edges, where `ptr + i` can be one past + /// the allocation (or null for an empty base). + fn emit_char_boundary_guard( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + ptr: Value, + len: Value, + i: Value, + ) -> Result<(), String> { + let zero = builder.ins().iconst(types::I64, 0); + let at_start = builder.ins().icmp(IntCC::Equal, i, zero); + let at_end = builder.ins().icmp(IntCC::Equal, i, len); + let is_edge = builder.ins().bor(at_start, at_end); + + let check_block = builder.create_block(); + let cont_block = builder.create_block(); + builder.ins().brif(is_edge, cont_block, &[], check_block, &[]); + + // Single predecessor (the brif above) — seal immediately. + builder.seal_block(check_block); + builder.switch_to_block(check_block); + let addr = builder.ins().iadd(ptr, i); + let byte = builder + .ins() + .load(types::I8, MemFlagsData::trusted(), addr, 0); + let masked = builder.ins().band_imm_u(byte, 0xC0); + let is_continuation = builder.ins().icmp_imm_u(IntCC::Equal, masked, 0x80); + Self::emit_panic_guard( + builder, + ctx, + is_continuation, + "slice index is not a UTF-8 char boundary", + )?; + // emit_panic_guard switched to its own fall-through block. + builder.ins().jump(cont_block, &[]); + + // Two predecessors (edge brif arm + checked fall-through), both + // added above. + builder.seal_block(cont_block); + builder.switch_to_block(cont_block); + Ok(()) + } + + /// `str`/`strview` equality (M8.4 §3.3). When either operand is a + /// string literal of at most `INLINE_LITERAL_MAX` bytes, emits an + /// inline compare — length check plus per-byte compares of the + /// other side against the compile-time bytes, gated behind the + /// length check so loads never run past the other buffer. Anything + /// else falls back to the extern `ryo_str_eq(ptr, len, ptr, len)`. + pub(crate) fn emit_str_eq( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + tag: TirTag, + lhs: TirRef, + rhs: TirRef, + ) -> Result { + // Operands may be owned str triples or strview view pairs + // (mixed equality wraps the owned side in ToView); only + // (ptr, len) is read. + let (l_ptr, l_len) = Self::eval_str_or_view_parts(builder, ctx, lhs)?; + let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; + + let literal = Self::strconst_id(ctx, lhs) + .map(|id| (true, id)) + .or_else(|| Self::strconst_id(ctx, rhs).map(|id| (false, id))); + let mut inline = None; + if let Some((is_lhs, id)) = literal { + let content = ctx.pool.str(id); + if content.len() <= INLINE_LITERAL_MAX { + let mut bytes = [0u8; INLINE_LITERAL_MAX]; + bytes[..content.len()].copy_from_slice(content.as_bytes()); + inline = Some((is_lhs, content.len(), bytes)); + } + } + + let result = if let Some((is_lhs, n, bytes)) = inline { + let (other_ptr, other_len) = if is_lhs { + (r_ptr, r_len) + } else { + (l_ptr, l_len) + }; + Self::emit_literal_eq(builder, other_ptr, other_len, n, &bytes) + } else { + let eq_ref = Self::declare_runtime_fn( + ctx.module, + builder, + "ryo_str_eq", + &[ctx.int_type, types::I64, ctx.int_type, types::I64], + &[types::I8], + )?; + let call = builder + .ins() + .call(eq_ref, &[l_ptr, l_len, r_ptr, r_len]); + builder.inst_results(call)[0] + }; + + if tag == TirTag::StrCmpNe { + let one = builder.ins().iconst(types::I8, 1); + Ok(builder.ins().bxor(result, one)) + } else { + Ok(result) + } + } + + /// The `StringId` behind a `str`-typed operand when it is + /// statically a string literal — directly (`StrConst`) or through + /// the owner→view `ToView` wrap. Anything else is `None`. + fn strconst_id(ctx: &FunctionContext<'_, M>, r: TirRef) -> Option { + let inst = ctx.tir.inst(r); + match (inst.tag, inst.data) { + (TirTag::StrConst, TirData::Str(id)) => Some(id), + (TirTag::ToView, TirData::UnOp(inner)) => Self::strconst_id(ctx, inner), + _ => None, + } + } + + /// Inline `other == `: `other_len == n`, then `n` + /// byte compares. The compares live in their own block reached only + /// when the lengths match, so `other_ptr` is never read past + /// `other_len` bytes. Result is an I8 0/1, the `ryo_str_eq` shape. + fn emit_literal_eq( + builder: &mut FunctionBuilder, + other_ptr: Value, + other_len: Value, + n: usize, + bytes: &[u8; INLINE_LITERAL_MAX], + ) -> Value { + let n_const = builder.ins().iconst(types::I64, n as i64); + let len_ok = builder.ins().icmp(IntCC::Equal, other_len, n_const); + if n == 0 { + return len_ok; + } + + let cmp_block = builder.create_block(); + let false_block = builder.create_block(); + let merge_block = builder.create_block(); + builder.append_block_param(merge_block, types::I8); + builder.ins().brif(len_ok, cmp_block, &[], false_block, &[]); + + // Single predecessor each — seal on entry. + builder.seal_block(cmp_block); + builder.switch_to_block(cmp_block); + let mut acc = builder.ins().iconst(types::I8, 1); + for (i, &b) in bytes[..n].iter().enumerate() { + let offset = i32::try_from(i).expect("inline literal byte offset fits i32"); + let byte = builder + .ins() + .load(types::I8, MemFlagsData::trusted(), other_ptr, offset); + let expect = builder.ins().iconst(types::I8, i64::from(b)); + let eq = builder.ins().icmp(IntCC::Equal, byte, expect); + acc = builder.ins().band(acc, eq); + } + builder.ins().jump(merge_block, &[BlockArg::Value(acc)]); + + builder.seal_block(false_block); + builder.switch_to_block(false_block); + let zero = builder.ins().iconst(types::I8, 0); + builder.ins().jump(merge_block, &[BlockArg::Value(zero)]); + + // Two predecessors (both jumps above), all added. + builder.seal_block(merge_block); + builder.switch_to_block(merge_block); + builder.block_params(merge_block)[0] + } +} diff --git a/ryo-backend/src/codegen/tests.rs b/ryo-backend/src/codegen/tests.rs index 1831c3c..2634f77 100644 --- a/ryo-backend/src/codegen/tests.rs +++ b/ryo-backend/src/codegen/tests.rs @@ -35,92 +35,3 @@ fn value_repr_expect_scalar_panics_on_str() { repr.expect_scalar(); } -/// The three targets CI and the toolchain support: Linux x86-64, -/// Windows x86-64 (MSVC ABI), macOS aarch64. -const SUPPORTED_TRIPLES: [&str; 3] = [ - "x86_64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "aarch64-apple-darwin", -]; - -/// Build a minimal function returning an i128 (the packed {ptr, len} -/// shape the string runtime ABI uses) and compile it with the given -/// flags. Returns the emitted machine code byte count. -fn compile_i128_return(flags: settings::Flags, triple: &str) -> Result { - let triple: Triple = triple - .parse() - .map_err(|e| format!("bad triple {triple}: {e}"))?; - let isa = isa::lookup(triple) - .map_err(|e| format!("isa lookup: {e}"))? - .finish(flags) - .map_err(|e| format!("isa build: {e}"))?; - - let mut sig = Signature::new(isa.default_call_conv()); - sig.returns.push(AbiParam::new(types::I128)); - let mut func = cranelift::codegen::ir::Function::with_name_signature( - cranelift::codegen::ir::UserFuncName::user(0, 0), - sig, - ); - { - let mut fb_ctx = FunctionBuilderContext::new(); - let frontend_config = isa.frontend_config(); - let mut fb = FunctionBuilder::new(&mut func, &mut fb_ctx); - let block = fb.create_block(); - fb.switch_to_block(block); - // iconst only supports i8-i64; build the i128 via uextend. - let lo = fb.ins().iconst(types::I64, 42); - let pair = fb.ins().uextend(types::I128, lo); - fb.ins().return_(&[pair]); - fb.seal_all_blocks(); - fb.finalize(frontend_config); - } - - let mut ctx = cranelift::codegen::Context::for_function(func); - ctx.compile( - &*isa, - &mut cranelift::codegen::control::ControlPlane::default(), - ) - .map_err(|e| format!("compile: {e:?}"))?; - let code = ctx - .compiled_code() - .ok_or_else(|| "no compiled code".to_string())?; - Ok(code.code_buffer().len()) -} - -#[test] -fn aot_i128_return_compiles_on_all_supported_targets() { - // The packed-u128 string ABI puts an i128 in every producing - // function's signature; the x64 ABI must accept it (LLVM ABI - // extensions) on Linux AND Windows, and aarch64 must keep working. - for triple in SUPPORTED_TRIPLES { - let flags = aot_shared_flags().expect("shared flags"); - let len = compile_i128_return(flags, triple) - .unwrap_or_else(|e| panic!("i128 return must compile for {triple}: {e}")); - assert!(len > 0, "empty machine code for {triple}"); - } -} - -#[test] -fn x64_i128_return_panics_without_llvm_abi_extensions() { - // Pins the failure mode this fix addresses: without the flag, the - // x64 ABI rejects i128 in signatures. If this test starts failing - // (no panic), Cranelift changed its gating — re-audit the flag. - let mut b = settings::builder(); - b.set("opt_level", "speed").expect("opt_level"); - let flags = settings::Flags::new(b); - for triple in ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _ = compile_i128_return(flags.clone(), triple); - })); - let err = result.expect_err("i128 return must panic without llvm abi extensions"); - let msg = err - .downcast_ref::() - .cloned() - .or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string())) - .unwrap_or_default(); - assert!( - msg.contains("i128 args/return values not supported"), - "unexpected panic for {triple}: {msg}" - ); - } -} diff --git a/ryo/tests/integration_driver.rs b/ryo/tests/integration_driver.rs index e23eb4d..91e3163 100644 --- a/ryo/tests/integration_driver.rs +++ b/ryo/tests/integration_driver.rs @@ -447,8 +447,9 @@ fn assert_explicit_24byte_slots(clif: &str, expected: usize) { fn clif_string_ops_slot_out_producers() { // Slot-out runtime ABI: string producers (`int_to_str`, from_view, // conversions, concat) write a tagged 24-byte slot passed as arg 0 - // and return nothing; literals and slices still return {ptr, len} - // packed in one u128. Slots in this program: 5 extraction scratch + // and return nothing; literals and slices are inlined as constants + // and pointer arithmetic — no packed-u128 call remains anywhere. + // Slots in this program: 5 extraction scratch // slots (the `"a" + "b"` operands, the `s + t` operands, and the // print arg — one fresh slot per extraction site) + 3 slot-out // call slots (the `"a" + "b"` concat, `int_to_str`, and the @@ -470,8 +471,8 @@ fn clif_string_ops_slot_out_producers() { let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("-> i128"), - "literal runtime calls still return the packed u128 pair: {}", + !stdout.contains("-> i128"), + "literal/slice packing is inlined; no call may return the packed u128 pair: {}", stdout ); assert_explicit_24byte_slots(&stdout, 8); @@ -502,8 +503,8 @@ fn clif_bytes_ops_slot_out_producers() { let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("-> i128"), - "literal/slice runtime calls still return the packed u128 pair: {}", + !stdout.contains("-> i128"), + "literal/slice packing is inlined; no call may return the packed u128 pair: {}", stdout ); assert_explicit_24byte_slots(&stdout, 9); @@ -588,12 +589,11 @@ fn clif_entry_block(clif: &str) -> &str { #[test] fn clif_str_literal_materialized_once_per_function() { - // A string literal is pure .rodata packing with no side effects, - // so each distinct literal must be materialized exactly once per - // function — hoisted into the entry block — instead of emitting a - // fresh ryo_str_from_literal call at every use (loop bodies - // included). `ryo_str_from_literal(ptr, len) -> i128` is the only - // (i64, i64) -> i128 runtime call this program can emit. + // A string literal is pure .rodata packing (`symbol_value` + + // `iconst`, no runtime call), so each distinct literal must be + // materialized exactly once per function — hoisted into the entry + // block — instead of re-packing the same (ptr, len) at every use + // (loop bodies included). let temp_dir = TempDir::new().expect("Failed to create temp directory"); let test_file = create_test_file( temp_dir.path(), @@ -610,20 +610,21 @@ fn clif_str_literal_materialized_once_per_function() { ); let stdout = String::from_utf8_lossy(&output.stdout); - let is_from_literal = |sig: &str| sig.starts_with("(i64, i64) -> i128"); - let from_literal_fns = clif_fns_matching_sig(&stdout, is_from_literal); - // Two distinct literals ("the quick brown fox", "fox") — "fox" - // appears at two source sites but must materialize only once. - assert_eq!( - count_calls_to(&stdout, &from_literal_fns), - 2, - "each distinct literal must be materialized exactly once per function: {}", + assert!( + !stdout.contains("-> i128"), + "no runtime call may return the packed u128 pair anymore: {}", stdout ); + // Two distinct literals ("the quick brown fox", "fox") — "fox" + // appears at two source sites but must materialize only once, in + // the entry block. + let entry_symbol_values = clif_entry_block(&stdout) + .lines() + .filter(|l| l.contains("symbol_value")) + .count(); assert_eq!( - count_calls_to(clif_entry_block(&stdout), &from_literal_fns), - 2, - "literal materializations must be hoisted out of the loop into the entry block: {}", + entry_symbol_values, 2, + "each distinct literal must be materialized exactly once, hoisted into the entry block: {}", stdout ); } From f48bcff7e4fbd08ff6b88b43f802fc88d175e0df Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 02:21:06 +0200 Subject: [PATCH 02/11] fix: cache runtime imports per module, unify JIT symbol table (I-093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit declare_runtime_fn re-imported the same runtime function at every use site (two int_to_str calls = two import declarations), and the JIT symbol list was hand-synced with the call sites — including dead registrations for ryo_str_alloc / ryo_bytes_alloc, which codegen never calls. - Codegen gains a module-level HashMap<&'static str, FuncId> cache, threaded into FunctionContext like guard_msg_data; cache hit reuses the import and only re-derives the cheap per-function FuncRef - declare_runtime_fn takes the FunctionContext (and &'static str names) instead of a bare module; declare_str_free / declare_bytes_free wrappers follow - The JIT symbol list is a single runtime_symbols() table; the two dead alloc registrations are dropped CLIF-verified: two int_to_str calls in one function now share one imported FuncId. --- ryo-backend/src/codegen/arith.rs | 2 +- ryo-backend/src/codegen/bytes.rs | 18 ++-- ryo-backend/src/codegen/expr.rs | 75 ++++++++------- ryo-backend/src/codegen/mod.rs | 144 ++++++++++++++++------------- ryo-backend/src/codegen/str_ops.rs | 14 ++- ryo-backend/src/codegen/structs.rs | 4 +- ryo-backend/src/codegen/tests.rs | 1 - ryo-backend/src/codegen/views.rs | 7 +- 8 files changed, 140 insertions(+), 125 deletions(-) diff --git a/ryo-backend/src/codegen/arith.rs b/ryo-backend/src/codegen/arith.rs index 6f2f1f8..48aecb3 100644 --- a/ryo-backend/src/codegen/arith.rs +++ b/ryo-backend/src/codegen/arith.rs @@ -324,7 +324,7 @@ impl Codegen { let ptr = builder.ins().symbol_value(ctx.int_type, data_ref); let len = builder.ins().iconst(types::I64, msg.len() as i64); let panic_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "ryo_panic", // Runtime contract: ryo_panic(ptr, len: u64) — the diff --git a/ryo-backend/src/codegen/bytes.rs b/ryo-backend/src/codegen/bytes.rs index fa0139c..878f3ca 100644 --- a/ryo-backend/src/codegen/bytes.rs +++ b/ryo-backend/src/codegen/bytes.rs @@ -42,20 +42,14 @@ impl Codegen { /// the function being built — the bytes-family counterpart of /// `declare_str_free`. Returns a `FuncRef` callable via /// `builder.ins().call(_, &[ptr, cap])`. `cap == 0` is a runtime - /// no-op (covers static `.rodata` payloads emitted by - /// `ryo_bytes_from_literal`). + /// no-op (covers static `.rodata` payloads materialized by + /// `emit_bytes_literal_fat`). pub(crate) fn declare_bytes_free( - module: &mut M, + ctx: &mut FunctionContext<'_, M>, builder: &mut FunctionBuilder, - int_type: types::Type, ) -> Result { - Self::declare_runtime_fn( - module, - builder, - "ryo_bytes_free", - &[int_type, types::I64], - &[], - ) + let int_type = ctx.int_type; + Self::declare_runtime_fn(ctx, builder, "ryo_bytes_free", &[int_type, types::I64], &[]) } /// True when a scheduled Free target is a `bytes` owner (M8.4.2) — @@ -84,7 +78,7 @@ impl Codegen { let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; let eq_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "ryo_bytes_eq", &[ctx.int_type, types::I64, ctx.int_type, types::I64], diff --git a/ryo-backend/src/codegen/expr.rs b/ryo-backend/src/codegen/expr.rs index 687e0bf..e9c58c8 100644 --- a/ryo-backend/src/codegen/expr.rs +++ b/ryo-backend/src/codegen/expr.rs @@ -314,7 +314,7 @@ impl Codegen { let (ptr, len) = Self::eval_str_or_view_parts(builder, ctx, base)?; let idx = Self::eval_inst(builder, ctx, index)?; let index_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "__ryo_bytes_index", &[ctx.int_type, types::I64, types::I64], @@ -366,25 +366,36 @@ impl Codegen { } /// Declare an external runtime function by name and return a - /// `FuncRef` usable in the current function being built. + /// `FuncRef` usable in the current function being built. The + /// module-level import is cached in `Codegen::runtime_fns` (one + /// `declare_function` per symbol per module); only the cheap + /// per-function `FuncRef` is derived per call site. pub(crate) fn declare_runtime_fn( - module: &mut M, + ctx: &mut FunctionContext<'_, M>, builder: &mut FunctionBuilder, - name: &str, + name: &'static str, params: &[types::Type], returns: &[types::Type], ) -> Result { - let mut sig = module.make_signature(); - for &p in params { - sig.params.push(AbiParam::new(p)); - } - for &r in returns { - sig.returns.push(AbiParam::new(r)); - } - let func_id = module - .declare_function(name, Linkage::Import, &sig) - .map_err(|e| format!("Failed to declare {}: {}", name, e))?; - Ok(module.declare_func_in_func(func_id, builder.func)) + let func_id = match ctx.runtime_fns.get(name) { + Some(&func_id) => func_id, + None => { + let mut sig = ctx.module.make_signature(); + for &p in params { + sig.params.push(AbiParam::new(p)); + } + for &r in returns { + sig.returns.push(AbiParam::new(r)); + } + let func_id = ctx + .module + .declare_function(name, Linkage::Import, &sig) + .map_err(|e| format!("Failed to declare {}: {}", name, e))?; + ctx.runtime_fns.insert(name, func_id); + func_id + } + }; + Ok(ctx.module.declare_func_in_func(func_id, builder.func)) } /// True if a `FreePoint` with the given `branch` tag is eligible @@ -531,9 +542,9 @@ impl Codegen { return Ok(*f); } let f = if is_bytes { - Self::declare_bytes_free(ctx.module, builder, ctx.int_type)? + Self::declare_bytes_free(ctx, builder)? } else { - Self::declare_str_free(ctx.module, builder, ctx.int_type)? + Self::declare_str_free(ctx, builder)? }; *slot = Some(f); Ok(f) @@ -655,9 +666,9 @@ impl Codegen { continue; }; let free_ref = if Self::free_target_is_bytes(ctx, drop.target) { - Self::declare_bytes_free(ctx.module, builder, ctx.int_type)? + Self::declare_bytes_free(ctx, builder)? } else { - Self::declare_str_free(ctx.module, builder, ctx.int_type)? + Self::declare_str_free(ctx, builder)? }; let ptr = builder.use_var(sl.ptr); let cap = builder.use_var(sl.cap); @@ -724,17 +735,11 @@ impl Codegen { /// no-op (covers static `.rodata` strings materialized by /// `emit_str_literal_fat`). pub(crate) fn declare_str_free( - module: &mut M, + ctx: &mut FunctionContext<'_, M>, builder: &mut FunctionBuilder, - int_type: types::Type, ) -> Result { - Self::declare_runtime_fn( - module, - builder, - "ryo_str_free", - &[int_type, types::I64], - &[], - ) + let int_type = ctx.int_type; + Self::declare_runtime_fn(ctx, builder, "ryo_str_free", &[int_type, types::I64], &[]) } /// Call a slot-out runtime producer: allocate a 24-byte slot, pass @@ -744,7 +749,7 @@ impl Codegen { pub(crate) fn emit_slot_out_call( builder: &mut FunctionBuilder, ctx: &mut FunctionContext<'_, M>, - fn_name: &str, + fn_name: &'static str, args: &[(Type, Value)], ) -> Result<(Value, Value, Value), String> { let slot = builder.create_sized_stack_slot(StackSlotData::new( @@ -756,7 +761,7 @@ impl Codegen { let mut param_tys = Vec::with_capacity(args.len() + 1); param_tys.push(ctx.int_type); param_tys.extend(args.iter().map(|(ty, _)| *ty)); - let func_ref = Self::declare_runtime_fn(ctx.module, builder, fn_name, ¶m_tys, &[])?; + let func_ref = Self::declare_runtime_fn(ctx, builder, fn_name, ¶m_tys, &[])?; let mut call_args = Vec::with_capacity(args.len() + 1); call_args.push(addr); call_args.extend(args.iter().map(|(_, v)| *v)); @@ -1293,7 +1298,7 @@ impl Codegen { } } let panic_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "ryo_panic", // Runtime contract: ryo_panic(ptr, len: u64) — the @@ -1329,7 +1334,7 @@ impl Codegen { ); let (ptr, len) = Self::eval_str_or_view_parts(builder, ctx, view.args[0])?; let print_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "ryo_print", &[ctx.int_type, types::I64], @@ -1367,7 +1372,7 @@ impl Codegen { // conversion, so sema accepts `Str | View(_)` here). let (suf_ptr, suf_len) = Self::eval_str_or_view_parts(builder, ctx, suffix_ref)?; let func_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "__ryo_str_push", &[ctx.int_type, ctx.int_type, types::I64], @@ -1419,7 +1424,7 @@ impl Codegen { .store(MemFlagsData::trusted(), cap, b_addr, 16); let x_val = Self::eval_inst(builder, ctx, x_ref)?; let func_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "__ryo_bytes_push", &[ctx.int_type, types::I64], @@ -1679,7 +1684,7 @@ impl Codegen { // is shared, and appending valid-UTF-8 + valid-UTF-8 stays // valid (no boundary check needed). let push_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "__ryo_str_push", &[ctx.int_type, ctx.int_type, types::I64], diff --git a/ryo-backend/src/codegen/mod.rs b/ryo-backend/src/codegen/mod.rs index b724579..0eb5677 100644 --- a/ryo-backend/src/codegen/mod.rs +++ b/ryo-backend/src/codegen/mod.rs @@ -131,6 +131,13 @@ pub struct Codegen { /// pass through the `InternPool`, so they are keyed on the static /// text itself. guard_msg_data: HashMap<&'static str, DataId>, + /// Module-level name → `FuncId` cache for imported runtime + /// functions: one `declare_function` import per symbol per module, + /// regardless of how many call sites (or functions) use it. + /// `FuncId`s are module-scoped, so this is valid for every + /// function compiled into the same module; the per-function + /// `FuncRef` is derived cheaply via `declare_func_in_func`. + runtime_fns: HashMap<&'static str, FuncId>, } /// Overflow guard message for the spec §18 checked-arithmetic traps. @@ -370,6 +377,9 @@ pub(crate) struct FunctionContext<'a, M: Module> { /// Module-level cache for compiler-generated guard messages; /// see `Codegen::guard_msg_data`. guard_msg_data: &'a mut HashMap<&'static str, DataId>, + /// Module-level import cache for runtime functions; see + /// `Codegen::runtime_fns`. + runtime_fns: &'a mut HashMap<&'static str, FuncId>, /// Cold panic blocks for guard failures (overflow, div-by-zero), /// paired with their message so all guards with the same message /// share one block. A Vec (not a map) keeps the drain order @@ -392,6 +402,7 @@ impl Codegen { data_ctx: DataDescription::new(), string_data: HashMap::new(), guard_msg_data: HashMap::new(), + runtime_fns: HashMap::new(), } } } @@ -450,6 +461,73 @@ impl Codegen { } } +/// Every runtime symbol the JIT must resolve, with its address. This +/// table is the single source of truth for JIT registration — the +/// names must stay in sync with the string literals codegen passes to +/// `declare_runtime_fn` (the module-level import cache is keyed on the +/// same names). Functions whose bodies codegen now inlines (literal +/// packing, slicing) are deliberately absent. +fn runtime_symbols() -> [(&'static str, *const u8); 21] { + [ + ("ryo_str_concat", ryo_runtime::ryo_str_concat as *const u8), + ("__ryo_str_push", ryo_runtime::__ryo_str_push as *const u8), + ( + "__ryo_str_ensure_heap", + ryo_runtime::__ryo_str_ensure_heap as *const u8, + ), + ( + "__ryo_bytes_ensure_heap", + ryo_runtime::__ryo_bytes_ensure_heap as *const u8, + ), + ("ryo_str_eq", ryo_runtime::ryo_str_eq as *const u8), + ("ryo_int_to_str", ryo_runtime::ryo_int_to_str as *const u8), + ( + "ryo_str_from_view", + ryo_runtime::ryo_str_from_view as *const u8, + ), + ( + "ryo_float_to_str", + ryo_runtime::ryo_float_to_str as *const u8, + ), + ("ryo_bool_to_str", ryo_runtime::ryo_bool_to_str as *const u8), + ("ryo_str_free", ryo_runtime::ryo_str_free as *const u8), + // M8.4.2 bytes family — names match the runtime's + // `#[unsafe(no_mangle)]` exports verbatim. + ( + "ryo_bytes_concat", + ryo_runtime::ryo_bytes_concat as *const u8, + ), + ( + "__ryo_bytes_push", + ryo_runtime::__ryo_bytes_push as *const u8, + ), + ( + "__ryo_bytes_index", + ryo_runtime::__ryo_bytes_index as *const u8, + ), + ("ryo_bytes_eq", ryo_runtime::ryo_bytes_eq as *const u8), + ( + "ryo_bytes_from_view", + ryo_runtime::ryo_bytes_from_view as *const u8, + ), + ( + "__ryo_bytes_to_str", + ryo_runtime::__ryo_bytes_to_str as *const u8, + ), + ( + "__ryo_str_to_bytes", + ryo_runtime::__ryo_str_to_bytes as *const u8, + ), + ( + "__ryo_bytes_repr", + ryo_runtime::__ryo_bytes_repr as *const u8, + ), + ("ryo_bytes_free", ryo_runtime::ryo_bytes_free as *const u8), + ("ryo_print", ryo_runtime::ryo_print as *const u8), + ("ryo_panic", ryo_runtime::ryo_panic as *const u8), + ] +} + impl Codegen { pub fn new_jit() -> Result { // opt_level=speed: run the egraph optimization pipeline (constant @@ -473,66 +551,7 @@ impl Codegen { .map_err(|e| format!("Failed to create JIT builder: {}", e))?; // Register runtime symbols so the JIT can resolve them. - jit_builder.symbols([ - ("ryo_str_alloc", ryo_runtime::ryo_str_alloc as *const u8), - ("ryo_str_concat", ryo_runtime::ryo_str_concat as *const u8), - ("__ryo_str_push", ryo_runtime::__ryo_str_push as *const u8), - ( - "__ryo_str_ensure_heap", - ryo_runtime::__ryo_str_ensure_heap as *const u8, - ), - ( - "__ryo_bytes_ensure_heap", - ryo_runtime::__ryo_bytes_ensure_heap as *const u8, - ), - ("ryo_str_eq", ryo_runtime::ryo_str_eq as *const u8), - ("ryo_int_to_str", ryo_runtime::ryo_int_to_str as *const u8), - ( - "ryo_str_from_view", - ryo_runtime::ryo_str_from_view as *const u8, - ), - ( - "ryo_float_to_str", - ryo_runtime::ryo_float_to_str as *const u8, - ), - ("ryo_bool_to_str", ryo_runtime::ryo_bool_to_str as *const u8), - ("ryo_str_free", ryo_runtime::ryo_str_free as *const u8), - // M8.4.2 bytes family — names match the runtime's - // `#[unsafe(no_mangle)]` exports verbatim. - ("ryo_bytes_alloc", ryo_runtime::ryo_bytes_alloc as *const u8), - ( - "ryo_bytes_concat", - ryo_runtime::ryo_bytes_concat as *const u8, - ), - ( - "__ryo_bytes_push", - ryo_runtime::__ryo_bytes_push as *const u8, - ), - ( - "__ryo_bytes_index", - ryo_runtime::__ryo_bytes_index as *const u8, - ), - ("ryo_bytes_eq", ryo_runtime::ryo_bytes_eq as *const u8), - ( - "ryo_bytes_from_view", - ryo_runtime::ryo_bytes_from_view as *const u8, - ), - ( - "__ryo_bytes_to_str", - ryo_runtime::__ryo_bytes_to_str as *const u8, - ), - ( - "__ryo_str_to_bytes", - ryo_runtime::__ryo_str_to_bytes as *const u8, - ), - ( - "__ryo_bytes_repr", - ryo_runtime::__ryo_bytes_repr as *const u8, - ), - ("ryo_bytes_free", ryo_runtime::ryo_bytes_free as *const u8), - ("ryo_print", ryo_runtime::ryo_print as *const u8), - ("ryo_panic", ryo_runtime::ryo_panic as *const u8), - ]); + jit_builder.symbols(runtime_symbols()); Ok(Self::from_module(JITModule::new(jit_builder))) } @@ -994,6 +1013,7 @@ impl Codegen { sidecar: func_sidecar, branch_stack: Vec::new(), guard_msg_data: &mut self.guard_msg_data, + runtime_fns: &mut self.runtime_fns, panic_blocks: Vec::new(), }; @@ -1437,9 +1457,9 @@ impl Codegen { // its emission point and may be stale across reassigns. if ctx.sidecar.free_on_reassign[r.index()].is_some() { let free_ref = if matches!(ctx.pool.kind(inst.ty), TypeKind::Bytes) { - Self::declare_bytes_free(ctx.module, builder, ctx.int_type)? + Self::declare_bytes_free(ctx, builder)? } else { - Self::declare_str_free(ctx.module, builder, ctx.int_type)? + Self::declare_str_free(ctx, builder)? }; let old_ptr = builder.use_var(locals.ptr); let old_cap = builder.use_var(locals.cap); diff --git a/ryo-backend/src/codegen/str_ops.rs b/ryo-backend/src/codegen/str_ops.rs index bf484b2..6872a4b 100644 --- a/ryo-backend/src/codegen/str_ops.rs +++ b/ryo-backend/src/codegen/str_ops.rs @@ -41,9 +41,7 @@ impl Codegen { end: Value, is_bytes: bool, ) -> Result<(Value, Value), String> { - let start_gt_end = builder - .ins() - .icmp(IntCC::UnsignedGreaterThan, start, end); + let start_gt_end = builder.ins().icmp(IntCC::UnsignedGreaterThan, start, end); let end_gt_len = builder .ins() .icmp(IntCC::UnsignedGreaterThan, end, base_len); @@ -84,7 +82,9 @@ impl Codegen { let check_block = builder.create_block(); let cont_block = builder.create_block(); - builder.ins().brif(is_edge, cont_block, &[], check_block, &[]); + builder + .ins() + .brif(is_edge, cont_block, &[], check_block, &[]); // Single predecessor (the brif above) — seal immediately. builder.seal_block(check_block); @@ -152,15 +152,13 @@ impl Codegen { Self::emit_literal_eq(builder, other_ptr, other_len, n, &bytes) } else { let eq_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, "ryo_str_eq", &[ctx.int_type, types::I64, ctx.int_type, types::I64], &[types::I8], )?; - let call = builder - .ins() - .call(eq_ref, &[l_ptr, l_len, r_ptr, r_len]); + let call = builder.ins().call(eq_ref, &[l_ptr, l_len, r_ptr, r_len]); builder.inst_results(call)[0] }; diff --git a/ryo-backend/src/codegen/structs.rs b/ryo-backend/src/codegen/structs.rs index 13a7ef3..813718b 100644 --- a/ryo-backend/src/codegen/structs.rs +++ b/ryo-backend/src/codegen/structs.rs @@ -343,9 +343,9 @@ impl Codegen { .ins() .load(types::I64, MemFlagsData::trusted(), base, off + 16); let free_ref = if matches!(ctx.pool.kind(field_ty), TypeKind::Bytes) { - Self::declare_bytes_free(ctx.module, builder, ctx.int_type)? + Self::declare_bytes_free(ctx, builder)? } else { - Self::declare_str_free(ctx.module, builder, ctx.int_type)? + Self::declare_str_free(ctx, builder)? }; builder.ins().call(free_ref, &[ptr, cap]); Ok(()) diff --git a/ryo-backend/src/codegen/tests.rs b/ryo-backend/src/codegen/tests.rs index 2634f77..4a81b2a 100644 --- a/ryo-backend/src/codegen/tests.rs +++ b/ryo-backend/src/codegen/tests.rs @@ -34,4 +34,3 @@ fn value_repr_expect_scalar_panics_on_str() { }; repr.expect_scalar(); } - diff --git a/ryo-backend/src/codegen/views.rs b/ryo-backend/src/codegen/views.rs index a8b2d0d..0f05d58 100644 --- a/ryo-backend/src/codegen/views.rs +++ b/ryo-backend/src/codegen/views.rs @@ -61,8 +61,7 @@ impl Codegen { } else { "__ryo_str_ensure_heap" }; - let func_ref = - Self::declare_runtime_fn(ctx.module, builder, callee, &[ctx.int_type], &[])?; + let func_ref = Self::declare_runtime_fn(ctx, builder, callee, &[ctx.int_type], &[])?; builder.ins().call(func_ref, &[addr]); let out_ptr = builder .ins() @@ -207,7 +206,7 @@ impl Codegen { "ryo_str_free" }; let free_ref = Self::declare_runtime_fn( - ctx.module, + ctx, builder, free_callee, &[ctx.int_type, types::I64], @@ -234,7 +233,7 @@ impl Codegen { } else { "__ryo_str_ensure_heap" }; - let func_ref = Self::declare_runtime_fn(ctx.module, builder, callee, &[ctx.int_type], &[])?; + let func_ref = Self::declare_runtime_fn(ctx, builder, callee, &[ctx.int_type], &[])?; builder.ins().call(func_ref, &[addr]); let out_ptr = builder .ins() From c32441c81880ed6cdb19f22d3c9786c6cc46c8d3 Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 02:22:38 +0200 Subject: [PATCH 03/11] docs: retire resolved I-093/I-161/I-181, refresh string_slicing numbers The three entries are resolved by the preceding two commits on this branch; numbers stay retired. I-144's stale cross-ref to I-093 (the per-use re-import it cited is now cached) is trimmed. The string_slicing README's 'planned fix' section is rewritten past-tense with the 2026-09-17 checkpoint: AOT 5.1 -> 3.4 ms, JIT 7.4 -> 4.7 ms, 2.27x vs Rust. --- ISSUES.md | 22 ++------------------ benchmarks/string_slicing/README.md | 31 ++++++++++++++++++----------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 6ba4783..3d81970 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -201,12 +201,6 @@ Resolved entries are **removed** from this file. Language-visible decisions behi **Summary:** (a) `inst_map` is `vec![None; uir.instructions.len()]` — the program-wide UIR size — allocated per function; (b) `check_call` clones `callee_modes`, `sig.params`, and builds `modes`/`arg_tirs` per call (3-4 allocations); (c) method dispatch does `pool.str(..).to_string()` per method call site, allocated even before the receiver-type check. **Resolution:** (a) `HashMap` or per-function UIR slice (the expr memo is the only consumer that needs random access); (b) borrow from the signatures table instead of cloning; (c) match on pre-interned `StringId`s for `len`/`is_empty` instead of a `String`. -### I-093 — Runtime functions are re-imported per use site; JIT symbol list is hand-synced - -**Files:** `ryo-backend/src/codegen/expr.rs` (`declare_runtime_fn` :507-525 and call sites), `ryo-backend/src/codegen/mod.rs` (JIT symbol table; dead `ryo_str_alloc` registration :354) -**Summary:** No name→`FuncId` cache exists; two `int_to_str` calls in one function produce two import declarations. Same for libc `write` and `exit`. Additionally `ryo_str_alloc` is registered in the JIT symbol table (`codegen/mod.rs:354`) with no call site anywhere — the symbol list and the call sites are kept in sync by hand. -**Resolution:** Add a per-module `HashMap<&'static str, FuncId>` cache on `Codegen`; drive the JIT symbol list from the same table. - ### I-094 — `compile_function` renders CLIF text unconditionally **Files:** `ryo-backend/src/codegen/mod.rs` (:800, discarded at :445) @@ -294,8 +288,8 @@ Resolved entries are **removed** from this file. Language-visible decisions behi ### I-144 — Per-if clone and repeated dead-drop scans in codegen **Files:** `ryo-backend/src/codegen/mod.rs` (`if_branches.get(...).cloned().unwrap_or_default()` :1196; called per arm :1237/:1273/:1289/:1305), `ryo-backend/src/codegen/expr.rs` (`emit_conditional_dead_drops` :703-719) -**Summary:** Every if-statement clones the `IfBranchIds` payload (heap `Vec` for elif branches) out of the sidecar even when there is no entry, because `.cloned().unwrap_or_default()` goes through `ctx`. Separately, `emit_conditional_dead_drops` re-scans the whole per-function `conditional_dead_drops` Vec at the start of *every* if arm with no empty-check early exit, and re-imports `ryo_str_free` inside the drop loop (`expr.rs:713`, cross-ref I-093). On if-heavy functions with dead drops this is O(ifs × arms × drops). -**Resolution:** Borrow the sidecar out of `ctx` first so `get` returns a reference instead of cloning; add the same `is_empty()` early-return `emit_due_frees` already has or index dead drops by `if_stmt` in a map built once per function; hoist the `ryo_str_free` import out of the loop. +**Summary:** Every if-statement clones the `IfBranchIds` payload (heap `Vec` for elif branches) out of the sidecar even when there is no entry, because `.cloned().unwrap_or_default()` goes through `ctx`. Separately, `emit_conditional_dead_drops` re-scans the whole per-function `conditional_dead_drops` Vec at the start of *every* if arm with no empty-check early exit. On if-heavy functions with dead drops this is O(ifs × arms × drops). +**Resolution:** Borrow the sidecar out of `ctx` first so `get` returns a reference instead of cloning; add the same `is_empty()` early-return `emit_due_frees` already has or index dead drops by `if_stmt` in a map built once per function. ### I-145 — Ownership materializes the full states map per break/continue @@ -345,12 +339,6 @@ Resolved entries are **removed** from this file. Language-visible decisions behi **Summary:** On Linux, `ryo build` links natively via `zig cc` with no `-target`, so binaries are dynamically coupled to whatever glibc the build host has — a silent portability gap, not a decision. The runtime staticlib is already `no_std`, so produced binaries need almost nothing from libc, which makes fully static musl (`-target -linux-musl`) nearly free and matches where Go (no libc), Rust (musl tier-1 opt-in), and Swift (Static Linux SDK) all converged. macOS (libSystem, dynamic mandatory) and Windows (MSVC ABI + UCRT via zig) need no equivalent change. **Resolution:** Before applying, re-verify the drawbacks: (1) musl mallocng is slow under multithreaded allocation-heavy load — matters once Go-style concurrency and `shared[T]` refcount churn land; may force shipping our own allocator in `ryo-runtime` first; (2) no NSS, limited `getaddrinfo`, no dlopen of glibc-built libs. If accepted: pass `-target -linux-musl` in `linker.rs` and switch the `build-support` archive build to the matching `*-unknown-linux-musl` triple in the same change (the two must move together), then check what the ASan/Valgrind smoke lanes still exercise under a static link. -### I-161 — Tiny runtime string ops cross the extern-call boundary per use - -**Files:** `ryo-backend/src/codegen/expr.rs` (`ryo_str_eq` call :282, `__ryo_slice` call :1022, `ryo_str_from_literal` call :1132), `runtime/src/lib.rs` (bodies: `ryo_str_from_literal` :251, `__ryo_slice` :319, `ryo_str_eq` :448) -**Summary:** Codegen imports these as opaque extern calls, so every use pays a full call that Cranelift can neither inline nor hoist. The bodies are a handful of instructions: `ryo_str_from_literal` is just `pack_pair` (shift + or), `__ryo_slice` is two bounds checks, two UTF-8 boundary tests, and a `ptr.add`, and `ryo_str_eq` against a short literal is a few byte compares. In `benchmarks/string_slicing` the scan loop makes three such calls per iteration (slice + literal materialization + eq) where Rust inlines all of it to pointer arithmetic and a 3-byte memcmp — the bulk of the measured 3.5× AOT gap (CLIF verified 2026-08-26: the `str`/`strview` param variants are instruction-identical in the loop except for these calls, and a same-compiler A/B ties at 5.9 ms both ways). -**Resolution:** Emit the tiny bodies as inline Cranelift IR at the call sites instead of extern calls (slice keeps its panic paths; eq can specialize when one side is a known short literal). Literal re-materialization is already handled (each distinct literal is emitted once per function in the entry block); inlining `pack_pair` would remove the remaining extern call from that one materialization. Larger ops (`ryo_str_concat`, `__ryo_str_push`) stay extern. - ### I-166 — Sema does not reject constant `INT_MIN / -1` at compile time **Files:** `ryo-frontend/src/sema.rs` (the literal-zero division check), `ryo-backend/src/codegen/expr.rs` (`emit_div_guard`) @@ -393,12 +381,6 @@ Resolved entries are **removed** from this file. Language-visible decisions behi **Summary:** `int_to_str(i64)` produces at most 20 chars — always under the 23-byte inline capacity — so its result is provably always-inline, yet codegen treats it as an opaque producer: an address-taken stack slot (defeating register allocation and forcing every use through memory), plus an unconditional `ryo_str_free` extern call that is a guaranteed no-op on the inline tag. Same shape for the other bounded producers (bool/char/float formatters, small conversions). **Resolution:** Add a max-output-length annotation to the builtin registry; when it is ≤ the inline capacity, (1) return the tagged slot by value in registers (multi-value return) instead of slot-out, so the value only touches the stack if spilled, and (2) elide `ryo_str_free` for that value entirely — the inline tag is statically known, generalizing the elision the cap=0 static-literal path already performs. -### I-181 — `(ptr, len)` pairs flow through codegen as packed i128; extracting a half costs a 128-bit shift legalization - -**Files:** `ryo-backend/src/codegen/expr.rs` (slice / `ryo_str_eq` call sites and view value representation), `ryo-core/src/tir.rs` (how pair values are typed), `runtime/src/lib.rs` (`pack_pair`) -**Summary:** Slice results and literal values are packed `(ptr, len)` pairs represented as i128, so extracting one half is a 128-bit shift — which Cranelift legalizes into a ~9-instruction funnel-shift/select sequence (`lsr`/`lsl`/`orr`/`csel`) instead of the register move it already is. Disassembly of `benchmarks/string_slicing`'s `count_fox` (aarch64, 2026-09-15): two such sequences per scan iteration, one to unpack the `__ryo_slice` result and one to unpack the literal — ~18 wasted instructions × 700k iterations ≈ 12.6M instructions, on top of the extern-call overhead tracked separately. Inlining the slice/eq bodies will not remove this if the values keep flowing as i128. -**Resolution:** Stop representing small pair values as packed i128 end to end. The C ABI does not require it: on aarch64/x86-64 SysV a `u128` return and a `#[repr(C)]` two-`u64` struct return occupy the same two registers, so changing the runtime signatures (`__ryo_slice` :427, `ryo_str_from_literal` :358, both currently `-> u128` via `pack_pair` :270) to return a repr(C) pair — and modeling views as two i64 SSA values in TIR/codegen — is machine-identical at the boundary while eliminating the i128 type that triggers the legalization. Verify Cranelift maps the two-register struct return correctly on the Windows x64 target (different struct-return convention there) before committing to the signature change. - ### I-183 — View-liveness back-edge merge is one-pass first-wins; reads inside a loop are attributed to the pre-loop slice **Files:** `ryo-frontend/src/ownership/views.rs` (`collect_view_liveness` / `view_liveness_loop_body` back-edge merge :603-621), `ryo-frontend/src/ownership/mod.rs` (promo scheduling fallback that compensates :700-731) diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index a3c738a..b408333 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -4,22 +4,17 @@ **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). -## Why Ryo trails here: a runtime call per operation (and the planned fix) +## Why Ryo trails here: what remains after inlining the tiny runtime ops -Unlike string_building, this gap is **not** semantic — it is codegen quality, and it is filed as tracked work. Each of the ~700k scan iterations makes two calls across the runtime-library boundary where Rust inlines everything (CLIF-verified 2026-08-26): +The original gap was **not** semantic — it was codegen quality. Each of the ~700k scan iterations made two calls across the runtime-library boundary where Rust inlines everything: `__ryo_slice(ptr, len, i, i+3)` (two bounds checks, two UTF-8 char-boundary tests, a `ptr.add`) and `ryo_str_eq(...)` (an extern call to compare 3 bytes). As of 2026-09-17 both bodies — plus literal packing, which was pure `pack_pair` — are emitted as inline Cranelift IR at the call site, and the packed-u128 pair ABI they returned is gone entirely (see the checkpoint below). The scan loop now makes zero runtime calls. -1. `__ryo_slice(ptr, len, i, i+3)` — two bounds checks, two UTF-8 char-boundary tests, and a `ptr.add`. Rust's `&text[i..i+3]` is inlined pointer arithmetic. -2. `ryo_str_eq(...)` — an extern call to compare 3 bytes; LLVM turns Rust's into a load-and-cmp. +What remains, in rough order of cost: -Two more per-iteration calls used to be on this list and are now removed (2026-08-26, verified by the `clif_str_literal_materialized_once_per_function` and `clif_static_cap_str_free_is_elided` tests): `ryo_str_from_literal("fox", 3)` re-packed the same `(ptr, len)` every iteration — each distinct literal is now materialized once per function in the entry block — and `ryo_str_free(lit, 0)`, a guaranteed no-op on the literal's cap=0 static sentinel, is no longer emitted when the cap is statically 0. +1. Three checked-arithmetic guard-and-branch pairs per iteration (`i + 3`, `i + 1`, `count += 1`) — spec §18 mandates them; Rust release wraps silently (same story as fibonacci). Value-range guard elision and fused flag branches are tracked work in `ISSUES.md`. +2. The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) — two bit tests inlined; Rust scans raw bytes (`&[u8]`) and never pays it. +3. Cranelift-vs-LLVM mid-end quality on what is left. -Plus three checked-arithmetic guard-and-branch pairs per iteration (`i + 3`, `i + 1`, `count += 1`) — spec §18 mandates them; Rust release wraps silently (same story as fibonacci). - -One fairness note: Rust scans raw bytes (`&[u8]`), while Ryo's slice validates UTF-8 char boundaries per spec §3.1 — a mandated check Rust never pays. Inlined, it is two bit tests; across an extern call it is part of the per-iteration call cost above. - -A second, smaller asymmetry: hyperfine times whole processes, so every arm's in-program string build (14 doublings) is included by design — and the Swift arm additionally pays a one-time `[UInt8](s.utf8)` materialization (~0.05 ms measured, ~2% of its total, within run noise) because `String.UTF8View` has no O(1) integer subscript and scanning it directly would be far slower. - -**The fix path** (tracked in `ISSUES.md`, no language change): emit the tiny runtime bodies as inline Cranelift IR at the call sites, and elide overflow guards a value-range analysis proves safe. These should remove most of the remaining call overhead; whatever margin remains after that is Cranelift-vs-LLVM mid-end quality plus the spec-mandated boundary checks. This benchmark is the tracking measure. +One fairness note: hyperfine times whole processes, so every arm's in-program string build (14 doublings) is included by design — and the Swift arm additionally pays a one-time `[UInt8](s.utf8)` materialization (~0.05 ms measured, ~2% of its total, within run noise) because `String.UTF8View` has no O(1) integer subscript and scanning it directly would be far slower. ## Benchmarks & Performance Results @@ -43,6 +38,18 @@ The string-runtime rework moved this benchmark twice, in opposite directions. (1 | **Ryo (AOT)** | 0.1.0-dev.20260914+4cef5f9 | 5.1 ms ± 0.2 ms | 3.03x slower | 2.75 MB | | **Ryo (JIT)** | 0.1.0-dev.20260914+4cef5f9 | 7.4 ms ± 0.7 ms | 4.41x slower | 6.89 MB | +### Checkpoint: tiny runtime ops inlined (2026-09-17) + +The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (bounds + UTF-8 guards, cold `ryo_panic` blocks), literal packing (pure `symbol_value` + `iconst` — `pack_pair` was the whole body), and `==`/`!=` against literals up to 16 bytes (length check + gated per-byte compares) are now inline Cranelift IR at the call site. With no pair-returning runtime call left, the packed-u128 ABI and its ~9-instruction i128 unpack legalization per use are gone, and `enable_llvm_abi_extensions` is retired with it. AOT 5.1 → 3.4 ms; JIT 7.4 → 4.7 ms. The remaining ~2.3× vs Rust is the spec-mandated UTF-8 boundary checks, the §18 overflow guards (elision/fusing tracked in `ISSUES.md`), and Cranelift-vs-LLVM mid-end quality. + +| Candidate | Version | Mean time | vs fastest | +|---|---|---|---| +| **Rust** | 1.98.0 | 1.5 ms ± 0.0 ms | 1.00x | +| **Ryo (AOT)** | 0.1.0-dev.20260917+c308a82 | 3.4 ms ± 0.1 ms | 2.27x slower | +| **Ryo (JIT)** | 0.1.0-dev.20260917+c308a82 | 4.7 ms ± 0.1 ms | 3.13x slower | + +(Rust/Swift rows were not re-measured for this checkpoint — same-day runs match their earlier values; Swift omitted from the table, see the 2026-09-14 checkpoint for its number.) + Measurement note: the Ryo rows are quiet-window means at the tagged commit (three runs each: AOT 5.1 ms ± 0.2, JIT 7.4 ms ± 0.7; full-suite batches under machine load read 5.8–6.0 ms with every arm inflated proportionally). The Rust and Swift rows are from the same-day full-suite run and match their 2026-09-11 values. ### Known tradeoff: growth headroom on doubling concat (2026-09-15) From 2adfcb13e96f92ab10e98c2d52ab03156557de07 Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 02:26:33 +0200 Subject: [PATCH 04/11] docs: correct measurement provenance in string_slicing checkpoint --- benchmarks/string_slicing/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index b408333..9b00882 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -48,7 +48,7 @@ The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (b | **Ryo (AOT)** | 0.1.0-dev.20260917+c308a82 | 3.4 ms ± 0.1 ms | 2.27x slower | | **Ryo (JIT)** | 0.1.0-dev.20260917+c308a82 | 4.7 ms ± 0.1 ms | 3.13x slower | -(Rust/Swift rows were not re-measured for this checkpoint — same-day runs match their earlier values; Swift omitted from the table, see the 2026-09-14 checkpoint for its number.) +(Rust was re-measured today alongside the Ryo rows; Swift was not re-run for this checkpoint — see the 2026-09-14 checkpoint for its number.) Measurement note: the Ryo rows are quiet-window means at the tagged commit (three runs each: AOT 5.1 ms ± 0.2, JIT 7.4 ms ± 0.7; full-suite batches under machine load read 5.8–6.0 ms with every arm inflated proportionally). The Rust and Swift rows are from the same-day full-suite run and match their 2026-09-11 values. From 63078acf0dc52631615aa030a0cfa1cc95f5ce31 Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 09:59:49 +0200 Subject: [PATCH 05/11] test: assert empty-slice length, not just successful execution CodeRabbit PR review follow-ups: strengthen test_slice_empty to bind s[3:] and assert len() == 0 (exercises the null-ptr/len-0 empty-view invariant end to end); move the 2026-09-14 measurement note back to its own checkpoint table in the string_slicing README; fix I-144's emit_conditional_dead_drops line reference after the expr.rs reshuffle. --- ISSUES.md | 2 +- benchmarks/string_slicing/README.md | 4 ++-- ryo/tests/integration_views.rs | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 3d81970..215ce44 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -287,7 +287,7 @@ Resolved entries are **removed** from this file. Language-visible decisions behi ### I-144 — Per-if clone and repeated dead-drop scans in codegen -**Files:** `ryo-backend/src/codegen/mod.rs` (`if_branches.get(...).cloned().unwrap_or_default()` :1196; called per arm :1237/:1273/:1289/:1305), `ryo-backend/src/codegen/expr.rs` (`emit_conditional_dead_drops` :703-719) +**Files:** `ryo-backend/src/codegen/mod.rs` (`if_branches.get(...).cloned().unwrap_or_default()` :1196; called per arm :1237/:1273/:1289/:1305), `ryo-backend/src/codegen/expr.rs` (`emit_conditional_dead_drops` :648-678) **Summary:** Every if-statement clones the `IfBranchIds` payload (heap `Vec` for elif branches) out of the sidecar even when there is no entry, because `.cloned().unwrap_or_default()` goes through `ctx`. Separately, `emit_conditional_dead_drops` re-scans the whole per-function `conditional_dead_drops` Vec at the start of *every* if arm with no empty-check early exit. On if-heavy functions with dead drops this is O(ifs × arms × drops). **Resolution:** Borrow the sidecar out of `ctx` first so `get` returns a reference instead of cloning; add the same `is_empty()` early-return `emit_due_frees` already has or index dead drops by `if_stmt` in a map built once per function. diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index 9b00882..f942920 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -38,6 +38,8 @@ The string-runtime rework moved this benchmark twice, in opposite directions. (1 | **Ryo (AOT)** | 0.1.0-dev.20260914+4cef5f9 | 5.1 ms ± 0.2 ms | 3.03x slower | 2.75 MB | | **Ryo (JIT)** | 0.1.0-dev.20260914+4cef5f9 | 7.4 ms ± 0.7 ms | 4.41x slower | 6.89 MB | +Measurement note (2026-09-14 checkpoint): the Ryo rows are quiet-window means at the tagged commit (three runs each: AOT 5.1 ms ± 0.2, JIT 7.4 ms ± 0.7; full-suite batches under machine load read 5.8–6.0 ms with every arm inflated proportionally). The Rust and Swift rows are from the same-day full-suite run and match their 2026-09-11 values. + ### Checkpoint: tiny runtime ops inlined (2026-09-17) The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (bounds + UTF-8 guards, cold `ryo_panic` blocks), literal packing (pure `symbol_value` + `iconst` — `pack_pair` was the whole body), and `==`/`!=` against literals up to 16 bytes (length check + gated per-byte compares) are now inline Cranelift IR at the call site. With no pair-returning runtime call left, the packed-u128 ABI and its ~9-instruction i128 unpack legalization per use are gone, and `enable_llvm_abi_extensions` is retired with it. AOT 5.1 → 3.4 ms; JIT 7.4 → 4.7 ms. The remaining ~2.3× vs Rust is the spec-mandated UTF-8 boundary checks, the §18 overflow guards (elision/fusing tracked in `ISSUES.md`), and Cranelift-vs-LLVM mid-end quality. @@ -50,8 +52,6 @@ The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (b (Rust was re-measured today alongside the Ryo rows; Swift was not re-run for this checkpoint — see the 2026-09-14 checkpoint for its number.) -Measurement note: the Ryo rows are quiet-window means at the tagged commit (three runs each: AOT 5.1 ms ± 0.2, JIT 7.4 ms ± 0.7; full-suite batches under machine load read 5.8–6.0 ms with every arm inflated proportionally). The Rust and Swift rows are from the same-day full-suite run and match their 2026-09-11 values. - ### Known tradeoff: growth headroom on doubling concat (2026-09-15) CodSpeed's memory mode flags this benchmark as a **+48.8% peak-allocation regression** (1.0 → 1.5 MB) after the string-runtime rework — with the allocation count unchanged at 14. The arithmetic is exact: the 14 doubling concats (`s = s + s` on a 43-byte seed) now route through the growth path, and `growth_cap` rounds every buffer up to the next power of two, so iteration *i* allocates 64×2^i bytes instead of exactly 43×2^i — and 64/43 = 1.488. This is the cost side of the same policy that makes `s = s + suffix` amortized O(1) in string_building; a doubling concat is the one append pattern where headroom can **never** be reused (the next iteration always needs 2×len, beyond any constant-factor slack), so the slack is pure overhead here. It does not show up in process RSS: 0.5 MB of heap slack sits under the ~2.7 MB process baseline, and Ryo AOT still measures the lowest RSS of the suite. diff --git a/ryo/tests/integration_views.rs b/ryo/tests/integration_views.rs index c5af758..c481489 100644 --- a/ryo/tests/integration_views.rs +++ b/ryo/tests/integration_views.rs @@ -153,9 +153,12 @@ fn test_slice_of_borrowed_param_last_use_in_returning_arm() { #[test] fn test_slice_empty() { - assert_ryo_runs( + // Empty slices carry the null-ptr/len-0 view: printing one must not + // dereference the null pointer, and its length is 0. + assert_ryo_output( "slice_empty.ryo", - "fn main():\n\ts: str = \"abc\"\n\tprint(s[3:])\n\tprint(s[0:0])\n", + "fn main():\n\ts: str = \"abc\"\n\tv = s[3:]\n\tprint(v)\n\tprint(int_to_str(v.len()))\n\tprint(int_to_str(s[0:0].len()))\n", + "00", ); } From 8e9a745bc8f6cae973da588cca9f51b769f85707 Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 10:27:29 +0200 Subject: [PATCH 06/11] fix: specialize bytes/bytesview eq against short literals like str emit_bytes_eq moved to str_ops.rs next to emit_str_eq; both now share emit_pair_eq, which inlines the compare when either side is a string or bytes literal of <= 16 bytes (length check + per-byte compares behind a branch) and only falls back to the extern ryo_str_eq/ryo_bytes_eq otherwise. Measured on a bytesview variant of benchmarks/string_slicing (text: bytes, s[i:i+3] == b"fox"): 4.6 ms -> 2.8 ms AOT once the per-iteration ryo_bytes_eq call is inlined (str variant: 3.4 ms, Rust: 1.6 ms; hyperfine -N, M-series). --- ryo-backend/src/codegen/bytes.rs | 32 +------- ryo-backend/src/codegen/str_ops.rs | 120 +++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 64 deletions(-) diff --git a/ryo-backend/src/codegen/bytes.rs b/ryo-backend/src/codegen/bytes.rs index 878f3ca..304cb73 100644 --- a/ryo-backend/src/codegen/bytes.rs +++ b/ryo-backend/src/codegen/bytes.rs @@ -64,36 +64,8 @@ impl Codegen { matches!(ctx.pool.kind(ty), TypeKind::Bytes) } - /// `bytes`/`bytesview` equality via `ryo_bytes_eq` (M8.4.2). - /// Operands may be owned triples or view pairs; only (ptr, len) is - /// read. `BytesCmpNe` inverts the I8 result, mirroring `StrCmpNe`. - pub(crate) fn emit_bytes_eq( - builder: &mut FunctionBuilder, - ctx: &mut FunctionContext<'_, M>, - tag: TirTag, - lhs: TirRef, - rhs: TirRef, - ) -> Result { - let (l_ptr, l_len) = Self::eval_str_or_view_parts(builder, ctx, lhs)?; - let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; - - let eq_ref = Self::declare_runtime_fn( - ctx, - builder, - "ryo_bytes_eq", - &[ctx.int_type, types::I64, ctx.int_type, types::I64], - &[types::I8], - )?; - let call = builder.ins().call(eq_ref, &[l_ptr, l_len, r_ptr, r_len]); - let result = builder.inst_results(call)[0]; - - if tag == TirTag::BytesCmpNe { - let one = builder.ins().iconst(types::I8, 1); - Ok(builder.ins().bxor(result, one)) - } else { - Ok(result) - } - } + // `emit_bytes_eq` lives in `str_ops.rs` next to `emit_str_eq` — both + // share the literal-specialized pair-compare (`emit_pair_eq`). } /// Define a string literal's content as a read-only `.rodata` object, diff --git a/ryo-backend/src/codegen/str_ops.rs b/ryo-backend/src/codegen/str_ops.rs index 6872a4b..3721e8b 100644 --- a/ryo-backend/src/codegen/str_ops.rs +++ b/ryo-backend/src/codegen/str_ops.rs @@ -2,7 +2,8 @@ //! under the 2000-line CI cap (`scripts/check_file_length.sh`). //! //! The bodies of `__ryo_slice` / `__ryo_bytes_slice` and the -//! short-literal specialization of `ryo_str_eq` are emitted as inline +//! short-literal specialization of `ryo_str_eq` / `ryo_bytes_eq` are +//! emitted as inline //! Cranelift IR at the call site instead of extern calls: each body is //! a handful of instructions, and the call boundary cost dominated //! (`benchmarks/string_slicing` made two such calls per scan @@ -14,7 +15,6 @@ use cranelift::codegen::ir::{BlockArg, MemFlagsData}; use cranelift::prelude::*; use cranelift_module::Module; use ryo_core::tir::{TirData, TirRef, TirTag}; -use ryo_core::types::StringId; use super::{Codegen, FunctionContext}; @@ -111,39 +111,70 @@ impl Codegen { Ok(()) } - /// `str`/`strview` equality (M8.4 §3.3). When either operand is a - /// string literal of at most `INLINE_LITERAL_MAX` bytes, emits an + /// `str`/`strview` equality (M8.4 §3.3): shared pair-compare with + /// `StrConst`/`BytesConst` literal specialization and the + /// `ryo_str_eq` fallback. + pub(crate) fn emit_str_eq( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + tag: TirTag, + lhs: TirRef, + rhs: TirRef, + ) -> Result { + Self::emit_pair_eq( + builder, + ctx, + lhs, + rhs, + tag == TirTag::StrCmpNe, + "ryo_str_eq", + ) + } + + /// `bytes`/`bytesview` equality (M8.4.2): same literal + /// specialization as `emit_str_eq`, `ryo_bytes_eq` fallback. + pub(crate) fn emit_bytes_eq( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + tag: TirTag, + lhs: TirRef, + rhs: TirRef, + ) -> Result { + Self::emit_pair_eq( + builder, + ctx, + lhs, + rhs, + tag == TirTag::BytesCmpNe, + "ryo_bytes_eq", + ) + } + + /// Shared (ptr, len) equality. When either operand is a string or + /// bytes literal of at most `INLINE_LITERAL_MAX` bytes, emits an /// inline compare — length check plus per-byte compares of the /// other side against the compile-time bytes, gated behind the /// length check so loads never run past the other buffer. Anything - /// else falls back to the extern `ryo_str_eq(ptr, len, ptr, len)`. - pub(crate) fn emit_str_eq( + /// else falls back to the extern `fallback(ptr, len, ptr, len)`. + /// `invert` flips the result for the `!=` tags. + fn emit_pair_eq( builder: &mut FunctionBuilder, ctx: &mut FunctionContext<'_, M>, - tag: TirTag, lhs: TirRef, rhs: TirRef, + invert: bool, + fallback: &'static str, ) -> Result { - // Operands may be owned str triples or strview view pairs - // (mixed equality wraps the owned side in ToView); only - // (ptr, len) is read. + // Operands may be owned triples or view pairs (mixed equality + // wraps the owned side in ToView); only (ptr, len) is read. let (l_ptr, l_len) = Self::eval_str_or_view_parts(builder, ctx, lhs)?; let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; - let literal = Self::strconst_id(ctx, lhs) - .map(|id| (true, id)) - .or_else(|| Self::strconst_id(ctx, rhs).map(|id| (false, id))); - let mut inline = None; - if let Some((is_lhs, id)) = literal { - let content = ctx.pool.str(id); - if content.len() <= INLINE_LITERAL_MAX { - let mut bytes = [0u8; INLINE_LITERAL_MAX]; - bytes[..content.len()].copy_from_slice(content.as_bytes()); - inline = Some((is_lhs, content.len(), bytes)); - } - } + let literal = Self::literal_bytes(ctx, lhs) + .map(|lit| (true, lit)) + .or_else(|| Self::literal_bytes(ctx, rhs).map(|lit| (false, lit))); - let result = if let Some((is_lhs, n, bytes)) = inline { + let result = if let Some((is_lhs, (n, bytes))) = literal { let (other_ptr, other_len) = if is_lhs { (r_ptr, r_len) } else { @@ -151,18 +182,19 @@ impl Codegen { }; Self::emit_literal_eq(builder, other_ptr, other_len, n, &bytes) } else { + let int_type = ctx.int_type; let eq_ref = Self::declare_runtime_fn( ctx, builder, - "ryo_str_eq", - &[ctx.int_type, types::I64, ctx.int_type, types::I64], + fallback, + &[int_type, types::I64, int_type, types::I64], &[types::I8], )?; let call = builder.ins().call(eq_ref, &[l_ptr, l_len, r_ptr, r_len]); builder.inst_results(call)[0] }; - if tag == TirTag::StrCmpNe { + if invert { let one = builder.ins().iconst(types::I8, 1); Ok(builder.ins().bxor(result, one)) } else { @@ -170,16 +202,36 @@ impl Codegen { } } - /// The `StringId` behind a `str`-typed operand when it is - /// statically a string literal — directly (`StrConst`) or through - /// the owner→view `ToView` wrap. Anything else is `None`. - fn strconst_id(ctx: &FunctionContext<'_, M>, r: TirRef) -> Option { + /// The compile-time bytes behind an operand when it is statically a + /// string or bytes literal of at most `INLINE_LITERAL_MAX` bytes — + /// directly (`StrConst`/`BytesConst`) or through the owner→view + /// `ToView` wrap. Anything else (or a longer literal) is `None`. + /// Returns `(len, bytes)`; `bytes[..len]` is the content. + fn literal_bytes( + ctx: &FunctionContext<'_, M>, + r: TirRef, + ) -> Option<(usize, [u8; INLINE_LITERAL_MAX])> { let inst = ctx.tir.inst(r); - match (inst.tag, inst.data) { - (TirTag::StrConst, TirData::Str(id)) => Some(id), - (TirTag::ToView, TirData::UnOp(inner)) => Self::strconst_id(ctx, inner), - _ => None, + let (id, is_bytes) = match (inst.tag, inst.data) { + (TirTag::StrConst, TirData::Str(id)) => (id, false), + (TirTag::BytesConst, TirData::Str(id)) => (id, true), + (TirTag::ToView, TirData::UnOp(inner)) => return Self::literal_bytes(ctx, inner), + _ => return None, + }; + // A `str` "A" and a `bytes` b"A" share one StringId (content + // dedup); bytes payloads need not be UTF-8, so each family + // reads through its own pool accessor. + let content: &[u8] = if is_bytes { + ctx.pool.bytes_payload(id) + } else { + ctx.pool.str(id).as_bytes() + }; + if content.len() > INLINE_LITERAL_MAX { + return None; } + let mut bytes = [0u8; INLINE_LITERAL_MAX]; + bytes[..content.len()].copy_from_slice(content); + Some((content.len(), bytes)) } /// Inline `other == `: `other_len == n`, then `n` From c67b5d66fdd2ad557e5ae606e48811daba1b6b2a Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 10:27:29 +0200 Subject: [PATCH 07/11] docs: file I-184 for promote-on-view per-iteration spill --- ISSUES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ISSUES.md b/ISSUES.md index 215ce44..210560f 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -389,6 +389,14 @@ Resolved entries are **removed** from this file. Language-visible decisions behi --- +### I-184 — Promote-on-view spill slot is re-written and re-checked every loop iteration for a loop-invariant base + +**Files:** `ryo-backend/src/codegen/views.rs` (`emit_ensure_heap_for_view_base` promo-slot path) +**Summary:** When a view's base owner was promoted (heap-buffered for aliasing), every slice/view derivation re-emits the spill sequence: store the owner (ptr, len, cap) triple plus a spilled flag into a stack slot, then load and branch on the flag — even when the base is loop-invariant and the slot contents never change. In `benchmarks/string_slicing`'s `count_fox` this is ~12 extra aarch64 instructions per scan iteration (measured by disassembly, 2026-09-17), a large share of the remaining gap to Rust after the slice/eq inlining work. +**Resolution:** Hoist the promo-slot spill and flag initialization out of loops (loop-invariant-code-motion on the spill sequence), or skip the slot write entirely on the heap/static fast path and keep the owner triple in registers when its liveness allows. + +--- + ## Cross-References - Architecture analysis: [docs/dev/architecture_analysis.md](docs/dev/architecture_analysis.md) — latest verified snapshot (2026-08-24); several current entries originated there, and its `I-xxx` citations reflect what was open at the time (older snapshots live in git history). From 0d6a3c14a2e0f7fec9968913958b1904673f5543 Mon Sep 17 00:00:00 2001 From: Pepe Navarro Date: Thu, 17 Sep 2026 10:49:53 +0200 Subject: [PATCH 08/11] test: split byte_slicing out of string_slicing, string arms everywhere string_slicing was not comparing like with like: Ryo's strview slices pay spec-mandated UTF-8 char-boundary validation while the Rust and Swift arms scanned raw bytes. The byte-scanning arms move to the new byte_slicing benchmark (Ryo bytes/bytesview, Rust &[u8], Swift [UInt8]) and string_slicing's Rust/Swift arms are converted to string semantics (Rust &str via text.get(i..i+3) with the same per-slice boundary validation as Ryo; Swift String.UTF8View scanned by index, dropping the [UInt8] materialization). 2026-09-17 (M3 Pro, hyperfine --shell=none): byte_slicing: Rust 1.6 / Swift 2.5 / Ryo AOT 2.7 / Ryo JIT 4.2 ms string_slicing: Rust 1.8 / Ryo AOT 3.4 / Ryo JIT 4.9 / Swift 6.2 ms On string semantics Ryo AOT now beats Swift; the str-vs-bytes delta in Ryo (3.4 vs 2.7 ms) isolates the UTF-8 validation cost. --- benchmarks/README.md | 21 +++-- benchmarks/byte_slicing/.gitignore | 3 + benchmarks/byte_slicing/README.md | 34 ++++++++ benchmarks/byte_slicing/byte_slicing.rs | 15 ++++ benchmarks/byte_slicing/byte_slicing.ryo | 18 ++++ benchmarks/byte_slicing/byte_slicing.swift | 27 ++++++ benchmarks/byte_slicing/run_benchmarks.sh | 83 +++++++++++++++++++ benchmarks/string_slicing/README.md | 22 +++-- benchmarks/string_slicing/string_slicing.rs | 18 +++- .../string_slicing/string_slicing.swift | 20 +++-- 10 files changed, 233 insertions(+), 28 deletions(-) create mode 100644 benchmarks/byte_slicing/.gitignore create mode 100644 benchmarks/byte_slicing/README.md create mode 100644 benchmarks/byte_slicing/byte_slicing.rs create mode 100644 benchmarks/byte_slicing/byte_slicing.ryo create mode 100644 benchmarks/byte_slicing/byte_slicing.swift create mode 100755 benchmarks/byte_slicing/run_benchmarks.sh diff --git a/benchmarks/README.md b/benchmarks/README.md index 3b63747..49fd508 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -46,40 +46,45 @@ JIT and AOT land within noise of each other (~1.42–1.43×) because both share ### 4. [String Slicing Benchmark](./string_slicing/) -* **Focus:** Zero-copy views — scan a 688 KiB in-program-generated string counting substring matches through `strview` slices, copying and storing nothing. +* **Focus:** Zero-copy string views — scan a 688 KiB in-program-generated string counting substring matches through string-semantic slices (`strview` / `&str` with boundary validation / `String.UTF8View`), copying and storing nothing. * **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). -### 5. [Mandelbrot Benchmark](./mandelbrot/) +### 5. [Byte Slicing Benchmark](./byte_slicing/) + +* **Focus:** The same scan workload on raw bytes — `bytesview` / `&[u8]` / `[UInt8]` slices with no UTF-8 char-boundary validation anywhere. Split out of string_slicing (2026-09-17) so each suite compares like with like; the delta between the two isolates Ryo's `str` boundary-validation cost. +* **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). + +### 6. [Mandelbrot Benchmark](./mandelbrot/) * **Focus:** Float codegen — 401×501 grid, max 80 iterations per pixel; no overflow guards in play, the cleanest Cranelift readout. * **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). -### 6. [Collatz Benchmark](./collatz/) +### 7. [Collatz Benchmark](./collatz/) * **Focus:** Integer loop/branch — total stopping time for seeds 1..1,000,000; a hot flat loop complementing fibonacci's recursion profile. * **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). -### 7. [Doubling Concat Benchmark](./doubling_concat/) +### 8. [Doubling Concat Benchmark](./doubling_concat/) * **Focus:** Runtime allocation strategy — `s = s + s` exponential growth to 16 MiB, stressing `ryo_str_alloc` / `ryo_str_concat`. * **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). -### 8. [Many Small Strings Benchmark](./many_small_strings/) +### 9. [Many Small Strings Benchmark](./many_small_strings/) * **Focus:** Flat-loop alloc/free churn — 500,000 short strings built and dropped, complementing eager_destruction's recursion angle. * **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). -### 9. [Struct Records Benchmark](./struct_records/) +### 10. [Struct Records Benchmark](./struct_records/) * **Focus:** Aggregate ABI traffic — 500,000 rounds of build → update → score on a `str + int` record, idiomatic per language; stresses struct returns, field-wise copies, and drop glue across a heap field. Ryo AOT currently beats Rust and Go here; only Swift's small-string optimization keeps it ahead. * **Languages compared:** Rust, Swift, Go, Python, and Ryo (AOT vs JIT). -### 10. [Struct Records Reuse Benchmark](./struct_records_reuse/) +### 11. [Struct Records Reuse Benchmark](./struct_records_reuse/) * **Focus:** The keep-original record update — same record, but the caller uses `p` again after `birthday`, so the update cannot consume it. Rust and Ryo pay an explicit clone, Swift/Go/Python share cheaply; tracking measure for the record-update ergonomics gap (I-172) and what `shared[T]` or a small-string optimization would buy. * **Languages compared:** Rust, Swift, Go, Python, and Ryo (AOT vs JIT). -### 11. [Struct Records Inout Benchmark](./struct_records_inout/) +### 12. [Struct Records Inout Benchmark](./struct_records_inout/) * **Focus:** Imperative update-in-place through a mutable borrow (`inout` / `&mut` / pointer / attribute store) — no new record, no clone, no sret. Verifies that choosing between inout and the consuming move+return form costs nothing, so the idiom choice can be driven by intent. * **Languages compared:** Rust, Swift, Go, Python, and Ryo (AOT vs JIT). diff --git a/benchmarks/byte_slicing/.gitignore b/benchmarks/byte_slicing/.gitignore new file mode 100644 index 0000000..fbf5633 --- /dev/null +++ b/benchmarks/byte_slicing/.gitignore @@ -0,0 +1,3 @@ +byte_slicing +byte_slicing_rs +byte_slicing_swift diff --git a/benchmarks/byte_slicing/README.md b/benchmarks/byte_slicing/README.md new file mode 100644 index 0000000..378e8cf --- /dev/null +++ b/benchmarks/byte_slicing/README.md @@ -0,0 +1,34 @@ +# Byte Slicing Benchmark + +**Focus:** Byte-level zero-copy views. Same workload as [`string_slicing`](../string_slicing/) — build a 688 KiB buffer in-program (doubling concat of a 43-byte seed), then scan it through 3-byte view slices counting `fox` occurrences — but every arm operates on raw bytes: Ryo `bytes`/`bytesview`, Rust `&[u8]`, Swift `[UInt8]`. No UTF-8 char-boundary validation anywhere; this is the like-for-like comparison for byte scanning, split out of `string_slicing` on 2026-09-17 when that benchmark's Rust and Swift arms were converted to string semantics. + +**Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). + +## What it isolates + +Ryo's `str` slicing validates UTF-8 char boundaries at slice creation (spec §3.1) to keep the `strview` "immutable UTF-8 view" invariant; `bytes` slicing is the intended no-check path. The delta between this benchmark and `string_slicing`'s Ryo rows is exactly that validation cost — about 0.6–0.7 ms across the ~700k-iteration scan loop (2.7 vs 3.4 ms AOT at the 2026-09-17 checkpoint). Everything else (bounds checks, §18 overflow guards, the promote-on-view spill tracked as I-184 in `ISSUES.md`) is identical between the two. + +The Ryo arm also exercises the short-literal `==` specialization on the bytes family: `text[i:i+3] == b"fox"` inlines to a length check plus three byte compares, with no `ryo_bytes_eq` call. + +One fairness note: the Swift arm pays a one-time `[UInt8](s.utf8)` materialization (~0.05 ms measured, ~2% of its total, within run noise) because Swift has no raw-byte string view with an O(1) integer subscript. + +## Benchmarks & Performance Results + +Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17. Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l`. + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Rust** | 1.98.0 | 1.6 ms ± 0.1 ms | 1.00x | 2.88 MB | +| **Swift** | 6.3.3 | 2.5 ms ± 0.1 ms | 1.57x slower | 7.09 MB | +| **Ryo (AOT)** | 0.1.0-dev.20260917+63078ac | 2.7 ms ± 0.1 ms | 1.70x slower | 2.75 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260917+63078ac | 4.2 ms ± 0.1 ms | 2.62x slower | 6.97 MB | + +Ryo AOT lands within noise of Swift here — the remaining 1.70× to Rust is the §18 checked-arithmetic guards (three per iteration), the promote-on-view per-iteration spill (I-184), and Cranelift-vs-LLVM mid-end quality. + +## How to Run + +Prerequisites: `hyperfine`, `rustc`, `swiftc`, plus a release build of the compiler (`cargo build --release` from the repository root — the script runs it for you). + +```bash +./run_benchmarks.sh +``` diff --git a/benchmarks/byte_slicing/byte_slicing.rs b/benchmarks/byte_slicing/byte_slicing.rs new file mode 100644 index 0000000..8b9e191 --- /dev/null +++ b/benchmarks/byte_slicing/byte_slicing.rs @@ -0,0 +1,15 @@ +fn count_fox(text: &[u8]) -> usize { + text.windows(3).filter(|w| *w == b"fox").count() +} + +fn main() { + let mut s = String::from("the quick brown fox jumps over the lazy dog"); + for _ in 0..14 { + s = s.repeat(2); + } + let count = count_fox(s.as_bytes()); + let n = s.len(); + assert_eq!(n, 704512, "byte_slicing length check"); + assert_eq!(count, 16384, "byte_slicing match count check"); + println!("assert passed, byte_slicing is correct"); +} diff --git a/benchmarks/byte_slicing/byte_slicing.ryo b/benchmarks/byte_slicing/byte_slicing.ryo new file mode 100644 index 0000000..73fbf6a --- /dev/null +++ b/benchmarks/byte_slicing/byte_slicing.ryo @@ -0,0 +1,18 @@ +fn count_fox(text: bytes) -> int: + mut count = 0 + mut i = 0 + n = text.len() + while i + 3 <= n: + if text[i:i+3] == b"fox": + count += 1 + i += 1 + return count + +fn main(): + mut s: bytes = b"the quick brown fox jumps over the lazy dog" + for i in range(0, 14): + s = s + s + count = count_fox(s) + assert(s.len() == 704512, "byte_slicing length check") + assert(count == 16384, "byte_slicing match count check") + print("assert passed, byte_slicing is correct\n") diff --git a/benchmarks/byte_slicing/byte_slicing.swift b/benchmarks/byte_slicing/byte_slicing.swift new file mode 100644 index 0000000..2f606f0 --- /dev/null +++ b/benchmarks/byte_slicing/byte_slicing.swift @@ -0,0 +1,27 @@ +import Foundation + +let fox = Array("fox".utf8) + +func countFox(_ text: [UInt8]) -> Int { + var count = 0 + var i = 0 + let n = text.count + while i + 3 <= n { + if text[i..<(i + 3)].elementsEqual(fox) { + count += 1 + } + i += 1 + } + return count +} + +var s = "the quick brown fox jumps over the lazy dog" +for _ in 0..<14 { + s = s + s +} +let bytes = [UInt8](s.utf8) +let count = countFox(bytes) +let n = bytes.count +precondition(n == 704512, "byte_slicing length check") +precondition(count == 16384, "byte_slicing match count check") +print("assert passed, byte_slicing is correct") diff --git a/benchmarks/byte_slicing/run_benchmarks.sh b/benchmarks/byte_slicing/run_benchmarks.sh new file mode 100755 index 0000000..6fb2416 --- /dev/null +++ b/benchmarks/byte_slicing/run_benchmarks.sh @@ -0,0 +1,83 @@ +#!/bin/bash +set -e + +# Check for prerequisites +if ! command -v hyperfine &> /dev/null; then + echo "Error: 'hyperfine' is not installed or not in PATH. Please install it to run performance benchmarks." + exit 1 +fi + +if ! command -v rustc &> /dev/null; then + echo "Error: 'rustc' is not installed or not in PATH." + exit 1 +fi + +if ! command -v swiftc &> /dev/null; then + echo "Error: 'swiftc' is not installed or not in PATH." + exit 1 +fi + +echo "Building benchmarks..." +(cd ../.. && cargo build --release > /dev/null) +rustc -O byte_slicing.rs -o byte_slicing_rs +swiftc -O byte_slicing.swift -o byte_slicing_swift +ryo_bin="../../target/release/ryo" +$ryo_bin build byte_slicing.ryo > /dev/null + +echo "" +echo "-------------------" +echo "Compiler Version" +echo "-------------------" +echo "Rust: $(rustc --version | cut -d' ' -f2)" +echo "Swift: $(swiftc --version | head -1 | awk '{for (i = 1; i < NF; i++) if ($i == "Swift" && $(i+1) == "version") { print $(i+2); exit }}')" +echo "Ryo: $($ryo_bin --version 2>&1 || echo 'dev')" + +echo "" +echo "-------------------" +echo "Memory Usage (Maximum Resident Set Size)" +echo "-------------------" +_OS="$(uname -s)" +measure_mem() { + local name=$1 + shift + + local mem_kb + local mem_out + case "$_OS" in + Darwin*) + # /usr/bin/time -l reports bytes on macOS; convert to KB + mem_kb=$( ( /usr/bin/time -l "$@" > /dev/null ) 2>&1 | awk '/maximum resident set size/ {printf "%d", $1 / 1024; exit}' ) + ;; + Linux*) + mem_kb=$( { /usr/bin/time -f "%M" "$@" > /dev/null; } 2>&1 | tail -n1 ) + ;; + *) + mem_kb="" + ;; + esac + + if [[ -n "$mem_kb" ]]; then + mem_out=$(awk -v kb="$mem_kb" 'BEGIN { printf "%.2f MB", kb / 1024 }') + else + mem_out="N/A" + fi + + printf "%-28s %s\n" "[$name]" "$mem_out" +} + +# Run once each to collect memory usage +measure_mem "Rust" ./byte_slicing_rs +measure_mem "Swift" ./byte_slicing_swift +measure_mem "Ryo (AOT)" ./byte_slicing +measure_mem "Ryo (JIT)" $ryo_bin run byte_slicing.ryo + +echo "" +echo "-------------------" +echo "Running Benchmarks (scan 688 KiB via views, count 16384 matches) using hyperfine" +echo "-------------------" + +hyperfine --warmup 3 --shell=none \ + './byte_slicing_rs' \ + './byte_slicing_swift' \ + './byte_slicing' \ + "$ryo_bin run byte_slicing.ryo" diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index f942920..0ad7c3f 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -1,6 +1,6 @@ # String Slicing Benchmark -**Focus:** Zero-copy views. Builds a 688 KiB string in-program (doubling concat of a 43-byte seed), then scans it through `strview` slices counting `fox` occurrences — `count_fox` borrows the `str` and every comparison is a view into the original buffer; nothing is copied or stored. +**Focus:** Zero-copy string views. Builds a 688 KiB string in-program (doubling concat of a 43-byte seed), then scans it through string slices counting `fox` occurrences — `count_fox` borrows the string and every comparison is a view into the original buffer; nothing is copied or stored. Every arm is **string-semantic**: Ryo `strview`, Rust `&str` (`text.get(i..i+3)`, which validates UTF-8 char boundaries per slice just like Ryo), and Swift `String.UTF8View` scanned by index. For the raw-byte variant of the same workload (`bytesview` / `&[u8]` / `[UInt8]`, no UTF-8 validation anywhere) see [`byte_slicing`](../byte_slicing/). **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). @@ -11,21 +11,23 @@ The original gap was **not** semantic — it was codegen quality. Each of the ~7 What remains, in rough order of cost: 1. Three checked-arithmetic guard-and-branch pairs per iteration (`i + 3`, `i + 1`, `count += 1`) — spec §18 mandates them; Rust release wraps silently (same story as fibonacci). Value-range guard elision and fused flag branches are tracked work in `ISSUES.md`. -2. The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) — two bit tests inlined; Rust scans raw bytes (`&[u8]`) and never pays it. +2. The promote-on-view per-iteration spill: every slice of the promoted base re-stores the owner triple and re-branches on the spilled flag (~12 aarch64 instructions per iteration for a loop-invariant base) — tracked as I-184 in `ISSUES.md`. 3. Cranelift-vs-LLVM mid-end quality on what is left. -One fairness note: hyperfine times whole processes, so every arm's in-program string build (14 doublings) is included by design — and the Swift arm additionally pays a one-time `[UInt8](s.utf8)` materialization (~0.05 ms measured, ~2% of its total, within run noise) because `String.UTF8View` has no O(1) integer subscript and scanning it directly would be far slower. +The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) is no longer a differentiator: since 2026-09-17 the Rust arm slices via `text.get(i..i+3)`, which performs the same boundary check, and the Swift arm scans `String.UTF8View` by index. On string semantics Ryo AOT (3.4 ms) now sits between Rust (1.8 ms) and Swift (6.2 ms) — Swift's index-advanced UTF-8 view scan is the slowest arm, as its README predicted back when it scanned a materialized `[UInt8]` instead. + +One fairness note: hyperfine times whole processes, so every arm's in-program string build (14 doublings) is included by design. ## Benchmarks & Performance Results -Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-11 — all rows re-measured after the Rust and Swift arms were rewritten to be idiomatic per the repository convention: Rust now builds the string with `s.repeat(2)` and scans with `text.windows(3).filter(|w| *w == b"fox")`, and Swift hoists the needle array out of the scan loop (previously it transliterated Ryo's `s = s + s` as `s.clone() + &s` and rebuilt a magic `[102, 111, 120]` literal per comparison). Same checksums and semantics; only expression quality changed. Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). +Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17 — first run with all arms string-semantic (Rust `&str` + `get()` boundary validation, Swift `String.UTF8View` index scan; see the split checkpoint below). Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). Tables before this date measured byte-scanning Rust/Swift arms — compare those against [`byte_slicing`](../byte_slicing/) instead. | Candidate | Version | Mean time | vs fastest | Max RSS | |---|---|---|---|---| -| **Rust** | 1.98.0 | 1.7 ms ± 0.2 ms | 1.00x | 2.88 MB | -| **Swift** | 6.3.3 | 2.6 ms ± 0.2 ms | 1.54x slower | 7.09 MB | -| **Ryo (AOT)** | 0.1.0-dev.20260911+490b10d | 4.9 ms ± 0.3 ms | 2.94x slower | 2.75 MB | -| **Ryo (JIT)** | 0.1.0-dev.20260911+490b10d | 6.5 ms ± 0.4 ms | 3.90x slower | 6.62 MB | +| **Rust** | 1.98.0 | 1.8 ms ± 0.1 ms | 1.00x | 2.88 MB | +| **Ryo (AOT)** | 0.1.0-dev.20260917+63078ac | 3.4 ms ± 0.2 ms | 1.84x slower | 2.75 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260917+63078ac | 4.9 ms ± 0.1 ms | 2.65x slower | 7.05 MB | +| **Swift** | 6.3.3 | 6.2 ms ± 0.1 ms | 3.40x slower | 7.03 MB | ### Checkpoint: SSO + consuming concat (2026-09-14) @@ -52,6 +54,10 @@ The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (b (Rust was re-measured today alongside the Ryo rows; Swift was not re-run for this checkpoint — see the 2026-09-14 checkpoint for its number.) +### Checkpoint: string-semantic arms + benchmark split (2026-09-17) + +The benchmark was not comparing like with like: Ryo's `strview` slices pay spec-mandated UTF-8 char-boundary validation while the Rust and Swift arms scanned raw bytes (`&[u8]`, `[UInt8]`) and never did. The byte-scanning arms moved to the new [`byte_slicing`](../byte_slicing/) benchmark (where Ryo uses `bytes`/`bytesview`, the intended no-check path), and this benchmark's Rust and Swift arms were converted to string semantics: Rust scans `&str` via `text.get(i..i+3)` (per-slice boundary validation, `None` on a split character — same contract as Ryo), Swift scans `String.UTF8View` by index with no `[UInt8]` materialization. Cost of going string-semantic: Rust 1.5 → 1.8 ms (the boundary checks are real but cheap), Swift 2.6 → 6.2 ms (index-advanced UTF-8 view scanning, no fast integer subscript). Ryo AOT now beats Swift on the string workload it was designed for. Same-day byte-arm numbers live in `byte_slicing`'s README; the current table at the top of this file holds the string-semantic run. + ### Known tradeoff: growth headroom on doubling concat (2026-09-15) CodSpeed's memory mode flags this benchmark as a **+48.8% peak-allocation regression** (1.0 → 1.5 MB) after the string-runtime rework — with the allocation count unchanged at 14. The arithmetic is exact: the 14 doubling concats (`s = s + s` on a 43-byte seed) now route through the growth path, and `growth_cap` rounds every buffer up to the next power of two, so iteration *i* allocates 64×2^i bytes instead of exactly 43×2^i — and 64/43 = 1.488. This is the cost side of the same policy that makes `s = s + suffix` amortized O(1) in string_building; a doubling concat is the one append pattern where headroom can **never** be reused (the next iteration always needs 2×len, beyond any constant-factor slack), so the slack is pure overhead here. It does not show up in process RSS: 0.5 MB of heap slack sits under the ~2.7 MB process baseline, and Ryo AOT still measures the lowest RSS of the suite. diff --git a/benchmarks/string_slicing/string_slicing.rs b/benchmarks/string_slicing/string_slicing.rs index 7c94752..e096092 100644 --- a/benchmarks/string_slicing/string_slicing.rs +++ b/benchmarks/string_slicing/string_slicing.rs @@ -1,5 +1,17 @@ -fn count_fox(text: &[u8]) -> usize { - text.windows(3).filter(|w| *w == b"fox").count() +fn count_fox(text: &str) -> usize { + let n = text.len(); + let mut count = 0; + let mut i = 0; + while i + 3 <= n { + // Byte-offset slicing with UTF-8 char-boundary validation, the + // same semantics as Ryo's strview slices: `get` returns None + // (rather than panicking) when the range splits a character. + if text.get(i..i + 3) == Some("fox") { + count += 1; + } + i += 1; + } + count } fn main() { @@ -7,7 +19,7 @@ fn main() { for _ in 0..14 { s = s.repeat(2); } - let count = count_fox(s.as_bytes()); + let count = count_fox(&s); let n = s.len(); assert_eq!(n, 704512, "string_slicing length check"); assert_eq!(count, 16384, "string_slicing match count check"); diff --git a/benchmarks/string_slicing/string_slicing.swift b/benchmarks/string_slicing/string_slicing.swift index 3b9ec01..16edd71 100644 --- a/benchmarks/string_slicing/string_slicing.swift +++ b/benchmarks/string_slicing/string_slicing.swift @@ -2,15 +2,18 @@ import Foundation let fox = Array("fox".utf8) -func countFox(_ text: [UInt8]) -> Int { +// String-semantic scan: iterate the string's native UTF-8 storage by +// index and compare 3-byte UTF-8 view slices — no [UInt8] +// materialization, no raw byte buffer. +func countFox(_ text: String) -> Int { + let utf8 = text.utf8 var count = 0 - var i = 0 - let n = text.count - while i + 3 <= n { - if text[i..<(i + 3)].elementsEqual(fox) { + var i = utf8.startIndex + while let end = utf8.index(i, offsetBy: 3, limitedBy: utf8.endIndex) { + if utf8[i.. Date: Thu, 17 Sep 2026 10:54:26 +0200 Subject: [PATCH 09/11] test: match Ryo's boundary-validation contract in string arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit follow-up: Rust arm now slices directly (&text[i..i+3] panics on a split character, same as Ryo's slice panic) instead of the Option-returning get(), and the Swift arm validates both slice endpoints with (b & 0xC0) != 0x80 continuation-byte tests — UTF8View slicing alone does no boundary validation. Swift 6.2 -> 7.1 ms, Rust unchanged at 1.8 ms. --- benchmarks/string_slicing/README.md | 8 ++++---- benchmarks/string_slicing/string_slicing.rs | 8 +++++--- benchmarks/string_slicing/string_slicing.swift | 7 ++++++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index 0ad7c3f..feba96b 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -14,20 +14,20 @@ What remains, in rough order of cost: 2. The promote-on-view per-iteration spill: every slice of the promoted base re-stores the owner triple and re-branches on the spilled flag (~12 aarch64 instructions per iteration for a loop-invariant base) — tracked as I-184 in `ISSUES.md`. 3. Cranelift-vs-LLVM mid-end quality on what is left. -The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) is no longer a differentiator: since 2026-09-17 the Rust arm slices via `text.get(i..i+3)`, which performs the same boundary check, and the Swift arm scans `String.UTF8View` by index. On string semantics Ryo AOT (3.4 ms) now sits between Rust (1.8 ms) and Swift (6.2 ms) — Swift's index-advanced UTF-8 view scan is the slowest arm, as its README predicted back when it scanned a materialized `[UInt8]` instead. +The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) is no longer a differentiator: since 2026-09-17 the Rust arm slices directly (`&text[i..i+3]`, panicking on a split character — the same contract as Ryo) and the Swift arm validates both slice endpoints explicitly (`(b & 0xC0) != 0x80` bit tests, mirroring Ryo's inlined checks) over `String.UTF8View`. On string semantics Ryo AOT (3.4 ms) now sits between Rust (1.8 ms) and Swift (7.1 ms) — Swift's index-advanced UTF-8 view scan is the slowest arm, as this README predicted back when it scanned a materialized `[UInt8]` instead. One fairness note: hyperfine times whole processes, so every arm's in-program string build (14 doublings) is included by design. ## Benchmarks & Performance Results -Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17 — first run with all arms string-semantic (Rust `&str` + `get()` boundary validation, Swift `String.UTF8View` index scan; see the split checkpoint below). Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). Tables before this date measured byte-scanning Rust/Swift arms — compare those against [`byte_slicing`](../byte_slicing/) instead. +Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17 — first run with all arms string-semantic (Rust `&str` direct slicing with boundary validation, Swift `String.UTF8View` index scan with explicit boundary tests; see the split checkpoint below). Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). Tables before this date measured byte-scanning Rust/Swift arms — compare those against [`byte_slicing`](../byte_slicing/) instead. | Candidate | Version | Mean time | vs fastest | Max RSS | |---|---|---|---|---| | **Rust** | 1.98.0 | 1.8 ms ± 0.1 ms | 1.00x | 2.88 MB | | **Ryo (AOT)** | 0.1.0-dev.20260917+63078ac | 3.4 ms ± 0.2 ms | 1.84x slower | 2.75 MB | | **Ryo (JIT)** | 0.1.0-dev.20260917+63078ac | 4.9 ms ± 0.1 ms | 2.65x slower | 7.05 MB | -| **Swift** | 6.3.3 | 6.2 ms ± 0.1 ms | 3.40x slower | 7.03 MB | +| **Swift** | 6.3.3 | 7.1 ms ± 0.7 ms | 3.94x slower | 7.03 MB | ### Checkpoint: SSO + consuming concat (2026-09-14) @@ -56,7 +56,7 @@ The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (b ### Checkpoint: string-semantic arms + benchmark split (2026-09-17) -The benchmark was not comparing like with like: Ryo's `strview` slices pay spec-mandated UTF-8 char-boundary validation while the Rust and Swift arms scanned raw bytes (`&[u8]`, `[UInt8]`) and never did. The byte-scanning arms moved to the new [`byte_slicing`](../byte_slicing/) benchmark (where Ryo uses `bytes`/`bytesview`, the intended no-check path), and this benchmark's Rust and Swift arms were converted to string semantics: Rust scans `&str` via `text.get(i..i+3)` (per-slice boundary validation, `None` on a split character — same contract as Ryo), Swift scans `String.UTF8View` by index with no `[UInt8]` materialization. Cost of going string-semantic: Rust 1.5 → 1.8 ms (the boundary checks are real but cheap), Swift 2.6 → 6.2 ms (index-advanced UTF-8 view scanning, no fast integer subscript). Ryo AOT now beats Swift on the string workload it was designed for. Same-day byte-arm numbers live in `byte_slicing`'s README; the current table at the top of this file holds the string-semantic run. +The benchmark was not comparing like with like: Ryo's `strview` slices pay spec-mandated UTF-8 char-boundary validation while the Rust and Swift arms scanned raw bytes (`&[u8]`, `[UInt8]`) and never did. The byte-scanning arms moved to the new [`byte_slicing`](../byte_slicing/) benchmark (where Ryo uses `bytes`/`bytesview`, the intended no-check path), and this benchmark's Rust and Swift arms were converted to string semantics: Rust slices `&str` directly (`&text[i..i+3]` panics on a split character — the same contract as Ryo's exit-101 slice panic), Swift scans `String.UTF8View` by index with no `[UInt8]` materialization and validates both slice endpoints with `(b & 0xC0) != 0x80` continuation-byte tests, mirroring Ryo's inlined boundary checks. Cost of going string-semantic: Rust 1.5 → 1.8 ms (the boundary checks are real but cheap), Swift 2.6 → 7.1 ms (index-advanced UTF-8 view scanning plus the explicit boundary tests). Ryo AOT now beats Swift on the string workload it was designed for. Same-day byte-arm numbers live in `byte_slicing`'s README; the current table at the top of this file holds the string-semantic run. ### Known tradeoff: growth headroom on doubling concat (2026-09-15) diff --git a/benchmarks/string_slicing/string_slicing.rs b/benchmarks/string_slicing/string_slicing.rs index e096092..8a69083 100644 --- a/benchmarks/string_slicing/string_slicing.rs +++ b/benchmarks/string_slicing/string_slicing.rs @@ -4,9 +4,11 @@ fn count_fox(text: &str) -> usize { let mut i = 0; while i + 3 <= n { // Byte-offset slicing with UTF-8 char-boundary validation, the - // same semantics as Ryo's strview slices: `get` returns None - // (rather than panicking) when the range splits a character. - if text.get(i..i + 3) == Some("fox") { + // same semantics as Ryo's strview slices: direct slicing panics + // when the range splits a character (Ryo panics with exit 101). + // The seed is ASCII-only, so the checks always pass here — but + // both languages pay them per iteration. + if &text[i..i + 3] == "fox" { count += 1; } i += 1; diff --git a/benchmarks/string_slicing/string_slicing.swift b/benchmarks/string_slicing/string_slicing.swift index 16edd71..6b28b23 100644 --- a/benchmarks/string_slicing/string_slicing.swift +++ b/benchmarks/string_slicing/string_slicing.swift @@ -10,7 +10,12 @@ func countFox(_ text: String) -> Int { var count = 0 var i = utf8.startIndex while let end = utf8.index(i, offsetBy: 3, limitedBy: utf8.endIndex) { - if utf8[i.. Date: Thu, 17 Sep 2026 11:16:15 +0200 Subject: [PATCH 10/11] test: make string_slicing arms idiomatic per suite convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust: iterator filter over direct &str slicing (boundary-validating, panics on a split character). Swift: 3-Character Substring windows walked by String.Index — boundary correctness is structural in Swift, so the manual continuation-byte bit tests are gone; they were never idiomatic. Divergence documented in the README: Swift windows are Characters, Ryo/Rust windows are validated bytes (identical for this ASCII-only input). Re-measured 2026-09-17: Rust 1.8 / Ryo AOT 3.4 / Ryo JIT 4.9 / Swift 17.6 ms — Character iteration's grapheme machinery makes Swift the slowest string arm; Ryo AOT beats it 5x. --- benchmarks/string_slicing/README.md | 16 ++++++------ benchmarks/string_slicing/string_slicing.rs | 21 ++++++---------- .../string_slicing/string_slicing.swift | 25 ++++++++----------- 3 files changed, 25 insertions(+), 37 deletions(-) diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index feba96b..037d4ad 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -1,6 +1,6 @@ # String Slicing Benchmark -**Focus:** Zero-copy string views. Builds a 688 KiB string in-program (doubling concat of a 43-byte seed), then scans it through string slices counting `fox` occurrences — `count_fox` borrows the string and every comparison is a view into the original buffer; nothing is copied or stored. Every arm is **string-semantic**: Ryo `strview`, Rust `&str` (`text.get(i..i+3)`, which validates UTF-8 char boundaries per slice just like Ryo), and Swift `String.UTF8View` scanned by index. For the raw-byte variant of the same workload (`bytesview` / `&[u8]` / `[UInt8]`, no UTF-8 validation anywhere) see [`byte_slicing`](../byte_slicing/). +**Focus:** Zero-copy string views. Builds a 688 KiB string in-program (doubling concat of a 43-byte seed), then scans it through string slices counting `fox` occurrences — `count_fox` borrows the string and every comparison is a view into the original buffer; nothing is copied or stored. Every arm is **string-semantic and idiomatic** (per the repository convention): Ryo `strview` byte-offset slices with UTF-8 char-boundary validation, Rust `&str` direct slicing (same boundary validation, panics on a split character), Swift `Substring` windows walked by `String.Index`. One documented divergence: Swift's window is 3 **Characters** (its `String.Index` cannot split a Character — boundary correctness is structural, not a paid check), while Ryo and Rust slice 3 **bytes** with validation; for this ASCII-only input the windows coincide. For the raw-byte variant of the same workload (`bytesview` / `&[u8]` / `[UInt8]`, no UTF-8 semantics anywhere) see [`byte_slicing`](../byte_slicing/). **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). @@ -14,20 +14,20 @@ What remains, in rough order of cost: 2. The promote-on-view per-iteration spill: every slice of the promoted base re-stores the owner triple and re-branches on the spilled flag (~12 aarch64 instructions per iteration for a loop-invariant base) — tracked as I-184 in `ISSUES.md`. 3. Cranelift-vs-LLVM mid-end quality on what is left. -The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) is no longer a differentiator: since 2026-09-17 the Rust arm slices directly (`&text[i..i+3]`, panicking on a split character — the same contract as Ryo) and the Swift arm validates both slice endpoints explicitly (`(b & 0xC0) != 0x80` bit tests, mirroring Ryo's inlined checks) over `String.UTF8View`. On string semantics Ryo AOT (3.4 ms) now sits between Rust (1.8 ms) and Swift (7.1 ms) — Swift's index-advanced UTF-8 view scan is the slowest arm, as this README predicted back when it scanned a materialized `[UInt8]` instead. +The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) is no longer a differentiator: since 2026-09-17 the Rust arm slices `&str` directly (panicking on a split character — the same contract as Ryo), and the Swift arm pays more, not less: its idiomatic `Substring`-by-`String.Index` scan walks grapheme clusters, so boundary correctness is structural but Character iteration costs it dearly. On string semantics Ryo AOT (3.4 ms) sits between Rust (1.8 ms) and Swift (17.6 ms). One fairness note: hyperfine times whole processes, so every arm's in-program string build (14 doublings) is included by design. ## Benchmarks & Performance Results -Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17 — first run with all arms string-semantic (Rust `&str` direct slicing with boundary validation, Swift `String.UTF8View` index scan with explicit boundary tests; see the split checkpoint below). Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). Tables before this date measured byte-scanning Rust/Swift arms — compare those against [`byte_slicing`](../byte_slicing/) instead. +Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17 — first run with all arms string-semantic and idiomatic (Rust `&str` direct slicing with boundary validation, Swift `Substring` windows by `String.Index`; see the split checkpoint below). Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). Tables before this date measured byte-scanning Rust/Swift arms — compare those against [`byte_slicing`](../byte_slicing/) instead. | Candidate | Version | Mean time | vs fastest | Max RSS | |---|---|---|---|---| -| **Rust** | 1.98.0 | 1.8 ms ± 0.1 ms | 1.00x | 2.88 MB | -| **Ryo (AOT)** | 0.1.0-dev.20260917+63078ac | 3.4 ms ± 0.2 ms | 1.84x slower | 2.75 MB | -| **Ryo (JIT)** | 0.1.0-dev.20260917+63078ac | 4.9 ms ± 0.1 ms | 2.65x slower | 7.05 MB | -| **Swift** | 6.3.3 | 7.1 ms ± 0.7 ms | 3.94x slower | 7.03 MB | +| **Rust** | 1.98.0 | 1.8 ms ± 0.2 ms | 1.00x | 2.88 MB | +| **Ryo (AOT)** | 0.1.0-dev.20260917+63078ac | 3.4 ms ± 0.2 ms | 1.89x slower | 2.75 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260917+63078ac | 4.9 ms ± 0.3 ms | 2.72x slower | 7.05 MB | +| **Swift** | 6.3.3 | 17.6 ms ± 1.4 ms | 9.78x slower | 7.03 MB | ### Checkpoint: SSO + consuming concat (2026-09-14) @@ -56,7 +56,7 @@ The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (b ### Checkpoint: string-semantic arms + benchmark split (2026-09-17) -The benchmark was not comparing like with like: Ryo's `strview` slices pay spec-mandated UTF-8 char-boundary validation while the Rust and Swift arms scanned raw bytes (`&[u8]`, `[UInt8]`) and never did. The byte-scanning arms moved to the new [`byte_slicing`](../byte_slicing/) benchmark (where Ryo uses `bytes`/`bytesview`, the intended no-check path), and this benchmark's Rust and Swift arms were converted to string semantics: Rust slices `&str` directly (`&text[i..i+3]` panics on a split character — the same contract as Ryo's exit-101 slice panic), Swift scans `String.UTF8View` by index with no `[UInt8]` materialization and validates both slice endpoints with `(b & 0xC0) != 0x80` continuation-byte tests, mirroring Ryo's inlined boundary checks. Cost of going string-semantic: Rust 1.5 → 1.8 ms (the boundary checks are real but cheap), Swift 2.6 → 7.1 ms (index-advanced UTF-8 view scanning plus the explicit boundary tests). Ryo AOT now beats Swift on the string workload it was designed for. Same-day byte-arm numbers live in `byte_slicing`'s README; the current table at the top of this file holds the string-semantic run. +The benchmark was not comparing like with like: Ryo's `strview` slices pay spec-mandated UTF-8 char-boundary validation while the Rust and Swift arms scanned raw bytes (`&[u8]`, `[UInt8]`) and never did. The byte-scanning arms moved to the new [`byte_slicing`](../byte_slicing/) benchmark (where Ryo uses `bytes`/`bytesview`, the intended no-check path), and this benchmark's Rust and Swift arms were converted to idiomatic string semantics: Rust slices `&str` directly (`&text[i..i+3]` panics on a split character — the same contract as Ryo's exit-101 slice panic), Swift compares 3-Character `Substring` windows walked by `String.Index` with no `[UInt8]` materialization. Cost of going string-semantic: Rust 1.5 → 1.8 ms (the boundary checks are real but cheap), Swift 2.6 → 17.6 ms (Character iteration carries grapheme-breaking machinery; boundary correctness is structural in Swift, since `String.Index` cannot split a Character). An earlier same-day variant scanned Swift's `UTF8View` by byte index with explicit continuation-byte tests (7.1 ms), but manual bit-twiddling violates the suite's idiomatic-per-language convention, so the `Substring` form is the committed arm. Ryo AOT now beats Swift 5× on the string workload it was designed for. Same-day byte-arm numbers live in `byte_slicing`'s README; the current table at the top of this file holds the string-semantic run. ### Known tradeoff: growth headroom on doubling concat (2026-09-15) diff --git a/benchmarks/string_slicing/string_slicing.rs b/benchmarks/string_slicing/string_slicing.rs index 8a69083..aff8c57 100644 --- a/benchmarks/string_slicing/string_slicing.rs +++ b/benchmarks/string_slicing/string_slicing.rs @@ -1,19 +1,12 @@ fn count_fox(text: &str) -> usize { + // String-semantic scan: direct &str slicing validates UTF-8 char + // boundaries per slice and panics on a split character — the same + // contract as Ryo's strview slices. The seed is ASCII-only, so the + // checks always pass here, but both languages pay them per window. let n = text.len(); - let mut count = 0; - let mut i = 0; - while i + 3 <= n { - // Byte-offset slicing with UTF-8 char-boundary validation, the - // same semantics as Ryo's strview slices: direct slicing panics - // when the range splits a character (Ryo panics with exit 101). - // The seed is ASCII-only, so the checks always pass here — but - // both languages pay them per iteration. - if &text[i..i + 3] == "fox" { - count += 1; - } - i += 1; - } - count + (0..n.saturating_sub(2)) + .filter(|&i| &text[i..i + 3] == "fox") + .count() } fn main() { diff --git a/benchmarks/string_slicing/string_slicing.swift b/benchmarks/string_slicing/string_slicing.swift index 6b28b23..d542898 100644 --- a/benchmarks/string_slicing/string_slicing.swift +++ b/benchmarks/string_slicing/string_slicing.swift @@ -1,24 +1,19 @@ import Foundation -let fox = Array("fox".utf8) - -// String-semantic scan: iterate the string's native UTF-8 storage by -// index and compare 3-byte UTF-8 view slices — no [UInt8] -// materialization, no raw byte buffer. +// String-semantic scan: walk the string Character by Character and +// compare 3-Character Substring windows against the needle. Boundary +// correctness is structural here — String.Index can never split a +// Character, so no explicit validation exists or is needed. (For this +// ASCII-only input, Character windows coincide with Ryo's 3-byte +// windows; on non-ASCII input the semantics diverge — see README.) func countFox(_ text: String) -> Int { - let utf8 = text.utf8 var count = 0 - var i = utf8.startIndex - while let end = utf8.index(i, offsetBy: 3, limitedBy: utf8.endIndex) { - // UTF-8 char-boundary validation, mirroring Ryo's strview - // slice contract: both endpoints must not land on a - // continuation byte (top bits 10). - let startOK = (utf8[i] & 0xC0) != 0x80 - let endOK = end == utf8.endIndex || (utf8[end] & 0xC0) != 0x80 - if startOK && endOK && utf8[i.. Date: Thu, 17 Sep 2026 11:40:21 +0200 Subject: [PATCH 11/11] docs: slim string_slicing README to the string-semantic run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-09-17 arm conversion made every earlier table non-comparable (byte-scanning Rust/Swift arms), so the checkpoint tables are dropped — they remain in git history, and the byte workload lives on in byte_slicing. Kept: the current string-semantic table, the codegen knowledge (tiny-op inlining, remaining gap breakdown), and the growth-headroom peak-allocation tradeoff note. --- benchmarks/string_slicing/README.md | 39 ++++------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index 037d4ad..f5f216c 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -4,9 +4,9 @@ **Languages compared:** Rust, Swift, and Ryo (AOT vs JIT). -## Why Ryo trails here: what remains after inlining the tiny runtime ops +## Why Ryo trails Rust here -The original gap was **not** semantic — it was codegen quality. Each of the ~700k scan iterations made two calls across the runtime-library boundary where Rust inlines everything: `__ryo_slice(ptr, len, i, i+3)` (two bounds checks, two UTF-8 char-boundary tests, a `ptr.add`) and `ryo_str_eq(...)` (an extern call to compare 3 bytes). As of 2026-09-17 both bodies — plus literal packing, which was pure `pack_pair` — are emitted as inline Cranelift IR at the call site, and the packed-u128 pair ABI they returned is gone entirely (see the checkpoint below). The scan loop now makes zero runtime calls. +The original gap was **not** semantic — it was codegen quality. Each of the ~700k scan iterations made two calls across the runtime-library boundary where Rust inlines everything: `__ryo_slice(ptr, len, i, i+3)` (two bounds checks, two UTF-8 char-boundary tests, a `ptr.add`) and `ryo_str_eq(...)` (an extern call to compare 3 bytes). As of 2026-09-17 both bodies — plus literal packing, which was pure `pack_pair` — are emitted as inline Cranelift IR at the call site, and the packed-u128 pair ABI they returned is gone entirely. The scan loop now makes zero runtime calls. What remains, in rough order of cost: @@ -14,13 +14,13 @@ What remains, in rough order of cost: 2. The promote-on-view per-iteration spill: every slice of the promoted base re-stores the owner triple and re-branches on the spilled flag (~12 aarch64 instructions per iteration for a loop-invariant base) — tracked as I-184 in `ISSUES.md`. 3. Cranelift-vs-LLVM mid-end quality on what is left. -The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) is no longer a differentiator: since 2026-09-17 the Rust arm slices `&str` directly (panicking on a split character — the same contract as Ryo), and the Swift arm pays more, not less: its idiomatic `Substring`-by-`String.Index` scan walks grapheme clusters, so boundary correctness is structural but Character iteration costs it dearly. On string semantics Ryo AOT (3.4 ms) sits between Rust (1.8 ms) and Swift (17.6 ms). +The spec-mandated UTF-8 char-boundary validation per slice (spec §3.1) is no longer a differentiator: the Rust arm slices `&str` directly (panicking on a split character — the same contract as Ryo), and the Swift arm pays more, not less: its idiomatic `Substring`-by-`String.Index` scan walks grapheme clusters, so boundary correctness is structural but Character iteration costs it dearly. On string semantics Ryo AOT (3.4 ms) sits between Rust (1.8 ms) and Swift (17.6 ms). One fairness note: hyperfine times whole processes, so every arm's in-program string build (14 doublings) is included by design. ## Benchmarks & Performance Results -Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17 — first run with all arms string-semantic and idiomatic (Rust `&str` direct slicing with boundary validation, Swift `Substring` windows by `String.Index`; see the split checkpoint below). Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). Tables before this date measured byte-scanning Rust/Swift arms — compare those against [`byte_slicing`](../byte_slicing/) instead. +Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09-17 — first run with all arms string-semantic and idiomatic; before this date the Rust and Swift arms scanned raw bytes, so older tables in git history are not comparable (that workload now lives in [`byte_slicing`](../byte_slicing/)). Hyperfine `--warmup 3 --shell=none`; peak RSS via `/usr/bin/time -l` (macOS) or `%M` (Linux). | Candidate | Version | Mean time | vs fastest | Max RSS | |---|---|---|---|---| @@ -29,36 +29,7 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Ryo (JIT)** | 0.1.0-dev.20260917+63078ac | 4.9 ms ± 0.3 ms | 2.72x slower | 7.05 MB | | **Swift** | 6.3.3 | 17.6 ms ± 1.4 ms | 9.78x slower | 7.03 MB | -### Checkpoint: SSO + consuming concat (2026-09-14) - -The string-runtime rework moved this benchmark twice, in opposite directions. (1) Promote-on-view landed with an *unconditional* runtime call: every slice of an owner-typed `str` paid a spill + extern `__ryo_str_ensure_heap` + reload to guarantee the base never moves — 4.9 → 5.7 ms across the ~700k-iteration scan loop. (2) A codegen tag-branch then recovered it: view creation now tests the base's inline tag (top byte of the cap word) and only inline bases take the promote call, while heap and static bases pass their pointer/length straight through — 5.7 → 5.1 ms. The ~0.2 ms residual over the pre-rework 4.9 ms is the per-slice tag test itself; closing it folds into the planned tiny-runtime-op inlining work named above (the same mechanism that will inline `__ryo_slice` and `ryo_str_eq`). - -| Candidate | Version | Mean time | vs fastest | Max RSS | -|---|---|---|---|---| -| **Rust** | 1.98.0 | 1.7 ms ± 0.1 ms | 1.00x | 2.88 MB | -| **Swift** | 6.3.3 | 2.7 ms ± 0.1 ms | 1.60x slower | 7.09 MB | -| **Ryo (AOT)** | 0.1.0-dev.20260914+4cef5f9 | 5.1 ms ± 0.2 ms | 3.03x slower | 2.75 MB | -| **Ryo (JIT)** | 0.1.0-dev.20260914+4cef5f9 | 7.4 ms ± 0.7 ms | 4.41x slower | 6.89 MB | - -Measurement note (2026-09-14 checkpoint): the Ryo rows are quiet-window means at the tagged commit (three runs each: AOT 5.1 ms ± 0.2, JIT 7.4 ms ± 0.7; full-suite batches under machine load read 5.8–6.0 ms with every arm inflated proportionally). The Rust and Swift rows are from the same-day full-suite run and match their 2026-09-11 values. - -### Checkpoint: tiny runtime ops inlined (2026-09-17) - -The fix the section above describes landed: `__ryo_slice`/`__ryo_bytes_slice` (bounds + UTF-8 guards, cold `ryo_panic` blocks), literal packing (pure `symbol_value` + `iconst` — `pack_pair` was the whole body), and `==`/`!=` against literals up to 16 bytes (length check + gated per-byte compares) are now inline Cranelift IR at the call site. With no pair-returning runtime call left, the packed-u128 ABI and its ~9-instruction i128 unpack legalization per use are gone, and `enable_llvm_abi_extensions` is retired with it. AOT 5.1 → 3.4 ms; JIT 7.4 → 4.7 ms. The remaining ~2.3× vs Rust is the spec-mandated UTF-8 boundary checks, the §18 overflow guards (elision/fusing tracked in `ISSUES.md`), and Cranelift-vs-LLVM mid-end quality. - -| Candidate | Version | Mean time | vs fastest | -|---|---|---|---| -| **Rust** | 1.98.0 | 1.5 ms ± 0.0 ms | 1.00x | -| **Ryo (AOT)** | 0.1.0-dev.20260917+c308a82 | 3.4 ms ± 0.1 ms | 2.27x slower | -| **Ryo (JIT)** | 0.1.0-dev.20260917+c308a82 | 4.7 ms ± 0.1 ms | 3.13x slower | - -(Rust was re-measured today alongside the Ryo rows; Swift was not re-run for this checkpoint — see the 2026-09-14 checkpoint for its number.) - -### Checkpoint: string-semantic arms + benchmark split (2026-09-17) - -The benchmark was not comparing like with like: Ryo's `strview` slices pay spec-mandated UTF-8 char-boundary validation while the Rust and Swift arms scanned raw bytes (`&[u8]`, `[UInt8]`) and never did. The byte-scanning arms moved to the new [`byte_slicing`](../byte_slicing/) benchmark (where Ryo uses `bytes`/`bytesview`, the intended no-check path), and this benchmark's Rust and Swift arms were converted to idiomatic string semantics: Rust slices `&str` directly (`&text[i..i+3]` panics on a split character — the same contract as Ryo's exit-101 slice panic), Swift compares 3-Character `Substring` windows walked by `String.Index` with no `[UInt8]` materialization. Cost of going string-semantic: Rust 1.5 → 1.8 ms (the boundary checks are real but cheap), Swift 2.6 → 17.6 ms (Character iteration carries grapheme-breaking machinery; boundary correctness is structural in Swift, since `String.Index` cannot split a Character). An earlier same-day variant scanned Swift's `UTF8View` by byte index with explicit continuation-byte tests (7.1 ms), but manual bit-twiddling violates the suite's idiomatic-per-language convention, so the `Substring` form is the committed arm. Ryo AOT now beats Swift 5× on the string workload it was designed for. Same-day byte-arm numbers live in `byte_slicing`'s README; the current table at the top of this file holds the string-semantic run. - -### Known tradeoff: growth headroom on doubling concat (2026-09-15) +## Known tradeoff: growth headroom on doubling concat (2026-09-15) CodSpeed's memory mode flags this benchmark as a **+48.8% peak-allocation regression** (1.0 → 1.5 MB) after the string-runtime rework — with the allocation count unchanged at 14. The arithmetic is exact: the 14 doubling concats (`s = s + s` on a 43-byte seed) now route through the growth path, and `growth_cap` rounds every buffer up to the next power of two, so iteration *i* allocates 64×2^i bytes instead of exactly 43×2^i — and 64/43 = 1.488. This is the cost side of the same policy that makes `s = s + suffix` amortized O(1) in string_building; a doubling concat is the one append pattern where headroom can **never** be reused (the next iteration always needs 2×len, beyond any constant-factor slack), so the slack is pure overhead here. It does not show up in process RSS: 0.5 MB of heap slack sits under the ~2.7 MB process baseline, and Ryo AOT still measures the lowest RSS of the suite.