perf(crypto): vectorized BLAKE3 / LtHash and cached did:key parsing #150
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!150
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-112-crypto-perf"
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?
Closes #112.
Speeds up the CPU-bound crypto that permissioned-space syncing and signature verification spend their time in. The public API does not change, and every result is bit-identical to before.
What changed
BLAKE3 (
Crypto/Blake3.cs, internal)Vector256<uint>, or 4 withVector128<uint>(for example on ARM64/NEON or x64 without AVX2), with a scalar path otherwise. The compression function is written once, generic over the lane type (ILanes<T>with static abstract members), so all three paths run the same rounds.Unsafe. Fixed-size buffers are[InlineArray]structs, so every index is bounds-checked. Zero heap allocations (the old code allocated about 800 B per hash).BinaryPrimitives), so big-endian hosts give identical output without a special case. Multi-chunk inputs keep the reference chunk-tree construction, now allocation-free with a stack-held chaining-value stack.Blake3.KeyLen.HashExtended(input, output, parallelism)pins the batch width (1/4/8) so tests can exercise every path on any host.LtHash (
Spaces/LtHash.cs)MemoryMarshal.AsBytes), with a byte swap only on big-endian hosts. Lanes are added and subtracted withVector<ushort>overMemoryMarshal.Cast, with a scalar tail and a scalar fallback.WriteStateand the state constructor copy in bulk instead of per lane.Signature verification (
Crypto/AtProtoCrypto.cs, newCrypto/DidKeyCache.cs)AtProtoCrypto.VerifySignature(didKey, …)goes throughDidKeyCache.Shared, a bounded LRU (1,024 keys) keyed by the did:key string. A did:key is its own content, so entries never go stale..NETdoes not documentECDsainstances as thread-safe, so each entry keeps a small pool of imported keys (at mostProcessorCountidle). A verification rents one key, and eviction disposes idle keys immediately and rented ones on return. Parsing happens outside the lock, and a malformed key throws without being cached.CurveInfoinstead of re-allocating 5 arrays perSign/Verify.IsLowSno longer allocates.List<byte>andIndexOf). The work is bounded by the destination size, so an arbitrarily long did:key costs no more than a short one.AtProtoKey.Verifyrejects anything but a 64-byter || ssignature explicitly (DER included). The result is the same as before, but it no longer relies on the platform to reject those.Internal duplication
AtProtoKey.GetCompressedPublicKeyandCompressPublicKeyshareCompressPoint.DecompressPointtakes the curve directly instead of re-deriving it from OIDs. The OID and multicodec literals live in one place.FromMultikeyreuses the multibase decoding instead of repeating it.algmapping is one internal extension,KeyCurve.JwsAlgorithm(), used byServiceAuthGeneratorandSpaceTokens.Behaviour notes (no public API change)
did:key/multikey whose key is not 33 bytes now throwsFormatException, as the XML docs already said, instead ofArgumentException. The in-repo callers (SpaceTokens,SpaceCommit,ISpaceServiceAuthVerifier,FirehoseVerifier) catch both.KeyCurvevalue throwsArgumentOutOfRangeExceptioninstead of silently being treated as K-256.Fixedin the CHANGELOG. Neither is binary- or source-breaking.Verification
@noble/hashes, so they already cover the 8-way path.crypto/(signature-fixtures.json,w3c_didkey_P256.json,w3c_didkey_K256.json), vendored unchanged frombluesky-social/atproto-interop-tests@056e574(CC0; origin and license intests/ATProtoNet.Tests/Crypto/TestData/README.md). The tests cover valid, high-S and DER-encoded signatures for both curves, through the cachedVerifySignature,FromDidKey, and the legacypublicKeyMultibaseform. The did:key vectors derive each published did:key from its private key, parse it back, and sign and verify with it.@noble/hashes1.8.0, an independent implementation, checked on the 1-, 4- and 8-lane paths. The official vectors stop at 131 bytes, which is too short to reach the batched path.Add/Removechecked lane for lane against the scalar construction, including elements past the 512-byte stack buffer and past one BLAKE3 chunk.DidKeyCache: results matchAtProtoKey.Verify(valid, tampered, wrong message), LRU capacity, malformed and Ed25519 did:keys throw without caching, and 8 threads verifying through a one-entry cache (constant eviction while keys are rented) all get correct answers.K256_GenerateAndSign_IfSupported(renamedGenerateK256Key_SignAndVerify_RoundTripsThroughDidKey) now callsAssert.Skipwhere secp256k1 is missing instead of passing silently. It also checks low-S and the cached verify.Benchmarks
BenchmarkDotNet 0.14, ShortRun (3 warmup, 10 iterations, in-process), .NET 10.0.12, AMD Ryzen 5 7600X. "Before" is
mainat7f672c0and "after" is this branch, with the same harness.LtHash.AddLtHash.RemoveLtHash.DigestVerifySignature(did:key)P-256VerifySignature(did:key)K-256FromDidKeyP-256 (uncached by design)AtProtoKey.SignP-256SpaceRepoCommit.FromIndex, 10,000 records (Stopwatch, mean of 20 runs, including building the element strings): 133 ms → 9–12 ms (runs of 8.6, 10.8 and 12.0 ms).DOTNET_EnableAVX2=0, so the 4-wayVector128path), the 2048-byte XOF takes 2,738 ns, 4.4× faster than before.LtHash.Add. This production version is within about 5 % of it withoutUnsafeor a length-limited fast path.Edits outside
Crypto/andSpaces/LtHash.cssrc/ATProtoNet/Auth/ServiceAuthGenerator.csandsrc/ATProtoNet/Spaces/SpaceTokens.cs: one line each, to useKeyCurve.JwsAlgorithm().tests/ATProtoNet.Tests/ATProtoNet.Tests.csproj: one<None Include="Crypto/TestData/*.json" CopyToOutputDirectory="PreserveNewest" />line, so the vendored fixtures reach the test output. This is the only way to ship them as JSON files.docs/crypto.md: two sentences on the verification cache and the rejected signature forms.Deferred
src/ATProtoNet.Server/Spaces/JsonWebKeyVerifier.csstill has the reverse mapping (JWKcrv→ curve andalg) and a K-256 OID literal. That file belongs to a parallel PR. It could use the same curve metadata once that lands.FromDidKey/FromMultikeystay uncached, because they return a key the caller owns and disposes. Their cost is mostly the BigInteger square root and the platform import.🤖 Generated with Claude Code
@claude /review
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
This is a large, carefully constructed perf PR, and the implementation holds up under close reading.
What I checked in detail:
Blake3.cs): the genericILanes<T>abstraction produces identical rounds/permutation across scalar,Vector128,Vector256paths. The per-lane counter carry (Vector128.LessThan(low, first)mask subtraction) correctly propagates overflow from the low 32 bits into the high word.Squeezecascades 8 → 4 → 1 → partial-block correctly and the tree construction (ChunkNode/TreeRoot/ParentNode) mirrors the reference chunk-tree algorithm faithfully, including the trailing-zero merge pattern. Extensive scalar-vs-vectorized differential tests plus independent-implementation vectors back this up.LtHash.cs): the direct-into-lane-span BLAKE3 write with a big-endian swap guard is correct, and theVector<ushort>add/subtract with a scalar tail correctly matches the unchecked-wraparound scalar semantics.DidKeyCache: the double-checked insert (parse outside the lock, re-check under lock, discard the loser) is correct and avoids leakingAtProtoKeyinstances; eviction disposes idle keys immediately and defers toReturn()for rented ones via the_evictedflag. The concurrent eviction test is a good stress case for this.Base58Decodeinto aSpan<byte>: the leading-zero counting, capacity bound, and final reverse-then-copy-into-offset (verified safe under span overlap sinceSpan<T>.CopyTousesMemmovesemantics) are correct, and the length-bounded decode meaningfully closes a DoS vector (previously unboundedList<byte>growth for arbitrarily long/malicious base58 input).CurveInfo/DecompressPoint: computing P/A/B/order once from hex literals instead of re-deriving 5 byte arrays per call is straightforward and preserves behavior; the sqrt-and-verify step (y*y % p != ySquared) still guards against off-curve X values.HasSignatureLength) and theFormatException/ArgumentOutOfRangeExceptionbehavior changes are explicitly called out in the CHANGELOG underFixed, consistent withCLAUDE.md's changelog requirement, and both are documented as source/binary compatible.I didn't find correctness bugs. The test suite described (official BLAKE3 vectors, independent long-output vectors, exhaustive batched-vs-scalar sweeps, vendored atproto-interop-tests fixtures, and the cache concurrency test) covers the areas that would be easiest to get subtly wrong in this kind of change.
⏱ 2m 14s · 1 turns · tokens: 63.9k in / 13.4k out (+23.9k cache-read)
📜 Full session transcript
b226fea721446067915f🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
Rebased onto main to resolve a CHANGELOG-only conflict (#149 merged).
verify-rebase: the PR's patch outsideCHANGELOG.mdis byte-identical between the approved headb226fea7and the new head44606791(2058 changed lines). CI is green on the new head (and 2546 unit tests pass locally), so the approval carries over under the maintainer-approved policy for CHANGELOG-only rebases.✅ Verdict: approve — no blocking issues found.
Independent read of the whole diff finds no correctness or security blockers. What I verified in detail:
ILanes<T>compression reproduces the spec's quarter-rounds, column/diagonal schedule and MSG_PERMUTATION tuple swap; the batchedSqueezecascades 8 → 4 → 1 → partial with strictly sequential output-block counters, and theLessThan(low, first)mask subtraction correctly carries low-word wrap into the high counter word.ChunkNode/TreeRootreproduce the reference chunk-tree (CHUNK_START on the first block, CHUNK_END on the last, trailing-zero merges, right-to-left stack fold, root counter preserved), andStore's lane-major transpose puts block i (counter i) at the right offset. All word/byte conversion is explicit little-endian. The official vectors, the noble long-output pins, and the exhaustive scalar-vs-batched sweeps give strong evidence all three paths agree.ReadUInt16LittleEndianwalk on little-endian hosts, and the in-placeReverseEndiannessswap covers big-endian;Vector<ushort>add/sub wraps identically to theuncheckedscalar ops, with a correct scalar tail;WriteState/state-ctor bulk copies touch onlyStateBytes.CurveInfoconstants (p, a, b, order, n/2, (p+1)/4) match the known values for both curves;DecompressPointkeeps the range check, the √-verification and parity selection, now writing fixed 32-byteECPointfields. The explicit 64-byter || scheck makes the DER/high-S rejection deterministic — the vendored interop fixtures cover exactly those cases.DidKeyCache: the parse-outside-lock + double-checked insert is correct (a racing loser's entry is evicted, a capacity evict always removes the LRU tail, never the just-added entry); evicted entries dispose idle keys immediately and flag rented ones for dispose-on-return, so nothing leaks or gets used after dispose; pools are bounded; malformed keys throw before anything is cached; no nested locks.bluesky-social/atproto-interop-tests@056e5741(checked contents;LICENSE-CC0exists upstream), the xunit-v3TheoryData<T>(IEnumerable<T>)constructor used by the new tests exists,Assert.Skipis a v3 API, the csproj copy mirrors the existingStreaming/TestDatapattern, and all in-repo callers ofVerifySignature/FromDidKeycatchFormatException/ArgumentException, so the documented exception-type changes are safe.Two minor, non-blocking notes below (post-merge review — the PR merged a few seconds after this run started, so treat these as follow-ups if convenient).
⏱ 18m 25s · 30 steps · $0.1958 · tokens: 304.5k in / 35.2k out (+3772.0k cache-read) · Go: 5h 3% · wk 97% · mo 81%
📜 Full session transcript
@ -177,0 +256,4 @@if (bytes.Length < BlockLen){Span<byte> padded = stackalloc byte[BlockLen];The zero-padding the doc comment promises is implicit:
paddedis a plainstackalloc, and onlybytes.Lengthbytes are copied. This works today because the C# compiler emitslocalsinitby default (which zero-initializes stackalloc data — confirmed via theSkipLocalsInitproposal/API docs), so the official vectors pass on every supported runtime as configured. But for a hash primitive this is a fragile invisible dependency: if anyone later adds[Module: SkipLocalsInit]— a documented perf optimization aimed precisely at stackalloc-heavy code like this — every input whose length isn't a multiple of 64 would hash leftover stack bytes into the block, producing garbage-dependent, test-flaky output. Onepadded.Fill(0)(orpadded[bytes.Length..].Clear()) before the copy makes the contract explicit at effectively zero cost.@ -0,0 +47,4 @@[Theory][MemberData(nameof(SignatureFixtures))]public void VerifySignature_InteropFixture_MatchesExpectedValidity(string comment)These two fixture theories (and the one at line 67) iterate the K-256 fixtures without an environment gate: on a host without secp256k1 (macOS/Windows),
AtProtoCrypto.VerifySignature/ImportCompressedPublicKeythrowPlatformNotSupportedExceptionfromECDsa.Create, which would fail rather than skip — unlikeAssertDidKeyFixtureandGenerateK256KeyOrSkip, which gate correctly. Consider skipping the K-256 fixtures whenfixture.Algorithm == "ES256K"on a platform that lacks the curve, e.g. by probing support once in a static helper.🔧 Let opencode-bot fix this — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do.