Eliminate redundant receive-side copy in gRPC protobuf decode path - #2441
Eliminate redundant receive-side copy in gRPC protobuf decode path#2441kshitijsuri90 wants to merge 2 commits into
Conversation
109764f to
ce4a313
Compare
|
|
||
| } | ||
|
|
||
| func TestClientStreamReceiveMessage(t *testing.T) { |
There was a problem hiding this comment.
why are we adding all these? did we not have coverage before?
I would say each new test should have a comment why it needs to exist, as opposed to re-using pre-existing tests.
There was a problem hiding this comment.
I added these tests to verify explicitly, the specific behaviour being introduced in the patch. I can add these as subtests to original if that is required?
There was a problem hiding this comment.
Strongtests are strongly preferred if they're extensible enough to re-use.
(and see above on comments)
| }) | ||
| } | ||
|
|
||
| func TestUnmarshalSlowPath(t *testing.T) { |
There was a problem hiding this comment.
wouldn't tests for this exist already?
There was a problem hiding this comment.
There exists a coverage test for unmarshal. I added this to explicitly check the slow path. I can remove it if that's the preference?
There was a problem hiding this comment.
In what cases would there be a slow path in grpc?
I think we should be adding comments (in code) to every test. Context is precious here.
| _, _, err := marshal(transport.Encoding("foo"), nil, newCodec(nil)) | ||
| assert.Equal(t, yarpcerrors.CodeInternal, yarpcerrors.FromError(err).Code()) | ||
| } | ||
|
|
There was a problem hiding this comment.
what's up with the v2 proto - which one is used, which one is not?
| // Run with: | ||
| // | ||
| // go test -bench=BenchmarkGRPCCodec_RoundTrip -benchmem ./encoding/protobuf/v2/ | ||
| func BenchmarkGRPCCodec_RoundTrip(b *testing.B) { |
There was a problem hiding this comment.
@rabbbit Added this because there was no v2 equivalent of a round trip test for grpc. There exists one for v1 at
There was a problem hiding this comment.
Can we add this as a separate PR? Much easier to stamp/approve, let's make this diff smaller.
Also I think we upgraded to new go version now, perhaps you can even use B.Loop?
69bbfb6 to
a547889
Compare
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>
a547889 to
c62c34e
Compare
| return nil, toYARPCStreamError(cs.closeWithErr(err)) | ||
| } | ||
| return &transport.StreamMessage{Body: ioutil.NopCloser(bytes.NewReader(msg))}, nil | ||
| return &transport.StreamMessage{Body: newBytesBody(msg)}, nil |
There was a problem hiding this comment.
I wondered for a second which case we care about - unary or streaming, but this seems harmless.
Eliminate redundant receive-side copy in gRPC protobuf decode path
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.
In depth document (Uber access required): link
Changes
stream, unary inbound, unary outbound) to use bytesBody
Safety
expected by transport.Request.Body and transport.StreamMessage.Body.
It is a drop-in replacement for bytes.NewReader / ioutil.NopCloser.
reader that does not implement Bytes() (HTTP, TChannel) falls through
to the existing bufferpool.ReadFrom slow path with zero behavior change.
calls after draining the reader — deserialization logic is identical.
Benchmark
📊 Benchmark 1:
protobuf v1(
encoding/protobuf/codec_bench_test.go)github.com/gogo/protobuf-benchmem -count=10(Payload sizes: 350B / 10KB / 1MB)go1.26.1 linux/amd64, AMD EPYC 9B45HEAD~1(before) vsHEAD(after)Execution Time (
sec/op)Small_350B-96Medium_10KB-96Large_1MB-96Memory Allocated (
B/op)Small_350B-96Medium_10KB-96Large_1MB-96Allocations (
allocs/op)Small_350B-96Medium_10KB-96Large_1MB-96📊 Benchmark 2:
protobuf v2(
encoding/protobuf/v2/codec_bench_test.go)google.golang.org/protobuf-benchmem -count=10(Payload sizes: 350B / 10KB / 1MB)go1.26.1 linux/amd64, AMD EPYC 9B45HEAD~1(before) vsHEAD(after)Execution Time (
sec/op)Small_350B-96Medium_10KB-96Large_1MB-96Memory Allocated (
B/op)Small_350B-96Medium_10KB-96Large_1MB-96Allocations (
allocs/op)Small_350B-96Medium_10KB-96Large_1MB-96Test plan
TestBytesBody— Read, Bytes (pointer identity / zero-copy), Close, empty data, large payloadTestServerStreamReceiveMessage— body exposesBytes(), empty messageTestClientStreamReceiveMessage— body exposesBytes(), empty messageTestUnmarshalFastPath— Bytes() called / Read() not called, empty body, invalid encoding, malformed protobufTestUnmarshalSlowPath— deserializes correctly without Bytes(), empty bodyBenchmarkUnmarshalBytesReader— 100B/1KB/10KB/100KB with GC metrics (gogo + v2)go test -count=1 ./transport/grpc/... ./encoding/protobuf/...RELEASE NOTES: N/A (internal optimization, no API changes)
Update (2026-07-27): rebased onto current
main(clean, no conflicts) and fixed the failinggo-1-dot-23-lintjob:gofmt: struct field alignment inencoding/protobuf/marshal_test.gostaticcheck U1000: removed the now-unusedreadClosertype fromtransport/grpc/stream.go(its only usage was replaced bybytesBody)make lintverified green in the CI docker image (yarpc-go-1.23).