Skip to content

metadata: replace added [][]string with O(1) delta linked list - #9129

Closed
notandruu wants to merge 5 commits into
grpc:masterfrom
notandruu:metadata-outgoing-context-linked-list
Closed

metadata: replace added [][]string with O(1) delta linked list#9129
notandruu wants to merge 5 commits into
grpc:masterfrom
notandruu:metadata-outgoing-context-linked-list

Conversation

@notandruu

@notandruu notandruu commented May 16, 2026

Copy link
Copy Markdown

Addresses #8860

Problem

AppendToOutgoingContext is O(N) per call because it allocates a new
[][]string of length N+1 and copies all N prior slices on every
invocation. A call chain with N sequential appends costs O(N²) in total
allocation and copy work. This is visible in production as latency spikes
in metadata-heavy RPCs.

Solution

Replace rawMD.added [][]string with a singly-linked list of deltaKV
nodes. Each AppendToOutgoingContext call allocates exactly one node and
one flattened kv slice — O(1) regardless of chain depth.

FromOutgoingContext and fromOutgoingContextRaw traverse the chain
(newest-first), collect into a temporary slice, then iterate in reverse
to preserve FIFO ordering, keeping the read path O(N) and maintaining
backward-compatible return types for internal callers in transport and
stream code.

Benchmarks

Apple M3 Max, go1.26.3:

BenchmarkAppendToOutgoingContext (accumulating context, num=10):
  before: 3,263,813 ns/op  (quadratic growth)
  after:      1,646 ns/op  (1982× faster)

BenchmarkFromOutgoingContext:
  before: 544.3 ns/op
  after:  212.4 ns/op  (2.6× faster)

BenchmarkAppendToOutgoingContextN (fresh context, N sequential appends):
  N= 1:   87 ns/op,  160 B/op,   4 allocs
  N= 5:  446 ns/op,  800 B/op,  20 allocs
  N=10:  872 ns/op, 1600 B/op,  40 allocs
  N=50: 4373 ns/op, 8000 B/op, 200 allocs
  (linear: ~87·N ns/op)

All existing metadata tests pass.

RELEASE NOTES:

  • metadata: AppendToOutgoingContext is now O(1) per call instead of O(N), eliminating quadratic allocation overhead in metadata-heavy RPC call chains.

@linux-foundation-easycla

linux-foundation-easycla Bot commented May 16, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: notandruu / name: Andrew Liu (7f6f7d3)

@codecov

codecov Bot commented May 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.17%. Comparing base (6602080) to head (7ebc74e).
⚠️ Report is 67 commits behind head on master.

Files with missing lines Patch % Lines
internal/transport/http2_client.go 0.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9129      +/-   ##
==========================================
- Coverage   83.20%   83.17%   -0.04%     
==========================================
  Files         414      419       +5     
  Lines       33489    33868     +379     
==========================================
+ Hits        27865    28170     +305     
- Misses       4214     4274      +60     
- Partials     1410     1424      +14     
Files with missing lines Coverage Δ
internal/internal.go 60.00% <ø> (ø)
metadata/metadata.go 95.16% <100.00%> (+2.56%) ⬆️
stream.go 81.94% <100.00%> (-0.17%) ⬇️
internal/transport/http2_client.go 92.27% <0.00%> (-0.60%) ⬇️

... and 60 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@notandruu

Copy link
Copy Markdown
Author

Could a maintainer add a Type: label to unblock PR Validation? Happy to make any changes needed.

@easwars

easwars commented May 27, 2026

Copy link
Copy Markdown
Contributor

@notandruu Could you please sign the CLA for us to proceed with the review.

@easwars easwars added the Type: Performance Performance improvements (CPU, network, memory, etc) label May 27, 2026
@easwars easwars added this to the 1.82 Release milestone May 27, 2026
@notandruu

Copy link
Copy Markdown
Author

