Skip to content

wip: Rotom layout alignment, assignment, and lowering - #2980

Draft
edwjchen wants to merge 86 commits into
google:mainfrom
edwjchen:rotom_layout
Draft

wip: Rotom layout alignment, assignment, and lowering#2980
edwjchen wants to merge 86 commits into
google:mainfrom
edwjchen:rotom_layout

Conversation

@edwjchen

Copy link
Copy Markdown
Collaborator

do not merge yet!

@AlexanderViand
AlexanderViand marked this pull request as draft May 21, 2026 16:52
@asraa
asraa self-requested a review May 21, 2026 16:52
edwjchen added 26 commits June 11, 2026 21:23
Add withRolls() and enumerateSingleRolls() to LayoutAlignment. The latter
ranges over ordered pairs of distinct, equal-extent slot-side traversal dims
and returns the materializable single-roll variants of a layout, the building
block for seeding rolled (diagonal) packings into the layout search.

Rolls only affect the slot line of the materialized layout, so a roll whose
'from' dim is ciphertext-side lowers as a no-op; restrict both roll indices to
the slot side so every variant is a genuinely distinct packing.
Two pipeline-execution tests proving rolled (diagonal) layouts compute
correctly through layout assignment, materialization, the relation-driven
lowering, and the interpreter:
- a shared rolled layout on both addf operands, and
- a rolled operand added to a row-major operand, which forces the lowering
  to remap between the rolled and unrolled packings.

Validates the premise that the existing brute-force path already handles
rolled layouts, de-risking rolled-variant injection into the search.
Add RotomTensorOpLowering::lowerMatmulByRotation, a diagonal matmul kernel
that replaces the brute-force per-scalar mask+remap lowering with ciphertext
rotations. For a single-ciphertext layout it groups every (i,j,k) contraction
term by the rotation pair that realizes it -- (srcSlot - dstSlot) mod numSlots
for each operand -- and emits one masked rotate-multiply per group, accumulated
into the destination operand. The construction is correct for any such layout
(each contribution is realized exactly once at its destination, with a
shift-collision guard and single-point/single-ciphertext gates that fall back
to the brute-force path), and collapses to few rotations exactly when the
layout is rolled/diagonal: a Halevi-Shoup matvec with a diagonal-packed matrix
uses 14 rotations vs 32 row-major.

Also add tensor_ext.rotate support to the OpenFHE interpreter so the kernel is
execution-verifiable, update the matmul lowering golden (remap -> rotate), and
add an execution test asserting the rolled packing uses fewer rotations and
still computes the correct matvec.
…NIST@32768

Implements the Halevi-Shoup ciphertext-axis diagonal matvec with a
baby-step/giant-step schedule, auto-discovered from a row-major seed, and
threads it through the whole seed -> assign -> materialize -> convert
pipeline so a full MNIST 784->512->10 MLP lowers at the reference
ciphertext size 32768 with the reference packing (16-ciphertext layer 1).

Kernels (RotomTensorOpLowering):
- lowerMatvecCtDiagonalBsgs: square/squat single-period + replicated BSGS.
- lowerMatvecDenseDiagonal: P=N/K diagonals per ciphertext (dense packing).
- Both handle non-power-of-two M and K by doing slot arithmetic over the
  padded extents Dp=nextPow2(m), Kp=nextPow2(n) while verifying only the
  real [0,m)x[0,n) domain (padding is zero); identical for power-of-two dims.
- Layout verification rebuilt the ISL relation per matrix element (O(m*n)
  ISL builds, minutes at network scale); now indexes each relation once
  (indexRangeByDomain) -> per-element lookups. Convert of a 512-hidden layer
  drops from minutes to ~16s.

Search (LayoutAssignment):
- Generates the rolled diagonal/identity candidates and a rotation-aware
  cost so the matvec kernel is discovered without a hand-written layout.
- Kernel gate checks the squat contract on padded extents.
- Fixes a back-propagation break where a bare operand candidate shadowed an
  intermediate's candidate (chained matmuls losing their kernel).

Seed (SeedLayout): replication-to-fill -- a tensor smaller than the
ciphertext (e.g. a 10x512 weight at n=32768) now gets a seed (full slot
packing + a replication dim) so the search has a candidate to roll.

Secret path (MaterializeTensorExtLayout): copy a secret.generic operand's
materialized layout onto the corresponding function-argument attribute, so
the type converter converts the secret arg (fixes a block-arg/operand type
mismatch when a secret vector materializes wider than its data).

Utils/Layout: guard isRelation{Bicyclic,RowMajor,PerRow,SquatDiagonal}
against incompatible Presburger spaces (a replicated layout has extra local
vars) instead of asserting inside IntegerRelation::isEqual.

Pipeline: register --mlir-to-rotom-ciphertext (seed -> assign -> materialize
-> convert) so high-level tensor IR in secret.generic regions lowers in one
command with no hand-written layouts.

