Field Arithmetic: The Foundation of Zero-Knowledge Virtual Machines
Part 1 of the zigz zkVM Deep Dive Series
Introduction
At the core of every zero-knowledge virtual machine sits a surprisingly simple mathematical structure: the finite field.
Even though a zkVM performs fairly sophisticated cryptographic computations to prove program execution, every single operation eventually reduces to arithmetic inside a finite field. Addition, polynomial evaluation, hashing, constraint checks. Everything happens there.
While working on zigz, I ended up spending a lot of time thinking about fields and how to implement them cleanly and efficiently in Zig.
In this post I'll walk through:
By the end, the hope is that the importance of field choice becomes clear and that Zig's compile-time generics start to look like a very natural fit for this problem.
What is a Finite Field?
A finite field (also known as a Galois field) is simply a set with a finite number of elements where the usual arithmetic operations behave nicely.
In other words, you can:
and the results always stay inside the set.
A Simple Example: F₁₇
Take the field F₁₇, which is just the integers modulo 17.
Elements: {0,1,2,...,16}
Addition: (a + b) mod 17
Multiplication: (a × b) mod 17
Examples:
One important property is that every non-zero element has a multiplicative inverse.
For instance:
5 × 7 = 1 mod 17
which means 7 is the inverse of 5 in this field.
Prime Fields
The most commonly used finite fields in zk systems are prime fields, written as Fₚ, where p is a prime number.
The elements are simply:
{0,1,2,...,p−1}
with arithmetic performed modulo p.
The requirement that p be prime is important. If the modulus were composite, some elements would not have multiplicative inverses and the structure would no longer be a field.
Why zkVMs Use Finite Fields
Zero-knowledge proof systems rely heavily on finite fields for a few reasons.
1. They Work Well for Cryptography
Many cryptographic assumptions live naturally in finite fields, including problems like:
These are the building blocks used in proof systems.
2. Exact Arithmetic
Field arithmetic is exact.
Unlike floating-point computations there are:
This matters a lot in zk systems because the prover and verifier must compute exactly the same values.
3. Efficient CPU Arithmetic
When the field modulus is chosen carefully, arithmetic becomes extremely efficient.
Modern CPUs handle 64-bit arithmetic very well, so primes that fit nicely within 32 or 64 bits can be implemented with very little overhead.
This allows:
4. Polynomial Commitment Schemes
zkVMs make heavy use of polynomials.
For example:
Polynomials over finite fields behave very well mathematically. They have unique representations and can be evaluated or interpolated efficiently.
5. Sumcheck Protocols
Protocols like sumcheck (used heavily in systems such as Jolt) rely on evaluating multilinear polynomials at random challenge points.
These operations only make sense if the underlying arithmetic is exact and deterministic. Finite fields provide that structure.
zigz's Field Implementation
When I started implementing field arithmetic in zigz, the goal was to keep it:
Zig's comptime feature turns out to be extremely convenient for this.
Generic Field Type
pub fn Field(comptime T: type, comptime modulus: T) type {
if (modulus <= 1) {
@compileError("Field modulus must be greater than 1");
}
return struct {
value: T,
pub const MODULUS: T = modulus;
};
}
The important part here is that the modulus is a compile-time parameter.
That gives a few nice properties:
Recommended by LinkedIn
Which also means you cannot accidentally mix them.
Addition
pub fn add(self: Self, other: Self) Self {
const sum = @addWithOverflow(self.value, other.value);
if (sum[1] == 1) {
return Self{ .value = @mod(sum[0], MODULUS) };
}
if (sum[0] >= MODULUS) {
return Self{ .value = sum[0] - MODULUS };
}
return Self{ .value = sum[0] };
}
The main idea is to handle overflow first and avoid a full modulo operation whenever possible.
Subtraction
pub fn sub(self: Self, other: Self) Self {
if (self.value >= other.value) {
return Self{ .value = self.value - other.value };
}
return Self{ .value = MODULUS - (other.value - self.value) };
}
This is just modular subtraction with a simple borrow correction.
Multiplication
pub fn mul(self: Self, other: Self) Self {
const wide_product = @as(u128, self.value) * @as(u128, other.value);
const reduced = @mod(wide_product, MODULUS);
return Self{ .value = @intCast(reduced) };
}
Here the multiplication is done in a wider integer type to avoid overflow before the reduction step.
Division
Division is implemented through multiplicative inverses.
pub fn inv(self: Self) !Self {
if (self.isZero()) {
return error.DivisionByZero;
}
// Extended Euclidean algorithm
}
Once the inverse is computed:
a / b = a × b⁻¹
Choosing the Right Field
The choice of field has a real impact on zkVM performance.
zigz currently includes a few commonly used ones.
BabyBear Field
p = 2³¹ − 2²⁷ + 1
pub const BabyBear = field.Field(u64, 2013265921);
Properties:
This is currently the default field used in zigz.
Goldilocks Field
p = 2⁶⁴ − 2³² + 1
pub const Goldilocks = field.Field(u64, 0xFFFFFFFF00000001);
This field is popular in systems like Plonky2.
Advantages include:
The name "Goldilocks" comes from the fact that it's large enough for security but still efficient to compute with.
Mersenne Fields
Examples:
2³¹ − 1
2⁶¹ − 1
These allow extremely efficient modular reduction using bit operations rather than division.
Why zigz Uses Compile-Time Specialization
One of the nicest things about Zig here is that we can write generic code once and let the compiler specialize it.
Example:
const a = BabyBear.init(10);
const b = BabyBear.init(20);
const c = a.add(b);
If we switch fields:
const a = Goldilocks.init(10);
const b = Goldilocks.init(20);
the compiler produces a separate optimized version of the arithmetic.
And since the types differ, this will not compile:
a_baby.add(b_gold)
which avoids a whole class of bugs.
Where Fields Appear in zigz
Field arithmetic shows up almost everywhere in the system:
For example:
const prover = try Prover(BabyBear).init(allocator);
const proof = try prover.prove(trace);
Changing the field simply changes the arithmetic layer underneath.
Final Thoughts
Finite field arithmetic is the mathematical foundation on which zkVMs are built. Every higher-level component eventually reduces to operations inside a field.
Some key points that stood out while building zigz:
In the next post I'll move up one level and talk about polynomials, particularly multilinear polynomials, and how they show up in zkVM designs.
Next: Polynomials in zkVMs