Skip to content
Open
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
8 changes: 2 additions & 6 deletions internal/transport/http2_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,16 +578,12 @@ func (t *http2Client) createHeaderFields(ctx context.Context, callHdr *CallHdr)
// so this may slightly over-count, which is preferable to growing the slice.
md, added, mdOK := metadataFromOutgoingContextRaw(ctx)
if mdOK {
for _, vv := range md {
hfLen += len(vv)
}
hfLen += headerFieldsCountFromMD(md)
for _, vv := range added {
hfLen += len(vv) / 2
}
}
for _, vv := range t.md {
hfLen += len(vv)
}
hfLen += headerFieldsCountFromMD(t.md)
headerFields := make([]hpack.HeaderField, 0, hfLen)
headerFields = append(headerFields, hpack.HeaderField{Name: ":method", Value: "POST"})
headerFields = append(headerFields, hpack.HeaderField{Name: ":scheme", Value: t.scheme})
Expand Down
56 changes: 45 additions & 11 deletions internal/transport/http2_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,21 @@ func appendHeaderFieldsFromMD(headerFields []hpack.HeaderField, md metadata.MD)
return headerFields
}

// headerFieldsCountFromMD returns the total number of header fields that
// appendHeaderFieldsFromMD will produce from md. Reserved headers are dropped
// by appendHeaderFieldsFromMD, so this may slightly over-count, which is
// preferable to growing the slice.
Comment thread
2307vivek marked this conversation as resolved.
// returns an upper bound on the number of header fields that
// appendHeaderFieldsFromMD will produce from md
func headerFieldsCountFromMD(md metadata.MD) int {
n := 0
for _, vv := range md {
n += len(vv)
}

return n
}
Comment thread
2307vivek marked this conversation as resolved.

