Skip to content
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

Single step with branching #2274

Merged
merged 45 commits into from
Jan 7, 2025
Merged

Single step with branching #2274

merged 45 commits into from
Jan 7, 2025

Conversation

chriseth
Copy link
Member

@chriseth chriseth commented Dec 20, 2024

Infers single-step update code including branching.

TODO:

  • perform constant propagation after branching (using the evaluator in only_concrete_known()-setting)
  • extend the "can process fully"-interface to allow answers like "yes, I am authorized to process, but it will never succeed given these range constraints". This way we can remove conflicting combinations of e.g. instruction flags. Another way would be to directly return range constraints on variables with the "can process" call, that way we could save branching. Maybe it's also fine to just implement this for the fixed machine. In any case, we should double-check that we do not create another machine call for an identity we already solved.

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark 'Benchmarks'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.20.

Benchmark suite Current: b58afc1 Previous: c83472f Ratio
evaluator-benchmark/std::math::ff::reduce 748 ns/iter (± 1) 588 ns/iter (± 1) 1.27

This comment was automatically generated by workflow using github-action-benchmark.

@chriseth chriseth changed the base branch from main to single_step_simple January 2, 2025 14:40
@chriseth chriseth changed the base branch from single_step_simple to main January 2, 2025 14:43
Base automatically changed from single_step_simple to main January 3, 2025 12:03
@chriseth chriseth marked this pull request as ready for review January 3, 2025 15:30
Copy link
Collaborator

@georgwiese georgwiese left a comment

Choose a reason for hiding this comment

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

Wild!

I'm a bit worried generating code for every single combination of instruction flags. How would that not be infeasible for realistic examples, such as RISC-V? Also, I'm not sure if it is always possible to derive a unique witness if multiple instructions are active. Their constraints could be conflicting, right? In that case, code gen would fail?

It seems to me like we need more information which we can only get by running the PC lookup at compile time. We can figure out that the PC determines all other values in the PC lookup, and there is only a small number of possible combinations. These should be the cases we branch on, right? In FixedLookup::can_process_fully, we do have the list of all possible values.

let mut complete = HashSet::new();
for iteration in 0.. {
let mut progress = false;
// TODO propagate known.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not 100% sure what this means! Is that about propagating concrete values?

.iter()
.filter_map(|var| witgen.range_constraint(var).map(|rc| (var, rc)))
.filter(|(_, rc)| rc.try_to_single_value().is_none())
.sorted()
Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess this has the effect that when in two range widths are equal we pick the column with the smaller ID. Is this so that the generated code is deterministic? I think this deserves a comment then :)

@@ -327,7 +363,7 @@ impl<'a, T: FieldElement, FixedEval: FixedEvaluator<T>> WitgenInference<'a, T, F

