Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b1a55eb
test: add struct_records benchmark (Ryo vs Rust vs Swift vs Python)
artefactop Sep 9, 2026
c6315ff
chore: add struct-records-aot to CodSpeed walltime and memory jobs
artefactop Sep 9, 2026
5030781
chore: file I-171 for small-string optimization in the str runtime
artefactop Sep 9, 2026
000b20f
docs: add language versions to struct_records results table
artefactop Sep 9, 2026
c5cbbe2
docs: narrow I-171 heap claim, lint fixes, document birthday rebuild
artefactop Sep 10, 2026
1436f52
test: make struct_records birthday idiomatic per language
artefactop Sep 10, 2026
490b10d
docs: codify idiomatic-per-language benchmark convention
artefactop Sep 10, 2026
6eeb66a
test: make string_slicing Rust/Swift arms idiomatic and re-measure
artefactop Sep 10, 2026
6e17e26
docs: add version column to string_slicing, require versions at check…
artefactop Sep 10, 2026
cb462b1
test: rewrite struct_records birthday as move + in-place mutation
artefactop Sep 10, 2026
e5386db
docs: record I-172, struct update ergonomics deferred to trait milestone
artefactop Sep 10, 2026
a151528
docs: map the I-172 design space from the Python comparison
artefactop Sep 10, 2026
77e70ee
test: add idiomatic Go arm to struct_records
artefactop Sep 10, 2026
e2db4c6
docs: record I-173, astgen drops parse-error statements causing E0036…
artefactop Sep 10, 2026
af2cae3
test: add struct_records_reuse benchmark for the keep-original update
artefactop Sep 10, 2026
4b2c65f
test: add struct_records_inout benchmark for update-in-place
artefactop Sep 10, 2026
59fe7a3
test: register struct_records_reuse and _inout in codspeed.yml exec list
artefactop Sep 11, 2026
b3b7d25
test: use range-over-int loops in the Go struct_records arms
artefactop Sep 11, 2026
f25e95a
docs: add version column to five benchmark READMEs, re-measure
artefactop Sep 11, 2026
15a01ee
docs: record I-174, centralize the copy-pasted benchmark runner mecha…
artefactop Sep 11, 2026
fa92ebd
test: match string_building Rust arm to Ryo's s = s + "x" shape
artefactop Sep 11, 2026
af34270
test: use compound assignment for struct field increments
artefactop Sep 11, 2026
97ea30c
docs: make benchmarks/README.md markdownlint-clean
artefactop Sep 11, 2026
7e5dcfa
docs: note measurement revisions in the two newest struct_records suites
artefactop Sep 11, 2026
7e58005
docs: drop stale JIT-regression note from string_slicing README
artefactop Sep 11, 2026
b0550a4
docs: sort doubling_concat results table fastest-first
artefactop Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/codspeed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ jobs:
./target/release/ryo build benchmarks/string_slicing/string_slicing.ryo
./target/release/ryo build benchmarks/mandelbrot/mandelbrot.ryo
./target/release/ryo build benchmarks/collatz/collatz.ryo
./target/release/ryo build benchmarks/struct_records/struct_records.ryo
./target/release/ryo build benchmarks/struct_records_reuse/struct_records_reuse.ryo
./target/release/ryo build benchmarks/struct_records_inout/struct_records_inout.ryo
- name: Install cargo-codspeed
uses: taiki-e/install-action@v2
with:
Expand Down Expand Up @@ -134,6 +137,9 @@ jobs:
./target/release/ryo build benchmarks/string_slicing/string_slicing.ryo
./target/release/ryo build benchmarks/mandelbrot/mandelbrot.ryo
./target/release/ryo build benchmarks/collatz/collatz.ryo
./target/release/ryo build benchmarks/struct_records/struct_records.ryo
./target/release/ryo build benchmarks/struct_records_reuse/struct_records_reuse.ryo
./target/release/ryo build benchmarks/struct_records_inout/struct_records_inout.ryo
- name: Install cargo-codspeed
uses: taiki-e/install-action@v2
with:
Expand Down
30 changes: 30 additions & 0 deletions ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ Resolved entries are **removed** from this file. Language-visible decisions behi
**Summary:** tir.rs re-defines near-identical `extra`-layout modules with different layouts: `call_extra` appends a modes tail; `var_decl_extra` drops the `TY` slot (`LEN: 3` vs uir's `4`). Same names, same constants, different meanings — a footgun when editing one side. `ExtraRange` itself is also byte-duplicated (`uir.rs:107-118` vs `tir.rs:87-98`), and `IfStmt` has no layout doc module at all in tir.rs (:677-715).
**Resolution:** Unify the shared pieces (`ExtraRange` at minimum) in one module; rename or document the layout differences explicitly; add the missing `if_stmt_extra` doc module.

### I-173 — Parse-error statements vanish in astgen, cascading a spurious `MissingReturn` (E0036)

**Files:** `ryo-frontend/src/astgen.rs` (`StmtKind::Error` empty arms :167, :602), `ryo-core/src/ast.rs` (`StmtKind::Error` :319), `ryo-core/src/tir.rs` (`block_definitely_returns` and the `Unreachable` suppression :1604-1612)
**Summary:** The parser recovers at statement boundaries by emitting `StmtKind::Error` placeholders (R10), and sema's return-flow analysis already suppresses cascading `MissingReturn` diagnostics for *sema-level* errors via the TIR `Unreachable` sentinel. The parse-error path leaks between those two mechanisms: astgen lowers `StmtKind::Error` to *nothing*, so a function whose only `return` failed to parse reaches sema with a body that genuinely ends without returning, and the user gets a bogus E0036 stacked on the real parse diagnostic (reproduced 2026-09-11: a typo'd `return Person{name=p.name, age=.age + 1}` produced E0100 at the typo *and* E0036 "missing return" on the function signature, pointing the user at the wrong place).
**Resolution:** Lower `StmtKind::Error` to a UIR error/unreachable sentinel (or have sema treat it as one) so the existing TIR `Unreachable` rule suppresses `MissingReturn` for parse-broken bodies, matching the cascade suppression sema tests already enforce for sema-internal errors. Regression test: a function whose only return statement fails to parse yields exactly the parse diagnostic, no E0036.

