Skip to content

Add gRPC codec round-trip benchmark for protobuf v2 encoding path - #2515

Open
kshitijsuri90 wants to merge 1 commit into
yarpc:mainfrom
kshitijsuri90:add_grpc_codec_benchmark_test
Open

Add gRPC codec round-trip benchmark for protobuf v2 encoding path#2515
kshitijsuri90 wants to merge 1 commit into
yarpc:mainfrom
kshitijsuri90:add_grpc_codec_benchmark_test

Conversation

@kshitijsuri90

Copy link
Copy Markdown
Contributor

Summary

  • Adds a gRPC round-trip benchmark for the protobuf v2 (google.golang.org/protobuf) encoding path
  • Copy of encoding/protobuf/codec_bench_test.go adapted for the v2 package at encoding/protobuf/v2/

Benchmark results

BenchmarkGRPCCodec_RoundTrip/Small_350B-96      66325     47290 ns/op    23367 B/op    316 allocs/op
BenchmarkGRPCCodec_RoundTrip/Medium_10KB-96     41518     86748 ns/op   108416 B/op    323 allocs/op
BenchmarkGRPCCodec_RoundTrip/Large_1MB-96        1154   3120548 ns/op  15391705 B/op   541 allocs/op

Test plan

  • go test -bench=BenchmarkGRPCCodec_RoundTrip -benchmem ./encoding/protobuf/v2/ passes
  • go build ./encoding/protobuf/v2/ compiles cleanly
  • go vet ./encoding/protobuf/v2/ reports no issues

Made with Cursor

Copy of encoding/protobuf/codec_bench_test.go adapted for the v2
(google.golang.org/protobuf) package.

Benchmark results:

  BenchmarkGRPCCodec_RoundTrip/Small_350B-96      66325     47290 ns/op    23367 B/op    316 allocs/op
  BenchmarkGRPCCodec_RoundTrip/Medium_10KB-96     41518     86748 ns/op   108416 B/op    323 allocs/op
  BenchmarkGRPCCodec_RoundTrip/Large_1MB-96        1154   3120548 ns/op  15391705 B/op   541 allocs/op

Co-authored-by: Cursor <cursoragent@cursor.com>
kshitijsuri90 added a commit to kshitijsuri90/yarpc-go that referenced this pull request Jul 5, 2026
Every protobuf message received over gRPC was copied twice: once by
the codec (Materialize, necessary) and again by the encoding layer
(bufferpool.ReadFrom, redundant). The second copy exists because the
transport wraps the already-materialized []byte in bytes.NewReader,
and the encoding layer has no way to extract the raw bytes.

Add a bytesBody type to the transport layer that implements
io.ReadCloser and exposes the underlying []byte via a Bytes() method.
The encoding layer's unmarshal function type-asserts for this method
and skips the bufferpool copy when available. Non-gRPC transports
(HTTP, TChannel) fall through to the existing ReadFrom path.

Changes:
- transport/grpc: Add bytesBody with Bytes() method for zero-copy access
- transport/grpc: Update all four receive paths (server stream, client
  stream, unary inbound, unary outbound) to use bytesBody
- encoding/protobuf: Add Bytes() fast-path in unmarshal (gogo and v2)

Safety:
- bytesBody is unexported and implements io.ReadCloser, the interface
  expected by transport.Request.Body and transport.StreamMessage.Body.
  It is a drop-in replacement for bytes.NewReader / ioutil.NopCloser.
- The Bytes() fast-path is opt-in via interface type assertion. Any
  reader that does not implement Bytes() (HTTP, TChannel) falls through
  to the existing bufferpool.ReadFrom slow path with zero behavior change.
- The fast-path calls unmarshalBytes, the same function the slow path
  calls after draining the reader — deserialization logic is identical.
- No public API changes; bytesBody is internal to transport/grpc.

Benchmark added in yarpc#2515

Benchmark (BenchmarkUnmarshalBytesReader, n=10, AMD EPYC 9B45, Go 1.26.1):

  Library  CPU Δ     Heap Δ   GC Cycles Δ   GC Pause Δ
  gogo     -16.1%    +1.1%    -3.6%          -9.3%
  v2       -15.1%    +0.9%    -2.8%          -7.6%
  average  -15.6%    +1.0%    -3.2%          -8.5%

  Heap increase at small payloads (<1KB) is due to bytesBody struct
  being marginally larger than bytes.Reader; at >=10KB the eliminated
  bufferpool copy dominates and heap decreases.

RELEASE NOTES: N/A (internal optimization, no API changes)

Made-with: Cursor
Co-authored-by: Cursor <cursoragent@cursor.com>
kshitijsuri90 added a commit to kshitijsuri90/yarpc-go that referenced this pull request Jul 5, 2026
Every protobuf message received over gRPC was copied twice: once by
the codec (Materialize, necessary) and again by the encoding layer
(bufferpool.ReadFrom, redundant). The second copy exists because the
transport wraps the already-materialized []byte in bytes.NewReader,
and the encoding layer has no way to extract the raw bytes.

