diff --git a/ISSUES.md b/ISSUES.md index 6ba4783..210560f 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) @@ -293,9 +287,9 @@ 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. +**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. ### 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) @@ -407,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). 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 a3c738a..f5f216c 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -1,51 +1,35 @@ # 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 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). -## Why Ryo trails here: a runtime call per operation (and the planned fix) +## Why Ryo trails Rust here -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. 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 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. -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). +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: 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. ## 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). - -| 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 | - -### 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`). +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 | |---|---|---|---|---| -| **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: 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. +| **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 | -### 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. diff --git a/benchmarks/string_slicing/string_slicing.rs b/benchmarks/string_slicing/string_slicing.rs index 7c94752..aff8c57 100644 --- a/benchmarks/string_slicing/string_slicing.rs +++ b/benchmarks/string_slicing/string_slicing.rs @@ -1,5 +1,12 @@ -fn count_fox(text: &[u8]) -> usize { - text.windows(3).filter(|w| *w == b"fox").count() +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(); + (0..n.saturating_sub(2)) + .filter(|&i| &text[i..i + 3] == "fox") + .count() } fn main() { @@ -7,7 +14,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..d542898 100644 --- a/benchmarks/string_slicing/string_slicing.swift +++ b/benchmarks/string_slicing/string_slicing.swift @@ -1,16 +1,19 @@ import Foundation -let fox = Array("fox".utf8) - -func countFox(_ text: [UInt8]) -> Int { +// 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 { var count = 0 - var i = 0 - let n = text.count - while i + 3 <= n { - if text[i..<(i + 3)].elementsEqual(fox) { + var i = text.startIndex + while let end = text.index(i, offsetBy: 3, limitedBy: text.endIndex) { + if text[i..