Skip to content

Run BinFHE bootstrapping on 32-bit words at native size 64/128 - #1295

Open
pascoec wants to merge 31 commits into
devfrom
issue1269
Open

Run BinFHE bootstrapping on 32-bit words at native size 64/128#1295
pascoec wants to merge 31 commits into
devfrom
issue1269

Conversation

@pascoec

@pascoec pascoec commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

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 to
back:

parameter set gadget digits NATIVE_SIZE=32 this path, in a 64-bit build stock 64-bit
MEDIUM 4 21.49 ms 22.00 ms 52.19 ms
STD128 6 38.44 ms 38.28 ms 92.38 ms
STD128Q 8 73.25 ms 68.00 ms 177.95 ms

The reason is that the build system forces HAVE_INT128 off whenever NATIVE_SIZE is 32, so a
genuine 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. BTKeyGen and BTKeyLoad now select the 32-bit form where it
qualifies. 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:

  • The excess-H gadget decomposition dropped a carry out of its top window, because the
    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.
  • PreCompute populated the gadget powers only for the three bases that sign evaluation
    switches 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.
  • A gadget base large enough to represent the modulus in a single digit leaves the
    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.
  • LWECryptoParams silently dropped its key-switching modulus and noise generator when
    copied, and its assignment operator additionally dropped the secret key distribution, so
    a copied parameter set reported the wrong key-switching digit count.
  • The remaining floating-point digit counts are now exact integer arithmetic.
    std::log is not correctly rounded and ConvertToDouble is lossy above 2^53, so a
    prime 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.

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.
@pascoec pascoec added this to the Release 1.6.0 milestone Sep 6, 2026
@pascoec
pascoec requested a review from yspolyakov September 6, 2026 23:12
@pascoec pascoec self-assigned this Sep 6, 2026
@pascoec pascoec added bug Something isn't working optimization Improves performance labels Sep 6, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working optimization Improves performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BinFHE: use 32-bit native arithmetic at runtime for parameter sets that fit, even in NATIVE_SIZE=64 builds

1 participant