Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/GO_ARRAYS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ elements of pointer arrays (allocated on the heap). Array equality/inequality
compare elements in index order and stop at the first difference, as Go does;
elements that are structs compare field by field ([struct equality](GO_TYPES.md#struct-equality)),
and arrays of slices, maps or function values are not comparable (go/types
rejects `==` on them). `len` and `cap` retain Go's operand-evaluation rules.
rejects `==` on them). On SBF, arrays of at least 8 one-byte integers
(`byte`, `uint8`, `int8`) compare 8 bytes at a time with no early exit, which
nothing can observe since byte comparisons have no side effects: a `[32]byte`
key comparison costs about 25 instructions instead of about 350 for the byte
loop ([measurements](../results/compiler/2026-10-19-byte-array-equal/README.md)).
Host C keeps the loop. `len` and `cap` retain Go's operand-evaluation rules.
Conversion between arrays with identical underlying types is supported,
including named/unnamed array conversions.

Expand Down
21 changes: 21 additions & 0 deletions internal/compiler/arrays.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,33 @@ func (g *generator) emitArray(node ast.Node, a *types.Array) {
g.line("for (u64 i=0;i<%dULL;i++) { if (!gosvm_iface_eq(a.items[i],b.items[i])) return 0; }", a.Len())
} else if isString(a.Elem()) {
g.line("for (u64 i=0;i<%dULL;i++) { if (!gosvm_str_eq(a.items[i],b.items[i])) return 0; }", a.Len())
} else if n := a.Len(); isByte(a.Elem()) && n >= 8 {
// On SBF a byte loop costs about 11 instructions per byte. Bytes have
// no side effects, so compare whole words (one unaligned ldxdw per 8
// bytes from the fixed-size memcpy) and fold the differences.
g.line("#ifdef __bpf__")
g.line("u64 d=0;")
g.line("for (u64 i=0;i+8<=%dULL;i+=8) { u64 x,y; __builtin_memcpy(&x,a.items+i,8); __builtin_memcpy(&y,b.items+i,8); d|=x^y; }", n)
if n%8 != 0 {
g.line("for (u64 i=%dULL;i<%dULL;i++) d|=(u8)(a.items[i]^b.items[i]);", n/8*8, n)
}
g.line("return d==0;")
g.line("#else")
g.line("for (u64 i=0;i<%dULL;i++) { if (a.items[i] != b.items[i]) return 0; }", n)
g.line("#endif")
} else {
g.line("for (u64 i=0;i<%dULL;i++) { if (a.items[i] != b.items[i]) return 0; }", a.Len())
}
g.line("return 1;\n}")
}

// isByte reports whether t is a one-byte integer (byte, uint8 or int8), whose
// arrays compare bytewise.
func isByte(t types.Type) bool {
b, ok := t.Underlying().(*types.Basic)
return ok && (b.Kind() == types.Uint8 || b.Kind() == types.Int8)
}

// emitElement defines what a composite array element of type t needs
// before the array: its struct, its array, or the struct or array it
// points to (a struct only by tag, since it may contain the array).
Expand Down
208 changes: 208 additions & 0 deletions internal/compiler/byteequal_sbf_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package compiler

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"gosvm/internal/targets"
"gosvm/internal/testvm"
)

// byteEqualSource compares two arrays built from the input with ==, for
// lengths 8, 13, 20, 32 and 64 of byte and of int8, and for [32]bool and
// [8]uint16, which keep the element loop. Input: [kind][len a bytes][len b
// bytes]. Account 0 gets 1 when they're equal and 2 when not, plus the
// struct comparison (a field array next to a scalar) in byte 1.
const byteEqualSource = `package main

import "gosvm/solana"

type keyed struct {
Tag uint16
Key [32]byte
}

func Process(c solana.Context) uint64 {
in := solana.Instruction(c)
out := solana.Data(c, 0)
k, d := in[0], in[1:]
eq := false
switch k {
case 0:
var a, b [8]byte
copy(a[:], d)
copy(b[:], d[8:])
eq = a == b
case 1:
var a, b [13]byte
copy(a[:], d)
copy(b[:], d[13:])
eq = a == b
case 2:
var a, b [20]byte
copy(a[:], d)
copy(b[:], d[20:])
eq = a == b
case 3:
var a, b [32]byte
copy(a[:], d)
copy(b[:], d[32:])
eq = a == b
x, y := keyed{Tag: 7, Key: a}, keyed{Tag: 7, Key: b}
if x == y {
out[1] = 1
} else {
out[1] = 2
}
case 4:
var a, b [64]byte
copy(a[:], d)
copy(b[:], d[64:])
eq = a == b
case 5:
var a, b [13]int8
for i := range a {
a[i], b[i] = int8(d[i]), int8(d[13+i])
}
eq = a == b
case 6:
var a, b [32]int8
for i := range a {
a[i], b[i] = int8(d[i]), int8(d[32+i])
}
eq = a == b
case 7:
var a, b [32]bool
for i := range a {
a[i], b[i] = d[i] != 0, d[32+i] != 0
}
eq = a == b
case 8:
var a, b [8]uint16
for i := range a {
a[i], b[i] = uint16(d[2*i])|uint16(d[2*i+1])<<8, uint16(d[16+2*i])|uint16(d[17+2*i])<<8
}
eq = a == b
}
if eq {
out[0] = 1
} else {
out[0] = 2
}
return 0
}
`

