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
- Start with Across all benchmarks: it is the whole suite in one table. The per-benchmark sections below it are the detail, each ending in a sentence or two of what that row means.
- One table per benchmark. Rows are implementations, the three time columns are the data sizes -- the small one is meant to fit in cache, the large one is meant not to -- and the last column is peak memory at the large size.
- Every Goose and C++ time cell is
v145 / clang: the same source built by each of the toolchains in the table below, so a gap between those numbers is the backend and nothing else. Goose is compiled to C once per size and every toolchain builds that same C. Rust rows carry a single time, because rustc is the only Rust backend here; it is LLVM, so it is the clang column they are most directly comparable with. - Times are the best of 3 whole-process wall clocks, after two discarded warm-up runs. Memory is the peak working set, the larger of the toolchains (they rarely differ).
- Everything a program does -- generating its input, building its
structure, tearing it down -- is inside the measurement. Whole-process
timing measures these lifecycle costs together; input generation can
dilute differences in the phase under study. Goose now has
os.clock_nsfor separate kernel experiments, but this suite retains its whole-process measurement policy. - Every implementation of a benchmark, under every toolchain, prints the same checksum lines and the harness verifies they agree.
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
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.exeThe 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:
strlisthasVec<String>andVec<&str>, which differ in ownership. Only the first can outlive the text it came from, which is the semantics the Goose row has.tree,interp,sexp,scene,calcandbintreeshave an owning-pointer row (Box) and an arena row (Vecplusu32indices). Both are safe and both are idiomatic; the arena is what the community recommends for anything pointer-heavy, anddesign.mdpredicted it would be the real comparison point rather thanBox. It is, by 2.5x (calc) to 14x (bintrees) on the six benchmarks that have both.graphhas three rows for the same reason the C++ side does:Vec<Vec>, CSR, and the one-pass index-linked arena. CSR wins there in every language and is index-based in every language, sographis the one place where the references-versus-indices question does not show up in the winning row.wordsandlruhave aHashMaprow and a hand-rolled open-addressed table, because std's default hasher is a security policy rather than a speed choice, and the usual Rust answer to that -- a hasher crate -- is outside a std-only suite. Inlruboth rows share the sameVecarena and free list for the nodes: std has no intrusive list andLinkedListhands out no node identity, so the idiomatic Rust LRU is already the arena, and the only thing the two rows differ in is the map.respondhas a DTO row (StringandVec<Item>built per request, rendered by a function returning a freshString) and a streaming row that writes the JSON straight into one reusedString, mirroring the two Goose rows.blurhas the obvious index-everything row and the row-slice/windows(3)iterator style a Rust programmer switches to when a profiler shows bounds checks -- which, it turns out, it would not here (see the bounds-check section).
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:
- clang vectorises what v145 does not. Goose's flat
bluris the largest gap in the suite at 3.64x (930 against 256 ms), and the next two are the C++__restrictrow of the same kernel at 1.83x andparticles cpp SoAat 1.62x. With every check in the flat kernel proven, what stops v145 is aliasing: MSVC reports a possible loop-carried dependence between the loads throughsrcand the stores throughdst(C5002, reason 1200) and leaves the loop scalar, where it vectorises the row-slice form, which is 1.12x. - v145 is better at
std::vector::push_backand at dispatch.push cpp vector+reserveis 0.61 (185 ms against 301), and clang trails on everyinterprow (0.84-0.91) and onsum(0.93 on the reserved vector, 0.97 on Goose). - clang is consistently 5-25% ahead on string, hash, allocator
and float-and-pointer work:
words(1.10-1.12 on the C++ rows),strlist(1.06-1.13),records variant+string(1.14),sexp unique_ptr(1.16),respond(1.16 and 1.23 on the two C++ rows),calc cpp arena(1.16), andscene, where it is 1.21 on the Goose row and 1.10 on the C++ arena. - On Goose's own rows:
recordsandinterpfavour v145 (0.89 and 0.91),respondstreaming,particleselementwise andblur_rowsfavour clang by 12-14%,wordsby 1.28-1.33, andlruby 1.53 -- its pool-relative loads are a base-plus-offset that clang schedules and v145 does not.bintrees, which favoured v145 at 0.84, is now level.
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:
format_intinrespondandrespond_streamcosts 3-5% under v145 against the hand-written digit loop, and nothing under clang. Most of the original gap wasformat_uintdividing by itsbaseparameter -- a real division per digit where the hand-written loop divides by the constant 10 -- and giving base ten its own loop in the library recovered about half of it. What is left is the extra call layer and the padding branch on a row that renders four numbers per request and does nothing else expensive.sexp, which formats one number per node among much other work, does not notice.push_ningraphandgraph_csrcosts 2-3%: the genericpushloop does not compile to the specialised one, and it also makes the destination array's length unprovable to the bounds-check pass a few statements later.fillalone, which is where the readability was, is free.push_nof a value containing self-relative references does not compile at all --push_n<A, T>(xs: A&, v: T, n: i64)takes its value by value, and such a value cannot be copied (spec 3.9).graph's edge-head fill stays a loop for that reason. AT¶meter would not help either: the library would still be copying out of it.
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
- Whole-process wall clock, so input generation is inside every
measurement. Where generation is a large share of the work
(
sexp,words,graph,calc) the ratios are diluted toward 1.0 and the gap on the interesting phase is larger than the table shows. - Best of three runs after two discarded warm-ups. The warm-ups are not optional: on this machine a freshly written executable costs up to 10x its steady-state time on the first execution and takes about three runs to settle, and it hits clang-linked binaries far harder than MSVC-linked ones. Without them the toolchain comparison measures the malware scanner.
- One machine, no core pinning. Differences under ~10% are within
measurement noise. The threshold is ~15% for the two cache-bound
random-access cases,
graph's linked rows andlru: two byte-identicallrubinaries measured 14% apart in the bounds-check A/B. - The allocator-heavy rows are the least reproducible in the
suite, and they are the ones the headline ratio rests on.
Running the whole harness twice back to back, the Goose rows land within
3% of themselves, and so do the arena and CSR rows in every language --
but the malloc-bound rows move by up to 23%. A difference in the "vs
idiomatic" geometric mean of less than about 10% is not significant, and
a single suite run is not enough to attribute a change to the compiler;
that wants a per-commit A/B built from the same sources minutes apart,
with
-falign-loops=32under clang so a shifted hot loop cannot masquerade as a codegen change. - Reports are not comparable with each other across machine
updates. Between the previous report and this one, most C++ and
Rust rows got up to 6% faster, every row in every language 3-4 MB
smaller, and the process-start floor fell from 12.4 to 7.9 ms, with no
change to their sources: something outside the compilers changed, and
MSVC did too (19.51.36256 to .36257). The malloc-bound C++ rows moved
most,
sexp'sunique_ptrrow by 1.42x, andtree's Goose row under v145 went from 956 to 605 ms although the previous compiler's C for it times the same as the current one's today. Compare compiler versions by building both and running them side by side. - This CPU has a very large L3, so
smallandmediumcan sit entirely in cache. Thelargerows are the ones that stress the memory subsystem, andcalc,respondandbintreesnever leave it by design: their working set is one input, one request, one tree. - The process-start floor is not constant between runs, and it is
inside every
smallcell, so read those as differences from the floor and read ratios only atlarge. - Rust rows are a single rustc time, not one per backend. rustc is LLVM, so the clang column is the one they are most directly comparable with; the Goose-vs-Rust ratios are reported against both Goose builds anyway.
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:
- Inline child arrays need their count at
construction. The A.2 shape
(T&<u32>)[varint]is only reachable when the children exist before the parent is built and their number is known; a builder that discovers them as it goes uses a limited[..k]array inside a fixed-size node (scene, 17 bytes for four links) or sibling links (sexp). - A plain reference parameter cannot be stored relatively
outside a recursive cycle.
fn link(prev: Node?) { pool.push(Node { next: prev }) }is rejected at any call site that passes a pool-rooted reference: the parameter's root is its own per-call-site class, and nothing identifies that class with the global the argument came from. Inside arecursivecycle the pool-class machinery (spec 7.8) does make the identification, which is whysexp's parser can do exactly this. The same identification outside a cycle is the natural fix. - Arithmetic at the operands' width bites on
u8data. The first version ofblursummed nineu8taps inu8and produced incorrect results; every tap needsas u16. C promotes tointsilently and Rust would panic in a debug build; Goose wraps, as the spec says it will (6.2), but nothing in the source points at it. - A negative literal through a generic parameter takes
i64.push_n(dist, -1, V)on ani32[>..]fails inside the library, where the boundTisi64and the push does not narrow;push_n(dist, -1 as i32, V)works, and a plain0binds fine. The literal's type should follow the destination through the instantiation. - Two parses to know about.
x as u64 & maskreadsu64&as a reference type (parenthesise the cast), and anif/else ifchain that ends without anelsein tail position is an error (write early returns). - Self-relative references trade speed for position
independence in code that relinks; pool-relative ones do not.
Self-relative links cost nothing measurable against indices on the five
benchmarks that build a structure once and walk it, and 1.5x on
lru, which relinks six links per hit. Spec 3.9'sin poolform -- offsets from a named global pool's base -- makes the relink a subtraction from a register and lets other arrays hold 4-byte links into the pool;lruon it is level with the arenas under clang. The encoding is a per-field decision: self-relative for blobs and for local pools,in poolfor relink-heavy structures in a global pool. - Thread queues cannot carry a relative-reference
structure (not flat, TODO 9), and at the time of these
measurements, save/load also lacked the whole-region copy of TODO 16.
Serialization has since been implemented
(
docs/design/serialization.md); queues can carry its byte images, but cannot directly carry values containing relative references.
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:
- Variable-mode enums.
recordsis 2.1-2.3x faster on 4.1x less memory because an element costs its own variant rather than the largest one, and aSay's text is inline rather than a second allocation.interpandsexpare the same property in a tree. Rust's enum is the best fixed-tag ADT of the three languages and still pays max-payload per element; changing that requires a different representation. - Variable-size parts stored inside records.
respondbuilds a DTO whose string, item list and per-item sku are all inline, renders it with no allocation anywhere, and lands level with the streaming rows that never build the object. The Rust DTO allocates per string and per list, per request. - References into a container that is still growing.
pushhas no safe Rust spelling at all;tree,graph,scene,bintreesandcalchave one only asu32indices. Goose gets the arena's layout with the pointer version's types. - Links narrower than a pointer.
sexp's nodes link with 4-byte offsets,calc's with 2-byte ones,scene's four child links live in a 17-byte array inside the node. Rust's arena reaches 4 bytes too -- as an index, which is untyped, needs a sentinel slot, and goes stale silently when the pool is reused.
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:
- 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.
- 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 whatlru's map does, and a grow-only array of such links works the same way, sosexp's root list is a candidate. The idiom deserves to be the default advice for a side table, not anlruspecial case. - 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:
- 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 aNode&<u32>?field of the same pool is rejected today, though the identical code inside arecursivecycle 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. - Specialise the library's element loops.
push_ncosts 2-3% against theforloop 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. - 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).
Goose