go-brainfuck-yourself

A Brainfuck interpreter that I wrote myself in Go
git clone git@abtrout.com:go-brainfuck-yourself.git
Log | Files | Refs | README

commit ed31b64ea0479f9fcf59a55b7e404998f238352d
parent d4706460bc780d61852e0538f569c4d081ff2c16
Author: david cochran <about.trout@gmail.com>
Date:   Thu, 23 Jul 2026 20:03:54 -0700

replace sparse loop jump map with dense array.

profiling showed we spent a lot of time computing hash lookups.
we can trade some memory for a big improvement on Eval performance,
at least for all of our benchmark programs!

> 70% reduction (333% improvement) on BenchmarkLong.
> 49% reduction (196%) on BenchmarkMandelbrot.
> 33% reduction (142%) on BenchmarkFactor.

```
$ go test -bench=.
goos: darwin
goarch: arm64
pkg: github.com/abtrout/gbfy
cpu: Apple M3
BenchmarkLong-8                        1        10378019125 ns/op
BenchmarkMandelbrot-8                  1        11992853958 ns/op
BenchmarkFactor-8                      1        1297234500 ns/op
PASS
ok      github.com/abtrout/gbfy 24.032s
```

also cleaned up partial eval logic. now the interpreter can Eval
multiple valid Brainfuck expressions.

Diffstat:
Mbf.go | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mbf_test.go | 111+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcmd/gbfy/main.go | 6+++---
3 files changed, 126 insertions(+), 79 deletions(-)

