Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ var (

// FromOutgoingContextRaw returns the un-merged, intermediary contents of
// metadata.rawMD.
FromOutgoingContextRaw any // func(context.Context) (metadata.MD, [][]string, bool)
FromOutgoingContextRaw any // func(context.Context) (metadata.MD, iter.Seq2[string, string], bool)

// UserSetDefaultScheme is set to true if the user has overridden the
// default resolver scheme.
Expand Down
20 changes: 7 additions & 13 deletions internal/transport/http2_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"context"
"fmt"
"io"
"iter"
"math"
"net"
"net/http"
Expand Down Expand Up @@ -65,7 +66,7 @@ var clientConnectionCounter uint64

var goAwayLoopyWriterTimeout = 5 * time.Second

var metadataFromOutgoingContextRaw = internal.FromOutgoingContextRaw.(func(context.Context) (metadata.MD, [][]string, bool))
var metadataFromOutgoingContextRaw = internal.FromOutgoingContextRaw.(func(context.Context) (metadata.MD, iter.Seq2[string, string], bool))

// http2Client implements the ClientTransport interface with HTTP2.
type http2Client struct {
Expand Down Expand Up @@ -621,7 +622,6 @@ func (t *http2Client) createHeaderFields(ctx context.Context, callHdr *CallHdr)
}

if md, added, ok := metadataFromOutgoingContextRaw(ctx); ok {
var k string
for k, vv := range md {
// HTTP doesn't allow you to set pseudoheaders after non pseudoheaders were set.
if isReservedHeader(k) {
Expand All @@ -631,18 +631,12 @@ func (t *http2Client) createHeaderFields(ctx context.Context, callHdr *CallHdr)
headerFields = append(headerFields, hpack.HeaderField{Name: k, Value: encodeMetadataHeader(k, v)})
}
}
for _, vv := range added {
for i, v := range vv {
if i%2 == 0 {
k = strings.ToLower(v)
continue
}
// HTTP doesn't allow you to set pseudoheaders after non pseudoheaders were set.
if isReservedHeader(k) {
continue
}
headerFields = append(headerFields, hpack.HeaderField{Name: k, Value: encodeMetadataHeader(k, v)})
for k, v := range added {
// HTTP doesn't allow you to set pseudoheaders after non pseudoheaders were set.
if isReservedHeader(k) {
continue
}
headerFields = append(headerFields, hpack.HeaderField{Name: k, Value: encodeMetadataHeader(k, v)})
}
}
for k, vv := range t.md {
Expand Down
94 changes: 65 additions & 29 deletions metadata/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ package metadata // import "google.golang.org/grpc/metadata"
import (
"context"
"fmt"
"iter"
"strings"

"google.golang.org/grpc/internal"
Expand Down Expand Up @@ -181,14 +182,15 @@ func AppendToOutgoingContext(ctx context.Context, kv ...string) context.Context
panic(fmt.Sprintf("metadata: AppendToOutgoingContext got an odd number of input pairs for metadata: %d", len(kv)))
}
md, _ := ctx.Value(mdOutgoingKey{}).(rawMD)
added := make([][]string, len(md.added)+1)
copy(added, md.added)
kvCopy := make([]string, 0, len(kv))
kvCopy := make([]string, len(kv))
for i := 0; i < len(kv); i += 2 {
kvCopy = append(kvCopy, strings.ToLower(kv[i]), kv[i+1])
kvCopy[i] = strings.ToLower(kv[i])
kvCopy[i+1] = kv[i+1]
}
added[len(added)-1] = kvCopy
return context.WithValue(ctx, mdOutgoingKey{}, rawMD{md: md.md, added: added})
return context.WithValue(ctx, mdOutgoingKey{}, rawMD{
md: md.md,
added: &deltaKV{kv: kvCopy, prev: md.added},
})
}

// FromIncomingContext returns the incoming metadata in ctx if it exists.
Expand Down Expand Up @@ -241,19 +243,44 @@ func copyOf(v []string) []string {

// fromOutgoingContextRaw returns the un-merged, intermediary contents of rawMD.
//
// Remember to perform strings.ToLower on the keys, for both the returned MD (MD
// is a map, there's no guarantee it's created using our helper functions) and
// the extra kv pairs (AppendToOutgoingContext doesn't turn them into
// lowercase).
func fromOutgoingContextRaw(ctx context.Context) (MD, [][]string, bool) {
// Remember to perform strings.ToLower on the keys in the returned MD (MD is a
// map, there's no guarantee it's created using our helper functions).
// Keys yielded by the iterator are already lowercase as AppendToOutgoingContext
// normalizes them.
func fromOutgoingContextRaw(ctx context.Context) (MD, iter.Seq2[string, string], bool) {
raw, ok := ctx.Value(mdOutgoingKey{}).(rawMD)
if !ok {
return nil, nil, false
return nil, emptySeq2, false
}

return raw.md, raw.added, true
if raw.added == nil {
return raw.md, emptySeq2, true
}
// 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
Comment on lines +258 to +277

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

}

// emptySeq2 is a no-op iterator returned when there are no appended key-value
// pairs, avoiding nil-function panics at call sites that unconditionally range.
var emptySeq2 iter.Seq2[string, string] = func(_ func(string, string) bool) {}

// FromOutgoingContext returns the outgoing metadata in ctx if it exists.
//
// All keys in the returned MD are lowercase.
Expand All @@ -263,33 +290,42 @@ func FromOutgoingContext(ctx context.Context) (MD, bool) {
return nil, false
}

mdSize := len(raw.md)
for i := range raw.added {
mdSize += len(raw.added[i]) / 2
}

out := make(MD, mdSize)
out := make(MD, len(raw.md))
for k, v := range raw.md {
// We need to manually convert all keys to lower case, because MD is a
// map, and there's no guarantee that the MD attached to the context is
// created using our helper functions.
key := strings.ToLower(k)
out[key] = copyOf(v)
}
for _, added := range raw.added {
if len(added)%2 == 1 {
panic(fmt.Sprintf("metadata: FromOutgoingContext got an odd number of input pairs for metadata: %d", len(added)))
}

for i := 0; i < len(added); i += 2 {
key := strings.ToLower(added[i])
out[key] = append(out[key], added[i+1])
// Merge appended kv pairs in FIFO order. Collect nodes newest-first,
// then replay oldest-first so later appends override earlier ones.
n := 0
for d := raw.added; d != nil; d = d.prev {
n++
}
nodes := make([]*deltaKV, 0, n)
Comment on lines +303 to +307

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)
	}

for d := raw.added; d != nil; d = d.prev {
nodes = append(nodes, d)
}
for i := len(nodes) - 1; i >= 0; i-- {
kv := nodes[i].kv
for j := 0; j < len(kv); j += 2 {
out[kv[j]] = append(out[kv[j]], kv[j+1])
}
}
return out, ok
}

// deltaKV is a node in a singly-linked list of key-value slices appended
// via AppendToOutgoingContext. The list is newest-first: each node's prev
// field points to the older delta. Keys in kv are already lowercased.
type deltaKV struct {
kv []string
prev *deltaKV
}

type rawMD struct {
md MD
added [][]string
added *deltaKV
}
57 changes: 43 additions & 14 deletions metadata/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,26 +356,55 @@ func Benchmark_AddingMetadata_ContextManipulationApproach(b *testing.B) {
}
}

// Newer/faster approach to adding metadata to context
// BenchmarkAppendToOutgoingContext measures the cost of N sequential
// AppendToOutgoingContext calls on a fresh context each iteration.
func BenchmarkAppendToOutgoingContext(b *testing.B) {
const num = 10
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
for n := 0; n < b.N; n++ {
for i := 0; i < num; i++ {
ctx = AppendToOutgoingContext(ctx, "k1", "v1", "k2", "v2")
}
for _, n := range []int{1, 5, 10, 50} {
b.Run(strconv.Itoa(n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
ctx := context.Background()
for i := 0; i < n; i++ {
ctx = AppendToOutgoingContext(ctx, "k1", "v1", "k2", "v2")
}
}
})
}
}

// BenchmarkFromOutgoingContext measures the read path after N appends.
func BenchmarkFromOutgoingContext(b *testing.B) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
ctx = NewOutgoingContext(ctx, MD{"k3": {"v3", "v4"}})
ctx = AppendToOutgoingContext(ctx, "k1", "v1", "k2", "v2")
for _, n := range []int{1, 5, 10, 50} {
b.Run(strconv.Itoa(n), func(b *testing.B) {
b.ReportAllocs()
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))
}
for b.Loop() {
FromOutgoingContext(ctx)
}
})
}
}

