Eliminate response JSON byte round-trip

perfloop/k6 · REDUNDANT SERIALIZATION

https://perfloop.ai/t/oss/case_0ba99ytksb

Verdict

VERIFIED · settled 2026-08-12

What happened: The paired measurements met the required improvement.

Hypothesis

convert first calls protojson.Marshal on the dynamic response and immediately gives that local raw buffer to json.Unmarshal into back. The buffer has no other consumer or escape. Conn.Invoke configures EmitUnpopulated, calls convert once for each non-discarded unary response, and stores the resulting map in response.Message; its nearby Sobek comment establishes that returning the dynamic message itself is not safe because JS property access needs a real map-like value. Thus the required map materialization is not being removed, only the intermediate JSON-byte representation and its reparsing. The cadence is once per script-issued unary RPC that does not request DiscardResponseMessage, with cost scaling with the response JSON size. An isolated go run probe using protobuf v1.36.11, a dynamic message with a 16 KiB string plus an int64, and the same MarshalOptions measured 16,415 JSON bytes, 16 marshal allocations/op and 32.88 us/op, 11 parse allocations/op and 113.71 us/op, and 26 total allocations/op and 153.26 us/op for the present pair; this is synthetic evidence of the local work, not a measurement of the workload share. go test ./internal/lib/netext/grpcext also passed as an unchanged baseline. A case should first differential-test a direct converter against the current protojson-then-json result for defaults, nested/repeated/map fields, enums, 64-bit values, nulls, Any, and other well-known types. Then benchmark representative unary response sizes and collect CPU/allocation profiles on Client.Invoke -> Conn.Invoke -> convert; the confirming signal is fewer allocations and less CPU in protojson marshal/encoding-json unmarshal frames, with an end-to-end CPU or latency improvement for sufficiently large non-discarded responses.

Change to test: Build the Sobek-compatible Go map/slice representation directly from the dynamic protobuf reflection data, preserving protojson JSON names, emitted defaults, scalar conventions, maps/lists, and resolved well-known/Any behavior; keep a correctness-verified fallback for message forms not yet handled instead of constructing a local JSON byte buffer solely to parse it again.

Where it lives

perfloop/k6 · internal/js/modules/k6/grpc/client.go

Evidence

16 KiB unary gRPC response through Client.Invoke · 10 sample pairs

metric baseline candidate paired median change confidence range required result
ns/op 335976 121558 −62.8% (−210950) −238804 to −199285 < 0 PASSED
B/op 135605 84668 −37.6% (−50947) −51299 to −50626 < 0 PASSED
allocs/op 300 277 −7.7% (−23) −23 to −23 < 0 PASSED

Checks: 3 of 3 passed. Verification: no defect found.

Timeline