var byteEqualLens = []int{8, 13, 20, 32, 64, 13, 32, 32, 16}

// byteEqualCases: equal pairs, then one bit flipped (low and high) at every
// position of b, for every kind.
func byteEqualCases() []allocCase {
var cases []allocCase
for kind, n := range byteEqualLens {
a := make([]byte, n)
for i := range a {
a[i] = byte(i*37 + kind + 1)
}
if kind == 7 {
for i := range a {
a[i] &= 1
}
}
add := func(name string, b []byte, equal bool) {
out := make([]byte, 1024)
out[0] = 2
if equal {
out[0] = 1
}
if kind == 3 {
out[1] = out[0]
}
in := append(append([]byte{byte(kind)}, a...), b...)
cases = append(cases, allocCase{name: fmt.Sprintf("kind%d-len%d-%s", kind, n, name), in: in, output: out})
}
add("equal", append([]byte(nil), a...), true)
for i := 0; i < n; i++ {
for _, bit := range []byte{1, 0x80} {
if kind == 7 && bit == 0x80 {
continue // bools are 0 or 1
}
b := append([]byte(nil), a...)
b[i] ^= bit
add(fmt.Sprintf("diff%02d-%02x", i, bit), b, false)
}
}
}
return cases
}

// TestByteArrayEqualSBF runs byteEqualSource on fogo-v0 and solana-v3: every
// comparison must match Go. GOSVM_BYTEEQ_RESULTS=<dir> keeps the run with
// each case's CU.
func TestByteArrayEqualSBF(t *testing.T) {
if testing.Short() {
t.Skip("SBF runs are skipped in -short mode")
}
llvm := modernLLVM()
if llvm == "" {
t.Skip("pinned platform-tools v1.57 backend required")
}
cases := byteEqualCases()
keep := os.Getenv("GOSVM_BYTEEQ_RESULTS")
for _, profile := range []string{target.Fogo, target.Solana} {
t.Run(profile, func(t *testing.T) {
runner, err := testvm.RunnerFor("", profile)
if err != nil {
t.Skipf("runner unavailable: %v", err)
}
result := runAllocProgram(t, llvm, runner, profile, "byte-equal", []byte(byteEqualSource), 0, cases, keep)
equalCU := map[string]any{}
for name, v := range result {
if strings.HasSuffix(name, "-equal") {
equalCU[name] = v
}
}
summary := map[string]any{"profile": profile, "elf_sha256": result["elf_sha256"], "cases": len(cases), "equal_cu": equalCU}
encoded, _ := json.MarshalIndent(summary, "", " ")
t.Logf("%s: %d comparisons match Go\n%s", profile, len(cases), encoded)
if keep != "" {
if err = os.WriteFile(filepath.Join(keep, profile, "summary.json"), append(encoded, '\n'), 0644); err != nil {
t.Fatal(err)
}
}
})
}
}

// TestByteArrayEqualC checks which arrays get the word comparison: one-byte
// integer elements and at least 8 of them, on SBF only.
func TestByteArrayEqualC(t *testing.T) {
src := `package main
import "gosvm/solana"
func Process(c solana.Context) uint64 {
in := solana.Instruction(c)
var a, b [32]byte
var e, f [7]byte
var g, h [32]bool
var i, j [8]uint16
copy(a[:], in)
e[0], g[0], i[0] = in[0], in[1] != 0, uint16(in[2])
if a == b || e == f || g == h || i == j {
return 1
}
return 0
}
`
c, err := Compile("program.go", []byte(src))
if err != nil {
t.Fatal(err)
}
words := strings.Count(string(c), "__builtin_memcpy(&x,a.items+i,8)")
if words != 1 {
t.Fatalf("%d word comparisons, want 1 ([32]byte only)\n", words)
}
}
10 changes: 9 additions & 1 deletion internal/compiler/check_corpus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"os"
"regexp"
"strings"
"testing"
)
Expand Down Expand Up @@ -80,6 +81,13 @@ func withoutMemorySyscalls(c []byte) []byte {
return []byte(strings.Join(out, ""))
}

// byteEqualWords matches the SBF branch that opt-byte-array-equal adds to
// arrayN_equal for byte arrays (docs/GO_ARRAYS.md); withoutByteEqualWords
// keeps only its #else branch, the loop recorded before the change.
var byteEqualWords = regexp.MustCompile(`(?s)#ifdef __bpf__\nu64 d=0;\n.*?#else\n(.*?)#endif\n`)

