Goose

Goose benchmark results

Deprecated language features: reusable and reusable[]. Use slot_pool<T> for slots and slice_pool<T> for contiguous runs. Builtin behavior and historical examples below remain relevant to existing code; removal is planned separately, after the remaining migration gaps.

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 implementation that is. Above 1.00 means Goose is ahead. The suite figure is the geometric mean over the 16 benchmarks.

There is only one rustc build, so the Rust number is the same in every toolchain column; those columns differ only in which Goose build they compare 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 no bounds check remains in the 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: a variable-mode enum stores each record at the size of its 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.

The aggregate combines results from different implementations. For each backend and comparison tier, it uses the fastest Goose implementation. Memory figures are selected separately, using the implementation with the lowest memory use. For example, graph speed comes from CSR and its memory figure from linked adjacency. words speed comes from the hand-written table and its memory figure from dictionary. The respond headline uses streaming; blur uses row slices under MSVC and flat indexing under clang. No single implementation has all of these results. Also, words 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
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. 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. The Goose compose and recursive update functions are checkfree, which protects 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 is run two ways: built by v145 from the generated C and then run, and compiled and run by the compiler in one process through TinyCC. Each cell is compiled / JIT in ms.

The JIT number includes 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 does not measure Goose against another language; it measures the cost of not needing a C toolchain. The memory columns are the peak working set; the JIT process holds the compiler, the generated C and the program at the same time.

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
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.48x

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

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 only enforce bounds-check elimination that the compiler already did; 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 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 as a whole, and with the rustc default of 16 units Rust would not get the same cross-function view. All other settings are the defaults a shipped Rust binary uses: bounds checks on, unwinding panics, and no target-cpu beyond the x86-64 baseline that 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 is stored 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 needs a second allocation as well. This is a property of Rust's enum layout.

Three Goose layouts differ substantially from the corresponding C++ and Rust implementations:

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 optimize those temporaries away, but TinyCC turns each one into a store and a reload. This accounts for most of the increase in the JIT rows 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 points to the commit that introduced this operand ordering; that commit 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 disables more than check elimination: 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

For words under v145 and graph under clang, the binary with the pass on measures slower. This is the noise floor of a cache-bound random-access row, not a real result. Everything between -7% and +7% falls 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 makes the flat kernel 1.8x faster and the row-slice form 2.9x faster. 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 does vectorise, which accounts for the remaining 2.6x between the two. Under clang the checks have no measurable cost: clang vectorises the checked loop to run as fast as the unchecked one, 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, which accounted for 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 what main itself knows: that both arrays are W * W long. Before the kernel was inlined, the same facts reached it through its call sites. Every call site of a specialization records what it proves about the lengths and integers it passes, and the body starts with the meet of those facts (spec 10.5). The fact stays valid between the two calls because 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 no such effects. So at the second call in blur(a, b); blur(b, a) the compiler knows as much as at 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 each slice's upper bound is compared against a length the kernel knows.

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

Four rewrites were tried to see whether any natural form lets the compiler prove the loaded-index checks, and none is worth adopting:

