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 withoutos/execor 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-unionValueinstead of interfaces with type assertions in hot paths.task.waitAnyusesreflect.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/jsonfor 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=2mbunder TinyGo - see the goroutine-stack note below. - No
-ldflags "-X package.var=value". TinyGo 0.41 silently ignores the-Xdirective. 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-tinytargets embedded systems, minimal containers, and small-footprint scripting hosts where ambient stdin, dynamic linking, and a full hosted runtime are not guaranteed. testingruns under regulargo test. TinyGo’stestingsupport is partial; we develop and verify withgo 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.
| Library | Affected names | What happens on jennifer-tiny |
|---|---|---|
os | os.run, os.spawn, os.wait, os.poll, os.kill | Runtime 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. |
net | Every 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/execfork/exec path is not implemented yet. If TinyGo upstream adds it, a host-targetedjennifer-tinycould gainos.run/os.spawn; that is upstream work, not a rebuild switch on our side. - Embedded / bare-metal / WASM / WASI targets (what
jennifer-tinyexists for): there is no process model at all - nothing to fork, no other programs, noexecsyscall. 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.
| Binary | Size |
|---|---|
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.
| Workload | tiny (ms) | go (ms) | Ratio | Where the time goes |
|---|---|---|---|---|
fib(N) recursive | 76 | 74 | 1.0x | Tight interpreter dispatch loop; effectively tied. |
primes up to LIMIT | 13287 | 16407 | 0.8x | Long numeric dispatch loop; TinyGo pulls ahead at scale - and this row carries the whole aggregate lead. |
newton sqrt batch | 242 | 298 | 0.8x | Float arithmetic + dispatch; TinyGo ahead. |
monte carlo pi | 896 | 839 | 1.1x | Float arithmetic + RNG calls; now marginally Go’s. |
list sort/reverse/slice | 1275 | 1449 | 0.9x | Allocation-heavy; TinyGo’s simpler GC beats Go’s concurrent GC at this scale. |
struct list build+read | 34 | 30 | 1.1x | Append hot loop is O(1). Both binaries are effectively free (sub-40ms). |
string join | 21 | 13 | 1.6x | Build-up-a-string pattern is O(1); both free in absolute terms (sub-30ms), Go’s runtime a step ahead. |
map insert+read | 1775 | 1206 | 1.5x | Go’s runtime map implementation outperforms TinyGo’s at this churn rate. |
| total | 17606 | 20316 | 0.9x | TinyGo 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.
| Workload | Go serial (ms) | Go par (ms) | Go speedup | TinyGo serial (ms) | TinyGo par (ms) | TinyGo speedup |
|---|---|---|---|---|---|---|
primes up to LIMIT | 16407 | 6298 | 2.61 | 13287 | 13181 | 1.01 |
newton sqrt batch | 298 | 78 | 3.82 | 242 | 243 | 1.00 |
monte carlo pi | 839 | 290 | 2.89 | 896 | 821 | 1.09 |
fib(N) x workers | 296 | 95 | 3.12 | 304 | 260 | 1.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):
| Metric | jennifer (Go) | jennifer-tiny (TinyGo) |
|---|---|---|
| peak resident (RSS) | ~35 MB | ~68 MB |
| minor page faults | ~213,000 | ~13,100 |
| CPU | 152% | 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.