Assessment reports>Barretenberg Bigfield>Discussion>Behavior in simulator mode

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;
    }
}
Zellic © 2025Back to top ↑