Go makes it possible to write architecture-specific assembly for hot paths, but handwritten assembly is difficult to maintain. Developers must know Go’s assembler dialect, stack-frame conventions, argument offsets, build tags, function stubs, and register pressure. Small mistakes can silently corrupt data or break when a Go type changes.
The avo project provides a higher-level approach: write a Go program that emits Go assembly. The generator uses familiar Go control flow, virtual registers, typed parameter loaders, return-value stores, labels, and instruction builders. It then produces the .s file and matching Go stub needed for normal package integration.
The core pattern is useful beyond one library. Treat assembly as generated code when the algorithm needs CPU-specific instructions but the implementation still benefits from type-aware tooling, repeatable builds, reviewable source, and testable generation. This is common in hashing, compression, cryptography, checksums, vector math, and low-level codecs.
An avo generator is an ordinary Go file, often excluded from normal builds with //go:build ignore. Running it creates assembly output. Because the generator is Go, it can use loops, helper functions, constants, tables, and package-level organization instead of copy-pasted assembly blocks.
Handwritten assembly forces the author to choose physical registers early. avo lets the generator request virtual registers such as GP64(). The allocation pass maps those virtual values to physical registers where possible, reducing register bookkeeping in the source generator.
Go assembly functions still need Go declarations so ordinary code can call them. avo can emit both the assembly file and the stub file from the same function signature, reducing mismatch risk between the generated symbol, argument layout, and package API.
The file reviewers maintain is the generator, not the emitted assembly. Generated assembly can still be inspected and benchmarked, but changes should be made in the higher-level generator so the output remains reproducible.
A generator starts with a TEXT declaration, documentation, a Go-compatible function signature, and instruction calls. Parameters are loaded by name, operations are appended to the current function body, and return values are stored by return index.
//go:build ignore
package main
import . "github.com/mmcloughlin/avo/build"
import . "github.com/mmcloughlin/avo/operand"
import . "github.com/mmcloughlin/avo/reg"
func main() {
TEXT("Add", NOSPLIT, "func(x, y uint64) uint64")
Doc("Add adds x and y.")
x := Load(Param("x"), GP64())
y := Load(Param("y"), GP64())
ADDQ(x, y)
Store(y, ReturnIndex(0))
RET()
Generate()
}
The source package usually includes a generation directive. Running go generate executes the generator and writes both output files. The generated stub exposes the function to Go callers, while the assembly file contains the final Go assembler text.
//go:generate go run asm.go -out add.s -stubs stub.go
// Generated stub shape:
package add
func Add(x uint64, y uint64) uint64
For data-parallel kernels, the generator can load slice base and length fields, create labels, emit branches, and build memory operands. This keeps the loop structure visible without manually tracking stack offsets for every parameter.
Hash functions and checksums often have tight inner loops that benefit from SIMD, carryless multiply, byte shuffles, or architecture-specific rotations. A generated assembly approach lets one source describe repeated rounds while still emitting specialized instructions.
Compression codecs spend significant time in match finding, checksums, copies, and bit packing. Generated assembly can express unrolled loops, vector loads, and CPU feature variants while preserving a readable generator for maintenance.
Cryptographic code needs both performance and careful review. Generators can reduce mechanical register and offset mistakes, but the emitted instructions still need test vectors, constant-time review, and architecture-specific benchmarking.
Assembly should accelerate a known-correct implementation, not be the only executable description of the algorithm. Keep a pure-Go fallback behind build tags or runtime CPU feature checks, and run the same tests against both paths.
// sum.go
func Sum(xs []uint64) uint64 {
var s uint64
for _, x := range xs {
s += x
}
return s
}
// sum_amd64.go
//go:build amd64
func SumASM(xs []uint64) uint64
Place generators under clear filenames such as asm.go or gen_amd64.go, exclude them from normal package builds, and include generated files only when the repository expects reproducible builds without requiring the generator dependency at consumer build time.
Test the algorithm result, the generated file freshness, and edge cases such as empty slices, unaligned lengths, overflow behavior, and CPU-feature fallbacks. For performance-sensitive packages, pair correctness tests with microbenchmarks that compare pure-Go and assembly variants.
NOSPLIT, and noescape choices affect garbage collection and stack growth. Use the conservative option unless a benchmark and review justify otherwise..s files disappear on the next generation run. Change the generator and regenerate.avo targets x86 assembly. Packages that also need ARM64 or generic support should keep a clear fallback and avoid exposing architecture-specific assumptions in the public API.