Skip to content

Add traits for hashing many messages at once - #2479

Open
npmccallum wants to merge 2 commits into
RustCrypto:masterfrom
npmccallum:feat/multi-digest
Open

Add traits for hashing many messages at once#2479
npmccallum wants to merge 2 commits into
RustCrypto:masterfrom
npmccallum:feat/multi-digest

Conversation

@npmccallum

@npmccallum npmccallum commented Aug 3, 2026

Copy link
Copy Markdown

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 Digest and 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:

  1. MultiUpdateBackend hides 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.

  2. MultiUpdateCore / MultiFixedOutputCore hide 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 by cipher: LanesSizeUser is the analogue of ParBlocksSizeUser, and LaneBlocks of ParBlocks.

Blocks in, a final tail at the end

update_blocks accepts whole blocks and nothing else; only finalize_fixed_core takes 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-shot multi_digest(msgs, out) forces the caller to materialize the concatenation first, copying the payload. Here a shared prefix is absorbed once for all lanes with update_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: MSGS lanes of BLOCKS blocks or a TAIL-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, like Digest.

fn update_blocks<const BLOCKS: usize>(&mut self, msgs: &[&[Block<Self>; BLOCKS]; MSGS]);
fn update_blocks_shared<const BLOCKS: usize>(&mut self, blocks: &[Block<Self>; BLOCKS]);
fn finalize_fixed_core<const TAIL: usize>(&mut self, tails: &[&[u8; TAIL]; MSGS], out: &mut [Output<Self>; MSGS]);

Output is written into caller-owned storage at the core layer, matching FixedOutputCore. The API uses plain slices and hybrid arrays, without inout.

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

  • Trait count. LanesSizeUser exists to mirror ParBlocksSizeUser and has a single implementor-facing use; it could be folded into MultiUpdateBackend. Mirror the convention, or collapse it?
  • Splitting update from finalize. MultiUpdateCore and MultiFixedOutputCore are split as UpdateCore and FixedOutputCore are, leaving room for a future finalize_xof_core. Worth it, or merge for now?
  • Naming. Const parameters are MSGS, BLOCKS, and TAIL; same-input-to-every-lane variants use a _shared suffix. Placeholders for review.
  • A byte-buffering convenience. Accepting arbitrary byte lengths means buffering a partial block whose length is only known at run time, which cannot be reconciled with the compile-time equal-length guarantee without either a runtime check (making the API fallible) or per-length monomorphization. It seems better to keep these traits infallible and leave a byte-oriented wrapper to an implementation crate. Agree, or should a wrapper live here?

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.
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.
@npmccallum

Copy link
Copy Markdown
Author

Thanks for the review.

On one-shot vs incremental: our use case needs incremental. The consumer hashes domain-separated Merkle leaves (tag ‖ page, 4 KiB payload). A one-shot multi-message method would force us to concatenate tag ‖ page per lane — a 4 KiB copy per message that otherwise never exists — which cancels most of the multi-buffer speedup. Incremental lets us absorb the shared tag block and then borrow the page blocks in place. So this isn't just an early trait for its own sake; it's driven by a zero-copy requirement one-shot can't meet.

On postponing the traits: happy to land this behind an unstable-gated feature so the surface stays revisable until more crates adopt it.

On the backend trick: agreed, and I'll align with cipher's closure-based dispatch (fn call<B: Backend> + a with_backend-style entry point) rather than the inline cfg_if selection currently in the sha2 side, so backend selection lives in the trait crate. Note the current design already carries the backend trait and runs a monomorphized generic driver after the runtime check — the closure formalizes and centralizes that.

On &[&[u8; TAIL]; MSGS] for finalize: this is deliberate, not incidental. Multi-buffer hashing requires every lane to absorb the same number of bytes, so that equal-length invariant has to be enforced somewhere. The two options are a compile-time guarantee via const generics, or a runtime check — and a runtime check is either a bare panic (low value) or forces every method to become fallible, which discards the infallibility that mirrors Digest. The multi-buffer use cases I'm aware of all have message sizes fixed at compile time, so encoding the invariant in the types costs callers nothing in practice while keeping the API zero-copy and infallible.

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