Skip to content

Add hwy/contrib/multiprec: fixed-width unsigned multiply (MulAdd52) - #3372

Open
tvost2 wants to merge 4 commits into
google:masterfrom
tvost2:feat/multiprec-contrib
Open

tvost2 wants to merge 4 commits into
google:masterfrom
tvost2:feat/multiprec-contrib

Conversation

@tvost2

@tvost2 tvost2 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Adds hwy/contrib/multiprec: a fixed-width unsigned multiply layer built on the MulAdd52 ops from #3365.

Design

  • Digits are 52 bits ("nails"), the largest width for which MulAdd52Lo/Hi are exact: for inputs < 2^52 the two together return the full 104-bit product. They exist on every target (native HWY_NATIVE_MULADD52, generic fallback otherwise), so there is no separate code path.
  • Layout is SoA: digit i of each operand is passed in its own vector, and the lanes of those vectors hold independent multiplications — the crypto use case (several RSA/ECC instances in parallel).
  • Schoolbook accumulation of the low/high parts of each product with per-digit carries.

API

  • WideMul<kDigits>::Mul(d, a, b, out) — SoA vector form; out has 2*kDigits digits.
  • WideMulBits<kBits> — bit-width form (kDigits = ceil(kBits/52)).
  • WideMulLimbs<kBits>(a, b, out) — scalar convenience over little-endian 64-bit limbs (e.g. u128/u192/u256).

Tests

  • WideMul<kDigits> for kDigits = 1..4, checked against a scalar reference on all targets.
  • WideMulLimbs<128/192/256> checked against an independent ripple-carry reference, plus maximum inputs.

Dependency

Depends on #3365 (MulAdd52); this branch is stacked on feat/muladd52, so the diff includes it until #3365 merges. Part of #3352.

Multiplies two unsigned integers of kDigits digits into 2*kDigits digits,
built on MulAdd52Lo/Hi. Digits are 52 bits ("nails"), the largest width for
which the two ops are exact (for inputs < 2^52 they return the full 104-bit
product), so no separate fallback is needed: the ops exist on every target
(native HWY_NATIVE_MULADD52, generic otherwise).

Layout is SoA: digit i of each operand is passed in its own vector, so the
lanes of those vectors hold independent multiplications - the crypto use
case (several RSA/ECC instances in parallel).

Adds the CMake wiring (HWY_CONTRIB_SOURCES + test list) and a test that
checks kDigits = 1..4 against a scalar reference on all targets.

Part of google#3352. Depends on the MulAdd52 ops from google#3365.
- WideMulBits<kBits>: bit-width form of the SoA API (kDigits = ceil(kBits/52)).
- WideMulLimbs<kBits>: scalar convenience that multiplies two kBits-bit
  integers given as little-endian 64-bit limbs, writing the 2*kBits/64-limb
  product (e.g. u128/u192/u256). Converts to/from the 52-bit digits and reuses
  the same schoolbook.
- Test the limb path (kBits = 128/192/256) against an independent ripple-carry
  reference, plus maximum inputs.
MulRec now uses Karatsuba's 3-way split for even digit counts of at least
kWideMulKaratsubaMinDigits = 8 digits:

  z0 = a_lo*b_lo
  z2 = a_hi*b_hi
  z1 = (a_lo+a_hi)*(b_lo+b_hi) - z0 - z2   // = a_lo*b_hi + a_hi*b_lo

i.e. 3 recursive multiplications instead of 4, giving ~O(kDigits^1.585)
instead of O(kDigits^2). Below the threshold, and for odd digit counts (whose
split would be unbalanced), the schoolbook kernel is kept.

The z1 subtraction reuses the z0/z2 already accumulated in `out`, so only s,
t, st and one temporary buffer per split are needed. Adds the helpers
AddDigitsCarry, AddInto and SubInto (the latter needs no separate sign
handling because the subtracted result is non-negative).

Test kDigits = 8 and 16 as well, still checked against the scalar reference.
@jan-wassenberg

Copy link
Copy Markdown
Member

Nice, this sounds sensible but I'm not experienced in this field. Can we first write a short 'market research' of existing approaches and both their APIs and their implementations, to help make sure our interface meets user needs?