Tests: RotomPipelineExecutionTest gains end-to-end numerical coverage for
the square/squat/dense/replicated/under-filled/padded matvec regimes, a
two-layer MLP (matvec -> square -> matvec), and an MNIST-layer-scale
512x1024 -> 16x512 MLP; plus a lit test for the registered pipeline.
A neural-net layer y = x . W^T is a vector-times-matrix matmul x(1xK) * M(KxN),
but the ciphertext-axis diagonal matvec kernel only handles matrix * column-
vector. SeedLayout now rewrites such matmuls (gate: lhs.dim0==1 && rhs.dim1>1)
into transpose(matmul(M^T, x^T)) before seeding, reusing the existing kernel;
when M = transpose(W) the pre-transpose W is used directly to avoid a redundant
transpose-of-transpose. The matrix is then W (NxK) and must be squat (N<=K),
which the real MNIST layers (512x784, 10x512) satisfy.

Both matmuls of the real MNIST model now lower to RotomMatmul with no leftover
matmul. Adds a lit test for the rewrite.

Also guards two more IntegerRelation::isEqual call sites (ConvertTensor
{Collapse,Expand}Shape) against incompatible Presburger spaces -- exposed by the
rewrite's vector transposes lowering to reshapes over replicated layouts.
Seven fixes from the branch code review:

- SeedLayout: only reuse a transpose producer's input when it is a true
  [1,0] permutation, else an identity transpose would build an invalid
  matmul(K x N, K x 1).
- LayoutAssignment::visitMatmul: bail on dynamic shapes (matching the
  lowering) so the padded-extent cost model never divides by kDynamic.
- MaterializeTensorExtLayout: error on a function argument feeding
  secret.generic operands with conflicting materialized layouts instead
  of silently keeping the first.
- verifyLayoutRolls: reject a zero-extent rolled dim before the modulo
  (was a div-by-zero in the verifier).
- lowerMatmulByRotation: index each relation once via indexRangeByDomain
  rather than rebuilding the IntegerRelation per (i,j,k) domain point.
- lowerMatvecCtDiagonalBsgs: drop the dead 'n > Kp' gate (Kp = pow2(n)).
- LayoutAssignment: use llvm::Log2_64_Ceil instead of a hand-rolled
  ceilLog2.

heir-opt builds clean; all 28 Rotom tests pass.
Addresses two more code-review findings:

google#4 layoutNumCiphertexts overcounted a dense straddling layout. Expose the
straddle-aware inferCtPrefixLen from RotomAttributes (moved out of the
anonymous namespace, declared in the header) and route the Rotom layout
cost utilities through it, so a boundary dim that spans the ct/slot split
contributes only its high (ciphertext) part. Adds a unit test pinning the
2-ciphertext count for a 4x8 straddling layout at n=16.

google#11 the 'spaces compatible then isEqual' guard was duplicated at six sites.
Factor it into relationsCompatibleAndEqual (lib/Utils/Layout) and route the
four isRelation* pattern matchers and the two collapse/expand checks
through it. Deliberately NOT isRelationEqual: that adds an ISL sampling
(tryProveUnequal) pass that is wasted work on these one-off comparisons and
measurably slowed layout propagation.

heir-opt builds clean; layout_propagation, convert_to_ciphertext_semantics,
and the Rotom suites all pass.
Code-review finding google#6. createMaskForPoints is atomic (validates all points,
then builds one constant) but the three diagonal matvec kernels called it
*after* emitting rotate/mul ops, so an (unreachable) mask failure returned
failure() with a partially built chain left in the IR -- the caller treats
that as 'kernel not applicable' and falls back, orphaning the dead ops.

The masks are constants independent of the accumulation, so build them up
front: the BSGS and dense kernels create the single gap mask right after the
builder, and lowerMatmulByRotation pre-builds every group's mask before the
emit loop. Now the only fallible step runs before any mutation, so a failure
cannot orphan a rotation chain.

Rotom execution + lit tests pass.
Code-review finding google#13. The func-arg layout propagation hand-rolled the
block-arg -> func-arg attribute storage (dyn_cast<FunctionOpInterface> +
getArgNumber + getArgAttr/setArgAttr). The shared association layer already
implements exactly that routing (rule 1: a FunctionOpInterface block argument
maps to its arg attr), so use findAttributeAssociatedWith to read the existing
arg layout and setAttributeAssociatedWith to write it. Keeps the func-arg
targeting (only function arguments need this) and the conflict diagnostic.

No BUILD change (AttributeUtils already a dep). Rotom tests pass.
Code-review finding google#12. normalizeRowVectorMatmuls was a structural matmul
rewrite (x.W^T -> transpose(matmul(W, x^T))) hardcoded at the top of the
SeedLayout pass, which is otherwise about seeding layout attributes. Move it
into a dedicated rotom-normalize-matmuls pass so the rewrite is independently
registered, testable, and reorderable.

