Do a performance and memory consumption optimization pass #87
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET#87
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
🤖 On it — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
Pushed to
claude/issue-87.Did a performance and memory pass over the paths a running consumer actually spends time in: firehose/Jetstream frame parsing, MST lookup and construction, CAR block resolution, CID encoding. I built a benchmark harness first so the changes were driven by measurements rather than guesses.
The three structural wins
FirehoseEventParser— 6.6× faster, 3.5× less memory. It decoded DAG-CBOR to aJsonElement, re-serialized that into aJsonNodetree, rewrote the tree, copied it into a fresh discriminator-first object, then serialized again for the deserializer — five passes per frame. The rewrite also deep-cloned at every level, so a#commit's base64blocksstring was re-copied once per level of nesting above it. It now writes the CBOR into aUtf8JsonWriterin a single pass;WriteBase64StringValuemeans the CAR blob never becomes a UTF-16 string at all. The discriminator-first rebuild turned out to be unnecessary —AllowOutOfOrderMetadataPropertiesis already set on the shared options.CarReader.FindBlock— was O(n) per call. Resolving a repository's MST is one lookup per node, so a repo walk was quadratic, and exports run to tens of thousands of blocks. It now builds a CID index on first use (deferred, so callers that only enumerateBlocksdon't pay), keyed by a span-comparing comparer so lookups allocate nothing.MerkleSearchTree.Get/TryUpdatecalledList.IndexOfon the entry they were iterating — a linear rescan from the front per comparison.GetandRemovealso computed the key's depth (a SHA-256) on entry and never used the result, so every tree walk hashed the key once per node visited; lookups now allocate zero.Createrecomputed every key's depth at every layer and copied the entry range into new lists as it partitioned — depths are hashed once and the recursion works on index bounds.Smaller items: single-receive fast paths in both WebSocket read loops, an exact-sized splice buffer in
FirehoseVerifier(WriteMapHeadernow takes aSpan<byte>),FromStreamAsyncparsing the buffer it already filled instead of copying the whole CAR again onto the LOH, and a thread-safe + frozenAtProtoJsonDefaults.Options(the??=could let concurrent first calls each build their own contract cache).One public API addition:
JetstreamEventParser.ParseFrame(ReadOnlyMemory<byte>, JetstreamProtocol).JsonDocumentcan't parse a span without copying it first, so the span overload duplicates every frame; the client's receive loop already holds an array and now uses the no-copy path — about 600 bytes per event off the live tail.Two things worth flagging
I measured a regression and backed it out. My first Jetstream change used
JsonDocument.ParseValue(ref Utf8JsonReader)to avoid the copy. In an isolated micro-benchmark that beat the existing code, but in the realParseFrameit was consistently ~35% slower — element access andClone()on such a document cost more than the copy saves. The span overload is now behaviorally unchanged from before, and the win moved to the new overload.MST.Serializeis not a time win. The original run suggested 703 µs → 900 µs, i.e. a regression. Measuring it isolated showed the 703 µs baseline was an artifact of warm caches from the benchmarks that ran before it; matched, it's ~961 µs → ~903 µs, which is inside the noise. The real gain there is allocation (−12%). The chart labels it≈1×rather than rounding it up to a win. For the same reason all the reported figures are medians of three runs with tiered compilation disabled — with it on, benchmark ordering moved individual numbers by 3–4×.Verified:
dotnet build -c Releaseclean (0 warnings, 0 errors);dotnet test tests/ATProtoNet.Tests/passes 1296 tests, 0 failures (1291 before, plus 5 new). The new tests cover the CID index including the duplicate-CID case, and theReadOnlyMemoryoverload — including that a parsedRecordsurvives its source buffer being cleared, which matters because that overload reads the caller's buffer in place.CHANGELOG.mdis updated under[Unreleased].⏱ 36m 10s · 151 turns · tokens: 301.1k in / 108.2k out (+28917.0k cache-read)
📜 Full session transcript
→ Opened PR: #88