---

## 🟢 Cleanup
Expand Down Expand Up @@ -351,6 +357,30 @@ Resolved entries are **removed** from this file. Language-visible decisions behi
**Summary:** The repo convention is lowercase with underscores for docs (special files like `README.md` excepted). The eight `ryo-*-*.md` files under `docs/dev/` use hyphens instead. `NOTES.md` was renamed to `notes.md` as the cheap half of this cleanup; the hyphenated set was scoped out because each rename must also update every inbound link (`CLAUDE.md`, `ISSUES.md`, the roadmap, and the docs/dev README index at minimum).
**Resolution:** One sweep: `git mv` each `ryo-*.md` to its underscore form, then repo-wide grep for each old basename to update links. Verify no residual references with a final grep for `ryo-.*\.md` across tracked markdown.

### I-171 — No small-string optimization: runtime-created `str` values heap-allocate, even ≤ 15-byte ones

**Files:** `runtime/src/lib.rs` (`RyoStrFat`, `ryo_str_alloc` / `ryo_str_concat` / `ryo_int_to_str` / `ryo_str_free`), `ryo-backend/src/codegen/mod.rs` (`STR_SLOT_SIZE` 24-byte slot layout), `ryo-backend/src/codegen/` (every site emitting str alloc/free/concat calls, including struct field drop glue)
**Summary:** `str` is a 24-byte fat pointer (ptr, len, cap). Static literals are the exception — they point into `.rodata` with cap == 0 (non-heap-owned, never freed) and empty strings avoid allocation entirely — but every runtime-created string (`int_to_str`, concatenation, anything built at runtime) heap-allocates, no matter how short. Swift's `String` inlines up to 15 bytes on 64-bit, so on string-churn workloads names like `user499999` never touch the heap there. That allocation difference is a likely contributor to Swift's win over both Rust and Ryo in `benchmarks/struct_records/` (~1.7x over Ryo AOT, ~2.1x over Rust), alongside the struct-return, field-copy, and drop-glue traffic the benchmark exercises (and to its edge in `many_small_strings`), per the 2026-09-11 checkpoint. Short strings dominate real programs (identifiers, keys, small messages), so this is the largest known structural gap in Ryo's string runtime, and it compounds with M9 structs: every short runtime-created `str` field embedded in an aggregate pays a heap alloc + free per copy.
**Resolution:** Repurpose the 24-byte slot as a tagged union: a discriminator (e.g. in the cap word or the final byte) selects between the current heap fat pointer and an inline representation holding up to ~22 bytes plus length in the slot itself. Runtime entry points (`alloc`, `concat`, `free`, `len`, comparison) branch on the tag; inline strings make `free` a no-op and `concat` fall back to heap only past the inline capacity. This is a breaking change to `RyoStrFat` and every codegen site that materializes or drops a `str` (including struct field drops and sret paths), so it should land as one coordinated runtime+codegen change with ASan coverage; re-checkpoint `many_small_strings` and `struct_records` to measure the win.