for n := 0; n < b.N; n++ {
FromOutgoingContext(ctx)
// BenchmarkFromOutgoingContextRaw measures the raw iterator path after N appends.
func BenchmarkFromOutgoingContextRaw(b *testing.B) {
for _, n := range []int{1, 5, 10, 50} {
b.Run(strconv.Itoa(n), func(b *testing.B) {
b.ReportAllocs()
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))
}
for b.Loop() {
_, added, _ := fromOutgoingContextRaw(ctx)
// Drain the lazy iterator so its work is actually measured;
// b.Loop() keeps the compiler from eliminating it.
for range added {
}
}
})
}
}

Expand Down
11 changes: 5 additions & 6 deletions stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"math"
rand "math/rand/v2"
"strconv"
Expand Down Expand Up @@ -50,7 +51,7 @@ import (
"google.golang.org/grpc/status"
)

var metadataFromOutgoingContextRaw = internal.FromOutgoingContextRaw.(func(context.Context) (metadata.MD, [][]string, bool))
var metadataFromOutgoingContextRaw = internal.FromOutgoingContextRaw.(func(context.Context) (metadata.MD, iter.Seq2[string, string], bool))

// StreamHandler defines the handler called by gRPC server to complete the
// execution of a streaming RPC. srv is the service implementation on which the
Expand Down Expand Up @@ -227,11 +228,9 @@ func newClientStream(ctx context.Context, desc *StreamDesc, cc *ClientConn, meth
return nil, status.Error(codes.Internal, err.Error())
}
// validate added
for _, kvs := range added {
for i := 0; i < len(kvs); i += 2 {
if err := imetadata.ValidatePair(kvs[i], kvs[i+1]); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
for k, v := range added {
if err := imetadata.ValidatePair(k, v); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
}
}
Expand Down
Loading