Skip to content

Support per-dimension gadget bases in FHEW bootstrapping - #1268

Merged
pascoec merged 9 commits into
devfrom
issue1144-fhew-param-opt
Aug 30, 2026
Merged

Support per-dimension gadget bases in FHEW bootstrapping#1268
pascoec merged 9 commits into
devfrom
issue1144-fhew-param-opt

Conversation

@HONGDUCK

@HONGDUCK HONGDUCK commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Below is a brief summary of the main changes.

The existing parameter structure, which previously supported a single baseG, has been extended to support multiple gadget bases. A gadgetBaseMap and the corresponding internal m_baseG_map were added, while the existing m_Gpower_map is used to store the precomputed gadget powers for each base.

GetBaseG() and GetDigitsG() now take an LWE secret-key coefficient index and return the gadget base and the corresponding number of decomposition digits assigned to that coefficient. To support this throughout the bootstrapping path, the LWE secret-key index is propagated through key generation and accumulator updates so that the appropriate gadget base can be selected during EvalACC. A new constructor that takes a gadgetBaseMap was also added.

The named parameter sets are now initialized with their corresponding gadget base maps. In addition, the parameter sets with the LPF_ prefix were updated using the openfhe-lattice-estimator so that their failure probability is approximately $2^{-128}$.

The same estimator can also be used to search for parameter sets targeting an arbitrary failure probability. The updated LPF_ parameter sets in this PR were obtained using this parameter search.

@HONGDUCK HONGDUCK self-assigned this Aug 19, 2026
@yspolyakov yspolyakov added this to the Release 1.6.0 milestone Aug 22, 2026
@yspolyakov yspolyakov added the new feature New feature or request label Aug 22, 2026
@yspolyakov

Copy link
Copy Markdown
Contributor

Review summary

The core mechanism looks correct — per-coefficient gadget bases are propagated consistently between key generation and accumulator evaluation in all three blind-rotation methods, and all pre-existing parameter sets remain behaviorally identical (confirmed by green CI across all backends). However, there is one issue that should block merge as-is (serialization compatibility), plus a test-coverage gap and a few latent hazards worth addressing.

Blocking issue

Serialization break without a version bump. save/load in rgsw-cryptoparameters.h now unconditionally read/write baseGMap, but SerializedVersion() still returns 1 and load doesn't gate the new field on the version. Any RingGSWCryptoParams serialized by the current release will fail to deserialize with this PR (JSON: missing-NVP exception; binary: stream misalignment corrupting everything after), and the failure won't be the clean "later library version" message because the version check can't distinguish the formats.

Suggested fix: bump SerializedVersion() to 2 and read baseGMap only when version >= 2.

Note that even with that fix, operator== now compares m_baseG_map, so an old blob (empty map) will compare unequal to a freshly generated context of the same named set — worth deciding how that should behave.

Verified as correct

  • Index propagation is consistent in all three methods: CGGI keygen and eval both use coefficient index i; DM likewise; LMKCDEY keygen uses i and eval passes indexVec[j], which holds the original coefficient index through the permutation map, so each key is decomposed at eval time with exactly the base it was generated under. Buffer sizes (digitsG2) are derived from the same GetDigitsG(index) on both sides, so no size mismatches.
  • LMKCDEY automorphism keys deliberately stay on the default base at both keygen (KeyGenAuto) and eval (single-poly SignedDigitDecompose overload) — a consistent pair, matching the paper's separate B_at base.
  • GetBaseG cumulative lookup is sound; the map constructor validates power-of-two bases and nonzero counts. I hand-verified all 43 named-set maps sum exactly to their latticeParam (e.g., LPF_STD128: 547+9=556; LPF_STD128_LMKCDEY: 392+164=556; LPF_STD128Q_LMKCDEY: 567+34=601).
  • Sign/function evaluation is unaffected: the arbFunc overload of GenerateBinFHEContext still uses the unmapped constructor, so Change_BaseG, the time-optimization key map in BTKeyGen, and the dynamic base switching in EvalFloor/EvalSign/EvalDecomp behave exactly as before.
  • Security parameters (n, q, Q, N, σ) are unchanged — only the failure probability moves. An independent check against the paper's Eq. (1) noise model shows the new LPF maps increase blind-rotation variance by ~1.06× (LPF_STD128Q) to ~3.0× (LPF_STD128_LMKCDEY), directionally and roughly quantitatively consistent with relaxing 2^-135 / 2^-150 to the 2^-128 target. The exact values rest on the openfhe-lattice-estimator run — please record the estimator configuration/commit in the PR or docs so the 2^-128 claims are reproducible.

Should-fix items

  1. No test exercises a genuinely heterogeneous map. Green CI only covers single-base maps (all the STD sets); the LPF_* sets — the only multi-base ones — appear solely in a benchmark file. A per-index keygen/eval mismatch would pass CI today. Please add at least one gate-bootstrapping unit test per method using an LPF_* set (or a custom BinFHEContextParams with a two-base map), plus a serialization round-trip of a mapped context.
  2. Coverage validation is asymmetric. The count != latticeParam check exists only in the BinFHEContextParams overload; the named-set table is unchecked, and a map covering more than n coefficients is silently accepted everywhere (excess entries ignored), while under-coverage throws only lazily at keygen. Moving the check where possible, or asserting coverage at keygen start, would fail fast.
  3. Latent signEval=true + map combination. The new constructor accepts it, but PreCompute's sign-eval branch populates the Gpower map only with the sign-eval base list — a mapped base outside that list would throw "No GPower found" at keygen. Unreachable via the public API today; an explicit throw in the constructor would keep it that way.
  4. Change_BaseG is silently ignored by per-index paths when the map is non-empty (GetBaseG(index) never consults m_baseG). Currently harmless because mapped contexts can't reach the dynamic-base code, but if those paths ever converge, base switching would become a no-op and quietly degrade the noise budget of EvalSign/EvalFloor. A comment or guard in Change_BaseG is cheap insurance.

Minor notes

  • GetBaseG/GetDigitsG signature changes are source-compatible but ABI-breaking for the binfhe shared library — fine for a minor release, but worth mentioning in release notes alongside the serialization version change.
  • The per-call float recomputation in GetDigitsG(index) and the O(#bases) map scan are negligible against the NTT cost — no action needed.

@HONGDUCK
HONGDUCK force-pushed the issue1144-fhew-param-opt branch from 6327ebf to d8c5aa7 Compare August 23, 2026 13:59
@HONGDUCK
HONGDUCK force-pushed the issue1144-fhew-param-opt branch from d8c5aa7 to f24598a Compare August 27, 2026 14:20
ceil(log_base(x)) was computed as std::ceil(std::log(x) / std::log(base))
in the gadget-decomposition, key-switching and trapdoor paths. The result
depends on libm rounding and on converting the modulus to a double, which
loses bits above 2^53, so the digit count could come out one too high --
wasting key material and work -- or one too low, leaving a gadget that
does not span the modulus. Across bases 2 to 300 the two disagree on 1498
of 14087 boundary neighbourhoods, in both directions.

Add GetDigitCount() to nbtheory.h: a shift for power-of-two bases and a
division loop otherwise, exact for any base and any modulus width. Use it
for the RingGSW gadget bases and the AP refreshing base, for the LWE
key-switching digit count -- now derived in LWECryptoParams so that key
generation and key switching cannot drift apart -- and for the DCRTPoly
trapdoor and gadget vector.
@yspolyakov
yspolyakov requested a review from pascoec August 28, 2026 16:23
Both SignedDigitDecompose implementations bias the centred coefficient by
H = (baseG/2) * (baseG^digitsG - 1)/(baseG - 1) and then read digitsG masked
windows. H is strictly greater than baseG^digitsG / 2, so Q/2 + H can exceed
baseG^digitsG, and masking the top window discards that carry: the
decomposition reconstructs the coefficient short by baseG^digitsG mod Q.
digitsG = ceil(log_baseG(Q)) does not prevent this, and the only precondition
noted in the code is the unrelated word-width one.

Ten of the fifty shipped (parameter set, gadget base) pairs are affected in the
ciphertext overload, between 0.01% and 1.6% of coefficients each: TOY,
TOY_MULTI_BASE, STD128_AP and STD128_LMKCDEY at base 512, STD192Q_LMKCDEY at
base 4096, and SIGNED_MOD_TEST at base 128, whose residual reached 65598 against
an allowed 64. The single-NativePoly overload, used by the LMKCDEY automorphism
path, decomposes with the default base and is affected for STD128_LMKCDEY and
STD192Q_LMKCDEY. This is a regression from #1238, which replaced the
carry-propagating decomposition with the excess-H form.

Leave the top window unmasked in both so it absorbs the carry. The biased value
stays below 2*baseG^digitsG, so that digit is bounded by 2*baseG: no extra
digit, no change to key sizes or to the work per gate. The mask is selected once
per digit rather than per coefficient, and the gate suite measures unchanged
(geomean 0.998 single-threaded, 1.000 at 36 threads).

Assign the digits instead of accumulating them. Every call site passes a buffer
that is written exactly once, so the read-modify-write was redundant and the
routine silently depended on the caller having pre-zeroed it.

Every shipped pair now reconstructs with a residual of exactly baseG/2, the
bound the approximate decomposition allows. In a configuration with every
coefficient at an affected base, the fix is worth 74 bits of decryption failure
probability.
Change_BaseG() updates m_baseG, m_digitsG and m_Gpower, but not the per-index
table that PreCompute() builds from the gadget base map. The accumulator reads
through GetBaseGParams(index), which consults those default members only when
the table is empty -- so on a context constructed with a base map, Change_BaseG
mutated state nothing reads: it appeared to succeed and changed nothing about
the decomposition.

The two features cannot currently meet. Change_BaseG serves the runtime base
switching in EvalSign/EvalDecomp and the per-base key pregeneration in BTKeyGen,
both of which need m_Gpower_map populated with 2^14/2^18/2^27 -- and that only
happens under PreCompute(signEval=true), which is set at exactly one call site,
the arbFunc constructor, which uses the single-base constructor. Every
map-carrying path passes signEval=false. Nothing stated or enforced this, so a
user calling GenerateBinFHEContext(BinFHEContextParams) with a gadgetBaseMap and
then EvalFloor/EvalSign would have walked into it.

We have made Change_BaseG throw when a base map is present, so the
incompatibility surfaces at the point of misuse instead of silently doing
nothing. This has no effect on any path that works today. The alternative reading
is that Change_BaseG should rebuild the per-index table uniformly with the new
base -- i.e. an override of all per-coefficient assignments -- which is coherent
but needs gadget powers for the new base, and it is not obvious that a global
override is meaningful once per-coefficient bases have been chosen deliberately.
We picked the conservative option; happy to switch to the override semantics if
that was the intent.
@pascoec

pascoec commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

The capability is sound and we'll add it now, but the four LPF configurations shipped with this PR
don't really seem to exercise it. We'll regenerate a new set of parameters in #1285 before v1.6 release.

The configurations don't make use of the capability

icelake, clang18, min of 3 reps, this PR vs its parent. Geomean over 13 gate/keygen rows
is 1.008 at one thread, 1.005 at 36. Exactly one row moves:

set coefficients moved gate 1t gate 36t margin spent
LPF_STD128_LMKCDEY 164/556 (29.5%) 1.054 1.051 ~25 bits
LPF_STD128Q_LMKCDEY 34/601 (5.7%) 1.015 1.002 below floor
LPF_STD128Q 20/601 (3.3%) 1.006 1.000 below floor
LPF_STD128 9/556 (1.6%) 1.001 1.004 below floor

The speedup tracks the fraction of coefficients moved to the larger base, which is the
mechanism working as designed. But three of the four maps move only 1.6–5.7%, so they
gain nothing measurable. The noise side agrees: on fixed code, same build both legs, only
the LPF rows differing, 10 keys x 500 gates pooled, only LPF_STD128_LMKCDEY shows a margin
loss above the measurement floor (~25 bits, against an 11-bit floor set by two untouched
control sets). So one set spends real margin and buys real speed; the other three spend
and buy nothing detectable.

Worth adding for #1285: the LWE key switching is 88% of total noise variance in
STD192, 68% in STD128, 30% in STD128_LMKCDEY and 1% in STD256 (measured by
feeding KeySwitch a noiseless dimension-N ciphertext and reading the phase error). For
the STD128/STD192 families (qKS, baseKS, digitCount) is the dominant lever, well ahead
of the gadget base.

Cleanup

I've also made a number of formatting, cleanup and optimization changes on top — the
per-index parameter table, replacing the std::optional accessors with plain overloads,
eager validation of the base map, and a few smaller things. Nothing behavioural beyond
what's called out below.

Open question: Change_BaseG — this is what I'd like input on

Change_BaseG() updates m_baseG, m_digitsG and m_Gpower, but not the new per-index
table. The accumulator reads through GetBaseGParams(index), which consults those default
members only when the table is empty — so on a context built with a base map,
Change_BaseG mutates state nothing reads. It appears to succeed and changes nothing.

The two features can't currently meet: Change_BaseG serves the runtime base switching in
EvalSign/EvalDecomp and the per-base pregeneration in BTKeyGen, both of which need
m_Gpower_map populated with 2^14/2^18/2^27, and that only happens under
PreCompute(signEval=true) — set at exactly one call site, the arbFunc constructor, which
uses the single-base constructor. Nothing states or enforces that, so a user calling
GenerateBinFHEContext(BinFHEContextParams) with a gadgetBaseMap and then
EvalFloor/EvalSign walks straight into it.

I made it throw when a base map is present, so the incompatibility surfaces at the
point of misuse rather than silently doing nothing. It has no effect on any path that
works today.

The alternative is that Change_BaseG should rebuild the per-index table uniformly with
the new base
— an override of all per-coefficient assignments. That's coherent, but it
needs gadget powers for the new base, and it isn't obvious a global override is meaningful
once someone has deliberately chosen per-coefficient bases.

Is the conservative option the intended behaviour, or should we implement the override?
I'll hold off merging until I hear back.

Two bugs found and fixed along the way

1. Digit counts were computed in floating point.
digitsG = ceil(log(Q)/log(baseG)) and the equivalent for baseKS and baseR were
evaluated as doubles. That disagrees with exact integer arithmetic on 1498 of 14087
boundary neighbourhoods across bases 2–300, in both directions — overcounting wastes key
material and work, undercounting leaves a gadget that doesn't span the modulus. Separately,
ConvertToDouble() is itself lossy above 2^53, so no floating-point form is sound for the
54–60 bit moduli in use.

Replaced with an exact integer GetDigitCount() in nbtheory.h (a shift for power-of-two
bases, a division loop otherwise), used by the RingGSW gadget bases, the AP refreshing base,
the LWE key-switching count, and the DCRTPoly trapdoor and gadget vector. No shipped
parameter set changes
: 3575 (modulus, base) pairs produce identical counts.

2. The signed digit decomposition lost a carry — this one is ours, from #1238.
SignedDigitDecompose biases the centred coefficient by
H = (baseG/2) * (baseG^digitsG - 1)/(baseG - 1) and reads digitsG masked windows.
H is strictly greater than baseG^digitsG / 2, so Q/2 + H can exceed baseG^digitsG,
and masking the top window discards that carry — the decomposition then reconstructs the
coefficient short by baseG^digitsG mod Q. digitsG = ceil(log_baseG(Q)) does not prevent
this, and the only precondition noted in the code is the unrelated word-width one.

It affects six pre-existing shipped (set, baseG) pairs — TOY, TOY_MULTI_BASE, STD128_AP
and STD128_LMKCDEY at base 512, STD192Q_LMKCDEY at base 4096, and SIGNED_MOD_TEST at base
128, whose residual reached 65598 against an allowed 64 — plus the LMKCDEY automorphism
path through the second overload.

It's relevant here because all four LPF maps pick a second base that triggers it (512
at 27-bit Q, 32 at 25-bit Q), so any noise measurement of those sets taken before the fix
is confounded. On a configuration with every coefficient at an affected base, removing it
was worth 74 bits of log2(P_fail).

