const Default
Default is the level CompileDefault uses, and the one a realm should want: every optimization this package knows, same semantics.
Package bf is a Brainfuck machine for gno.land, and the measuring stick the rest of the guest-VM work is calibrated a...
A Brainfuck machine for gno.land, and the measuring stick the guest-VM work is calibrated against.
The package ships two things that look alike and are not:
Execute, the naive interpreter from 2023: a switch over the source
bytes, a brace matcher that rescans the program on every loop edge. It is
kept verbatim as rung 0 of the ladder below, because a baseline you have
edited is not a baseline. It panics on , and on unbalanced brackets, and
both bugs are pinned by tests so nobody "fixes" the reference.Compile + Machine, the real one: resolved jump targets, fused
operator runs, loop idioms folded into single ops, and a fuel-metered step
loop over a vmkit Host, so a program pauses when it runs out
and resumes in a later transaction.Live demo: r/moul/x/vm/bfdemo.
1m, err := bf.Load("+++++[->++++++++++<]>++.")
2if err != nil { return err }
3used, status := m.Step(host, 1000) // status: running, halted, trapped, out of fuel
4snap := m.Snapshot() // resume later: NewMachine(prog).Restore(snap)
Measured 2026-09-22 with gno test -print-runtime-metrics, gno built from
gnolang/gno master@877379432. Reproduce with:
1gno test -print-runtime-metrics .
The program is heavy (ladder_test.gno): 408 source bytes, 121,201 guest
instructions, no output, so it measures the dispatch loop and nothing else.
| rung | what changed | machine ops | cycles | cycles / guest op |
|---|---|---|---|---|
| 0 | Execute, the original |
161,200 dispatches | 1.4G | 11,551 |
| 1 | compile to ops, resolve jump targets | 121,202 | 613.4M | 5,061 |
| 2 | fuse runs of + and > |
81,203 | 444.6M | 3,668 |
| 3 | fold loop idioms ([-], [->+<], [>]) |
1,203 | 10.0M | 83 |
| 5 | rung 3 with a fuel budget enforced | 1,203 | 11.0M | 91 |
From the 1x and 4x pairs, which cancel the fixed per-test overhead: rung 0 runs at 11,003 cycles per guest instruction, rung 3 at 79.5. The ladder is worth 138x, and almost all of it is rung 3: folding a counted loop into the multiply it performs is the only optimization here that changes the complexity rather than the constant.
A guest instruction is one operator the language executes, counted the way any
jump-table interpreter counts it: 121,201 for heavy. The naive interpreter
dispatches more often than that for the same program, because its ] handler
scans back to the matching [ and lands on it, so [ is re-evaluated on every
iteration of every loop: 161,200, a factor of 1.33.
Rung 1 removes exactly that excess, so quoting cycles-per-dispatch would credit rung 1 twice and make rung 0 look 33% better than it is. Both counts come from an independent simulator, not from this package.
The rungs above are the classic optimizations, fewer instructions for the same
program. On the GnoVM a second axis matters as much, and it is not in any
interpreter textbook: the same instruction stream, run by loops that differ from
each other by one line. micro_test.gno is that matrix.
Rung 1's stream, 121,202 ops:
| loop | cycles | allocs | vs. locals |
|---|---|---|---|
| cursors as struct fields | 523.7M | 31.5M | +18% |
| cursors in locals | 442.4M | 31.5M | baseline |
+ vmkit.Meter.Charge per op |
821.3M | 110.1M | +86% |
| + inline fuel counter instead | 514.2M | 31.5M | +16% |
| + tape as a local slice | 492.1M | 31.6M | +11% |
Rung 3's stream, 1,203 ops, same ordering: 9.0M / 8.2M / 11.9M / 8.9M / 8.7M.
Three things follow, and the shipped Machine.Step does all three:
vmkit.Meter costs 86% on top of the entire
dispatch loop and more than triples the allocation count. Meter is the
right type at the API boundary, where it is called once per slice. It is not
a hot-path type, and no guest VM in the zoo should treat it as one. An inline
counter does the same job for 16%.The pc range check is also hoisted out of the loop: every jump target is
produced by Compile and every restored pc is validated by Restore, so
checking per op buys nothing.
A snapshot carries the tape only up to the highest cell the program has
reached, so hello world pauses in 43 bytes, not 30,000. That is what makes
continuations cheaper than re-running, which is the kill criterion vmkit set
for them.
The program is not in the snapshot: a realm stores it once beside the instance, not once per pause.
Tape of 30,000 wrapping byte cells, matching the original. , past the end of
input yields a zero cell, the most common of the three conventions the language
never settled. Compile rejects unbalanced brackets and sources above 64 KiB.
The idiom rewriter is deliberately conservative: it folds a loop only when the body moves and adds, returns to where it started, and takes exactly one off the current cell. A loop that adds one per iteration still terminates by wrapping after 256 rounds, and is left as a real loop, because folding it would be a different program.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:

🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Package bf is a Brainfuck machine for gno.land, and the measuring stick the rest of the guest-VM work is calibrated against.
It ships two things that look alike and are not:
The ladder between them is measured, not asserted: see the README for the table, and ladder_test.gno for the harness that produces it.
Live demo: r/moul/x/vm/bfdemo(/r/moul/x/vm/bfdemo/v0).
Default is the level CompileDefault uses, and the one a realm should want: every optimization this package knows, same semantics.
MaxSource bounds the program a realm will accept. Compilation is linear in the source, but the op array and every snapshot of it are consensus state, so the size has to be bounded somewhere the caller can see.
NaiveTapeSize is the tape length used by Execute. It is 30,000 because that is what the original had, and rung 0 is not allowed to drift.
TapeSize is the tape length of a compiled Machine. It matches NaiveTapeSize so that the ladder compares like with like: a rung that changed the semantics would not be a rung, it would be a different program.
VMName is the identifier this machine registers under in a vmkit.Instance.
1const (
2 // LevelJumps is rung 1: one op per source byte, with the jump targets
3 // resolved at compile time. This is the rung that kills the brace
4 // rescan, which in [Execute] costs O(loop body) on every iteration.
5 LevelJumps Level = iota
6
7 // LevelFuse is rung 2: runs of identical operators collapse, so
8 // "+++++" is one add-5 and ">>>" is one move-3. Real programs are
9 // mostly runs.
10 LevelFuse
11
12 // LevelIdioms is rung 3: a loop whose body only moves and adds, and
13 // which returns to where it started while decrementing the current
14 // cell by one, is replaced by the multiply-adds it performs plus a
15 // clear. "[-]" becomes one op, "[->+<]" becomes two.
16 LevelIdioms
17)ErrNoProgram is returned when a Machine is stepped without a program.
Execute runs code on a fresh tape and returns everything it wrote, using the original 2023 implementation: no compilation step, one switch per source byte, and a brace matcher that rescans the source every time a loop opens or closes.
It is rung 0 of the ladder and exists to be measured. Two of its properties are bugs that are deliberately preserved, and are the reason nothing should call it on untrusted input:
Use Compile and Machine for anything real: they reject a malformed program up front, read input from the host, and cannot run unbounded.
Run compiles src at Default and runs it to completion against h, with no fuel bound. It is the one-shot path: convenient for a test or a small program, and exactly the thing a realm should not do with untrusted input.
Load compiles src at Default and returns a machine for it.
NewMachine returns a machine ready to run prog from the start.
Compile turns Brainfuck source into a Program at the requested ladder level. Everything that is not one of the eight operators is a comment, as the language requires.
It fails on unbalanced brackets rather than trusting the runtime to notice, which is the first thing that separates it from Execute: a malformed program is rejected before anybody pays to run it.
CompileDefault compiles at Default.
Level selects how far up the optimization ladder Compile goes.
Every level produces the same output for the same program: the levels exist so the cost of each optimization can be measured in isolation, which is the published result this package is really for. Rung 0 of the ladder is Execute and has no compiler at all.
1type Machine struct {
2 prog *Program
3
4 tape [TapeSize]byte
5 ptr int
6 pc int
7 inPos int // read cursor into Host.Input, carried across slices
8
9 status vmkit.Status
10 trap string
11
12 // hi is the highest tape index written so far, so a snapshot can stop
13 // there instead of carrying 30,000 bytes of zeros.
14 hi int
15}Machine is a compiled Brainfuck program, mid-execution: a vmkit.Machine with a fixed tape, a fuel-metered step loop, input and output through the host, and a snapshot small enough that pausing is cheaper than starting over.
The tape is a fixed-size array rather than a slice: that is rung 4 of the ladder, and the README reports what it actually bought.
Cell returns the tape byte at i, wrapped into range.
PC returns the index of the next op to execute.
Pointer returns the tape pointer.
Program returns the program the machine is running.
Restore loads a snapshot into a machine that already carries the program the snapshot was taken from. The program is not in the snapshot: a realm stores it once, beside the instance, instead of once per pause.
Snapshot serializes the machine. The tape is truncated at the highest cell the program has reached, which is what makes pausing affordable: a hello world that touched six cells snapshots six bytes of tape, not 30,000.
Status returns the machine's current status.
Step runs until the program halts, traps, or spends `fuel` units, charging one unit per op executed and one per cell a scan walks over. Pass vmkit.Unmetered to run to completion.
Fuel is charged before the op runs, so a machine that stops for lack of fuel has not half-executed anything: it is exactly at the op it could not pay for, and resuming re-executes that op and nothing else.
Three things in here are written the way they are because the alternative was measured and lost. The README has the table.
Touched returns how many tape cells the program has reached, which is the length a snapshot has to carry.
Trap returns the reason the machine trapped, or "". It makes Machine a vmkit.Trapper.
Program is compiled Brainfuck, ready to run on a Machine.
Len returns the number of compiled ops, including the trailing halt. It is the number worth quoting next to the source length: the ratio is exactly what the ladder buys.
Level returns the ladder rung this program was compiled at.
Source returns the program text the Program was compiled from.