docs(perf): add a benchmark and profiling guide
docs/performance.md lists the benchmarks per area, the baseline and benchstat workflow, CPU and memory profiles, escape analysis, and the oracle and property checks that a performance change must keep passing.
- commit
- 1315cb1e3619020111801ec2f37ee45367238151
- author
- brice · · 2026-10-02 16:38 UTC
- parent
- c6dd596
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +98 -0
M README.md +2 -0
| @@ -177,3 +177,5 @@ Go packages retain the module's standard-library-only dependency boundary apart | |||
| 177 | 177 | from `vev-vt/core`. | |
| 178 | 178 | ||
| 179 | 179 | [Storage benchmarks and design decisions →](docs/storage-optimization.md) | |
| 180 | + | ||
| 181 | + | [Measuring and profiling performance changes →](docs/performance.md) | |
| @@ -177,3 +177,5 @@ Go packages retain the module's standard-library-only dependency boundary apart | |||||
| 177 | from `vev-vt/core`. | 177 | from `vev-vt/core`. | ||
| 178 | 178 | ||||
| 179 | [Storage benchmarks and design decisions →](docs/storage-optimization.md) | 179 | [Storage benchmarks and design decisions →](docs/storage-optimization.md) | ||
| 180 | + | ||||
| 181 | + | [Measuring and profiling performance changes →](docs/performance.md) | |||
A docs/performance.md +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | # Performance work | |
| 2 | + | ||
| 3 | + | How to measure CPU time and allocations before and after a change, and how to | |
| 4 | + | check that the change kept behavior. | |
| 5 | + | ||
| 6 | + | ## Workflow | |
| 7 | + | ||
| 8 | + | 1. Pick the benchmark that covers the code you change (table below). | |
| 9 | + | 2. Record a baseline on the base branch. | |
| 10 | + | 3. Make the change, then record the same benchmark again. | |
| 11 | + | 4. Compare with `benchstat`; keep only wins that are stable across runs. | |
| 12 | + | 5. Run the safety checks below before you commit. | |
| 13 | + | ||
| 14 | + | ```sh | |
| 15 | + | # Baseline, from a worktree of the base branch | |
| 16 | + | go test ./html -run '^$' -bench '^BenchmarkRenderer$' -benchmem -count=10 > /tmp/old.txt | |
| 17 | + | # After the change | |
| 18 | + | go test ./html -run '^$' -bench '^BenchmarkRenderer$' -benchmem -count=10 > /tmp/new.txt | |
| 19 | + | benchstat /tmp/old.txt /tmp/new.txt | |
| 20 | + | ``` | |
| 21 | + | ||
| 22 | + | `benchstat` comes from `go install golang.org/x/perf/cmd/benchstat@latest`. Use | |
| 23 | + | `-count=10` so it can report variation; a `~` in its output means no | |
| 24 | + | significant change. | |
| 25 | + | ||
| 26 | + | ## Benchmarks | |
| 27 | + | ||
| 28 | + | | Area | Package | Benchmarks | | |
| 29 | + | |---|---|---| | |
| 30 | + | | VT parsing and screen writes | `.` | `BenchmarkScreenPrintableASCII`, `BenchmarkScreenMixedUTF8`, `BenchmarkScreenCSIHeavy`, `BenchmarkScreenShellRedrawBurst`, `BenchmarkScreenFullscreenScrollRegion`, `BenchmarkScreenKittyAPC` | | |
| 31 | + | | Damage capture and resize | `.` | `BenchmarkScreenCaptureDamage`, `BenchmarkScreenResizeReflowViewport` | | |
| 32 | + | | History build, views and storage | `.` | `BenchmarkHistoryBuild10Kx120`, `BenchmarkHistoryView10KRows*`, `BenchmarkHistoryRetained10Kx120`, see [storage-optimization.md](storage-optimization.md) | | |
| 33 | + | | ANSI renderer | `./ansi` | `BenchmarkRendererFullFrameDraw`, `BenchmarkRendererIncrementalOneCell`, `BenchmarkRendererFragmentedDamage`, `BenchmarkRendererBroadRegularDamage`, `BenchmarkRendererIncrementalNoBytePrepareCommit` | | |
| 34 | + | | HTML renderer | `./html` | `BenchmarkRenderer` (`snapshot-*` and `one-row-*` at 80x24, 120x40, 240x80) | | |
| 35 | + | ||
| 36 | + | `one-row-*` is the common interactive case: one changed line in a full frame. | |
| 37 | + | It must stay cheaper than `snapshot-*` at the same size. | |
| 38 | + | ||
| 39 | + | ## Profiles | |
| 40 | + | ||
| 41 | + | Use a CPU or memory profile to find where the time or bytes go: | |
| 42 | + | ||
| 43 | + | ```sh | |
| 44 | + | go test ./html -run '^$' -bench '^BenchmarkRenderer$/one-row-240x80' -benchmem \ | |
| 45 | + | -cpuprofile /tmp/cpu.out -memprofile /tmp/mem.out | |
| 46 | + | go tool pprof -top /tmp/cpu.out | |
| 47 | + | go tool pprof -sample_index=alloc_space -top /tmp/mem.out | |
| 48 | + | go tool pprof -http=:0 /tmp/cpu.out # flame graph in the browser | |
| 49 | + | ``` | |
| 50 | + | ||
| 51 | + | To see why a value escapes to the heap: | |
| 52 | + | ||
| 53 | + | ```sh | |
| 54 | + | go build -gcflags=-m ./html 2>&1 | grep -v inlin | |
| 55 | + | ``` | |
| 56 | + | ||
| 57 | + | Tests can pin an allocation result with `testing.AllocsPerRun`; several | |
| 58 | + | packages already do, for example `TestCopyFromDoesNotAllocateOnceSized` in | |
| 59 | + | `core`. | |
| 60 | + | ||
| 61 | + | ## Safety checks | |
| 62 | + | ||
| 63 | + | A faster change must produce the same output: | |
| 64 | + | ||
| 65 | + | ```sh | |
| 66 | + | go test ./... # includes TestDifferentialOracle | |
| 67 | + | go test -race ./... | |
| 68 | + | go vet ./... && staticcheck ./... | |
| 69 | + | ``` | |
| 70 | + | ||
| 71 | + | `TestDifferentialOracle` digests the output of every layer (VT, history, | |
| 72 | + | graphics, ANSI, HTML) for seeded workloads. A performance change must leave | |
| 73 | + | `testdata/oracle/golden.txt` unchanged. The property tests in `ansi` and `html` | |
| 74 | + | compare reused-buffer renderers with clone-based references through random | |
| 75 | + | edits, aborts and resets. | |
| 76 | + | ||
| 77 | + | When a change reuses buffers, check: | |
| 78 | + | ||
| 79 | + | - Nothing returned to a caller still points into a reused buffer. | |
| 80 | + | - Retained scratch is bounded, so one huge frame does not pin memory. | |
| 81 | + | - Reused slices of strings or pointers are cleared, so they do not keep old | |
| 82 | + | values alive. | |
| 83 | + | ||
| 84 | + | ## Browser rendering | |
| 85 | + | ||
| 86 | + | For the HTML frontend, the browser's style, layout and paint usually cost more | |
| 87 | + | than the Go side. The Playwright performance gate in | |
| 88 | + | [`internal/htmlharness`](../internal/htmlharness/README.md) bounds sustained | |
| 89 | + | snapshot and row work in Chromium: | |
| 90 | + | ||
| 91 | + | ```sh | |
| 92 | + | npx playwright test internal/htmlharness/performance.spec.js --project=chromium | |
| 93 | + | ``` | |
| 94 | + | ||
| 95 | + | Measure user-visible scrolling in the host application, with a real GPU and the | |
| 96 | + | user's zoom level: headless Chromium does not reproduce compositor costs. | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | # Performance work | |||
| 2 | + | ||||
| 3 | + | How to measure CPU time and allocations before and after a change, and how to | |||
| 4 | + | check that the change kept behavior. | |||
| 5 | + | ||||
| 6 | + | ## Workflow | |||
| 7 | + | ||||
| 8 | + | 1. Pick the benchmark that covers the code you change (table below). | |||
| 9 | + | 2. Record a baseline on the base branch. | |||
| 10 | + | 3. Make the change, then record the same benchmark again. | |||
| 11 | + | 4. Compare with `benchstat`; keep only wins that are stable across runs. | |||
| 12 | + | 5. Run the safety checks below before you commit. | |||
| 13 | + | ||||
| 14 | + | ```sh | |||
| 15 | + | # Baseline, from a worktree of the base branch | |||
| 16 | + | go test ./html -run '^$' -bench '^BenchmarkRenderer$' -benchmem -count=10 > /tmp/old.txt | |||
| 17 | + | # After the change | |||
| 18 | + | go test ./html -run '^$' -bench '^BenchmarkRenderer$' -benchmem -count=10 > /tmp/new.txt | |||
| 19 | + | benchstat /tmp/old.txt /tmp/new.txt | |||
| 20 | + | ``` | |||
| 21 | + | ||||
| 22 | + | `benchstat` comes from `go install golang.org/x/perf/cmd/benchstat@latest`. Use | |||
| 23 | + | `-count=10` so it can report variation; a `~` in its output means no | |||
| 24 | + | significant change. | |||
| 25 | + | ||||
| 26 | + | ## Benchmarks | |||
| 27 | + | ||||
| 28 | + | | Area | Package | Benchmarks | | |||
| 29 | + | |---|---|---| | |||
| 30 | + | | VT parsing and screen writes | `.` | `BenchmarkScreenPrintableASCII`, `BenchmarkScreenMixedUTF8`, `BenchmarkScreenCSIHeavy`, `BenchmarkScreenShellRedrawBurst`, `BenchmarkScreenFullscreenScrollRegion`, `BenchmarkScreenKittyAPC` | | |||
| 31 | + | | Damage capture and resize | `.` | `BenchmarkScreenCaptureDamage`, `BenchmarkScreenResizeReflowViewport` | | |||
| 32 | + | | History build, views and storage | `.` | `BenchmarkHistoryBuild10Kx120`, `BenchmarkHistoryView10KRows*`, `BenchmarkHistoryRetained10Kx120`, see [storage-optimization.md](storage-optimization.md) | | |||
| 33 | + | | ANSI renderer | `./ansi` | `BenchmarkRendererFullFrameDraw`, `BenchmarkRendererIncrementalOneCell`, `BenchmarkRendererFragmentedDamage`, `BenchmarkRendererBroadRegularDamage`, `BenchmarkRendererIncrementalNoBytePrepareCommit` | | |||
| 34 | + | | HTML renderer | `./html` | `BenchmarkRenderer` (`snapshot-*` and `one-row-*` at 80x24, 120x40, 240x80) | | |||
| 35 | + | ||||
| 36 | + | `one-row-*` is the common interactive case: one changed line in a full frame. | |||
| 37 | + | It must stay cheaper than `snapshot-*` at the same size. | |||
| 38 | + | ||||
| 39 | + | ## Profiles | |||
| 40 | + | ||||
| 41 | + | Use a CPU or memory profile to find where the time or bytes go: | |||
| 42 | + | ||||
| 43 | + | ```sh | |||
| 44 | + | go test ./html -run '^$' -bench '^BenchmarkRenderer$/one-row-240x80' -benchmem \ | |||
| 45 | + | -cpuprofile /tmp/cpu.out -memprofile /tmp/mem.out | |||
| 46 | + | go tool pprof -top /tmp/cpu.out | |||
| 47 | + | go tool pprof -sample_index=alloc_space -top /tmp/mem.out | |||
| 48 | + | go tool pprof -http=:0 /tmp/cpu.out # flame graph in the browser | |||
| 49 | + | ``` | |||
| 50 | + | ||||
| 51 | + | To see why a value escapes to the heap: | |||
| 52 | + | ||||
| 53 | + | ```sh | |||
| 54 | + | go build -gcflags=-m ./html 2>&1 | grep -v inlin | |||
| 55 | + | ``` | |||
| 56 | + | ||||
| 57 | + | Tests can pin an allocation result with `testing.AllocsPerRun`; several | |||
| 58 | + | packages already do, for example `TestCopyFromDoesNotAllocateOnceSized` in | |||
| 59 | + | `core`. | |||
| 60 | + | ||||
| 61 | + | ## Safety checks | |||
| 62 | + | ||||
| 63 | + | A faster change must produce the same output: | |||
| 64 | + | ||||
| 65 | + | ```sh | |||
| 66 | + | go test ./... # includes TestDifferentialOracle | |||
| 67 | + | go test -race ./... | |||
| 68 | + | go vet ./... && staticcheck ./... | |||
| 69 | + | ``` | |||
| 70 | + | ||||
| 71 | + | `TestDifferentialOracle` digests the output of every layer (VT, history, | |||
| 72 | + | graphics, ANSI, HTML) for seeded workloads. A performance change must leave | |||
| 73 | + | `testdata/oracle/golden.txt` unchanged. The property tests in `ansi` and `html` | |||
| 74 | + | compare reused-buffer renderers with clone-based references through random | |||
| 75 | + | edits, aborts and resets. | |||
| 76 | + | ||||
| 77 | + | When a change reuses buffers, check: | |||
| 78 | + | ||||
| 79 | + | - Nothing returned to a caller still points into a reused buffer. | |||
| 80 | + | - Retained scratch is bounded, so one huge frame does not pin memory. | |||
| 81 | + | - Reused slices of strings or pointers are cleared, so they do not keep old | |||
| 82 | + | values alive. | |||
| 83 | + | ||||
| 84 | + | ## Browser rendering | |||
| 85 | + | ||||
| 86 | + | For the HTML frontend, the browser's style, layout and paint usually cost more | |||
| 87 | + | than the Go side. The Playwright performance gate in | |||
| 88 | + | [`internal/htmlharness`](../internal/htmlharness/README.md) bounds sustained | |||
| 89 | + | snapshot and row work in Chromium: | |||
| 90 | + | ||||
| 91 | + | ```sh | |||
| 92 | + | npx playwright test internal/htmlharness/performance.spec.js --project=chromium | |||
| 93 | + | ``` | |||
| 94 | + | ||||
| 95 | + | Measure user-visible scrolling in the host application, with a real GPU and the | |||
| 96 | + | user's zoom level: headless Chromium does not reproduce compositor costs. | |||