Fluent forwarding rebuilds each decoded object through reflection
perfloop-oss/accesslog · ALLOCATION HOT LOOP
https://perfloop.ai/t/oss/case_655vyptbq6
Verdict
VALIDATED BY A DIFFERENT CHANGE · settled 2026-10-23
What happened: The paired measurements met the required improvement.
Hypothesis
Fluent-bound unary logging rebuilds an object that has already been decoded from JSON. The extra map and reflection temporaries are created for each successfully decoded object sent to the forwarder. A local default-async profile shows material allocation cost; realized savings and production exposure remain unmeasured.
The benefiting operation is unary access-log emission through middleware.UnaryServerInterceptor, NewGRPCLogger, and writer.NewFluentLogWriter at revision b22fa9461231b8d9f94f52526a57d0752a04c25b. middleware/grpc.go:15-22 emits once per completed handler. With the default formatter and no callbacks, grpc.go:111-116 supplies five fields; the optional-field branches are disabled. Ignored methods exit before emission. README.md:51-57 offers Fluent sinks and io.Writer integration, while examples/grpc/README.md:16-25 supplies the example method and field shape. This is the ordinary logging route once the supported Fluent binding is selected, not a retry or fallback. The prevalence of that binding among consumers is unknown.
FluentLogWriter.Write unmarshals into map[string]interface{} and calls Post at writer/fluent.go:59-64. The pinned fluent-logger-golang v1.8.0 PostWithTime creates another map at fluent/fluent.go:261, materializes reflected keys at line 262, and inserts reflected values at line 263. That second map is only consumed by the subsequent encoding call. The dependency already accepts msgp.Marshaler before this reflection branch at lines 226-228. Its EncodeData serializes the record before postRawData selects async enqueue, so default Async does not defer map consumption.
Profile call_y2bRZO8ow9oGc6FbVDdNw59i ran BenchmarkHuntDefaultAsyncUnary for 20,000 iterations using the actual default constructor, default formatter, documented method, a no-op handler, and a draining local TCP receiver. An auditing writer checked one successful write per iteration. On Go 1.27.3, the sampled allocation profile totaled 33.38 MiB including process setup. PostWithTime lines 261-263 accounted for approximately 8 MiB, or 24%, excluding subsequent encoding. JSON decoding accounted for approximately 9.5 MiB, making the rebuild comparable to the largest allocation phase rather than incidental bookkeeping. These are local allocated-byte observations, not production frequency or elapsed-time results.
If the internal representation adds negligible allocation, the attributable rebuild suggests roughly 400 bytes saved per five-field emission and an allocation reduction on the order of one fifth in this workload. That estimate is not a measured improvement. The required proof target is lower allocated bytes per successful default-async unary log emission with the same setup and field shape. Repeating the retained benchmark and profiling the changed operation can reject the estimate if replacement allocations consume the saving.
Wire-level comparisons are regression controls, not the source of the cost claim. They must preserve decoded fields and nested values, top-level null behavior, whole-input rejection of malformed or trailing JSON, and wrapped forwarding errors. Controls must also cover synchronous forwarding and custom-forwarder TagPrefix, MarshalAsJSON, acknowledgment, and subsecond options. Existing integer-preservation and elapsed-unit Cases are separate work and supply none of the claimed saving. The README establishes an offered sink at this revision, not maintainer acceptance of this proposal; the future maintenance policy remains unknown.
Change to test: Avoid rebuilding the decoded record by using an internal map-backed MessagePack marshaler with Fluent's existing Post fast path. Preserve whole-input JSON validation, error behavior, wire fields, and forwarder configuration semantics without adding public API or changing numeric representation.
Where it lives
Evidence
default-async unary gRPC access-log emission · 9 sample pairs
| metric | baseline | candidate | paired median change | confidence range | required | result |
|---|---|---|---|---|---|---|
B/op |
1675 |
1050 |
−37.3% (−625) |
−626 to −624 |
gain > 0; gain > 0.05 × |baseline median|; arm medians separated |
PASSED |
ns/op |
5489 |
3990 |
−25.9% (−1423) |
−2164 to −216 |
loss ≤ 0.05 × |baseline median| |
PASSED |
allocs/op |
37 |
24 |
−35.1% (−13) |
−13 to −13 |
loss ≤ 0.05 × |baseline median| |
PASSED |
Checks: 1 of 1 passed. Verification: no defect found.
Timeline
2026-10-22· Case opened