Skip to content

encoding: make CallOption a concrete struct to avoid interface boxing - #2538

Open
geekswaroop wants to merge 2 commits into
yarpc:mainfrom
geekswaroop:interface-boxing-with-headers
Open

encoding: make CallOption a concrete struct to avoid interface boxing#2538
geekswaroop wants to merge 2 commits into
yarpc:mainfrom
geekswaroop:interface-boxing-with-headers

Conversation

@geekswaroop

Copy link
Copy Markdown
Contributor

Description

encoding.CallOption wrapped an unexported callOption interface, so WithHeader and the other With* constructors boxed a small concrete value into that interface on every call (runtime.convT). Across Uber's production Go fleet, WithHeader was a hotspot for boxing.

This replaces the interface with a tagged value struct that carries the option data inline and dispatches in a single apply method. The public API is unchanged and the struct's fields are unexported and only the With* helpers construct it. So this is an internal representation swap with no caller impact.

Test Plan

  • Existing api/encoding unit tests pass, including TestCallOptionsReflectEquals,
    which guards the reflect.DeepEqual equality that gomock argument matching relies on.
  • go build ./..., go vet ./..., gofmt, goimports, staticcheck, golint: all clean.
  • Full suite go test ./...: green.
  • Added call_option_bench_test.go.
 % benchstat before.txt after.txt
goos: linux
goarch: amd64
pkg: go.uber.org/yarpc/api/encoding
cpu: AMD EPYC 7B13
                          │  before.txt  │               after.txt               │
                          │    sec/op    │    sec/op     vs base                 │
WithHeader-96                53.62n ± 2%    10.23n ± 1%   -80.92% (p=0.000 n=10)
WithShardKey-96             0.6454n ± 6%   2.2050n ± 1%  +241.65% (p=0.000 n=10)
NewOutboundCallHeaders-96    522.7n ± 3%    375.5n ± 3%   -28.15% (p=0.000 n=10)
NewOutboundCallMixed-96      291.7n ± 4%    269.7n ± 3%    -7.54% (p=0.000 n=10)
geomean                      47.93n         38.88n        -18.88%

                          │  before.txt  │                after.txt                │
                          │     B/op     │    B/op     vs base                     │
WithHeader-96               32.00 ± 0%      0.00 ± 0%  -100.00% (p=0.000 n=10)
WithShardKey-96             0.000 ± 0%     0.000 ± 0%         ~ (p=1.000 n=10) ¹
NewOutboundCallHeaders-96   384.0 ± 0%     288.0 ± 0%   -25.00% (p=0.000 n=10)
NewOutboundCallMixed-96     176.0 ± 0%     144.0 ± 0%   -18.18% (p=0.000 n=10)
geomean                                ²               ?                       ² ³
¹ all samples are equal
² summaries must be >0 to compute geomean
³ ratios must be >0 to compute geomean

                          │  before.txt  │                after.txt                │
                          │  allocs/op   │ allocs/op   vs base                     │
WithHeader-96               1.000 ± 0%     0.000 ± 0%  -100.00% (p=0.000 n=10)
WithShardKey-96             0.000 ± 0%     0.000 ± 0%         ~ (p=1.000 n=10) ¹
NewOutboundCallHeaders-96   7.000 ± 0%     4.000 ± 0%   -42.86% (p=0.000 n=10)
NewOutboundCallMixed-96     6.000 ± 0%     5.000 ± 0%   -16.67% (p=0.000 n=10)
geomean                                ²               ?                       ² ³
¹ all samples are equal
² summaries must be >0 to compute geomean
³ ratios must be >0 to compute geomean

RELEASE NOTES:
encoding: constructing a CallOption (WithHeader, WithShardKey, WithRoutingKey,
WithRoutingDelegate) no longer allocates an interface box per option.

CallOption wrapped an unexported callOption interface, so WithHeader and
the other With* constructors boxed a small concrete value into that
interface on every call (runtime.convT). WithHeader alone accounts for
~733 CPU-cores across the production Go fleet.

Replace the interface with a tagged value struct that carries the option
data inline and dispatches in a single apply method. The public API is
unchanged: the struct's fields are unexported and only the With* helpers
construct it, so this is an internal representation swap.

Benchmarks on go1.26 / AMD EPYC 7B13 (count=10):

  WithHeader               53.6 ns -> 10.2 ns   (-81%, 32 B/1 alloc -> 0)
  WithShardKey (dynamic)   34.5 ns ->  2.2 ns   (-94%, 16 B/1 alloc -> 0)
  NewOutboundCall (3 hdr)   523 ns ->  375 ns   (-28%, 7 -> 4 allocs)

Value equality via reflect.DeepEqual is preserved, so gomock argument
matching on CallOptions still works.
@geekswaroop
geekswaroop force-pushed the interface-boxing-with-headers branch from 53e78c2 to 3b2867b Compare August 26, 2026 15:02
// Encoding authors should accept yarpc.CallOptions and convert them to
// encoding.CallOptions to use with NewOutboundCall. This will keep the
// API for service authors simple.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, so the old code explicitly follows the https://github.com/uber-go/guide/blob/master/style.md#functional-options.

If we're breaking that we should have a good explanation, let's discuss.

func BenchmarkWithShardKey(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
benchOptionSink = WithShardKey("shard-42")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WithShardKey-96 0.6454n ± 6% 2.2050n ± 1% +241.65% (p=0.000 n=10)

why is this happening? :>

case callOptionTypeRoutingDelegate:
v := o.value
call.routingDelegate = &v
case callOptionTypeResponseHeaders:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well we def need to do something here, right? (default case)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same for unknown?

Comment on lines +26 to +27
benchOptionSink CallOption
benchCallSink *OutboundCall

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:|

// BenchmarkWithHeader measures the core option-construction site.
func BenchmarkWithHeader(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per https://github.com/yarpc/yarpc-go/blob/main/go.mod we're at 1.26 so we definitely can use b.Loop now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants