Skip to content

Commit 7ce67e7

Browse files
committed
encode plaintext at highest used level instead of max level
1 parent f23b772 commit 7ce67e7

20 files changed

Lines changed: 747 additions & 25 deletions

File tree

lib/Dialect/LWE/Conversions/LWEToLattigo/LWEToLattigo.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,9 +541,12 @@ struct ConvertRlweEncodeOp : public OpConversionPattern<EncodeOp> {
541541
Value params = result2.value();
542542

543543
Value input = adaptor.getInput();
544+
// A missing level attribute leaves the allocation at
545+
// params.MaxLevel().
544546
auto alloc = AllocOp::create(
545547
rewriter, op.getLoc(),
546-
this->typeConverter->convertType(op.getOutput().getType()), params);
548+
this->typeConverter->convertType(op.getOutput().getType()), params,
549+
op.getLevelAttr());
547550

548551
auto encoding = op.getEncoding();
549552
int64_t scale = lwe::getScalingFactorFromEncodingAttr(encoding);

lib/Dialect/LWE/IR/LWEOps.td

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ def LWE_RLWEEncodeOp : LWE_Op<"rlwe_encode", [
201201
be floating points, and a scaling factor described by the encoding will be
202202
applied.
203203

204+
The optional `level` attribute records the level of the ciphertext this
205+
plaintext will be combined with, i.e., the `current` index of that
206+
ciphertext's modulus chain. It is
207+
recorded on the op rather than in `!lwe.lwe_plaintext` because that type is
208+
not RNS-aware (see #1643), and it must be attached where the plaintext is
209+
created, since passes such as `--split-preprocessing` later sever the
210+
def-use chain to the consuming ciphertext op. When absent, backends fall
211+
back to the top level of the modulus chain.
212+
204213
Examples:
205214

206215
```
@@ -212,7 +221,7 @@ def LWE_RLWEEncodeOp : LWE_Op<"rlwe_encode", [
212221
SignlessIntegerOrFloatLike:$input,
213222
AnyPlaintextEncodingAttr:$encoding,
214223
Polynomial_RingAttr:$ring,
215-
OptionalAttr<I64Attr>:$level,
224+
OptionalAttr<ConfinedAttr<I64Attr, [IntNonNegative]>>:$level,
216225
OptionalAttr<I64Attr>:$scale
217226
);
218227

lib/Dialect/LWE/IR/LWETypes.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "lib/Dialect/LWE/IR/LWETypes.h"
22

33
#include <cstdint>
4+
#include <optional>
45

56
#include "lib/Dialect/LWE/IR/LWEAttributes.h"
67
#include "lib/Dialect/Polynomial/IR/PolynomialAttributes.h"
@@ -111,6 +112,12 @@ LWECiphertextType cloneAtLevel(LWECiphertextType inputType, int64_t level) {
111112
lwe::KeyAttr::get(ctx, 0), newChain);
112113
}
113114

115+
std::optional<int64_t> getLevel(LWECiphertextType ctType) {
116+
auto modulusChain = ctType.getModulusChain();
117+
if (!modulusChain) return std::nullopt;
118+
return modulusChain.getCurrent();
119+
}
120+
114121
} // namespace lwe
115122
} // namespace heir
116123
} // namespace mlir

lib/Dialect/LWE/IR/LWETypes.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#define LIB_DIALECT_LWE_IR_LWETYPES_H_
33

44
#include <cstdint>
5+
#include <optional>
56

67
#include "lib/Dialect/LWE/IR/LWEAttributes.h"
78
#include "mlir/include/mlir/IR/MLIRContext.h" // from @llvm-project
@@ -44,6 +45,11 @@ FailureOr<LWECiphertextType> applyModReduce(LWECiphertextType inputType);
4445
// value.
4546
LWECiphertextType cloneAtLevel(LWECiphertextType inputType, int64_t level);
4647

48+
// Return the level of a ciphertext type, i.e., the index of the top modulus of
49+
// its modulus chain. Returns nullopt for schemes that don't use a modulus
50+
// chain
51+
std::optional<int64_t> getLevel(LWECiphertextType ctType);
52+
4753
} // namespace lwe
4854
} // namespace heir
4955
} // namespace mlir
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#include "lib/Dialect/LWE/Transforms/AnnotatePlaintextLevel.h"
2+
3+
#include <algorithm>
4+
#include <cstdint>
5+
#include <optional>
6+
7+
#include "lib/Dialect/LWE/IR/LWEOps.h"
8+
#include "lib/Dialect/LWE/IR/LWETraits.h"
9+
#include "lib/Dialect/LWE/IR/LWETypes.h"
10+
#include "lib/Dialect/ModuleAttributes.h"
11+
#include "llvm/include/llvm/ADT/DenseSet.h" // from @llvm-project
12+
#include "llvm/include/llvm/ADT/STLExtras.h" // from @llvm-project
13+
#include "llvm/include/llvm/ADT/SmallVector.h" // from @llvm-project
14+
#include "mlir/include/mlir/IR/BuiltinOps.h" // from @llvm-project
15+
#include "mlir/include/mlir/IR/Operation.h" // from @llvm-project
16+
#include "mlir/include/mlir/IR/TypeUtilities.h" // from @llvm-project
17+
#include "mlir/include/mlir/IR/Value.h" // from @llvm-project
18+
#include "mlir/include/mlir/IR/Visitors.h" // from @llvm-project
19+
#include "mlir/include/mlir/Interfaces/CallInterfaces.h" // from @llvm-project
20+
#include "mlir/include/mlir/Support/LLVM.h" // from @llvm-project
21+
22+
namespace mlir {
23+
namespace heir {
24+
namespace lwe {
25+
26+
#define GEN_PASS_DEF_ANNOTATEPLAINTEXTLEVEL
27+
#include "lib/Dialect/LWE/Transforms/Passes.h.inc"
28+
29+
namespace {
30+
31+
// Return the highest level among an op's ciphertext operands and results, or
32+
// nullopt if it touches no ciphertext carrying a modulus chain.
33+
std::optional<int64_t> highestCiphertextLevel(Operation* op) {
34+
std::optional<int64_t> result;
35+
auto join = [&](Value value) {
36+
auto ctType =
37+
dyn_cast<LWECiphertextType>(getElementTypeOrSelf(value.getType()));
38+
if (!ctType) return;
39+
std::optional<int64_t> level = getLevel(ctType);
40+
if (!level) return;
41+
result = result ? std::max(*result, *level) : *level;
42+
};
43+
for (Value operand : op->getOperands()) join(operand);
44+
for (Value opResult : op->getResults()) join(opResult);
45+
return result;
46+
}
47+
48+
// Return true if an op's results account for every place a value it consumes
49+
// can end up, so the walk can follow them. Ops with regions route operands into
50+
// block arguments (`scf.for`'s iter_args) and calls route them into a callee,
51+
// neither of which the walk models; a terminator has no results to follow.
52+
bool forwardsThroughResults(Operation* op) {
53+
return op->getNumResults() > 0 && op->getNumRegions() == 0 &&
54+
!isa<CallOpInterface>(op) &&
55+
llvm::all_of(op->getResults(), [](Value result) {
56+
return isa<LWEPlaintextType>(getElementTypeOrSelf(result.getType()));
57+
});
58+
}
59+
60+
// Return the level to encode the plaintext at, or nullopt to leave it at the
61+
// top of the modulus chain. Plaintexts are not always consumed directly: the
62+
// encode op may feed a `tensor.from_elements` or similar first, so keep walking
63+
// forward through the ops that forward it until one that consumes it is
64+
// reached.
65+
//
66+
// The two kinds of consumer constrain the level differently:
67+
//
68+
// - A ct-pt op sets a *lower bound*. The backend combines at the lower of the
69+
// two levels, so encoding above the ciphertext costs limbs but nothing
70+
// else, while encoding below it silently truncates that ciphertext. The
71+
// highest such use is therefore the smallest level that serves them all.
72+
//
73+
// - An encryption sets an *exact* level. It has no ciphertext operand to be
74+
// clamped against: the backend builds the ciphertext at the plaintext's own
75+
// level. Both `lwe.rlwe_encrypt` and `lwe.trivial_encrypt` work
76+
// this way.
77+
//
78+
// One encoding can serve both only when the encryption's level also covers
79+
// every ct-pt use. When it does not, or when two encryptions want different
80+
// levels, no annotation is correct and the fallback takes over.
81+
std::optional<int64_t> findUseLevel(RLWEEncodeOp encodeOp) {
82+
std::optional<int64_t> combinedLevel;
83+
std::optional<int64_t> encryptedLevel;
84+
SmallVector<Value> worklist = {encodeOp.getOutput()};
85+
DenseSet<Operation*> visited;
86+
87+
while (!worklist.empty()) {
88+
Value value = worklist.pop_back_val();
89+
for (Operation* user : value.getUsers()) {
90+
if (!visited.insert(user).second) continue;
91+
92+
if (isa<RLWEEncryptOp, TrivialEncryptOp>(user)) {
93+
// A consumer whose ciphertexts carry no modulus chain constrains
94+
// nothing, and leaves the plaintext to whatever its other uses need.
95+
std::optional<int64_t> level = highestCiphertextLevel(user);
96+
if (!level) continue;
97+
if (encryptedLevel && *encryptedLevel != *level) return std::nullopt;
98+
encryptedLevel = *level;
99+
continue;
100+
}
101+
if (user->hasTrait<IsCiphertextPlaintextOp>()) {
102+
// Likewise for a ct-pt op whose ciphertexts carry no modulus chain.
103+
if (std::optional<int64_t> level = highestCiphertextLevel(user)) {
104+
combinedLevel =
105+
combinedLevel ? std::max(*combinedLevel, *level) : *level;
106+
}
107+
continue;
108+
}
109+
if (!forwardsThroughResults(user)) return std::nullopt;
110+
for (Value userResult : user->getResults()) {
111+
worklist.push_back(userResult);
112+
}
113+
}
114+
}
115+
116+
if (!encryptedLevel) return combinedLevel;
117+
if (combinedLevel && *combinedLevel > *encryptedLevel) return std::nullopt;
118+
return encryptedLevel;
119+
}
120+
121+
struct AnnotatePlaintextLevel
122+
: impl::AnnotatePlaintextLevelBase<AnnotatePlaintextLevel> {
123+
using AnnotatePlaintextLevelBase::AnnotatePlaintextLevelBase;
124+
125+
void runOnOperation() override {
126+
ModuleOp module = getOperation();
127+
128+
// BFV does no level management: every ciphertext sits at the bottom of the
129+
// modulus chain, while the backend's ciphertexts still span the whole chain
130+
// (the extra modulus is an artifact of the encryption technique). So the
131+
// chain's `current` is not an encoding level here, and there is nothing to
132+
// save by lowering one.
133+
if (moduleIsBFV(module)) {
134+
module->walk([&](RLWEEncodeOp encodeOp) { encodeOp.removeLevelAttr(); });
135+
return;
136+
}
137+
138+
module->walk([&](RLWEEncodeOp encodeOp) {
139+
std::optional<int64_t> level = findUseLevel(encodeOp);
140+
// Drop a level that no longer has a use to justify it
141+
if (!level) {
142+
encodeOp.removeLevelAttr();
143+
return;
144+
}
145+
encodeOp.setLevel(*level);
146+
});
147+
}
148+
};
149+
150+
} // namespace
151+
152+
} // namespace lwe
153+
} // namespace heir
154+
} // namespace mlir
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#ifndef LIB_DIALECT_LWE_TRANSFORMS_ANNOTATEPLAINTEXTLEVEL_H_
2+
#define LIB_DIALECT_LWE_TRANSFORMS_ANNOTATEPLAINTEXTLEVEL_H_
3+
4+
// IWYU pragma: begin_keep
5+
#include "mlir/include/mlir/Pass/Pass.h" // from @llvm-project
6+
// IWYU pragma: end_keep
7+
8+
namespace mlir {
9+
namespace heir {
10+
namespace lwe {
11+
12+
#define GEN_PASS_DECL_ANNOTATEPLAINTEXTLEVEL
13+
#include "lib/Dialect/LWE/Transforms/Passes.h.inc"
14+
15+
} // namespace lwe
16+
} // namespace heir
17+
} // namespace mlir
18+
19+
#endif // LIB_DIALECT_LWE_TRANSFORMS_ANNOTATEPLAINTEXTLEVEL_H_

lib/Dialect/LWE/Transforms/BUILD

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,30 @@ cc_library(
1313
],
1414
deps = [
1515
":AddDebugPort",
16+
":AnnotatePlaintextLevel",
1617
":DecomposeLWEOps",
1718
":ImplementTrivialEncryptionAsAddition",
1819
":pass_inc_gen",
1920
"@heir//lib/Dialect/LWE/IR:Dialect",
2021
],
2122
)
2223

24+
cc_library(
25+
name = "AnnotatePlaintextLevel",
26+
srcs = ["AnnotatePlaintextLevel.cpp"],
27+
hdrs = ["AnnotatePlaintextLevel.h"],
28+
deps = [
29+
":pass_inc_gen",
30+
"@heir//lib/Dialect:ModuleAttributes",
31+
"@heir//lib/Dialect/LWE/IR:Dialect",
32+
"@llvm-project//llvm:Support",
33+
"@llvm-project//mlir:CallOpInterfaces",
34+
"@llvm-project//mlir:IR",
35+
"@llvm-project//mlir:Pass",
36+
"@llvm-project//mlir:Support",
37+
],
38+
)
39+
2340
cc_library(
2441
name = "AddDebugPort",
2542
srcs = ["AddDebugPort.cpp"],

lib/Dialect/LWE/Transforms/Passes.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// IWYU pragma: begin_keep
55
#include "lib/Dialect/LWE/IR/LWEDialect.h"
66
#include "lib/Dialect/LWE/Transforms/AddDebugPort.h"
7+
#include "lib/Dialect/LWE/Transforms/AnnotatePlaintextLevel.h"
78
#include "lib/Dialect/LWE/Transforms/DecomposeLWEOps.h"
89
#include "lib/Dialect/LWE/Transforms/ImplementTrivialEncryptionAsAddition.h"
910
// IWYU pragma: end_keep

lib/Dialect/LWE/Transforms/Passes.td

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,42 @@ def ImplementTrivialEncryptionAsAddition : Pass<"implement-trivial-encryption-as
6969
let options = [];
7070
}
7171

72+
def AnnotatePlaintextLevel : Pass<"lwe-annotate-plaintext-level", "mlir::ModuleOp"> {
73+
let summary = "Annotate `lwe.rlwe_encode` ops with the level they are used at";
74+
let description = [{
75+
Sets the `level` attribute of each `lwe.rlwe_encode` op to the level its
76+
plaintext is used at, found by walking forward from the encode op through
77+
plaintext-typed values.
78+
79+
Backends allocate a plaintext at the top of the modulus chain unless told
80+
otherwise, but encoding cost is dominated by per-RNS-limb work, so a
81+
plaintext only ever combined with a level 3 ciphertext is cheaper to encode
82+
with 4 limbs than with all of them. For a ct-pt op the result is unchanged
83+
either way: combining a ciphertext and a plaintext yields the lower of the
84+
two levels, so annotating with the *highest* level among such uses is the
85+
smallest choice that serves them all.
86+
87+
`lwe.rlwe_encrypt` is not a ct-pt op and does not follow that rule. It has
88+
no ciphertext operand to be clamped against, so the backend builds the
89+
ciphertext at the plaintext's own level and the annotation is exact rather
90+
than a lower bound. Annotating an encryption therefore changes what the
91+
client emits, not just how many limbs it encodes: where the entry level sits
92+
below the top of the chain, the ciphertext now enters at the level its type
93+
declares instead of at `MaxLevel`. A plaintext that both feeds an encryption
94+
and is combined with a higher-level ciphertext has no correct level, and is
95+
left to the fallback.
96+
97+
Run this after CSE. Identical encode ops that CSE has merged are cheaper as
98+
one plaintext at the highest level than as several at their own levels,
99+
since each encode pays a fixed cost on top of the per-limb work.
100+
101+
(* example filepath=tests/Dialect/LWE/Transforms/annotate_plaintext_level.mlir *)
102+
}];
103+
let dependentDialects = [
104+
"mlir::heir::lwe::LWEDialect",
105+
];
106+
}
107+
72108
def DecomposeLWEOps : Pass<"lwe-decompose"> {
73109
let summary = "Decomposes complex LWE ops into primitive LWE ops";
74110
let description = [{

lib/Dialect/Lattigo/IR/LattigoBGVOps.td

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ def Lattigo_BGVNewPlaintextOp : Lattigo_BGVOp<"new_plaintext"> {
1717
let summary = "Create a new plaintext in the Lattigo BGV dialect";
1818
let description = [{
1919
This operation creates a new plaintext value in the Lattigo BGV dialect.
20+
21+
The optional `level` attribute is the level to allocate the plaintext at.
22+
When absent, the
23+
plaintext is allocated at `params.MaxLevel()`.
2024
}];
2125
let arguments = (ins
22-
Lattigo_BGVParameter:$params
26+
Lattigo_BGVParameter:$params,
27+
OptionalAttr<ConfinedAttr<I64Attr, [IntNonNegative]>>:$level
2328
);
2429
let results = (outs Lattigo_RLWEPlaintext:$plaintext);
2530
}

0 commit comments

Comments
 (0)