/// Returns the current best-known range constraint on the given variable
/// combining global range constraints and newly derived local range constraints.
fn range_constraint(&self, variable: &Variable) -> Option<RangeConstraint<T>> {
pub fn range_constraint(&self, variable: &Variable) -> Option<RangeConstraint<T>> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it would be better to instead have a public method that selects the variable to branch on. Then the processor does not need to care about range constraints at all.

Copy link
Member Author

Choose a reason for hiding this comment

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

We could use some information specific to the processor, for example if we know it's a single step processor in a VM, we can branch on the first variable in the longest lookup that is binary constrained (it will likely be an instruction flag).

.filter_map(|var| witgen.range_constraint(var).map(|rc| (var, rc)))
.filter(|(_, rc)| rc.try_to_single_value().is_none())
.sorted()
.min_by_key(|(_, rc)| rc.range_width())
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we have some limit on the width? In practice, this will bisect until it's a concrete value, right? Which would take forever for very large ranges?

Copy link
Member Author

Choose a reason for hiding this comment

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

I would say we'll tune it later and see where we get with this.

VM::B[1] = VM::B[0];
if (VM::instr_add[0] == 1) {
if (VM::instr_mul[0] == 1) {
VM::A[1] = -((-(VM::A[0] + VM::B[0]) + -(VM::A[0] * VM::B[0])) + VM::A[0]);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Interesting. It doesn't know that instruction flags are exclusive. Does that mean that for $n$ instructions it generates $2^n$ branches? (For the Keccak RISC-V example, $n = 36$.)

enum VariableOrValue<T, V> {
Variable(V),
Value(T),
}

pub trait FixedEvaluator<T: FieldElement> {
pub trait FixedEvaluator<T: FieldElement>: Clone {
Copy link
Collaborator

Choose a reason for hiding this comment

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

In practice, we just pass a reference, which implements Clone, right? I guess the advantage of this is that we can also pass something like Rc<_>, and it can automatically free the memory?

I guess it's the same for CanProcessCall below, which is not restricted to be Clone as well, but it is in practice. I feel like accepting references and not requiring Clone would reduce verbosity, but no strong opinion...

Copy link
Member Author

Choose a reason for hiding this comment

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

I'd like to avoid the lifetimes...

executor/src/witgen/jit/compiler.rs Outdated Show resolved Hide resolved
},
vec![assignment(&y, symbol(&x) + number(1))],
vec![assignment(&y, symbol(&x) + number(2))],
)];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
)];
)];
let witgen_code = witgen_code(&[x.clone()], &effects);
assert_eq!(
witgen_code,
"
#[no_mangle]
extern \"C\" fn witgen(
WitgenFunctionParams{
data,
known,
row_offset,
params,
mutable_state,
call_machine
}: WitgenFunctionParams<FieldElement>,
) {
let known = known_to_slice(known, data.len);
let data = data.to_mut_slice();
let params = params.to_mut_slice();
let p_0 = get_param(params, 0);
let p_1;
if 7 <= IntType::from(p_0) && IntType::from(p_0) <= 20 {
p_1 = (p_0 + FieldElement::from(1));} else {
p_1 = (p_0 + FieldElement::from(2));}
set_param(params, 1, p_1);
set_param(params, 1, p_1);
}
"
);

Copy link
Collaborator

Choose a reason for hiding this comment

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

At least for me, it helps to see a concrete example in the tests.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll instead add unit tests for the branch code itself. I don't really like these long auto-generated code tests because they need a lot of changes if we decide to do something in a different order (i.e. they don't really test the core that needs to be tested).

executor/src/witgen/jit/single_step_processor.rs Outdated Show resolved Hide resolved
@georgwiese
Copy link
Collaborator

BTW, we'll need branching in block machines too, right? For stuff like operation_id * (a + b - c) + (1 - operation_id) * (a * b - c). So hopefully, we can pull some stuff out of the SingleStepProcessor in the future.

@georgwiese
Copy link
Collaborator

Ah, now I read & (somewhat) understood the issue description ^^

Another way would be to directly return range constraints on variables with the "can process" call, that way we could save branching.

I think this is the way to go, because the number of combinations is exponential and I don't think we want to ask the sub-machine that many times if it can succeed.

So with this solution what would happen in the example from the test is:

  • Branch on instr_add:
    • instr_add == 0: Call can_process_call_fully, learn that instr_mul must be 1 => uniquely determines the full witness.
    • instr_add == 1: Call can_process_call_fully, learn that instr_mul must be 0 => uniquely determines the full witness.

Sounds nice!

Also, I think returning range constraints might be a good idea anyway, because it would give us a "transfer of range constraints" ([a, b] in [c, d] => any range constraint on d transfers to b) also in cases where the constraint is not global (because e.g. it depends on the operation ID).

@chriseth
Copy link
Member Author

chriseth commented Jan 6, 2025

Indeed, and it is already implemented in this PR: #2300

Copy link
Collaborator

@georgwiese georgwiese left a comment

Choose a reason for hiding this comment

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

Vamos!

@@ -62,6 +62,16 @@ impl<T: Display> Display for Value<T> {
}
}

/// Return type of the `branch_on` method.
pub struct BranchResult<'a, T: FieldElement, FixedEval> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

❤️

@georgwiese georgwiese added this pull request to the merge queue Jan 7, 2025
Merged via the queue into main with commit dc4c5cd Jan 7, 2025
16 checks passed
@georgwiese georgwiese deleted the single_step_with_branching branch January 7, 2025 13:08
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.

2 participants