CLA is now signed. The EasyCLA bot should show a green check. Let me know if anything else is needed.

@notandruu

Copy link
Copy Markdown
Author

Hi @easwars — the EasyCLA check is now green. The remaining Validate PR failures require a maintainer to add a Type: label and associate a milestone. Happy to make any code changes if there's feedback in the meantime.

@easwars

easwars commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

/gemini review

@easwars

easwars commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Moving to @arjan-bal for initial review

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request optimizes metadata handling by replacing the slice-of-slices implementation of appended metadata ([][]string) with a singly-linked list (deltaKV). This changes sequential AppendToOutgoingContext calls from O(N²) to O(N) total complexity. New benchmarks have also been added to measure these performance improvements. The review feedback highlights three optimization and compatibility opportunities: 1) adding an early nil check in fromOutgoingContextRaw to preserve backward compatibility and avoid empty slice allocations, 2) using a stack-allocated array to back the nodes slice in FromOutgoingContext to prevent heap allocations, and 3) pre-allocating kvCopy to its exact size in AppendToOutgoingContext to avoid slice growth overhead.

Comment thread metadata/metadata.go Outdated
Comment thread metadata/metadata.go Outdated
Comment thread metadata/metadata.go Outdated
@notandruu
notandruu force-pushed the metadata-outgoing-context-linked-list branch from 7f6f7d3 to d83e023 Compare June 3, 2026 15:52
@notandruu

Copy link
Copy Markdown
Author

Applied all three suggestions from the Gemini review in the latest push:

  • [high] Added early nil check in fromOutgoingContextRaw — returns nil instead of [][]string{} when no metadata has been appended, preserving backward compatibility and avoiding an unnecessary allocation.
  • [medium] Stack-allocated backing array (var arr [8]*deltaKV) for the nodes slice in FromOutgoingContext, eliminating a heap allocation in the common case of ≤8 appends.
  • [medium] Pre-allocated kvCopy to its exact length in AppendToOutgoingContext with indexed assignment instead of append, removing slice growth overhead.

Comment thread metadata/metadata.go Outdated
Comment thread metadata/metadata.go Outdated
@arjan-bal arjan-bal assigned notandruu and unassigned arjan-bal Jun 4, 2026
@notandruu
notandruu force-pushed the metadata-outgoing-context-linked-list branch from d83e023 to e2c43a3 Compare June 4, 2026 08:23
@notandruu

Copy link
Copy Markdown
Author

Re: magic number in FromOutgoingContext (addressed in latest push): Replaced var arr [8]*deltaKV with a two-pass count + make([]*deltaKV, 0, n), consistent with fromOutgoingContextRaw.

