Bivariate CKKS
May 23, 2026 · 25 min read
CKKS is usually presented over RNS: pick a chain of NTT-friendly primes , store every polynomial as its CRT residues, and let level be the unit of homomorphic budget. The recent paper of Belorgey, Carpov, Gama, Guasch and Jetchev, Revisiting Key Decomposition Techniques for FHE: Simpler, Faster and More Generic, keeps the scheme but throws out the prime chain entirely. In its place sits the bivariate representation: one cyclotomic axis, one multi-precision axis, and a free parameter that has nothing to do with primes.
This article walks through CKKS as it is implemented in poulpy-ckks on top of that representation. It is meant as a bridge between the rather abstract setting of the paper (Section 3 introduces the representation in full generality, with gadget decompositions and external products as the main motivation) and what actually runs on a CPU when you call ckks_add_into or ckks_mul_into. Some familiarity with CKKS is assumed, deep RNS expertise is not.
API note (16 August 2026): the code and naming below were checked against poulpy 139a3cd, the tip of main at the time of this update.
1. The bivariate representation, math first
Fix the cyclotomic ring
and let be the real torus. CKKS encrypts elements of the real torus polynomial ring
Pick an integer , the limb size. In poulpy this is called base2k.
A typical value is for the NTT4x30 backend family. The bivariate representation describes an element by a bivariate integer polynomial
with (the paper calls such a polynomial -normalized and reduced), together with the evaluation map
We require . The picture splits into two independent axes:
- the -axis carries the cyclotomic structure: a length- vector of integer coefficients per limb, handled by an FFT over or an NTT modulo a single large prime;
- the -axis carries the multi-precision structure: limbs in , with carry propagation playing the role of arithmetic over .
Two structural consequences worth highlighting up front.
Limb count is a function of precision, not of primes. The number of limbs depends only on the desired precision and on . There is no prime chain, and is a free parameter rather than a property of some prime family.
Prefix property. A precision- ciphertext, truncated to its first limbs along , is a valid precision- ciphertext of the same plaintext, with . Modulus switching becomes a length adjustment on the limb sequence, i.e. a slice, not a computation.
2. CKKS in poulpy: where the bivariate ring lives
A CKKS ciphertext in poulpy-ckks is a GLWE buffer paired with semantic precision metadata, plus a typestate marker for whether the limb digits are carry-normalized:
pub struct CKKSCiphertext<
D: Data,
W: ZnxWord,
S: CKKSNormalizationState = Normalized,
> {
pub(crate) inner: GLWE<D, W>,
pub(crate) meta: CKKSMeta,
_state: PhantomData<S>,
}
pub struct CKKSMeta {
pub log_delta: usize,
pub log_sparsity: usize,
pub slots: SlotsKind,
}
W names the backend’s coefficient-word layout; user code normally lets the backend-specific alias CKKSCiphertextOwned<BE> fill it in. The S parameter is either Normalized or Unnormalized and tracks the limb invariant the unnormalized arithmetic variants discussed in §5.3 expose at the type level. CKKSMeta stores the scale log_delta, the sparse-packing factor log_sparsity (0 for dense / full packing), and a SlotsKind::{Real, Complex} claim. The last field lets operations preserve a known-real subfield when possible without ever mistaking an unknown value for real.
The remaining homomorphic budget is deliberately not stored in CKKSMeta; it is derived on demand from the wrapped GLWE’s torus width k as .
These quantities have direct mathematical meaning:
- (
log_delta) is the base- logarithm of the plaintext scaling factor: the encoded message lives at scale . - (
log_budget, derived) is the remaining homomorphic capacity in bits: the noise sits at the LSB end of the limb stack, mixed with the lowest bits of the scaled plaintext, and is the gap between the noise floor and the message MSB.
Together they define the effective torus width used by the kernels, which is exactly the wrapped GLWE’s torus width k:
The logical limb count is size() = ceil(k / K). Allocation is separate and fixed: max_size() is the number of limbs the backing buffer can hold, and max_k() = max_size() * K. They agree just after an exact-size allocation, but an operation may reduce k and therefore size() while leaving max_size() and max_k() unchanged. The bits between k and the next limb boundary are padding; any further gap up to max_k is unused capacity.
Picturing one ciphertext as a stack of limbs along , with the most-significant limb on top:
A plaintext is the same shape minus the secret-key half. Unlike RNS, where a plaintext has to live across the full RNS basis to interact with a ciphertext, a bivariate plaintext can be much shorter than the ciphertext it operates on and stored with just enough precision to hold it. Alignment of the two limb stacks is handled inside the kernels to allow operations.
3. The torus is a lie
The torus picture is elegant. It is also, on a finite computer, a representation lie. What we call the torus is , a continuous one-dimensional object. What actually lives in RAM, in poulpy, is
a vector of signed -bit integers per cyclotomic coefficient. Reading the limbs as the -coefficient of a torus element means evaluating
which is exactly applied coefficient-wise. The torus has been discretized at resolution , and any “real number on the torus” only exists up to that resolution.
From RNS-CKKS to bivariate
For readers more comfortable with the RNS picture: in RNS-CKKS a ciphertext at level lives in with , stored as the CRT tuple of residues modulo each . The “level” is both a budget and a storage layout: dropping a level (reducing the modulus to ) is itself just discarding the residue at , but the useful operation that actually consumes a level, rescaling the ciphertext by dividing it by , needs a mod-switch pass on every remaining residue.
In bivariate, the same ciphertext lives in , -normalized, with limbs along . The CRT axis is gone. The “level” decouples into three quantities:
| Quantity | RNS-CKKS | Bivariate |
|---|---|---|
| Budget unit | one prime (– bits) | one bit |
| Storage unit | one RNS residue (one prime) | one limb of bits |
| Rescale (consume budget) | divide + mod-switch | bit shift; choose a narrower result k when useful |
| Modulus | product of primes | |
| Key-switch online NTTs | ||
| Evaluation key | tied to prime chain | prefix-shared across precisions |
The hard part of the migration is not algebra: it is essentially a change of basis at the storage level, but rebuilding the kernels around the semantic pair (log_delta, derived log_budget), the requested logical width k, and the allocation max_k instead of the single fused level. Going from RNS to bivariate looks like a change of representation, but in practice it is mostly a change of bookkeeping, and the bookkeeping is most of CKKS engineering.
Bivariate CKKS is also easier to parameterize since instead of having to choose a chain of prime moduli, one can just choose and .
Note that Grafting in RNS provides a similar decoupling functionality, but the difference is that what Grafting gets by engineering, you get by design in the bivariate representation.
4. Three independence axes
Once the representation is set, every CKKS operation has to deal with three quantities that may differ between operands and result:
- - the scale at which the message is encoded.
- - the bits of clean torus above the message.
- - the fixed physical storage capacity, while the requested logical precision is the derived .
In RNS-CKKS these collapse into a single state (the level), so reconciling two ciphertexts amounts to “modulus-switch to the lowest common level”. In bivariate they are independent and each has its own primitive.
The axis is reconciled by shifting inside the torus. Two ciphertexts at the same scale but with different budgets have their noise floors at different bit positions in the limb stack. A left-shift of the higher-budget operand by the budget difference brings its noise floor down to the lower-budget operand’s, at the cost of consuming the extra budget:
The / max_k axis is explicit in the destination. The destination’s k() is the precision requested from an *_into operation; if it is narrower than the natural result, the kernel rounds away the low-order tail and charges the difference to . max_k() only describes the fixed allocation and does not silently select the arithmetic width. To obtain a physically smaller owned result, allocate the destination at the desired k before the operation.
The axis is tracked by the kernels too. Ciphertext addition and multiplication accept unequal scales and stamp the result with ; multiplication absorbs bits. MultiplicationPrecisionUnderflow means the available budget cannot pay that cost, not merely that the input scales differ. Plaintext operands declare both their CKKS metadata and their encoded integer width; an impossible ciphertext–plaintext alignment produces PlaintextAlignmentImpossible.
All fallible CKKS operation traits now return CKKSResult<T>. Recoverable conditions such as the two above live under CKKSError::Composition(CKKSCompositionError), while backend or invariant failures are carried as CKKSError::Internal.
5. Bivariate CKKS operations
5.1 Encoding and decoding
Encoding follows the standard CKKS slot map. A complex slot vector is placed at the canonical embedding positions and inverse-FFT’d to yield a real-coefficient polynomial. In current poulpy the transform runs through a backend-resident planar [re | im] CKKSEncodingBuffer, at either f64 or portable binary128 Quad precision:
This step is identical to RNS-CKKS: the canonical embedding obviously does not care how the underlying ring is stored, as it is just a ring homomorphism.
The interesting step is the next one: turning into the bivariate representation at scale . We want an integer polynomial such that , then we want to spread across the -axis so that the limbs of each coefficient encode the integer in base , with the message lying in the lowest bits of the limb stack - its lowest bits being where the encryption noise sits - and the bits above it left clean for noise growth:
In RNS this same operation requires representing modulo each prime , which forces the encoder to carry around the full prime chain. In poulpy the module’s encoding operation writes into limbs of bits, with chosen freely. The slot map is unchanged, but the quantization-to-storage is done so that the integer lands in the right limbs with the right alignment, and so that subsequent kernels see the limb stack the way they expect.
There is a single plaintext type, CKKSPlaintext, which lives in the ZNX (integer-limb torus) domain:
pub struct CKKSPlaintext<D: Data, W: ZnxWord> {
pub(crate) inner: GLWEPlaintext<D, W>,
pub(crate) meta: CKKSMeta,
}
The old host-side Encoder object is gone. Encoding is now an operation family on Module<BE>: CKKSEncodingOps works entirely on backend buffers, while CKKSEncodingHostOps provides explicit host-slice adapters. The common path allocates the plaintext, presets its metadata, and supplies the caller’s scratch arena:
let mut pt = module.ckks_pt_vec_alloc(base2k, k);
pt.set_meta(CKKSMeta {
log_delta,
log_sparsity: 0,
slots: SlotsKind::Complex,
});
module.ckks_encode_reim_into(&mut pt, &re, &im, &mut scratch)?;
This one call stages [re | im] into the backend arena, applies the slot-to-coefficient transform, scales by , rounds to the appropriate integer width, and decomposes the coefficients into base- limbs. Backends cache their own transform plans, so there is no encoder or FFT table for user code to construct. Raw coefficient paths are also available through ckks_encode_coeffs_into and ckks_encode_coeffs_host_into.
Decoding runs the chain in reverse with module.ckks_decode_reim_into(&pt, &mut re, &mut im, &mut scratch): the bivariate plaintext is collapsed back to a scalar per coefficient, divided by , and mapped through the forward FFT to recover an approximation of the input slot vector. The accuracy of the round-trip is governed by the bits of that survived the homomorphic computation.
5.2 Encryption
Encryption is the standard RLWE encryption of the ZNX plaintext into a pair . CKKSEncryptOps::ckks_encrypt_sk takes the total torus width from the encryption parameters and stamps
The randomness arguments are ordered (source_xe, source_xa): error first, uniform mask second. Decryption is destination-framed in the current API: ckks_decrypt no longer copies the ciphertext metadata into its output. Allocate with ckks_plaintext_alloc_from_infos(&ct), or otherwise preset the destination’s (log_delta, log_budget) frame, before decrypting.
5.3 Addition
Adding two ciphertexts at the same scale is just GLWE addition, if the budgets and storage widths agree. When they don’t, the kernel reconciles all three axes in a single pass:
-
Compute the natural result width and the destination offset
-
Left-shift the lower-budget operand by bits and add the higher-budget operand left-shifted by bits.
-
Set the result’s metadata: , .
The default ckks_add_into returns a Normalized ciphertext: it follows the addition with a carry-propagation pass that brings every limb back into . The ckks_add_into_unnormalized variant writes into an UnnormalizedCKKSCiphertext and skips that pass, which is correct for chains of linear operations as long as ct.normalize(&module, &mut scratch) happens before any DFT-domain operation downstream (keyswitching, multiplication, automorphism). The Unnormalized typestate flags a numeric invariant (limbs may hold un-propagated carries and eventually overflow i64 if you keep accumulating), not a memory invariant, and the type system refuses to hand such a value to a kernel that assumes normalized limbs.
Plaintext addition takes a CKKSPlaintext already in the ZNX domain. The ckks_add_pt_vec_* family consumes a full polynomial; ckks_add_pt_const_* consumes one encoded coefficient. The plaintext declares its meaningful k and its integer consuming width encoded_k(), which can be much narrower than the ciphertext’s allocation, and the kernel aligns it inside the ciphertext before adding.
5.4 Multiplication
A product of two CKKS ciphertexts at scales and is a ciphertext at scale . To recover a single-scale result, one factor of must be absorbed, which costs bits of :
Let . Then
The pair handles the common case (equal scales: , so the subtraction is just ) as well as genuinely different input scales. MultiplicationPrecisionUnderflow is the bivariate counterpart of “out of levels”: multiplication is impossible when . If dst.k() is below the natural result width, the kernel additionally rounds the low bits to the requested precision and charges bits of budget.
Pictorially, for two operands at the same scale and budgets :
The rescale brings the noise floor up by bits relative to the message, closing the gap by that much: the budget after the multiplication is .
The kernel produces the tensor product of the two ciphertexts under the appropriate shift, then relinearizes using a precomputed tensor key. Current gadget-key layouts are parameterized by decomposition count dnum, digit width dsize, and an auxiliary guard k_aux, rather than by a caller-supplied total k:
The key-switching operation derives its working width from the input ciphertext and this key layout. One sufficiently wide tensor key therefore serves computations at lower ciphertext precisions without a circuit-specific prime schedule.
Plaintext multiplication is dramatically cheaper. A constant multiplication consumes only the plaintext’s bits of budget, with no factor of the ciphertext’s scale, and skips relinearization entirely. A small constant such as consumes exactly bits, instead of forcing an entire prime drop as it would in RNS without Grafting.
5.5 Rescale and the prefix property
Re-anchoring the message inside the limb stack is exposed through CKKSPow2Ops, poulpy’s multiply/divide-by-a-power-of-two primitives. ckks_mul_pow2_into / ckks_mul_pow2_assign left-shift the torus polynomial by bits positions, so the encrypted value becomes at the same scale . The in-place variant leaves metadata unchanged, so the caller is responsible for ensuring the larger value still fits the nominal headroom.
ckks_div_pow2_into / ckks_div_pow2_assign right-shift the polynomial. They are the precision-aware rescale and transfer bits from the budget into the scale,
Division rejects a request that would push below zero with InsufficientHomomorphicCapacity: there is no equivalent to “negative levels”. An *_into call may consume further budget when dst.k() is narrower than the source; multiplying by a power of two only reports this error for that destination-width charge, not for bits itself.
The prefix property the paper highlights still costs no basis conversion, but its API expression has changed. Compact and SetSize have been removed. An owned ciphertext now has a fixed allocation (max_size, max_k) and a logical precision (k, with size() = ceil(k/K)). Budget-consuming operations lower the logical k; kernels work over that logical limb prefix while the backing allocation stays put. If memory must shrink too, the caller allocates the destination at the smaller k before an *_into operation.
The picture, in the storage layout the kernels actually manipulate (limbs indexed as in §1, with carrying weight on the buffer integer, so is the high half, the low half):
The logical view uses the high-weight prefix . A narrower destination represents the same plaintext at lower precision, with the kernel performing the required value-preserving rounding; it does not require a CRT lift or reprojection.
Evaluation keys and plaintexts inherit the same structural advantage. Key operations derive the necessary prefix from the operand and the key’s decomposition/k_aux coverage, while plaintext operations bound the integer polynomial by encoded_k().
5.6 Keyswitching, relinearization, and automorphisms
Relinearization, slot rotations, and conjugation all reduce to the same primitive: a keyswitch, where a ciphertext is rewritten under a different secret. In poulpy the underlying kernel is glwe_keyswitch from poulpy-core, wrapped at the CKKS level by ckks_rotate_into, ckks_conjugate_into, and the relinearization step inside ckks_mul_into.
A keyswitch evaluates where the are the gadget decomposition of one ciphertext polynomial and the are the key. In RNS-CKKS the operands have to be raised to an auxiliary modulus before the elementwise products, and that base extension dominates the online cost. In bivariate the are already polynomials over in the same ring: the online phase is one NTT per limb, the products, and a carry-propagation pass: no base extension, no CRT reprojection.
The prefix property carries over to key material: a relinearization, automorphism, or generic keyswitching key with sufficient decomposition and k_aux coverage works for lower-precision operands too. The current core API derives the working width from the operand and key; it no longer accepts a separate output/working-size argument.
6. Why this is cool: a worked example
The runnable example at poulpy-cpu-ref/examples/ckks_poly2.rs evaluates a degree- Chebyshev approximation of on encrypted complex slots,
encoded in Baby-Step/Giant-Step form and evaluated homomorphically with ckks_eval_poly_real_const_coeffs_from_power_basis over a Chebyshev PowerBasis. Build and run it with:
cargo run -p poulpy-cpu-ref --features enable-ckks --example ckks_poly2
The parameters are (so slots), (base2k), and a ciphertext width , giving (six limbs). The message is encoded at .
The per-phase trace tells the bivariate story compactly. The columns are the metadata accessors: log_delta, log_budget, the logical torus width k, its derived size() in limbs, and the allocated max_k:
encoded plaintext x log_delta=45 log_budget= 5 k= 50 limbs= 1 max_k= 52
ciphertext x log_delta=45 log_budget=255 k=300 limbs= 6 max_k=312
sin(x) log_delta=45 log_budget= 30 k= 75 limbs= 2 max_k=312
max |have − sin(x)| = 0.000000000075357
The freshly encrypted has and bits of clean headroom above the scaled message, so its torus width fills all six -bit limbs (, bits of padding). The plaintext it was encrypted from needed only a single limb (): a bivariate plaintext is stored with just enough precision to hold its value and gets aligned to the ciphertext inside the kernels.
Then the whole degree- polynomial is evaluated, and the budget falls from to bits: bits consumed across all the Chebyshev recurrences, baby-step products, and giant-step multiplications. The result’s logical width falls from six limbs to two (), while its fixed backing allocation remains max_k=312. This is a metadata/view change, not an automatic compaction or reallocation. Decrypting the result recovers with a maximum slot error of about in the current deterministic example.
The headline observation is that the budget falls in bits, not in levels. A degree- evaluation that under RNS-CKKS would burn a discrete chain of primes, one whole prime per rescale with each consumption rounded up to to bits, here spends exactly the bits each step needs and lands on a two-limb logical value with surviving bits. A caller that also wants a two-limb allocation requests that k when allocating the destination.
For a polynomial whose coefficients are mostly small fractions, those roundings compound: dozens of bit-sized consumptions versus dozens of full-prime drops. The practical consequence is fewer bootstrappings for the same circuit.
7. Conclusion and what’s next
The bivariate representation keeps the CKKS scheme intact and replaces the way ciphertexts are stored and operated on. Polynomials become bivariate (a cyclotomic axis of length and a multi-precision axis of length ), the limb size is a free parameter, and the prefix property turns modulus switching into a length adjustment. The torus picture remains a useful piece of mathematical scaffolding, but it is honest to say that the working object is a stack of -bit signed integers and that every operation is, ultimately, integer arithmetic on those stacks.
In poulpy-ckks this materializes as word-generic ciphertexts and plaintexts carrying a CKKSMeta { log_delta, log_sparsity, slots } alongside their GLWE storage, with derived from the logical torus width k; backend-resident encoding operations on Module<BE>; arithmetic kernels that reconcile mismatched scales, budgets and requested result widths through offsets and shifts; fixed allocations separated from the logical limb prefix; and typed, bit-granular composition errors where RNS-CKKS would have signalled “out of levels”.
Much of what this foundation was built for has since landed in poulpy: bit-granular polynomial evaluation (including reusable minimax approximation planning), linear transforms (the homomorphic DFT, slot rotations, and matrix-vector products), and several bootstrapping paths. Those now include the standard and S2C-first CKKS pipelines, functional bootstrapping, and the native PaCo and SHIP families, all parameterized by precision rather than by a fixed prime chain.
Still coming next:
- Discrete CKKS - variants of the scheme where the plaintext space is integer rather than real, sharing the bivariate plumbing with the standard real-valued CKKS, with functional bootstrapping.
- Faster backends - as poulpy was designed to work with any hardware, it is relatively easy to add a new backend hardware acceleration. Poulpy already ships
AVX2 + FMA,AVX512(with anIFMAvariant), andARM(NEON/ASIMD, AArch64) backends; aCUDAbackend is on the roadmap.
The torus is a lie. The cake, on the other hand, is real.
Thanks to Jean-Philippe Bossuat for pointing out mistakes in an earlier version of this post.