Skip to content

Commit 0bb5a0c

Browse files
committed
Rotom: axis roll arguments and per-piece roll semantics
A roll argument is now either a piece -- a dims-list position, the original Rotom reading -- or a whole tensor axis, spelled 'axis N' and stored as -(axis+1) in the flat rolls array. An axis argument is legal only when the axis is packed as more than one piece; the piece spelling is canonical for an unsplit axis, where the two coincide. A piece FROM rewrites only its own mixed-radix digit -- the original per-piece semantics, now materialized correctly on split axes (no borrow crosses digits). An axis FROM rewrites the whole axis index modulo its full extent, each piece then taking its digit of the rolled index: the borrow across digits is what diagonal packings over a split axis need and no combination of piece rolls can express. A BY piece of a split axis shifts by that piece's digit of the axis's current (possibly already-rolled) expression. Rolls stay a pure packing description: a roll shifts by exactly its BY argument's index, and a layout describes its value's packed bytes in full. Kernel schedules that shift by a MULTIPLE of a digit -- the baby-step/giant-step giant shift -- are not layout vocabulary and are not folded into any value's packing; the kernel emits them as rotations of its coefficient operand, which for plaintext weights a backend folds into the encoded constants. The BSGS diagonal packing is then an ordinary layout: #rotom.layout<n = 16, rolls = [(axis 1, 2)], dims = [[1:4:4], [1:4:1] | [0:16:1]]> where the roll diagonalizes the whole split k against i (ciphertext (g, b) holds the digits of (k - i) mod 16).
1 parent c98f428 commit 0bb5a0c

8 files changed

Lines changed: 617 additions & 104 deletions

File tree

lib/Dialect/Rotom/IR/RotomAttributes.cpp

Lines changed: 145 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -227,25 +227,40 @@ static ParseResult parseLayoutDims(AsmParser& parser,
227227
}
228228
}
229229

