notes
This note assumes some familiarity with permutation checks.
Miden VM supports linear read-write random access memory. This memory is word-addressable, meaning, four values are located at each address, and we can read and write values to/from memory in batches of four. Each value is a field element in a -bit prime field with modulus . Memory address can be any field element.
In this note we describe the rational for selecting the above design and describe AIR constraints needed to support it.
The design makes extensive use of -bit range checks. An efficient way of implementing such range checks is described here.
The simplest (and most efficient) alternative to the above design is contiguous write-once memory. To support such memory, we need to allocate just two trace columns as illustrated below.
In the above, addr
column holds memory address, and value
column holds the field element representing the value stored at this address. Notice that some rows in this table are duplicated. This is because we need one row per memory access (either read or write operation). In the example above, value was first stored at memory address , and then read from this address.
The AIR constraints for this design are very simple. First, we need to ensure that values in the addr
column either remain the same or are incremented by as we move from one row to the next. This can be achieved with the following constraint:
where is the value in addr
column in the current row, and is the value in this column in the next row.
Second, we need to make sure that if the value in the addr
column didn't change, the value in the value
column also remained the same (i.e., a value stored in a given address can only be set once). This can be achieved with the following constraint:
where is the value in value
column at the current row, and is the value in this column in the next row.
In addition to the above constraints we would also need to impose constraints needed for permutation checks, but we omit these constraints here because they are needed for all designs described in this note.
As mentioned above, this approach is very efficient: each memory access requires just trace cells.
Write-once memory is tricky to work with, and many developers may need to climb a steep learning curve before they become comfortable working in this model. Thus, ideally, we'd want to support read-write memory. To do this, we need to introduce additional columns as illustrated below.
In the above, we added clk
column, which keeps track of the clock cycle at which memory access happened. We also need to differentiate between memory reads and writes. To do this, we now use two columns to keep track of the value: old val
contains the value stored at the address before the operation, and new val
contains the value after the operation. Thus, if old val
and new val
are the same, it was a read operation. If they are different, it was a write operation.
The AIR constraints needed to support the above structure are as follows.
We still need to make sure memory addresses are contiguous:
Whenever memory address changes, we want to make sure that old val
is set to (i.e., our memory is always initialized to ). This can be done with the following constraint:
On the other hand, if memory address doesn't change, we want to make sure that new val
in the current row is the same as old val
in the next row. This can be done with the following constraint:
Lastly, we need to make sure that for the same address values in clk
column are always increasing. One way to do this is to perform a -bit range check on the value of , where is the reference to clk
column. However, this would mean that memory operations involving the same address must happen within VM cycles from each other. This limitation would be difficult to enforce statically. To remove this limitation, we need to add two more columns as shown below:
In the above column d0
contains the lower bits of while d1
contains the upper bits. The constraint needed to enforces this is as follows:
Additionally, we need to apply -bit range checks to columns d0
and d1
.
Overall, the cost of reading or writing a single element is now trace cells and -bit range-checks.
Requiring that memory addresses are contiguous may also be a difficult limitation to impose statically. To remove this limitation, we need to introduce one more column as shown below:
In the above, the prover sets the value in the new column t
to when the address doesn't change, and to otherwise. To simplify constraint description, we'll define variable computed as follows:
Then, to make sure the prover sets the value of correctly, we'll impose the following constraints:
The above constraints ensure that whenever the address changes, and otherwise. We can then define the following constraints to make sure values in columns d0
and d1
contain either the delta between addresses or between clock cycles.
Condition | Constraint | Comments |
---|---|---|
When the address changes, columns d0 and d1 at the next row should contain the delta between the old and the new address. |
||
When the address remains the same, columns d0 and d1 at the next row should contain the delta between the old and the new clock cycle. |
We can combine the above constraints as follows:
The above constraint, in combination with -bit range checks against columns d0
and d1
ensure that values in addr
and clk
columns always increase monotonically, and also that column addr
may contain duplicates, while values in clk
column must be unique for a given address.
In many situations it may be desirable to assign memories to different context. For example, when making a cross-contract calls, memories of the caller and the callee should be separate. That is, caller should not be able to access the memory of the callee and vice-versa.
To accommodate this feature, we need to add one more column as illustrated below.
This new column ctx
should behave similarly to the address column: values in it should increase monotonically, and there could be breaks between them. We also need to change how the prover populates column t
:
t
should be set to the inverse , where is a reference to column ctx
.t
should be set to the inverse of .t
should be set to .To simplify description of constraints, we'll define two variables and as follows:
Thus, when the context changes, and otherwise. Also, when context remains the same and address changes, and otherwise.
To make sure the prover sets the value of column t
correctly, we'll need to impose the following constraints:
We can then define the following constraints to make sure values in columns d0
and d1
contain the delta between contexts, between addresses, or between clock cycles.
Condition | Constraint | Comments |
---|---|---|
When the context changes, columns d0 and d1 at the next row should contain the delta between the old and the new contexts. |
||
|
When the context remains the same but the address changes, columns d0 and d1 at the next row should contain the delta between the old and the new addresses. |
|
|
When both the context and the address remain the same, columns d0 and d1 at the next row should contain the delta between the old and the new clock cycle. |
We can combine the above constraints as follows:
The above constraint, in combination with -bit range checks against columns d0
and d1
ensure that values in ctx
, addr
, and clk
columns always increase monotonically, and also that columns ctx
and addr
may contain duplicates, while the values in column clk
must be unique for a given combination of ctx
and addr
.
Notice that the above constraint has degree .
While the approach described above works, it comes at significant cost. Reading or writing a single value requires trace cells and -bit range checks. Assuming a single range check requires roughly trace cells, the total number of trace cells needed grows to . This is about x worse the simple contiguous write-once memory described earlier.
Miden VM frequently needs to deal with batches of field elements, which we call words. For example, the output of Rescue Prime hash function is a single word. A single 256-bit integer value can be stored as two words (where each element contains one -bit value). Thus, we can optimize for this common use case by making the memory word-addressable. That is field elements are located at each memory address, and we can read and write elements to/from memory in batches of four.
The layout of Miden VM memory table is shown below:
where:
ctx
contains context ID. Values in this column must increase monotonically but there can be gaps between two consecutive values of up to . Also, two consecutive values can be the same. In AIR constraint description below, we refer to this column as .addr
contains memory address. Values in this column must increase monotonically for a given context but there can be gaps between two consecutive values of up to . Also, two consecutive values can be the same. In AIR constraint description below, we refer to this column as .clk
contains clock cycle at which the memory operation happened. Values in this column must increase monotonically for a given context and memory address but there can be gaps between two consecutive values of up to . In AIR constraint description below, we refer to this column as .u0, u1, u2, u3
columns contain field elements stored at a given context/address/clock cycle prior to the memory operation.v0, v1, v2, v3
columns contain field elements stored at a given context/address/clock cycle after the memory operation.d0
and d1
contain lower and upper bits of the delta between two consecutive context IDs, addresses, or clock cycles. Specifically:
t
contains the inverse of the delta between two consecutive context IDs, addresses, or clock cycles. Specifically:
For every memory access operation (i.e., read or write), a new row is added to the memory table. For read operations values in u
columns are equal to the corresponding values in v
columns. For write operations, the values may be different. Memory values are always initialized to .
The amortized cost of reading or writing a single value is between and trace cells (this accounts for the trace cells needed for -bit range checks). Thus, from performance standpoint, this approach is roughly x worse than the simple contiguous write-once memory described earlier. However, our view is that this trade-off is worth it given that this approach provides read-write memory, context separation, and eliminates the contiguous memory requirement.
To simplify description of constraints, we'll define two variables and as follows:
To make sure the prover sets the value of column t
correctly, we'll need to impose the following constraints:
The above constraints guarantee that when context changes , when context remains the same but address changes , and when neither the context nor the address change, .
To enforce the values of context ID, address, and clock cycle grow monotonically as described in the previous section, we define the following constraint.
In addition to this constraint, we also need to make sure that values in registers and are less than , and this can be done with permutation-based range checks.
Next, we need to make sure that values at a given memory address are always initialized to . This can be done with the following constraint:
where . Thus, when either the context changes, or the address changes, values in columns are guaranteed to be zeros.
Lastly, we need to make sure that for the same context/address combination, the columns of the current row are equal to the corresponding columns of the next row. This can be done with the following constraints:
where .
Notice that the maximum degree for all constraints described above is .
To use the above table in permutation checks, we need to reduce each row of the memory table to a single value. This can be done like so:
where and are random values sent from the verifier to the prover for use in permutation checks.
To move elements between the stack and the memory, Miden VM provides two operations MLOAD
and MSTORE
. Semantic of these operations are described below.
MLOAD
operation is used to move values from memory to the top of the stack. This operation works as follows:
Graphically, this looks like so:
Note that as a result of this operation the stack is shifted to the left by one.
Denoting stack registers as , clock cycle register as , and context register as , we can compute the lookup row value as follows:
where and are random values sent from the verifier to the prover for use in permutation checks.
Note that the values from the top of the stack are added into the row twice: once for "old" values and once for "new" values. We can do this because old and new values in the memory table row corresponding the load operation are the same.
MSTORE
operation is used to move values from the top of the stack to the memory. This operation works as follows:
Graphically, this looks like so:
Note that as a result of this operation the stack is shifted to the left by one, and the values saved to memory remain on the stack.
Denoting stack registers as , helper registers as , clock cycle register as , and context register as , we can compute the lookup row value as follows:
where and are random values sent from the verifier to the prover for use in permutation checks. Values for the helper registers are provided by the VM non-deterministically.
We also need to make sure that the saved values remained on the stack. This can be done with the following constraint:
where .