func withoutByteEqualWords(c []byte) []byte { return byteEqualWords.ReplaceAll(c, []byte("$1")) }

// corpusChanges lists the recorded programs whose verdict changed on purpose
// after the corpus was frozen, with the outcome the current compiler must
// produce instead. The .gz is never edited; every other entry must still match
Expand Down Expand Up @@ -401,7 +409,7 @@ func TestCorpusMatchesPreCheckCompiler(t *testing.T) {
continue
}
if err == nil {
sum := sha256.Sum256(withoutMemorySyscalls(out))
sum := sha256.Sum256(withoutMemorySyscalls(withoutByteEqualWords(out)))
if hex.EncodeToString(sum[:]) != e.CSHA256 {
t.Errorf("entry %d: emitted C changed (sha256 %x)", i, sum)
}
Expand Down
54 changes: 54 additions & 0 deletions results/compiler/2026-10-19-byte-array-equal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Word-at-a-time `==` on byte arrays

October 19, 2026. Local only; runners 0.5.1 (fogo-v0, LiteSVM 0.8.2) and 0.6.0
(solana-v3, LiteSVM 0.13.0), platform-tools v1.57. Contract:
[GO_ARRAYS.md](../../../docs/GO_ARRAYS.md).

## Correctness

`TestByteArrayEqualSBF` compares `[8]byte`, `[13]byte`, `[20]byte`,
`[32]byte` (also as a struct field), `[64]byte`, `[13]int8`, `[32]int8`,
`[32]bool` and `[8]uint16` pairs: equal, and with bit 0 and bit 7 flipped at
every position. All 437 comparisons match Go on both profiles.
`TestByteArrayEqualC` checks that only one-byte integer arrays of at least 8
elements get the word comparison, and `TestCorpusMatchesPreCheckCompiler`
that it is the only change to the frozen corpus's C.

## CU (whole transaction, equal inputs; `*/before-summary.json` is main `939b215`)

| arrays | before | after | saved |
| --- | ---: | ---: | ---: |
| `[8]byte` | 165 | 162 | 3 |
| `[13]byte` | 241 | 197 | 44 |
| `[20]byte` | 290 | 196 | 94 |
| `[32]byte` and a struct holding one (2 comparisons) | 979 | 183 | 796 |
| `[64]byte` | 1,053 | 207 | 846 |
| `[32]int8` | 1,165 | 739 | 426 |
| `[32]bool` (unchanged lowering) | 1,342 | 1,340 | 2 |

fogo-v0; solana-v3 is the same within 1 CU. The transactions also copy the
inputs, which #22 (merged in #25) moved to `sol_memmove_`, so these totals are
lower than on `79cf25c` (1,548 to 750 for the `[32]byte` row) but the saving
per comparison is unchanged: about 400 CU per equal `[32]byte` pair. Short
arrays were already cheap.

## Hyperlane Go programs

Measured before #25, on `79cf25c`: `hyperlane-monorepo` `sealevel` at
`1d4694c13f` (which already compares account keys with an unrolled XOR, not
`==`), built with `hyperlane-integration-2` with and without this branch, all
23 Rust functional suites, 584/584 pass both ways. Total CU: fee 1.2% less,
IGP 1.4% less, synthetic token 1.6% less, others within 0.5%; per-call median
unchanged. Programs that compare keys with `==` gain more.

## Reference ELFs

This change moves the full swap (fogo-v0 `24c23925…`, solana-v3 `83f72984…`).
`clock2` is unchanged. `rebuild/metrics.json` is the fresh-rebuild check
against the previous reference (the #25 integration rebuild), and
`scripts/verify_rebuild.py` now uses it as the default reference:

```sh
python3 scripts/verify_rebuild.py --output <new-directory> --cli build/gosvm
python3 scripts/check_rebuild_reference.py <new-directory>/metrics.json
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"cases": 437,
"elf_sha256": "041d435cb18bcbfe4c709ef82ed9d5631f52a2a3dc0209625fe4f6c76d0238fb",
"equal_cu": {
"kind0-len8-equal": 162,
"kind1-len13-equal": 197,
"kind2-len20-equal": 196,
"kind3-len32-equal": 183,
"kind4-len64-equal": 207,
"kind5-len13-equal": 388,
"kind6-len32-equal": 739,
"kind7-len32-equal": 1340,
"kind8-len16-equal": 154
},
"profile": "fogo-v0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"cases": 437,
"elf_sha256": "1c50f84df909737323c136bcd3017691f4fbaa000c9fc8f8c8607231449a1754",
"equal_cu": {
"kind0-len8-equal": 165,
"kind1-len13-equal": 241,
"kind2-len20-equal": 290,
"kind3-len32-equal": 979,
"kind4-len64-equal": 1053,
"kind5-len13-equal": 405,
"kind6-len32-equal": 1165,
"kind7-len32-equal": 1342,
"kind8-len16-equal": 158
},
"profile": "fogo-v0"
}
Loading
Loading