Skip to content

Add pass to algebraize felt operations - #640

Open
krushimir wants to merge 2 commits into
project-llzk:mainfrom
reilabs:krushimir/algebraize-felt-ops
Open

Add pass to algebraize felt operations#640
krushimir wants to merge 2 commits into
project-llzk:mainfrom
reilabs:krushimir/algebraize-felt-ops

Conversation

@krushimir

Copy link
Copy Markdown

Implements stage 2 of #585.

Adds -llzk-algebraize-felt-ops, an LLZK pre-lowering pass for functions marked function.allow_constraint. It lowers unsupported felt bitwise, shift, inverse, division, unsigned division, and modulo operations into basic felt arithmetic, nondeterministic witnesses, bit decompositions, and equality constraints.

The pass reuses decompositions, uses known width bounds where available, and rejects unsupported division/modulo cases rather than producing unsound constraints.

Tested with the AlgebraizeFeltOps and PCLLowering lit suites (15 tests).

@krushimir
krushimir requested a review from a team as a code owner July 23, 2026 16:14
@krushimir
krushimir force-pushed the krushimir/algebraize-felt-ops branch from db87d75 to a3adba9 Compare July 23, 2026 16:19
@krushimir
krushimir force-pushed the krushimir/algebraize-felt-ops branch from a3adba9 to b9540cf Compare July 24, 2026 05:50
@iangneal

iangneal commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Review

No blocking issues found in the proposed algebraization pass.

The implementation consistently limits rewriting to constraint-bearing functions, handles unsupported unsigned division/modulo inputs with explicit diagnostics, and avoids caching full-width bit decompositions that could represent non-canonical field values. lib/Transforms/LLZKAlgebraizeFeltOpsPass.cppL353-L364 lib/Transforms/LLZKAlgebraizeFeltOpsPass.cppL571-L590 lib/Transforms/LLZKAlgebraizeFeltOpsPass.cppL665-L680

I did not modify the branch, so no commit or follow-up pull request was created.

Checks

  • git diff --check 286358af11c34836b75d20bdb304dff4d26345e7...HEAD
  • ⚠️ nix develop --command bash -c 'cmake --build build --target check-lit' — could not run because nix is not installed in this environment.

View task →

const llvm::APInt &ConstrainAlgebraizer::primeOf(Type type) {
auto [it, inserted] = primes.try_emplace(type);
if (inserted) {
FeltType feltType = llvm::cast<FeltType>(type);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cast might fail if the type passed in isn't actually a !felt.type, right? Can we have this method just take a FeltType and require the caller to check the cast instead?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comments elsewhere llvm::cast<FeltType>(...) is used

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seconding this suggestion

%self = struct.new : <@MixedXor>
function.return %self : !struct.type<@MixedXor>
}
function.def @constrain(%self: !struct.type<@MixedXor>, %a: !F, %b: !F) attributes {function.allow_constraint, function.allow_non_native_field_ops} {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this pass removes all the operations enabled by this attribute, we could also remove it from the function.

@@ -0,0 +1,29 @@
// RUN: llzk-opt -llzk-algebraize-felt-ops %s > /dev/null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this test a TODO?

const llvm::APInt &ConstrainAlgebraizer::primeOf(Type type) {
auto [it, inserted] = primes.try_emplace(type);
if (inserted) {
FeltType feltType = llvm::cast<FeltType>(type);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seconding this suggestion

Comment on lines +493 to +495
for (unsigned i = 0, e = sBits.size(); i < e; ++i) {
llvm::APInt cMinus1 = toExactWidthAPInt(curPow2 - 1, constBits);
Value scaled = b.create<MulFeltOp>(loc, sBits[i], feltConst(loc, type, cMinus1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i is not used anywhere else in this loop

Suggested change
for (unsigned i = 0, e = sBits.size(); i < e; ++i) {
llvm::APInt cMinus1 = toExactWidthAPInt(curPow2 - 1, constBits);
Value scaled = b.create<MulFeltOp>(loc, sBits[i], feltConst(loc, type, cMinus1));
for (auto &sBit : sBits) {
llvm::APInt cMinus1 = toExactWidthAPInt(curPow2 - 1, constBits);
Value scaled = b.create<MulFeltOp>(loc, sBit, feltConst(loc, type, cMinus1));

Comment on lines +203 to +207
/// Records the bound proven by an asserted range guard
/// `constrain.eq(cast.tofelt(bool.cmp lt/le/gt/ge(x, c)), 1)` — the pattern
/// frontends emit to pin blackbox input widths. Only guards at the top level
/// of the body may be harvested: a guard inside a conditional region holds
/// only on its branch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of these guards in relation to the ops converted by this pass? Because, when converting an op inside a conditional block, wouldn't we be missing a range constraint for that op if it was also defined inside the same block?

Comment on lines +310 to +311
for (unsigned i = 1, e = bits.size(); i < e; ++i) {
Value term = b.create<MulFeltOp>(loc, bits[i], feltConst(loc, type, weight));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: similar comment about i

Suggested change
for (unsigned i = 1, e = bits.size(); i < e; ++i) {
Value term = b.create<MulFeltOp>(loc, bits[i], feltConst(loc, type, weight));
for (auto &bit : bits.drop_front()) {
Value term = b.create<MulFeltOp>(loc, bit, feltConst(loc, type, weight));

Comment on lines +586 to +591
for (Operation &op : fn.getBody().front()) {
if (auto eq = llvm::dyn_cast<EmitEqualityOp>(&op)) {
harvestRangeGuard(eq.getLhs(), eq.getRhs());
harvestRangeGuard(eq.getRhs(), eq.getLhs());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for (Operation &op : fn.getBody().front()) {
if (auto eq = llvm::dyn_cast<EmitEqualityOp>(&op)) {
harvestRangeGuard(eq.getLhs(), eq.getRhs());
harvestRangeGuard(eq.getRhs(), eq.getLhs());
}
}
fn.walk([this](EmitEqualityOp eq) {
harvestRangeGuard(eq.getLhs(), eq.getRhs());
harvestRangeGuard(eq.getRhs(), eq.getLhs());
});

// rewritten op's result bound (set by cacheResultBits) feeds later ops.
// The snapshot keeps the loop off the ops the rewrites create.
SmallVector<Operation *> ops;
fn.walk([&](Operation *op) { ops.push_back(op); });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prefer to pass only the bindings actually used by the lambda

Suggested change
fn.walk([&](Operation *op) { ops.push_back(op); });
fn.walk([&ops](Operation *op) { ops.push_back(op); });

Comment on lines +593 to +600
// One pass in program order: operands are defined before uses, so bounds
// are propagated before the ops consuming them are rewritten, and a
// rewritten op's result bound (set by cacheResultBits) feeds later ops.
// The snapshot keeps the loop off the ops the rewrites create.
SmallVector<Operation *> ops;
fn.walk([&](Operation *op) { ops.push_back(op); });

for (Operation *op : ops) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of iterating over all the operations twice you could run the algorithm in the first pass, accumulating the to-be-removed ops along with their replacement. Then do another loop that actually removes and replaces the ops.

}];
}

def AlgebraizeFeltOpsPass : LLZKPass<"llzk-algebraize-felt-ops"> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pass could be made an operation pass for function.def. We don't need a whole-world pass for this and will allow running in parallel.

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.

4 participants