Skip to content

Faster in-place protobuf decode + codegen stale-cache fix#37

Merged
excpt merged 3 commits into
mainfrom
fix/codegen-track-tool-dll-input
May 25, 2026
Merged

Faster in-place protobuf decode + codegen stale-cache fix#37
excpt merged 3 commits into
mainfrom
fix/codegen-track-tool-dll-input

Conversation

@excpt

@excpt excpt commented May 25, 2026

Copy link
Copy Markdown
Owner

Three related commits, intended for rebase-merge so each lands on main independently.

1. Fix codegen stale-cache trap (#36)

FSharpGrpc_GenerateProto declared Inputs="@(Protobuf)" but not the codegen tool DLL, so a rebuilt generator with unchanged .proto files never re-ran — downstream projects silently compiled stale obj/protos/*.generated.fs.

  • Add $(_FSharpGrpc_CodegenDll) to the target Inputs so a rebuilt/upgraded generator invalidates the cached manifest.
  • Add _FSharpGrpc_ForceCodegen pre-target: -p:FSharpGrpc_ForceCodegen=true deletes the manifest to force unconditional regeneration.

This is the trap that made the original #18/#35 benchmark un-measurable — the "AFTER" run compiled cached "BEFORE" code.

2. Decode sub-messages in place (#18)

The decoder copied every nested message, packed field, and map entry into a fresh ByteString + second CodedInputStream (input.ReadBytes().CreateCodedInput()) — one byte[] + stream allocation per sub-message, the dominant decode cost on message-heavy payloads.

PushLimit/PopLimit are internal in Google.Protobuf, and our records aren't IMessage (that would need a MessageDescriptor — runtime machinery this codegen avoids). So bound the field loop by the public CodedInputStream.Position instead:

decodeFrom (input) (endPos: int64) =
    while input.Position < endPos do
        match GetTagFieldNumber(input.ReadTag()) with ...

// nested: no copy, no second stream
let endPos = int64 (input.ReadLength()) + input.Position
Address.decodeFrom input endPos

WKT/wrapper fields use the public input.ReadMessage (they are IMessage). list/option surface types are unchanged. decodeFrom gains an endPos parameter (top-level decode passes the buffer length); hand-written generated code in the test projects and all snapshots updated to match.

3. Benchmark + README

Add HighCardinalityUserProfileBenchmarks (the case that stresses sub-message decode) and refresh README numbers from a confirmed-fresh Release run.

Results (F# decode, before → after)

Case Allocated Mean
UserProfile, high cardinality (1000 ints + 100 msgs) 109.27 KB → 82.3 KB (−24.7%) 15.8 µs → 12.7 µs (−19.6%)
UserProfile, small (5 ints + 2 msgs) 5,200 B → 2,752 B (−47%) 815 ns → 546 ns (−33%)

The win comes from the decoder implementation alone — list/option kept as-is. The residual F#-vs-C# gap at high cardinality is now almost entirely the list cons cells + option boxing (the immutability surface), which would only close with a breaking list → array change — deliberately deferred.

Tests

146/146 pass, including the 61 cross-language byte-exact (F#↔C#) and 10 gRPC interop tests that prove the new reader is wire-identical. F# formatting and C# formatting clean.

🤖 Generated with Claude Code

excpt and others added 3 commits May 25, 2026 10:29
FSharpGrpc_GenerateProto declared Inputs="@(Protobuf)" but not the
codegen tool DLL. MSBuild skipped the target whenever the manifest
output was newer than the proto inputs, so a rebuilt generator with
unchanged .proto files never re-ran — downstream projects silently
compiled stale obj/protos/*.generated.fs until obj/ was cleared.

- Add $(_FSharpGrpc_CodegenDll) to the target Inputs so a rebuilt or
  upgraded generator invalidates the cached manifest automatically.
- Add _FSharpGrpc_ForceCodegen pre-target that deletes the manifest
  when -p:FSharpGrpc_ForceCodegen=true, forcing unconditional regen
  (delete-the-output, since Inputs-without-Outputs is skipped by
  MSBuild, not run).

This is the trap that made the #18/#35 benchmark un-measurable: the
"AFTER" run compiled cached "BEFORE" code. Fixes #36.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The decoder read every nested message, packed field, and map entry by
copying its bytes into a fresh ByteString and wrapping a second
CodedInputStream (`input.ReadBytes().CreateCodedInput()`) — one byte[]
+ stream allocation per sub-message. For message-heavy payloads this
was the dominant decode allocation (the UserProfile "Address sub-decode"
hotspot from #18), independent of the list-vs-array surface type.

Read sub-regions in place instead. `PushLimit`/`PopLimit` are internal
in Google.Protobuf, and our records aren't `IMessage` (that would need a
MessageDescriptor — runtime machinery this codegen avoids), so bound the
field loop by the public `CodedInputStream.Position`:

  decodeFrom (input) (endPos: int64) =
      while input.Position < endPos do
          match GetTagFieldNumber(input.ReadTag()) with ...

  // nested: no copy, no second stream
  let endPos = int64 (input.ReadLength()) + input.Position
  Address.decodeFrom input endPos

`int64 (ReadLength()) + Position` ordering is load-bearing: ReadLength
advances past the prefix before Position is read. WKT/wrapper fields use
the public `input.ReadMessage` (they are IMessage). `list`/`option`
surface types are unchanged.

decodeFrom gains an `endPos` parameter (top-level `decode` passes the
buffer length). Hand-written generated code in the test projects and all
snapshots updated to match. Cross-language byte-exact tests (F#<->C#)
and gRPC interop tests stay green, proving wire compatibility. Re #18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add HighCardinalityUserProfileBenchmarks (1000 repeated ints + 100
nested messages) — the case that exercises the sub-message decode path,
where the in-place reader's win over the old buffer-copy is visible
(F# decode 109.27 KB -> 82.3 KB, 15.8 us -> 12.7 us). Doubles as a
regression guard against the decode allocation regressing.

Refresh all README benchmark tables from a single confirmed-fresh
Release run and add the high-cardinality row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@excpt
excpt merged commit 07cc840 into main May 25, 2026
4 checks passed
@excpt
excpt deleted the fix/codegen-track-tool-dll-input branch May 25, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant