mirror of
https://github.com/hanzoai/benchmarks.git
synced 2026-10-30 14:59:11 +00:00
Modules declare go 1.27.1. go fix applied the modernizers; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
228 lines
7.5 KiB
Go
228 lines
7.5 KiB
Go
// What an agent costs when it is a goroutine, measured.
|
|
//
|
|
// Naïve publish 2.79 ms cold start and 1.2 MB per agent on isolated-vm, and
|
|
// that is the number to beat. But an isolate is a JavaScript execution context
|
|
// inside a running process — so the honest comparison is not our container, it
|
|
// is our equivalent: a goroutine holding the loop, and wazero holding the code.
|
|
//
|
|
// A goroutine starts with a 2 KB stack that grows on demand, so a fleet is
|
|
// bounded by what the agents actually hold rather than by a per-agent floor.
|
|
// wazero is a pure-Go WebAssembly runtime — no CGO, no container, in-process
|
|
// like an isolate — and WASM is not one language: CPython, TypeScript via
|
|
// QuickJS, Rust and Go all compile to it.
|
|
//
|
|
// go run . # spawn, memory, and WASM instantiation
|
|
// FLEET=1000000 go run .
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/tetratelabs/wazero"
|
|
"github.com/tetratelabs/wazero/api"
|
|
)
|
|
|
|
func mb(b uint64) float64 { return float64(b) / 1024 / 1024 }
|
|
|
|
func heap() uint64 {
|
|
var m runtime.MemStats
|
|
runtime.GC()
|
|
runtime.ReadMemStats(&m)
|
|
return m.HeapAlloc
|
|
}
|
|
|
|
// A minimal WASM module: one exported function that adds two numbers. Small on
|
|
// purpose — it measures the runtime's instantiation cost rather than the
|
|
// module's own work, which is what "cold start" means.
|
|
var addWasm = []byte{
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic, version
|
|
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, // type: (i32,i32)->i32
|
|
0x03, 0x02, 0x01, 0x00, // func 0 has type 0
|
|
0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, // export "add"
|
|
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b, // body: local.get 0, local.get 1, i32.add
|
|
}
|
|
|
|
func main() {
|
|
fleet := 1_000_000
|
|
if v := os.Getenv("FLEET"); v != "" {
|
|
fleet, _ = strconv.Atoi(v)
|
|
}
|
|
|
|
fmt.Printf("\n── Agent as goroutine · %s ──\n\n", runtime.Version())
|
|
// The load belongs here for the same reason the core count does: this lane
|
|
// read 601 bytes per agent on a host at load 3 and 594 on one at load 73, and
|
|
// a reader cannot tell the two apart from the number alone.
|
|
fmt.Printf(" host: %s/%s, %d cores, load %s\n\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU(), load1())
|
|
|
|
// ── 1. Spawn latency. One agent, started, doing nothing yet.
|
|
//
|
|
// THE FIRST ROUND IS THROWN AWAY, and that is the whole of why this number
|
|
// moved. Measured as the first thing the process did, spawn read 202, 210 and
|
|
// 201 ns on three freshly built binaries, and 123, 125 and 123 ns when the
|
|
// same binary ran again — a 1.65x step, three times out of three, with each
|
|
// side tight. It is not the spawn that costs more: the window is 41 ms rather
|
|
// than 25 ms for the same 200,000 goroutines, so it is a fixed cost inside the
|
|
// measurement — first-touch paging of the text segment and the kernel's
|
|
// signature check on first exec, both of which happen once per binary and
|
|
// never again.
|
|
//
|
|
// So the old number was the cost of STARTING THIS PROGRAM, divided by 200,000
|
|
// and printed as the cost of starting a goroutine. The suite already knew the
|
|
// neighbouring version of this — "benchmark a built binary, under `go run` the
|
|
// compile is counted" — and stopped one step short: a built binary's first run
|
|
// is inflated too.
|
|
//
|
|
// A warm round is the honest one because it is the case being claimed. An
|
|
// agent fleet spawns continuously in a process that is already up; nobody
|
|
// pays this twice.
|
|
const spawns = 200_000
|
|
spawn := func() time.Duration {
|
|
var wg sync.WaitGroup
|
|
wg.Add(spawns)
|
|
start := time.Now()
|
|
for range spawns {
|
|
go func() { wg.Done() }()
|
|
}
|
|
wg.Wait()
|
|
return time.Since(start)
|
|
}
|
|
cold := spawn()
|
|
took := spawn()
|
|
per := took.Nanoseconds() / int64(spawns)
|
|
fmt.Printf(" spawn %d ns per agent (%s for %s · first round %s, discarded)\n",
|
|
per, took.Round(time.Millisecond), fmtN(spawns), cold.Round(time.Millisecond))
|
|
|
|
// ── 2. A whole fleet, resident and parked. Every goroutine is alive and
|
|
// blocked on a channel — the shape of an agent waiting for its next turn.
|
|
before := heap()
|
|
gate := make(chan struct{})
|
|
var live atomic.Int64
|
|
var fleetWg sync.WaitGroup
|
|
fleetWg.Add(fleet)
|
|
|
|
t0 := time.Now()
|
|
for i := 0; i < fleet; i++ {
|
|
go func() {
|
|
live.Add(1)
|
|
<-gate // parked: waiting on a person, a schedule, an upstream job
|
|
fleetWg.Done()
|
|
}()
|
|
}
|
|
for live.Load() < int64(fleet) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
spawnAll := time.Since(t0)
|
|
after := heap()
|
|
|
|
fmt.Printf(" fleet resident %s goroutines in %s (%s/s)\n",
|
|
fmtN(fleet), spawnAll.Round(time.Millisecond),
|
|
fmtN(int(float64(fleet)/spawnAll.Seconds())))
|
|
fmt.Printf(" memory %.1f MB total, %.0f bytes per agent\n",
|
|
mb(after-before), float64(after-before)/float64(fleet))
|
|
fmt.Printf(" runtime.NumGoroutine %s\n\n", fmtN(runtime.NumGoroutine()))
|
|
|
|
// ── 3. Waking the fleet. Closing the gate releases every parked agent.
|
|
t1 := time.Now()
|
|
close(gate)
|
|
fleetWg.Wait()
|
|
wake := time.Since(t1)
|
|
fmt.Printf(" wake all %s for %s (%.0f ns per agent)\n\n",
|
|
wake.Round(time.Millisecond), fmtN(fleet),
|
|
float64(wake.Nanoseconds())/float64(fleet))
|
|
|
|
// ── 4. The sandbox: a WASM module instantiated per call, in-process.
|
|
ctx := context.Background()
|
|
rt := wazero.NewRuntime(ctx)
|
|
defer rt.Close(ctx)
|
|
|
|
compiled, err := rt.CompileModule(ctx, addWasm)
|
|
if err != nil {
|
|
fmt.Printf(" wasm compile failed: %v\n", err)
|
|
return
|
|
}
|
|
|
|
const insts = 2000
|
|
cfg := wazero.NewModuleConfig().WithName("")
|
|
t2 := time.Now()
|
|
var fn api.Function
|
|
for range insts {
|
|
mod, err := rt.InstantiateModule(ctx, compiled, cfg)
|
|
if err != nil {
|
|
fmt.Printf(" wasm instantiate failed: %v\n", err)
|
|
return
|
|
}
|
|
fn = mod.ExportedFunction("add")
|
|
_ = mod.Close(ctx)
|
|
}
|
|
perInst := float64(time.Since(t2).Microseconds()) / float64(insts)
|
|
fmt.Printf("── Sandbox: wazero, in-process ──\n\n")
|
|
fmt.Printf(" compile once %s\n", "cached")
|
|
fmt.Printf(" instantiate %.1f µs per sandbox (%.3f ms)\n", perInst, perInst/1000)
|
|
|
|
// A call into the sandbox, to show the boundary is cheap once crossed.
|
|
if fn != nil {
|
|
mod, _ := rt.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName(""))
|
|
f := mod.ExportedFunction("add")
|
|
const calls = 100_000
|
|
t3 := time.Now()
|
|
for range calls {
|
|
_, _ = f.Call(ctx, 2, 3)
|
|
}
|
|
fmt.Printf(" call into sandbox %.0f ns\n", float64(time.Since(t3).Nanoseconds())/float64(calls))
|
|
_ = mod.Close(ctx)
|
|
}
|
|
|
|
languages()
|
|
|
|
fmt.Printf(`
|
|
── Against the published numbers ──
|
|
|
|
Naïve, isolated-vm 2.79 ms cold start 1.2 MB per agent
|
|
Hanzo, goroutine %.4f ms spawn %.0f bytes per agent
|
|
Hanzo, wazero sandbox %.4f ms instantiate in-process, no container
|
|
|
|
WASM is not one language: CPython, QuickJS for TypeScript, Rust and Go all
|
|
target it. A V8 isolate is JavaScript only.
|
|
`, float64(per)/1e6, float64(after-before)/float64(fleet), perInst/1000)
|
|
}
|
|
|
|
func fmtN(n int) string {
|
|
s := strconv.Itoa(n)
|
|
var out strings.Builder
|
|
for i, c := range s {
|
|
if i > 0 && (len(s)-i)%3 == 0 {
|
|
out.WriteByte(',')
|
|
}
|
|
out.WriteRune(c)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// load1 is the one-minute load average, or "?" where the OS will not say.
|
|
func load1() string {
|
|
out, err := exec.Command("uptime").Output()
|
|
if err != nil {
|
|
return "?"
|
|
}
|
|
txt := string(out)
|
|
i := strings.LastIndex(txt, ":")
|
|
if i < 0 {
|
|
return "?"
|
|
}
|
|
f := strings.FieldsFunc(txt[i+1:], func(r rune) bool { return r == ',' || r == ' ' || r == '\n' })
|
|
if len(f) == 0 {
|
|
return "?"
|
|
}
|
|
return f[0]
|
|
}
|