perf: reduce allocations on Option/Result/Either hot paths#110
Conversation
Cover Option, Result, Either/Either3-5, Future, Task, IO, State and Do: constructors, accessors/transformations, JSON and binary codecs, SQL Scan. Benchmarks use classic b.N loops to stay compatible with go1.18 CI builds. https://claude.ai/code/session_01CkPtmgw2vWn8vUJm1JERhg
bytes.ToLower allocates a full copy of the input on every call, even for
large value payloads that are only compared against "null". bytes.EqualFold
performs the same case-insensitive comparison without allocating.
benchstat (go1.24, count=6):
│ before │ after │
OptionUnmarshalJSON/Null-4 ns/op 29.91 8.09 -72.93%
OptionUnmarshalJSON/Null-4 allocs/op 1 0 -100.00%
OptionUnmarshalJSON/Small-4 B/op 176 160 -9.09%
OptionUnmarshalJSON/Large-4 B/op 2320 1168 -49.66%
https://claude.ai/code/session_01CkPtmgw2vWn8vUJm1JERhg
json.Marshal(nil) runs the full encoder machinery (encoder cache lookup,
encode state) just to produce the constant "null". Return the literal
directly. A fresh slice is returned on each call since callers may mutate
the result.
benchstat (go1.24, count=6):
│ before │ after │
OptionMarshalJSON/None-4 ns/op 55.39 12.00 -78.34%
OptionMarshalJSON/None-4 B/op 8 4 -50.00%
https://claude.ai/code/session_01CkPtmgw2vWn8vUJm1JERhg
Encoding a map[string]any allocates the map, boxes every value in an
interface and sorts keys inside the encoder. Anonymous structs with json
tags produce the exact same bytes ({"result":...} / {"error":{"message":...}})
without any of that work.
benchstat (go1.24, count=6):
│ before │ after │
ResultMarshalJSON/Ok-4 ns/op 576.6 175.2 -69.63%
ResultMarshalJSON/Ok-4 B/op 456 40 -91.23%
ResultMarshalJSON/Ok-4 allocs 7 2 -71.43%
ResultMarshalJSON/Err-4 ns/op 1037.0 203.4 -80.39%
ResultMarshalJSON/Err-4 B/op 880 48 -94.55%
ResultMarshalJSON/Err-4 allocs 12 2 -83.33%
https://claude.ai/code/session_01CkPtmgw2vWn8vUJm1JERhg
MarshalBinary used to prepend the discriminator byte with
append([]byte{tag}, buf.Bytes()...), copying the whole gob payload into a
second slice. Writing the tag byte into the buffer before encoding removes
that copy and its allocation; the cost scales with payload size.
benchstat (go1.24, count=6, small payloads):
│ before │ after │
OptionBinary/MarshalBinary-4 ns/op 851.7 808.5 -5.08%
OptionBinary/MarshalBinary-4 allocs 15 13 -13.33%
EitherBinary/MarshalBinary-4 ns/op 812.0 748.4 -7.83%
EitherBinary/MarshalBinary-4 allocs 13 11 -15.38%
Either3/4/5 MarshalBinary allocs 13 11 -15.38%
https://claude.ai/code/session_01CkPtmgw2vWn8vUJm1JERhg
reflect.DeepEqual is 50-200x slower than a typed comparison. For scalar
kinds (bool, ints, uints, floats, complex, string) == is strictly
equivalent to DeepEqual, so compare through interface equality instead.
Pointers, structs, arrays, maps, slices and interfaces keep DeepEqual
semantics (DeepEqual follows pointers, == does not).
benchstat (go1.24, count=6):
│ before │ after │
OptionEqual/Int-4 ns/op 26.41 6.24 -76.36%
OptionEqual/String-4 ns/op 77.49 8.18 -89.44%
OptionEqual/String-4 allocs 2 0 -100.00%
OptionEqual/Struct-4 ns/op 117.6 119.8 ~ (p=0.167)
https://claude.ai/code/session_01CkPtmgw2vWn8vUJm1JERhg
There was a problem hiding this comment.
Pull request overview
This PR optimizes performance-critical paths in Option, Result, and Either by reducing allocations in JSON/binary serialization and speeding up Option.Equal, and adds a new benchmark suite under bench/ to measure the impact.
Changes:
- Optimize
Result.MarshalJSONto marshal via dedicated structs instead ofmap[string]any. - Optimize
OptionJSON handling (MarshalJSONfast-path forNone,UnmarshalJSONnulldetection viabytes.EqualFold) and add a scalar-kind fast path forOption.Equal. - Reduce allocations in
Option/Either/Either3-5MarshalBinaryby writing the discriminant byte directly into thebytes.Bufferbefore gob encoding; add benchmarks covering these hot paths.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| result.go | Switch Result.MarshalJSON from map-based encoding to struct-based encoding to reduce allocations. |
| option.go | Add None JSON fast-path, allocation-reducing null check in UnmarshalJSON, discriminant write optimization for MarshalBinary, and scalar fast-path in Equal. |
| either.go | Write discriminant byte directly into buffer before gob payload to avoid post-encode copy. |
| either3.go | Write argId discriminant into buffer up front in MarshalBinary to avoid append copy. |
| either4.go | Same discriminant-write optimization for Either4.MarshalBinary. |
| either5.go | Same discriminant-write optimization for Either5.MarshalBinary. |
| bench/result_bench_test.go | Add benchmarks for Result constructors/accessors and JSON marshal/unmarshal. |
| bench/option_bench_test.go | Add benchmarks for Option constructors/accessors, JSON marshal/unmarshal, binary marshal/unmarshal, IsZero, Equal, and Scan. |
| bench/either_bench_test.go | Add benchmarks for Either constructors/accessors and binary marshal/unmarshal (including Either3-5 marshal). |
| bench/io_state_bench_test.go | Add benchmarks for IO, State, and Do hot paths. |
| bench/future_bench_test.go | Add benchmarks for Future/Task creation and chaining/collection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #110 +/- ##
==========================================
+ Coverage 85.21% 85.33% +0.12%
==========================================
Files 28 28
Lines 2192 2210 +18
==========================================
+ Hits 1868 1886 +18
Misses 271 271
Partials 53 53
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Summary
Reduces allocations and CPU time on serialization and comparison hot paths, verified with a new benchmark suite (in
bench/):Option.UnmarshalJSON: check fornullby comparing bytes directly instead of copying the payload into a trimmed buffer first.Option.MarshalJSON: fast path forNone— return the constantnullbytes without invokingjson.Marshal.ResultJSON marshaling: marshal via dedicated structs instead ofmap[string]any, eliminating map allocation and interface boxing.Option/Either/Either3-5MarshalBinary: write the discriminant byte into thebytes.Bufferup front so the gob payload no longer needs to be copied withappendafterwards.Option.Equal: fast path for comparable scalar kinds before falling back toreflect.DeepEqual.Benchmarks
benchstatcomparingmastervs this branch (-count=10, linux/amd64, Intel Xeon @ 2.80GHz):Allocation highlights:
Note:
OptionEqual/Structshows a small +4.7% regression from the kind check before thereflect.DeepEqualfallback; the scalar fast paths (-72% to -88%) seemed worth the trade-off.Wire formats (JSON and binary) are unchanged — all existing marshal/unmarshal round-trip tests pass, and the full suite is green with
-race.https://claude.ai/code/session_01CkPtmgw2vWn8vUJm1JERhg
Generated by Claude Code