diff --git a/bf.go b/bf.go @@ -1,49 +1,46 @@ package gbfy import ( - "errors" "fmt" "io" ) type Brainfuck struct { - cells [3e4]byte - cmds []byte - i, d int // instruction and data pointer - in io.Reader // for reading with , - out io.Writer // for writing with . - loops map[int]int // stores index for matching [ or ] - parLoops []int // indices for not-yet-complete loops + cells [3e4]byte + cmds []byte + i, d int // instruction and data pointer + in io.Reader // for reading with , + out io.Writer // for writing with . + loops []int // stores index for matching [ or ] } // New returns a new Brainfuck interpreter. func New(in io.Reader, out io.Writer) *Brainfuck { - return &Brainfuck{in: in, out: out, loops: map[int]int{}} + return &Brainfuck{in: in, out: out, loops: []int{}} } -// Eval a single Brainfuck command. -func (bf *Brainfuck) Eval(cmd byte) error { - switch cmd { - case '>', '<', '+', '-', '.', ',', '[', ']': - bf.cmds = append(bf.cmds, cmd) - default: - return nil +// Eval evaluates a valid Brainfuck program. +func (bf *Brainfuck) Eval(src string) error { + cmds, loops, err := parseProgram(src) + if err != nil { + return fmt.Errorf("failed to parse program: %v", err) } - // Handle loops before calling internal eval. - if cmd == '[' { - bf.parLoops = append(bf.parLoops, len(bf.cmds)-1) - } else if cmd == ']' { - if len(bf.parLoops) == 0 { - return errors.New("invalid loop close") + // update loop pairs. + off := len(bf.cmds) + for i := 1; i < len(loops); i++ { // skip first element; see below! + if loops[i] > 0 { + loops[i] += off } - i, j := len(bf.cmds)-1, bf.parLoops[len(bf.parLoops)-1] - bf.loops[i] = j - bf.loops[j] = i - bf.parLoops = bf.parLoops[:len(bf.parLoops)-1] } - if len(bf.parLoops) > 0 { - return nil // delay eval if there are partial loops. + // ... and handle special case where program begins with loop. + if loops[0] > 0 { + loops[loops[0]] += off + loops[0] += off } + // update interpreter state + bf.cmds = append(bf.cmds, cmds...) + bf.loops = append(bf.loops, loops...) + return bf.eval() } @@ -96,3 +93,38 @@ func (bf *Brainfuck) eval() error { func (bf *Brainfuck) Dump() (int, []byte, int, []byte) { return bf.d, bf.cells[:], bf.i, bf.cmds } + +func parseProgram(src string) ([]byte, []int, error) { + // collect valid Brainfuck commands. + var cmds []byte + for _, b := range []byte(src) { + switch b { + case '[', ']', '<', '>', '+', '-', ',', '.': + cmds = append(cmds, b) + } + } + // scan program and remember indices of loop pairs. + loops := make([]int, len(cmds)) + var opens []int + for i, cmd := range cmds { + switch cmd { + case '[': + opens = append(opens, i) + case ']': + if len(opens) == 0 { + return nil, nil, fmt.Errorf("mismatched [ at index %d", i) + } + // pop + j := opens[len(opens)-1] + opens = opens[:len(opens)-1] + // swap + loops[i] = j + loops[j] = i + } + } + if len(opens) > 0 { + return nil, nil, fmt.Errorf("mismatched ] at index %d", opens[0]) + } + + return cmds, loops, nil +} diff --git a/bf_test.go b/bf_test.go @@ -12,8 +12,8 @@ import ( func TestEval(t *testing.T) { tests := []struct { - // Command to evaluate. - cmd byte + // Program to evaluate. + cmds string // Expected value of instruction and Data pointer. i, d int // Expected values of *specific* cells; mapping between cell @@ -21,51 +21,47 @@ func TestEval(t *testing.T) { // large (3e4) and sparse (0 by default) only specific cells // are checked. cells map[int]byte + // Similar as above, but for the internal loop jump lookup array. + // Internally this is a dense array (len(loops) == len(cmds)) with + // nonzero elements corresponding to the ['s or ]'s index in bf.cmds + loops map[int]int // Expected output data bytes. out []byte }{ // Check < and > move data pointer around circular cell region. - {'<', 1, 29999, nil, nil}, - {'>', 2, 0, nil, nil}, + {"<", 1, 29999, nil, nil, nil}, + {">", 2, 0, nil, nil, nil}, // Check that + and - modify cell values. - {'+', 3, 0, map[int]byte{0: 1}, nil}, - {'-', 4, 0, map[int]byte{0: 0}, nil}, + {"+", 3, 0, map[int]byte{0: 1}, nil, nil}, + {"-", 4, 0, map[int]byte{0: 0}, nil, nil}, // Check that , and . read input and write output bytes. - {',', 5, 0, map[int]byte{0: 4}, nil}, - {'>', 6, 1, map[int]byte{0: 4}, nil}, - {',', 7, 1, map[int]byte{0: 4, 1: 8}, nil}, - {',', 8, 1, map[int]byte{0: 4, 1: 15}, nil}, - {'>', 9, 2, map[int]byte{0: 4, 1: 15}, nil}, - {',', 10, 2, map[int]byte{0: 4, 1: 15, 2: 16}, nil}, - {'.', 11, 2, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16}}, - {'<', 12, 1, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16}}, - {'.', 13, 1, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15}}, - {'<', 14, 0, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15}}, - {'.', 15, 0, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15, 4}}, - // Check that [ and ] are handled correctly by adding two adjacent cells. - // Evaluation is delayed in the presence of partial loops, so all internal - // state remain the same until the final ] is Eval'd. - {'[', 15, 0, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15, 4}}, - {'-', 15, 0, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15, 4}}, - {'>', 15, 0, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15, 4}}, - {'+', 15, 0, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15, 4}}, - {'<', 15, 0, map[int]byte{0: 4, 1: 15, 2: 16}, []byte{16, 15, 4}}, - {']', 21, 0, map[int]byte{0: 0, 1: 19, 2: 16}, []byte{16, 15, 4}}, - {'>', 22, 1, map[int]byte{0: 0, 1: 19, 2: 16}, []byte{16, 15, 4}}, - {'.', 23, 1, map[int]byte{0: 0, 1: 19, 2: 16}, []byte{16, 15, 4, 19}}, + {",", 5, 0, map[int]byte{0: 4}, nil, nil}, + {">", 6, 1, map[int]byte{0: 4}, nil, nil}, + {",", 7, 1, map[int]byte{0: 4, 1: 8}, nil, nil}, + {",", 8, 1, map[int]byte{0: 4, 1: 15}, nil, nil}, + {">", 9, 2, map[int]byte{0: 4, 1: 15}, nil, nil}, + {",", 10, 2, map[int]byte{0: 4, 1: 15, 2: 16}, nil, nil}, + {".", 11, 2, map[int]byte{0: 4, 1: 15, 2: 16}, nil, []byte{16}}, + {"<", 12, 1, map[int]byte{0: 4, 1: 15, 2: 16}, nil, []byte{16}}, + {".", 13, 1, map[int]byte{0: 4, 1: 15, 2: 16}, nil, []byte{16, 15}}, + {"<", 14, 0, map[int]byte{0: 4, 1: 15, 2: 16}, nil, []byte{16, 15}}, + {".", 15, 0, map[int]byte{0: 4, 1: 15, 2: 16}, nil, []byte{16, 15, 4}}, + {"[->+<]", 21, 0, map[int]byte{0: 0, 1: 19, 2: 16}, map[int]int{15: 20, 20: 15}, []byte{16, 15, 4}}, + {">", 22, 1, map[int]byte{0: 0, 1: 19, 2: 16}, map[int]int{15: 20, 20: 15}, []byte{16, 15, 4}}, + {".", 23, 1, map[int]byte{0: 0, 1: 19, 2: 16}, map[int]int{15: 20, 20: 15}, []byte{16, 15, 4, 19}}, } var out bytes.Buffer bf := New(bytes.NewBuffer([]byte{4, 8, 15, 16, 23, 41}), &out) - if err := checkInterpreter(bf, 0, 0, nil); err != nil { + if err := checkInterpreter(bf, 0, 0, nil, nil); err != nil { t.Fatalf("[0] Unexpected interpreter state: %v", err) } for i, test := range tests { - if err := bf.Eval(test.cmd); err != nil { - t.Fatalf("[%d] Eval(%q) failed with error: %v", i, test.cmd, err) + if err := bf.Eval(test.cmds); err != nil { + t.Fatalf("[%d] Eval(%q) failed with error: %v", i, test.cmds, err) } - if err := checkInterpreter(bf, test.i, test.d, test.cells); err != nil { + if err := checkInterpreter(bf, test.i, test.d, test.cells, test.loops); err != nil { t.Fatalf("[%d] Unexpected interpreter state: %v", i, err) } if diff := cmp.Diff(test.out, out.Bytes()); diff != "" { @@ -74,23 +70,42 @@ func TestEval(t *testing.T) { } } -func checkInterpreter(bf *Brainfuck, i, d int, cells map[int]byte) error { +func checkInterpreter(bf *Brainfuck, i, d int, cells map[int]byte, loops map[int]int) error { if bf.i != i { return fmt.Errorf("instruction pointer; got %d, want %d", bf.i, i) } if bf.d != d { return fmt.Errorf("data pointer; got %d, want %d", bf.d, d) } + // bf.cells \subseteq cells. for idx, got := range bf.cells { if want := cells[idx]; got != want { return fmt.Errorf("cell value at index %d; got %d, want %d", idx, got, want) } } + // cells \subseteq bf.cells. + for idx, want := range cells { + if got := bf.cells[idx]; got != want { + return fmt.Errorf("cell value at index %d; got %d, want %d", idx, got, want) + } + } + // bf.loops \subseteq loops. + for idx, got := range bf.loops { + if want := loops[idx]; got != want { + return fmt.Errorf("loop value at index %d; got %d, want %d", idx, got, want) + } + } + // loops \subseteq bf.loops. + for idx, want := range loops { + if got := bf.loops[idx]; got != want { + return fmt.Errorf("loop value at index %d; got %d, want %d", idx, got, want) + } + } return nil } func TestInvalidLoopHandling(t *testing.T) { - tests := []string{"]", "[]]", "[][]]"} + tests := []string{"[", "]", "[]]", "[][", "[][]]"} for _, test := range tests { if _, err := Run([]byte(test), nil); err == nil { t.Errorf("Parsed invalid program %q; expected error", test) @@ -126,24 +141,24 @@ func TestCellWrapping(t *testing.T) { bf := New(nil, nil) // Move to the right .... 3e4 - 1 times. for i := 0; i < len(bf.cells)-1; i++ { - if err := bf.Eval('>'); err != nil { + if err := bf.Eval(">"); err != nil { t.Fatalf("Eval(>) failed with error %v", err) } } - if err := checkInterpreter(bf, 29999, 29999, nil); err != nil { + if err := checkInterpreter(bf, 29999, 29999, nil, nil); err != nil { t.Fatalf("Unexpected interpreter state: %v", err) } // Move to the right once more -- should wrap! - if err := bf.Eval('>'); err != nil { + if err := bf.Eval(">"); err != nil { t.Fatalf("Eval(>) failed with error %v", err) - } else if err := checkInterpreter(bf, 30000, 0, nil); err != nil { + } else if err := checkInterpreter(bf, 30000, 0, nil, nil); err != nil { t.Fatalf("Unexpected interpreter state: %v", err) } // Move to the left -- should wrap! - if err := bf.Eval('<'); err != nil { + if err := bf.Eval("<"); err != nil { t.Fatalf("Eval(<) failed with error %v", err) } - if err := checkInterpreter(bf, 30001, 29999, nil); err != nil { + if err := checkInterpreter(bf, 30001, 29999, nil, nil); err != nil { t.Fatalf("Unexpected interpreter state: %v", err) } } @@ -151,20 +166,20 @@ func TestCellWrapping(t *testing.T) { func Run(cmds []byte, in io.Reader) ([]byte, error) { var out bytes.Buffer bf := New(in, &out) - for _, cmd := range cmds { - if err := bf.Eval(cmd); err != nil { - return nil, err - } + if err := bf.Eval(string(cmds)); err != nil { + return nil, err } return out.Bytes(), nil } // Find your own source files! -func BenchmarkLong(b *testing.B) { runBenchmark(b, "long.bf", nil) } -func BenchmarkMandelbrot(b *testing.B) { runBenchmark(b, "mandelbrot.bf", nil) } -func BenchmarkFactor(b *testing.B) { runBenchmark(b, "factor.bf", bytes.NewReader([]byte("418151632\n"))) } +func BenchmarkLong(b *testing.B) { runBenchmark(b, "long.bf", nil) } +func BenchmarkMandelbrot(b *testing.B) { runBenchmark(b, "mandelbrot.bf", nil) } +func BenchmarkFactor(b *testing.B) { + runBenchmark(b, "factor.bf", bytes.NewReader([]byte("418151632\n"))) +} -func runBenchmark(b *testing.B, path string, in io.Reader) { +func runBenchmark(b *testing.B, path string, in io.Reader) { b.Helper() src, err := os.ReadFile(path) if err != nil { diff --git a/cmd/gbfy/main.go b/cmd/gbfy/main.go @@ -122,14 +122,14 @@ func runPiped(bf *gbfy.Brainfuck) error { func runCommands(bf *gbfy.Brainfuck, r *bufio.Reader) error { for { - cmd, err := r.ReadByte() + cmds, err := r.ReadString('\n') if err == io.EOF { break } else if err != nil { return fmt.Errorf("failed to ReadByte: %v", err) } - if err := bf.Eval(cmd); err != nil { - return fmt.Errorf("failed to Eval %q: %v", cmd, err) + if err := bf.Eval(cmds); err != nil { + return fmt.Errorf("failed to Eval %q: %v", cmds, err) } } return nil