On naming: do we intend to provide more such operations, or will it stay mul-only? If the latter, we should probably rename the directory.

Also, the test has arrays of vectors which does not compile on RVV/SVE. Let's replace with dynamically allocated arrays of T.

@tvost2

tvost2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Here is the market research you asked for (§1-3 are the survey, §4-5 the
conclusions). Short version:

  • Our API already matches what every serious fixed-width library does: width in
    the type, 2n digits out-of-place, caller-owned memory, no allocation. The
    outliers are the allocating high-level bignums (GMP mpz, OpenSSL BN,
    mbedtls_mpi), which are not a fit for this use case anyway.
  • The two things users will ask for next are Montgomery reduction and carry
    helpers (add/sub with carry, add-multiply-accumulate). That is also the
    argument in §5 for keeping a directory name that covers multi-precision
    arithmetic rather than multiply only.
  • WideMul<kDigits> sits where crypto-bigint::Uint<LIMBS> and
    ruint::Uint<BITS, LIMBS> sit, and the lane-parallel layout matches Intel's
    multibuffer model, which is the same RSA/ECC use case that motivated this.

Naming: I would keep multiprec, and give the reduction its own name when it
lands rather than growing WideMul.

The RVV/SVE test fix (arrays of vectors replaced by dynamically allocated arrays
of T) is next - it is what the four failing AArch64/RISC-V jobs in the current
run are hitting.


Market research: fixed-width multi-precision multiply

What existing approaches do, what their APIs and implementations look like, and
what that implies for hwy/contrib/multiprec.

1. The question

The interface has to serve a specific user: RSA/ECC-style code that multiplies
kDigits-digit operands, in parallel across SIMD lanes, with no allocation and
no data-dependent branches. So the useful comparison axes are:

  • how the width is expressed (type, template argument, runtime size argument),
  • where the result lives (2n digits out-of-place, in-place, returned),
  • who owns the memory (caller-supplied pointers vs. heap),
  • whether aliasing/overlap is allowed,
  • the digit/limb width, and whether digits are saturated or have spare bits
    ("nails"),
  • whether the library also provides the reduction step (Montgomery), which is
    what any real user does next with the product,
  • and whether it is lane-parallel (many independent instances) or single-instance.

2. Prior art

2.1 High-level, allocating bignums

mpz_* (GMP), BN_* (OpenSSL), mbedtls_mpi_*, num-bigint (Rust),
BigInteger (Java), int (Python). The API is result = a * b with heap
allocation, dynamic length, and often data-dependent behaviour (early exits,
variable limb counts). Convenient, but the wrong shape here: allocation is not
acceptable in crypto callers, and the length is not known at compile time.
mbedTLS' newer "core" API partially moves away from this with fixed-size
limb-array functions (mbedtls_mpi_core_mul and friends) — the same direction we
are taking.

2.2 Low-level limb arrays, caller-managed memory

This is the classic shape, and the closest relative of what we are adding.

  • GMP mpn_*: mpn_mul_n (rp, s1p, s2p, n) and
    mpn_mul (rp, s1p, s1n, s2p, s2n). A source is (pointer to least significant
    limb, count)
    , a destination is just a pointer, and the caller guarantees the
    space. The per-limb helpers are mpn_addmul_1 (rp, s1p, n, s2limb),
    mpn_mul_1, mpn_submul_1, each returning the carry rather than storing
    it. In-place is allowed only where documented, and partial overlap is not.
    There is also a "secure" family (mpn_sec_mul) with explicit scratch space and
    constant-time guarantees.
  • Nails. GMP's nail feature reserves a few top bits of every limb
    (GMP_NAIL_BITS, GMP_NUMB_BITS, GMP_LIMB_BITS). The manual makes two
    points that are directly relevant here: nails "can significantly improve carry
    handling on some processors", and a future non-zero nail "would help vector
    processors since carries would only ever need to propagate one or two limbs".
    Our 52-bit digits are that idea, with the spare 12 bits used to make
    MulAdd52Lo/Hi exact.
  • BearSSL ships the idea as the representation: i32 (32-bit limbs),
    i31 (31-bit limbs stored in uint32_t, chosen for portability and speed),
    i15 (for cores without a 64-bit multiply) and i62 (a 64x64->128 path for
    modular exponentiation). Multiplication is br_i31_mulacc / br_i32_mulacc
    with a documented working area, constant-time, and deliberately frugal in ROM
    and RAM.
  • OpenSSL / BoringSSL: BN_mul/BN_sqr at the high level, BN_MONT_CTX +
    BN_mod_mul_montgomery/BN_mod_exp_mont for the field, with a
    per-architecture bn_mul_mont assembly kernel underneath. The lesson is that
    Montgomery is the first thing layered on top, and that the low-level kernel
    is written for one fixed limb size per architecture.

2.3 Fixed-size, formally verified kernels

The modern high-assurance school converges on a very specific API shape: one
function per (operation, size)
, fixed width, out-of-place, no allocation,
constant-time, no partial overlap.

  • s2n-bignum (AWS): pure machine code for x86_64 and aarch64, one routine per
    size and operation (bignum_mul_4_8-style names), constant-time by
    construction, and every function carries a machine-checked HOL-Light proof of
    its mathematical result. Used by aws-lc.
  • HACL*: verified bignum arithmetic (fixed-size entry points plus a generic
    implementation), extracted from F*, used in Firefox and Linux.
  • fiat-crypto: generates field arithmetic per modulus from a Coq model
    (e.g. fiat_25519_carry_mul), saturated limb arithmetic with explicit carry
    chains, verified correct; deployed in BoringSSL (see its boringssl_notes.md).

2.4 Const-generic fixed-width types

  • crypto-bigint (RustCrypto): Uint<LIMBS>, U256, U1024, ... stack
    allocated, constant-time by default, and explicitly variable-time functions
    suffixed _vartime. The widening operations are the ones we care about:
    mul_wide, square_wide, plus mul_mod and Montgomery helpers.
  • ruint: Uint<BITS, LIMBS> with 64-bit limbs and aliases (U256),
    widening multiplication, and optional Karatsuba / Montgomery (REDC).

The takeaway: the width belongs in the type (or a compile-time constant), and
the widening multiply is the primitive everything else is built on.

2.5 Field-specialized, hand-written with nails

  • libsecp256k1: field_5x52_int128_impl.h stores a field element as five
    52-bit limbs
    , multiplies with 64x64->128 products and a hand-written carry
    chain, and asserts VERIFY_BITS on every intermediate to show nothing
    overflows. There is a 10x26 variant as well. Fixed width, no allocation, no
    branches. Same family as the Curve25519 "5x51" representations.
  • This is the strongest evidence for our digit choice: 52 bits is the largest
    width for which a product stays exact in the available 52-bit multiply
    primitives — the same reason we use MulAdd52.

2.6 SIMD / multibuffer libraries

  • Intel Cryptography Primitives (formerly ipp-crypto) and its Crypto
    Multi-buffer Library: RSA/ECDSA/ECDH/x25519/SM2 primitives that process many
    independent instances in parallel
    on AVX-512, which is precisely the use case
    in the PR description. Their memory model is arrays of instances — SoA across
    lanes, digits in separate registers — and the selling point is throughput on
    servers doing many handshakes.

Our WideMul<kDigits>::Mul(d, a, b, out), with one digit vector per operand and
independent multiplications in the lanes, is the same model, except that the lane
count comes from HWY_LANES rather than being fixed to 512 bits.

3. Comparison

Approach Width expressed as Result Memory Aliasing Digits Reduction provided Lane-parallel
GMP mpz / OpenSSL BN / mbedTLS mpi runtime length new object heap n/a 32/64 Montgomery (BN/mbedTLS) no
GMP mpn runtime n caller buffer, 2n limbs caller in-place if documented, no partial overlap 32/64 + nails separate mpn_* no
BearSSL i31/i32/i15/i62 runtime n per impl. caller buffer caller in-place 15/31/32 br_i31_montymul etc. no
s2n-bignum in the name (_4_8) caller buffer caller none 64 separate routines no
HACL* in the name / generic caller buffer caller none 32/64 separate partly (HACLxN)
fiat-crypto in the name, per modulus caller buffer caller none saturated in the "carry" functions no
crypto-bigint in the type (Uint<LIMBS>) returned tuple stack n/a 32/64 mul_mod, Montgomery no
ruint in the type (Uint<BITS, LIMBS>) returned tuple stack n/a 64 REDC no
libsecp256k1 in the type (fe = 5x52) in-place fe stack n/a 52 (nails) fe_mul includes it no
Intel multibuffer in the call (ifma_*) caller arrays caller none 52 (IFMA) separate yes
this PR in the type (WideMul<kDigits>) caller buffer, 2*kDigits digits caller none (documented) 52 (nails) not yet yes

4. What this means for our interface

Points of agreement with the field (keep them):

  1. Width in the type (WideMul<kDigits>, WideMulBits<kBits>) matches
    crypto-bigint, ruint and the field types; nobody in this space uses a
    runtime length for a fixed-width use case.
  2. Caller-supplied output and no allocation matches mpn, BearSSL,
    s2n-bignum, HACL*, fiat-crypto and the multibuffer libraries.
  3. 2*kDigits digits out-of-place is the universal convention
    (mpn_mul_n writes 2n limbs, bignum_mul_4_8 writes 8).
  4. Unsaturated digits with documented spare bits is GMP's nails and the
    52-bit representations of secp256k1 and IFMA. We should state the invariant
    as explicitly as GMP does (nails zero on entry and exit) — i.e. whether digits
    are normalized on input and on output.
  5. Lane-parallel equals multibuffer matches Intel's model, which is exactly
    the RSA/ECC-in-parallel use case.

Gaps worth closing before users arrive:

  1. Reduction. Every RSA/ECC user multiplies and then reduces (Montgomery).
    Without it they will reimplement the loop over our output. A MontMul/Redc
    helper (or at least a documented pattern) is the single most valuable
    addition.
  2. Carry helpers. Schoolbook accumulation needs add/sub with carry and
    add-multiply-accumulate (mpn_addmul_1-style). Cheap to provide, awkward to
    hand-roll on top of a Mul that only returns full products.
  3. Aliasing contract. Be explicit, as mpn is: may out alias a or b?
    Today the answer is no, and the doc should say so, since mpn users expect
    in-place to work "where documented".
  4. Squaring. Mul with a == b should be documented as the supported way to
    square (or specialized, since it is about 2x cheaper).
  5. Digit-count arithmetic. Spell out the mapping in the docs, since that is
    how users will pick kBits: 2048, 3072, 4096 and 8192 bits are 40, 60, 79 and
    158 digits of 52 bits.

5. On naming

The question was whether we intend to provide more such operations or stay
multiply-only, because that decides the directory name. The prior art says the
directory will grow: every serious library pairs the widening multiply with
Montgomery reduction and carry helpers, because that is what the caller needs
next. So:

  • keep a name that covers "multi-precision arithmetic" and state the roadmap in
    the header: multiprec does that. Alternatives: bignum collides with the
    high-level meaning above; intwide is narrower than where we will end up.
  • keep the type names specific: WideMul, WideMulBits, WideMulLimbs are
    honest as long as they only multiply. The reduction should get its own name
    (MontMul or similar) rather than growing into WideMul.

6. Sources

@tvost2

tvost2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

One more finding while preparing the test fix, because I do not think the test
alone is what the RVV/SVE jobs are hitting.

multiprec-inl.h also keeps vectors in arrays, which is equally ill-formed where
Vec<D> is a sizeless type (vfloat64m1_t/svfloat64_t):

// multiprec-inl.h, SchoolbookMul
Vec<D> low[2 * kDigits - 1];
Vec<D> high[2 * kDigits - 1];

and the public entry point takes them by array:

// multiprec-inl.h
template <size_t kDigits, class D>
HWY_INLINE void SchoolbookMul(D d, const Vec<D>* HWY_RESTRICT a, ...)

So replacing the test's arrays with arrays of T (as you suggested) is necessary
but not sufficient: the test would then just fail to compile inside the kernel
instead of at its own call site. Vec<D>* pointers are fine, but the local
low[]/high[] arrays are not, and neither is an array-typed parameter for the
digits.

Two ways out, and I would like your preference:

  1. Guard it now: keep the vector kernel for targets that can hold vectors in
    arrays (#if !HWY_HAVE_SCALABLE), and on scalable targets either omit the
    contrib or provide a T-based fallback. Cheap and unblocks the AArch64/RISC-V
    jobs immediately, at the cost of the contrib being unavailable where Highway
    is most portable.
  2. Make the interface T-based: Mul(d, const T* a, const T* b, T* out)
    with digits at a + i * N, Load/Store inside, and either a caller-provided
    scratch buffer for the 2*kDigits-1 partial products or a scalar loop on
    scalable targets. This matches the "arrays of T" direction and works
    everywhere, but the partial products must then live in memory rather than in
    registers, which is exactly where the IFMA win comes from.

My preference is (1) for this PR (so the target-restriction is explicit and
documented, like TestWideMul already is with #if HWY_TARGET != HWY_SCALAR),
and (2) as a follow-up if we want the contrib to be usable on SVE/RVV. Either
way I will do the test fix in the same commit as whichever we pick.

@jan-wassenberg jan-wassenberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the survey. I have heard of Montgomery reduction being central in this space, and agree the multiprec name makes sense.

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated
// low[k] is the sum of the low 52 bits of a[i]*b[j] over all i+j == k;
// high[k] is the sum of bits 52..103 of the same products. Both fit in 64
// bits because there are at most kDigits terms, each < 2^52.
Vec<D> low[2 * kDigits - 1];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to the SVE issue (no arrays of vectors), this may spill even for non-scalable targets for large kDigits. Comba's method loops over k and sums i+j = k and would only use 3 vectors.

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated
}

// s = a_lo + a_hi and t = b_lo + b_hi, each kHalf + 1 digits.
Vec<D> s[kHalf + 1];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also array of T with MaxLanes * kNumVectors.

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated

// Fixed-width unsigned integer multiplication for arbitrary precision.
//
// Digits are 52 bits ("nails"), the largest width for which the IFMA

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nails are the unused bits in the word/limb. Digits seems like confusing terminology, I'd suggest just using limbs and saying whether they are 52 or 64-bit.

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated
// `out` at digits 0 and 2*kHalf and nothing else has been added yet, so
// subtract them from there.
Vec<D> st[kSumDigits];
MulRec<kHalf + 1>(d, s, t, st);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we care about power of two kDigits? If so, that's often going to hit the schoolbook codepath because this is always odd.
I think we can either handle odd cases inside Karatsuba, or keep the recursion at kHalf by handling carries separately.

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated

// Multiplies two kDigits-digit unsigned integers, each digit < 2^52, into
// 2*kDigits digits. `d` must be a u64 vector descriptor; `out` must have room
// for 2*kDigits digits. Input digits at or above 2^52 are ignored (as in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually these inputs are no longer ignored, they must be < 2^52.

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated
}
}

// dst[0..kTotal) -= src[kOffset..kOffset + kCount), zero-extending src, with

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior of kOffset differs between Add and Sub - out vs src. Should we be consistent in one or the other?

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated
const Vec<D> sub = Add(sv, borrow);
const Mask<D> underflow = Lt(dst[i], sub);
dst[i] = And(Sub(dst[i], sub), mask);
borrow = IfThenElse(underflow, one, zero);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we compute borrow as the upper bit shifted right 63?

Comment thread hwy/contrib/multiprec/multiprec-inl.h Outdated
static_assert(kDigits <= 2048, "accumulators may overflow");
static constexpr size_t kNumResultDigits = 2 * kDigits;

template <class D>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably want HWY_IF_U64_D(D) here.

@tvost2

tvost2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 in 732c73c, one commit.

SchoolbookMul -> Comba. You are right on both counts: the 2*kNumLimbs-1
column arrays are arrays of vectors, and they spill. The kernel is now Comba's
method, which keeps only the carry plus a low/high accumulator pair live. While
doing it I re-derived the accumulator bound: with the carry from the previous
limb folded in, kNumLimbs <= 2048 can overflow by one step, so it is 1024 now,
with the arithmetic written out next to the assertion.

Arrays of T, in the API and the test. Mul now takes
const TFromD<D>* a, const TFromD<D>* b, TFromD<D>* out, with limb i of an
operand at a + i * N, and Loads/Stores inside the kernel. That is what makes
SVE/RVV work, and it removes the arrays of vectors from the test as you asked.

Karatsuba removed. Two reasons, both from your comments: its temporaries were
arrays of vectors, and its split is unbalanced for power-of-two limb counts, since
the a_lo + a_hi sums need kHalf + 1 limbs (an even size would fall back to
schoolbook anyway). Removing it also removes AddDigitsCarry/AddInto/SubInto,
so the inconsistent kOffset between Add and Sub, and the comparison-based borrow
in Sub, are gone with them rather than left as dead code. I would like to bring
Karatsuba back as a follow-up with T scratch and a split that handles the extra
limb, once we agree on the recursion — happy to do that now if you prefer.

digits -> limbs, with the 12 unused bits of each 64-bit word called nails, as
GMP does. Docs corrected on the two points you flagged: inputs must be below
2^52 (no longer "ignored above"), and out must not overlap a or b. Also
documented that the top result limb of WideMulLimbs may be partial.

HWY_IF_U64_D(D) added to Mul.

Build. BUILD and meson.build did not register the new contrib at all, which
would have failed the bazel and meson jobs as soon as anything compiled it; both
now have the header library, plus a test target in bazel. CMakeLists.txt already
listed the two files.

Extra coverage while in there: the vector test runs an all-ones round as well
(largest representable inputs, every carry), and static_asserts pin the bit-width
alias (WideMulBits<128> == WideMul<3>, <256> == WideMul<5>, <2048> has
80 result limbs).

Local: clean under -Wall -Wextra -Wconversion -Wsign-conversion, and the test
passes 10/10 (TestAllWideMul, TestAllWideMulLimbs) on SSE2, SSSE3, SSE4, AVX2
and EMU128. No arrays of vectors remain anywhere in the contrib, which is what the
AArch64/RISC-V jobs were failing on.

@tvost2
tvost2 force-pushed the feat/multiprec-contrib branch from 732c73c to 98504f6 Compare September 16, 2026 16:39
@tvost2

tvost2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment: I did not end up removing Karatsuba. It is back
in 98504f6 (still a single commit), done the way you suggested in the comment
about power-of-two sizes, and with the two helper fixes you asked for.

  • It recurses only at kHalf, never at kHalf + 1. The carries of
    a_lo + a_hi and b_lo + b_hi are tracked as separate 0/1 vectors and folded
    back in with branch-free masked adds, so the recursion stays on the same size
    and the base case is Comba. A power-of-two limb count no longer drops to
    schoolbook at every other level.
  • All of its temporaries are arrays of T (z0, z2, s, t, st, plus one limb
    holding the constant 1) in caller-provided scratch: no arrays of vectors and no
    allocation. The requirement is exposed as WideMul<>::kScratchLimbs, and the
    scratch-free Mul remains Comba.
  • One kOffset convention for AddInto, AddIntoIf and SubInto: it
    indexes the destination. SubInto now takes the borrow from bit 63 of the
    wrapped difference instead of comparing, which is exact because both operands
    are below 2^52 — your suggestion, and it removes the mask from the inner loop.

The test now exercises both paths for every size where kScratchLimbs is
nonzero (8 and 16 here) against the same independent reference, in addition to the
all-ones round. Local: -Wall -Wextra -Wconversion -Wsign-conversion clean,
10/10 passing on SSE2, SSSE3, SSE4, AVX2 and EMU128.

For the record, the review points that were about code that no longer exists
(whether kOffset should mean the same thing in Add and Sub, and the borrow) are
now answered by fixing that code rather than by deleting it.

@tvost2
tvost2 force-pushed the feat/multiprec-contrib branch from 98504f6 to 6fe22ac Compare September 16, 2026 17:26
@tvost2

tvost2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Montgomery is in (same single commit, 6fe22ac). Design notes, since you know this space better than I do:

  • MontgomeryN0(d, low limb of N) returns -N[0]^{-1} mod 2^52 per lane, by Newton iteration starting from the 3 correct bits an odd input gives; six steps cover 3/6/12/24/48/96 bits. It is taken on the low limb of the modulus, so callers compute it once per modulus and reuse it.
  • Montgomery<kNumLimbs>::Mul is CIOS (Koc): one row of the product alternating with one reduction step, so the working array is kNumLimbs + 2 limbs and no separate 2*kNumLimbs product is needed. Scratch is kScratchLimbs * Lanes(d) limbs.
  • It is branch-free, and moduli differ per lane, which is the point (several RSA/ECC instances in parallel): both the extra carry handling and the final conditional subtraction are per-lane selects. The subtraction runs twice so that N above R/2 is covered as well, and the borrow is bit 63 of the wrapped difference, as in SubInto.
  • The reduction row needs one care point worth spelling out: the low limb of t cancels exactly by construction of n0, but its sum can still carry into the next limb, so the carry has to come from the full sum (t[0] included) rather than from the high half of m*N[0] alone. That was the first bug my test caught.
  • The tests use an independent reference: modular multiplication by binary double-and-add over the same 52-bit limbs, with no Montgomery and no R. Cases: random operands, 0, 1 and n-1, a modulus above R/2, and a modulus whose low limb is 1 (so n0 is -1), across all lanes. The second bug it caught was subtraction limbs leaking bits above 2^52 when a borrow is repaid by a higher limb, so everything is masked now.

Local: 15/15 (TestAllWideMul, TestAllWideMulLimbs, TestAllMontgomery) on SSE2, SSSE3, SSE4, AVX2 and EMU128, clean under -Wall -Wextra -Wconversion -Wsign-conversion.

Still open, and I would rather ask than guess: conversion helpers. Multiplying in Montgomery form needs R^2 mod N to get in and out, and computing that needs modular arithmetic we do not have yet. Do you want that in this PR (a generic ModMul/reduction would be the building block), or is supplying R^2 mod N the caller a reasonable contract for now? I have documented it as the latter.

…ication

Addresses the second review round, without dropping any of the previous work.

- SchoolbookMul accumulated all 2*kNumLimbs-1 columns at once, which needs an
  array of vectors (not allowed on SVE/RVV) and can spill for large kNumLimbs.
  It is now Comba's method: for each output limb, sum the products a[i]*b[k-i],
  keeping only three vectors live.

- Karatsuba is kept, and now recurses only at kHalf instead of at kHalf + 1, by
  tracking the carries of a_lo + a_hi and b_lo + b_hi as separate 0/1 vectors
  and folding them back in with branch-free masked adds. All of its temporaries
  (z0, z2, s, t, st) are arrays of T in caller-provided scratch, so it no longer
  needs arrays of vectors and no longer falls back to schoolbook at every other
  level. The scratch size is exposed as WideMul<>::kScratchLimbs; the no-scratch
  Mul remains Comba.

- AddInto, AddIntoIf and SubInto all take kOffset indexing the destination, and
  SubInto derives the borrow from bit 63 of the wrapped difference instead of
  comparing (both operands are below 2^52, so the bit is exact).

- Added Montgomery modular multiplication. MontgomeryN0 computes -N[0]^{-1} mod
  2^52 per lane by Newton iteration, and Montgomery<>::Mul is a CIOS (Koc) kernel
  that alternates one row of the product with one reduction step, so the working
  array is only kNumLimbs + 2 limbs and no separate 2*kNumLimbs product is
  needed. It is branch-free: the final conditional subtraction is a per-lane
  select, run twice so that moduli above R/2 are covered too, and the borrow
  comes from bit 63 of the wrapped difference. Moduli and n0 may differ per lane,
  which is the use case (several RSA/ECC instances in parallel). Scratch is
  kScratchLimbs * Lanes(d) limbs.

- The API takes arrays of T (TFromD<D>) instead of arrays of Vec<D>, and the
  tests do the same, so everything works where vector types are sizeless.

