Skip to main content

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

Every ciphertext has n / 2 slots, where n is the ring dimension from Choosing parameters. One axis of your tensor, the n axis, goes into those slots.
The two kinds of axes have very different prices. Along the other axes, data is addressable: each element of that axis lives in its own ciphertext, so combining two elements is just one 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: free.

Element-wise operators touch every slot independently and in parallel. The packing costs them nothing.

Across slots: a rotation.

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

A rotation shifts every slot by the same offset, cyclically. Combined with a sum, it brings slots together:
one rotate-and-sumCIPHERTEXT SLOTS
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 ]
Two facts to carry forward. A rotation always moves the whole ciphertext (all 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

When an operator has a big slot job (sum 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_sizes = (2, 2, 2)SUM OVER 8 SLOTS
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
Each stage multiplies the coverage by its group size, so the entries must multiply out to the whole job: 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.
The plan also fixes the rotation cost. A stage with group size 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:
Plan for k = 8Distinct offsets
(2, 2, 2)1 + 1 + 1 = 3, over three rounds
(4, 2)3 + 1 = 4, over two rounds
(8,)7, in a single round
Fewer stages always means more offsets, and vice versa; that trade is below.
The pattern sharpens with size. For k = 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.
All of this is settled at compile time. From 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

The plan adapts to k, never the other way around. So where does k come from? Three cases, depending on the operator:
From k (default: full slot count).

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.

From you.

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.

No k at all.

Element-wise operators (HomAdd, HomMul, the reshapes) never work across slots. No k, no stage_sizes, no rotations.

where_k_comes_from.pyPYTHON · LATTICA
# 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
The 128 passed to 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

Now the trade. Offsets and rounds pull in opposite directions:
Fewer stages: bigger key, faster query.

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.

More stages: small key, a little slower.

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.

Merging stages into fewer, wider ones is called stage collapse. It is a real trade, not an optimization with a right answer: key size against runtime. And there is a third cost, which connects back to the previous page: levels.

Stages spend levels

Two of the slot operators, 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.
stages_per_levelBehavior
1 (default)Mod-switch after every stage
len(stage_sizes)One mod-switch, at the end
Must divide len(stage_sizes) exactly; anything else fails compilation.
So the level cost of these operators is len(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

Three operators take a stage plan directly:
OperatorWhat it does across slots
HomExpandReplicates a value k times along a new axis: stage_sizes, stages_per_level
HomRunningSumRunning (prefix) sum across a slot span k: k, stage_sizes, stages_per_level
HomSumSlotsSlot-axis reduction over span k: k, stage_sizes; rotation/add only, never mod-switches
Exact rules and defaults are on each operator's reference page.
A few operators use these under the hood, with their own stage knobs: HomMatMul 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

01. Start with the defaults.

While developing, leave stage_sizes unset. The log-depth plan keeps the evaluation key at its smallest, and key generation and upload stay quick.

02. Collapse when speed matters more than key size.

If key size, upload time, and worker GPU memory are acceptable, widen the stages to cut rounds and speed up the query.

03. Group mod-switches when levels are tight.

Keep the stages, raise stages_per_level. You keep the small key and pay fewer levels; only the round count stays.

stage_plans.pyPYTHON · LATTICA
# 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
When in doubt, budget the levels first: the depth walk on the previous page counts stage-driven mod-switches like any other spend. Then tune the stage widths against your key-size and runtime constraints.

Next

The offsets you just planned become entries in the evaluation key; the levels you spend come from the modulus chain on the previous page. And if even a well-grouped plan runs the chain dry, Bootstrap is how you recover levels mid-pipeline.