-
Notifications
You must be signed in to change notification settings - Fork 122
Find and replace functions that compute "are all equal zero" #3046
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
| mult=is_valid * 1, args=[writes_aux__base__timestamp_lt_aux__lower_decomp__0_0, 17] | ||
| mult=is_valid * 1, args=[writes_aux__base__timestamp_lt_aux__lower_decomp__1_0, 12] | ||
|
|
||
| // Bus 6 (BITWISE_LOOKUP): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code starting here should be equivalent to
a_is_zero * a = 0;
a_is_zero = 1 - a_inv * a;
b_is_zero * b = 0;
b_is_zero = 1 - b_inv * b;
c_is_zero * c = 0;
c_is_zero = 1 - c_inv * c;
d_is_zero * d = 0;
d_is_zero = 1 - d_inv * d;
cmp_result_0 = a_is_zero * b_is_zero * c_is_zero * d_is_zero;
cmp_result_0 * (1 - cmp_result_0) = 0;
(where a corresponds to b__0_0, b corresponds to b__1_0 etc.)
these are 13 columns, while the present code also has 13 columns, but performs two bitwise lookups.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we allow two bitwise lookups, we get
// Bus 6 (BITWISE_LOOKUP)
mult=is_valid, args=[a, b, ab, 0, 1]
mult=is_valid, args=[c, d, cd, 0, 1]
ab_is_zero * ab = 0;
ab_is_zero = 1 - ab_inv * ab;
cd_is_zero * cd = 0;
cd_is_zero = 1 - cd_inv * cd;
result = ab_is_zero * cd_is_zero;
result * (1 - result) = 0;
Which has only 11 columns. Adding another bitwise lookup we get to
mult=is_valid, args=[a, b, ab, 0, 1]
mult=is_valid, args=[c, d, cd, 0, 1]
mult=is_valid, args=[abcd, ab, cd, 0, 1]
result * abcd = 0;
result = 1 - abcd_inv * abcd;
which only has 9 columns.
Maybe it also makes sense to subtract 5 from the columns (4 inputs, one output), because those will likely be consumed by the preceding and following instructions. Then we would have
two lookups: from 8 to 6
three lookups: from 8 to 4
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually it's even simpler.
We know that 0 <= a, b, c, d <= 255. Because of that, if we define x = a + b + c + d, then 0 <= x <= 0x3fc which is less than half the modulus. This means that x is zero if and only if all of a, b, c, d are zero.
Now the system reads:
x = a + b + c + d
result * x = 0
result = 1 - x_inv * x
Of course, we can always inline x, and thus we have only one additional column, zero lookups and 6 columns in total.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The slight problem here is that we might need witgen for x_inv.
|
Now on to how to detect that we indeed are working with an "is equal zero" condition. Let's try to detect the pattern. Property we want to check:
The third property is easy to check. Once we have that, we can see if setting all |
|
TODO: Check that with byte range constraints we can infer |
|
Strategy: For every boolean column (we hope it is "result", let's call it Does setting it to one value ( Now the goal is to find a better constraint system that encodes the same function. First, we try to reduce the number of inputs variables: For every variable |
| (1 - new_var2 * (a__0_0 + a__1_0 + a__2_0 + a__3_0)) * a__0_0 = 0 | ||
| (1 - new_var2 * (a__0_0 + a__1_0 + a__2_0 + a__3_0)) * a__1_0 = 0 | ||
| (1 - new_var2 * (a__0_0 + a__1_0 + a__2_0 + a__3_0)) * a__2_0 = 0 | ||
| (1 - new_var2 * (a__0_0 + a__1_0 + a__2_0 + a__3_0)) * a__3_0 = 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These four constraints are actually equivalent to the one in line 26 and the reason is quite interesting - we could turn this into an optimization step:
Because of line 21, the first factor in the four constraints is binary. The second factors are byte-constrained because they are read from memory. This means each of the expression in the four constraints are byte range constrained. Now the interesting observation is that x = 0, y = 0 is equivalent to x + y = 0 if x and y are non-negative and the sum does not wrap. So we can turn the set of these four constraints into a single constraint by adding them, and we arrive exactly at the constraint in line 26.
Note that the observation that we can use a sum to express a conjunction is actually used by this "check equal zero" optimization step as well. So we could maybe split it into two steps, with the advantage that it would also work in case the values are not byte-constrained (which is the case for "check equal X" for some other constant X, because there the values are essentially y - X, and thus they can get negative)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use the fact that x = 0, y = 0 is equivalent to x**2 + y**2 if the sum doesnt wrap (with no condition on x and y being non negative)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For that we also need that the squares do not wrap and especially the result would not be equivalent to the constraint in line 26. Or did you have something else in mind?
| mult=is_valid * 1, args=[reads_aux__1__base__timestamp_lt_aux__lower_decomp__1_3, 12] | ||
|
|
||
| // Bus 6 (BITWISE_LOOKUP): | ||
| mult=is_valid * 1, args=[1 - new_var2 * (b__0_0 + b__1_0 + b__2_0 + b__3_0), 1 - new_var3 * (b__0_1 + b__1_1 + b__2_1 + b__3_1), new_var3 * (b__0_1 + b__1_1 + b__2_1 + b__3_1) + new_var2 * (b__0_0 + b__1_0 + b__2_0 + b__3_0) + 2 * a__0_3 - 2, 1] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we have another chance of optimization: We have two binary-constrained inputs to the bitwise lookup, we can just do that with a single algebraic constraint.
| mult=is_valid * 1, args=[writes_aux__base__timestamp_lt_aux__lower_decomp__1_0, 12] | ||
|
|
||
| // Algebraic constraints: | ||
| -((1 - new_var2 * (b__0_0 + b__1_0 + b__2_0 + b__3_0)) * (new_var2 * (b__0_0 + b__1_0 + b__2_0 + b__3_0))) = 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh wow, this one is redundant as well, isn't it?
| // Bus 6 (BITWISE_LOOKUP): | ||
| mult=is_valid * 1, args=[b__0_0, 3, b__0_0 + 3 - 2 * b__0_1, 1] | ||
| mult=is_valid * 1, args=[1 - new_var_175 * b__0_1, 1 - new_var_176 * (b__0_2 + b__1_2 + b__2_2 + b__3_2), new_var_176 * (b__0_2 + b__1_2 + b__2_2 + b__3_2) + new_var_175 * b__0_1 + 2 * a__0_4 - 2, 1] | ||
| mult=is_valid * 1, args=[b__1_0, b__2_0, 0, 0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These should be removed by #3012
|
Tests failing |
|
This now fails after the bus interaction var refactoring because we now have And we remove |
|
Furthermore, we determine the inputs to be the 8 bus interaction variables, not just 4. So this is an example of "the constraints further restrict the inputs". |
|
|
||
| while let Some(v) = remaining_variables.pop_first() { | ||
| let variables_to_extract = reachable_variables([v.clone()], &constraint_system); | ||
| if variables_to_extract.is_empty() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It at least contains v so I think this can be removed again.
|
clippy is failing |
…_in_exhaustive_search
…_in_exhaustive_search
…_in_exhaustive_search
georgwiese
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool! I'll have to have another look tomorrow (got through half of equal_zero_cheks.rs, until the end of try_replace_equal_zero_check).
High-level, the questions I have so far:
- Did you consider running this only on the instruction circuits (with PC lookup variables bound to the right values)? I feel like this could reduce complexity, increase efficiency and yield the same results.
- What is the performance impact of this?
| } | ||
|
|
||
| /// Adds a derived variable. The variable is not created with this function, it only adds | ||
| /// a method to compute it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand the second sentence.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried to say it differently.
| } | ||
|
|
||
| #[test] | ||
| fn single_seqz() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think these should be in openvm/tests/apc_builder_pseudo_instructions.rs?
| let mut new_var = || { | ||
| let id = var_allocator.issue_next_poly_id(); | ||
| AlgebraicReference { | ||
| name: format!("new_var_{id}").into(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we receive a name here, and use it instead of "new_var"? Would make it easier to read the snapshots.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean? A name that depends on the other names? Are you proposing to make it a variant of the output variable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean changing it to
let mut new_var = |name| {
let id = var_allocator.issue_next_poly_id();
AlgebraicReference {
name: format!("{name}_{id}").into(),
id,
}
};and calling it as new_var("sum_inv"). This way we wouldn't have column names like new_var_29 in the snapshots.
| constraint_system.clone(), | ||
| bus_interaction_handler.clone(), | ||
| ) { | ||
| if subsystem.referenced_unknown_variables().count() > 200 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could be a constant
| // Check that each input is non-negative and that the sum of inputs cannot wrap. | ||
| // TODO we do not need this. If this property is false, we can still find | ||
| // a better constraint that also models is-equal-zero. | ||
| let min_input = inputs |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This block could be extracted into a function.
| let remove_bus_interaction = |bus_interaction: &BusInteraction<GroupedExpression<T, V>>| { | ||
| bus_interaction | ||
| .referenced_unknown_variables() | ||
| .any(|var| variables_to_remove.contains(var)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think by definition of variables_to_remove, the bus interactions removed here are guaranteed to be stateless, right? Maybe we can add an assertion?
| return; | ||
| } | ||
|
|
||
| // It's a go! Remove the constraind and add a more efficient version. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense to split try_replace_equal_zero_check (which is quite long) into try_find_equal_zero_subcircuit and replace_equal_zero_subcircuit?
| &inputs.iter().map(|v| (v.clone(), solver.get(v))).collect(), | ||
| bus_interaction_handler.clone(), | ||
| ) { | ||
| return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we expect this to happen? Or is this a sanity check?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is important.
georgwiese
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool! Looking at the second half, I'm wondering the same thing as yesterday: Do we need check_redundancy? It is a significant amount of code (including is_satisfiable, which is only called from there) and seems a bit ad-hoc. If it is just a sanity check, I'd propose moving it to a separate module (indicating that this code is not essential for soundness) and asserting that it always returns true, instead of silently failing.
| } else { | ||
| GroupedExpression::one() - GroupedExpression::from_unknown_variable(output.clone()) | ||
| }; | ||
| let sum_of_inputs = inputs |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add to the docstring that we assume that inputs are nonzero and can be summed without overflow. Maybe also worth asserting this again here?
| solver: &mut impl Solver<T, V>, | ||
| new_var: &mut impl FnMut() -> V, | ||
| output: V, | ||
| value: T, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| value: T, | |
| binary_value: T, |
It looks a bit more descriptive to me.
| value: T, | ||
| ) { | ||
| // First, we try to find input and output variables that satisfy the equal zero check property. | ||
| let Ok(solution) = solve_with_assignments( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this function solve all the unknown variables in the system, or only some of them?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It runs the solver, whatever it will determine it will return. But note that the solver has run before on that system, so it will usually only return results that can be obtained given the new assignments.
| // is-equal-zero constraint) because they are only connected to a stateful | ||
| // bus interaction via the input or output. | ||
| let blocking_variables = inputs.iter().chain([&output]).cloned(); | ||
| let outside_variables = reachable_variables_except_blocked( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found this name a bit confusing at first — I initially thought it meant the reachable variables excluding the blocking ones, but then I realized it actually includes reachable blocking variables and just stops the search from continuing through them. Just wanted to mention this in case others might read it the same way.
|
@georgwiese yes, check_redundancy is vital. Otherwise we might remove more constraints than we want to. What could happen is that the constraints we remove restrict the set of possible inputs. I think we should talk about that in person because I also feel that there still might be a bug in the code wrt that. |
Co-authored-by: Georg Wiese <[email protected]>
Co-authored-by: Georg Wiese <[email protected]>
Co-authored-by: Georg Wiese <[email protected]>
Co-authored-by: Georg Wiese <[email protected]>
Searches the constraints to find a set of input columns and one output column such that (semantically, not syntactically), the ouput is zero if and only if all the inputs are zero (and output is one otherwise). Then it tries to replace the involved constraints and auxiliary columns by a better version of that function.