Skip to content

BigInt addition: Moving analysis to toolchains #50

Description

@jakobkummerow

Background

BigInts are commonly represented as arrays of "digits" or "limbs", typically register-sized unsigned integers. So adding two BigInts means adding these digits and propagating carries; basically like elementary school math, just with bigger digits. In pseudo code, assuming identical length for simplicity, this is:

let carry = 0;
for (let i = 0; i < length; i++) {
  result[i], new_carry = add3(a[i], b[i], carry);
  carry = new_carry;
}

The code for the helper function add3 needs a way to express the carry it produces; languages like C++ or Rust typically accomplish that by using a type that's wider than the digit type:

u64 add3(u64 a, u64 b, u64 c_in, u64* c_out) {
  u128 result = u128{a} + u128{b} + u128{c};
  *c_out = result >> 64;
  return cast<u64>(result);
}

Of course, today's common 64-bit CPUs don't have 128-bit addition instructions, so compilers compile this code to a sequence of 64-bit operations. It's easy for a compiler to realize that the upper half of 64-to-128 bit widenings like u128{a} is zero, and to optimize out this constant. That leads to a machine code sequence like the following (x86_64; actual Clang output; all the names are denoting registers):

mov   result_low, carry_reg
xor   carry_reg, carry_reg
add   result_low, [src1 + 8*index]
setc  carry_reg
add   result_low, [src2 + 8*index]
adc   carry_reg, 0
mov   [dst + 8*index], result_low

Note that there are 3 additions in this sequence: one for adding c_in+a, one for adding b onto that, one for adding the two carry bits that the previous two additions might have produced. That's a correct translation for a context-free interpretation of the add3 function above: if all of a, b, c_in are sufficiently large values, then c_out could be 2, and all three additions perform nontrivial work.

Optimization opportunity

However, if we inline add3 into the for-loop above and take that context into account, we can generate a more optimal machine code sequence that needs only one or two additions. The reason is that carry starts out as 0, and in the loop's first iteration can at most become 1, and then in every following iteration is also guaranteed to remain in range 0..1: even if a and b are both maximal, at most one carry bit is produced by adding up all three values: 0xFFFF'FFFF'FFFF'FFFF + 0xFFFF'FFFF'FFFF'FFFF + 1 == 0x1'FFFF'FFFF'FFFF'FFFF. So there is no need for the third addition, and one of the other two can be replaced with a shift; we can get by with just this:

shr   carry_reg, 1  ;; Shifts carry_reg into the carry flag
mov   result_low, [src1 + 8*index]
adc   result_low, [src2 + 8*index]
setc  carry_reg  ;; register was zeroed by initial `shr`
mov   [dst + 8*index], result_low

Emitting this optimal machine code sequence hence requires analyzing the context and figuring out that one of the three inputs to add3 is guaranteed to remain in range 0..1. It is entirely possible to run this analysis in the optimizing JIT compiler of a Wasm engine. However, I argue that a design that allows moving this analysis work to AOT toolchains/optimizers would be a better fit for Wasm's philosophy: it would allow even simple engines to emit optimal machine code, without having to run fancy analysis passes first. This requires the Wasm wire bytes to carry more information than the add128 instruction in the current version of this proposal does.

Approach 1: Range annotation

One obvious approach would be to directly express the notion that c_in is in range 0..1. We could devise various range annotation schemes for this; one straightforward way to do it would be to introduce an i1 type. AOT toolchains (in particular: LLVM in Wasm mode) would detect the pattern discussed above, and would translate the entire add3 function to a single Wasm instruction add_with_carry with signature [i64, i64, i1] -> [i64, i1]. Engines could, with zero further analysis, translate that single Wasm instruction to the optimal machine code sequence; that's super simple to do even for a single-pass baseline compiler.

The i1 type would be a special tool for a special purpose; it would not be a general-purpose arithmetic type with a complete set of operations that process it. Like other scalar types, it would have no interaction with any other type (no subtyping/casting/…). It would not require any special hardware support, and would not be expected to be represented in the CPU's flags register. Recall its purpose, which is to annotate a register-sized integer with the fact that its value is guaranteed to be in range 0..1. So engines would be expected to represent it internally as exactly that: a conveniently-sized integer, with range information expressed by its type, which compilers can use to emit better code.

The only instructions required for an i1 type would be those that the loop above uses:

  • i1.const for initializing the local. (Technically, implicit zero-initialization might be enough, but it's probably nice to have an instruction for this.)
  • add_with_carry and its mirror image sub_with_borrow
  • some way to inspect or convert the final carry after the end of the loop, perhaps i64.extend_i1 and/or i1.eqz.

The only places where an i1 type must definitely be allowed to occur is in locals and on the value stack; however since it's just a "conveniently sized integer with attached range information", it is easy to allow it anywhere: function signatures, globals, structs, arrays.

Approach 2: "Special" instruction behavior

A possible alternative approach would be to only use existing types for add_with_carry's signature: [i64, i64, i64] -> [i64, i64], and add extra descriptions to the instruction's specified behavior.

Variant A: "the third input must be in range 0..1, and then the second output will also be in that range; otherwise the behavior is implementation-defined". That would allow engines to emit the optimal code sequence above, and simply not care about the weirdness that will happen if c_in > 1. (You may think "invalid inputs should trap", but as the assembly snippets in this post show, the whole endeavor is about shaving off every single machine instruction that we can get rid of, so adding a check for a trap would be way too costly to be worth it in comparison to the potential gains.)

Variant B: "the third input is treated like a boolean value: any value > 0 is treated as if it were 1. The second output is always in range 0..1". This idea of "treat it as a boolean" has precedent in the if instruction, which takes an i32 but only cares whether it is zero or non-zero. This variant would require a slightly different instruction sequence with distinct carry_in/carry_out registers, replacing the initial shr with an add to perform the "rounding" of any nonzero value to 1, and using an xor to clear carry_out:

xor   carry_out, carry_out
add   carry_in, -1  ;; sets carry flag iff carry_in != 0
mov   result_low, [src1 + 8*index]
adc   result_low, [src2 + 8*index]
setc  carry_out
mov   [dst + 8*index], result_low

While this seems at first like it would require an additional mov to move the value from carry_out to carry_in for the next iteration, this is often not the case in practice due to loop unrolling: for such a small loop, optimizing compilers will typically emit at least two copies of the loop body anyway, and can hence "ping-pong" the carry value between two registers without additional cost.

(You might consider replacing the xor before the setc with a zero-extending movzx after it, which would easily make it possible to use the same register for carry_in and carry_out, but that turns out to be slower due to CPU-internal data dependencies.)

It could make sense to use a different type for the third input just to make it clearer that the three are not interchangeable. The obvious choice for that would be i32, so we'd have [i64, i64, i32] -> [i64, i32] (with the same semantics as before).

Performance

It turns out that the two optimized machine code sequences above (the one with shr for "third input is in 0..1 range, either due to i1 type annotation or due to instruction semantics that ignore anything else", and the one with add -1 for "third input is treated as boolean") have practically the same performance.
In a loop that adds two BigInts (i.e. vectors of u64 digits stored in linear memory), the optimized sequences are consistently 9% faster than the original sequence on my machine (Zen 3). I think that speedup is worth having!
In Alex' i128 benchmarks, the largest impact is on "fib_10000" with around 3% improvement in my measurements. Considering that a Fibonacci benchmark should mostly be performing additions, I'm surprised that the impact is so much less there; I have not spent the time to figure out why that is.

Other thoughts

Adding an i1 type "just for this" might feel like a lot of effort, but considering that it would only need a very small instruction set, I argue that it would, in fact, not be a lot of effort. For engines it would be less implementation work than the add128 version of this proposal. And it could be a good investment in the sense that it could have other uses in the future.

In particular, it would be unfortunate if we accepted a slightly weird workaround now just to get by without an i1 type, only to introduce it next time it's useful. That said, aesthetics of the wire bytes don't really matter, and the approach of treating an i32 as a boolean works just as well to accomplish the performance goals here.

One conceivable future use for i1 is bit packing in struct fields, even denser than i8 fields. Of course the type would be just a hint and an engine's actual behavior unobservable, just like today it is up to engines to represent an i8 as an actual byte, rather than just treating it as an alias for "i32", and just like it is up to engines to reorder a field sequence like [i8, i32, i8] for better density.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions