Skip to content

TinyGo as the size-optimized runtime lane — findings, blocker, and the hard boundary #504

Closed as not planned
0 of 2 issues completed
Closed as not planned

Description

@mparrett

Part of the footprint & constrained-targets epic (#503). #466 adds the standard-Go WASI target and notes in docs/guide/tinygo.md that TinyGo isn't supported, blocked at init on TinyGo 0.41's incomplete reflect. This issue is the fuller picture: a prototype gets much further than the doc-note suggests, and the remaining boundary is specific.

Why TinyGo

It's the size-and-memory lane that standard Go can't reach. On the same program (legmacs), a prototype build measured:

target artifact resident memory
stock Go, native 18.9 MB —
stock Go, wasm (GOOS=js) 7.3 MB —
TinyGo, wasm 1.5 MB —
stock Go, native linux/arm64 17 MB 11.5 MB
TinyGo, native linux/arm64 9.9 MB 3.5 MB

~5x smaller wasm and ~3.3x less resident memory native, at a ~1.3x compute penalty. That's the case for embedding let-go on edge/constrained devices and in size-sensitive wasm, where stock Go's runtime is the ceiling.

The init-time blocker, and a workaround

TinyGo 0.41's reflect is incomplete: the method-reflection used by NativeFn.Box (variadic detection and call assembly) traps at init, because native-fn boxing runs MustBox at package init and every bundle hits it on boot. Build-tag-splitting that init path — stubbing the reflect-based box so it errors only if invoked, rather than at load — clears the trap. With that plus a couple of goroutine-stack and output-routing fixes, a prototype legmacs builds, boots, and takes input under TinyGo on current main, in both lanes below.

TinyGo's -target=wasm is GOOS=js, so the existing browser host seam (the Go HostWriter output path and the key-source input ring) works unchanged — no new host shim.

Hard boundary: 32-bit int on the wasm lane

TinyGo's wasm int is 32-bit (unsafe.Sizeof(int) == 4), where stock Go's wasm int is 64-bit. let-go's Int is a Go int, so on TinyGo-wasm it's 32-bit. Programs that depend on 64-bit integer width — hash mixing, wrapping multiplies, anything determinism-sensitive — diverge or overflow. A determinism-dependent app (a game keyed on a 64-bit cell hash) builds and renders under TinyGo-wasm but then can't reproduce its world, because the hash arithmetic wraps at 2^32.

This does not affect the TinyGo native lane, whose int is 64-bit, nor programs that don't lean on 64-bit width (legmacs is fine). So the honest scope is: TinyGo is a supported size lane for width-agnostic programs; 64-bit-int-dependent programs need the native TinyGo lane (or stock Go).

Remaining gaps for a real integration

  • xxh3 on native TinyGo. The xxh3 namespace uses arm64 NEON assembly with no pure-Go fallback, so the native TinyGo build currently ships without it. A purego path (or a build-tag scalar fallback) would close it.
  • Native TinyGo doesn't link on Darwin (TinyGo's syscall is Linux/GOOS=js-shaped for the calls let-go makes). The wasm lane sidesteps this via GOOS=js; native TinyGo is a Linux/arm64 story today.
  • Upstreaming the init split. The NativeFn.Box build-tag split is the load-bearing change; it's small and reflect-gated, and would want a CI job like Build let-go as a WASI (GOOS=wasip1) module #466's wasip1-build to keep the TinyGo link from regressing.

Ask

Not a PR yet — surfacing the findings so the target's scope is a decision rather than an assumption. Worth deciding: whether TinyGo is in scope as a supported size lane (with the 64-bit-int boundary documented), and if so whether to take the init split + a build gate as the first slice. Happy to turn the prototype into a reviewable PR if the target is wanted.

Activity

  1. mparrett commented on Sep 6, 2026

    @mparrett
    CollaboratorAuthor

    Re-tested this lane on origin/main @ 0911118 (2026-09-05), TinyGo 0.41.1, wasmtime 47.0.3, Go 1.26.3. No branches, no cherry-picks: everything below is what LETGO_USE_TINYGO=1 lg -w does today.

    legmacs works out of the box

    1.9 MB self-contained bundle, exit 0. In headless Chromium it boots to its *scratch* buffer with line numbers, syntax-coloured comments and a full modeline; a typed 14-character marker self-inserts and the modeline tracks to Ln 5, Col 15. Zero console errors, zero page errors. Both the output path (HostWriter) and the input path (SAB keysource) work.

    The -stack-size=1MB that this lane needed is now a shipped default in the bundler rather than something a caller passes.

    xsofy did not — and the cause is not the 32-bit boundary

    It builds at 2.0 MB, reaches its title screen, then dies:

    FATAL ERROR: TypeError: nil is not a function
      new-game <- show-title-screen <- generate-title <- pick <- int-in <- xxh3-64
    

    pkg/rt/interop_xxh3.go is gated !tinygo, so a TinyGo build has no xxh3 namespace at all. A program calling xxh3/* gets nil and fails at the call site. This issue and the guide both describe a substituted hash producing different digests; nothing is substituted.

    That matters for how the boundary is stated. The wall here was a missing namespace, not integer width.

    The 32-bit int boundary binds less than stated

    pkg/rt/hash_murmur3.go is untagged, registered on every build, and masks its results to 31 bits so a value is identical on 64-bit native and 32-bit wasm. Its surface mirrors xxh3's, so porting a hash-dependent program is a namespace swap.

    Swapping xsofy's seed hash to murmur3 (nooga/xsofy#192), the game builds, boots and plays under TinyGo wasm — and its generated world is byte-identical to the stock-Go wasm build at a fixed seed, same sha256 over the rendered dungeon. A 64-bit and a 32-bit build agreeing exactly.

    So the honest scope is narrower than "64-bit-int-dependent programs need the native lane". A program whose width dependence is confined to its hash can stay on TinyGo wasm by choosing the portable one. Width still binds for programs doing 64-bit arithmetic outside the hash; it does not bind merely because a program is determinism-sensitive.

    Sizes, refreshed on current main

    stock-Go wasm TinyGo wasm
    xsofy 8,430,124 B 2,147,452 B

    3.9x, holding as the game has grown (this issue recorded 7.3 MB against 1.5 MB on a smaller xsofy).

    Related

    What is still open

    The xxh3-shaped hole is worth a decision rather than a workaround: today a program written against xxh3/* compiles for TinyGo and fails at runtime. A build-time error, or a registered namespace that fails with a clear message, would both beat nil.

  2. mparrett commented on Sep 27, 2026

    @mparrett
    CollaboratorAuthor

    Closing as superseded. #522 put the TinyGo lane in the tree, #859 made Int 64-bit on every host, which removes the hard boundary described here, and #860 registers xxh3 on TinyGo wasm. The remaining gaps each have their own issue: #546 (alts! on reflect.Select), #547 (Darwin native link), #576 (reflect-free interop bindings), #867 (residual 32-bit narrowing), and #945 (encoding/json footprint).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions