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

LogUp-based Virtual Bus #1307

Merged
merged 38 commits into from
Jun 25, 2024
Merged

LogUp-based Virtual Bus #1307

merged 38 commits into from
Jun 25, 2024

Conversation

Al-Kindi-0
Copy link
Collaborator

Describe your changes

An initial version of the GKR prover for the LogUp-based global virtual bus for Miden VM.

A few remarks:

  1. Only the range checker sub-bus is implemented at the moment.
  2. Memory handling is pretty primitive as there are many .clone() instances that could and should be avoided.
  3. There is an optimization where we can reuse the evaluation of the merged polynomials in the last sum-check. This is not implemented but we should comeback to it.
  4. There are many places where the code could easily benefit from mult-threading. This is left for latter.

A note about the usage in the context of the overall STARK. After the prover commits to the main-trace and sends the commitment to the verifier, the verifier replies with the LogUp randomness (i.e., the $\alpha$ values). At this point, and as part of the auxiliary trace building logic (see here), the prover initializes and runs

let vb_prover = VirtualBusProver::new(alphas)?;
let gkr_proof = vb_prover.prove(&trace, &mut transcript);

The above gkr_proof should be attached to the usual STARK proof so that the STARK verifier can call a GKR verifier to verify it as part of its proof verification.
As part of the auxiliary trace building logic still, the prover then uses

let FinalOpeningClaim{ eval_point, openings } = proof.get_final_opening_claim();

To get the randomness defining the Lagrange kernel auxiliary trace column and the openings which will be used to define a boundary constraint against the multi-linear opening helper column (column s here) and before that the batching randomness $\lambda$ as

transcript.reseed(Rpo256::hash_elements(&openings));
let lambda = transcript.draw();// TODO: should draw a vector

At this point the constraints, both boundary and transition, are updated and both multi-linear opening helper columns of the auxiliary trace are constructed. This concludes the auxiliary trace building stage and at this point the protocol continues as before.

Checklist before requesting a review

  • Repo forked and branch created from next according to naming convention.
  • Commit messages and codestyle follow conventions.
  • Relevant issues are linked in the PR description.
  • Tests added for new functionality.
  • Documentation/comments updated according to changes.

Comment on lines 263 to 320
let poly_a = circuit.p_0_vec[0].to_owned();
let poly_b = circuit.p_1_vec[0].to_owned();
let poly_c = circuit.q_0_vec[0].to_owned();
let poly_d = circuit.q_1_vec[0].to_owned();
let mut merged_mls = vec![poly_a, poly_b, poly_c, poly_d, poly_x];
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should be changed

Comment on lines 333 to 348
// construct the vector of multi-linear polynomials
// TODO: avoid unnecessary allocation
let poly_a = circuit.p_0_vec[layer_id].to_owned();
let poly_b = circuit.p_1_vec[layer_id].to_owned();
let poly_c = circuit.q_0_vec[layer_id].to_owned();
let poly_d = circuit.q_1_vec[layer_id].to_owned();
let mut mls = vec![poly_a, poly_b, poly_c, poly_d, poly_x];
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should be changed

Copy link
Contributor

Choose a reason for hiding this comment

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

Right, putting the EqFunction as the last ml is very brittle, and makes the code harder to follow. Ultimately, we need the EqFunction evaluation in the GkrComposition::evaluate(); couldn't we just evaluate it there directly?

And then SimpleGkrFinalClaimBuilder wouldn't need to remove the last opening.

Comment on lines 439 to 462
for i in 0..num_evaluations {
for j in 0..4 {
let query: Vec<E> = mls.iter().map(|ml| ml[i]).collect();

composition_polys[j].iter().for_each(|c| {
let evaluation = c.as_ref().evaluate(&query);
num_den[j].push(evaluation);
});
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should use multi-threads

@Al-Kindi-0 Al-Kindi-0 changed the title Al gkr circuit LogUp-based Virtual Bus Apr 5, 2024
// on memory usage.
let trace_len = trace.num_rows();
let mut mls: Vec<MultiLinearPoly<E>> = trace
.columns()
Copy link
Collaborator Author

@Al-Kindi-0 Al-Kindi-0 Apr 5, 2024

Choose a reason for hiding this comment

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

Only the columns which figure in the virtual bus relation should be used here. At the moment all of the trace is used but this should changed in the future

@plafer plafer mentioned this pull request Apr 10, 2024
Copy link
Contributor

@plafer plafer left a comment

Choose a reason for hiding this comment

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

Thank you for this - it will really help to clear up any remaining confusions for me!

(Not a full review yet)

In addition to the main suggestion I made in the last comment, I'm confused about our use of CompositionPolynomial and MultiLinearPoly. We make a MultiLinearPoly for each column in our trace, and then use CompositionPolynomials to encode looking up these columns to build the numerators and denominators. This confuses me, since MultiLinearPoly is supposed to be the $f_i$'s, but it no longer is in the implementation.

I haven't read through the entire PR, but could we do away with CompositionPolynomials? It seems like they come with a lot of boilerplate (a different type for each numerator/denominator expression, use of Arc<dyn ...>, etc), and are usually little more than accessing 1-3 trace columns. Basically we could make MultiLinearPoly actually represent the f_is, and then use functions to represent the various numerator/denominators (haven't thought too much exactly how to best model this yet)?

.columns()
.map(|col| {
let mut values: Vec<E> = col.iter().map(|value| E::from(*value)).collect();
values[trace_len - 1] = E::ZERO;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we zero out the last row?

Also we could use the slice last_mut() method instead of [trace_len - 1]

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is because we randomize the last row of the trace here

column[i] = rng.draw().expect("failed to draw a random value");

This is mainly because of some quirks regarding degree validation in Winterfell.
Will change to last_mut()

Comment on lines 121 to 112
let outp_p_0 = (0..len / 2)
.map(|i| inp_p_0[i] * inp_q_1[i] + inp_p_1[i] * inp_q_0[i])
.collect::<Vec<E>>();
let outp_p_1 = (len / 2..len)
.map(|i| inp_p_0[i] * inp_q_1[i] + inp_p_1[i] * inp_q_0[i])
.collect::<Vec<E>>();
let outp_q_0 = (0..len / 2).map(|i| inp_q_0[i] * inp_q_1[i]).collect::<Vec<E>>();
let outp_q_1 = (len / 2..len).map(|i| inp_q_0[i] * inp_q_1[i]).collect::<Vec<E>>();
Copy link
Contributor

Choose a reason for hiding this comment

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

This yields a "tangled" circuit, which is to draw out and reason about. If we view each gate of the circuit as performing a sum over projective coordinates (p0, q0) + (p1, q1) (the interpretation in the paper)

  • out_p_0 is the vector of all p0s of the next layer (gates' left numerator)
  • out_p_1 is the vector of all p1s of the next layer (gates' right numerator)
  • out_q_0 is the vector of all q0s of the next layer (gates' left denom)
  • out_q_1 is the vector of all q1s of the next layer (gates' right denom)

However, out_p_1 is defined to start at index len/2, i.e. in the next layer, the first gate's right numerator is halfway down the first layer. I would have expected out_p_1[0] to be made up of indices 2 and 3 from the first layer instead.

But more generally, I find keeping track of the p's and q's like this very difficult. It would seem more natural to me to have some ProjectiveCoordinate type, with addition defined (p0, q0) + (p1, q1) = (p0*q1 + p1*q0, q0*q1), and encode the circuit as a list of ProjectiveCoordinates. Then, to get the next layer, we just apply the sum to neighbors: [layer[0] + layer[1], layer[2] + layer[3], ...], where layer is a vector of all projective coordinates for a given layer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

To describe what is happening in the code above, we can do the following instead of lines 121-126

        let mut out_p = (0..len)
            .map(|i| inp_p_0[i] * inp_q_1[i] + inp_p_1[i] * inp_q_0[i])
            .collect::<Vec<E>>();
        let outp_p_1 = out_p.split_off(len/2);
        let outp_p_0 = out_p;

The use of "projective coordinate" in the paper is just to give some intuition on the technique. However, and this is why the code avoids its mention or use, the numerator (or denominators) should be viewed as multi-linear extension polynomials (especially for the deeper layers). With this view, if we denote by $p(x_0, \cdots, x_{\nu - 1})$ the multi-linear extension of all of the numerators at a certain layer, then $p(x_0, \cdots, x_{\nu - 2}, 0)$ is exactly our *_p_0 and $p(x_0, \cdots, x_{\nu - 2}, 1)$ is our *_p_1 vector. This translates directly to the formulas given in the paper for the sum-checks at a given layer.
If you still think that the use of projective coordinates is more intuitive, I would suggest that we create an issue for it and tackle it once we are decided on the bigger issues.

Copy link
Contributor

Choose a reason for hiding this comment

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

As I understand it, this doesn't yield the same circuit as in the paper. IIUC, it should be:

let mut out_p_0 = Vec::new();
let mut out_p_1 = Vec::new();
let mut out_q_0 = Vec::new();
let mut out_q_1 = Vec::new();

for i in 0..len/2 {
  out_p_0[i] = in_p_0[2*i] * in_q_1[2*i] + in_p_1[2*i] * in_q_0[2*i];
  out_p_1[i] = in_p_0[2*i + 1] * in_q_1[2*i + 1] + in_p_1[2*i + 1] * in_q_0[2*i + 1];
  out_q_0[i] = in_q_0[2*i] * in_q_1[2*i];
  out_q_1[i] = in_q_0[2*i + 1] * in_q_1[2*i + 1];
}

Copy link
Contributor

@plafer plafer Apr 22, 2024

Choose a reason for hiding this comment

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

Upon closer inspection, I see that your implementation is consistent with how you defined the input layer here. However, I would change that too, so that it corresponds to expanding the LogUp sum for the range checker bus. Specifically, the sum expression is:

$$ \sum_{x \in \{0 , 1\}^{\nu}} \frac{\mathrm{rc_multiplicity}(x)}{(\alpha - \mathrm{rc_value})(x)} + \frac{-\mathrm{f_s}(x)}{(\alpha - \mathrm{sv_0})(x)} + \frac{-\mathrm{f_s}(x)}{(\alpha - \mathrm{sv_1})(x)} + \frac{-\mathrm{f_s}(x)}{(\alpha - \mathrm{sv_2})(x)} + \frac{-\mathrm{f_s}(x)}{(\alpha - \mathrm{sv_3})(x)} + \frac{-\mathrm{f_m}(x)}{(\alpha - \mathrm{mv_0})(x)} + \frac{-\mathrm{f_m}(x)}{(\alpha - \mathrm{mv_1})(x)} + \frac{0(x)}{1(x)} $$

Expanded out, we get

$$ \frac{\mathrm{rc_multiplicity}(0)}{(\alpha - \mathrm{rc_value})(0)} + \frac{-\mathrm{f_s}(0)}{(\alpha - \mathrm{sv_0})(0)} + \frac{-\mathrm{f_s}(0)}{(\alpha - \mathrm{sv_1})(0)} + \frac{-\mathrm{f_s}(0)}{(\alpha - \mathrm{sv_2})(0)} + \frac{-\mathrm{f_s}(0)}{(\alpha - \mathrm{sv_3})(0)} + \frac{-\mathrm{f_m}(0)}{(\alpha - \mathrm{mv_0})(0)} + \frac{-\mathrm{f_m}(0)}{(\alpha - \mathrm{mv_1})(0)} + \frac{0(0)}{1(0)} + \cdots $$

Given the tree we would build with these as the leaves, I would expect our 4 numerator/denominators to be defined as:

$$ \begin{align*} p0 &= [\mathrm{rc_multiplicity}(0), -\mathrm{f_s}(0), -\mathrm{f_s}(0), -\mathrm{f_m}(0)] \\ p1 &= [-\mathrm{f_s}(0), -\mathrm{f_s}(0), -\mathrm{f_m}(0), 0(0)]\\ q0 &= [(\alpha - \mathrm{rc_value})(0), (\alpha - \mathrm{sv_1})(0), (\alpha - \mathrm{sv_3})(0), (\alpha - \mathrm{mv_1})(0)]\\ q1 &= [(\alpha - \mathrm{sv_0})(0), (\alpha - \mathrm{sv_2})(0), (\alpha - \mathrm{mv_0})(0), 1(0)]\\ \end{align*} $$

compared to how they are currently defined:

$$ \begin{align*} p0 &= [ \mathrm{rc_multiplicity}(0), -\mathrm{f_s}(0), -\mathrm{f_s}(0), -\mathrm{f_s}(0)] \\ p1 &= [ -\mathrm{f_s}(0), -\mathrm{f_m}(0), -\mathrm{f_m}(0), 0(0)] \\ q0 &= [ (\alpha - \mathrm{rc_value})(0), (\alpha - \mathrm{sv_0})(0), (\alpha - \mathrm{sv_1})(0), (\alpha - \mathrm{sv_2})(0)] \\ q1 &= [ (\alpha - \mathrm{sv_3})(0), (\alpha - \mathrm{mv_0})(0), (\alpha - \mathrm{mv_1})(0), 1(0)] \\ \end{align*} $$

@Al-Kindi-0
Copy link
Collaborator Author

In addition to the main suggestion I made in the last comment, I'm confused about our use of CompositionPolynomial and MultiLinearPoly. We make a MultiLinearPoly for each column in our trace, and then use CompositionPolynomials to encode looking up these columns to build the numerators and denominators. This confuses me, since MultiLinearPoly is supposed to be the fi's, but it no longer is in the implementation.

MultiLinearPoly stands for the MLE of a map from the Boolean hyper-cube to the field. In the code MultiLinearPoly is used for the trace columns (these are maps from the hyper-cube to the (base) field) and is also use for numerators/ denominators in in all sum-checks except the last one.
CompositionPolynomial stands for a non-linear multi-variate polynomial. This is used when there is a non-linear expression involving MLEs. So, one can have a linear combination of CompositionPolynomial all of the same arity and composed with the same set of MLEs. We can also have a Merge operation on such linear combinations and we can multiply such expressions. The result of these operations is again a CompositionPolynomial. Now if this last CompositionPolynomial is the one corresponding to the g in the sum-check docs and the multi-linears it is composed with are the f_is therein.

I haven't read through the entire PR, but could we do away with CompositionPolynomials? It seems like they come with a lot of boilerplate (a different type for each numerator/denominator expression, use of Arc<dyn ...>, etc), and are usually little more than accessing 1-3 trace columns. Basically we could make MultiLinearPoly actually represent the f_is, and then use functions to represent the various numerator/denominators (haven't thought too much exactly how to best model this yet)?

I would love to simplify this also. Indeed, the use of so much boilerplate is a bit irritating. I have spent some time thinking about it and I think that there might be a simpler way to do things. Will update this comment soon...

@plafer plafer changed the base branch from al-sum-check to next April 22, 2024 11:09
processor/src/trace/virtual_bus/circuit/prover.rs Outdated Show resolved Hide resolved
// generate challenge to batch two sumchecks
let data = vec![claim.0, claim.1];
transcript.reseed(H::hash_elements(&data));
let r_batch = transcript.draw().map_err(|_| ProverError::FailedToGenerateChallenge)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

We could make

pub enum ProverError {
    // ...
    FailedToGenerateMl(#[from] RandomCoinError)
    // ...
}

and change this line to

let r_batch = transcript.draw()?;

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That would be nice. I tried it and I think we need to modify RandomCoinError as well. Here is the error:
missing #[error("...")] display attribute

let p1 = proof.openings_claim.openings[1];
let q0 = proof.openings_claim.openings[2];
let q1 = proof.openings_claim.openings[3];
claim = (p0 + r_layer * (p1 - p0), q0 + r_layer * (q1 - q0));
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason why claims at the GKR level are not reduced the same way as the sum-check level? Given 2 claims c0 and c1, and some randomness r,

  • GKR: c = c0 + r * (c1 - c0)
  • sum-check: c = c0 + r * c1

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The reason for the expression used in the GKR claim reduction is due to the use of linear interpolation formula to evaluate a multi-linear.
We could use the same for batching the two sum-checks but we don't need to afais. There is a slight advantage of having a subtraction less in the currently used batching expression.

Comment on lines 195 to 219
for i in 1..num_rounds {
// generate random challenge r_i for the i-th round
let round_challenge = coin.draw().map_err(|_| Error::FailedToGenerateChallenge)?;

// compute the new reduced round claim
let new_round_claim =
reduce_claim(&round_proofs[i - 1], current_round_claim, round_challenge);

// fold each multi-linear using the round challenge
mls.iter_mut().for_each(|ml| ml.bind(round_challenge));

// run the i-th round of the protocol using the folded multi-linears for the new reduced
// claim. This basically computes the s_i polynomial.
let round_poly_evals = sumcheck_round(&self.composition_poly, mls);

// update the claim
current_round_claim = new_round_claim;

let round_poly_coefs = round_poly_evals.to_poly(current_round_claim.claim);

// reseed with the s_i polynomial
coin.reseed(H::hash_elements(&round_poly_coefs.coefficients));
let round_proof = RoundProof { round_poly_coefs };
round_proofs.push(round_proof);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we introduce new_round_claim variable here? Seems like the following should be equivalent:

for i in 1..num_rounds {
    // generate random challenge r_i for the i-th round
    let round_challenge = coin.draw().map_err(|_| Error::FailedToGenerateChallenge)?;

    // compute the new reduced round claim
    current_round_claim =
        reduce_claim(&round_proofs[i - 1], current_round_claim, round_challenge);

    // fold each multi-linear using the round challenge
    mls.iter_mut().for_each(|ml| ml.bind(round_challenge));

    // run the i-th round of the protocol using the folded multi-linears for the new reduced
    // claim. This basically computes the s_i polynomial.
    let round_poly_evals = sumcheck_round(&self.composition_poly, mls);
    let round_poly_coefs = round_poly_evals.to_poly(current_round_claim.claim);

    // reseed with the s_i polynomial
    coin.reseed(H::hash_elements(&round_poly_coefs.coefficients));
    let round_proof = RoundProof { round_poly_coefs };
    round_proofs.push(round_proof);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That's mainly for readability. No strong preference from my side though

Comment on lines 263 to 266
fn sumcheck_round<E: FieldElement>(
composition_poly: &dyn CompositionPolynomial<E>,
mls: &mut [MultiLinearPoly<E>],
) -> UnivariatePolyEvals<E> {
Copy link
Contributor

Choose a reason for hiding this comment

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

A few questions here:

  1. Why do we need dynamic objects here (i.e., dyn)? Could we not just use simple generics?
  2. It seems like invocation of sumcheck_round() is always followed by .to_poly() call on the result. Would it make sense to move .to_poly() call into the sumcheck_round() function.
  3. Why mls parameter needs to be passed in as mut? It doesn't seem like we are mutating it in the function.

Applying all 3 changes above, the signature of the function could look like so:

fn sumcheck_round<E: FieldElement, P: CompositionPolynomial<E>>(
    composition_poly: &P,
    mls: &[MultiLinearPoly<E>],
) -> UnivariatePolyCoef<E> 

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

  1. Probably a remnant from a previous approach. Changed.
  2. We can, but then we would have to pass claim so that we can call to_poly. I think keeping sumcheck_round clean is a good idea but, again, no strong preference from my side.
  3. I doesn't. Changed

Copy link
Contributor

Choose a reason for hiding this comment

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

2. We can, but then we would have to pass claim so that we can call to_poly. I think keeping sumcheck_round clean is a good idea but, again, no strong preference from my side.

Ah - I see. Let's keep it as is for now.

Comment on lines +10 to +19
/// A sum-check round proof.
///
/// This represents the partial polynomial sent by the Prover during one of the rounds of the
/// sum-check protocol. The polynomial is in coefficient form and excludes the coefficient for
/// the linear term as the Verifier can recover it from the other coefficients and the current
/// (reduced) claim.
#[derive(Debug, Clone)]
pub struct RoundProof<E: FieldElement> {
pub round_poly_coefs: UnivariatePolyCoef<E>,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like RoundProof and UnivariatePolyCoef are basically the same thing and are always used together. I'm wondering if we need two separate structs for it. Alternatives could be:

  1. Keep RoundProof and get rid of UnivariatePolyCoef. To do this, we'll need to move evaluate_using_claim() method into the RoundProof struct.
  2. Keep UnivariatePolyCoef and get rid of RoundProof - this would take even fewer changes than option 1 above.

Copy link
Contributor

@bobbinth bobbinth May 24, 2024

Choose a reason for hiding this comment

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

Another option (especially if we go with the changes to the sumcheck_round() function described in #1307 (comment)) could be to get rid of RoundProof and UnivariatePolyEvals and keep just UnivariatePolyCoef (and maybe rename it to just UnivariatePoly). This struct then could look like this:

pub struct UnivariatePoly<E> {
    coefficients: Vec<E>,
}

impl<E: FieldElement> UnivariatePoly<E> {

    pub fn from_evaluations(evaluations: Vec<E>, round_claim: E) -> Self {
        // this would be basically the same method as UnivariatePolyEvals::to_poly()
    }

    pub fn evaluate_using_claim(&self, claim: E, challenge: E) -> E {
       // this method would be the same as now
    }

    pub fn hash<H: ElementHasher<BaseField = E::BaseField>>(&self) -> H::Digest {
        // compute the hash of coefficients
    }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think keeping RoundProof is nice from a clarity point of view but on the other hand it is no different than a polynomial for all practical purposes.
If we are willing to pass claim to sumcheck_round then the above should work

Al-Kindi-0 and others added 11 commits May 24, 2024 08:31
* change prover

* change verifier

* cleanup

* Remove `VirtualBusVerifier`

* Remove `VirtualBusProver`

* reorganize exports

* `LayerGatesInputs`

* use constant

* docstrings

* add TODO

* remove TODO

* clippy

* add static_assertions

* add padding comment

* add back `claim` in verifier
processor/Cargo.toml Outdated Show resolved Hide resolved
Al-Kindi-0 and others added 2 commits May 24, 2024 09:43
* `ProjectiveCoordinates`

* Refactor `LayerGatesInputs`

* FractionalSumCircuit2 WIP

* Use `FractionalSumCircuit2` in prover

* SumCheckProverError

* fix number of layers computed

* SumCheckVerifierError

* Remove old `FractionalSumCircuit`

* Remove old `LayerGatesInputs`

* Remove `CircuitGateInputs`

* Document and rename

* docs

* Document `ProjectiveCoordinates`

* sumcheck_round variable ordering

* fix direction of sum-check

* Simplify and clean up (#1347)

* wip: change terminology of circuit eval

* wip: remove left/right num/denom

* chore: remove static

* chore: remove old file and comments

* Document `project_lower_variable`

* chore: minor nits and renaming

---------

Co-authored-by: Philippe Laferriere <plafer@protonmail.com>

* add TODOP

* Use `Wire` terminology

* Remove `CompositionPolynomial::num_variables()`

* fix capacity

* rename `NUM_WIRES_PER_TRACE_ROW` constant

* TODOP

* Introduce `reduce_layer_claim`

* use left/right terminology in GkrCompositionMerge

* adjust TODOP

* fix docs

* docs

* clippy

* clippy

* cleanup prove_before_final_circuit_layers loop

No longer uses an index

* adjust docs

* fmt

* doc nit

* nodes -> wires

* doc nit

* cleanup doc

* document loop

* fmt

---------

Co-authored-by: Al-Kindi-0 <82364884+Al-Kindi-0@users.noreply.github.com>
@plafer plafer changed the base branch from next to logup-gkr June 14, 2024 15:12
@plafer
Copy link
Contributor

plafer commented Jun 14, 2024

Note: changed the base to logup-gkr so that we may merge follow-up PRs before merging into next

miden-air = { package = "miden-air", path = "../air", version = "0.9", default-features = false }
static_assertions = "1.1.0"
thiserror = { version = "1.0", default-features = false }
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we using this somewhere? I think standard thiserror requires stdblib and we should probably use the one forked by @bitwalker as we do in the assembler. Though, we should probably move it into this org - probably as miden-thiserror.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah we should use the fork anywhere in this workspace that we depend on thiserror, at least temporarily - the error_in_core feature is stabilized in 1.80, and I expect that the mainline thiserror will support no-std shortly thereafter. With that in mind, it probably is not worth moving the thiserror fork to our org for such a short time. That said, I'll double check and see what the plan is though for thiserror, and if it looks like it'll be a month or two, then moving it makes more sense (I don't actually care whether we move it now or not, but didn't want to overcomplicate things for what amounts to a temporary workaround).

We probably should move the miette fork to our org though, since that will likely stick around for awhile longer, even once the thiserror fork goes away. I can try to find some time to do that.

Copy link
Contributor

Choose a reason for hiding this comment

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

the error_in_core feature is stabilized in 1.80

Ah - I didn't realize it is already slated for 1.80. In this case, no need to create miden-thiserror - we can just rely on your fork until the canonical thiserror is updated.

Comment on lines +71 to +121
/// Converts a main trace row (or more generally "query") to numerators and denominators of the
/// input layer.
fn evaluate_fractions_at_main_trace_query<E>(
query: &[E],
log_up_randomness: &[E],
) -> [[E; NUM_WIRES_PER_TRACE_ROW]; 2]
where
E: FieldElement,
{
// numerators
let multiplicity = query[M_COL_IDX];
let f_m = {
let mem_selec0 = query[CHIPLETS_OFFSET];
let mem_selec1 = query[CHIPLETS_OFFSET + 1];
let mem_selec2 = query[CHIPLETS_OFFSET + 2];
mem_selec0 * mem_selec1 * (E::ONE - mem_selec2)
};

let f_rc = {
let op_bit_4 = query[DECODER_OP_BITS_OFFSET + 4];
let op_bit_5 = query[DECODER_OP_BITS_OFFSET + 5];
let op_bit_6 = query[DECODER_OP_BITS_OFFSET + 6];

(E::ONE - op_bit_4) * (E::ONE - op_bit_5) * op_bit_6
};

// denominators
let alphas = log_up_randomness;

let table_denom = alphas[0] - query[V_COL_IDX];
let memory_denom_0 = -(alphas[0] - query[MEMORY_D0_COL_IDX]);
let memory_denom_1 = -(alphas[0] - query[MEMORY_D1_COL_IDX]);
let stack_value_denom_0 = -(alphas[0] - query[DECODER_USER_OP_HELPERS_OFFSET]);
let stack_value_denom_1 = -(alphas[0] - query[DECODER_USER_OP_HELPERS_OFFSET + 1]);
let stack_value_denom_2 = -(alphas[0] - query[DECODER_USER_OP_HELPERS_OFFSET + 2]);
let stack_value_denom_3 = -(alphas[0] - query[DECODER_USER_OP_HELPERS_OFFSET + 3]);

[
[multiplicity, f_m, f_m, f_rc, f_rc, f_rc, f_rc, E::ZERO],
[
table_denom,
memory_denom_0,
memory_denom_1,
stack_value_denom_0,
stack_value_denom_1,
stack_value_denom_2,
stack_value_denom_3,
E::ONE,
],
]
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this basically the only place where we have logic specific to Miden VM? Not for this PR, but I'm wondering if everything else is generic logic that could be a part of Winterfell rather than Miden VM. For example, we could introduce a method on the Air trait that would basically do the job of this function and look something like this:

fn build_gkr_input_layer<F, E>(&self, row: &[F], logup_rands: &[E) -> [Vec<E>; 2]
where
  F: FieldElement<BaseField = Self::BaseField>,
  E: FieldElement<BaseField = Self::BaseField> + ExtensionOf<F>,

And then both GKR prover and verifier would be able to use this to build and verify the proof respectively.

A couple of other questions:

  • What is log_up_randomness here? Is it a set of random values generated from the main trace commitment root? Are these the "lambdas" referenced in this issue or is it something else?
    • I'm assuming we are using only the first element from log_up_randomness because of the nature of the constraints. If we had more complicated tables (e.g., where multiple columns needed to be merged into a single value per row), we'd need to use more random elements - right?
  • Where would we specify "boundary" constraints for the buses? Specifically, for sub-busses which do not start and/or end with 0, what would be the way to specify initial/final values?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, that makes sense. This will require however that Winterfell is able to handle the logic for building the s-column similar to how it does for the l-column (i.e., Lagrange column).

To this end we would need to introduce the concept of an oracle. This is basically a wrapper around CompositionPolynomial with information about the index of the columns appearing in the algebraic expression encoded in the CompositionPolynomial as well as additional information needed to verify the final evaluation claim e.g., whether a column is periodic (or more generally transparent) and thus can be computed by the verifier un-assisted and hence does not need to be included in the s-column, whether a column is a shift as this is important when building the s-column...

  • What is log_up_randomness here? Is it a set of random values generated from the main trace commitment root?

Yes, these are the randomness generated from the main trace commitment, usually called the $\alpha$'s and indeed if we had more column to merge we would use more than one.

Where would we specify "boundary" constraints for the buses? Specifically, for sub-busses which do not start and/or end with 0, what would be the way to specify initial/final values?

The verifier will compute all of the boundary values in order to get a single claim value. The verifier will then check that the output of the LogUp circuit equals that claim. More precisely this would be done here

pub fn verify<
    E: FieldElement<BaseField = Felt>,
    C: RandomCoin<Hasher = H, BaseField = Felt>,
    H: ElementHasher<BaseField = Felt>,
>(
    claim: E,
    proof: GkrCircuitProof<E>,
    log_up_randomness: Vec<E>,
    transcript: &mut C,
) -> Result<FinalOpeningClaim<E>, VerifierError> {
...
// check that the output matches the expected `claim`
    if (p0 * q1 + p1 * q0) / (q0 * q1) != claim {
        return Err(VerifierError::MismatchingCircuitOutput);
    }
...
}

At the moment, claim is set to zero but if we want to go with the proposal of delegating everything to Winterfell, then we would need to add a method to the GKR verifier so that it can compute claim from the public inputs.

Comment on lines +142 to +148
/// A GKR proof for the correct evaluation of the sum of fractions circuit.
#[derive(Debug)]
pub struct GkrCircuitProof<E: FieldElement> {
circuit_outputs: CircuitLayerPolys<E>,
before_final_layer_proofs: BeforeFinalLayerProof<E>,
final_layer_proof: FinalLayerProof<E>,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: how big can we expect the proof to be (e.g., in KB) and what are the primary drivers? I'm guessing the primary driver is the complexity of the bus constraints - but does trace length also make a big difference?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The proof is composed of two parts:

  1. The round polynomials: The number of these is dependent on the trace length as well as the complexity of the bus. The degree of these is dependent on the max selector degree.
  2. The opening points: The number of this dependent on the trace length and the complexity of the bus.

To give a concrete formula, suppose that the trace length is $2^{\nu}$ and the number of fractions defining the bus is $2^{\mu + 1}$, then:

  1. The number of field elements defining the round polynomials is given by $$\frac{3\cdot(\mu + \nu - 1)\cdot(\mu + \nu)}{2} + 3\cdot\mu + d\cdot\nu,$$ the $3$ represents the degree of the round polynomials in the sum-checks appearing in all but the final sum-check, while the $d$ represent the degree of the round polynomial for the final sum-check (which depends as stated above on the degree of the selectors).
  2. The number of opening points is given by $$4\cdot (\mu + \nu - 1) + k,$$ where $k$ is the number of trace columns, as well as their shifts, appearing in the bus.

Note that all elements in the above are extension field elements.

To give some concrete numbers, suppose that $\nu = 20$, $\mu = 5$ and $k = 5$ (max selector degree is 3), then:

  1. The proof size due to round polynomials is: 1015 extension field elements which translates in the case of a quadratic extension degree to about 16Kb.
  2. The proof size due to opening points is: 136 extension field elements assuming we open 40 columns and column-shifts. This translates in the case of a quadratic extension degree to about 2Kb.

/// defined from the main trace columns.
pub fn new(
main_trace_columns: &[MultiLinearPoly<E>],
log_up_randomness: &[E],
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
log_up_randomness: &[E],
log_up_randomness: &E,

log_up_randomness is guaranteed to be a single element, so we should change it everywhere to only be &E

@bobbinth bobbinth marked this pull request as ready for review June 25, 2024 16:57
@bobbinth bobbinth merged commit de4606e into logup-gkr Jun 25, 2024
12 of 15 checks passed
@bobbinth bobbinth deleted the al-gkr-circuit branch June 25, 2024 16:57
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