### I-172 — Consuming struct update has no ergonomic form: move + mutate + return dance, no update sugar, no clone

**Files:** `ryo-frontend/src/ownership/structs.rs` (`check_field_move_out`, E0043), `docs/specification.md` (§5.1 ownership rules; §4.5 struct literals; the operator-uniqueness rule reserving `..` for type bounds), `benchmarks/struct_records/` (the motivating measurement)
**Summary:** Updating one field of an owned struct into a new value — the everyday "record update" — currently takes three statements: `mut q = p; q.age = q.age + 1; return q` (with a `move` parameter, since parameters borrow by default). Moving a single field out is rejected (E0043: fields move only with the whole struct), and there is no clone builtin, so the shorter `Person{name=p.name, age=p.age+1}` shape is inexpressible. The struct_records benchmark quantified the trap: the natural transliteration that re-derives the field costs ~1.8x walltime versus the move+mutate form (36.9 ms → 20.1 ms, commit `cb462b1`), so users who don't know the idiom write measurably slow code. Two candidate fixes exist but both are deferred design decisions: (a) struct-update syntax sugar — note Rust's `..p` spelling collides with the spec's operator-uniqueness rule (`..` is reserved for type bounds), so a different spelling would be needed; (b) same-type duplication via the `Clone` trait already designed for the v0.2/v0.3 trait milestone (see `docs/dev/ryo-view-materialization.md` §2), which would also cover the harder duplicate-and-modify case where the original must survive.
**Resolution:** Revisit at the trait milestone, with the design space already mapped by the Python comparison (2026-09-11). Python's `Person(p.name, p.age + 1)` one-liner works because reference semantics + refcounting make the field read a retain, and `str` immutability makes the aliasing safe; Ryo's uniquely-owned `str` can express the same surface syntax only as a move (unsound from a borrow — double free) or a hidden clone (O(n) + alloc). That splits the problem cleanly on the *liveness of the source*, not the syntax: (1) **Consuming case** — `move` parameter (or any binding) that is dead after the struct literal: moving the field out is sound, the struct is being consumed field-by-field. Relaxing E0043 for dead-parent field reads (the last-use analysis eager destruction already runs) gives Python's ergonomics at zero runtime cost and matches the M8.1 roadmap's prediction that field-by-field move tracking would follow from the same dataflow. (2) **Duplicate-and-modify case** — borrowed parameter or live source: a borrow guarantees the parent stays alive, so no liveness relaxation can ever apply; the field read must be a retain or a clone. Auto-clone here is rejected as a hidden-cost footgun — the struct_records trap measured exactly this work at ~1.8x walltime, and making it the default meaning of innocent syntax would recreate the trap everywhere; auto-share is impossible without changing the field's declared representation to `shared[T]`. The explicit options are the right ones: `.clone()` via the planned `Clone` trait (v0.2/v0.3, see `docs/dev/ryo-view-materialization.md` §2, visible cost) or a `shared[T]` field (retain, visible in the type, spec §5.6). The target end-state is therefore: same syntax, semantics selected by ownership context — borrowed source requires `.clone()`, moved-from dying source compiles as a field move — with any update sugar (spelling must avoid the `..` collision) as optional ergonomics on top of (1). Until then the move + mutate + return pattern is the documented idiom — make sure the struct documentation states it explicitly so users don't rediscover the slow path. The keep-original case has a dedicated tracking benchmark, `benchmarks/struct_records_reuse/` (added 2026-09-11): Ryo clones by hand (`p.name + ""`) and lands with Rust, while Swift/Go/Python share cheaply — the gap the `Clone` trait, `shared[T]`, or a small-string optimization would close.