- New pass NormalizeMatmuls (td/h/cpp) carrying the verbatim rewrite, with
  Linalg/Tensor as dependent dialects (it creates those ops).
- SeedLayout drops the rewrite, its call, and the now-unused Linalg/Tensor
  includes + BUILD deps.
- The mlir-to-rotom-ciphertext pipeline runs rotom-normalize-matmuls before
  rotom-seed-layout; the lit test invokes it explicitly.

heir-opt builds clean; Rotom lit + execution tests pass.
Code-review finding google#9. lowerMatvecCtDiagonalBsgs and lowerMatvecDenseDiagonal
duplicated ~120 lines of baby-step/giant-step accumulation, squat residual
rotate-and-sum, and gap masking. Factor that into one emitDiagonalBsgs helper
parameterized by the contraction vector, an extract-diagonal callback, the
diagonal count, the rotation modulus, the residual limit, the gap mask, and an
optional layout tag.

The tag is a parameter rather than unified behavior on purpose: the
single-period kernel works at the full ciphertext width (numSlots == output
width) so its intermediates carry the output layout, whereas the dense kernel
works K-wide (!= the N-wide output) and must not tag them. Each kernel keeps
its own structure verification, gap-mask construction, and output placement.

Behavior-preserving: the execution test (both single-period and dense matvecs)
and the Rotom lit tests pass.
Replace the out-parameter with a small struct { size_t length; int64_t
straddleSlotExtent; } so the don't-care caller no longer declares a throwaway
local. Behavior unchanged; build + 28 tests pass.
…actor phase A)

Relation-preserving first step toward a strided layout model. The ct/slot
straddle was tracked as a StraddleRole{None,High,Low} per piece plus a global
straddleSlotExtent, special-cased in the ISL address emitter (High -> floorDiv,
Low -> mod). Replace that with a per-piece mixed-radix digit descriptor on
LayoutData: pieceDivBy/pieceModBy, where a piece reads digit = (i / divBy) mod
modBy of its tensor index. A whole-dim piece is (1, 0) => i; a straddle splits
into a ct piece (L, 0) => i/L and a slot piece (1, L) => i mod L.

This drops the StraddleRole enum and the straddle special-casing in
emitSegmentAddress/emitSplitCtSlotIsl in favor of one uniform digit rule that
also generalizes to N-way splits (groundwork for making the attribute stride the
address weight). Attribute syntax and seeding are unchanged; the emitted ISL
relations are byte-identical -- the lowering unit test, materialize lit tests,
and execution test all pass.
…ensor axis

Refactor phase B. Make the dim attribute's stride meaningful and unify the old
'repeated dim id' tiling with the straddle's mixed-radix split:

- getDim() is the tensor axis. Pieces sharing a getDim() are a mixed-radix split
  of that one axis and share its domain variable (preprocessing dedups the
  traversal entry per tensor dim).
- For a multi-piece axis, the attribute stride is the within-axis digit divisor:
  digit = (i / stride) mod extent, with strides being the cumulative products of
  the lower extents (1, e0, e0*e1, ...). Placement and the ct/slot split stay
  positional (the cumulative product of extents crosses n at the boundary).
- A single-piece axis keeps the legacy behavior (stride ignored, digit == i), so
  existing layouts -- including seeds with non-unit strides and the detected
  straddle -- are byte-identical.
- LayoutAttr::verify rejects an axis whose pieces are not a valid mixed-radix
  decomposition (e.g. duplicate strides).
- The ISL emitter now sums per piece, not per dim, so an axis can place several
  digits in one segment.

The old position-based 'tiled duplicate dim' interpretation is replaced by this:
the tiled-row-major tests keep the same (ct,slot) packing, now expressed with one
domain variable per axis. Full Rotom suite (lit/unit/execution),
convert-to-ciphertext-semantics, and layout_propagation pass.
…muls pass

Removes all Rotom matmul/diagonal machinery, keeping elementwise and
rolled-layout functionality:
- lowering kernels (lowerMatmul, diagonal/BSGS matvec) in RotomTensorOpLowering
- the convert-to-ciphertext matmul pattern branch (supportsRotomMatmul)
- matmul cost/layout/kernel-selection paths in LayoutAssignment
- the NormalizeMatmuls pass (.td/.h/.cpp + BUILD/Passes.h/heir-opt wiring)
- the dead KernelName::RotomMatmul enum value across Kernel + LayoutOptimization
- dependent matmul lit tests and the matmul lowering unit test
With the diagonal matvec layouts removed, the only producer of un-split
straddling dims is gone: SeedLayout always splits a boundary-spanning axis
into an explicit ct piece + slot piece. So inferCtPrefixLen no longer needs
to detect a straddle and auto-insert a slot piece during preprocessing.

- inferCtPrefixLen returns size_t (the CtPrefix struct collapses to its one
  remaining field); a dim that does not fit the slot budget simply stays on
  the ciphertext axis.
- preprocessLayoutData drops the auto-split branch; boundary-spanning axes
  must be expressed as explicit mixed-radix splits.
- layoutNumCiphertexts counts the ct prefix directly, no straddle divide.
- Migrate the straddle unit test to an explicit mixed-radix split.
A roll(i, j) shifts one dim by the other modulo their shared extent, so the
two rolled dims must have equal extents (only the extents must match; strides
may differ). Tighten the verifier accordingly and drop the dead non-zero
extent check (DimAttr::verify already guarantees size > 0).
Rename LayoutData::pieceDivBy -> pieceStride and pieceModBy -> pieceExtent to
match Rotom's [dim:extent:stride] notation. pieceExtent now always holds the
piece's true extent (never a 0 sentinel): the "drop the redundant modulus on
the most-significant digit" optimization moves from a preprocessing sentinel to
an emitter-side check (stride * extent < the axis full extent). Byte-identical
ISL output; also simplifies preprocessLayoutData.
…ivalence

Reverts the pieceStride/pieceExtent rename. Since the sibling field is the
positional address coeff (not a Rotom dim), the digit descriptor keeps the
implementation-oriented divBy/modBy names (and the 0 = no-modulus sentinel),
with a comment noting they are the piece Rotom stride/extent.
edwjchen added 27 commits June 24, 2026 05:45
Refactor phase A of the LayoutAssignment split. Each of the 12 per-op
candidate visitors (visitFunc/visitElementwise/visitTranspose/... ) becomes a
free static generate*(AssignmentContext&, XOp) function; visitOperation
dispatches to them. The pass implements the new narrow AssignmentContext
interface -- the seed/candidate/cost API the generators call back into -- so
each generator depends only on that, not on the pass struct. This is the
decoupling that lets the generators move to per-category files next.

Also tighten the file-local anonymous namespace to wrap only the
LayoutAssignment struct; the free helpers and generators are now plain static
functions per the LLVM convention.

Behavior-preserving: builds clean, 22/22 Rotom lit + execution tests pass.
Move getPlainValueType / isTensorLike / isLayoutCompatibleWithValue out of
LayoutAssignment.cpp into a small ValueUtils.{h,cpp}. These are the shared
value-type predicates the per-op generators rely on; giving them a header lets
the generators move to their own files (refactor phase C) without depending on
the pass translation unit. Behavior-preserving: 22/22 Rotom tests pass.
Refactor phase C. The 12 generate* functions now live in four op-family files
under LayoutAssignment/gen/ -- Structural (func/secret.generic/yield/
passthrough), Elementwise (add/sub/mul + linalg.generic), ReduceTranspose, and
Reshape (collapse/expand/extract/insert) -- all declared in Generators.h. The
linalg-body predicates (isElementwiseGeneric/hasAddLikeBody) move with the
elementwise generator.

LayoutAssignment.cpp drops from ~680 to ~420 lines: it now holds the pass
struct, the AssignmentContext implementation, the op dispatch, and the
selection phase. Adding a new tensor op is now: a generator in the relevant
gen/ file + a Generators.h declaration + one dispatch line.

Behavior-preserving: builds clean, 22/22 Rotom lit + execution tests pass.
enumerateMatmulPlans derives, per (lhs, rhs) layout pairing in (i, j, k)
iteration-space dims, the deterministic aligned placements for
expand -> multiply -> sum-k matmul: compute/expanded/result layouts plus
raw rotation/add counts for replication fill (free across ciphertexts,
log2 within slots) and the k reduction. No kernel names; M2 will price
these in generateMatmul and M3 will re-derive them in lowering.
linalg.matmul candidates now come from ContractionAlignment's
deterministic plans: operand candidates are relabeled into the (i, j, k)
iteration space, each plan is priced as operand alignment
(convert-to-pre-fill + replication fill) + ciphertext multiplies +
k-reduction, and the result candidate carries the plan's result layout
with no kernel name -- the lowering (M3) re-derives the same plan from
the assigned layouts.
ConvertRotomMatmul re-derives the deterministic ContractionAlignment
plan from the (lhs, rhs, result) rotom layouts the assignment records
under the rotom.matmul op attribute, then emits: one same-shape
convert_layout per operand onto its inner expanded placement (the shift
network fills slot replication), free outermost ciphertext copies via
concat, one elementwise multiply, a log-tree rotate-and-reduce per
k slot piece, and contiguous row-block adds for an outermost k
ciphertext piece. No kernel attribute involved.

ContractionAlignment now speaks each tensor's own dims at its boundary
(computeLayout stays in iteration space) and exposes
stripOuterCtReplication + isLowerableMatmulPlan; generateMatmul gates
candidates on lowerability and prices operand alignment with the
shift-network conversion cost, so the assignment never selects a plan
the lowering cannot realize (4x4 matmul thus needs n=64; matvec works
at n=16).
The deduped traversalDims kept first-appearance order, and the ISL
lowering emits its domain variables in that order -- but every tensor_ext
consumer reads domain variables positionally as tensor dims. Any layout
whose pieces lead with a later dim (e.g. column-major [1:.][0:.]) thus
materialized with silently permuted dims: row- and column-major 4x4
produced identical ISL, so conversions between them priced (and lowered)
as free no-ops.

preprocessLayoutData now sorts traversalDims by dim id and remaps
pieceIndex, making the domain order a defined invariant. Re-blessed the
tests that encoded the old convention: the column-major evaluate tests
index the domain as [row, col] (expected packings unchanged), the
column-major materialize tests now check genuinely column-major
relations, and the assignment winners that flipped once conversion
costs became honest (elementwise now emits the real transpose remap for
mixed row/column-major seeds; the 4x4 matmul picks the lhs-hosted slot
plan).
Execution tests run matvec (n=16) and 4x4 matmul (n=64) through
seed -> assign -> materialize -> convert -> shift-network and compare
against plaintext references, checking exactly the slots the result
layout claims.

Writing the expected packing exposed a wrong claim from M1: after the
cyclic log-tree rotate-and-reduce, only the k=0 offset holds the true
sum (other offsets hold window sums whose carries cross into the digit
above k), so a summed slot-k piece now becomes a gap in resultLayout,
not replication.
New rotom-outline-kernels pass (after layout assignment): each
linalg.matmul carrying a rotom.matmul layout combination moves into a
private kernel function keyed by (op, layouts, types) -- two matmuls
with the same signature share one callee -- and the call site carries
the result layout. The materializer and the ciphertext lowering process
kernel bodies like any other layout-assigned function, keeping the
Rotom kernel vocabulary contained per function and unit-testable in
isolation.

Pipelines (mlir-to-rotom-ciphertext and the execution test) inline the
calls after the ciphertext lowering so cross-kernel optimization and
backends without call support see a flat function; tensor_ext gains the
trivial DialectInlinerInterface this requires, and heir-opt registers
the func-dialect inliner extension.
OutlineKernels now covers every Rotom-lowered tensor operator, not just
matmul: elementwise arith ops carrying a Rotom secret.kernel outline
into per-signature functions too (operand layouts read from the
per-value rotom.layout attributes, result layout carried on the call).
The op-specific logic is reduced to a target predicate plus operand/
prologue selection over a shared outlineOp/buildKernel core, so future
ops (reduce, transpose) are one dispatch entry each. Outlining is gated
to plain-func bodies for now; secret.generic regions are a TODO with
the secret-path work.
Per design rule: gate by cost, not capability. planLayoutExpansion
(LayoutAlignment) decomposes any layout pair -- including different
ciphertext counts, which tensor_ext.convert_layout cannot express --
into deterministic rotate/mask/accumulate steps grouped by
(targetCt, sourceCt, shift). The matmul generator prices exactly those
steps (pure ct-replication prices free), ConvertRotomMatmul emits them
(per-target-ct extract/rotate/mask/add, free copies for full-row
zero-shift steps), and the k-ciphertext reduction is generalized to
digit-stripping row gathers for any k placement. isLowerableMatmulPlan
and stripOuterCtReplication are deleted: every enumerated plan is now
emittable, so search prunes expensive expansions purely by cost.

The 4x4 matmul at n=16 -- previously unassignable -- now lowers and
executes numerically exactly (new execution test).
roll(a, r) with r a replication dim of equal extent makes replica d hold
dim a cyclically rotated by d: the layout materializes every rotation of
the rolled data, so downstream alignment becomes replica selection
instead of a shift network. The verifier now permits a replication dim
in the roll-by position (the rolled dim must stay traversal; gaps stay
rejected), and the ISL emitter substitutes the replica's existential
variable into the roll expression. Evaluation test pins the packing
(replica d = rotate-left-by-d).

Also fixes a latent assert: makeCheckedLayout streamed verifier
diagnostics into an inactive InFlightDiagnostic; it now uses a real,
silenced emitter.
A roll-by dim may now be a gap of equal extent. The gap's block index is
the shift, so block g holds the rolled dim cyclically shifted by g: a
rolled-by gap claims its blocks with the rotations (a plain gap stays
unclaimed space folded to zero). The ISL emitter existentially
quantifies rolled-by gaps, emits their address term, and counts them in
the ciphertext bound; layoutNumCiphertexts counts rolled ct-prefix gaps
the same way. Rolling FROM a gap or replication dim stays rejected (the
block index is existential, so the shift is a no-op).
…ering

planLayoutExpansion now backs the general conversion path, not just
matmul operand alignment: cachedConversionCost prices a ciphertext-
count-changing conversion (expansion or compaction) by its explicit
rotate/mask/accumulate steps, supportsRotomAlignmentLowering no longer
requires equal ciphertext counts, and the elementwise lowering emits
those steps via a shared RotomTensorOpLowering::convertToLayout helper
(convert_layout when the count is unchanged). ConvertRotomMatmul's
expandOperand delegates to the same helper. Compaction thus competes on
cost anywhere a conversion is priced, per the gate-by-cost rule.

New coverage: a planner unit test for compacting a gapped 4-ct matmul
result into one column-major ciphertext, and a numeric pipeline test
adding operands seeded at different ciphertext counts.
Extends enumerateMatmulPlans with rolled variants: a unit-stride k piece
in the ciphertext prefix rolled by a same-extent unit-stride i/j piece in
the slot region. The roll is positional metadata on the compute footprint,
inherited piece-for-piece by both operand expansions (the operand that does
not own the partner dim rolls by the replication piece that subsumed it),
so the multiply and ciphertext-add counts are unchanged and the result is
unrolled. Hosts also gain a reverse-subsumption variant -- the free dim
placed over an existing same-extent replication piece -- so operands
already at an expanded placement (rolled or not) enumerate the compute
placement they came from at zero conversion cost.

Since a rolled plan and its roll-free sibling share a result layout, the
layout assignment now records the priced winner's computeLayout as a
fourth element of rotom.matmul (via selectMatmulPlan, sharing the exact
generator cost formula), and ConvertRotomMatmul matches plans by that
unique identity, falling back to result-layout matching for 3-element
attrs.

The seeded ct-diagonal pair a = roll(0,2)[1:4];[0:4][R:4],
b = roll(0,2)[0:4];[R:4][1:4] now lowers to 4 ciphertext multiplies and
3 adds with zero rotations, leaving one row-major result ciphertext;
verified numerically end to end.
A source candidate (a value straight from seeding: a secret function
argument or cleartext feeding secret compute) is data packed at encode
time, so a matmul plan's expanded operand placement is reachable by
packing the source there directly instead of converting in ciphertext
space. generateMatmul now offers this repack option per operand alongside
the priced conversion: the candidate's assignment overwrites the source's
layout with the expanded placement at a small epsilon cost (carried on the
source's assignment entry so multiple consumers charge it once, and large
enough that a plan whose operands already sit at their seeded layouts wins
ties -- the user's packing stands when nothing beats it). Shared-source
consistency is preserved by the existing assignment merge: a source
consumed elsewhere at a different layout rejects the combination and the
conversion-priced candidates take over.

Winners improve across the board: the n=64 matmul and n=16 matvec lit
tests now pack operands at their compute placements outright (the matvec
kernel drops its operand remap entirely -- multiply plus two rotate-adds),
and the n=16 row-major-seeded matmul repacks both sources onto the
4-ciphertext ct-k placement, summing k with plain ciphertext adds. A new
numeric test pins the conversion path: a matmul of an intermediate (a + a)
still converts that operand in ciphertext space, since only sources can
repack.
…R2.5)

Two search goals, per design discussion: (1) cheap conversion/op costs,
(2) compact ciphertext counts (future bootstrap placement wants compact
candidates).

Goal 2: RotomCostModel gains a ciphertextCount carrying weight, charged
once per assigned value on its layout's ciphertext count (in
setCandidates; block arguments and yields alias an already-charged value
and are exempt; repacked sources re-charge at the expanded placement).
This keeps encode-time repacking honest: blowing a source up to four
ciphertexts is no longer epsilon-cheap, so compact packings plus
rotation-only ciphertext-space expansions win when they should.

Goal 1: a unit test pins that planLayoutExpansion already prices the
replicate-then-roll route -- expanding compact column-major onto the
rolled-by-replication placement (roll-by partner outermost in slots) is
one full-ciphertext rotation per target ciphertext, no masks, since
ciphertext replication is free and the roll's mod-extent wrap coincides
with the whole-ciphertext cyclic rotation.

A numeric test runs the route end to end: a column-major-seeded lhs stays
at ONE ciphertext (the carrying cost outweighs the fat repack) and is
expanded by pure rotations in ciphertext space, against a pre-rolled rhs;
result matches the plaintext reference. The downstream_selects_intermediate
lit fixture now uses equal-ciphertext-count seed orderings so it isolates
the downstream-preference mechanism from the new compactness pressure.
[R:4:1] and [G:4:1] instead of [-1:4:1] and [-2:4:1] -- easier to read.
The numeric ids are still accepted on input and round-trip to the letter
forms (covered in syntax.mlir). Test spellings updated; the n=64 matmul
lit test's cost-tied winner shifted because the structural tie key orders
candidates by their printed form.
Generalizes the R1 roll decoration: a unit-stride k piece anywhere in the
compute footprint may roll by any same-extent unit-stride traversal piece
elsewhere in it (pairs entirely inside the ciphertext prefix are skipped
-- that only permutes ciphertext contents). This adds the slot-diagonal
family to the existing ct-diagonal one: a slot k rolled by a slot piece is
the classic Halevi-Shoup diagonal packing, and by a ciphertext piece the
replicate-then-roll form. The k reduction keeps its footprint shape -- per
remaining coordinate the rolled index is a bijection of k, so the slot
rotate-and-reduce still sums k and the result gaps the piece as usual --
and the lowering needs no changes at all.