Add a bytesBody type to the transport layer that implements
io.ReadCloser and exposes the underlying []byte via a Bytes() method.
The encoding layer's unmarshal function type-asserts for this method
and skips the bufferpool copy when available. Non-gRPC transports
(HTTP, TChannel) fall through to the existing ReadFrom path.

Changes:
- transport/grpc: Add bytesBody with Bytes() method for zero-copy access
- transport/grpc: Update all four receive paths (server stream, client
  stream, unary inbound, unary outbound) to use bytesBody
- encoding/protobuf: Add Bytes() fast-path in unmarshal (gogo and v2)

Safety:
- bytesBody is unexported and implements io.ReadCloser, the interface
  expected by transport.Request.Body and transport.StreamMessage.Body.
  It is a drop-in replacement for bytes.NewReader / ioutil.NopCloser.
- The Bytes() fast-path is opt-in via interface type assertion. Any
  reader that does not implement Bytes() (HTTP, TChannel) falls through
  to the existing bufferpool.ReadFrom slow path with zero behavior change.
- The fast-path calls unmarshalBytes, the same function the slow path
  calls after draining the reader — deserialization logic is identical.
- No public API changes; bytesBody is internal to transport/grpc.

Benchmark added in yarpc#2515

Benchmark (BenchmarkUnmarshalBytesReader, n=10, AMD EPYC 9B45, Go 1.26.1):

  Library  CPU Δ     Heap Δ   GC Cycles Δ   GC Pause Δ
  gogo     -16.1%    +1.1%    -3.6%          -9.3%
  v2       -15.1%    +0.9%    -2.8%          -7.6%
  average  -15.6%    +1.0%    -3.2%          -8.5%

  Heap increase at small payloads (<1KB) is due to bytesBody struct
  being marginally larger than bytes.Reader; at >=10KB the eliminated
  bufferpool copy dominates and heap decreases.

RELEASE NOTES: N/A (internal optimization, no API changes)

Made-with: Cursor
Co-authored-by: Cursor <cursoragent@cursor.com>
b.ResetTimer()
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.

are we not yet on b.Loop?

}
defer serverDispatcher.Stop()

serverAddr := listener.Addr().String()

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.

why a var? inline?

kshitijsuri90 added a commit to kshitijsuri90/yarpc-go that referenced this pull request Jul 27, 2026
Every protobuf message received over gRPC was copied twice: once by
the codec (Materialize, necessary) and again by the encoding layer
(bufferpool.ReadFrom, redundant). The second copy exists because the
transport wraps the already-materialized []byte in bytes.NewReader,
and the encoding layer has no way to extract the raw bytes.

Add a bytesBody type to the transport layer that implements
io.ReadCloser and exposes the underlying []byte via a Bytes() method.
The encoding layer's unmarshal function type-asserts for this method
and skips the bufferpool copy when available. Non-gRPC transports
(HTTP, TChannel) fall through to the existing ReadFrom path.

Changes:
- transport/grpc: Add bytesBody with Bytes() method for zero-copy access
- transport/grpc: Update all four receive paths (server stream, client
  stream, unary inbound, unary outbound) to use bytesBody
- encoding/protobuf: Add Bytes() fast-path in unmarshal (gogo and v2)

Safety:
- bytesBody is unexported and implements io.ReadCloser, the interface
  expected by transport.Request.Body and transport.StreamMessage.Body.
  It is a drop-in replacement for bytes.NewReader / ioutil.NopCloser.
- The Bytes() fast-path is opt-in via interface type assertion. Any
  reader that does not implement Bytes() (HTTP, TChannel) falls through
  to the existing bufferpool.ReadFrom slow path with zero behavior change.
- The fast-path calls unmarshalBytes, the same function the slow path
  calls after draining the reader — deserialization logic is identical.
- No public API changes; bytesBody is internal to transport/grpc.

Benchmark added in yarpc#2515

Benchmark (BenchmarkUnmarshalBytesReader, n=10, AMD EPYC 9B45, Go 1.26.1):

  Library  CPU Δ     Heap Δ   GC Cycles Δ   GC Pause Δ
  gogo     -16.1%    +1.1%    -3.6%          -9.3%
  v2       -15.1%    +0.9%    -2.8%          -7.6%
  average  -15.6%    +1.0%    -3.2%          -8.5%

  Heap increase at small payloads (<1KB) is due to bytesBody struct
  being marginally larger than bytes.Reader; at >=10KB the eliminated
  bufferpool copy dominates and heap decreases.

Lint fixes (2026-07-27): gofmt field alignment in
encoding/protobuf/marshal_test.go; removed the now-unused readCloser
type from transport/grpc/stream.go (staticcheck U1000) — its only
usage was replaced by bytesBody.

RELEASE NOTES: N/A (internal optimization, no API changes)

Made-with: Cursor
Co-authored-by: Cursor <cursoragent@cursor.com>
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