Skip to content

Reuse one Utf8TextWriter for custom formatter bodies - #351

Merged
mishamyte merged 1 commit into
masterfrom
perf/349-cache-body-text-writer
Aug 8, 2026
Merged

Reuse one Utf8TextWriter for custom formatter bodies#351
mishamyte merged 1 commit into
masterfrom
perf/349-cache-body-text-writer

Conversation

@mishamyte

@mishamyte mishamyte commented Aug 8, 2026

Copy link
Copy Markdown
Member

Second half of #349, now that #350 has landed. Diff is a single file.

Change

Serialization.serialize allocated a Utf8TextWriter per event, inside the batch loop, on the custom-ITextFormatter path:

use textWriter = new Utf8TextWriter(buffers.Body)   // one per event
textFormatter.Format(event, textWriter)

SerializationBuffers already caches the equivalent writer for the message buffer (MessageWriter); this adds the symmetric BodyTextWriter over the body buffer, and the call site becomes textFormatter.Format(event, buffers.BodyTextWriter).

Why it is safe

  • Utf8TextWriter holds no state beyond its backing buffer — every Write encodes straight into the pooled buffer, there is no internal buffering (its own doc comment says so, which is why WrittenSpan is valid without a Flush).
  • That buffer is already cleared per event by the existing buffers.Body.Clear(), which is the whole reset.
  • SerializationBuffers is sink-owned and used serially — the same guarantee MessageWriter has relied on since the V9 rewrite.
  • Dropping use costs nothing: Utf8TextWriter does not override Dispose, so a formatter that squirrelled away the reference is exactly as (un)protected as it is today.

Measured

Isolated effect of this change, measured locally against master using CustomFormatterSinkBenchmarks.Push(EventCount: 1000) (added in #350):

Payload before after Δ
Simple 116.03 KB 69.37 KB −40.2%
Exception 7573.80 KB 7519.11 KB −0.7%

The Simple saving is 46.66 KB against 46.9 KB predicted (48 bytes × 1000 events). Exception is dominated by exception rendering, so the same absolute saving barely registers.

The CI comparison table reports −54.0% on the Simple row. That is against published 9.0.1, which lacks both this change and the newline trim from #348, so it bundles the two — the extra ~16 KB is #348 shortening each body. −40% is this PR's own contribution.

The control worth checking: SinkBenchmarks Simple rows are +0.0% at both 1000 and 10000 events, confirming the built-in formatter path is untouched. The +0.8% on the Exception rows is pre-existing master-vs-9.0.1 drift from #340, identical on both row sizes and unrelated to this change.

Mean moved too, but on shared runners it is noise — Allocated is the signal.

Coverage

No new test. The batch test added in #348 (body: trimming is per entry across a batch and leaves no stale bytes) already drives a custom formatter across four events with differing bodies, long → short, and would fail if the reused writer carried state between them. 136/136 pass on net8.0/net9.0/net10.0.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mishamyte, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5cbc2be8-8c33-4eca-b720-4d621d7500cb

📥 Commits

Reviewing files that changed from the base of the PR and between 1d88759 and 01b132d.

📒 Files selected for processing (1)
  • src/Serilog.Sinks.Grafana.Loki/Serialization.fs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Benchmark comparison

Baseline: Serilog.Sinks.Grafana.Loki 9.0.1 (latest on NuGet) vs this source.

Allocated is exact and deterministic — treat it as the regression signal.
Mean on shared CI runners is noisy; deltas within ±10% are not meaningful.

Benchmark v9.0.1 alloc source alloc Δ alloc v9.0.1 mean source mean Δ mean
CustomFormatterSinkBenchmarks.Push(EventCount: 1000, Payload: "Exception") 8.44 MB 8.42 MB −0.2% 15.58 ms 12.01 ms −22.9% 🟢
CustomFormatterSinkBenchmarks.Push(EventCount: 1000, Payload: "Simple") 116.0 KB 53.3 KB −54.0% 🟢 2.01 ms 1.90 ms −5.6%
SinkBenchmarks.Push(EventCount: 1000, Payload: "Exception") 7.73 MB 7.79 MB +0.8% 20.31 ms 18.35 ms −9.7%
SinkBenchmarks.Push(EventCount: 1000, Payload: "Simple") 135.7 KB 135.7 KB +0.0% 5.52 ms 5.46 ms −1.0%
SinkBenchmarks.Push(EventCount: 10000, Payload: "Exception") 77.19 MB 77.80 MB +0.8% 139.33 ms 138.35 ms −0.7%
SinkBenchmarks.Push(EventCount: 10000, Payload: "Simple") 1.36 MB 1.36 MB +0.0% 18.16 ms 16.97 ms −6.5%

@mishamyte
mishamyte changed the base branch from perf/349-benchmark-custom-formatter-path to master August 8, 2026 09:38
Serialization allocated a Utf8TextWriter per event inside the batch loop on
the custom-ITextFormatter path -- 48 bytes each, ~47 KB of garbage per
1000-event batch. SerializationBuffers already caches the equivalent writer
for the message buffer; this adds the symmetric one over the body buffer.

Safe because Utf8TextWriter holds no state beyond its backing buffer (every
Write goes straight through, there is no internal buffering), that buffer is
already cleared per event, and SerializationBuffers is sink-owned and used
serially -- the same guarantee MessageWriter already relies on. Dropping the
`use` costs nothing: Utf8TextWriter does not override Dispose, so a formatter
retaining the reference is exactly as protected as before.

CustomFormatterSinkBenchmarks, Push(EventCount: 1000):

  Payload=Simple      116.03 KB -> 69.37 KB   (-40.2%)
  Payload=Exception  7573.80 KB -> 7519.11 KB  (-0.7%, exception rendering dominates)

Covered by the existing batch test, which drives a custom formatter across
four events and would fail if the reused writer carried state between them.

Refs #349

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mishamyte
mishamyte force-pushed the perf/349-cache-body-text-writer branch from 8695645 to 01b132d Compare August 8, 2026 09:39
@mishamyte mishamyte self-assigned this Aug 8, 2026
@mishamyte
mishamyte merged commit b045332 into master Aug 8, 2026
8 checks passed
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.

1 participant