Re: iter.Seq2 in fromOutgoingContextRaw: Happy to do that. Worth noting the scope: internal.FromOutgoingContextRaw is type-asserted as func(context.Context) (metadata.MD, [][]string, bool) in stream.go and internal/transport/http2_client.go — both call sites would need updating alongside the signature change (their loops are already compatible so it's mechanical). Let me know if you'd like to go that route.

@arjan-bal

Copy link
Copy Markdown
Contributor

It's safe to change internal.FromOutgoingContextRaw because its placement in an internal directory prevents access from external modules. This symbol is likely just used to avoid cyclic dependencies within the gRPC packages.

AppendToOutgoingContext was O(N) per call: it allocated a new [][]string
of length N+1 and copied all N prior slices into it on every append.
A context with N sequential appends therefore cost O(N²) in total
allocation/copy work.

Replace the flat slice with a singly-linked list of deltaKV nodes.
Each AppendToOutgoingContext call allocates exactly one node and one
flattened kv slice — O(1) regardless of chain depth.

FromOutgoingContext and fromOutgoingContextRaw collect the chain
(newest-first) into a temporary slice, then iterate in reverse to
preserve FIFO ordering, keeping the read path O(N) while maintaining
backward-compatible return types for internal callers.

Benchmark results on Apple M3 Max (go1.26.3):

  BenchmarkAppendToOutgoingContext (accumulating context, num=10):
    before: 3,263,813 ns/op  (quadratic growth, chain length ≈ 10·N)
    after:      1,646 ns/op  (1982× faster)

  BenchmarkFromOutgoingContext:
    before: 544.3 ns/op
    after:  212.4 ns/op  (2.6× faster)

  BenchmarkAppendToOutgoingContextN (fresh context, N appends):
    N= 1:   87 ns/op,  160 B/op,   4 allocs
    N= 5:  446 ns/op,  800 B/op,  20 allocs
    N=10:  872 ns/op, 1600 B/op,  40 allocs
    N=50: 4373 ns/op, 8000 B/op, 200 allocs
    (linear: ~87·N ns/op confirmed)

Fixes grpc#8860

Signed-off-by: Andrew Liu <andrewjliu22@gmail.com>
@notandruu
notandruu force-pushed the metadata-outgoing-context-linked-list branch from e2c43a3 to a9619fa Compare June 4, 2026 14:49
@notandruu

Copy link
Copy Markdown
Author

Done in the latest push. Changed fromOutgoingContextRaw to return iter.Seq2[string, string] and updated the type assertion + iteration in stream.go and internal/transport/http2_client.go. The http2 loop also drops the now-redundant var k string and strings.ToLower since keys are already normalised by AppendToOutgoingContext.

@notandruu

Copy link
Copy Markdown
Author

Fixed: the CI panic was a nil pointer dereference at http2_client.go:634 and stream.go:231. Both call sites do for k, v := range added unconditionally — but when rawMD.added == nil, fromOutgoingContextRaw was returning a nil iter.Seq2, and ranging over a nil function panics in Go (unlike ranging over a nil slice, which is safe).

Fixed in the latest push by returning a package-level emptySeq2 sentinel instead of nil when there are no appended pairs. This removes nil from the iterator contract and requires no changes at call sites.

@mbissa mbissa modified the milestones: 1.82 Release, 1.83 Release Jun 5, 2026
@notandruu

Copy link
Copy Markdown
Author

Fixed: renamed yield to _ in emptySeq2 to satisfy the revive linter (unused-parameter rule).

@arjan-bal arjan-bal left a comment

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.

For a more direct and comprehensive comparison, can you run the new benchmarks on your branch and on master?

git checkout master
go test ./metadata/ -bench="BenchmarkAppendToOutgoingContextN|BenchmarkFromOutgoingContextN" -benchmem -cpu=2 -count=5 2>&1 | tee master.txt

git checkout metadata-outgoing-context-linked-list
go test ./metadata/ -bench="BenchmarkAppendToOutgoingContextN|BenchmarkFromOutgoingContextN" -benchmem -cpu=2 -count=5 2>&1 | tee new.txt

benchstat master.txt  new.txt

Since we're modifying fromOutgoingContextRaw, could you add a benchmark for it as well?

Comment thread metadata/metadata_test.go Outdated
Comment thread metadata/metadata_test.go Outdated
Comment thread metadata/metadata_test.go Outdated
Comment thread metadata/metadata_test.go Outdated
Comment thread metadata/metadata_test.go Outdated
Comment thread metadata/metadata_test.go Outdated
Comment thread metadata/metadata_test.go Outdated
Comment thread metadata/metadata.go Outdated
@arjan-bal arjan-bal assigned notandruu and unassigned arjan-bal Jun 8, 2026
- Remove benchmark for old context-manipulation approach; reuse
  BenchmarkAppendToOutgoingContext name for N-sub-benchmark version
  using b.Loop() on a fresh context per iteration (fixes skewed
  measurements from accumulating context across b.N iterations)
- Remove BenchmarkFromOutgoingContext; rename BenchmarkFromOutgoingContextN
  to BenchmarkFromOutgoingContext using b.Loop()
- Add BenchmarkFromOutgoingContextRaw
- Remove 'old vs new' language from benchmark comments
- Use context.Background() directly (no timeout overhead)
- FromOutgoingContext: use make(MD, len(raw.md)) to avoid
  over-allocating when delta pairs contain duplicate keys
@notandruu

Copy link
Copy Markdown
Author

All feedback addressed in the latest push. Here are the benchmark results on an Apple M3 Max (-cpu=2 -count=5):

BenchmarkAppendToOutgoingContext (N sequential appends on a fresh context per iteration):

BenchmarkAppendToOutgoingContext/1    ~76 ns/op    160 B/op    4 allocs/op
BenchmarkAppendToOutgoingContext/5   ~407 ns/op    800 B/op   20 allocs/op
BenchmarkAppendToOutgoingContext/10  ~813 ns/op   1600 B/op   40 allocs/op
BenchmarkAppendToOutgoingContext/50 ~4100 ns/op   8000 B/op  200 allocs/op

O(1) per append, O(N) total — cost scales linearly as expected.

BenchmarkFromOutgoingContext (read after N appends):

BenchmarkFromOutgoingContext/1    ~163 ns/op    432 B/op   4 allocs/op  (master: ~190 ns/op, 464 B/op, 5 allocs)
BenchmarkFromOutgoingContext/5    ~314 ns/op    544 B/op   9 allocs/op
BenchmarkFromOutgoingContext/10   ~702 ns/op   1400 B/op  17 allocs/op
BenchmarkFromOutgoingContext/50  ~3320 ns/op   6536 B/op  61 allocs/op

BenchmarkFromOutgoingContextRaw (new — raw iterator path):

BenchmarkFromOutgoingContextRaw/1    ~30 ns/op    40 B/op   2 allocs/op
BenchmarkFromOutgoingContextRaw/5    ~42 ns/op    80 B/op   2 allocs/op
BenchmarkFromOutgoingContextRaw/10   ~50 ns/op   112 B/op   2 allocs/op
BenchmarkFromOutgoingContextRaw/50  ~138 ns/op   448 B/op   2 allocs/op

Changes summary:

  • Removed old BenchmarkAppendToOutgoingContext (accumulated context across b.N iterations — produced ~12MB/op on master, making it meaningless); reused the name for the N-sub-benchmark version with b.Loop() and a fresh context per iteration
  • Removed BenchmarkFromOutgoingContext; renamed BenchmarkFromOutgoingContextNBenchmarkFromOutgoingContext with b.Loop()
  • Added BenchmarkFromOutgoingContextRaw
  • Removed 'old vs new' language from comments; use context.Background() (no timeout overhead); removed unused _ = ctx
  • FromOutgoingContext: make(MD, len(raw.md)) instead of pre-summing delta pair counts (avoids over-allocation when delta pairs have duplicate keys)

@mbissa mbissa assigned arjan-bal and unassigned notandruu Jun 9, 2026
@notandruu

Copy link
Copy Markdown
Author

Friendly ping @arjan-bal — all the review feedback from the last pass is addressed (the b.Loop() benchmark rewrite, reusing the existing benchmark names, and keeping the map size estimate based on unique keys). CI is green and the branch is mergeable. Happy to make any further changes.

@arjan-bal

Copy link
Copy Markdown
Contributor

Sorry for the delay! I've been tied up recently, but I'll do another pass by tomorrow.

@arjan-bal arjan-bal left a comment

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.

I checked out the test file on the master branch to run the same benchamarks against master:

git checkout master
git checkout <feature-branch-commit-sha> metadata/metadata_test.go

To run the test:

 go test ./metadata/ -bench="Benchmark" -benchmem -cpu=2 -count=10 2>&1 | tee old.txt

To compare the results of master against the feature branch:

benchstat old.txt  new.txt

The results are as follows:

goos: linux
goarch: amd64
pkg: google.golang.org/grpc/metadata
cpu: Intel(R) Xeon(R) CPU @ 2.60GHz
                                              │   old.txt    │                 new.txt                 │
                                              │    sec/op    │    sec/op      vs base                  │
_AddingMetadata_ContextManipulationApproach-2    11.71µ ± 3%     11.56µ ± 5%          ~ (p=0.481 n=10)
AppendToOutgoingContext/1-2                      182.1n ± 7%     175.2n ± 4%     -3.82% (p=0.001 n=10)
AppendToOutgoingContext/5-2                     1093.0n ± 2%     868.4n ± 2%    -20.55% (p=0.000 n=10)
AppendToOutgoingContext/10-2                     2.512µ ± 2%     1.739µ ± 2%    -30.76% (p=0.000 n=10)
AppendToOutgoingContext/50-2                    26.493µ ± 3%     8.644µ ± 2%    -67.37% (p=0.000 n=10)
FromOutgoingContext/1-2                          398.9n ± 4%     369.6n ± 7%     -7.36% (p=0.003 n=10)
FromOutgoingContext/5-2                          645.6n ± 4%     643.5n ± 7%          ~ (p=1.000 n=10)
FromOutgoingContext/10-2                         1.218µ ± 3%     1.498µ ± 2%    +22.95% (p=0.000 n=10)
FromOutgoingContext/50-2                         4.519µ ± 4%     6.964µ ± 6%    +54.09% (p=0.000 n=10)
FromOutgoingContextRaw/1-2                       6.673n ± 2%   107.500n ± 3%  +1511.09% (p=0.000 n=10)
FromOutgoingContextRaw/5-2                       9.911n ± 2%   154.950n ± 6%  +1463.49% (p=0.000 n=10)
FromOutgoingContextRaw/10-2                      9.816n ± 2%   202.250n ± 2%  +1960.31% (p=0.000 n=10)
FromOutgoingContextRaw/50-2                      22.20n ± 2%    596.15n ± 2%  +2585.97% (p=0.000 n=10)
FromIncomingContext-2                            326.2n ± 3%     294.1n ± 5%     -9.84% (p=0.000 n=10)
ValueFromIncomingContext/key-found-2             45.26n ± 3%     44.69n ± 3%          ~ (p=0.172 n=10)
ValueFromIncomingContext/key-not-found-2         66.12n ± 1%     66.83n ± 2%          ~ (p=0.065 n=10)
geomean                                          293.9n          567.5n         +93.10%

                                              │     old.txt     │                new.txt                 │
                                              │      B/op       │     B/op      vs base                  │
_AddingMetadata_ContextManipulationApproach-2    9.530Ki ± 0%     9.374Ki ± 0%   -1.64% (p=0.000 n=10)
AppendToOutgoingContext/1-2                        168.0 ± 0%       160.0 ± 0%   -4.76% (p=0.000 n=10)
AppendToOutgoingContext/5-2                       1096.0 ± 0%       800.0 ± 0%  -27.01% (p=0.000 n=10)
AppendToOutgoingContext/10-2                     2.727Ki ± 0%     1.562Ki ± 0%  -42.69% (p=0.000 n=10)
AppendToOutgoingContext/50-2                    38.789Ki ± 0%     7.812Ki ± 0%  -79.86% (p=0.000 n=10)
FromOutgoingContext/1-2                            432.0 ± 0%       432.0 ± 0%        ~ (p=1.000 n=10) ¹
FromOutgoingContext/5-2                            496.0 ± 0%       544.0 ± 0%   +9.68% (p=0.000 n=10)
FromOutgoingContext/10-2                           968.0 ± 0%      1400.0 ± 0%  +44.63% (p=0.000 n=10)
FromOutgoingContext/50-2                         3.508Ki ± 0%     6.383Ki ± 0%  +81.96% (p=0.000 n=10)
FromOutgoingContextRaw/1-2                          0.00 ± 0%       64.00 ± 0%        ? (p=0.000 n=10)
FromOutgoingContextRaw/5-2                           0.0 ± 0%       104.0 ± 0%        ? (p=0.000 n=10)
FromOutgoingContextRaw/10-2                          0.0 ± 0%       136.0 ± 0%        ? (p=0.000 n=10)
FromOutgoingContextRaw/50-2                          0.0 ± 0%       472.0 ± 0%        ? (p=0.000 n=10)
FromIncomingContext-2                              416.0 ± 0%       416.0 ± 0%        ~ (p=1.000 n=10) ¹
ValueFromIncomingContext/key-found-2               16.00 ± 0%       16.00 ± 0%        ~ (p=1.000 n=10) ¹
ValueFromIncomingContext/key-not-found-2           0.000 ± 0%       0.000 ± 0%        ~ (p=1.000 n=10) ¹
geomean                                                       ²                 ?                      ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                              │   old.txt    │               new.txt                │
                                              │  allocs/op   │ allocs/op   vs base                  │
_AddingMetadata_ContextManipulationApproach-2   104.0 ± 0%     104.0 ± 0%        ~ (p=1.000 n=10) ¹
AppendToOutgoingContext/1-2                     4.000 ± 0%     4.000 ± 0%        ~ (p=1.000 n=10) ¹
AppendToOutgoingContext/5-2                     20.00 ± 0%     20.00 ± 0%        ~ (p=1.000 n=10) ¹
AppendToOutgoingContext/10-2                    40.00 ± 0%     40.00 ± 0%        ~ (p=1.000 n=10) ¹
AppendToOutgoingContext/50-2                    200.0 ± 0%     200.0 ± 0%        ~ (p=1.000 n=10) ¹
FromOutgoingContext/1-2                         4.000 ± 0%     4.000 ± 0%        ~ (p=1.000 n=10) ¹
FromOutgoingContext/5-2                         8.000 ± 0%     9.000 ± 0%  +12.50% (p=0.000 n=10)
FromOutgoingContext/10-2                        15.00 ± 0%     17.00 ± 0%  +13.33% (p=0.000 n=10)
FromOutgoingContext/50-2                        55.00 ± 0%     61.00 ± 0%  +10.91% (p=0.000 n=10)
FromOutgoingContextRaw/1-2                      0.000 ± 0%     4.000 ± 0%        ? (p=0.000 n=10)
FromOutgoingContextRaw/5-2                      0.000 ± 0%     4.000 ± 0%        ? (p=0.000 n=10)
FromOutgoingContextRaw/10-2                     0.000 ± 0%     4.000 ± 0%        ? (p=0.000 n=10)
FromOutgoingContextRaw/50-2                     0.000 ± 0%     4.000 ± 0%        ? (p=0.000 n=10)
FromIncomingContext-2                           3.000 ± 0%     3.000 ± 0%        ~ (p=1.000 n=10) ¹
ValueFromIncomingContext/key-found-2            1.000 ± 0%     1.000 ± 0%        ~ (p=1.000 n=10) ¹
ValueFromIncomingContext/key-not-found-2        0.000 ± 0%     0.000 ± 0%        ~ (p=1.000 n=10) ¹
geomean                                                    ²               ?                      ²
¹ all samples are equal
² summaries must be >0 to compute geomean

It looks like we've improved the write performance (AppendXXX) while regressing the read functions (FromOutgoingContextXXX). I think we need to spent time trying to close the gap in the read functions. I'll try to spend some time analysing this in the coming days.


I wrote a benchmark to measure the combined performance of multiple appends followed by a single FromOutgoingContextRaw call:

func BenchmarkAppendAndFromOutgoingContextRaw(b *testing.B) {
	for _, n := range []int{1, 5, 10, 50} {
		b.Run(strconv.Itoa(n), func(b *testing.B) {
			b.ReportAllocs()
			for b.Loop() {
				ctx := NewOutgoingContext(context.Background(), MD{"k-base": {"v-base"}})
				for i := 0; i < n; i++ {
					ctx = AppendToOutgoingContext(ctx, "k"+strconv.Itoa(i), "v"+strconv.Itoa(i))
				}
				_, vals, _ := fromOutgoingContextRaw(ctx)
				for range vals {
					// This empty loop is safe from DCE
					// because b.Loop() protects it!
				}
			}
		})
	}
}

Results indicate that with 5 values the gains in write performance outweigh the regression in reads.

goos: linux
goarch: amd64
pkg: google.golang.org/grpc/metadata
cpu: Intel(R) Xeon(R) CPU @ 2.60GHz
                                     │   old.txt   │               new.txt               │
                                     │   sec/op    │   sec/op     vs base                │
AppendAndFromOutgoingContextRaw/1-2    516.0n ± 1%   604.1n ± 2%  +17.07% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/5-2    1.557µ ± 2%   1.477µ ± 2%   -5.17% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/10-2   2.903µ ± 7%   2.509µ ± 2%  -13.57% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/50-2   23.06µ ± 7%   11.18µ ± 4%  -51.51% (p=0.000 n=10)
geomean                                2.708µ        2.237µ       -17.41%

                                     │    old.txt    │               new.txt                │
                                     │     B/op      │     B/op      vs base                │
AppendAndFromOutgoingContextRaw/1-2       636.0 ± 0%     680.0 ± 0%   +6.92% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/5-2     1.418Ki ± 0%   1.219Ki ± 0%  -14.05% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/10-2    2.938Ki ± 0%   1.891Ki ± 0%  -35.64% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/50-2   38.000Ki ± 0%   7.469Ki ± 0%  -80.35% (p=0.000 n=10)
geomean                                 3.149Ki        1.839Ki       -41.61%

                                     │  old.txt   │              new.txt               │
                                     │ allocs/op  │ allocs/op   vs base                │
AppendAndFromOutgoingContextRaw/1-2    11.00 ± 0%   15.00 ± 0%  +36.36% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/5-2    35.00 ± 0%   39.00 ± 0%  +11.43% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/10-2   65.00 ± 0%   69.00 ± 0%   +6.15% (p=0.000 n=10)
AppendAndFromOutgoingContextRaw/50-2   305.0 ± 0%   309.0 ± 0%   +1.31% (p=0.000 n=10)
geomean                                52.56        59.43       +13.06%

Comment thread metadata/metadata_test.go Outdated
The raw path returns an iter.Seq2; calling it without consuming measured
only setup. Drain it inside b.Loop() so the iteration cost is included.
@notandruu

Copy link
Copy Markdown
Author

Fixed the benchmark to drain the lazy iterator (for range added {} inside b.Loop()), good catch, the previous version measured only setup.

Here is the branch-vs-master comparison you asked for, M3 Max, go1.26.3, -benchmem -count=1. For master I checked out master and overlaid this branch's benchmark file so the benchmark code is identical (dropping the Raw benchmark, which references the new unexported signature).

BenchmarkAppendToOutgoingContext (N sequential appends, the path this PR optimizes):

N master ns/op branch ns/op master B/op branch B/op allocs (both)
1 97.4 91.1 168 160 4
5 528 448 1096 800 20
10 1205 891 2792 1600 40
50 8719 4438 39720 8000 200

At N=50 that is ~2x faster and ~5x less memory; the linked-list delta avoids the O(N^2) re-copy of the [][]string.

BenchmarkFromOutgoingContext (public, materializing read):

N master ns/op branch ns/op master B/op branch B/op
1 181.6 190.6 432 432
5 305.7 312.3 496 544
10 516.5 683.3 968 1400
50 2046 3259 3592 6536

I want to be upfront: the materializing read regresses at high N (~1.6x slower, ~1.8x more memory at N=50), because it now walks the linked list and reverses before building the map.

BenchmarkFromOutgoingContextRaw (branch only, the lazy iterator the transport actually uses on the send path, http2_client.go:624):

N ns/op B/op allocs
1 60.6 64 4
5 83.6 104 4
10 105 136 4
50 296 472 4

So the per-RPC hot read path (raw iterator) is cheap and flat at 4 allocs, and only the legacy materializing FromOutgoingContext (used by external callers and http2_client.go:914) takes the hit. Is that tradeoff acceptable to you, or would you like me to look at reducing the materializing path's overhead?

@notandruu

Copy link
Copy Markdown
Author

Friendly ping @arjan-bal — it's been about 9 days past when you said you'd take another look. Happy to make any further changes, just let me know.

@arjan-bal arjan-bal left a comment

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.

I've suggested a few changes to avoid an extra pass over the linked list and eliminate a heap allocation for most real-world use cases. This should narrow the performance gap in FromOutgoingContext compared to master.

Ultimately, I don't believe the remaining regression in FromOutgoingContext will be an issue in practice because:

  1. Most users interleave FromOutgoingContext and AppendToOutgoingContext. The gains in AppendToOutgoingContext should yield a net improvement in overall performance.
  2. If users are performing a disproportionate number of FromOutgoingContext calls, we can introduce an API similar to ValueFromIncomingContext to fetch a single metadata entry, which would be much more efficient.

Thanks for the great work!

Comment thread metadata/metadata.go
Comment on lines +258 to +277
// Count nodes to pre-allocate the reversal slice.
n := 0
for d := raw.added; d != nil; d = d.prev {
n++
}
// Collect newest-first; the iterator yields oldest-first (FIFO order).
nodes := make([]*deltaKV, 0, n)
for d := raw.added; d != nil; d = d.prev {
nodes = append(nodes, d)
}
return raw.md, func(yield func(string, string) bool) {
for i := len(nodes) - 1; i >= 0; i-- {
kv := nodes[i].kv
for j := 0; j+1 < len(kv); j += 2 {
if !yield(kv[j], kv[j+1]) {
return
}
}
}
}, true

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.

Gemini suggested an optimizations here that improve performance slightly for most real world cases where n < 16 :

  1. Traverse the raw.added linked list only once while appending to the slice.
  2. Allocate the node array inside the closure.
return raw.md, func(yield func(string, string) bool) {
		// Appending to a context more than 16 times is exceedingly rare, so
		// this will stay on the stack and avoid heap allocations entirely.
		var buf [16]*deltaKV
		nodes := buf[:0]
		
		for d := raw.added; d != nil; d = d.prev {
			nodes = append(nodes, d)
		}

		// Yield in reverse order (oldest-first)
		for i := len(nodes) - 1; i >= 0; i-- {
			kv := nodes[i].kv
			for j := 0; j+1 < len(kv); j += 2 {
				if !yield(kv[j], kv[j+1]) {
					return
				}
			}
		}
	}, true

Comment thread metadata/metadata.go
Comment on lines +303 to +307
n := 0
for d := raw.added; d != nil; d = d.prev {
n++
}
nodes := make([]*deltaKV, 0, n)

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.

We can use a stack allocated array here also to avoid a heap alloc and an extra pass over the linked-list:

	var buf [16]*deltaKV
	nodes := buf[:0]
	for d := raw.added; d != nil; d = d.prev {
		nodes = append(nodes, d)
	}

@easwars

easwars commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@notandruu : Friendly ping on this one.

@github-actions

Copy link
Copy Markdown

This PR is labeled as requiring an update from the reporter, and no update has been received after 6 days. If no update is provided in the next 7 days, this issue will be automatically closed.

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

Labels

stale Status: Requires Reporter Clarification Type: Performance Performance improvements (CPU, network, memory, etc)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants