Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source pure

Package bf is a Brainfuck machine for gno.land, and the measuring stick the rest of the guest-VM work is calibrated a...

Readme View source

p/moul/x/vm/bf

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)

The optimization ladder

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.

Two denominators, and why it matters

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 other axis: what the GnoVM charges for

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:

  1. A method call per guest instruction is the most expensive thing in the loop. Charging fuel through 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%.
  2. Reaching through a struct pointer for the program counter and the tape pointer costs 16%. Hoist them into locals and write back once.
  3. The fixed-array tape is not the win the textbooks claim. An array is supposed to remove a bounds check; here a slice is 4% faster, because a slice header can be copied into a local and an array cannot. It is the smallest effect on this page, and the classic ladder puts it above idiom recognition, which is worth 44x.

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.

Snapshots

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.

Semantics

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:

gno.land/p/moul/x/vm/bf/v0 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.

Overview

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:

  • Execute, the naive interpreter: 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 optimization ladder, because a baseline you have edited is not a baseline.
  • Compile plus Machine, the real one: the program is compiled to an op array with resolved jumps, runs of identical operators are fused, the common loop idioms become single ops, and execution is metered by a caller-supplied fuel budget against a vmkit(/p/moul/x/vm/vmkit/v0) Host, so a program can pause when it runs out and resume in a later transaction.

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).

Constants 6

const Default

1const Default = LevelIdioms
source

Default is the level CompileDefault uses, and the one a realm should want: every optimization this package knows, same semantics.

const MaxSource

1const MaxSource = 64 * 1024
source

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.

const NaiveTapeSize

1const NaiveTapeSize = 30000
source

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.

const TapeSize

1const TapeSize = 30000
source

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.

const LevelJumps, LevelFuse, LevelIdioms

 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)
source

Variables 2

var ErrNoProgram

1var ErrNoProgram = errors.New("bf: machine has no program")
source

ErrNoProgram is returned when a Machine is stepped without a program.

Functions 6

func Execute

1func Execute(code string) string
source

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:

  • `,` panics with "unsupported": there is no input.
  • unbalanced brackets run the scan off the end of the source and panic with an index out of range.

Use Compile and Machine for anything real: they reject a malformed program up front, read input from the host, and cannot run unbounded.

func Run

1func Run(src string, h vmkit.Host) (vmkit.Status, error)
source

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.

func Load

1func Load(src string) (*Machine, error)
source

Load compiles src at Default and returns a machine for it.

func NewMachine

1func NewMachine(prog *Program) *Machine
source

NewMachine returns a machine ready to run prog from the start.

func Compile

1func Compile(src string, lvl Level) (*Program, error)
source

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.

Types 3

type Level

ident
1type Level int
source

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.

Methods on Level

func String

method on Level
1func (l Level) String() string
source

String names the level, for the ladder table and for realm output.

type Machine

struct
 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}
source

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.

Methods on Machine

func Cell

method on Machine
1func (m *Machine) Cell(i int) byte
source

Cell returns the tape byte at i, wrapped into range.

func PC

method on Machine
1func (m *Machine) PC() int
source

PC returns the index of the next op to execute.

func Pointer

method on Machine
1func (m *Machine) Pointer() int
source

Pointer returns the tape pointer.

func Program

method on Machine
1func (m *Machine) Program() *Program
source

Program returns the program the machine is running.

func Restore

method on Machine
1func (m *Machine) Restore(b []byte) error
source

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.

func Snapshot

method on Machine
1func (m *Machine) Snapshot() []byte
source

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.

func Status

method on Machine
1func (m *Machine) Status() vmkit.Status
source

Status returns the machine's current status.

func Step

method on Machine
1func (m *Machine) Step(h vmkit.Host, fuel int64) (int64, vmkit.Status)
source

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.

  • The fuel counter is two locals, not a vmkit.Meter. Calling Meter.Charge once per instruction costs 86% on top of the whole dispatch loop and triples the allocation count. Meter stays the type at the API boundary, where it is called once; it is not a hot-path type, and no guest VM should treat it as one.
  • The program counter and the tape pointer are locals, written back once by the deferred closure. Reaching through m. for them on every instruction costs 16%.
  • The pc range check happens once, before the loop, not per op. Every jump target is produced by Compile and every restored pc is validated by Machine.Restore, so per-op checking buys nothing.

func Touched

method on Machine
1func (m *Machine) Touched() int
source

Touched returns how many tape cells the program has reached, which is the length a snapshot has to carry.

func Trap

method on Machine
1func (m *Machine) Trap() string
source

Trap returns the reason the machine trapped, or "". It makes Machine a vmkit.Trapper.

type Program

struct
1type Program struct {
2	ops   []op
3	src   string
4	level Level
5}
source

Program is compiled Brainfuck, ready to run on a Machine.

Methods on Program

func Len

method on Program
1func (p *Program) Len() int
source

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.

func Level

method on Program
1func (p *Program) Level() Level
source

Level returns the ladder rung this program was compiled at.

func Source

method on Program
1func (p *Program) Source() string
source

Source returns the program text the Program was compiled from.

Imports 4

Source Files 9