Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TinyGo notes

Jennifer ships as two binaries built from the same source:

  • jennifer - default, built with the standard Go toolchain. Full host-feature surface: file I/O, os/exec, network stack, everything.
  • jennifer-tiny - constrained variant, built with TinyGo. Smaller binary, embeddable; the stock build ships without os/exec or a network stack (a build choice, not a hard TinyGo limit - see TinyGo restrictions below).

make build produces both. Use make build-go or make build-tinygo for just one; both regenerate the version file before compiling.

The language is written to stay TinyGo-clean even though the default binary is standard-Go. The jennifer-tiny build sits in CI so any change that breaks TinyGo compatibility surfaces immediately. A few constraints shape the implementation:

  • No reflect-heavy code. Tagged-union Value instead of interfaces with type assertions in hot paths. task.waitAny uses reflect.Select (verified under TinyGo); other new reflect uses need justification.
  • No text/template. Not needed yet; would drag in fragile runtime paths under TinyGo.
  • No encoding/json for in-binary serialization. The reflect-based marshaler is fragile under TinyGo, so the AST JSON emitter is hand-rolled (see CLI > Inspection).
  • Goroutines are allowed (used for spawn), but need -stack-size=2mb under TinyGo - see the goroutine-stack note below.
  • No -ldflags "-X package.var=value". TinyGo 0.41 silently ignores the -X directive. Use the codegen path (scripts/gen-version.sh -> internal/version/version_gen.go) for build-time string injection. See CLI > Version injection.
  • No hard dependencies on a hosted runtime. jennifer-tiny targets embedded systems, minimal containers, and small-footprint scripting hosts where ambient stdin, dynamic linking, and a full hosted runtime are not guaranteed.
  • testing runs under regular go test. TinyGo’s testing support is partial; we develop and verify with go test ./....

Verify both builds after non-trivial changes:

make build
./jennifer      run examples/hello.j   # default (standard Go); full host features
./jennifer-tiny run examples/hello.j   # constrained (TinyGo); no os/exec, no net

TinyGo restrictions

A few standard-library features depend on TinyGo runtime support that isn’t there today. Calls into them from jennifer-tiny error with a friendly Jennifer-level message pointing the user at the default jennifer binary. The default binary always supports the full surface.

LibraryAffected namesWhat happens on jennifer-tiny
osos.run, os.spawn, os.wait, os.poll, os.killRuntime error pointing at the default jennifer binary. The os/exec subprocess surface: unimplemented in TinyGo on host targets, and absent by nature on embedded / WASM. Not the same “recompile” story as net - see the note below.
netEvery entry point (TCP, UDP, DNS)Runtime error pointing at the default jennifer binary. Our stock jennifer-tiny registers no netdev driver, so net is stubbed. Build-tag split: netlib_tinygo.go returns friendly errors. Not a hard TinyGo limit - see the note below.

net on TinyGo is a build choice, not a hard limit

The “no network” state is a property of the stock jennifer-tiny build, not of TinyGo itself. TinyGo compiles most of net.Dial / net.Listen; what it does not do on a default target is register a netdev driver at runtime (the pluggable network device interface its net package dials through), and our stock build ships none - so we compile the tinygo-tagged netlib_tinygo.go stub that returns a friendly error instead of failing cryptically deep in Go’s net.

Anyone who needs networking on the tiny binary can restore it by rebuilding with a network stack: target (or link in) a registered netdev driver - or a net-capable target such as one exposing a host socket layer - and drop the tinygo build tag on net so the real implementation compiles in. With a network stack present, net and every net-backed module (smtp, pop, imap, redis, resque, memcache, session, ratelimit, …) run on jennifer-tiny too. So read “needs the default jennifer binary” as “needs a build that includes a network stack” - the stock jennifer has one, the stock jennifer-tiny does not. (UDP is the one genuinely thinner spot: net.ListenPacket is not part of TinyGo’s surface today, so a rebuild covers TCP / DNS more readily than UDP.)

os/exec on TinyGo is a platform limit, not a switch

The os restriction is narrower than it looks: it is only the os/exec subprocess surface - os.run / os.spawn / os.wait / os.poll / os.kill. Everything else in os (env, args, flags, the PLATFORM / ARCH / EOL / DIRSEP / PATHSEP / ARGS values) works fully on both binaries.

Do not read the net note above as applying here. net needs a pluggable driver you can supply; os/exec needs a whole host operating system with a process model - fork/exec, a process table, executables on a filesystem. There is no component to link in. Two cases:

  • Host-OS TinyGo target (Linux / macOS / Windows): a TinyGo standard-library maturity gap - the os/exec fork/exec path is not implemented yet. If TinyGo upstream adds it, a host-targeted jennifer-tiny could gain os.run / os.spawn; that is upstream work, not a rebuild switch on our side.
  • Embedded / bare-metal / WASM / WASI targets (what jennifer-tiny exists for): there is no process model at all - nothing to fork, no other programs, no exec syscall. So the subprocess surface is fundamentally inapplicable, a hard platform limit rather than a missing piece. It stays unavailable there, permanently.

This also fits the deployment target: minimal containers and embedded scripting hosts generally should not shell out to external processes (no shell, no other executables), so the restriction aligns with where jennifer-tiny runs rather than fighting it. In short: net = a driver you can supply and rebuild around; os/exec = a host capability that is a TinyGo gap on host targets and simply absent on embedded / WASM.

The constants and the env / argv / flag helpers in os (os.PLATFORM, os.ARCH, os.EOL, os.DIRSEP, os.PATHSEP, os.ARGS, os.getEnv, os.hasFlag, os.flag) all work fully on both binaries. Every other shipped library (io, convert, math, strings, lists, maps, meta, time, hash, crc, encoding, task, fs, regex, testing) has full TinyGo support.

Development subcommands are default-binary only. jennifer-tiny is a run-only interpreter: run and repl execute Jennifer source, but the development subcommands tokens, ast, fmt, lint, profile, and test are build-tag-excluded (cmd/jennifer/devtools_tinygo.go) and return a friendly error pointing at the default jennifer binary. They pull in lexer-dump, AST-JSON, formatter, and lint machinery that a minimal-footprint embedding has no use for; build the standard-Go jennifer binary for development work.

TinyGo goroutine stack. Jennifer’s tree-walking evaluator wraps each Jennifer-level call in many Go-stack frames (execBlock + evalCall + evalExpr + …), so even a modest-depth recursion (fib 23) easily exceeds TinyGo’s default goroutine stack of ~8KB and segfaults. The Makefile passes -stack-size=2mb to tinygo build for jennifer-tiny so it can run recursive spawn bodies (and the parallel section of examples/benchmark.j). The default jennifer binary doesn’t need this - Go’s goroutine stacks grow automatically.

TinyGo scheduler. jennifer-tiny pins the cooperative single-thread scheduler (-scheduler=tasks in the Makefile). spawn works fully (semantics, loud-fail, registry), but every goroutine shares one OS thread, so it gives concurrency without multi-core parallelism: parallel speedups stay close to 1.0, and -stack-size=2mb reliably covers recursive spawn bodies. The pin is deliberate - the threads-capable default briefly showed real multi-core speedups (161% CPU) but segfaulted on recursive spawn bodies, because -stack-size doesn’t govern OS-thread stacks. Real multi-core on jennifer-tiny is separate future work, not a default flip; the default jennifer binary already reaches multi-core speedup via Go’s scheduler.

Future library work will grow the restrictions table if further TinyGo runtime gaps surface. Each new gap lands with the same friendly-message pattern.

Binary size

The constrained build is the smaller one, which is the point of jennifer-tiny targeting minimal-footprint deployments. Sizes from make build on linux/amd64 (Go 1.26.3, TinyGo 0.41.1, unstripped). The absolute numbers move with toolchain version and platform; the ratio is the stable part.

BinarySize
jennifer~7.0 MB (7,250,750 bytes)
jennifer-tiny~4.5 MB (4,621,624 bytes)

jennifer-tiny comes in at ~64% of the default binary (about a third smaller). Most of that gap is TinyGo’s smaller runtime versus the standard Go runtime; the run-only trim (excluding the tokens / ast / fmt / lint / profile / test development subcommands) shaves an incremental slice on top.

These are unstripped make build (dev) sizes. Release builds strip: the Go binary adds -trimpath -ldflags "-s -w" (down to ~5.0 MB, a third smaller) and the TinyGo binary adds -no-debug (down to ~1.8 MB, a ~60% cut). Shipped artifacts are therefore well under the dev numbers above; dev builds keep symbols for debugging.

Single-binary benchmark results

Reference numbers from examples/benchmark.j (version 0.16.0-dev+7.de20da4) on an AMD Ryzen 5 7600X3D (6 cores, 12 threads; desktop active, load low) - the machine the suite now prints in its own header, from os.NCPU plus a /proc/cpuinfo read done in Jennifer. The serial section is single-threaded by design; the parallel section fans out to PARALLEL_WORKERS = 4 spawn tasks per workload. The interpreter build is the current one (eager-copy value semantics, lexical slot resolution, parse-time constant folding), so append-in-a-loop is amortised O(N) with in-place growth.

jennifer (standard-Go binary, default)

=== Jennifer benchmark suite ===
build:   go
version: 0.16.0-dev+7.de20da4
cpu:     AMD Ryzen 5 7600X3D 6-Core Processor (12 cores)

----------------------------------------------------------------------
Workload                               base        iters      time_ms
----------------------------------------------------------------------
fib(N) recursive                         23            1           74
primes up to LIMIT                   100000            1        16407
newton sqrt batch                     10000        10000          298
monte carlo pi                       500000       500000          839
list sort/reverse/slice               10000          500         1449
struct list build+read                10000        10000           30
string join                           10000        10000           13
map insert+read                       10000        10000         1206
----------------------------------------------------------------------
total                                                           20316

Parallel comparison (workers = 4, scheduler = go)
----------------------------------------------------------------------
Workload                          serial_ms       par_ms      speedup
----------------------------------------------------------------------
primes up to LIMIT                    16407         6298         2.61
newton sqrt batch                       298           78         3.82
monte carlo pi                          839          290         2.89
fib(N) x workers                        296           95         3.12
----------------------------------------------------------------------

user   40.96s
sys     0.44s
real   27.08s     (152% CPU)

user + sys (41.4s) exceeds real (27.1s) by ~14.3s - that gap is Go’s concurrent GC running on a second CPU during the serial section, plus the four spawn workers running in parallel during the parallel section. Sys time is tiny (0.4s) because Go’s runtime coordinates goroutines with cheap in-process sync primitives.

jennifer-tiny (TinyGo binary)

=== Jennifer benchmark suite ===
build:   tinygo
version: 0.16.0-dev+7.de20da4
cpu:     AMD Ryzen 5 7600X3D 6-Core Processor (1 cores)

----------------------------------------------------------------------
Workload                               base        iters      time_ms
----------------------------------------------------------------------
fib(N) recursive                         23            1           76
primes up to LIMIT                   100000            1        13287
newton sqrt batch                     10000        10000          242
monte carlo pi                       500000       500000          896
list sort/reverse/slice               10000          500         1275
struct list build+read                10000        10000           34
string join                           10000        10000           21
map insert+read                       10000        10000         1775
----------------------------------------------------------------------
total                                                           17606

Parallel comparison (workers = 4, scheduler = tinygo)
----------------------------------------------------------------------
Workload                          serial_ms       par_ms      speedup
----------------------------------------------------------------------
primes up to LIMIT                    13287        13181         1.01
newton sqrt batch                       242          243         1.00
monte carlo pi                          896          821         1.09
fib(N) x workers                        304          260         1.17
----------------------------------------------------------------------

user   31.93s
sys     0.03s
real   32.12s     (99% CPU)

This is the pinned build (-scheduler=tasks -stack-size=2mb): the whole suite completes, serial and parallel. os.NCPU reports 1 here - honest about the cooperative single-thread scheduler’s usable parallelism, not the 12 threads the machine has. The parallel column hovers at ~1.0 by design - spawn under that scheduler is concurrency, not multi-core throughput - and user ~= real at 99% CPU confirms the single-thread execution (contrast Go’s 152% above). The 2MB stack (up from 1MB) is what lets the serial recursive fib run at all: at 1MB it fit bare but overflowed nested one call frame deeper, inside benchFib.

Per-workload comparison (serial section)

Ratios are tiny_ms / go_ms; > 1.0 means jennifer-tiny is slower, < 1.0 means it is faster.

Workloadtiny (ms)go (ms)RatioWhere the time goes
fib(N) recursive76741.0xTight interpreter dispatch loop; effectively tied.
primes up to LIMIT13287164070.8xLong numeric dispatch loop; TinyGo pulls ahead at scale - and this row carries the whole aggregate lead.
newton sqrt batch2422980.8xFloat arithmetic + dispatch; TinyGo ahead.
monte carlo pi8968391.1xFloat arithmetic + RNG calls; now marginally Go’s.
list sort/reverse/slice127514490.9xAllocation-heavy; TinyGo’s simpler GC beats Go’s concurrent GC at this scale.
struct list build+read34301.1xAppend hot loop is O(1). Both binaries are effectively free (sub-40ms).
string join21131.6xBuild-up-a-string pattern is O(1); both free in absolute terms (sub-30ms), Go’s runtime a step ahead.
map insert+read177512061.5xGo’s runtime map implementation outperforms TinyGo’s at this churn rate.
total17606203160.9xTinyGo still posts the lower serial total, but the entire margin is the primes row.

The two binaries have converged from the earlier build: TinyGo still posts the lower serial total (17.6s vs 20.3s, ~13% faster), but the gap has closed from the old ~32% - Go’s serial total fell (~23.6s -> 20.3s) while TinyGo’s rose slightly (~16.0s -> 17.6s), a normal cross-build drift as the interpreter grew.

More telling: TinyGo’s entire aggregate lead is one workload. The primes row alone is 3120 ms in TinyGo’s favour (13287 vs 16407), which is larger than the whole 2710 ms total gap - so on the sum of every other workload Go is actually ~410 ms faster. TinyGo wins the long tight numeric loops (primes, newton) and stays ahead on allocation-heavy list; Go wins the stdlib-churn workloads (map insert+read, string join), the tiny structural rows, and now edges monte carlo. The “compute-bound favours TinyGo” rule of thumb is now really “the longest dispatch loop favours TinyGo, and it’s big enough to tip the total.”

Parallel section

Speedup is serial_ms / par_ms; > 1.0 means the four-worker version beat serial. Both columns are the pinned build now (no more crashed fib row): Go gets real multi-core speedup, TinyGo’s cooperative scheduler stays at ~1.0 by design.

WorkloadGo serial (ms)Go par (ms)Go speedupTinyGo serial (ms)TinyGo par (ms)TinyGo speedup
primes up to LIMIT1640762982.6113287131811.01
newton sqrt batch298783.822422431.00
monte carlo pi8392902.898968211.09
fib(N) x workers296953.123042601.17

Go reaches real multi-core speedup (2.61x-3.82x on four workers). jennifer-tiny pins the cooperative scheduler, so spawn there is concurrency without multi-core throughput (~1.0 by design); use the default binary when parallel throughput matters.

This is where the serial-total lead reverses. TinyGo has the lower serial total (17.6s vs 20.3s), but Go finishes the whole suite in less wall-clock time: real is 27.1s for Go vs 32.1s for TinyGo. The parallel section is why - Go crunches it in ~6.8s (four workers, real speedup) where TinyGo takes ~14.5s (no parallelism), and that ~7.7s swing more than erases TinyGo’s ~2.7s serial edge. Lower single-thread compute time does not mean a faster end-to-end run once any spawn parallelism is in play.

Picking a binary, in short: TinyGo leads single-thread dispatch on the long numeric loops and posts the lower serial total, but that lead lives almost entirely in primes; both are essentially free on the small structural workloads; Go leads on string/map churn, wins the end-to-end wall clock whenever spawn parallelism is involved, and is the only choice for real multi-core throughput; TinyGo trades all of this for a larger resident-memory footprint (below).

Memory and page faults

Same machine, /bin/time (GNU time) on an equivalent run (per-workload timings within noise of the tables above):

Metricjennifer (Go)jennifer-tiny (TinyGo)
peak resident (RSS)~35 MB~68 MB
minor page faults~213,000~13,100
CPU152%99%

The two runtimes trade opposite resources. TinyGo uses ~2x the peak RSS - its cooperative scheduler reserves each goroutine’s full -stack-size up front (the four parallel spawn workers each hold a 2MB stack, ~8MB before the interpreter’s own data), where Go grows goroutine stacks on demand from ~8KB. Bumping the stack from 1MB to 2MB (to fit the recursive fib body) doubled that reservation, and it shows here. Go, in turn, churns ~16x the page faults (~213k vs ~13k): its concurrent GC allocates and reclaims pages aggressively across cores - the same activity behind its 152% CPU and the user >> real gap. TinyGo’s simpler GC touches far fewer pages and runs single-threaded at 99% CPU. So TinyGo buys lower single-thread compute and low GC churn with a larger, flatter memory footprint; Go buys a smaller footprint and real multi-core parallelism with heavy GC activity.