Also fixes a pricing bug this exposed: conversionMoves atomizes dims only
and is blind to rolls, so its empty-moves fast path claimed conversions
between rolled and unrolled layouts of the same footprint (e.g. row-major
onto the diagonal packing) were free. Rolled layouts now return the
sentinel and defer to the relation-based pricing.

Numeric test: a matmul whose lhs is an intermediate held at the
one-ciphertext diagonal packing -- unable to repack, its only cheap
expansion is onto the slot-diagonal plan's replicated-diagonal placement
(four zero-shift copies, no rotations or masks). The search picks the
Halevi-Shoup plan (pinned by the four-ciphertext gapped result), the
compact diagonal source stays at one ciphertext, and the lowered kernel is
one multiply plus a two-rotation log-tree reduce; values match the
plaintext reference.
A seeded value is packed at encode time, so its diagonal packings are
available at the same zero packing cost. seedValue now widens each seed
with enumerateSingleRollVariants: every verifier-legal, materializable
single roll (from a unit-stride traversal piece, by a same-extent
unit-stride traversal or replication piece, skipping pairs entirely inside
the ciphertext prefix). A rolled placement materializes every rotation of
the rolled piece across its partner's blocks, so any consumer -- the
elementwise alignment, conversion pricing, and matmul plan enumeration all
already handle rolled layouts -- can align against it by block selection
instead of slot permutation; whether a variant wins stays a cost decision.

Observable wins: row-major and column-major seeded elementwise operands now
each adopt their diagonal variant, leaving a smaller residual conversion
between the two diagonal packings than the direct slot swap (lit test
re-blessed); a new numeric test pins the zero-conversion case, where one
operand arrives packed only diagonally and the other source adopts the
matching diagonal variant, lowering to a single add with no rotations or
convert_layouts.

Variants are filtered by isMaterializableRotomLayout up front: candidates
become assigned layouts, which must materialize -- an unfiltered variant
reached the same-count conversion pricing assert.
Measurement showed the VVE shift network quotes n-1 rotations for
rolled-replication fills (15/63/255/1023 at D=4..32) where D-1 rotations
suffice. Fixes:

- planLayoutExpansion gains replica-aware greedy grouping: per target
  point, candidate source keys are ranked by (existing-group, zero-shift,
  smallest key), so replicated sources coalesce into D-1 rotation groups.
- New chooseSameCountConversion(from, to, n) plans explicit steps AND
  quotes the VVE shift network, returning whichever needs fewer rotations
  (ties favor VVE). Both cachedConversionCost (pricing) and
  convertToLayout (emission) call it, so the priced route is the emitted
  route.
- conversionMoves stays roll-blind and is not consulted here.
- Regression test SameCountConversionPrefersStepsForRolledFill pins
  useSteps with D-1 rotations; the elementwise lowering lit test is
  re-blessed from tensor_ext.remap to 3 explicit rotates + masks.
enumerateMatmulPlans previously built footprints from host dims only
(host rolls vanished) and always emitted an unrolled result. Now:

- Footprints carry the host's own rolls (positions shifted for the
  ct-prepended free piece), minus reduction-incompatible pairs (a non-k
  dim rolled BY k does not commute with the k-sum). A rolled operand
  hosts its diagonal form at zero conversion, including multi-roll hosts
  the single-roll decorations cannot re-derive.
- Roll decorations generalize beyond k: an i/j piece rolled by the other
  free/host traversal dim or by a replication piece (the free-swap
  diagonal). These commute with the k-sum, so the RESULT inherits them
  at its surviving positions -- a matmul can produce a diagonal result
  directly, the operand form a downstream Halevi-Shoup matmul consumes.
  Decorations stack on either base (roll-free or host-rolled) unless the
  composition would be order-dependent.
- Expansions drop rolls whose FROM piece they subsume into replication
  (the operand does not own the dim, so the roll is a no-op for it);
  a subsumed BY piece keeps the roll on the replication piece as before.
- Plans whose result layout cannot materialize are skipped: the result
  becomes a value's assigned layout (expansions were already priced).

Row-major pair grows from 9 to 15 deduped plans (two free-swap variants
per footprint). New unit tests pin the free-swap plan's expansions and
rolled result, the non-seeding of incompatible host rolls, and rolled
hosting of a chained diagonal result; a numeric (A x B) x C pipeline
test executes the chain, where the search hosts the intermediate on a
rolled ct-diagonal plan for the second matmul at zero conversion.
roll(i, j) rewrites dims[i]'s index to (idx_i - idx_j) mod size(dims[i]),
which is well-defined for any partner extent: a smaller partner covers a
prefix of the rotations, a larger one wraps. The materializer already
reduced mod the from extent, so only the verifier's equal-extent check
and the enumeration filters (matmul roll decorations, single-roll seed
variants) blocked unequal pairs. Size-1 pieces stay excluded from
enumeration on either side of a pair: a mod-1 roll is an attr-distinct
identity that would pollute plan dedup.

This unlocks rolled plans for rectangular matmuls (k extent different
from i/j) and is a prerequisite for BSGS: the baby-step expansion is a
layout whose k:D piece rolls by a sqrt(D)-extent partner.

Re-blessed: the mismatched-extent verifier lit case is now positive;
single-roll variants of [0:4][R:4][1:2] grow 1 -> 4; the non-power-of-two
plan count grows 5 -> 10; layout_assignment's generic_assign seeds adopt
unequal-extent diagonal variants (rolls = [(1, 0)] on both args). New
coverage: ISL materialization of both unequal orientations, the
rectangular replicate-then-roll plan family, and numeric rectangular
matmul plus rectangular elementwise diagonal adoption.
Two roll extensions that together express baby-step/giant-step diagonal
packings as ordinary layouts:

- Scales: a roll tuple takes an optional third element (from, by, s),
  shifting by s times the partner index -- (idx_from - s*idx_by) mod
  extent. Stored in a parallel optional rollScales parameter whose
  canonical form is omitted when every scale is 1, so scale-free layouts
  unique to the same attribute as before; the printer emits pairs for
  unit scales.

- Split-dim rolls: a roll FROM any piece of a mixed-radix split dim
  rewrites the WHOLE dim's index mod its full extent (each piece then
  takes its digit of the rolled index), and a roll BY a split piece
  shifts by that piece's digit of the dim's current (possibly already
  rolled) expression. The materializer now resolves roll pieces to
  traversal variables by dim id (preprocessing dedupes one variable per
  logical axis), where it previously matched (dim, size, stride) and
  failed on any split-dim roll.

The BSGS diagonal packing of a 16x16 matrix materializes and evaluates
correctly: dims [[k:4:4],[k:4:1],[i:16:1]] with rolls
[(0, 2), (2, 0, -4)] puts iteration point ((a-4g) mod 16, (a+b) mod 16)
in ciphertext (g, b) slot a -- giant-step partial sums pre-rotation.
The baby-step vector expansion needs no new machinery (an unequal-extent
roll by replication). Plan enumeration and the kernel reduce mode land
separately; enumeration is unaffected (it builds unit-scale rolls only).
Enumeration: a ciphertext k piece of composite extent D = G * B splits
into giant/baby digits [k:G:B][k:B:1]; the whole k is rolled by the
outermost slot partner (same-extent i/j) and the partner is rolled back
by the giant digit with scale -B. The operand owning the partner
inherits the placement positionally -- the BSGS diagonal packing, free
for a repackable source -- while the other operand collapses to the BABY
form: replication over both k digits with its k placed whole at the
partner's slot position, rolled by the baby replication. The result is
fully unrolled (the giant rotations consume the partner roll). Kernel
reduce prices numCtResult * (G - 1) giant rotations.

Lowering: the ct-k sum reads giant structure straight off the compute
layout (a scaled roll from a slot piece by a ct k digit); source blocks
group per result ciphertext by their giant shift, sum plainly within a
group, and only the G per-shift partial sums rotate into place.

Conversion pricing and emission now share rotated rows across steps with
the same (source ciphertext, shift): the lowering emits one extract/
rotate per distinct pair and reuses it for every target it feeds, and
both the ct-changing pricing and chooseSameCountConversion count
rotations the same way -- this is what lets the baby expansion price its
B-1 rotations instead of one per replicated block.

A 16x16 matvec with an intermediate vector operand now picks the BSGS
plan end to end: 3 baby + 3 giant = 6 rotations against 15 for the full
rolled expansion, verified numerically against the plaintext reference.
When a footprint's slot suffix leaves slack (an implicit front gap of
s = n / slot extent), a unit-stride ciphertext piece can straddle the
ct/slot boundary instead: enumerateMatmulPlans adds densified footprint
variants splitting such a piece into a ct digit [d:E/s:s] and a slot
digit [d:s:1] that fills the gap, dividing the ciphertext count by s.

Splitting k this way and letting the existing roll decorations roll its
slot digit by i (a roll FROM either digit rewrites the whole k index)
yields the dense Halevi-Shoup packing: s diagonals per ciphertext. All
downstream machinery -- decorations, expansions, result construction,
counts, the kernel lowering, and the BSGS generator (whose giant/baby
split lands its baby digit in the slack automatically at larger n) --
treats densified footprints like any other.

A 16x16 matvec at n = 64 now repacks its matrix at 4 dense ciphertexts
instead of 16 three-quarters-empty ones (the search picks the
dense-BSGS hybrid), with the vector and result staying single
ciphertexts, verified numerically.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant