Behavior in simulator mode
The circuit code can be run with a circuit simulator instead of a real circuit builder for faster testing. In simulator mode, generally code paths should be used that are analogous to the case of constant values. This is described in comments in cpp/src/barretenberg/stdlib_circuit_builders/circuit_simulator.hpp
:
* The general strategy is to use the "constant" code paths that already exist in the stdlib, but with a circuit
* simulator as its context (rather than, for instance, a nullptr). In cases where this doesn't quite work, we use
* template metaprogramming to provide an alternative implementation.
*
* This means changing the data model in some ways. Since a simulator does not contain a `variables` vector, we cannot
* work with witness indices anymore. In particular, the witness index of every witness instantiated with a simulator
* type will be `IS_CONSTANT`, and the witness is just a wrapper for a value. A stdlib `field_t` instance will now store
* all of its data in the `additive_constant`, and something similar is true for the `uint` classes.
Note that the documentation here is incorrect regarding the witness index being IS_CONSTANT
. The value of IS_CONSTANT
is 0xffffffff=4294967295
, whereas the circuit simulator returns 1028
as witness indexes — see, for example, in these functions still in cpp/src/barretenberg/stdlib_circuit_builders/circuit_simulator.hpp
:
inline uint32_t add_variable([[maybe_unused]] const bb::fr index) const { return 1028; }
inline bb::fr get_variable([[maybe_unused]] const uint32_t index) const { return 1028; }
uint32_t put_constant_variable([[maybe_unused]] const bb::fr& variable) { return 1028; }
In the bigfield
component, there some places where simulator mode behaves unusually, which may result in incomplete testing.
The function bigfield::assert_equal
just returns in simulator mode, rather than asserting that the values are equal, which would be the expected behavior (see also Finding ref↗ for the same issue in the case of both operands being constant). There is already a TODO and issue for this.
Similarly in the following constructor,
template <typename Builder, typename T>
bigfield<Builder, T>::bigfield(const field_t<Builder>& low_bits_in,
const field_t<Builder>& high_bits_in,
const bool can_overflow,
const size_t maximum_bitlength)
the limbs are treated differently in the simulated case. This constructor is passed low and high halves of the value, so, for example, low_bits_in
contains the value that will be represented by the two lower binary limbs, which together are 2*68
bits wide. In the nonconstant !HasPlookup<Builder> && !IsSimulator<Builder>
case, this element will be decomposed into two limbs using decompose_into_base4_accumulators
as follows:
low_accumulator = context->decompose_into_base4_accumulators(
low_bits_in.witness_index, static_cast<size_t>(NUM_LIMB_BITS * 2), "bigfield: low_bits_in too large.");
mid_index = static_cast<size_t>((NUM_LIMB_BITS / 2) - 1);
// Range constraint returns an array of partial sums, midpoint will happen to hold the big limb
// value
if constexpr (!IsSimulator<Builder>) {
limb_1.witness_index = low_accumulator[mid_index];
}
// We can get the first half bits of low_bits_in from the variables we already created
limb_0 = (low_bits_in - (limb_1 * shift_1));
Here, limb_1
is first assigned the correct value from the return value of decompose_into_base4_accumulators
, and then limb_0
is derived from this. As decompose_into_base4_accumulators
will constrain low_bits_in
to be at most 2*68
bits wide, and it ensures that limb_1
assigned the way it is will be the high 68 bits, setting limb_0 = (low_bits_in - (limb_1 * shift_1))
ensures that limb_0
will be the low 68 bits of low_bits_in
. In particular, both limb_0
and limb_1
will be at most 68 bits wide.
However, in the nonconstant !HasPlookup<Builder> && IsSimulator<Builder>
case, we skip setting the limb_1
witness index. This in itself makes sense, as the witness index does not carry information in the simulated case, and the std::vector<uint32_t> decompose_into_base4_accumulators
function is also not properly implemented for CircuitSimulatorBN254
. It just returns a single index, rather than a list with the expected number of elements. Note that this can cause crashes due to reading out of bounds if any caller does not special-case the IsSimulator<Builder>
case for return values of decompose_into_base4_accumulators
. However, the end result in the simulated case will be that limb_0
will be up to 2*68
bits wide, and limb_1
will have value zero. As the first limb still gets attached a default maximum value that is 68 bits wide, the assumption that limb values are always at most their maximum value will be broken:
binary_basis_limbs[0] = Limb(limb_0, DEFAULT_MAXIMUM_LIMB);
It is unclear whether this would have any impact, as code paths for constant values usually do not use the maximum values. Furthermore, it is unclear whether low_bits_in.witness_index != IS_CONSTANT && !HasPlookup<Builder> && IsSimulator<Builder>
can ever be true, as the construction of field_t
out of witnesses special-cases the simulated case and makes the field element a constant (cpp/src/barretenberg/stdlib/primitives/field/field.cpp
):
template <typename Builder>
field_t<Builder>::field_t(const witness_t<Builder>& value)
: context(value.context)
{
if constexpr (IsSimulator<Builder>) {
additive_constant = value.witness;
multiplicative_constant = 1;
witness_index = IS_CONSTANT;
} else {
additive_constant = 0;
multiplicative_constant = 1;
witness_index = value.witness_index;
}
}