func (t *http2Server) checkForHeaderListSize(hf []hpack.HeaderField) bool {
if t.maxSendHeaderListSize == nil {
return true
Expand Down Expand Up @@ -1041,9 +1056,14 @@ func (t *http2Server) writeHeader(s *ServerStream, md metadata.MD) error {
}

func (t *http2Server) writeHeaderLocked(s *ServerStream) error {
// TODO(mmukhi): Benchmark if the performance gets better if count the metadata and other header fields
// first and create a slice of that exact size.
headerFields := make([]hpack.HeaderField, 0, 2) // at least :status, content-type will be there if none else.
// Count the metadata header fields as well so the slice is not reallocated
// while they are appended below.
headerFieldCount := 2 // :status, content-type. Keep in sync with the appends below
headerFieldCount += headerFieldsCountFromMD(s.header)
if s.sendCompress != "" {
headerFieldCount++
}
headerFields := make([]hpack.HeaderField, 0, headerFieldCount)
headerFields = append(headerFields, hpack.HeaderField{Name: ":status", Value: "200"})
headerFields = append(headerFields, hpack.HeaderField{Name: "content-type", Value: grpcutil.ContentType(s.contentSubtype)})
if s.sendCompress != "" {
Expand Down Expand Up @@ -1086,11 +1106,28 @@ func (t *http2Server) writeStatus(s *ServerStream, st *status.Status) error {
if s.getState() == streamDone {
return nil
}
p := istatus.RawStatusProto(st)
hasStatusDetails := len(p.GetDetails()) > 0

// TODO(mmukhi): Benchmark if the performance gets better if count the metadata and other header fields
// first and create a slice of that exact size.
headerFields := make([]hpack.HeaderField, 0, 2) // grpc-status and grpc-message will be there if none else.
if !s.updateHeaderSent() { // No headers have been sent.
// Count the metadata header fields as well so the slice is not reallocated
// while they are appended below.
headersAlreadySent := s.updateHeaderSent()
headerFieldCount := 2 // grpc-status, grpc-message. Keep in sync with the appends below
if hasStatusDetails {
// Do not use the user's grpc-status-details-bin (if present) if we are
// even attempting to set our own.
delete(s.trailer, grpcStatusDetailsBinHeader)

// +1 for optional grpc-status-details-bin
headerFieldCount++
}
headerFieldCount += headerFieldsCountFromMD(s.trailer)
if !headersAlreadySent && len(s.header) == 0 {
// Trailer only response gets :status and content-type as well.
headerFieldCount += 2
}
headerFields := make([]hpack.HeaderField, 0, headerFieldCount)
if !headersAlreadySent { // No headers have been sent.
if len(s.header) > 0 { // Send a separate header frame.
if err := t.writeHeaderLocked(s); err != nil {
return err
Expand All @@ -1103,10 +1140,7 @@ func (t *http2Server) writeStatus(s *ServerStream, st *status.Status) error {
headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-status", Value: strconv.Itoa(int(st.Code()))})
headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-message", Value: encodeGrpcMessage(st.Message())})

if p := istatus.RawStatusProto(st); len(p.GetDetails()) > 0 {
// Do not use the user's grpc-status-details-bin (if present) if we are
// even attempting to set our own.
delete(s.trailer, grpcStatusDetailsBinHeader)
if hasStatusDetails {
stBytes, err := proto.Marshal(p)
if err != nil {
// TODO: return error instead, when callers are able to handle it.
Expand Down
124 changes: 124 additions & 0 deletions internal/transport/http2_server_header_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package transport

import (
"context"
"fmt"
"testing"
"time"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)

// BenchmarkServerHeaderFields measures header-slice construction as the amount
// metadata varies in.
func BenchmarkServerHeaderFields(b *testing.B) {
for _, mdCount := range []int{0, 4, 12} {
b.Run(fmt.Sprintf("writeHeaderLocked_mdCount=%d", mdCount), func(b *testing.B) {
t, s, cleanup := newBenchStream(b, mdCount, 0)
defer cleanup()

b.ReportAllocs()
for b.Loop() {
s.state = streamActive
if err := t.writeHeaderLocked(s); err != nil {
b.Fatal(err)
}
}
})

st := status.New(codes.OK, "OK")

b.Run(fmt.Sprintf("writeStatus_mdCount=%d", mdCount), func(b *testing.B) {
t, s, cleanup := newBenchStream(b, 0, mdCount)
defer cleanup()

b.ReportAllocs()
for b.Loop() {
s.state = streamActive
s.headerSent.Store(false)
if err := t.writeStatus(s, st); err != nil {
b.Fatal(err)
}
}
})
}
}

func makeMD(prefix string, count int) metadata.MD {
md := make(metadata.MD, count)
for i := range count {
md[fmt.Sprintf("%s-%d", prefix, i)] = []string{
fmt.Sprintf("value-%d-a", i),
fmt.Sprintf("value-%d-b", i),
}
}
return md
}

func newBenchStream(b *testing.B, headerCount, trailerCount int) (*http2Server, *ServerStream, func()) {
b.Helper()

done := make(chan struct{})
t := &http2Server{
controlBuf: newControlBuffer(done),
setResetPingStrikes: func() {},
maxSendHeaderListSize: nil,
}

// Drain the control buffer continuously, the way loopyWriter would in a
// real server. Without this, every headerFrame pushed by
// writeHeaderLocked/writeStatus stays queued forever, so the benchmark
// leaks memory and its timings are dominated by growing GC pressure
// instead of the cost of the functions under test.
drainDone := make(chan struct{})
go func() {
defer close(drainDone)
for {
if _, err := t.controlBuf.get(true); err != nil {
return
}
}
}()

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
s := &ServerStream{
Stream: Stream{
id: 1,
fc: inFlow{limit: 65536},
ctx: ctx,
contentSubtype: "proto",
},
st: t,
header: makeMD("header", headerCount),
}

s.trailer = makeMD("trailer", trailerCount)
s.cancel = cancel

cleanup := func() {
cancel()
close(done) // unblocks controlBuf.get so the drain goroutine exits
<-drainDone
}
return t, s, cleanup
}
Loading