TYCHO Search

Search documentation

    ↑ ↓ to move · Enter to open · Esc to close

    Goose review: what transfers to Tycho

    Status: research note, 2026-10-21. No language or runtime change is implied by this file.

    Sources: aardappel/goose, read at master on 2026-10-21: its README, language spec, reference, implementation notes, packages, standard library, and the samples/benchmark notes. Goose's performance claims are its author's measurements, not independently reproduced here.

    Constraints for this review:

    The useful split

    Goose's headline features are mostly consequences of a different memory model. Its data stacks make growth stable and free; references and slices are cheap because the compiler proves their roots; variable-size records are inline because there is no heap. Tycho does not need those mechanisms to get the useful parts.

    What transfers is narrower:

    1. Fresh values should be built where they are stored, not in a temporary and copied afterwards.
    2. A function should be able to state a bounds contract once, and the compiler should report whether any check survives.
    3. A fixed-size sum value can be stored inline; Option/Result already are, and both compilers now store scalar-payload user enums that way.

    Everything else is either incompatible with the value model or belongs behind a concrete caller.

    1. In-place construction for push (highest value)

    Goose makes this a language guarantee: a value constructed into an array slot, a return slot or a field is built there, through calls and branches (spec section 4.3). Tycho already has the return-slot move, the caller-arena return and the O3 last-use move into push. The gap is fresh expressions that are neither a named local nor a return value.

    A local probe makes the gap visible. For an outer [string]:

    out: [string] = []
    for i := 0; i < N; i += 1:
        push(out, str(i) + "...")
    

    the emitted C is, in shape:

    tycho_arr_str_push(&owner,
        tycho_str_copy(&owner, tycho_str_concat(&owner, ...)));
    

    The concatenation has already built a fresh string in the destination arena; push then allocates and copies it again before storing the pointer. The original buffer is left as dead scratch. The same issue applies to a fresh struct or array passed to push, and to a call result that is copied into the receiver.

    The fix is not "add references". It is a compiler destination:

    This should become a documented guarantee only after it is enforced for returns, push, array literals and control-flow branches. Until then it is a conservative optimization.

    Validation: a fixture that pushes fresh strings and fresh records into an outer array, compiled with a TYCHOC_NO_FRESH_PUSH control. Measure peak RSS and wall time (median of five), require byte-identical output, then run the sanitizer lanes and the compiler fixpoint. The existing O3 push_move path and bench/opt/movebuild are the precedent, not a replacement for this test.

    2. Bounds contracts and a check-free report

    Goose's strongest compiler-side idea is not a new type: it is the contract. assert establishes a fact once, index types retain bounds as values, and checkfree fn rejects a kernel if a bounds check survives (reference sections 15 and 16; spec sections 7.14 and 10.5). The compiler also prints the surviving checks per line with --bce-lines.

    Tycho already removes the checks in monotone len(xs) loops (TYCHOC_NO_BOUNDS_ELISION=1 is the control), but it has no assert builtin and no way to state or inspect a cross-call length relation. Two pieces are worth considering separately:

    Full Goose index types do not map directly. Tycho arrays are values that can shrink; a u32 in nodes needs a stable, named, grow-only array. A later version could tie an index type to a bounded[N]T or a new grow-only array, but that is a separate design. Do not smuggle references in to get it.

    Validation: a graph or stencil kernel with a data-dependent index and an explicit length precondition. Build with the report and with TYCHOC_NO_BOUNDS_ELISION=1; compare checks, output, RSS and time. The gate is the report's accuracy, not a promised speedup.

    3. Inline fixed-size enum payloads

    Goose's fixed-mode enum stores tag plus the largest payload inline; variable mode stores each value at its own size and gives up indexing. Tycho stores user-defined enums as pointers to one tagged cell, with the payload inline in that cell (docs/internals/design-enums.md). Option and Result are already inline value structs; user enums are the remaining boxed case.

    A 1,000,000-value probe compared a fixed three-variant user enum with the equivalent packed tag-plus-payload record (the lower bound for unboxing):

    valuepeak RSSwall time
    [Kind] old pointer cell28,068 KB7 ms
    [Kind] inline packed tag+union19,872 KB5 ms

    That is 29% less memory and about 29% less time. The same effect appeared for Option(int) against a packed record: 28,500 KB / 8 ms versus 19,500 KB / 6 ms. Option and Result already have the inline rep; they pay padding, not an allocation. An inline representation for *user* enums whose variants are all fixed-size and non-recursive would remove the per-value cell and pointer chase; recursive enums stay boxed. This is a layout optimization, not variable-size records and not variable-mode enums; the value model is unchanged.

    Status: adopted by both compilers for scalar payloads. A fixed user enum with scalar payloads is now a packed tag+union value; recursive, generic, fieldless, package-mangled and heap-payload enums keep the pointer cell. The control TYCHOC_NO_INLINE_ENUM=1 reproduces the old representation.

    4. Static function values: measure first, do not restrict closures

    Goose passes function values as compile-time entities: they cannot escape, so every higher-order call is direct and inlinable (spec section 7.6). Tycho deliberately supports escaping closures and arrays of closures. Copying Goose's restriction would be a regression.

    The possible transfer is an optimization: specialize a call when a function literal or named function reaches a higher-order parameter and the value does not escape. The C backend can then inline the closure body and skip the environment.

    Measure before building it. A local apply probe with 10,000,000 calls was already ~7 ms, which is consistent with the C compiler devirtualizing the constant function values. The bench/prongB/dispatch workload is the opposite case: an array of escaping closures, which Goose's static values cannot express and Tycho can. Tycho's more expressive closure model should stay; treat this as a compiler optimization only if a real higher-order workload shows a gap.

    5. Lower-priority ergonomics

    These are real Goose conveniences, but none is a reason to change the model:

    Reject: incompatible or already covered

    Recommendation

    1. Ship destination construction for fresh push values. It earned its keep: ~35–43% less peak RSS and ~17–20% less wall time on the string case.
    2. Treat guard-derived bounds facts as a rejected experiment; a reporting mode may still be useful, but no speed claim is made.
    3. Keep inline fixed-size *scalar-payload* enums in both compilers.
    4. Leave static function specialization and the small ergonomics demand-gated.