Add traits for hashing many messages at once - #2479
Conversation
Add a feature-gated `multi` module with two traits for hashing many independent messages of one compile-time length at once, one per SIMD lane. This is a different axis of parallelism from block-level ParBlocks, which processes multiple blocks of a single stream. Message length N and batch size B are const generics, so equal length and the one-output-per-message count are enforced by the type system rather than checked at run time: multi_digest takes &[&[u8; N]; B] and returns [Output; B], with no caller buffer and no digest copy. The lane count stays an associated typenum (Lanes: ArraySize), matching the cipher and keccak backend convention, and is hidden from callers behind the implementation's runtime dispatch. MultiDigestBackend is the per-width kernel; MultiDigest::multi_digest is the one-shot entry point each implementation provides.
7951152 to
f810a42
Compare
A hash processes one message serially: each block depends on the last, so a single message cannot be spread across SIMD lanes. Independent messages have no such dependency, so several can be hashed side by side, one per lane. This is a different axis of parallelism from ParBlocks, which processes several blocks of one stream. Add a feature-gated `multi` module structured as two layers, each hiding one detail from the layer above: MultiUpdateBackend hides the implementation. One backend is one way of doing the work - AVX2, AVX-512, portable, or a future instruction set - with a lane count fixed by the hardware it targets. MultiUpdateCore and MultiFixedOutputCore hide the lane count. A caller asks to hash some number of messages, a property of its workload rather than of the machine; the core selects a backend for the current CPU, splits the batch across it, and handles the remainder. update_blocks takes whole blocks, which keeps absorption free of copies: blocks are read where the caller already keeps them, and lanes are borrowed separately so messages need not be contiguous. A shared prefix is absorbed once for all lanes. Only finalize takes a sub-block tail, and the only bytes copied are the final padded blocks, which do not exist in the message. Message count, block counts, and tail length are const generics, so lanes advance in step by construction: unequal lengths fail to compile rather than panicking, and the API is infallible.
f810a42 to
4007825
Compare
|
Thanks for the review. On one-shot vs incremental: our use case needs incremental. The consumer hashes domain-separated Merkle leaves ( On postponing the traits: happy to land this behind an On the backend trick: agreed, and I'll align with On |
Strawman trait API for #2478. Traits only; no algorithm implementation is included here.
These traits are backed by a complete multi-message implementation of SHA-224/256/384/512 (AVX2 4- and 8-lane, AVX-512 8- and 16-lane, and a portable fallback), tested byte-for-byte against scalar
Digestand benchmarked.On Zen 4c it reaches 4.2× single-stream throughput for SHA-512 and 1.6× for SHA-256, with throughput flat from 16 to 512 messages per batch. That implementation is not yet public and will follow separately; I'm glad to answer questions about it here in the meantime.
What this adds
A hash reads one message and produces one digest. Inside, each block depends on the one before it, so a single message cannot be spread across SIMD lanes. Two different messages have no such dependency, so several can be hashed side by side — one per lane, the same instructions driving all of them. That is the capability this adds.
It is a different axis of parallelism from
ParBlocks, which processes several blocks of one stream.New feature-gated module
digest::multi(multi = [], off by default), additive.Layering
Two unrelated things must be kept apart, and each layer hides one of them from the layer above:
MultiUpdateBackendhides the implementation. One backend is one concrete way of doing the work — an AVX2 routine, an AVX-512 routine, a portable one, or a future instruction set. Each has a lane count fixed by the hardware it targets. An algorithm supplies as many backends as it has implementations.MultiUpdateCore/MultiFixedOutputCorehide the lane count. A caller asks to hash N messages, where N is a property of the workload rather than of the machine. The core selects a backend the current CPU supports, splits the batch across it, and handles whatever does not fill a complete set of lanes. Above this layer lane counts do not appear.This mirrors the layering already used for ordinary hashing — a backend, then
UpdateCore/FixedOutputCore. The backend layer follows the conventions used bycipher:LanesSizeUseris the analogue ofParBlocksSizeUser, andLaneBlocksofParBlocks.Blocks in, a final tail at the end
update_blocksaccepts whole blocks and nothing else; onlyfinalize_fixed_coretakes a sub-block tail. Absorbing whole blocks is what keeps the bulk of a message copy-free: blocks are read from wherever the caller already keeps them, and lanes are borrowed individually, so messages need not be contiguous with one another. Because finalize is the last call, it can take the loose tail and pad it without compromising that.This matters most for structured messages —
tag ‖ payload, as in Merkle leaves or any tagged record. A one-shotmulti_digest(msgs, out)forces the caller to materialize the concatenation first, copying the payload. Here a shared prefix is absorbed once for all lanes withupdate_blocks_shared, and each payload is absorbed where it already lives. The only bytes copied are the one or two final padded blocks, which do not exist in the message and must be built regardless.The equal-length invariant is in the types
Every method takes fixed-size arrays:
MSGSlanes ofBLOCKSblocks or aTAIL-byte tail. So all lanes advance in step by construction — unequal lengths fail to compile rather than panicking, and there is no run-time length check anywhere. The API is infallible, likeDigest.Output is written into caller-owned storage at the core layer, matching
FixedOutputCore. The API uses plain slices and hybrid arrays, withoutinout.Dispatch
Backends are static types; the core selects among them at run time. A single binary stays correct on any CPU while using the widest available implementation, and adding an instruction set later requires a new backend and dispatch arm rather than a change to these traits.
Open questions
LanesSizeUserexists to mirrorParBlocksSizeUserand has a single implementor-facing use; it could be folded intoMultiUpdateBackend. Mirror the convention, or collapse it?MultiUpdateCoreandMultiFixedOutputCoreare split asUpdateCoreandFixedOutputCoreare, leaving room for a futurefinalize_xof_core. Worth it, or merge for now?MSGS,BLOCKS, andTAIL; same-input-to-every-lane variants use a_sharedsuffix. Placeholders for review.