Goose

Goose benchmark results

Generated by bench/run_bench.py on 2026-10-16 14:59. Do not edit by hand. The benchmarks themselves are bench/goose/*.goose, bench/cpp/*.cpp and bench/rust/*.rs; their purpose is explained in bench/design.md.

How to read this

Environment

CPU AMD Ryzen 9 9950X3D 16-Core Processor
RAM 94 GB
OS Windows 11 10.0.26200 (AMD64)
v145 MSVC 19.51.36260 (toolset 14.51.36231), /O2 (C++20 for the C++ rows)
clang clang-cl 22.1.3 (bundled with VS), /O2 (C++20 for the C++ rows)
Goose goose -O2 to C, then each toolchain above on that C
Goose JIT goose -O2 --jit: TinyCC in the compiler's own process, no C file
Rust rustc 1.98.0 (88d9e12ae 2026-08-18), -O -C codegen-units=1

Startup floor (an empty program, best of 5): Goose 13.1 ms / 7.8 MB, C++ 13.8 ms / 7.8 MB. Account for this startup cost when interpreting ratios at small sizes.

Across all benchmarks

How many times faster (or smaller) Goose is than the best row of each comparison at the large size. Idiomatic is what a C++ programmer writes first (unique_ptr, vector<string>, unordered_map, std::variant); expert is the hand-built version (exactly-reserved arena with indices, string_view, flat offsets, open addressing, CSR); rust is the best safe Rust row for that benchmark, whichever shape won. Above 1.00 means Goose wins. The suite figure is the geometric mean over the 16 benchmarks.

The Rust number is the same in every toolchain column -- there is only one rustc -- so those columns differ only in which Goose build they are against.

benchmark vs idiomatic (v145) vs idiomatic (clang) vs expert (v145) vs expert (clang) vs rust (v145) vs rust (clang) memory vs idiomatic memory vs expert memory vs rust
sum 1.60x 1.52x 1.11x 1.14x 1.05x 0.98x 1.42x 1.00x 1.00x
push 3.13x 3.08x 1.19x 1.93x 1.08x 1.08x 1.87x 1.00x 1.00x
strlist 3.52x 3.27x 1.32x 1.20x 1.24x 1.22x 3.12x 0.88x 1.04x
records 2.77x 2.28x 1.49x 1.42x 2.26x 2.02x 4.79x 3.74x 3.98x
tree 4.41x 4.07x 1.19x 1.12x 1.09x 1.03x 2.65x 1.00x 1.00x
interp 5.51x 6.13x 1.35x 1.39x 1.41x 1.29x 3.68x 2.26x 2.04x
graph 4.96x 5.49x 1.12x 1.23x 1.16x 1.32x 1.31x 1.00x 1.00x
words 2.20x 2.53x 1.38x 1.66x 1.30x 1.74x 1.34x 1.51x 1.32x
particles 1.20x 1.00x 1.07x 1.00x 1.00x 1.03x 1.00x 1.00x 1.00x
sexp 3.72x 3.41x 1.14x 1.18x 1.11x 1.16x 5.04x 2.57x 2.43x
lru 3.36x 4.23x 0.88x 1.09x 0.81x 1.09x 3.16x 1.36x 1.03x
scene 5.78x 7.06x 1.09x 1.22x 0.82x 1.02x 1.33x 1.99x 1.35x
calc 4.06x 3.74x 1.07x 0.91x 0.91x 0.89x 1.00x 1.00x 1.00x
bintrees 15.58x 15.82x 1.91x 2.00x 1.14x 1.16x 2.27x 1.13x 0.99x
respond 2.10x 2.08x 1.09x 1.01x 1.19x 1.35x 1.01x 1.00x 1.00x
blur 3.59x 3.96x 1.90x 1.16x 1.00x 1.12x 1.34x 1.00x 1.00x
geometric mean 3.51x 3.54x 1.24x 1.26x 1.13x 1.19x 1.95x 1.32x 1.25x

The October 16, 2026 run puts Goose at 3.51x / 3.54x the speed of idiomatic C++, 1.24x / 1.26x the speed of expert C++, and 1.13x / 1.19x the speed of the best safe Rust rows, under MSVC / clang respectively. These are geometric means over the sixteen benchmarks at their large sizes. Peak-memory ratios are 1.95x, 1.32x and 1.25x in Goose's favor. The full suite was rebuilt and rerun at all three sizes, including the separate JIT measurements.

The source improvement in this refresh is check-free graph traversal. Both Goose graph rows store vertex IDs as u32 in dist; CSR also stores prefix offsets as u32 upto out. Validation happens during construction, and checkfree fn bfs verifies that none remains in traversal. The entries are still four bytes, and construction and validation are still timed. The fresh large CSR times are 699/617 ms, against C++ 784/761 and Rust 811: 1.12x/1.23x ahead of matching C++, and 1.16x/1.32x ahead of Rust. Linked adjacency remains much slower than CSR in every language. Alternating same-compiler trials against the original Goose sources measured speedups of 1.21x/1.19x for linked adjacency and 1.08x/1.17x for CSR at the large size (notes.md). These measure the whole source rewrite; the full-suite table alone does not isolate a source change from other changes since the previous report.

Compact representation remains the clearest advantage. records is 2.02-2.26x faster than Rust on 3.98x less memory: variable-mode enums pay for each record's own payload, including inline text. interp is 1.29-1.41x faster on 2.04x less memory for the same distinction in a tree. sexp uses 2.43x less memory than the Rust arena and 5.04x less than the C++ owning nodes. For strlist, distinguish Goose's owned inline strings from the borrowing string_view, flat-offset and Vec<&str> rows; those have different lifetime requirements.

Losses and near-ties remain. calc takes 481/491 ms against the Rust arena's 436 ms. lru takes 2,760/2,068 ms against Rust's 2,249, so it loses under MSVC and wins under clang. scene is 460/371 ms against Rust 379, again a loss under MSVC and approximately level under clang. This refresh measures these differences; it does not re-establish the older diagnostic claims about which individual compiler mechanisms caused them.

A check-free kernel can still be limited by backend optimization. The flat blur is now explicitly checkfree and takes 952/266 ms. The row-slice form, also checkfree including slice construction, takes 298/269 ms. Rust's two forms take 299-300 ms. Removing bounds checks therefore does not by itself close the MSVC flat-kernel gap. The scene composition and recursive update also carry checkfree contracts. The fastest particle rows are nearly level across Goose, Rust and clang C++; small differences are not evidence of a language advantage on this flat numeric workload.

Allocation and interfaces matter to the comparison. bintrees takes 206/204 ms, against C++'s arena at 393/407 and Rust's arena at 235. The per-node allocation rows exceed three seconds. Goose uses typed references into growing storage where the safe Rust arena rows use integer indices. In respond, the inline Goose DTO takes 507/491 ms against Rust's DTO at 1,338, while Goose streaming takes 454/400 against Rust streaming at 540. Compare matching interfaces when deciding which result applies to an application.

Read the aggregate with the row-level controls. It selects the fastest Goose implementation separately for each backend and comparison tier; memory independently selects the smallest implementation. Thus graph speed uses CSR while its memory figure uses linked adjacency, and words speed uses the hand-written table while memory uses dictionary. The respond headline uses streaming, and blur uses row slices under MSVC and flat indexing under clang. These are not the resource profiles of a single implementation. words also compares different hash functions and table policies; the Goose dictionary uses less memory than its hand-written table but runs more slowly.

These are whole-process measurements on one machine. They include input generation, construction and teardown, use the best of three runs after two warm-ups, and do not pin cores. Treat small differences cautiously, especially in cache-sensitive graph and LRU workloads. The empty-process floor is now 13.1 ms for Goose and 13.8 ms for C++, versus about 8 ms in the October 10 report, and MSVC has changed from 19.51.36257 to 19.51.36260. Cross-date timing changes cannot be attributed solely to Goose compiler improvements. The older diagnostic studies are retained, explicitly marked historical, in notes.md; current comparisons come from the fresh tables.

sum

Control: build a flat i32 array, then scan it eight times.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=1000000) medium (N=16000000) large (N=128000000) large MB
goose grow-only array 16.3 / 16.0 50.2 / 54.8 298.8 / 318.5 496.1
cpp vector 17.6 / 17.6 75.3 / 74.3 477.0 / 484.1 702.0
cpp vector+reserve 16.3 / 16.2 56.3 / 59.0 332.7 / 362.2 495.8
rust Vec, collect 15.9 53.3 313.7 495.8

This control uses the same flat scalar layout in all three languages. The unreserved C++ vector pays for growth and spare capacity; the reserved vector and Rust collect over a TrustedLen iterator allocate the exact size. Whole-process time includes both building the array and scanning it.

push

Push N records while keeping pointers to every 64th one.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=1000000) medium (N=16000000) large (N=64000000) large MB
goose array, typed references 15.8 / 15.6 50.6 / 50.4 162.1 / 162.0 503.8
cpp vector, indices 20.5 / 20.3 128.7 / 126.4 508.1 / 499.1 940.0
cpp vector+reserve, indices 16.1 / 18.1 61.0 / 91.2 193.0 / 313.3 505.3
cpp deque, pointers 37.6 / 35.3 409.5 / 387.5 1,541.1 / 1,863.5 1,254.9
rust Vec+reserve, indices 18.4 58.4 174.3 504.5

Goose retains typed Item& references across later pushes. The C++ vector and safe Rust Vec rows retain indices instead; the pointer-stable C++ deque has a different allocation and locality cost. Compare the reserved vector with Goose to separate growth strategy from the cost of building records.

strlist

Split a text into N words, keep them as a list, scan it four times.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=200000) medium (N=2000000) large (N=8000000) large MB
goose inline strings, owned 19.7 / 20.5 76.7 / 81.1 262.7 / 265.8 221.4
cpp vector 33.2 / 30.2 212.1 / 194.1 925.0 / 867.9 691.4
cpp vector<string_view> 22.7 / 23.1 103.9 / 98.0 374.5 / 345.9 348.1
cpp flat offsets 23.3 / 21.9 102.5 / 93.8 345.9 / 318.5 194.6
rust Vec 32.2 203.7 777.9 491.1
rust Vec<&str> 22.5 92.8 324.7 230.9

Goose, vector, and Vec own the split words. The string_view, flat-offset, and Vec<&str> rows borrow the input instead, so their lifetime and ownership requirements differ. Goose uses the standard library each_split and packs owned strings into one contiguous list.

records

Build a log of N variant records, aggregate it four times.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=500000) medium (N=4000000) large (N=16000000) large MB
goose variable enum 27.8 / 29.5 114.6 / 124.2 394.2 / 441.0 149.0
goose fixed enum 30.9 / 31.1 130.2 / 130.2 464.5 / 466.8 328.2
cpp virtual + unique_ptr 66.5 / 66.2 501.1 / 435.5 1,641.5 / 1,678.2 712.9
cpp variant + string 54.2 / 47.7 312.6 / 268.0 1,090.5 / 1,006.4 1,103.2
cpp variant + buffer 32.7 / 37.1 162.6 / 173.3 588.6 / 625.5 556.9
rust enum + String 42.9 232.9 891.8 593.4

The variable enum stores each record at the size of its own variant, including inline text. The fixed-enum Goose row controls for that representation choice. C++ variant and Rust enum reserve space for the largest payload in every element; their owned strings may also allocate separately.

tree

Build a complete binary tree of the given depth, sum it eight times.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (depth=16) medium (depth=20) large (depth=24) large MB
goose pool + typed relative refs 15.9 / 15.7 43.9 / 44.9 618.0 / 658.3 391.8
cpp unique_ptr 20.0 / 20.1 190.1 / 182.3 2,948.4 / 2,880.6 1,038.7
cpp new/delete 20.3 / 20.2 175.2 / 172.7 2,726.6 / 2,681.3 1,038.7
cpp arena + indices 16.5 / 15.8 47.6 / 50.0 733.9 / 737.3 391.5
rust Box nodes 19.6 179.7 2,777.8 1,038.7
rust arena + indices 15.8 44.5 675.8 391.5

The arena rows avoid per-node allocation and recursive destruction. Goose links its 12-byte nodes with typed nullable relative references; C++ and safe Rust use u32 indices. Compare bintrees for the cost of repeatedly building and discarding this shape.

interp

Build an expression tree of the given depth, evaluate it eight times.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (depth=16) medium (depth=20) large (depth=24) large MB
goose case functions + relative refs 14.4 / 14.7 18.6 / 19.1 62.0 / 67.8 28.0
cpp virtual + unique_ptr 16.3 / 16.2 33.4 / 30.4 341.7 / 415.1 102.9
cpp variant + arena 15.7 / 15.0 22.0 / 23.1 87.3 / 96.3 63.0
cpp tagged union + arena 14.7 / 14.7 22.3 / 22.6 83.5 / 93.8 63.4
rust enum + Box 16.2 33.3 454.2 102.9
rust enum + arena indices 15.4 22.4 87.5 57.0

Variable-size leaves and binary nodes use different amounts of storage in Goose. The C++ tagged union and Rust enum arenas reserve the largest payload in every node. The owning-pointer rows additionally pay for allocation and recursive destruction.

graph

Build adjacency for V vertices and 8V edges, then BFS from four sources.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (V=100000) medium (V=500000) large (V=2000000) large MB
goose one-pass, typed relative refs 42.7 / 41.2 431.4 / 543.4 3,911.7 / 4,078.0 160.5
goose CSR two-pass 25.9 / 27.7 82.7 / 81.4 698.5 / 616.5 221.7
cpp vector 61.7 / 59.7 549.3 / 456.5 3,462.1 / 3,381.9 211.0
cpp CSR two-pass 27.0 / 25.9 87.9 / 94.5 784.3 / 761.1 221.1
cpp arena + indices 40.8 / 39.2 510.7 / 573.9 4,603.5 / 5,278.6 160.1
rust Vec 41.8 334.5 2,085.4 248.2
rust CSR two-pass 27.2 99.1 810.9 221.1
rust arena + indices 40.0 426.1 4,441.1 160.1

CSR and linked adjacency have very different traversal locality in every language; compare matching representations before attributing a gap to the compiler. Both Goose BFS functions are checkfree: u32 vertex index types validate destinations on insertion, and CSR bound types validate offsets during construction. These retain the original four-byte entries. Construction and distance-array resets remain inside the measured process. Random-access timings vary substantially between runs.

words

Count word frequencies over a text of N words.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=500000) medium (N=4000000) large (N=16000000) large MB
goose slices + open addressing 31.0 / 28.2 229.6 / 179.2 1,608.3 / 1,199.5 535.6
goose std dictionary 33.2 / 34.2 261.1 / 203.1 1,822.2 / 1,473.4 423.6
cpp unordered_map 48.5 / 46.0 388.7 / 346.4 3,534.9 / 3,031.3 568.2
cpp unordered_map<string_view> 48.9 / 46.7 407.7 / 378.8 3,870.0 / 3,527.0 567.2
cpp open addressing 45.2 / 43.0 345.4 / 309.9 2,214.5 / 1,987.8 638.4
rust HashMap<&str> 41.7 320.6 2,919.4 559.0
rust open addressing 37.0 296.9 2,084.0 675.9

The hand-written Goose table and std dictionary differ in capacity and load-factor policy as well as implementation. Goose uses the standard library hash; the C++ and Rust open-addressed tables use FNV-1a, while Rust HashMap uses its default keyed hasher. The timing comparison therefore includes different hash functions. The dictionary row measures the convenience and memory tradeoff of the standard container.

particles

Integrate N particles for 200 steps of f32 vector math.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=100000) medium (N=1000000) large (N=4000000) large MB
goose AoS, elementwise 34.9 / 28.4 226.8 / 159.9 975.1 / 831.0 99.3
goose AoS, per component 29.6 / 29.4 170.3 / 162.9 856.1 / 831.2 99.3
cpp AoS, per component 32.4 / 28.0 204.9 / 162.3 914.4 / 827.2 99.0
cpp AoS, elementwise 35.9 / 28.5 234.4 / 158.9 1,028.5 / 835.1 99.1
cpp SoA 62.7 / 39.8 493.7 / 311.6 1,927.1 / 1,182.9 99.1
rust AoS, elementwise 30.5 173.1 867.8 99.1
rust AoS, per component 29.6 165.0 857.8 99.1

This flat floating-point workload offers little representation advantage. Compare elementwise and per-component rows to see the cost of expression style under each backend. The C++ struct-of-arrays row changes layout as well. All arithmetic is exact in binary32, so checksum equality is required across implementations.

sexp

Generate N s-expression forms, parse them into a tree, walk it four times.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=20000) medium (N=100000) large (N=300000) large MB
goose pool + refs, inline text 97.0 / 95.4 475.3 / 459.7 1,411.5 / 1,352.9 376.2
cpp unique_ptr + string 378.1 / 334.3 1,751.8 / 1,579.9 5,256.1 / 4,614.2 1,895.6
cpp arena + string_view 119.7 / 120.4 537.2 / 542.7 1,612.0 / 1,598.9 968.5
rust enum + Box + String 300.6 1,449.1 4,271.4 1,384.2
rust arena + &str 113.2 519.0 1,571.7 913.6

Goose stores symbol text inline in variable-size nodes and links them with four-byte relative references. The C++ and Rust arenas borrow symbol text from the completed input; their owning-pointer rows allocate nodes and strings separately. Input generation, parsing, four walks, and teardown are all timed.

lru

An LRU cache of capacity N/8 under N skewed lookups, with an invalidation every 16th operation.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=2000000) medium (N=8000000) large (N=32000000) large MB
goose reusable pool + pool-relative refs 52.7 / 44.3 294.1 / 215.6 2,759.9 / 2,067.9 132.8
cpp list + unordered_map 188.1 / 240.5 2,016.8 / 1,798.0 9,267.9 / 8,739.2 420.1
cpp arena + open addressing 53.3 / 47.8 265.0 / 239.3 2,415.2 / 2,258.3 181.6
rust HashMap + arena 72.9 543.1 5,088.0 172.9
rust open addressing + arena 49.5 257.7 2,249.2 136.5

Goose uses a reusable pool with four-byte pool-relative references in both nodes and map slots. The C++ and Rust expert rows use arena indices; the idiomatic C++ row uses list plus unordered_map. Backend differences and cache-sensitive random access matter here. The separate lru_indices.goose experiment is not a headline row.

scene

Build a scene graph of the given depth, then animate it and recompute world transforms for 16 frames.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (depth=12) medium (depth=14) large (depth=16) large MB
goose pool + inline child refs 21.6 / 20.4 74.7 / 65.2 459.9 / 371.3 185.4
cpp unique_ptr children 31.4 / 30.0 414.2 / 262.4 2,655.7 / 2,620.3 246.2
cpp arena + child indices 23.4 / 21.8 78.8 / 72.4 501.7 / 454.0 369.9
rust Box children 29.3 277.4 2,628.5 255.9
rust arena + child indices 21.1 61.3 378.6 249.4

Goose stores child references inline in each 117-byte node and composes transforms directly through references. The Rust arena uses child indices and a temporary transform to work within its borrowing rules. Goose compose and recursive update are checkfree, protecting the matrix kernel and its traversal against bounds-check regressions.

calc

Generate, parse and evaluate N small arithmetic expressions, a third of them malformed.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=100000) medium (N=400000) large (N=1600000) large MB
goose local pool + return from 43.5 / 44.1 131.1 / 134.1 481.4 / 490.8 7.8
cpp unique_ptr + exceptions 136.3 / 129.8 494.4 / 468.4 1,956.0 / 1,834.2 7.8
cpp arena + error codes 46.3 / 41.6 142.8 / 123.3 514.1 / 447.6 7.8
rust Box + Result 86.4 305.1 1,175.0 7.8
rust arena + Result 40.8 119.5 435.8 7.8

The workload includes malformed input and division by zero, so error propagation is part of the comparison. Goose uses a local pool, varint number payloads, a global parse cursor, and return from; C++ compares exceptions with explicit error codes, and Rust uses Result. This refresh does not isolate the individual costs of those choices.

bintrees

The Benchmarks Game binary-trees: build, check and discard many trees up to the given depth.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (depth=15) medium (depth=17) large (depth=19) large MB
goose local pool per tree 22.8 / 22.5 55.1 / 54.8 206.0 / 203.8 31.8
cpp new/delete 151.6 / 151.5 638.2 / 648.4 3,208.4 / 3,223.7 72.0
cpp vector arena 32.1 / 32.9 98.9 / 102.6 393.3 / 406.9 35.8
rust Box 170.8 677.4 3,102.5 72.0
rust Vec arena 24.5 64.7 235.4 31.5

This repeatedly builds and discards trees rather than retaining one large tree. Goose releases a local pool by resetting its data-stack pointer; vector arenas reuse their capacity, while new/delete and Box pay per-node allocation and teardown. The arena comparisons are the relevant controls for the allocator advantage.

respond

Build and render N JSON responses, each a DTO holding a name and a list of line items.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (N=100000) medium (N=400000) large (N=1600000) large MB
goose inline DTO 46.7 / 44.3 140.1 / 135.7 507.0 / 491.0 7.8
goose streaming 42.2 / 38.1 125.0 / 110.9 454.2 / 399.9 7.8
cpp DTO + string 74.8 / 66.4 252.1 / 221.3 956.2 / 832.7 7.8
cpp streaming 45.5 / 38.4 134.7 / 112.4 494.7 / 402.6 7.8
rust DTO + String 98.3 346.8 1,338.1 7.8
rust streaming 49.1 145.9 540.4 7.8

The DTO rows construct an object before rendering; streaming rows write the same response without constructing it. Goose keeps the DTO name, item list, and per-item text inline and performs no heap allocation. All rows checksum the rendered bytes with FNV-1a, including the Goose implementations.

blur

16 passes of a 3x3 blur over a WxW 8-bit image.

Time in ms as v145 / clang for the Goose and C++ rows, and a single rustc time for the Rust ones; memory is the peak working set at the largest size.

implementation small (W=1024) medium (W=2048) large (W=8192) large MB
goose flat indexing 32.4 / 19.5 76.3 / 29.8 952.1 / 266.0 135.8
goose row slices 19.6 / 17.6 31.4 / 29.2 297.9 / 269.2 135.8
cpp flat indexing 31.4 / 30.2 80.5 / 75.7 1,069.8 / 1,054.4 181.8
cpp row pointers 22.3 / 20.1 49.7 / 32.6 564.8 / 309.0 135.5
rust flat indexing 18.8 31.2 299.2 136.0
rust row slices 17.9 30.4 299.8 136.5

Both Goose kernels are checkfree, including the row-slice constructions, so a backend gap is not evidence of surviving bounds checks. Row slices change the information available to the C compiler for alias analysis and vectorisation. Compare flat indexing and row-based implementations separately; all perform the same stencil and include image generation.

Toolchain: v145 vs clang

Ratio of v145 time to clang time at the large size (>1 means clang is faster). Only rows differing by more than 5% are listed, biggest gap first.

benchmark implementation v145 ms clang ms v145/clang
blur goose flat indexing 952.1 266.0 3.58
blur cpp row pointers 564.8 309.0 1.83
particles cpp SoA 1,927.1 1,182.9 1.63
push cpp vector+reserve, indices 193.0 313.3 0.62
words goose slices + open addressing 1,608.3 1,199.5 1.34
lru goose reusable pool + pool-relative refs 2,759.9 2,067.9 1.33
scene goose pool + inline child refs 459.9 371.3 1.24
words goose std dictionary 1,822.2 1,473.4 1.24
particles cpp AoS, elementwise 1,028.5 835.1 1.23
respond cpp streaming 494.7 402.6 1.23
interp cpp virtual + unique_ptr 341.7 415.1 0.82
particles goose AoS, elementwise 975.1 831.0 1.17
push cpp deque, pointers 1,541.1 1,863.5 0.83
words cpp unordered_map 3,534.9 3,031.3 1.17
calc cpp arena + error codes 514.1 447.6 1.15
respond cpp DTO + string 956.2 832.7 1.15
sexp cpp unique_ptr + string 5,256.1 4,614.2 1.14
respond goose streaming 454.2 399.9 1.14
graph goose CSR two-pass 698.5 616.5 1.13
graph cpp arena + indices 4,603.5 5,278.6 0.87
words cpp open addressing 2,214.5 1,987.8 1.11
interp cpp tagged union + arena 83.5 93.8 0.89
blur goose row slices 297.9 269.2 1.11
records goose variable enum 394.2 441.0 0.89
particles cpp AoS, per component 914.4 827.2 1.11
scene cpp arena + child indices 501.7 454.0 1.11
words cpp unordered_map<string_view> 3,870.0 3,527.0 1.10
interp cpp variant + arena 87.3 96.3 0.91
strlist cpp flat offsets 345.9 318.5 1.09
interp goose case functions + relative refs 62.0 67.8 0.91
records cpp variant + string 1,090.5 1,006.4 1.08
strlist cpp vector<string_view> 374.5 345.9 1.08
sum cpp vector+reserve 332.7 362.2 0.92
lru cpp arena + open addressing 2,415.2 2,258.3 1.07
calc cpp unique_ptr + exceptions 1,956.0 1,834.2 1.07
strlist cpp vector 925.0 867.9 1.07
sum goose grow-only array 298.8 318.5 0.94
tree goose pool + typed relative refs 618.0 658.3 0.94
lru cpp list + unordered_map 9,267.9 8,739.2 1.06
records cpp variant + buffer 588.6 625.5 0.94

Goose: compiled vs JIT

The same Goose source two ways: built by v145 from the generated C and run, against the compiler compiling and running it in one process through TinyCC. Each cell is compiled / JIT in ms.

The JIT number covers the compiler's own front end and TinyCC compiling the program as well as running it, and TinyCC is a one-pass compiler with no optimizer, so this is not a measurement of Goose against another language -- it is what the convenience of needing no C toolchain costs. The memory columns are the peak working set; the JIT one holds the compiler, the generated C and the program at once.

benchmark implementation small medium large JIT/compiled (large) large MB compiled / JIT
sum goose grow-only array 16.3 / 42.5 50.2 / 196.9 298.8 / 1,333.8 4.46x 496.1 / 499.4
push goose array, typed references 15.8 / 44.2 50.6 / 202.1 162.1 / 709.4 4.38x 503.8 / 507.1
strlist goose inline strings, owned 19.7 / 53.9 76.7 / 223.9 262.7 / 779.4 2.97x 221.4 / 226.7
records goose variable enum 27.8 / 68.2 114.6 / 271.7 394.2 / 992.0 2.52x 149.0 / 152.6
records goose fixed enum 30.9 / 69.5 130.2 / 338.2 464.5 / 1,222.2 2.63x 328.2 / 331.8
tree goose pool + typed relative refs 15.9 / 37.7 43.9 / 111.5 618.0 / 1,412.6 2.29x 391.8 / 395.3
interp goose case functions + relative refs 14.4 / 33.3 18.6 / 43.1 62.0 / 149.4 2.41x 27.9 / 31.6
graph goose one-pass, typed relative refs 42.7 / 83.9 431.4 / 1,040.5 3,911.7 / 7,662.7 1.96x 160.5 / 166.0
graph goose CSR two-pass 25.9 / 61.6 82.7 / 188.7 698.5 / 1,703.0 2.44x 221.7 / 227.0
words goose slices + open addressing 31.0 / 91.8 229.6 / 728.5 1,608.3 / 4,687.6 2.91x 535.6 / 541.2
words goose std dictionary 33.2 / 103.5 261.1 / 597.7 1,822.2 / 4,231.6 2.32x 423.6 / 429.6
particles goose AoS, elementwise 34.9 / 98.1 226.8 / 684.6 975.1 / 2,648.2 2.72x 99.3 / 103.1
particles goose AoS, per component 29.6 / 95.3 170.3 / 679.1 856.1 / 2,573.4 3.01x 99.3 / 103.2
sexp goose pool + refs, inline text 97.0 / 269.9 475.3 / 1,235.5 1,411.5 / 3,651.0 2.59x 376.2 / 382.0
lru goose reusable pool + pool-relative refs 52.7 / 123.7 294.1 / 775.6 2,759.9 / 6,082.3 2.20x 132.8 / 138.4
scene goose pool + inline child refs 21.6 / 86.1 74.7 / 373.9 459.9 / 2,148.1 4.67x 185.4 / 189.5
calc goose local pool + return from 43.5 / 129.6 131.1 / 418.1 481.4 / 1,547.5 3.21x 7.8 / 17.5
bintrees goose local pool per tree 22.8 / 70.9 55.1 / 218.0 206.0 / 885.4 4.30x 31.8 / 35.3
respond goose inline DTO 46.7 / 143.1 140.1 / 466.9 507.0 / 1,767.1 3.49x 7.8 / 18.5
respond goose streaming 42.2 / 108.1 125.0 / 326.2 454.2 / 1,202.7 2.65x 7.8 / 18.4
blur goose flat indexing 32.4 / 189.6 76.3 / 649.8 952.1 / 9,917.6 10.42x 135.8 / 139.2
blur goose row slices 19.6 / 181.5 31.4 / 626.2 297.9 / 9,512.0 31.93x 135.8 / 139.4
geometric mean 3.44x

Floor for a JIT row (an empty program compiled and run, best of 5): 30.5 ms / 15.6 MB. That is almost all compilation, and every JIT cell above carries it.

October 16, 2026 refresh

The main tables are a fresh run of all sixteen benchmarks, at all three sizes, under MSVC, clang-cl, rustc and Goose's TinyCC backend. The compiler was rebuilt in Release from f6d31d0e, with the benchmark source changes in this refresh and no optional native packages:

cmake -S . -B build-bench-refresh -DGOOSE_BUILTIN_LIBS=
cmake --build build-bench-refresh --config Release --parallel 1 -- /nodeReuse:false
python bench/run_bench.py --exe build-bench-refresh/Release/goose.exe

The default measurement policy is unchanged: two warm-ups, then the best of three whole-process runs, with checksum agreement across implementations. Input construction, validation, traversal, and teardown remain timed. No bounds checks are disabled in the headline measurements. The JIT rows also include compiling the program, and their peak memory includes this minimal compiler build; compare them separately from native executables.

All 528 measurements succeeded. The 48 benchmark/size combinations agree across implementations and with the saved previous run's checksums (after normalizing line endings). The original and revised graph implementations also agree at V=1, V=2 and V=17.

Index types and checkfree kernels

Both graph implementations now store destinations and queue entries as u32 in dist. The CSR implementation additionally stores prefix offsets as u32 upto out. The arrays still have four-byte elements and perform the same graph construction and four breadth-first searches. dist and out are initialized before constructing the typed values they validate.

Both bfs functions are checkfree. A length assertion on the companion head/offset array states its relationship to dist once per search. Every access in traversal is then proven, including an index loaded from another array. The CSR successor offset widens the vertex to i64 before adding one, so the proof does not have to allow wrapping u32 arithmetic.

At the source files' default sizes, --bce-lines reports:

source index checks elided slice checks elided contract
graph 12/12 0/0 BFS
graph_csr 23/27 0/0 BFS; four checks remain in construction
blur 20/20 0/0 flat kernel
blur_assert 20/20 0/0 flat kernel with explicit precondition
blur_rows 20/20 8/8 row slices and stencil
scene 12/12 0/0 composition and recursive update

The blur and scene annotations enforce existing elimination; they do not change the computation. An attempted LRU rewrite could not prove masked probe indices from a dynamic table length, so it was not adopted. This refresh makes no claim that every benchmark is entirely check-free.

bench/bce_ab.py still supports the annotated sources: its --no-bce diagnostic copy removes only the checkfree annotations, since their purpose is to reject precisely that build. It retains runtime bounds checks and index-type validation. The ordinary source and all headline builds retain their contracts.

Paired comparison of the graph rewrite

Original sources from f6d31d0e and the revised sources were built with the same fresh Release compiler and C toolchains. Each pair ran in alternating order, with two discarded warm-up pairs and five measured pairs. The table reports the best time of each version at V=2,000,000 (16,000,000 edges, four searches); checksums agree in every run.

implementation backend original ms revised ms speedup
graph v145 4,708.4 3,883.8 1.21x
graph clang 4,822.5 4,050.0 1.19x
graph_csr v145 809.6 749.1 1.08x
graph_csr clang 744.0 633.2 1.17x

This measures the complete source rewrite, including global array placement, unsigned index storage and extraction of BFS. It does not isolate the cost of removing bounds checks alone. The entry widths and storage requirements are unchanged. The separate small-size CSR trials were not consistently faster; the improvement claim is for these large inputs.

Raw full-suite measurements are saved locally in bench/gen/results.json; the paired samples and source snapshots are in bench/gen/refresh/. As with other generated benchmark files, that directory is ignored by Git.

Historical analysis through October 10, 2026

Everything below records earlier measurements and compiler experiments. Those A/B studies were not all repeated for the October 16 refresh. Their times, elision counts, causal explanations and references to "this report" describe the older run, not the current tables. In particular, the old conclusion that graph indices loaded from memory cannot be proven is superseded by the index-type implementation above. Use the current generated tables and summary.md for present-day comparisons; the historical material is retained as a record of the experiments.

Reading the Rust rows (historical)

The Rust comparisons use safe, idiomatic implementations. C++ has separate idiomatic and expert tiers. Where two Rust implementations differ in ownership or data structure, both are listed:

Everything else is a single row. The aggregate table compares Goose against the fastest Rust implementation in this suite.

Build flags are -O -C codegen-units=1. Using one codegen unit matches the C++ compilation model: every C++ row is one translation unit compiled whole, and the rustc default of 16 units would deny Rust the same cross-function view. Everything else is left where a shipped Rust binary leaves it -- bounds checks on, unwinding panics, no target-cpu beyond the x86-64 baseline the C++ rows also build for. Goose and Rust are therefore both checked, with C++ as the unchecked baseline.

Data layouts and memory use

Measured with size_of and sizeof, or read off the packed declarations:

Rust C++ Goose
tree node 12 arena / 24 Box 12 arena / 24 unique_ptr 12
interp node 12 12, both variant and union 5 leaf, 9 binary
sexp node 24 arena / 32 + heap 24 arena / 64 + heap variable, text inline, 4-byte links
records event 32 + heap for Say 48 + heap / 24 buffer variable
string header String 24, &str 16 std::string 32 none: inline varint
lru node 16 arena, both rows 16 arena / 24 std::list node + a map node, each malloc'd 16
lru map slot 8 (HashMap adds control bytes) 8 / a 32-byte unordered_map node 8
scene node 120 arena / 128 + a Vec per node 120 arena / 128 + a vector per node 117
calc node 12 arena / 24 Box + heap 16 arena / 32 unique_ptr + heap 2-3 number, 3 negate, 3 paren, 6 binary
bintrees node 8 arena / 16 Box + heap 8 arena / 16 new + heap 8
respond item 24 String + 16 + heap 32 std::string + 8 + heap sku inline, 1 + 6 + 2 varint bytes

The records row is where Rust beats C++ outright and still loses to Goose by 4.1x. Rust's 32 bytes against MSVC's 48 comes from two things: String is three words where std::string is four, and the enum tag hides in the pointer's niche rather than needing its own word. But it is still a fixed enum, so every one of the 16M elements is sized for Say whether it is a Say or not, and the text is a second allocation on top. This is a property of Rust's enum layout. records_fixed.goose provides a control with a fixed-size representation.

Three Goose layouts differ substantially from the corresponding C++ and Rust implementations. A sexp node is as big as its variant needs, with its symbol bytes inline behind a varint length and its two links 4-byte offsets into the pool -- 1.3x smaller than the same program with 8-byte links, which is what a language that cannot make a null link relative would be stuck with. A scene node is a 117-byte record whose four child links are 4-byte offsets in a limited array inside the record (spec 5.3, 3.9), so the whole scene is one contiguous, position-independent block; the arena rows in both other languages reach the same 120 bytes with uint32 indices, and their idiomatic rows pay a heap vector/Vec per node for the children on top of 128. A calc binary node is 6 bytes -- a tag, an operator byte and two 2-byte relative references, because a per-input tree never spans more than a few hundred bytes -- where the arenas' u32 links make it 12-16 and the owning-pointer rows 24-32 plus a heap block per node.

Reading the toolchain table

The two backends are within 10% on most rows and neither dominates. The gaps that remain:

Three conclusions change with the backend, which is why every ratio in this report is given per backend: against the best safe Rust row, lru is 0.72x under v145 and 1.10x under clang, scene 0.83x and 1.00x, and blur 0.98x and 1.10x. graph and bintrees, which used to be on this list, are on the same side of the Rust row under both backends now.

Reading the JIT table

TinyCC keeps a C local in a stack slot and holds values in registers only within one expression. The generated C reads each operand into a temporary of its own before later operands run, so that a later operand cannot change what an earlier one read; clang and MSVC erase those temporaries, and TinyCC turns each into a store and a reload. That is most of why the JIT rows went from 2.96x their compiled time in the previous report to 3.48x in this one: on blur, whose kernel is one long expression per pixel, bisecting the JIT slowdown lands on the commit that introduced the ordering, which took the W=2048 row from 365 to 666 ms.

Bounds-check elimination, measured

The compiler proves most index and slice checks away (spec 10.5). bench/bce_ab.py builds each benchmark with and without --no-bce under both toolchains, at the size baked into each source file rather than the report's large one. --no-bce switches off more than the checks: the loop-view hoist decides whether a loop's array length is invariant by asking BCE's kill summary, so with the pass off the view is re-read every iteration too. The A/B measures both together. The counts are per generated function, so a kernel inlined at two call sites counts its checks twice.

benchmark index checks elided slice v145 gain clang gain
blur 20/20 0/0 +82% +3%
blur_assert 20/20 0/0 +81% -1%
blur_rows 20/20 8/8 +191% -1%
lru 6/20 0/0 +2% +7%
scene 12/12 0/0 +5% 0%
respond 3/3 3/3 +4% -2%
strlist 1/2 1/2 +4% -2%
calc 19/22 0/0 +1% -1%
records 0/1 0/0 0% +1%
sexp 11/12 0/1 0% 0%
graph_csr 15/21 0/0 0% -3%
graph 7/9 0/0 0% -6%
words 60/67 11/12 -7% +1%
sum, push, tree, interp, particles, bintrees no index expressions at all

words under v145 and graph under clang read the pass-on binary slower, which is the noise floor of a cache-bound random-access row rather than a result. Everything between -7% and +7% is in the same category, which now includes graph_csr's clang column, +31% in the previous round.

The row where it matters is the image kernel, and only under v145. At W=2048, 16 passes, ms, best of four:

v145 clang
blur: src[y*W + x], 20/20 68 23
blur_assert: the same plus a now redundant assert(src.len == W * W), 20/20 68 23
blur_rows: row slices with a length assert each, 20/20 26 23
any of the three with --no-bce 124-74 23-24

Under v145 the nine checks per pixel keep the loop scalar: proving them away is worth 1.8x on the flat kernel and 2.9x on the row-slice form. The flat kernel stays scalar even with every check gone, because MSVC cannot rule out that the stores through dst feed later loads through src; the row-slice form vectorises, which is the remaining 2.6x between the two. Under clang the checks cost nothing at all -- it vectorises the checked loop as fast as the unchecked one, exactly as it does for the Rust blur_index row, which keeps ten panic_bounds_check sites in its assembly and runs at the speed of the checkless windows row. In the previous round clang also needed the loop-view hoist, worth most of a +327% on the blur row, because src and dst were fat reference parameters whose headers a byte store through dst might change. The kernel is now inlined at both of its calls in main, where the two arrays are locals whose headers no store can reach, and the clang build is as fast with the pass off as with it on.

blur proves its checks from main's own knowledge that both arrays are W * W long. Before it was inlined, the same facts reached the kernel through its call sites: every call site of a specialization records what it proves about the lengths and integers it passes, and the body enters with the meet of those facts (spec 10.5). What keeps the fact alive between the two calls is that a call does not kill it: a callee's effects on its callers' arrays are summarized, and a kernel that only reads and writes elements has none, so the second call in blur(a, b); blur(b, a) knows as much as the first. The assert in blur_assert.goose is redundant, and the two rows time the same; the row slices in blur_rows.goose also lose their slice checks, since the slice's upper bound is measured against a length the kernel knows.

Every check that survives elsewhere is an index that came out of memory. dist[u] where u = q[qh], dist[w] where w = cur.to, out[cursor[s]] in the CSR fill, pool[slots[at].node] throughout lru. The analysis gets everything derived from loop counters, lengths, constant masks and reductions, and nothing that was loaded from a data structure -- which is what test/optimizer/bce.goose documents. blur adds a second class, an index that is a product of two counters; the difference-constraint domain carries one-shot product and two-term-sum bases for that idiom, which is also what takes scene to 12/12.

Rewrites were tried to see whether any natural form unlocks the loaded-index checks, and none is worth adopting: making graph's arrays globals (the invariant would have to hold on array contents), deriving words's mask from slots.len, using the documented % reduction (the range fact does not survive the loop back-edge), and one assert at the top of the probe loop -- all four timing within noise of each other, because those checks are perfectly predicted branches on values already in registers. Array-contents invariants themselves were implemented and measured, and bought elisions on the two graph rows with no measurable time on either, so they were dropped. The blur measurement above is the counterexample: a check per load in a loop that would otherwise vectorise is not free at all.

What the standard library costs the benchmarks

Seven of the sixteen benchmarks use stdlib/ functions in place of hand-written code with the same behavior. Each adoption was measured on its own, at the large size, both toolchains, best of three interleaved runs, against the hand-written form it replaced (above 1.00 means the library form is faster):

row what it uses v145 clang
strlist each_split 1.04 1.07
words each_split, hash, next_pow2, push_n 1.01 1.03
graph, graph_csr fill 0.98 1.04
lru next_pow2, push_n 1.03 0.98
sexp format_int 1.00 1.01
particles, particles_scalar, scene vec's float3 0.99 1.00

The adopted library functions add no measurable cost; on two benchmarks they are slightly faster than the loops they replace. The words figures predate std's hash reading 8 bytes a step; against the byte-wise FNV-1a it used to be, which is also what the C++ and Rust tables hash with, the current one measures 0.94 under v145 and 1.19 under clang on that row. respond and respond_stream used hash too, as the checksum of each rendered response, and now write FNV-1a out instead: every row of a benchmark has to print the same checksum, and the other languages' rows compute FNV-1a. words additionally keeps a whole second row, words_dictionary.goose, which replaces the hand-written table with dictionary<u8[:], i32>: that one is a data-structure comparison, not a notation one, and the per-benchmark table has it.

Three other substitutions were tested but not adopted:

The library also has to be reachable from the benchmarks' own names. The std functions are global, so a local named fill shadows the library function and produces an error at the call rather than the declaration; graph_csr's CSR write cursor is called cursor for that reason, which is also what it is.

Where Goose loses

Relinking through relative references is the mutation-heavy case, and the pool-relative form is what makes it competitive. lru is the one benchmark in the suite that mutates links rather than building them once. With self-relative links and index slots it is 1.5x behind the Rust and C++ arenas; with spec 3.9's in pool form -- 4-byte offsets from the pool's base in the nodes and in the 8-byte map slots, pool.free(pool.index_of(n)) closing the loop -- the row is 3,163/2,065 ms against the Rust arena's 2,274 and the C++ arena's 2,607/2,437: 1.10x and 1.18x ahead of them under clang, 1.39x and 1.21x behind under v145, on the least memory of the three. The index form is kept as lru_indices.goose; back to back, in an earlier round, it was level under v145 and 1.31x slower under clang. The same program written more ways, at the large size, all with the map holding indices, also from an earlier round:

links node v145 clang
Node&<u32>? self-relative 16 3,738 3,189
Node&<u64>? self-relative, no range check 20 3,573 2,881
Node? plain references 24 2,557 2,352
i32 indices, pool[i] bounds-checked (the Rust shape) 16 2,397 2,266

Plain references and indices are level, so it is not references that cost and not the reusable pool (every variant frees and reuses through the same freelist, 1.3M times). It is the self-relative encoding: an offset computed from two addresses and stored on every relink -- six per hit -- and, on every load, a null test and an add before the next load can issue. The range check is a tenth of it under clang and nothing under v145; the rest is the arithmetic on the pointer-chasing critical path. The links were made relative for the 8 bytes they save per node against plain references, which lru never gets to enjoy: the index shape is the same 16 bytes. On the build-once benchmarks (tree, interp, sexp, scene, bintrees) the same links cost nothing measurable against indices, so this is specifically the relink pattern. The pool-relative form replaces the field-address subtraction with one from a base held in a register, and the load with base + off; clang schedules that far better than v145, which is the 1.53x between the two backends on this row and the whole of what is left. The build-once benchmarks keep self-relative links, whose position independence they want and whose cost against indices there is nothing measurable.

Recursive construction is no longer one of the losses, except in calc. bintrees builds and discards 34M nodes in recursive calls, and used to trail the Rust arena by 24% under clang (282 against 228 ms). The builder takes its pool by reference, and the compiler now keeps that pool's top and count in locals across the recursion instead of going through its header at every push: Goose lands at 196/191 ms against the Rust arena's 221 and the C++ arena's 378/391. tree -- the same node built once and summed eight times -- has Goose 6-10% ahead of the Rust arena. calc is 0.84-0.87x of the Rust arena: what remains there is the global cursor, the return from discriminant and a varint decode on values that mostly need two bytes.

Flat scalar kernels depend on backend optimization, with no memory advantage. blur in its obvious form is 3.3x behind flat Rust under v145, where MSVC leaves the loop scalar for fear that dst aliases src, and 1.10x ahead of it under clang (256 against 282 ms); written over row slices it is level with Rust under both. particles is 2-4% ahead of Rust on identical memory, which is within the noise of the row. There is no Goose advantage on either beyond noise, and the design did not predict one.

Pointer-linked adjacency loses to CSR, in all three languages. On graph the one-pass linked build is 5-6x slower to traverse than two-pass count-then-fill, in every language. The Goose CSR row exists to show this is a data-structure effect rather than a language one, and Goose writes CSR level with both -- 791/793 ms against C++'s 803/824 and Rust's 833.

And v145 is a worse backend for the float-and-pointer mix than clang. scene is level with the Rust arena under clang and 21% behind under v145, with the C++ arena 10% behind its own clang build on the same row; padding the 117-byte node to 120 changes nothing, so it is not the packed layout.

Caveats on these numbers

What the benchmarks say about the language

Findings from writing and maintaining the sixteen benchmarks, independent of how fast anything ran. Between them they reach reusable pools, per-input arenas inside recursion, return from as a taken error path, inline child arrays, DTOs with nested variable-size parts, and the standard library.

Features that worked well: grow-only pools with interior references, relative references inside a single root, variable-mode ADTs with inline variable-size payloads, case functions as the dispatch mechanism, reusable pools with sentinel-linked lists, limited arrays of relative references inside pool elements, a DTO with a variable-size string and an inline list of variable-size records built by a function returning straight into the field, return ... from taken on a third of all inputs from three levels of recursion, per-input local pools reset by scope exit, and a recursive builder building into a pool it is handed by reference.

Difficulties encountered:

What the Rust implementations say about Rust

Findings from implementing the suite in Rust, independent of how fast anything ran. In these measurements, Rust is closer to Goose than idiomatic C++ on speed. The implementation differences below help explain the remaining gaps.

push requires indices in the Rust implementation. push keeps a pointer to every 64th element of an array it is still appending to. In Goose that is marks.push(items.push(...)) and the reference stays valid for the array's whole life. In Rust it does not compile: a &Item borrows items for as long as it is held, so the following items.push is rejected. No std container is both pointer-stable and O(1)-append. The row uses indices because that is what a Rust programmer writes; the interesting cost is in the source, not the numbers.

Several Rust implementations use indices where Goose uses references. This applies to tree, interp, graph, sexp, scene, calc, bintrees and lru: the fast, safe, recommended Rust shape is a Vec arena with u32 links, exactly as design.md predicted. What that costs is not safety -- an out-of-range index panics like any other Rust index -- but: the type says u32, not what it points at; Option<&T> becomes a sentinel that occupies a real slot; and an index into a pool that is later reused is stale in a way the compiler cannot see, where a reference would have been rejected. Goose reaches the same layouts with TNode&<u32>?: checked, nullable, and typed to the thing it points at.

The arena is sometimes worse to write than the Box row, not just different. In scene_arena.rs, update cannot compose into the node in place: &pool[i].local and &mut pool[i].world are two borrows of one Vec and neither Index nor IndexMut can split them, so the transform is built in a stack local, copied in, and the child list is copied out of the node before recursing. scene_box.rs has neither problem, because disjoint fields of one &mut borrow independently. Goose composes straight through Xf& references into the pool element.

The idiomatic LRU is already the arena. std has no intrusive list and LinkedList hands out no node identity, so O(1) move-to-front by handle needs Rc<RefCell<Node>> (two allocations and a refcount pair per node) or the Vec-plus-indices arena. Both Rust rows use the arena; they differ only in whether the map is HashMap (SipHash) or open addressing.

Rust enums are the best fixed-tag ADT of the three. A byte discriminant, a jump table from match, no vtable pointer, and niche optimisation that hides the tag inside a payload pointer. That is enough to beat std::variant on both time and memory in records and interp. What it cannot do is stop being fixed: every element is sized for the largest variant. Variable-mode enums are the one Goose feature in this suite without a direct counterpart in Rust's builtin enums.

Result and ? work well in calc. The Rust code reads like the C++ exception implementation and has a runtime cost similar to the error-code implementation. What the arena version gets for free -- Vec::clear() as an O(1) teardown -- holds only because Expr owns nothing; a node holding a String would turn the clear back into a per-node drop walk, which is the ordinary case and the one Goose never has.

Rust's idioms are already the fast ones, twice. collect() over a TrustedLen iterator preallocates exactly, so the "did you remember to reserve" gap that sum and push measure against C++ simply does not exist against Rust. And borrowing is the default, so Vec<&str> and HashMap<&str, _> are the first thing written. Both are real language-design wins over C++ and both shrink the Goose margin.

Where Rust pays anyway: Box::new(T { .. }) builds the value in a stack temporary and moves it to the heap (no guaranteed construct-in-place, spec 4.3/7.3); teardown is real and it is the same cache-hostile pointer chase that building was (interp's Box row is behind C++ virtual dispatch; bintrees is nothing else); std's HashMap hashes with SipHash-1-3 by policy; and the borrow that makes sexp's arena row fast works only because the text is complete before parsing starts.

LLVM removes what the Goose pass cannot, and keeps what it can. Rust's blur_index row keeps ten panic_bounds_check sites in its assembly and runs as fast as the checkless windows row, because LLVM vectorises around them; the Goose flat row under v145 is 3.3x slower for a different reason: its checks are all proven, but MSVC cannot rule out that the stores through dst alias the loads through src and leaves the loop scalar. The Goose row-slice form, which MSVC does vectorise, runs 3.3x faster than the flat one.

Where the Goose-Rust gap comes from, and where it can still move

The sixteen rows split three ways by why the Rust comparison lands where it does, and only two of the three are worth working on.

Gains from data representation. The clearest gains come from differences in layout and reference rules:

Backend, not language. lru under v145 (0.72x), scene under v145 (0.83x), and blur's flat form under v145 (0.98x). In all three the C++ row built by the same toolchain loses in the same direction, so it is what MSVC makes of a C shape, not what Goose asked for. Nothing here is closed by changing the language, and the ceiling is visible: each of these rows is level or ahead under clang. bintrees under clang (0.81x) was on this list and was not the backend after all: keeping the builder's pool top and count in locals made it 1.16x ahead of the Rust arena.

Costs required by the chosen semantics. The bounds checks that survive are indices loaded out of a data structure, which is exactly where a check is not redundant; calc's return from discriminant is a real error path that the C++ error-code row also pays and the exception row pays far more for. These are not gaps to close.

What would extend the lead, in order:

  1. Identify a reference parameter's root class with its pool outside a recursive cycle (item 1 below). Narrow links are the memory advantage, and today a structure linked by them can only be built by functions inside the cycle that builds it. Lifting that makes the advantage available to ordinary code rather than to parsers.
  2. Cross-array narrow links, everywhere they typecheck. T&<u32 in pool> already lets a side table hold 4-byte links into a pool instead of indices -- that is what lru's map does, and a grow-only array of such links works the same way, so sexp's root list is a candidate. The idiom deserves to be the default advice for a side table, not an lru special case.
  3. Keep looking for places a root requirement is not really needed. A sentinel-ended chain does not force plain 8-byte links, because null needs no root -- that alone is 1.3x of sexp's memory. The same question is worth asking of every rule that currently demands an exact root: what does the representation actually require?

Compiler work, ordered by measured benefit

The first item of the previous round, carrying array lengths across calls, is done: a call site's facts about the arguments reach the callee's entry, and a call kills only what the callee can actually resize, so blur proved all ten checks from what main knows and ran at the asserted kernel's speed under both toolchains (1.75x under v145, 141 to 77 ms at the time). What remains:

  1. Identify a reference parameter's root class with the pool it came from, outside a recursive cycle too. A non-recursive function that takes a Node? and stores it into a Node&<u32>? field of the same pool is rejected today, though the identical code inside a recursive cycle is accepted; the cycle machinery already computes what is needed. This is what stops a linked structure's helper functions from being factored out of the cycle that builds it.
  2. Specialise the library's element loops. push_n costs 2-3% against the for loop it replaces and blocks a bounds-check elision downstream, which is the whole reason two benchmarks kept their loops. The generic form should compile to what the loop compiles to.
  3. Inline a base case that is not the first statement (scene, interp), and match expression-bodied folds (if c { 0 } else { 1 + f(x) }) in tail-recursion elimination.

Ideas that were measured and should not be done: array-contents invariants for the bounds-check pass (more elisions, no measurable time), merging tag dispatch (1%), dropping #pragma pack(1) where the layout is already gap-free (no difference on either backend), fusing an optional relative-reference load with its null test (both backends get worse), and rewriting benchmark code so the bounds-check pass can prove loaded indices (no measurable gain).