From 65cbd2b71dcd1e57251e6e3d7406dad26b640a02 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 6 Aug 2026 20:34:57 +0200 Subject: [PATCH 1/3] test: modernize benchmarks with b.Loop --- element_create_benchmark_test.go | 12 +++--- examples/minesweeper/main_benchmark_test.go | 3 +- jaws_test.go | 41 ++++++++----------- lib/assets/js_benchmark_test.go | 2 + lib/key/key_benchmark_test.go | 2 +- lib/tag/tag_benchmark_test.go | 3 +- ...container_nested_reorder_benchmark_test.go | 2 + lib/ui/container_reuse_benchmark_test.go | 11 ++--- lib/ui/container_test.go | 5 ++- lib/ui/http_request_benchmark_test.go | 4 +- lib/ui/jsvar_benchmark_test.go | 21 ++++++---- lib/ui/template_owned_benchmark_test.go | 1 + lib/wire/wsmsg_benchmark_test.go | 5 +-- 13 files changed, 54 insertions(+), 58 deletions(-) diff --git a/element_create_benchmark_test.go b/element_create_benchmark_test.go index dbe58671..b1aae230 100644 --- a/element_create_benchmark_test.go +++ b/element_create_benchmark_test.go @@ -27,14 +27,14 @@ func (benchCreateUI) JawsUpdate(elem *Element) {} // // Batching is deliberate: b.StopTimer and b.StartTimer each call runtime.ReadMemStats, so // toggling around a single sub-microsecond creation would leave the timed section tiny, -// calibration would pick an enormous b.N, and the excluded setup would run for minutes. +// calibration would pick an enormous iteration count, and the excluded setup would run +// for minutes. // Amortising both calls over the batch keeps that honest, and deleting the batch keeps the // Request registry bounded instead of growing across iterations. The reported figure is per // batch of 64 Elements. // -// The Jaws and Request are built before b.ResetTimer, and the final b.StopTimer excludes the -// deferred Close: both would otherwise be divided into every reported figure, time and -// allocations alike, making the result depend on b.N rather than on the Element. +// [testing.B.Loop] excludes construction and the deferred Close from the measurement, +// while the explicit timer calls exclude per-batch deletion. func BenchmarkElementCreateBatch(b *testing.B) { b.ReportAllocs() const batch = 64 @@ -51,8 +51,7 @@ func BenchmarkElementCreateBatch(b *testing.B) { var ui benchCreateUI elems := make([]*Element, 0, batch) - b.ResetTimer() - for range b.N { + for b.Loop() { elems = elems[:0] for range batch { elem := rq.NewElement(ui) @@ -65,5 +64,4 @@ func BenchmarkElementCreateBatch(b *testing.B) { rq.DeleteElements(elems) b.StartTimer() } - b.StopTimer() } diff --git a/examples/minesweeper/main_benchmark_test.go b/examples/minesweeper/main_benchmark_test.go index a42f232d..ad1a7ece 100644 --- a/examples/minesweeper/main_benchmark_test.go +++ b/examples/minesweeper/main_benchmark_test.go @@ -49,8 +49,7 @@ func BenchmarkSingleCellDirtyFanout(b *testing.B) { cell := g.cells[0][0] b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { // Mirror the framework's dirty dispatch: expand the toggle's tags and // resolve each to its registered elements (Request.GetElements is the same // tagMap lookup makeUpdateList performs). The sum is the number of element diff --git a/jaws_test.go b/jaws_test.go index c96c1f39..fa64b8f0 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -3137,8 +3137,7 @@ func BenchmarkRequestLifecyclePooling(b *testing.B) { jw := newBenchPoolJaws(b) cycleBenchRequest(jw, impl.newRq, impl.recycle, tags) // warm the pool and backing arrays b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { cycleBenchRequest(jw, impl.newRq, impl.recycle, tags) } }) @@ -3204,8 +3203,7 @@ func BenchmarkRequestRecycleAfterHighWater(b *testing.B) { } jw.recycle(big) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { rq := jw.NewRequest(nil) jw.recycle(rq) runtime.KeepAlive(rq) @@ -3223,8 +3221,7 @@ func BenchmarkRequestClaimStartFinish(b *testing.B) { jw := newBenchPoolJaws(b) r := httptest.NewRequest(http.MethodGet, "/", nil) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { rq := jw.NewRequest(r) if jw.UseRequest(rq.JawsKey, r) != rq { b.Fatal("claim failed") @@ -3292,8 +3289,7 @@ func BenchmarkSubscriptionChannels(b *testing.B) { rq := jw.NewRequest(nil) defer jw.recycle(rq) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { msgCh := jw.subscribe(rq, 1) if msgCh == nil { b.Fatal("subscribe returned nil") @@ -3353,8 +3349,7 @@ func BenchmarkDistributeDirt(b *testing.B) { } b.ReportAllocs() - b.ResetTimer() - for n := 0; n < b.N; n++ { + for b.Loop() { b.StopTimer() jw.setDirty(tags) for _, rq := range reqs { @@ -3387,14 +3382,14 @@ func BenchmarkRequestWantMessage(b *testing.B) { b.Run("single-tag", func(b *testing.B) { msg := wire.Message{Dest: hit, What: what.Update} b.ReportAllocs() - for n := 0; n < b.N; n++ { + for b.Loop() { _ = rq.wantMessage(&msg) } }) b.Run("multi-tag", func(b *testing.B) { msg := wire.Message{Dest: []any{tag.Tag("nope"), hit}, What: what.Update} b.ReportAllocs() - for n := 0; n < b.N; n++ { + for b.Loop() { _ = rq.wantMessage(&msg) } }) @@ -3409,7 +3404,7 @@ func BenchmarkGetSendMsgs(b *testing.B) { b.Run("idle/elems="+strconv.Itoa(n), func(b *testing.B) { rq := newBenchRequest(b, n) b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { _ = rq.getSendMsgs() } }) @@ -3427,13 +3422,13 @@ func BenchmarkGetElementByJid(b *testing.B) { miss := Jid(n + 1) b.Run("last/elems="+strconv.Itoa(n), func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { _ = rq.getElementByJidLocked(last) } }) b.Run("miss/elems="+strconv.Itoa(n), func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { _ = rq.getElementByJidLocked(miss) } }) @@ -3460,8 +3455,7 @@ func BenchmarkRequestHandleBroadcastCall(b *testing.B) { } msg := wire.Message{Dest: tc.dest, What: what.Call, Data: `app.refresh={"source":"server"}`} b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { rq.handleBroadcast(msg, nil) rq.muQueue.Lock() rq.wsQueue = rq.wsQueue[:0] @@ -3515,8 +3509,7 @@ func BenchmarkRequestEventDispatch(b *testing.B) { var err error b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { err = rq.callAllEventHandlers(elem.Jid(), what.Input, "value") } if err != nil { @@ -3545,8 +3538,7 @@ func BenchmarkRequestEventDispatch(b *testing.B) { var err error b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { err = rq.callAllEventHandlers(0, what.Click, clickValue) } if err != nil { @@ -3566,7 +3558,7 @@ func BenchmarkAppendJSQuote(b *testing.B) { b.Run(c.name, func(b *testing.B) { buf := make([]byte, 0, 256) b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { buf = appendJSQuote(buf[:0], c.s) } _ = buf @@ -3598,8 +3590,7 @@ func BenchmarkSendQueue(b *testing.B) { } ch := make(chan wire.WsMsg, k) b.ReportAllocs() - b.ResetTimer() - for n := 0; n < b.N; n++ { + for b.Loop() { rq.muQueue.Lock() rq.wsQueue = append(rq.wsQueue[:0], msgs...) rq.muQueue.Unlock() @@ -3627,7 +3618,7 @@ func BenchmarkDistributeDirtSort(b *testing.B) { } b.Run("tags="+strconv.Itoa(n), func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { benchDirtSink = sortedDirtTags(m) } }) diff --git a/lib/assets/js_benchmark_test.go b/lib/assets/js_benchmark_test.go index 3ff934a6..c1029678 100644 --- a/lib/assets/js_benchmark_test.go +++ b/lib/assets/js_benchmark_test.go @@ -51,6 +51,8 @@ for (let i = 0; i < count; i++) { } ` + // Keep b.N here so Node executes the calibrated message count in one process; + // starting a process per B.Loop iteration would measure process startup instead. cmd := exec.CommandContext(b.Context(), node, "-e", script, jsPath, strconv.Itoa(b.N)) b.SetBytes(int64(len(frame))) b.ResetTimer() diff --git a/lib/key/key_benchmark_test.go b/lib/key/key_benchmark_test.go index 6e12aaac..051233f2 100644 --- a/lib/key/key_benchmark_test.go +++ b/lib/key/key_benchmark_test.go @@ -12,7 +12,7 @@ var appendBenchSink []byte // allocation-light when appending into an existing buffer. func BenchmarkAppend(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { appendBenchSink = key.Append(appendBenchSink[:0], key.Key(0x1234abcd)) } } diff --git a/lib/tag/tag_benchmark_test.go b/lib/tag/tag_benchmark_test.go index 95b0d43d..3acfc292 100644 --- a/lib/tag/tag_benchmark_test.go +++ b/lib/tag/tag_benchmark_test.go @@ -49,8 +49,7 @@ var tagExpandBenchSink []any func benchmarkTagExpandCase(b *testing.B, tag any) { b.Helper() b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { got, err := TagExpand(tag) if err != nil { b.Fatal(err) diff --git a/lib/ui/container_nested_reorder_benchmark_test.go b/lib/ui/container_nested_reorder_benchmark_test.go index af256ba1..4014aace 100644 --- a/lib/ui/container_nested_reorder_benchmark_test.go +++ b/lib/ui/container_nested_reorder_benchmark_test.go @@ -36,6 +36,8 @@ func BenchmarkNestedContainersUpdate(b *testing.B) { b.ReportAllocs() b.ResetTimer() + // Keep b.N because validation crosses iterations with the timer stopped; + // B.Loop requires its condition to run while the timer is enabled. for i := range b.N { if benchmark.reorder { tree.reverse() diff --git a/lib/ui/container_reuse_benchmark_test.go b/lib/ui/container_reuse_benchmark_test.go index df63f68f..6e03de49 100644 --- a/lib/ui/container_reuse_benchmark_test.go +++ b/lib/ui/container_reuse_benchmark_test.go @@ -65,6 +65,7 @@ func benchReuseRequest(b *testing.B) (*jaws.Jaws, *jaws.Request) { func BenchmarkContainerOfTemplatesUpdate(b *testing.B) { b.ReportAllocs() const rows = 200 + // Keep b.N because fixture cleanup leaves the timer stopped between iterations. for range b.N { b.StopTimer() jw, rq := benchReuseRequest(b) @@ -91,6 +92,7 @@ func BenchmarkContainerOfTemplatesUpdate(b *testing.B) { func BenchmarkContainerOfStableChildrenUpdate(b *testing.B) { b.ReportAllocs() const rows = 200 + // Keep b.N because fixture cleanup leaves the timer stopped between iterations. for range b.N { b.StopTimer() jw, rq := benchReuseRequest(b) @@ -119,6 +121,7 @@ func BenchmarkContainerInitialRender(b *testing.B) { container := NewContainer("div", bc) b.ReportAllocs() b.ResetTimer() + // Keep b.N to bound batches by the calibrated number of operations. for completed := 0; completed < b.N; { batchSize := min(benchmarkContainerBatchSize, b.N-completed) elems := make([]*jaws.Element, batchSize) @@ -149,7 +152,6 @@ func BenchmarkContainerInitialRender(b *testing.B) { // BenchmarkContainerUnchangedUpdate measures a small reconciliation that reuses every // child and emits no browser mutation. func BenchmarkContainerUnchangedUpdate(b *testing.B) { - b.StopTimer() tr := newReuseRequest(b) bc := &benchStableContainer{contents: benchChildren(0, 4)} container := NewContainer("div", bc) @@ -159,12 +161,9 @@ func BenchmarkContainerUnchangedUpdate(b *testing.B) { } before := benchmarkContainerElements(b, elem, 4) b.ReportAllocs() - b.ResetTimer() - b.StartTimer() - for range b.N { + for b.Loop() { container.JawsUpdate(elem) } - b.StopTimer() after := benchmarkContainerElements(b, elem, 4) for i := range before { if after[i] != before[i] { @@ -192,6 +191,7 @@ func BenchmarkContainerAppendRemoveUpdate(b *testing.B) { b.ReportAllocs() b.ResetTimer() expanded := false + // Keep b.N to bound batches by the calibrated number of operations. for completed := 0; completed < b.N; { batchSize := min(benchmarkContainerBatchSize, b.N-completed) startedExpanded := expanded @@ -227,6 +227,7 @@ func BenchmarkContainerRegisterFirstUpdate(b *testing.B) { container := NewContainer("div", bc) b.ReportAllocs() b.ResetTimer() + // Keep b.N to bound batches by the calibrated number of operations. for completed := 0; completed < b.N; { batchSize := min(benchmarkContainerBatchSize, b.N-completed) elems := make([]*jaws.Element, batchSize) diff --git a/lib/ui/container_test.go b/lib/ui/container_test.go index ca1baf28..d81f5c8f 100644 --- a/lib/ui/container_test.go +++ b/lib/ui/container_test.go @@ -522,8 +522,7 @@ func benchChildren(start, count int) []jaws.UI { func BenchmarkContainerValidateChildren(b *testing.B) { b.ReportAllocs() children := benchChildren(0, 1000) - b.ResetTimer() // exclude the one-time fixture allocation so -benchtime=1x is honest - for range b.N { + for b.Loop() { if _, ok := firstUnusableChild(children); ok { b.Fatal("unexpected unusable child") } @@ -533,6 +532,7 @@ func BenchmarkContainerValidateChildren(b *testing.B) { func BenchmarkContainerUpdateAppendHeavy(b *testing.B) { b.ReportAllocs() const size = 1000 + // Keep b.N because fixture cleanup leaves the timer stopped between iterations. for range b.N { b.StopTimer() jw, rq := benchRequest(b) @@ -553,6 +553,7 @@ func BenchmarkContainerUpdateAppendHeavy(b *testing.B) { func BenchmarkContainerUpdateMixed(b *testing.B) { b.ReportAllocs() const size = 1000 + // Keep b.N because fixture cleanup leaves the timer stopped between iterations. for range b.N { b.StopTimer() jw, rq := benchRequest(b) diff --git a/lib/ui/http_request_benchmark_test.go b/lib/ui/http_request_benchmark_test.go index 3a8bb768..c7c16b4c 100644 --- a/lib/ui/http_request_benchmark_test.go +++ b/lib/ui/http_request_benchmark_test.go @@ -38,8 +38,7 @@ func BenchmarkHTTPPageRenderingByComplexity(b *testing.B) { handler := bc.handler(b, jw) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { req := benchmarkPageRequest() rr := httptest.NewRecorder() @@ -52,7 +51,6 @@ func BenchmarkHTTPPageRenderingByComplexity(b *testing.B) { benchmarkCleanupNoscript(b, jw, req.RemoteAddr, rr.Body.String()) b.StartTimer() } - b.StopTimer() if got := jw.RequestCount(); got != 0 { b.Fatalf("request count after benchmark = %d, want 0", got) diff --git a/lib/ui/jsvar_benchmark_test.go b/lib/ui/jsvar_benchmark_test.go index 21a9a478..bb0cba32 100644 --- a/lib/ui/jsvar_benchmark_test.go +++ b/lib/ui/jsvar_benchmark_test.go @@ -53,10 +53,12 @@ func BenchmarkJsVarSetPathBroadcast(b *testing.B) { b.Run("Serial", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { - if err := jsvar.JawsSetPath(elem, "value", i); err != nil { + value := 0 + for b.Loop() { + if err := jsvar.JawsSetPath(elem, "value", value); err != nil { b.Fatal(err) } + value++ } }) b.Run("Parallel", func(b *testing.B) { @@ -88,10 +90,12 @@ func BenchmarkJsVarPathSetterMutation(b *testing.B) { b.Run("Serial", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { - if err := jsvar.JawsSetPath(elem, "value", i); err != nil { + value := 0 + for b.Loop() { + if err := jsvar.JawsSetPath(elem, "value", value); err != nil { b.Fatal(err) } + value++ } }) b.Run("Parallel", func(b *testing.B) { @@ -137,15 +141,16 @@ func BenchmarkJsVarClientWrite(b *testing.B) { b.Fatal(err) } b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + alternate := false + for b.Loop() { value := `"abcdefghij"` - if i&1 != 0 { + if alternate { value = `"0123456789"` } if err = jsvar.JawsInput(elem, "items.0="+value); err != nil { b.Fatal(err) } + alternate = !alternate } } @@ -160,7 +165,7 @@ func BenchmarkJsVarClientWrite(b *testing.B) { func BenchmarkValidateJsVarName(b *testing.B) { params := []any{"state"} b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { name, err := validateJsVarName(params) if err != nil || name != "state" { b.Fatalf("validateJsVarName() = %q, %v", name, err) diff --git a/lib/ui/template_owned_benchmark_test.go b/lib/ui/template_owned_benchmark_test.go index 2e26113b..6b51a847 100644 --- a/lib/ui/template_owned_benchmark_test.go +++ b/lib/ui/template_owned_benchmark_test.go @@ -81,6 +81,7 @@ func BenchmarkTemplateUpdateOwnedCleanup(b *testing.B) { func benchmarkTemplateUpdateOwnedCleanup(b *testing.B, nested int) { b.ReportAllocs() + // Keep b.N because fixture cleanup leaves the timer stopped between iterations. for range b.N { b.StopTimer() jw, update := benchOwnedFixture(b, nested) diff --git a/lib/wire/wsmsg_benchmark_test.go b/lib/wire/wsmsg_benchmark_test.go index 20791c4c..4623ab26 100644 --- a/lib/wire/wsmsg_benchmark_test.go +++ b/lib/wire/wsmsg_benchmark_test.go @@ -20,7 +20,7 @@ func BenchmarkAppend(b *testing.B) { What: what.Alert, } b.ReportAllocs() - for i := 0; i < b.N; i++ { + for b.Loop() { appendBenchSink = m.Append(nil) } } @@ -44,8 +44,7 @@ func BenchmarkParse(b *testing.B) { for _, f := range frames { b.Run(f.name, func(b *testing.B) { b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { + for b.Loop() { parseBenchSink, _ = Parse(f.frame) } }) From 3d2688850f50ba001c249173bf4cf8ea5c618548 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 07:13:46 +0200 Subject: [PATCH 2/3] chore: restart GitHub Actions From 697ee1d344ff5ad4d105f7b4cc1c86aa0db7ebe2 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 08:47:06 +0200 Subject: [PATCH 3/3] test: prewarm key append benchmark buffer --- lib/key/key_benchmark_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/key/key_benchmark_test.go b/lib/key/key_benchmark_test.go index 051233f2..8f836303 100644 --- a/lib/key/key_benchmark_test.go +++ b/lib/key/key_benchmark_test.go @@ -11,8 +11,12 @@ var appendBenchSink []byte // BenchmarkAppend guards the base-32 encode hot path; it must stay // allocation-light when appending into an existing buffer. func BenchmarkAppend(b *testing.B) { + const benchmarkKey key.Key = 0x1234abcd b.ReportAllocs() + // Seed the backing array before B.Loop resets the timer so every measured + // append uses an existing buffer, including fixed-iteration runs. + appendBenchSink = key.Append(appendBenchSink[:0], benchmarkKey) for b.Loop() { - appendBenchSink = key.Append(appendBenchSink[:0], key.Key(0x1234abcd)) + appendBenchSink = key.Append(appendBenchSink[:0], benchmarkKey) } }