All four time 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. They removed more checks on the two graph rows but saved 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 has a large cost.

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 against the hand-written form it replaced, at the large size, under both toolchains, best of three interleaved runs (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 were measured before std's hash started reading 8 bytes per step. Compared with the byte-wise FNV-1a that std's hash used to be, which is also what the C++ and Rust tables hash with, the current hash measures 0.94 under v145 and 1.19 under clang on that row. respond and respond_stream also used hash, as the checksum of each rendered response, and now compute FNV-1a in their own code instead: every row of a benchmark has to print the same checksum, and the other languages' rows compute FNV-1a. words also keeps a second row, words_dictionary.goose, which replaces the hand-written table with dictionary<u8[:], i32>. That row is a data-structure comparison rather than a comparison of notation, and its results are in the per-benchmark table.

Three other substitutions were tested but not adopted:

Benchmark code also has to avoid names that hide library functions. The std functions are global, so a local named fill shadows the library function, and the error appears at the call rather than at the declaration. graph_csr's CSR write cursor is called cursor for that reason, and the name also describes 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 only 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, the nodes and the 8-byte map slots hold 4-byte offsets from the pool's base, and pool.free(pool.index_of(n)) turns a link back into a freelist index. In that form the row takes 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 and 1.39x and 1.21x behind under v145, while using the least memory of the three. The index form is kept as lru_indices.goose; measured back to back in an earlier round, it was level under v145 and 1.31x slower under clang. The table below shows the same program written in 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 the cost comes neither from references as such nor from the reusable pool (every variant frees and reuses through the same freelist, 1.3M times). It comes from the self-relative encoding. Every relink (six per hit) computes an offset from two addresses and stores it, and every load needs a null test and an add before the next load can issue. The range check accounts for a tenth of that cost under clang and none of it under v145; the rest is the arithmetic on the pointer-chasing critical path. The links were made relative to save 8 bytes per node against plain references, but lru gets no benefit from that, because the index form is also 16 bytes. On the build-once benchmarks (tree, interp, sexp, scene, bintrees) the same links have no measurable cost against indices, so the cost is specific to the relink pattern. The pool-relative form replaces the subtraction of the field's address with a subtraction of a base held in a register, and replaces the load with base + off. clang schedules that much better than v145; this accounts for the 1.53x between the two backends on this row, and for all of the remaining gap. The build-once benchmarks keep self-relative links, because they want the position independence and the links have no measurable cost against indices there.

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. With that, Goose takes 196/191 ms against the Rust arena's 221 and the C++ arena's 378/391. On tree, which builds a tree of the same nodes once and sums it eight times, Goose is 6-10% ahead of the Rust arena. calc runs at 0.84-0.87x of the Rust arena; the remaining costs there are 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. The flat, indexed form of blur is 3.3x slower than flat Rust under v145. MSVC leaves the loop scalar because it cannot rule out dst aliasing src. Under clang, the same form is 1.10x faster than Rust (256 against 282 ms). Written over row slices, it is level with Rust under both backends. particles is 2-4% ahead of Rust with identical memory use, which is within the noise of that row. Neither benchmark shows a Goose advantage 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 the Goose CSR implementation is level with both: 791/793 ms against C++'s 803/824 and Rust's 833.

v145 is also a worse backend than clang for the float-and-pointer mix. 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 the packed layout is not the cause.

Caveats on these numbers

What the benchmarks say about the language

These findings come from writing and maintaining the sixteen benchmarks, independent of how fast anything ran. Together the benchmarks use 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:

Difficulties encountered:

What the Rust implementations say about Rust

These findings come 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 lifetime. In Rust the equivalent 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 difference is in the source code, not in the timings.

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 form is a Vec arena with u32 links, as design.md predicted. The arena does not give up safety, since an out-of-range index panics like any other Rust index. Its costs are these: the type says u32 rather than what it points at; Option<&T> becomes a sentinel that occupies a real slot; and an index into a pool that is later reused goes stale in a way the compiler cannot detect, where a reference would have been rejected. Goose gets the same layouts with TNode&<u32>?, which is checked, nullable, and typed to the thing it points at.

The arena is sometimes harder to write than the Box row. 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 and 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 directly through Xf& references into the pool element.

The idiomatic LRU is already the arena. std has no intrusive list, and LinkedList provides no handle to an individual node, 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. They have a byte discriminant, a jump table from match, no vtable pointer, and a 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. But a Rust enum is still a fixed enum: every element is sized for the largest variant. Variable-mode enums are the only Goose feature in this suite that has no 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. The arena version gets an O(1) teardown from Vec::clear(), but only because Expr owns nothing. If a node held a String, the clear would become a per-node drop walk again; that is the ordinary case, and one that never arises in Goose.

In two cases, Rust's idiomatic form is already the fast one. collect() over a TrustedLen iterator preallocates the exact size, so the "did you remember to reserve" gap that sum and push measure against C++ does not exist against Rust. And borrowing is the default, so Vec<&str> and HashMap<&str, _> are what a Rust programmer writes first. Both are real language-design advantages over C++, and both reduce the Goose margin.

Where Rust still pays:

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, so it 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 fall into three groups by why the Rust comparison comes out as it does, and only two of the three groups are worth working on.

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

Backend, not language. This group is 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 the gap comes from what MSVC makes of a given C shape, not from what Goose asked for. Changing the language would not close any of these gaps, and the upper limit is visible: each of these rows is level or ahead under clang. bintrees under clang (0.81x) was on this list, but turned out not to be a backend effect: keeping the builder's pool top and count in locals put it 1.16x ahead of the Rust arena.

Costs required by the chosen semantics. The bounds checks that survive are on indices loaded from a data structure, which is where a check is not redundant. calc's return from discriminant serves a real error path; the C++ error-code row also pays this cost, and the exception row pays far more. 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 provide the memory advantage, and today a structure linked by them can be built only by functions inside the cycle that builds it. Removing that restriction would make the advantage available to ordinary code, not only 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. lru's map does this, and a grow-only array of such links works the same way, so sexp's root list is a candidate. This idiom should be the default advice for a side table, rather than 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 accounts for 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 now reach the callee's entry, and a call kills only facts about arrays the callee can actually resize. As a result blur proved all ten checks from what main knows and ran at the speed of the asserted kernel 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 its source pool outside recursive cycles too. A non-recursive function that takes a Node? and stores it into a Node&<u32>? field of the same pool is rejected today. The identical code inside a recursive cycle is accepted; the cycle machinery already computes the information needed. This restriction prevents linked-structure helpers from being factored out of the cycle that builds the structure.
  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 why 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.

Measurements ruled out the following ideas: