Fixed-Point Arithmetic
Circuits only know field elements, but many real computations need fractions: prices, interest rates, or the weights of a machine learning model. Floating-point numbers are far too complex for a circuit, so we use fixed-point numbers instead, which are simply integers with an implied scale. This is how zkML libraries such as circomlib-ml represent the numbers of a neural network.
Representation
A fixed-point number with fractional bits is stored as the integer . With :
| Number | Stored as |
|---|---|
| , rounded | |
Negative numbers are just negative field elements, i.e. , as described in Finite Fields. We call a number -bit if its stored integer is in . Our circuits are written for any and , and the tests use .
We use a power of two as the scale, so that rescaling is just dropping bits. A power of ten such as (as used by circomlib-ml, and by Ethereum tokens) works as well, but it needs an integer division instead.
Addition Is Free
The sum of two fixed-point numbers is the sum of their integers, as . So addition, subtraction, and multiplication by an integer constant are free, just like with integers.
Multiplication Needs Rescaling
The product of two fixed-point numbers has the wrong scale:
So after multiplying, we must divide by , i.e. drop the lowest bits. In a field, there is no “drop the bits” operation, so we decompose the number into bits and add back only the ones we keep.
Rounding Signed Numbers
Decomposing into bits only works for non-negative numbers, as a negative number is a huge field element. So we first shift the number by , which maps the signed range to , drop the bits, and then undo the shift:
template FixedPointRescale(M, F) {
assert(F < M && M < 254);
signal input in;
signal output out;
// shift to a non-negative number, which also asserts that `in` has `M` bits
signal bits[M] <== Num2Bits(M)(in + 2 ** (M - 1));
// drop the lowest `F` bits
var shifted = 0;
for (var i = F; i < M; i++) {
shifted += bits[i] * 2 ** (i - F);
}
// undo the shift, which is a multiple of `2^F`
out <== shifted - 2 ** (M - 1 - F);
}
Since the shift is a multiple of , undoing it after dropping bits gives exactly , i.e. the result is rounded down, also for negative numbers: becomes , not .
The bit decomposition has another job as well: it checks that the input actually has bits. Without it, a number such as would wrap around after the shift, and give a wrong result.
Multiplication
template AssertSigned(N) {
signal input in;
// shifting by `2^(N-1)` maps [-2^(N-1), 2^(N-1)) to [0, 2^N)
AssertBits(N)(in + 2 ** (N - 1));
}
template FixedPointMul(N, F) {
signal input in[2];
signal output out;
AssertSigned(N)(in[0]);
AssertSigned(N)(in[1]);
// the product has `2F` fractional bits; it is at most `2^(2N-2)`, which is
// the square of the smallest number `-2^(N-1)`, so it fits in `2N` bits
signal product <== in[0] * in[1];
out <== FixedPointRescale(2 * N, F)(product);
}
We range-check the inputs, so that we know the size of their product. An -bit number is at least , so the product is at most .
Careful here: itself is not a -bit signed number, which goes up to ! The square of the smallest number is the single product that does not fit, so we rescale with bits. The tests multiply the extreme values to catch exactly this kind of off-by-one.
Note that the output is not range-checked: the product of two -bit numbers may need more than bits, e.g. does not fit in , whose largest number is just under . If the output is used in another multiplication, that multiplication checks its range anyway.
Dot Product
Most of the arithmetic in a neural network is in dot products, . Rescaling costs constraints, which is much more than the multiplication itself, so we do not rescale each product. Instead, we add up the products with fractional bits, which is free, and rescale only once at the end:
template FixedPointDot(n, N, F) {
signal input a[n];
signal input b[n];
signal output out;
// sum the products with `2F` fractional bits, without rescaling them one by one
signal products[n];
var sum = 0;
for (var i = 0; i < n; i++) {
AssertSigned(N)(a[i]);
AssertSigned(N)(b[i]);
products[i] <== a[i] * b[i];
sum += products[i];
}
// each product is at most `2^(2N-2)`, so their sum is less than `2^(2N-2+nbits(n))`
out <== FixedPointRescale(2 * N + nbits(n), F)(sum);
}
The sum of products is larger than a single one, so we need more bits, which is about nbits(n). As a bonus, rescaling once also rounds once, so the result is more accurate.
Beyond Dot Products
With these, you can build a whole neural network layer: a matrix-vector product is dot products, and adding a bias is free. The activation functions are harder:
- ReLU is , which needs the sign of . In
FixedPointRescale, the sign is the top bit of the shifted number, so it is almost free to get! - Sigmoid and other smooth functions have no exact form, so they are replaced with a polynomial approximation, which only needs multiplications and additions.
Constraint Count
| Circuit | non-linear constraints |
|---|---|
FixedPointMul(32, 16) | 129 |
FixedPointDot(4, 32, 16) | 327 |
FixedPointDot(100, 32, 16) | 6571 |
FixedPointMul(N, F) has constraints for range-checking the inputs, 1 for the product, and for rescaling, so . FixedPointDot(n, N, F) has for the range checks & products, and nbits(n) for a single rescale. Rescaling each product separately would add constraints per product, about 6400 more for .
See Also
- Integer Division, for rescaling with a scale that is not a power of two.
- circomlib-ml, which implements the layers of a neural network in Circom.