230+
// One argument of a roll pair: a bare non-negative integer is a dims-list
231+
// position (one piece); `axis N` names the whole tensor axis N.
232+
static ParseResult parseRollArg(AsmParser& parser, int64_t& encoded) {
233+
const bool isAxis = succeeded(parser.parseOptionalKeyword("axis"));
234+
int64_t value;
235+
if (parser.parseInteger(value)) return failure();
236+
if (value < 0) {
237+
return parser.emitError(parser.getNameLoc())
238+
<< (isAxis ? "an axis roll argument must name a non-negative "
239+
"tensor axis"
240+
: "a piece roll argument must be a non-negative dims "
241+
"position (spell a whole-axis argument as `axis N`)");
242+
}
243+
encoded = encodeRollArg({isAxis, value});
244+
return success();
245+
}
246+
230247
static ParseResult parseLayoutRolls(AsmParser& parser,
231248
SmallVector<int64_t>& rolls) {
232249
if (parser.parseLSquare()) return failure();
233250
if (succeeded(parser.parseOptionalRSquare())) return success();
234251

252+
// Each entry is one `(from, by)` pair, parenthesized -- the form the
253+
// printer emits, so written and round-tripped layouts read alike.
235254
while (true) {
236-
if (succeeded(parser.parseOptionalLParen())) {
237-
int64_t from;
238-
int64_t to;
239-
if (parser.parseInteger(from) || parser.parseComma() ||
240-
parser.parseInteger(to) || parser.parseRParen())
241-
return failure();
242-
rolls.push_back(from);
243-
rolls.push_back(to);
244-
} else {
245-
int64_t value;
246-
if (parser.parseInteger(value)) return failure();
247-
rolls.push_back(value);
255+
int64_t from;
256+
int64_t by;
257+
if (parser.parseLParen() || failed(parseRollArg(parser, from)) ||
258+
parser.parseComma() || failed(parseRollArg(parser, by)) ||
259+
parser.parseRParen()) {
260+
return failure();
248261
}
262+
rolls.push_back(from);
263+
rolls.push_back(by);
249264

250265
if (succeeded(parser.parseOptionalComma())) continue;
251266
return parser.parseRSquare();
@@ -255,48 +270,109 @@ static ParseResult parseLayoutRolls(AsmParser& parser,
255270
static LogicalResult verifyLayoutRolls(
256271
ArrayAttr dims, DenseI64ArrayAttr rolls,
257272
function_ref<InFlightDiagnostic()> emitError) {
258-
if (!rolls) return success();
273+
const bool noRolls = !rolls || rolls.empty();
274+
if (noRolls) return success();
259275
ArrayRef<int64_t> r = rolls.asArrayRef();
260-
if (r.empty()) return success();
261276
if (r.size() % 2 != 0) {
262-
return emitError() << "rolls must contain an even number of integers "
263-
"(pairs of dim indices)";
277+
return emitError() << "rolls must contain an even number of arguments "
278+
"(pairs)";
264279
}
265280

281+
// Traversal pieces of a tensor axis: their count decides whether an `axis`
282+
// argument is legal (split axes only -- the piece spelling is canonical
283+
// when the axis is one piece), and their extent product is the modulus a
284+
// whole-axis rewrite reduces by.
285+
auto piecesOfAxis = [&](int64_t axis) {
286+
std::pair<int64_t, int64_t> countAndExtent{0, 1};
287+
for (Attribute a : dims) {
288+
auto d = dyn_cast<DimAttr>(a);
289+
if (d && !d.isGap() && !d.isReplicate() && d.getDim() == axis) {
290+
++countAndExtent.first;
291+
countAndExtent.second *= d.getSize();
292+
}
293+
}
294+
return countAndExtent;
295+
};
296+
266297
for (size_t i = 0; i < r.size(); i += 2) {
267-
const int64_t ti = r[i];
268-
const int64_t tj = r[i + 1];
269-
if (ti == tj) {
270-
return emitError() << "each roll must use two distinct dim indices";
298+
const RollArg from = decodeRollArg(r[i]);
299+
const RollArg by = decodeRollArg(r[i + 1]);
300+
301+
// Resolve each argument: the piece it names (null for axis arguments)
302+
// and the tensor axis it reads or rewrites (sentinel for gap/replication
303+
// pieces).
304+
DimAttr fromPiece;
305+
DimAttr byPiece;
306+
int64_t fromAxis = 0;
307+
int64_t byAxis = 0;
308+
auto checkArg = [&](const RollArg& e, DimAttr& piece,
309+
int64_t& axis) -> LogicalResult {
310+
if (e.isAxis) {
311+
auto [count, extent] = piecesOfAxis(e.index);
312+
(void)extent;
313+
if (count == 0) {
314+
return emitError() << "an axis roll argument must name a tensor "
315+
"axis present in dims";
316+
}
317+
if (count == 1) {
318+
return emitError() << "an axis roll argument requires a split "
319+
"axis; spell an unsplit axis's argument as "
320+
"its piece position";
321+
}
322+
axis = e.index;
323+
return success();
324+
}
325+
if (e.index >= static_cast<int64_t>(dims.size())) {
326+
return emitError() << "roll piece argument out of bounds for dims "
327+
"list";
328+
}
329+
piece = dyn_cast<DimAttr>(dims[e.index]);
330+
if (!piece) {
331+
return emitError() << "roll arguments must refer to #rotom.dim "
332+
"entries";
333+
}
334+
axis = piece.getDim();
335+
return success();
336+
};
337+
if (failed(checkArg(from, fromPiece, fromAxis)) ||
338+
failed(checkArg(by, byPiece, byAxis))) {
339+
return failure();
271340
}
272-
if (ti < 0 || tj < 0 || ti >= static_cast<int64_t>(dims.size()) ||
273-
tj >= static_cast<int64_t>(dims.size())) {
274-
return emitError() << "roll dim index out of bounds for dims list";
341+
342+
// The extents need not match: a roll rewrites the from index to
343+
// (idx - shift) mod extent(from), well-defined for any partner extent (a
344+
// smaller partner covers a prefix of the rotations, a larger one wraps).
345+
// FROM is the index expression being rewritten, so it must be a
346+
// traversal piece or a whole (traversal) axis. The by argument may be
347+
// any kind: rolling by a replication or gap piece shifts by that piece's
348+
// block index, so each block holds a distinct cyclic rotation of the
349+
// rolled index -- the layout materializes every rotation and alignment
350+
// becomes block selection. (A rolled-by gap thus claims its blocks,
351+
// unlike a plain gap.)
352+
if (!from.isAxis && (fromPiece.isGap() || fromPiece.isReplicate())) {
353+
return emitError() << "the rolled dim must be a traversal dim (dim >= "
354+
"0)";
275355
}
276-
auto di = dyn_cast<DimAttr>(dims[ti]);
277-
auto dj = dyn_cast<DimAttr>(dims[tj]);
278-
if (!di || !dj) {
279-
return emitError() << "roll indices must refer to #rotom.dim entries";
356+
// A roll may not shift an index by itself. Piece arguments must be
357+
// distinct positions (two pieces of one axis are distinct digits); an
358+
// axis argument overlaps every argument on the same axis, because a
359+
// whole-axis rewrite touches all of its digits.
360+
if (!from.isAxis && !by.isAxis && from.index == by.index) {
361+
return emitError() << "each roll must use two distinct arguments";
280362
}
281-
// The extents need not match: roll(i, j) rewrites dims[i]'s index to
282-
// (i_i - i_j) mod size(dims[i]), well-defined for any partner extent (a
283-
// smaller partner covers a prefix of the rotations, a larger one wraps).
284-
// The rolled (from) dim must be a traversal dim -- it is the index
285-
// expression being rewritten. The roll-by (second) dim may be any kind:
286-
// rolling by a replication or gap dim shifts by that dim's block index,
287-
// so each block holds a distinct cyclic rotation of the rolled dim -- the
288-
// layout materializes every rotation and alignment becomes block
289-
// selection. (A rolled-by gap thus claims its blocks, unlike a plain gap.)
290-
if (di.isGap() || di.isReplicate()) {
291-
return emitError() << "the rolled dim must be a traversal dim (dim >= 0)";
363+
if ((from.isAxis || by.isAxis) && fromAxis == byAxis) {
364+
return emitError() << "a roll may not shift an axis by one of its own "
365+
"pieces";
292366
}
293367
// A rolled-by GAP claims one ciphertext block per gap index, each holding
294-
// a distinct rotation of the rolled dim. If the gap is larger than the
295-
// rolled dim's extent the rotations repeat (period = the from extent),
296-
// claiming blocks the conversion/kernel accounting was never audited for.
368+
// a distinct rotation of the rolled index. If the gap is larger than the
369+
// rolled extent the rotations repeat (period = the from extent), claiming
370+
// blocks the conversion/kernel accounting was never audited for.
297371
// (Replication partners of larger extent are intended -- replicate-then-
298372
// roll -- so only gaps are bounded.)
299-
if (dj.isGap() && dj.getSize() > di.getSize()) {
373+
const int64_t fromExtent =
374+
from.isAxis ? piecesOfAxis(fromAxis).second : fromPiece.getSize();
375+
if (byPiece && byPiece.isGap() && byPiece.getSize() > fromExtent) {
300376
return emitError() << "a rolled-by gap dim must not exceed the rolled "
301377
"dim's extent";
302378
}
@@ -345,10 +421,19 @@ void LayoutAttr::print(AsmPrinter& printer) const {
345421
DenseI64ArrayAttr rolls = getRolls();
346422
if (rolls && !rolls.asArrayRef().empty()) {
347423
ArrayRef<int64_t> values = rolls.asArrayRef();
424+
auto printArg = [&](int64_t encoded) {
425+
const RollArg e = decodeRollArg(encoded);
426+
if (e.isAxis) printer << "axis ";
427+
printer << e.index;
428+
};
348429
printer << ", rolls = [";
349430
for (size_t i = 0; i < values.size(); i += 2) {
350431
if (i != 0) printer << ", ";
351-
printer << "(" << values[i] << ", " << values[i + 1] << ")";
432+
printer << "(";
433+
printArg(values[i]);
434+
printer << ", ";
435+
printArg(values[i + 1]);
436+
printer << ")";
352437
}
353438
printer << "]";
354439
}
@@ -436,6 +521,18 @@ Attribute LayoutAttr::parse(AsmParser& parser, Type type) {
436521
ArrayAttr::get(context, dims), n, DenseI64ArrayAttr::get(context, rolls));
437522
}
438523

524+
SmallVector<RollSpec> getRollSpecs(LayoutAttr layout) {
525+
SmallVector<RollSpec> specs;
526+
DenseI64ArrayAttr rolls = layout.getRolls();
527+
if (!rolls) return specs;
528+
ArrayRef<int64_t> r = rolls.asArrayRef();
529+
530+
for (size_t i = 0; i + 1 < r.size(); i += 2) {
531+
specs.push_back({decodeRollArg(r[i]), decodeRollArg(r[i + 1])});
532+
}
533+
return specs;
534+
}
535+
439536
FailureOr<LayoutData> preprocessLayoutAttr(LayoutAttr layout) {
440537
return preprocessLayoutData(layout.getDims(), layout.getN(),
441538
layout.getContext());
@@ -452,7 +549,9 @@ LogicalResult LayoutAttr::verify(function_ref<InFlightDiagnostic()> emitError,
452549
return emitError() << "`dims` must be an array of `#rotom.dim<...>`";
453550
}
454551

455-
if (failed(verifyLayoutRolls(dims, rolls, emitError))) return failure();
552+
if (failed(verifyLayoutRolls(dims, rolls, emitError))) {
553+
return failure();
554+
}
456555

457556
SmallVector<DimAttr> dimVec;
458557
dimVec.reserve(dims.size());
@@ -508,7 +607,8 @@ void canonicalizeLayoutDims(MLIRContext* ctx, SmallVector<DimAttr>& dims,
508607
if (fill <= 1) return;
509608
dims.insert(dims.begin() + ctLen,
510609
DimAttr::get(ctx, /*dim=*/-2, fill, /*stride=*/1));
511-
// Roll endpoints at or past the insertion shift right.
610+
// Piece arguments at or past the insertion shift right; axis arguments
611+
// (encoded negative) name axes and do not move.
512612
for (int64_t& encoded : rolls) {
513613
if (encoded >= static_cast<int64_t>(ctLen)) ++encoded;
514614
}

lib/Dialect/Rotom/IR/RotomAttributes.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,37 @@
1515

1616
namespace mlir::heir::rotom {
1717

18+
// One argument of a roll: either one piece of the layout (a position in its
19+
// dims list) or a whole tensor axis (spelled `axis N`; legal only when the
20+
// axis is packed as more than one piece).
21+
struct RollArg {
22+
bool isAxis;
23+
int64_t index; // Dims-list position, or the tensor axis id when isAxis.
24+
bool operator==(const RollArg& other) const {
25+
return isAxis == other.isAxis && index == other.index;
26+
}
27+
};
28+
29+
// Argument encoding in the flat rolls storage: a piece argument is its
30+
// non-negative dims-list position, an axis argument is -(axis + 1).
31+
inline int64_t encodeRollArg(RollArg e) {
32+
return e.isAxis ? -(e.index + 1) : e.index;
33+
}
34+
inline RollArg decodeRollArg(int64_t encoded) {
35+
return encoded < 0 ? RollArg{true, -encoded - 1} : RollArg{false, encoded};
36+
}
37+
38+
// One roll of a layout: FROM's index is rewritten to
39+
// (idx_from - shift(by)) mod extent(from), where a piece FROM rewrites only
40+
// its own mixed-radix digit and an axis FROM rewrites the whole axis index.
41+
struct RollSpec {
42+
RollArg from;
43+
RollArg by;
44+
};
45+
46+
// The layout's rolls with both arguments decoded.
47+
llvm::SmallVector<RollSpec> getRollSpecs(LayoutAttr layout);
48+
1849
enum class LayoutPieceKind { Traversal, Replication, Gap };
1950

2051
struct LayoutPiece {

lib/Dialect/Rotom/IR/RotomAttributes.td

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,54 @@ def Rotom_LayoutAttr : Rotom_Attr<"Layout", "layout"> {
5555
with an explicit gap piece (e.g. `[G:4:1]`).
5656
See [Section 4.2 of the Rotom paper](https://eprint.iacr.org/2025/1319.pdf).
5757

58-
Optional **rolls** encode a `roll(i,j)` metadata object: each pair `(i, j)`
59-
indexes into the `dims` array (the flattened `ct_dims + slot_dims` list) and
60-
rewrites `dims[i]`'s index to `(idx_i - idx_j) mod size(dims[i])`. The two
61-
extents need not match: the shift reduces modulo the rolled dim's extent, so
62-
a smaller partner covers a prefix of the rotations and a larger one wraps.
58+
Optional **rolls** encode `roll(from, by)` metadata objects, applied left
59+
to right. Each argument is either a *piece* -- a position in the `dims`
60+
list, spelled as a bare integer -- or a whole tensor *axis*, spelled
61+
`axis N` (legal only when axis `N` is packed as more than one piece; the
62+
piece spelling is canonical for an unsplit axis, where the two coincide).
63+
64+
```mlir
65+
// Halevi-Shoup diagonal of a 4x4 matrix: slot j of ciphertext i holds
66+
// A[i, (j - i) mod 4]. Argument 1 (the axis-1 piece) is rolled by
67+
// argument 0 (the axis-0 piece).
68+
#diag = #rotom.layout<n = 4, rolls = [(1, 0)], dims = [[0:4:1] | [1:4:1]]>
69+
70+
// Axis 1 is split across two pieces, so a whole-axis roll spells its
71+
// argument `axis 1` rather than a piece position.
72+
#split = #rotom.layout<n = 16, rolls = [(axis 1, 2)],
73+
dims = [[1:4:4], [1:4:1] | [0:16:1]]>
74+
```
75+
76+
A piece FROM rewrites that piece's own mixed-radix digit in place,
77+
`digit(from) <- (digit(from) - shift(by)) mod extent(from)`, leaving
78+
the axis's other digits untouched. An `axis` FROM rewrites the whole axis
79+
index modulo its full extent, and each piece then takes its digit of the
80+
rolled index -- the shift borrows across digits, which no combination of
81+
piece rolls can express:
82+
83+
```mlir
84+
// Axis 0 has extent 4, split into digits [0:2:2] (high) and [0:2:1]
85+
// (low), and is rolled by a replication argument of extent 2. Writing
86+
// the index as (high, low), replica d holds:
87+
//
88+
// axis roll, `(axis 0, 0)`: index (i - d) mod 4, so replica 1 maps
89+
// (0,0) <- (0,1) <- (1,0) <- (1,1): the
90+
// subtraction borrows out of the low
91+
// digit into the high one.
92+
// piece roll, `(1, 0)`: only the high digit moves, so replica 1
93+
// maps (0,0) <- (1,0) and (0,1) <- (1,1);
94+
// the low digit never carries.
95+
//
96+
// Rolling BOTH pieces by the same argument is still not the axis roll:
97+
// each digit wraps inside itself, so (0,0) <- (1,1), not (1,1) <- (0,0).
98+
```
99+
100+
The shift is the by argument's index: a
101+
traversal piece's digit (its whole index when the axis is unsplit), a
102+
whole axis's index via `axis`, a replication piece's replica index, or a
103+
gap piece's block index. The two extents need not match: the shift
104+
reduces modulo the rolled extent, so a smaller partner covers a prefix of
105+
the rotations and a larger one wraps.
63106
}];
64107

65108
let parameters = (ins

0 commit comments

Comments
 (0)