Conversation
Encrypt and Decrypt accumulated the inner product across the whole dimension before reducing once at the end, letting the accumulator reach dim*q. That overflows whenever dim*q >= 2^NATIVE_SIZE, which a ciphertext held at the ring modulus Q reaches: STD128 gives N*Q = 2^37 against a 32-bit word. The overflow does not change the decrypted value -- the corruption is ~10^5 against a q/8 decision threshold of 1.68e7 -- so no correctness test can see it. It does shift the phase by a multiple of 2^32 mod Q, silently invalidating any noise measurement taken on such a ciphertext. boolean-pke.cpp reaches this on STD128 in a 32-bit build. Reduce inside the loop in both routines, as EncryptN already did. Encrypt's initial b also needed reducing, since (m % p) * (q / p) plus a Gaussian sample can reach 2q and ModAddFastEq requires reduced operands. KeySwitchGen guarded the same accumulator on NATIVE_SIZE rather than on the bound it actually depends on, so a 32-bit build took the reduced path for every parameter set even though only the three with a prime modKS need it. Test (n+1)*qKS against the word instead, hoisted out of the loops: unchanged at NATIVE_SIZE=64, and the fast path is now available to 32-bit builds wherever it is safe.
Encrypt, EncryptN and Decrypt each opened with a condition of the form . The right operand is , so the condition is unconditionally false and the guards have never fired. They cannot simply be repaired. Read as intended -- , i.e. reject a plaintext modulus that does not tile an even ciphertext modulus -- they reject 3-input gates, which use p = 6 and therefore divide no power-of-two q. Enabling them fails 12 AND3/OR3 tests across all three accumulators while leaving every other configuration untouched, since 2- and 4-input gates use p = 4 and p = 8. The misalignment they would have flagged is real but small: message m sits m*frac(q/p) below its cell centre, costing ~1.4% of the decryption threshold at 3-input STD128. The alternative alignment, p = 8, would cost 25% of the threshold, so p = 6 is the better choice and the drift is the price of it. A TODO at the encoding site records the rounded placement that would halve the remainder.
Adds an opt-in internal 32-bit execution path for BinFHE: when the ring modulus fits MAX_MODULUS_SIZE32 = 28 bits, bootstrapping keys are stored in 32-bit words and the whole bootstrap runs at 32-bit width -- blind rotation for GINX, AP and LMKCDEY, and the LWE key switch -- while ciphertexts and the public API stay at the native 64-bit types. Outputs are bit-identical to the 64-bit path. BTKeyGen(sk, mode, internal32=true) enables it at key generation and BTKeyLoad(key, internal32=true) for deserialized keys. Each key qualifies independently -- MSB(Q) <= 28 for the refreshing key, qKS in a 32-bit word for the switching key -- and falls back to the 64-bit form otherwise, so the flag is safe on any parameter set, and sets whose Q is too large (STD192, STD256) still get the switching key, their larger object. Gates speed up 1.7-2.6x depending on compiler and method (the CGGI accumulation uses a lazy inner product with one modular reduction per gadget inner product, which a 64-bit accumulator word makes possible -- this path can outrun a genuine NATIVE_SIZE=32 build), and resident key memory roughly halves on every shipped set (STD128 549 -> 292 MB, STD256 4320 -> 2343 MB). Serialization keeps the 64-bit wire format: GetRefreshKey/GetSwitchKey widen a 32-bit key on demand and cache it, so existing serialization code works unchanged; CompressBTKeys() releases the 64-bit copies again and AllocTrim() returns the pages. The accumulator algorithms shared by both widths (signed digit decomposition, the DM/LMKCDEY accumulation and automorphism key switch, both scheduling loops) now live once as templates in rgsw-acc-common.h; the 64-bit accumulators delegate to them. Core gains the NativeVector32/ NativePoly32 machinery and MAX_MODULUS_SIZE32 behind NATIVEINT != 32. Also fixes two pre-existing bugs: ClearBTKeys() left BSkey32 resident, and CompressBTKeys() never freed the 64-bit refreshing key because the key map aliases m_BTKey.
The accumulators at both widths (AddToAccCGGI and AddToAccCGGI32, the shared AddToAccNoMonomial and AutomorphismKeySwitch bodies) allocated and zero-initialized roughly a dozen polynomials per blind-rotation step -- n times per bootstrap -- although every buffer is fully overwritten before it is read. They now keep per-thread scratch across steps, resetting format tags with OverrideFormat instead of reallocating, and the automorphism key switch transforms before zeroing instead of copying first. The per-step allocations serialized on the allocator under threads, so the gain concentrates there: 32-bit gates +2-4% at one thread and +13-21% at eight (STD128: clang 21.1 -> 18.6 ms, gcc 21.2 -> 17.7 ms); 64-bit gates +1-2% and +5-10% (STD128 8t: clang 39.6 -> 36.2 ms, gcc 44.0 -> 40.0 ms). The 32-bit GINX monomial multiply moves from the Barrett vector loop to a Shoup multiply against cached per-element constants: the crypto params build a precon table alongside the 32-bit monomials (~8 MB per GINX context, about 3% of compressed STD128 keys), and ShoupMulEq32 uses one high-word estimate per lane, which vectorizes at every ISA where the Barrett form does not (probed 1.4-2.1x per element across gcc-13/14/clang-18 at SSE2 through AVX-512). GINX gates gain another 1-4%.
BTKeyGen(internal32=true) previously generated every key at 64 bits and narrowed it. All of it now runs on the 32-bit types directly: the discrete Gaussian and uniform samplers are instantiated at NativeVector32, KeyGenCGGI/DM/LMKCDEY/Auto gain file-local 32-bit twins (the secret NTT is narrowed once per key generation), and the switching key is sampled natively -- its inner product multiplies the fixed secret, so the secret's Shoup constants are precomputed once and the reduced products accumulate lazily in a 64-bit word (bound (n + 1) * qKS < 2^43). Keys generated this way follow the same distributions but consume the PRNG in a different order, so they are not bit-comparable to narrowed 64-bit keys; correctness is verified by truth tables across all methods and parameter sets, including the mixed STD192 path. Measured on 8 threads, key generation improves 6-11% (clang) and 9-18% (gcc) across GINX/LMKCDEY/DM -- modest, because key generation is dominated by PRNG sampling, which is width-independent; the 32-bit width only accelerates the NTT fraction. The change also removes the transient 64-bit key material from the keygen path entirely. Also guards the lazy 64-bit widening in GetRefreshKey/GetSwitchKey with a mutex: two threads triggering the first widening concurrently raced on the cached shared_ptr assignment.
DiscreteUniformGenerator drew each value as full 32-bit chunks plus a bounded top chunk, retrying when the assembled value reached the modulus. The bounded draw pays std::uniform_int_distribution's internal division on every sample, and when the modulus barely spills into its top chunk the bound quantizes badly: a 33/34-bit modulus rejects a third of all draws and runs 1.7x slower than it should. The sampler now draws exactly ceil(MSB/32) raw full-range words, rejects draws at or above the largest contained multiple of the modulus (precomputed in SetModulus), and reduces. Every residue then appears exactly floor(2^(32c)/q) times, so the output is exactly uniform; the rejection probability is (2^(32c) mod q)/2^(32c) -- at most a few percent for a modulus just above a power of two, typically far smaller -- and a retry is just another cheap draw. Everything fits the integer's own width, so one code path serves the native backends at every size and the big-integer backends alike. The all-ones draw-domain constant is built by seeding the first chunk rather than shifting into place, because a shift by the full word width (the one-chunk case at 32 bits) is undefined. Measured per sample against the real PRNG (which dominates at ~18 ns per word): 19-26% faster at 32-bit moduli, 28-37% at u64 moduli up to 30 bits, 7-15% at 59-bit, and 1.65-1.7x on the bad-chunk-fill class (moduli whose MSB is 1-2 bits into a new chunk), on both gcc and clang with no cell regressing. BinFHE internal-32 key generation gains a further 6-9% on top of the native-keygen change. The sampling sequence differs from the old sampler, so seeded streams produce different (equally uniform) values; no tests pin seeded outputs. Also guards two raw integer-literal shifts found auditing shift-width UB after the above: 1 << logQ in CKKS scheme switching (UB at logQ >= 31 through a public parameter) and 1 << baseBits in BaseDecompose (UB at baseBits >= 31).
From reduced inputs, the forward FTT butterfly's values grow by 2*modulus per stage, so when (2*stages + 3)*modulus fits the word the per-butterfly conditional subtract can be dropped from every stage; the peeled final stage folds the values back down through a fixed chain of halving multiples of the modulus (each step is a multiple of the modulus and steps at or below the value are no-ops, so residues and therefore outputs are untouched). The current schedule remains for moduli without the headroom -- notably 60-bit towers -- and for clang without wide lanes at 64-bit words, the one measured cell where the lazy schedule ran slower. Every eligible transform covers the shipped BinFHE sets (27-28-bit Q at ring dimensions up to 2048) and 64-bit moduli up to ~58 bits. Measured per 32-bit transform: gcc 10-13% faster, clang 2-7%; on BinFHE gates gcc gains 3-6% at both widths. Outputs are bit-identical throughout (12/12 across all compiler/flag/thread combinations), truth tables 264/264, core/binfhe/pke suites green, and the lazy-ineligible 60-bit NTT rows measure unchanged.
The scheme-switching BinFHE context generates its bootstrapping keys with internal32 enabled. Qualification is per key: the refreshing key stays 64-bit on every scheme-switching configuration (its RGSW modulus is ~2^54 by construction for large-precision sign evaluation), while the switching key always qualifies (qKS = 2^15), so this is the same mixed path STD192/STD256 already use -- the switching key is stored on 32-bit words at roughly half the memory and the key-switch step runs the 32-bit path. All 71 scheme-switching unit tests pass on clang and gcc, including the serialization cases that exercise the on-demand widening of the switching key.
GetRefreshKey and GetSwitchKey returned references, which forced them to cache the 64-bit copy they widen from a 32-bit internal key: the cache pinned roughly half a key set of extra memory until an explicit CompressBTKeys(), it made the getters mutate shared state under const (racing on concurrent first access -- the interim mutex for that also deleted the context's copy and move operations), and serializing after an internal32 key generation silently held both key forms. The getters now return the shared pointer by value: a 64-bit key is widened directly into the returned handle and lives only as long as the caller holds it, so serialize-then-drop returns to the 32-bit footprint with no release step, there is no shared mutable state and hence no race and no mutex, and the context keeps its value semantics. When the 64-bit key is already resident the getters return it as before at the cost of a reference count. Every in-tree caller consumes the getters inside a single expression and compiles unchanged.
…m by default BTKeyGen and BTKeyLoad took internal32 = false, so the 32-bit key path shipped switched off and only a caller who had read the parameter got the benefit. Both now default to true. Nothing has to qualify for this to be safe: each key converts only when its own moduli fit, and a key that does not fit is generated in the 64-bit form exactly as before, so no parameter set changes behaviour beyond getting faster. Coverage is wider than the refresh key alone suggests -- the switching key qualifies for every shipped set because qKS is 2^14..2^17 independent of Q, so STD192 and STD256 halve their switching keys (STD192's is the 1.29 GB object) even though their refresh keys stay 64-bit. The STD128 family additionally runs the blind rotation on 32-bit words, which is where the gate time goes: gates are 2.1-3.3x faster depending on parameter set and thread count, and resident key material is about 1.9x smaller. Gate outputs stay bit-identical and the noise is unchanged. Two consequences a caller can see. Keys generated under this default are not bit-identical to keys generated before it, because the 32-bit path samples natively rather than narrowing a 64-bit key -- both are valid keys, but a test that pins key bytes against a fixed PRNG seed will differ. And generating keys and then serializing them holds a transient widened copy, so that particular sequence peaks higher than it used to even though the steady-state footprint is much smaller. Also documents the parameter on BTKeyGen, which never described it, and drops a duplicated doc comment above BTKeyLoad.
AddToAccNoMonomial, which DM and LMKCDEY both call once per LWE coefficient, ran one parallel region for the forward NTTs surrounded by three serial phases: the two inverse NTTs, the digit decomposition, and the two-column gadget product. The external product for CGGI already had the fused shape; the two schemes that share this helper never got it, and the automorphism key switch was worse still, with its two multiply-accumulate chains fully serial after a region of its own. Both now run as a single region: the inverse NTTs, the decomposition in a single block, the forward NTTs, and the columns as independent chains. Bit-identity is structural, since each chain keeps its own operation order and only the two chains overlap. The columns can no longer share the in-place reuse of the first decomposed digit that GadgetMatrixProduct performs, because both would write it, so the fused path multiplies out of place for both columns. A parallel region is also no longer entered at all when the thread limit resolves to one. That path previously paid team setup once per LWE coefficient to run the loop body on a single thread. Gates on the affected parameter sets, versus the parent commit, min of five, pinned, on two machines and both OpenMP runtimes. Under libgomp: AP 1.46x and LMKCDEY 1.63-1.74x at 64 bits, 1.21x and 1.71-1.78x at 32 bits. Under libomp: AP 1.36-1.37x and LMKCDEY 1.25-1.26x at 64 bits, 1.06-1.09x at 32 bits. Equal at 8 and 36 threads, so the two-wide chain phase is not yet the constraint. CGGI sets, which route around this code, move 0.98-1.04x and serve as the control. Single-threaded gates gain 1-4% from the region guard. The gain is largest under libgomp because that runtime charges most for barriers, and it removes rather than mitigates a 1.45-1.58x penalty it was imposing on LMKCDEY: after this change the two runtimes are within 13% at 64 bits, and libgomp is the faster of the two at 32 bits.
…forms Also restores the members that LWECryptoParams silently dropped when copied. The key-switching modulus and its noise generator had been commented out of both the copy constructor and the assignment operator, and the assignment operator additionally omitted the secret key distribution, so a copied parameter set lost its key-switching modulus and reported the wrong key-switching digit count. Nothing in the library copies these today.
PreCompute's sign-evaluation branch precomputed only the three bases Change_BaseG switches between, and assigned the live m_Gpower vector only when the context's own base happened to be one of them. Any other base left it empty, and the first key generation then indexed an empty vector: a SIGSEGV with no exception. The overload taking explicit n, N, q and Q hardcodes sign evaluation on, so every context built through it with a conventional gadget base crashed on its first BTKeyGen. The parameter-set overload passes false, which is why the examples and the unit tests never reached it. The two branches are now one: the context's own base is always precomputed, and sign evaluation adds the three switchable bases rather than replacing the base in use. PrecomputeGPower computes the same values and memoizes, so every configuration that worked before is unchanged. This also repairs Change_BaseG to the context's own base, and a per-dimension base map combined with sign evaluation, both of which threw because the base was missing from m_Gpower_map. The defect predates the per-dimension gadget base work; built at 2f499f5 it crashes for bases 32, 128 and 512 while the same base reached through the parameter-set path succeeds. Verified over all 44 shipped parameter sets across three methods, with the previously crashing bases now evaluating gates correctly.
The approximate gadget decomposition ignores the first digit, so a base large enough to represent the modulus in a single digit yields an external product with no rows at all. Such a context was accepted, and the failure surfaced much later inside EvalBinGate as "No values in PolyImpl" from a polynomial that was never sized. The digit count is now checked where it is computed: in both constructors, for every base in a base map, since each LWE index carries its own digit count and any one of them can be degenerate, and in Change_BaseG before it mutates, so a rejected switch leaves the object as it was. The message names the base and the reason. Every shipped parameter set already satisfies this: all 44 of them across three bootstrapping methods produce at least two digits.
The rest of the suite exercises whatever the defaults do. Keys whose moduli fit 32 bits are now held and evaluated in a narrowed form by default, and every parameter set those tests use qualifies, so nothing in the suite runs at the native word size any more. That happened silently when the default changed, since no test names the setting. These tests ask for each representation explicitly rather than inferring it from whether a parameter set qualifies, which is a property of the moduli and shifts whenever a modulus or a default does. Three run the gates at the native width. Three more require the narrowed form to agree with it bit for bit, evaluating before and after the conversion with one key and one set of inputs, since agreeing with the native result is a stronger claim than working alone. Two cover keys sampled directly in the narrowed form, which are not bit-comparable and are checked against the truth tables instead. The last three cover the boundaries: a set whose modulus is too wide to narrow the refresh key but whose key-switching modulus still fits, narrowing at load time, and the serialization getters, which must widen into the handle they return without the context giving up its own narrowed keys. A NATIVE_SIZE=32 build has no narrowed form to select, so the native size is what the rest of the suite already covers and this file compiles to nothing there.
CompressBTKeys converts the bootstrapping keys to their 32-bit internal forms. It began as a way for a test to narrow an existing key and ended up as the implementation of a default path, since BTKeyLoad calls it when internal32 is requested, but it was never something an application should reach for: BTKeyGen and BTKeyLoad already select the representation, and the two accessors report which one is held. Leaving it public would have meant publishing a return value that cannot be read correctly. False means the keys were already narrowed as much as it means they do not qualify, and after the default changed the first of those is the ordinary case. It was also the only part of this interface that disappears in a 32-bit build, where the accessors are simply present and answer false. The method moves to the private section unchanged. Nothing outside the library used it, and it is not in a release, so this decides what to publish rather than withdrawing anything. A caller that generated keys at the native width and then wants them narrowed still has a supported route through BTKeyLoad, which is what the test now uses.
Four scheduling changes to the DM/LMKCDEY external-product regions in rgsw-acc-common.h, none of which alters the computed values: - Cap the region width at half the gadget width (minimum 4) so every worker holds at least two digit transforms. Under static scheduling, a thread holding exactly one chunk has nothing left to overlap the next barrier wait; libgomp punishes that shape severely (up to 4.8x vs libomp on a saturated 8-core box, with bimodal run-to-run variance, and 1.5-2.9x on a 72-core box even with most cores idle). With the cap the penalty and the bimodality disappear, libomp gains 5-14% where the cap binds, and the default thread count lands on what previously required hand-tuning OMP_NUM_THREADS. - Spread the digit decomposition across the region's threads (at the price of one extra barrier) when the gadget is wide and at least four threads share it; below that the extra barrier is not repaid. - Block the 32-bit lazy inner-product chains over coefficient ranges instead of the two accumulator columns, so the chain phase scales with the region instead of stopping at two threads. - Drop the barrier after each region's final worksharing loop (nowait); the region's own closing barrier is the one the caller needs.
LWESwitchingKey32Impl stored its key in value-initialized std::vectors, so constructing the key faulted and zeroed the whole allocation (769 MiB at STD256) on one thread before the parallel generation loop rewrote every element. That serial prologue cost more than the generation itself at high thread counts (1.10 s against a 0.65 s parallel loop at 36 threads, and it placed every page on the constructing thread's socket), which made 32-bit key generation up to 2.7x slower than the 64-bit path at 36 threads even though it is the faster path serially. The storage is now default-initialized unique_ptr arrays, so the pages are first touched by the threads that write them: the serial prologue drops to zero and STD256 key generation at 36 threads goes from 2.0 s to under 1.0 s, within a couple hundred ms of the 64-bit path; at one thread the narrow form keeps its ~2 s advantage. Both fill paths write every element, the raw buffer is never serialized (the wire format goes through Widen), and the generated values are unchanged.
The half cap cured the OpenMP penalty for a different reason than its
commit stated: the cost is not workers holding single chunks, it is the
runtime reconfiguring its thread team whenever consecutive parallel
regions request different counts. LMKCDEY is the only method that
alternates two region sizes per bootstrap (the accumulator asked for
digitsG2 threads, the automorphism key switch for half that), which is why
it was the only method with the penalty -- and why capping GINX, a
single-shape method, only cost time. The cap worked by making the two
sizes coincide as a side effect, and paid for it with half the region
occupancy.
The distinguishing experiment ran all four combinations of {single-chunk
workers} x {alternating sizes}: equal sizes with every worker holding one
chunk is completely healthy, while differing sizes with every worker
holding two chunks reproduces the full penalty on both runtimes --
including the one cell where the two expressions happen to coincide and
the penalty vanishes with them.
This commit keeps the sizes equal by construction and restores the full
width: the accumulator regions return to digitsG2 threads and the
automorphism key switch requests exactly the same count (its digit count
is one less than the accumulator's, so digitsG << 1 == digitsG2). Against
the capped code on the shipped LMKCDEY sets at 36 threads this is 6-11%
faster on both OpenMP runtimes, ties or wins on a saturated 8-core grid,
and recovers the 2-4% the cap had cost the largest-ring sets under
libomp. GINX and DM run a single region shape throughout and are
untouched.
LWECiphertextImpl::SetModulus reduced m_a through the vector ModEq, which applies a centered correction when the modulus shrinks by less than two, and m_b through the integer ModEq, which reduces plainly. The two interpretations disagree about what a value above the old midpoint means, and an LWE ciphertext needs the same one for both halves. No shipped path reaches the disagreement: the only shrink BinFHE performs is exactly two-fold, which takes the plain branch for both halves. Verified no-op on every ratio the library uses, against roughly half the coefficients differing on any shrink below two-fold.
The 41 STD*/LPF* sets give way to 105 selected by the lattice-estimator search against this branch's measured gate times and a noise model validated on it: every (security level, gate arity) cell at failure targets 2^-64 and 2^-128, for GINX, LMKCDEY and now AP, which gets its own *_AP family. The LPF_STD256Q_4 cells have no admissible configuration at N = 2048 and are absent. Every row sits at 25 to 28 bits with a key-switching modulus of at most 2^19, so both bootstrapping keys take the 32-bit path on every predefined set; the rows that previously carried 37- and 50-bit moduli are where the table pays off. Two-base gadget maps take the smaller base as gadgetBase, which is what the automorphism keys decompose with. The enum is generated from one list, BINFHE_PARAMSET_LIST, which also produces the name table, convertToBINFHE_PARAMSET (the counterpart of core's convertToSecurityLevel) and the method mask that isMethodCompatible checks: *_AP and *_LMKCDEY sets are bound to their method, unsuffixed sets are tuned for GINX but stay open to every method for backward compatibility. Enumerator values change with the reordering, so anything that persisted a BINFHE_PARAMSET as an integer must store the name instead.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
BinFHE's bootstrapping keys and the operations that consume them now run on 32-bit
words whenever the parameters allow it, inside an ordinary 64-bit build. Nothing about
the scheme changes: the same keys, the same gates, the same noise. What changes is the
width of the machine words the blind rotation and key switch actually execute on, and
the amount of memory the keys occupy while they do it.
Most shipped parameter sets have a bootstrapping modulus of 27 or 28 bits and a
key-switching modulus between 2^14 and 2^17. Both fit comfortably in 32 bits, yet every
coefficient was stored and multiplied as a 64-bit integer. Halving the word width halves
the resident key material, doubles the number of coefficients in each cache line and each
vector register, and lets the NTT inner loops vectorise at every instruction set rather
than only where 64-bit lanes are available.
Measured against the branch base across all 40 shipped GINX and LMKCDEY parameter sets, both
compilers, both OpenMP runtimes and 1/8/36 threads — 240 cells, zero regressions — parameter
sets whose keys take the 32-bit path run 2.4 to 4.0 times faster on one thread and up to 7.9
times faster at 36 threads; sets that keep a 64-bit refresh key still gain 1.5 to 2.7 times
multi-threaded from the accumulator and scheduling work below. In absolute terms, a default
STD128 boolean gate now takes 17-18 ms on a 36-core machine (78-94 ms before this branch)
and 33-35 ms on a single thread (95-126 ms), with the fastest LMKCDEY sets also clearing
20 ms. Those figures predate the final region-scheduling commit, which adds a further 6-11%
on the wide LMKCDEY sets at high thread counts. Resident key material is about 1.9 times
smaller, and gate outputs are bit-identical to the 64-bit path throughout.
How it is built
The central idea is that the 32-bit form is an internal representation, not a new
scheme, a new key type or a new wire format. Everything an application can observe is
unchanged.
Qualification is per key and automatic. A refresh key converts when its modulus fits
the 32-bit accumulator's headroom; a switching key converts when its key-switching
modulus fits 32 bits and the lazy accumulator cannot overflow. Neither depends on the
other, so a parameter set can legitimately end up mixed. STD192 is exactly that case: a
37-bit modulus keeps its refresh key at native width while its 2^15 key-switching modulus
still halves the key switch, which is where its bulk lives. A key whose moduli do not fit
is generated at the native width exactly as before, so no parameter set changes behaviour
beyond getting faster.
Keys are generated directly in the narrow form rather than built wide and converted.
The 64-bit key is never materialised, so peak memory during key generation drops as well
as steady state.
The wire format is untouched. Serialization getters widen a fresh 64-bit copy into the
handle they return, so serialized keys are byte-compatible with existing readers and the
narrow form never escapes the process that built it. The returned copy lives only as long
as the caller holds it.
The accumulator gained a width-parameterised path alongside the existing one, with the
32-bit inner products accumulating lazily in a 64-bit word and reducing once per output
coefficient instead of once per digit.
It compares favourably with a genuine 32-bit build
Running narrow inside a 64-bit build is not a compromise against building the library at
NATIVE_SIZE=32, and past a point it is better. Measured at one thread on one machine, back toback:
NATIVE_SIZE=32The reason is that the build system forces
HAVE_INT128off wheneverNATIVE_SIZEis 32, so agenuine 32-bit build cannot use a 128-bit reduction at all, on hardware that supports one. This
path lives in a 64-bit build and keeps that option, which is what makes the lazy inner product
worth having. Against that, it pays a narrow-and-widen conversion around each gate that a real
32-bit build does not, so at four gadget digits the conversion is not repaid and the genuine
build stays marginally ahead. The two break even around six digits and by eight this path wins
outright, since the lazy reduction's gain grows with the digit count while the conversion cost
does not.
The practical consequence is that BinFHE no longer needs a separate 32-bit build to get 32-bit
performance, which matters because such a build changes the word size for the whole library
rather than for the keys that can use it.
Beyond the width change
Three pieces of work stand on their own and would be worth having regardless.
The NTT inner loops now auto-vectorise. They previously did not, on either compiler,
because a 32-bit induction variable combined with a runtime offset made the dependence
analysis non-affine. Correcting the induction types and compacting the per-stage twiddle
factors let both compilers vectorise the butterflies, and the deferred-reduction schedule
below lets the Cooley-Tukey butterfly skip its conditional subtraction entirely when the
modulus has the headroom to absorb the growth.
The DM and LMKCDEY accumulators no longer serialise their gadget product. Each external
product ran one parallel region for the forward transforms surrounded by three serial
phases. They are now a single fused region — worth 1.46x on DM and up to 1.78x on LMKCDEY
at eight threads and above — with the digit decomposition spread across the region's
threads on wide gadgets and the 32-bit inner products blocked over coefficient ranges, so
every phase scales with the region.
The LMKCDEY regions request one team size throughout. LMKCDEY was the only method
whose bootstrap alternates two parallel-region sizes — the accumulator asked for digitsG2
threads, the automorphism key switch for half that — and OpenMP runtimes reconfigure
their thread team whenever consecutive regions request different counts. libgomp charges
heavily for that: up to 4.8x against LLVM's runtime on a saturated 8-core machine with
bimodal run-to-run variance, and 1.5 to 2.7x on shipped parameter sets on a 72-core
machine even with most of its cores idle, where asking for 36 OpenMP threads ran slower
than asking for 8. The key switch now requests exactly the accumulator's team size, so
the sizes are equal by construction at every width. That removes the penalty and the
variance outright — the two runtimes land within a few per cent of each other on every
shipped set and adding threads is no longer harmful — while keeping the regions at full
width, worth a further 6 to 11 per cent at 36 threads on both runtimes over capping them.
The default thread count now achieves what previously required hand-tuning
OMP_NUM_THREADS per parameter set. The mechanism was isolated with a controlled
experiment over all four combinations of worker-per-chunk ratio and size alternation:
equal sizes are healthy even with one chunk per worker, and differing sizes reproduce the
penalty even with two — on both runtimes.
Key points for review
The default changed.
BTKeyGenandBTKeyLoadnow select the 32-bit form where itqualifies. Keys generated under this default are not bit-identical to keys generated
before it, because the narrow path samples natively rather than narrowing a wide key. Both
are valid keys and the noise is unchanged, but a test that pins key bytes against a fixed
PRNG seed will differ. Generating keys and then serializing them also holds a transient
widened copy, so that particular sequence peaks higher than it used to even though the
steady-state footprint is much smaller.
A discrete uniform sampler with exact rejection. The bounded top-chunk sampler is
replaced by the sliver rejection method designed for DPRIVE: draw the minimum number of
raw words, reject any draw at or above the largest contained multiple of the modulus, and
reduce. It is exactly uniform for every modulus and word width, where the previous
implementation filled its top chunk in a way that rejected a third of draws for some
moduli, and it is 19 to 37 per cent faster. PRNG word generation dominates every sampler
in the library, so this lands on more than BinFHE.
Correctness fixes carried in this branch. These are not performance work and deserve
separate attention:
bias can push the value past the digit count and the top window was masked rather than
taking the remaining quotient. This affects ten of the fifty shipped
(parameter set, gadget base) pairs and is live in the released library. Earlier noise
measurements on the affected sets are invalid.
PreComputepopulated the gadget powers only for the three bases that sign evaluationswitches between, so any context built through the custom-parameter overload with an
ordinary gadget base left the vector empty and the first key generation read past its
end. It is a segmentation fault with no exception, it predates the per-dimension gadget
base work, and the parameter-set overload was never affected.
external product with no rows, because the approximate decomposition discards the first
digit. That context used to construct successfully and fail much later inside gate
evaluation; it is now rejected at construction, naming the base.
LWECryptoParamssilently dropped its key-switching modulus and noise generator whencopied, and its assignment operator additionally dropped the secret key distribution, so
a copied parameter set reported the wrong key-switching digit count.
std::logis not correctly rounded andConvertToDoubleis lossy above 2^53, so aprime just above a power of two could lose a digit. Verified to change no generated
parameter set: the two forms agree on all 2271 primes the library produces across bit
sizes 20 to 60 and seven ring dimensions.
Testing. Gate outputs are bit-identical to the 64-bit path across every gate, every
shipped parameter set and all three bootstrapping methods. A new unit test file covers
BinFHE at the build's true native word size, because the rest of the suite exercises
whatever the defaults do and therefore no longer reaches it, and covers the conversions
between the two representations: narrowing an existing key must not change a ciphertext
bit, and the serialization getters must widen without the context giving up its own
narrow keys.
Platforms. Validated on clang-18, gcc-14, clang-15 and gcc-11.4, across two machines,
under both OpenMP runtimes, at one thread and at eight and thirty-six.