Fixed in both overloads by leaving the top window unmasked so it absorbs the carry. The
biased value stays below 2*baseG^digitsG, so that digit is bounded by 2*baseG — no
extra digit, no change to key sizes or work per gate, and the gate suite measures unchanged.
Every shipped pair now reconstructs with a residual of exactly baseG/2.

BinFHEContext::BTKeyGen caches bootstrapping keys in m_BTKey_map, which is
keyed by gadget base alone -- but what it caches is only valid for the secret
key it was generated from, and nothing enforced that. Without the time
optimization, the else-branch populated the map, so the next call took the
cache-hit branch and assigned m_BTKey from it, ignoring the sk it was passed.
A second BTKeyGen(sk2) on the same context therefore returned sk1's key
silently -- no exception, no warning -- and every subsequent decryption
returned noise.

Also compute gadget powers on demand in Change_BaseG. It looked the base up
in m_Gpower_map and threw when absent, but a deserialized context reaches
that state legitimately: RingGSWCryptoParams::load() calls PreCompute() with
signEval defaulted to false, so it lacks the sign-evaluation bases the
generating context had, and both boolean-serial-*-dynamic-large-precision
examples aborted on Change_BaseG(1 << 27). Filling the entry instead fixes
that without restoring the previous behaviour, where operator[] inserted an
empty vector for the accumulator to read past the end of.

Add regression tests for two secret keys on one context, with and without the
time optimization. The first fails before this change. The second is excluded
at NATIVE_SIZE=32 as in UnitTestFunc.cpp, because the large-precision
constructor sets qKS = 1 << 35, which truncates to zero in a 32-bit
NativeInteger.
@pascoec

pascoec commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Following up on the Change_BaseG question from the review — we've gone with making
it throw when a base map is present.

How we got there: we ran into this while updating our lattice-estimator parameter
tooling, which turned up a separate keygen bug along the way (BTKeyGen handed back a
cached bootstrapping key when you called it a second time with a different secret key —
fixed separately). Digging into that made the Change_BaseG interaction concrete
rather than hypothetical.

The deciding factor was BTKeyGen's time-optimization path. It loops over every gadget
base calling Change_BaseG(k), generates a key for each, then switches back. If
Change_BaseG instead rebuilt the per-index table uniformly with the new base — the
other option we raised — that loop would wipe out the per-dimension assignment on its
first iteration, generate all the cached keys against a uniform gadget structure rather
than the configured map, and then "restore" to a uniform table that isn't the original
map. A single call would quietly corrupt the context, and nothing would report it.

Throwing also keeps the door open for what we suspect is the real answer if these two
features are ever meant to work together: teaching Change_BaseG about the map, so it
moves the whole thing to a coarser set of bases instead of collapsing to one global
base. That's what a multi-base config would need for large-precision EvalSign /
EvalFloor. Turning a throw into that later costs nothing, whereas the rebuild-uniformly
version would bake in semantics we'd have to undo first. Happy to change in a future issue if you have something else in mind.

@pascoec
pascoec merged commit 252b2b1 into dev Aug 30, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add FHEW parameterization method and optimizations proposed in https://ia.cr/2025/1892

3 participants