Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 11 additions & 21 deletions ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstRef, TirRef>` 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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <arch>-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 <arch>-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`)
Expand Down Expand Up @@ -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)
Expand All @@ -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).
Expand Down
21 changes: 13 additions & 8 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions benchmarks/byte_slicing/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
byte_slicing
byte_slicing_rs
byte_slicing_swift
34 changes: 34 additions & 0 deletions benchmarks/byte_slicing/README.md
Original file line number Diff line number Diff line change
@@ -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
```
15 changes: 15 additions & 0 deletions benchmarks/byte_slicing/byte_slicing.rs
Original file line number Diff line number Diff line change
@@ -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");
}
18 changes: 18 additions & 0 deletions benchmarks/byte_slicing/byte_slicing.ryo
Original file line number Diff line number Diff line change
@@ -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")
27 changes: 27 additions & 0 deletions benchmarks/byte_slicing/byte_slicing.swift
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading