Goose benchmark results
Deprecated language features:
reusableandreusable[]. Useslot_pool<T>for slots andslice_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
- Start with Across all benchmarks: it shows the whole suite in one table. The per-benchmark sections below it give the details, and each ends with a sentence or two on what that row means.
- There is 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 comes from the backend alone. 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, including generating its input, building
its structure and 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 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
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.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 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:
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. That prediction held: the arena row is 2.5x (calc) to 14x (bintrees) faster than theBoxrow 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 is the fastest in every language and is index-based in every language, sographis the only benchmark where the references-versus-indices question does not show up in the fastest row.wordsandlruhave both aHashMaprow and a hand-written open-addressed table. std's default hasher prioritizes security over speed. Rust programmers commonly substitute a hasher crate, but this suite allows only std. Inlru, both rows use the sameVecarena and free list for the nodes; only the map differs. The arena is also the idiomatic choice here: std has no intrusive list, andLinkedListprovides no way to refer to an individual node.respondhas a DTO row (StringandVec<Item>built per request, rendered by a function returning a freshString) and a streaming row that writes the JSON directly into one reusedString, mirroring the two Goose rows.blurhas a version that indexes every element and a version using row slices andwindows(3)iterators. The latter is a common way to avoid bounds checks when profiling shows they are costly. They are not costly 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 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:
- A
sexpnode is only as big as its variant needs. Its symbol bytes are stored inline after a varint length, and its two links are 4-byte offsets into the pool. This makes the program 1.3x smaller than the same program with 8-byte links, which is what a language that cannot make a null link relative would have to use. - A
scenenode 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 C++ and Rust both reach 120 bytes withuint32indices, and their idiomatic rows add a heapvector/Vecper node for the children on top of 128 bytes. - A
calcbinary node is 6 bytes: a tag, an operator byte and two 2-byte relative references, which are enough because a per-input tree never spans more than a few hundred bytes. The arenas'u32links make the same node 12-16 bytes, and the owning-pointer rows make it 24-32 bytes 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. Every check in the flat kernel is proven, so what stops v145 from vectorising it is aliasing: MSVC reports a possible loop-carried dependence between the loads throughsrcand the stores throughdst(C5002, reason 1200) and leaves the loop scalar. It does vectorise the row-slice form, where the gap 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, because its pool-relative loads are base-plus-offset loads, which 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 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:
- making
graph's arrays globals (the invariant would have to hold on array contents); - deriving
words's mask fromslots.len; - using the documented
%reduction (the range fact does not survive the loop back-edge); - one
assertat the top of the probe loop.
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:
format_intinrespondandrespond_streamcosts 3-5% under v145 against the hand-written digit loop, and nothing under clang. Most of the original gap came fromformat_uintdividing by itsbaseparameter: that is a real division per digit, where the hand-written loop divides by the constant 10. Giving base ten its own loop in the library recovered about half of the gap. 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, shows no measurable difference.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 gain was, has no measurable cost.push_nof a value containing self-relative references does not compile: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.
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
- 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 necessary: on this machine a freshly written executable takes up to 10x its steady-state time on its first execution and about three runs to settle, and the effect is far larger on clang-linked binaries than on MSVC-linked ones. Without the warm-ups the toolchain comparison would measure 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. When
the whole harness is run twice back to back, the Goose rows stay within
3% of their earlier times, as do the arena and CSR rows in every
language, but the malloc-bound rows move by up to 23%. A difference of
less than about 10% in the "vs idiomatic" geometric mean is not
significant, and a single suite run is not enough to attribute a change
to the compiler. That needs a per-commit A/B built from the same sources
minutes apart, with
-falign-loops=32under clang so that a shifted hot loop cannot look like 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 got 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 changed too (19.51.36256 to .36257). The malloc-bound C++ rows
moved most,
sexp'sunique_ptrrow by 1.42x.tree's Goose row under v145 went from 956 to 605 ms, although the C that the previous compiler generated for it runs in the same time today as the current compiler's C. 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 the cache by design: each one's working set is a single input, request or 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
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:
- 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;
reusablepools 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 directly into the field;
return ... fromtaken on a third of all inputs from three levels of recursion;- per-input local pools reset by scope exit;
- 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]requires the children to exist and their number to be known before the parent is built. A builder that discovers children 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 have no measurable cost against indices on the five
benchmarks that build a structure once and walk it, but cost 1.5x on
lru, which relinks six links per hit. Spec 3.9'sin poolform (offsets from a named global pool's base) turns the relink into a subtraction from a register and lets other arrays hold 4-byte links into the pool; with it,lruis 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
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:
Box::new(T { .. })builds the value in a stack temporary and moves it to the heap (there is no guaranteed construct-in-place; spec 4.3/7.3).- Teardown is real work, and it is the same cache-hostile pointer
chase that building was (
interp'sBoxrow is behind C++ virtual dispatch;bintreesconsists of nothing else). - std's
HashMaphashes with SipHash-1-3 by policy. - 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, 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:
- Variable-mode enums.
recordsis 2.1-2.3x faster on 4.1x less memory because each element takes the space of its own variant rather than of the largest one, and aSay's text is inline rather than a second allocation.interpandsexpshow the same property in a tree. Rust's enum is the best fixed-tag ADT of the three languages and still uses the maximum payload size for every 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 runs level with the streaming rows, which never build the object. The Rust DTO allocates per string and per list, per request. - References into a container that is still growing. Safe
Rust cannot express this at all for
push, and fortree,graph,scene,bintreesandcalcit can express it only asu32indices. Goose gets the arena's layout with the types of the pointer version. - Links narrower than a pointer.
sexp's nodes link with 4-byte offsets,calc's with 2-byte ones, andscene's four child links are stored in a 17-byte array inside the node. Rust's arena also gets down to 4 bytes, but as an index, which is untyped, needs a sentinel slot, and goes stale silently when the pool is reused.
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:
- 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.
- 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, sosexp's root list is a candidate. This idiom should be the default advice for a side table, rather than 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 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:
- 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 aNode&<u32>?field of the same pool is rejected today. The identical code inside arecursivecycle 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. - Specialise the library's element loops.
push_ncosts 2-3% against theforloop 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. - 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:
Array-contents invariants for the bounds-check pass: more checks removed, but no measurable time saved.
Merging tag dispatch: a 1% gain.
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.
Rewriting benchmark code so the bounds-check pass can prove loaded indices: no measurable gain.
Goose