Modulus-chain depth
Example source: examples/04_modulus_chain_depth.py
This example builds several modulus-chain depths for one preset and compares modulus bits, active RNS rows, security estimates, and value sizes. The tutorial connects the evaluator's required rescale/level-transition budget to configured chain depth, security budget, and ciphertext memory.
Run the example
Inspect low, middle, and full-depth variants of a preset:
python examples/04_modulus_chain_depth.py --preset slots32768-scale40-levels34-int64Select exact depth values:
python examples/04_modulus_chain_depth.py \
--preset slots32768-scale40-levels34-int64 \
--depths 16,24,342
3
1. Scale-prime count fixes the public-level interval
cfg = CkksConfig.parse(preset, num_scale_primes=depth)num_scale_primes counts configured scale-prime rows and equals engine.public_level_count. Public levels are therefore [0, num_scale_primes), and the number of ordinary public one-level transitions available from level zero is num_scale_primes - 1.
The level-zero ordinary modulus contains those scale primes plus one structural base Q prime, so
The final public level retains the last scale prime and the structural base. Bootstrap entry owns the subsequent transition into the base-only state.
For the maintained int64 presets, a useful approximation is:
where num_scale_primes, scale_bits, num_p_primes. The maintained int64 presets use
The exact catalog primes remain authoritative. In particular, every native modulus must also satisfy
total_modulus_bits is the exact configured value total_modulus_bits <= maximum_modulus_bits when enforce_security_budget=True.
The exact primes come from the immutable catalog. The configuration validates that the selected chain remains within the requested security budget.
2. Q and P have different roles
- Q rows form the ordinary ciphertext modulus chain.
- One leading scale prime is consumed by each rescale.
- The base Q row remains at the end of the chain.
- P rows support hybrid key switching and are not ordinary ciphertext rows.
The table printed by the example distinguishes Q primes, P primes, and total primes rather than reporting one ambiguous limb count.
3. Level-zero values are largest
ct0 = engine.encrypt_message([1, 2, 3, 4], level=0)
print(ct0.data.nbytes)2
At level zero, every Q row is active. A level transition drops leading Q rows, so a later-level ciphertext is smaller.
For a two-component ciphertext, the approximate payload size is:
where
Allocator overhead and temporary operation storage are separate from this payload calculation.
4. Compare costs at the same active level
Initial configured chain depth and current active Q-row count are different quantities. Two configurations that have reached the same active Q-row count can have similar current ciphertext sizes even if one started with a longer chain.
Conversely, comparing only level zero makes a longer initial chain look more expensive because it genuinely stores more rows at that point.
5. Choose depth from the circuit
Count rescale operations in the intended circuit and reserve a small engineering margin. Do not always select the largest chain depth simply because it fits the security table:
- more initial Q rows increase ciphertext and prepared-plaintext memory;
- key-switch and relinearization work touches more active rows;
- key material can dominate serving capacity;
- unnecessary depth makes early-level operations more expensive.
Level is not an abstract counter
In FHElium, level determines an exact ordered prime_ids interval and a concrete dense tensor shape. Operations validate this structure rather than trusting level metadata alone.
Complete runnable source
#!/usr/bin/env python3
"""Explore modulus-chain depth, modulus bits, and level-dependent sizes.
Run:
python examples/04_modulus_chain_depth.py --preset slots32768-scale40-levels34-int64
"""
from __future__ import annotations
import argparse
from common import add_engine_args, format_bytes, parse_preset, print_table
from fhelium import CkksEngine
from fhelium.config import CkksConfig
def variant_depths(preset_name: str) -> list[int]:
default_cfg = CkksConfig.parse(parse_preset(preset_name))
full = default_cfg.num_scale_primes
# Low/mid/full defaults, clipped and de-duplicated.
candidates = [max(1, full // 2), max(1, (full * 3) // 4), full]
return sorted(set(candidates))
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
add_engine_args(parser, default_preset="slots32768-scale40-levels34-int64")
parser.add_argument(
"--depths",
default=None,
help=(
"Comma-separated num_scale_primes values. "
"Default: low/mid/full for the preset."
),
)
args = parser.parse_args()
preset = parse_preset(args.preset)
depths = (
[int(x) for x in args.depths.split(",")]
if args.depths
else variant_depths(args.preset)
)
rows = []
for depth in depths:
cfg = CkksConfig.parse(preset, num_scale_primes=depth)
engine = CkksEngine(
cfg,
device=args.device,
ntt_backend=args.ntt_backend,
)
ct0 = engine.encrypt_message([1, 2, 3, 4], level=0)
# A ciphertext at level l stores the active Q_l scale rows plus the
# base Q row. Level 0 is therefore the largest ciphertext.
rows.append(
[
depth,
cfg.total_modulus_bits,
cfg.maximum_modulus_bits,
cfg.num_q_primes,
cfg.num_p_primes,
cfg.total_num_primes,
format_bytes(ct0.data.nbytes),
f"{ct0.data.nbytes / 1e6:.3f}",
]
)
default_cfg = CkksConfig.parse(preset)
print(
f"Preset {args.preset}: scale_bits={default_cfg.scale_bits} by "
"default; num_scale_primes is the public-level count."
)
print_table(
[
"scale primes/public levels",
"QP modulus bits",
"security budget bits",
"Q primes",
"P primes",
"total primes",
"level-0 ct size",
"level-0 ct MB",
],
rows,
)
print(f"\nRule of thumb for the selected {default_cfg.torch_dtype} preset:")
base_bits = default_cfg.base_prime_bits or default_cfg.message_bits
print(
" total_modulus_bits ~= num_scale_primes * "
f"{default_cfg.scale_bits} + {base_bits}(base) + "
f"{default_cfg.message_bits}*num_p_primes"
)
print(
" current ciphertext size follows the active Q_l rows at the current level, not just the initial chain length."
)
if __name__ == "__main__":
main()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102