Slots, rotations & stages
On Ciphertext state we saw that one axis of your tensor, the n axis, is packed into a ciphertext's slots. Values in different slots can't reach each other directly: any operator that needs to copy or sum values across slots must rotate the ciphertext, and rotations are among the most expensive operators a pipeline does. Stages, set through the stage_sizes parameter, are how you control that cost.
One axis lives in slots
n / 2 slots, where n is the ring dimension from Choosing parameters. One axis of your tensor, the n axis, goes into those slots.HomAdd between two ciphertexts. Along the n axis it is not: the values are sealed inside one encrypted object, and nothing can read "slot 5".Element-wise operators touch every slot independently and in parallel. The packing costs them nothing.
To sum the n axis, or copy a value along it, slots must line up with each other. The only way to do that is to shift the whole ciphertext, a rotation.
Rotations
slots [ x0 x1 x2 x3 x4 x5 x6 x7 ] rotate by 2 [ x2 x3 x4 x5 x6 x7 x0 x1 ] sum [ x0+x2 x1+x3 x2+x4 x3+x5 x4+x6 x5+x7 x6+x0 x7+x1 ]
n / 2 slots, occupied or not). The unoccupied ones hold zeros. Your n axis is padded up to a whole block before encryption, and a rotation moves those zeros at the same price as your real values. An n axis of 4097 fills two blocks and pays for 8192 slots. And you never call one yourself: the few operators that work across slots rotate internally. They're listed in Operators that take a stage plan below, and how much rotating they do is set by their stage plan. That plan is the subject of this page.Stages: stage_sizes
k slots, replicate a value k times), it works in rounds: each round combines the results of the previous one. One round is a stage. You choose the stage plan yourself, as a plain list of integers, one entry per round: that's the parameter stage_sizes. Each entry is that stage's group size, how many pieces it combines at once. Summing 8 slots with stage_sizes = (2, 2, 2) means three rounds, each combining pairs:stage 1 (size 2) (x0+x1) (x2+x3) (x4+x5) (x6+x7) covers 2 slots each stage 2 (size 2) (x0+x1+x2+x3) (x4+x5+x6+x7) covers 4 slots each stage 3 (size 2) (x0+x1+x2+x3+x4+x5+x6+x7) covers 8 = k
prod(stage_sizes) == k. Plans don't have to be uniform: (4, 2) also covers 8, one stage of fours, then a final pair. (3, 2) doesn't: 3 × 2 = 6, two slots would never be reached, and compilation fails.s needs s − 1 distinct rotation offsets. Summing those across every stage gives the plan's total offset count, the number of distinct rotation-key entries the plan needs overall:(2, 2, 2)1 + 1 + 1 = 3, over three rounds(4, 2)3 + 1 = 4, over two rounds(8,)7, in a single roundk = 1024, the plan (2,)×10 needs 10 offsets over ten rounds; (1024,) needs 1,023 in one round; (32, 32) sits in between with 62 over two.k and the plan, the compiler derives the exact offsets every stage will use; the server just runs the finished schedule, and the rotation key is generated with exactly those entries: no more, no fewer.Defaults: leave stage_sizes unset and you get (2,) × log₂(k): as many size-2 stages as it takes to double up to k. At one offset per stage it is the cheapest plan in offsets, which is why it's the small-key choice. It only exists when k is a power of two; for any other k, write the plan yourself.
Where k comes from
k, never the other way around. So where does k come from? Three cases, depending on the operator:HomSumSlots and HomRunningSum take k as the slot span to reduce or accumulate. When omitted, k defaults to the ciphertext's full slot count. Optional stage_sizes must multiply to k.
HomExpand can't infer k at all, since a single value could be replicated any number of times, 8 copies or 4096. So you pass k explicitly, chosen to fit the next step, usually the length of the operand the result will meet.
Element-wise operators (HomAdd, HomMul, the reshapes) never work across slots. No k, no stage_sizes, no rotations.
# 7 meaningful values in a 4096-slot ciphertext → one score → broadcast to meet a length-128 vector HomSumSlots() # k defaults to n_slots (full slot count) HomExpand(k=128, ...) # k = 128 (chosen to match the next operand) HomMul(...) # element-wise against the 128 weights: no k
HomExpand here isn't derived from anything in the ciphertext or the computation so far. It is a value you choose because it matches what comes next in your pipeline, the length of the weights the result will meet, the same way you'd match array dimensions in any ML framework. The compiler checks the match at compile time, but it's on you to pick the right number.The price of a stage plan
Wide stages need many distinct offsets, and every offset is one more entry in the rotation key: a bigger key to generate and upload, more GPU memory on the worker. In exchange, fewer rounds: the query runs faster.
Narrow stages reuse a handful of offsets, so the key stays small and cheap to upload. In exchange, more rounds: each round is one more pass over the ciphertext, so the query is somewhat slower.
Stages spend levels
HomExpand and HomRunningSum, run a mod-switch as they go. How often is set by stages_per_level: how many stages to group together before each mod-switch.1 (default)Mod-switch after every stagelen(stage_sizes)One mod-switch, at the endlen(stage_sizes) / stages_per_level, and here the two knobs interact: the default ten-stage plan for k = 1024, left at stages_per_level = 1, spends ten levels, even though its rotation tree is the cheap one. More stages eat more levels unless you group the mod-switches.HomSumSlots is the exception. It never mod-switches between stages, has no stages_per_level parameter, and spends zero levels whatever its stage plan. Its only costs are key size and rounds.
Operators that take a stage plan
HomExpandReplicates a value k times along a new axis: stage_sizes, stages_per_levelHomRunningSumRunning (prefix) sum across a slot span k: k, stage_sizes, stages_per_levelHomSumSlotsSlot-axis reduction over span k: k, stage_sizes; rotation/add only, never mod-switchesHomMatMul runs a slot sum when the axis it multiplies over is the n axis. On the client side, Repeat tiles your input to fill the slots before encryption, so the server-side ops have a full block to work with.Same name, different thing: Bootstrap has its own internal transforms. Those are separate from the stage plans on this page; nothing here applies to them.
Choosing a stage plan
While developing, leave stage_sizes unset. The log-depth plan keeps the evaluation key at its smallest, and key generation and upload stay quick.
If key size, upload time, and worker GPU memory are acceptable, widen the stages to cut rounds and speed up the query.
Keep the stages, raise stages_per_level. You keep the small key and pay fewer levels; only the round count stays.
# Default: log-depth tree (smallest key) HomExpand(k=128) # 7 stages, 7 levels # Collapsed: one wide stage (bigger key, fastest) HomExpand(k=128, stage_sizes=(128,)) # 1 stage, 1 level # Small key, grouped mod-switches (fewest levels for this tree) HomExpand(k=128, stage_sizes=(2,)*7, stages_per_level=7) # 7 stages, 1 level