### I-174 — Benchmark runner mechanism is copy-pasted across 11 suites; centralize into a shared framework

**Files:** `benchmarks/*/run_benchmarks.sh` (11 copies, ~1,030 lines total), `benchmarks/README.md` (idiomatic/checkpoint conventions), `codspeed.yml`, `.github/workflows/codspeed.yml`
**Summary:** Every benchmark suite carries its own `run_benchmarks.sh`, and they are literally copies: the struct_records_reuse and struct_records_inout scripts were created by `sed`-substituting the suite name into the struct_records one. Each copy re-implements the same mechanism — prerequisite checks, `cargo build --release`, per-language compile lines, the compiler-version banner, the macOS/Linux `measure_mem` switch, the hyperfine invocation — with the suite-specific part (which arms exist, build commands, run commands) interleaved rather than declared. Drift is already visible: only some suites have Go or Python arms, the version-banner formats differ subtly, the table format and Version-column convention live only in prose in the global README, and adding a suite means another 100-line fork (two were added on 2026-09-11). The same duplication extends to registration: a new suite must be added to the root `codspeed.yml` exec list *and* both AOT build lists in `.github/workflows/codspeed.yml` by hand.
**Resolution:** One shared runner (a single script, or a small `xtask`-style tool) where each suite declares its arms — name, source file, build command, run command — in one manifest (e.g. a TOML/YAML per suite or one central file), and the framework does everything else: prereq checks, builds, correctness run (assert checksum) before timing, version capture, RSS measurement, hyperfine, and emitting the README results table (Version column included) in the canonical format. Suite registration for CodSpeed should be generated from the same manifest so `codspeed.yml` and the workflow lists can't drift from the suites. Migrate the existing 11 suites and delete the per-suite scripts.

### I-175 — Consuming `str` concat always allocates a fresh exact-size buffer; no in-place append on a provably-unique lhs

**Files:** `runtime/src/lib.rs` (`ryo_str_concat`, `__ryo_str_push`, `RyoStrFat`), `ryo-backend/src/codegen/` (concat call sites), `ryo-frontend/src/ownership/` (the reassign/dead-binding analysis that already proves uniqueness), `benchmarks/string_building/` (the tracking measure)
**Summary:** `s = s + suffix` compiles to `ryo_str_concat`, which allocates a fresh exact-size buffer (`cap == len`), copies both operands, and frees the old buffer at the reassign — so a 50,000-iteration append loop is O(n²) (~1.25 GB copied, the entire ~11.8x gap to Rust in `benchmarks/string_building/`). Rust proves this is unnecessary: its `impl Add<&str> for String` consumes the lhs and reuses its buffer (documented behavior), so the identical source `s = s + "x"` is amortized O(n) — uniqueness comes from ownership, not refcounts. Ryo's ownership pass already proves the same fact statically: at a reassign concat the old binding is dead, and a reassignable `s` provably has no live views, so in-place append is sound without COW refcounts or runtime uniqueness checks. What is missing is purely allocation policy: Ryo buffers carry no growth headroom (`cap == len` always), and concat never attempts to extend the lhs allocation even when it could.
**Resolution:** Make `s = s + suffix` compile (or lower) to the amortized path when the ownership pass proves the lhs binding is consumed by the concat — i.e. route it through `__ryo_str_push`-style growth (realloc-or-extend, copy the suffix only) instead of fresh-buffer `ryo_str_concat`. Two substrate changes: (1) string buffers must be allowed `cap > len` headroom from concat/push paths (the fat-pointer layout already carries `cap`; only allocation policy changes), and (2) codegen/sema must select the push path only when the lhs is a plain local binding that dies at the concat — field reads, shared results, and any borrowed lhs keep the allocating path. Interacts with the small-string work (I-171), which redesigns the same slot layout; land them in coordination. Re-checkpoint `string_building` — the gap should collapse toward Rust parity with no source change — and `doubling_concat`.

---

## Cross-References
Expand Down
Loading
Loading