- Renamed digits to limbs (52-bit) and noted that the 12 unused bits of each
  64-bit word are what GMP calls nails. Corrected the docs: inputs must be below
  2^52 (they are no longer ignored above that), out and scratch must not overlap
  the inputs, and the top result limb may be partial.

- Constrained the descriptor with HWY_IF_U64_D, and tightened the accumulator
  bound to 1024 limbs with the arithmetic spelled out (2048 could overflow with
  the carry from the previous limb).

- BUILD and meson.build now register the contrib (header library and test);
  CMakeLists.txt already listed both files.

- Tests: T-based API with no arrays of vectors, an all-ones round in addition to
  random inputs, the Karatsuba path checked against the same reference wherever
  kScratchLimbs is nonzero, static_asserts for the bit-width alias, and for
  Montgomery an independent reference (modular multiplication by binary
  double-and-add, with no Montgomery and no R) over random operands, 0, 1 and
  n-1, a modulus above R/2, and a modulus whose low limb is 1 so n0 is -1.

- Added MontgomeryR2, which computes R^2 mod n - the constant needed to enter
  Montgomery form - as 104*kNumLimbs modular doublings from 1, branch-free in the
  same style as the reduction: the carry out of the top limb and the borrow of the
  conditional subtraction become one per-lane select. With it callers need no
  modular arithmetic of their own:

    ToMontgomery(x)   = Montgomery<>::Mul(x, r2, ...)
    FromMontgomery(y) = Montgomery<>::Mul(y, one, ...)   // one = {1, 0, ...}

  Scratch is 2*kNumLimbs limbs per lane (MontgomeryR2ScratchLimbs).

- Tests for that path: R^2 against the reference, and the round trip
  x -> Mul(x, R2) -> Mul(., one) == x, for every size where it applies. The
  kernels were checked separately by a standalone sweep over 1, 2, 4 and 8-limb
  moduli, which also showed that the failures I hit were in the test's operand
  reduction rather than in the code under test: a single conditional subtraction
  is not enough for small moduli, and a reference that assumes a < n is circular.
  The operands are now reduced properly.

Local: compiles with -Wall -Wextra -Wconversion -Wsign-conversion, and 20/20
tests pass (TestAllWideMul, TestAllWideMulLimbs, TestAllMontgomery,
TestAllMontgomeryForms) on SSE2,
SSSE3, SSE4, AVX2 and EMU128. The Montgomery test caught two real bugs in the
first draft of the kernel: a dropped carry out of the cancelled low limb, and
unmasked subtraction limbs leaking bits above 2^52.
@tvost2
tvost2 force-pushed the feat/multiprec-contrib branch from 6fe22ac to 2f44c3f Compare September 16, 2026 18:45
@tvost2

tvost2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

R^2 is in (same single commit, 2f44c3f), so callers no longer have to supply it:

  • MontgomeryR2(d, n, out, scratch) returns R^2 mod n as 104*kNumLimbs modular doublings from 1, branch-free in the same style as the reduction: the carry out of the top limb and the borrow of the conditional subtraction become one per-lane select. Scratch is MontgomeryR2ScratchLimbs<kNumLimbs>() * Lanes(d) limbs.

  • With that, both conversions are Montgomery products:

    ToMontgomery(x)   = Montgomery<>::Mul(x, r2, ...)
    FromMontgomery(y) = Montgomery<>::Mul(y, one, ...)   // one = {1, 0, ...}
    

    which answers the question I had left open: neither direction needs a generic modular multiplication, so the "caller supplies R^2" contract is gone. I have documented both conversions next to the helper.

  • Tests: R^2 against the independent double-and-add reference, and the round trip x -> Mul(x, R2) -> Mul(., one) == x, for every size the helper applies to.

Worth recording, because it cost me a detour: the first version of these tests failed, and the failures were in the test rather than in the kernel. It was reducing operands with a single conditional subtraction, which is only valid when n exceeds half the limb range, and my first replacement reference was circular - it assumed a < n while being used to establish a < n. I ended up checking the kernels separately with a standalone sweep over 1, 2, 4 and 8-limb moduli, 300 random cases each, which passed and pointed back at the test. Operand reduction is proper now, and the suite is 20/20 (it was 15/15).

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.

2 participants