Building a CAS in OCaml Part 1
2026-04-06 · 13 min
A computer algebra system is exact or it is nothing, and exactness bottoms out in integers that do not overflow. This is the whole bignum layer: representation, addition, two multiplication algorithms, long division, and decimal conversion - in OCaml, with no dependencies, tested against native arithmetic where it fits and against algebraic identities where it does not.
Layout, and how to build it
The snapshot this post produces. Every later part of the series adds to the same lib/ and keeps its own complete copy, so a diff between two parts is exactly what that post changed.
ocaml-cas/part1/├── dune-project├── build.sh├── lib/│ ├── dune│ └── bigint.ml├── bin/│ ├── dune│ └── main.ml└── test/├── dune└── test_bigint.ml
dune-project, lib/dune, bin/dune, test/dune - the library is named cas, so the module below is Cas.Bigint.
(lang dune 3.16)(library(name cas))(executable(name main)(libraries cas))(test(name test_bigint)(libraries cas))
Representation
Sign and magnitude, with the magnitude a little-endian array of limbs in base 2^30 and no leading zero limbs. One representation per value, so structural comparison of magnitudes is meaningful.
Base 2^30 is the interesting choice: a product of two limbs is under 2^60, and an OCaml native int holds up to 2^62 - 1, which leaves room for a carry and an addend without ever reaching for Int64 or boxing.
let base_bits = 30let base = 1 lsl base_bitslet base_mask = base - 1
Strip leading zero limbs so every magnitude is canonical. The identity check avoids a copy in the common case where nothing needed stripping.
let mag_normalize (a : int array) : int array =let n = ref (Array.length a) inwhile !n > 0 && a.(!n - 1) = 0 dodecr ndone;if !n = Array.length a then a else Array.sub a 0 !nlet mag_is_zero (a : int array) : bool = Array.length a = 0
Because magnitudes are canonical, a longer array is always the larger number - only equal lengths need a limb-by-limb walk, from the top down.
let mag_compare (a : int array) (b : int array) : int =let la = Array.length a and lb = Array.length b inif la <> lb then compare la lbelse beginlet rec go i =if i < 0 then 0else if a.(i) <> b.(i) then compare a.(i) b.(i)else go (i - 1)ingo (la - 1)end
Addition and subtraction of magnitudes
Carry propagation, one limb at a time, with the top limb of the result holding the final carry before normalization drops it if it is zero.
let mag_add (a : int array) (b : int array) : int array =let la = Array.length a and lb = Array.length b inlet l = max la lb inlet r = Array.make (l + 1) 0 inlet carry = ref 0 infor i = 0 to l - 1 dolet s = (if i < la then a.(i) else 0) + (if i < lb then b.(i) else 0) + !carry inr.(i) <- s land base_mask;carry := s lsr base_bitsdone;r.(l) <- !carry;mag_normalize r
Subtraction assumes a >= b, which the signed layer guarantees by comparing first and swapping. Borrowing is the mirror of carrying.
(* Requires mag_compare a b >= 0. *)let mag_sub (a : int array) (b : int array) : int array =let la = Array.length a and lb = Array.length b inlet r = Array.make la 0 inlet borrow = ref 0 infor i = 0 to la - 1 dolet d = a.(i) - (if i < lb then b.(i) else 0) - !borrow inif d < 0 then beginr.(i) <- d + base;borrow := 1endelse beginr.(i) <- d;borrow := 0enddone;mag_normalize r
Shifting by whole limbs is multiplication by a power of the base - needed by Karatsuba. Shifting by bits within a limb is needed by division.
let mag_shift_limbs (a : int array) (k : int) : int array =if mag_is_zero a || k = 0 then aelse beginlet n = Array.length a inlet r = Array.make (n + k) 0 inArray.blit a 0 r k n;rend(* 0 <= s < base_bits *)let mag_shift_left_bits (a : int array) (s : int) : int array =if s = 0 || mag_is_zero a then aelse beginlet n = Array.length a inlet r = Array.make (n + 1) 0 inlet carry = ref 0 infor i = 0 to n - 1 dolet t = (a.(i) lsl s) lor !carry inr.(i) <- t land base_mask;carry := t lsr base_bitsdone;r.(n) <- !carry;mag_normalize rend(* 0 <= s < base_bits *)let mag_shift_right_bits (a : int array) (s : int) : int array =if s = 0 || mag_is_zero a then aelse beginlet n = Array.length a inlet r = Array.make n 0 inlet carry = ref 0 infor i = n - 1 downto 0 dolet cur = a.(i) inr.(i) <- (cur lsr s) lor (!carry lsl (base_bits - s));carry := cur land ((1 lsl s) - 1)done;mag_normalize rend
Schoolbook multiplication
The classic double loop, accumulating into the result in place. Skipping a zero multiplier limb is worth the branch on sparse-ish inputs.
let mag_mul_school (a : int array) (b : int array) : int array =let la = Array.length a and lb = Array.length b inif la = 0 || lb = 0 then [||]else beginlet r = Array.make (la + lb) 0 infor i = 0 to la - 1 dolet ai = a.(i) inif ai <> 0 then beginlet carry = ref 0 infor j = 0 to lb - 1 do(* ai * b.(j) < 2^60, plus two addends < 2^31: no overflow. *)let t = (ai * b.(j)) + r.(i + j) + !carry inr.(i + j) <- t land base_mask;carry := t lsr base_bitsdone;let k = ref (i + lb) inwhile !carry <> 0 dolet t = r.(!k) + !carry inr.(!k) <- t land base_mask;carry := t lsr base_bits;incr kdoneenddone;mag_normalize rend
The overflow argument, written out, because it is the reason base 2^30 was picked and the thing that silently breaks if someone raises it to 2^31.
ai <= 2^30 - 1b.(j) <= 2^30 - 1ai*b.(j) <= 2^60 - 2^31 + 1r.(i+j) <= 2^30 - 1carry <= 2^30sum < 2^60 + 2^31 < 2^62 - 1 = max_intat base_bits = 31 the product alone reaches 2^62 and this overflows
Karatsuba
Splitting each operand in half turns one big multiplication into four half-sized ones; the trick is that the middle term can be recovered from a single extra product rather than two, taking it from four to three and the exponent from log2(4) = 2 down to log2(3) ~ 1.585.
The identity, before the code.
a = a1*B^m + a0b = b1*B^m + b0a*b = a1*b1 * B^2m+ (a1*b0 + a0*b1) * B^m+ a0*b0and the middle term is recoverable without computing it directly:a1*b0 + a0*b1 = (a0 + a1)*(b0 + b1) - a0*b0 - a1*b1so three products (a0*b0, a1*b1, (a0+a1)*(b0+b1)) suffice, not four
Recursion bottoming out in the schoolbook version, which wins below roughly thirty limbs where the extra additions and allocations cost more than the saved product.
let karatsuba_threshold = 32let rec mag_mul (a : int array) (b : int array) : int array =let la = Array.length a and lb = Array.length b inif la = 0 || lb = 0 then [||]else if la < karatsuba_threshold || lb < karatsuba_threshold thenmag_mul_school a belse beginlet m = (max la lb + 1) / 2 inlet low x =let n = Array.length x inif n <= m then x else mag_normalize (Array.sub x 0 m)inlet high x =let n = Array.length x inif n <= m then [||] else mag_normalize (Array.sub x m (n - m))inlet a0 = low a and a1 = high a inlet b0 = low b and b1 = high b inlet z0 = mag_mul a0 b0 inlet z2 = mag_mul a1 b1 inlet z1 = mag_sub (mag_sub (mag_mul (mag_add a0 a1) (mag_add b0 b1)) z0) z2 inmag_add (mag_add z0 (mag_shift_limbs z1 m)) (mag_shift_limbs z2 (2 * m))end
Both mag_sub calls are safe because z1 is mathematically non-negative: (a0+a1)(b0+b1) always dominates a0*b0 + a1*b1 for non-negative limbs. That is a real precondition, not an accident, and the test suite checks it by comparing against the schoolbook result on random inputs.
$ ./_out/main bench 8000
Measured on this machine, random operands, both algorithms on the same inputs with the result compared for equality every time.
limbs schoolbook karatsuba agree500 0.000s 0.001s true1000 0.002s 0.002s true2000 0.006s 0.005s true4000 0.024s 0.013s true8000 0.098s 0.035s true16000 0.392s 0.105s true
Division: the short case
Dividing by a single limb is a straight walk from the top down, carrying the remainder. The intermediate stays under 2^60 because the running remainder is always below the divisor, which is below the base.
(* 0 < d < base *)let mag_divmod_small (a : int array) (d : int) : int array * int =let n = Array.length a inlet q = Array.make n 0 inlet r = ref 0 infor i = n - 1 downto 0 dolet cur = (!r * base) + a.(i) inq.(i) <- cur / d;r := cur mod ddone;(mag_normalize q, !r)
Division: Knuth algorithm D
The general case is the one piece of a bignum library that is genuinely hard to get right. Normalize so the divisor's top limb has its high bit set, estimate each quotient limb from the top two limbs of the running remainder, correct the estimate down by at most two, then multiply-and-subtract - with an add-back path for the rare case where the estimate was still one too large.
Normalization is what bounds the error in the estimate: with the divisor's top limb at least base/2, the two-limb estimate is never more than two above the true quotient limb.
let bit_length (x : int) : int =let rec go x acc = if x = 0 then acc else go (x lsr 1) (acc + 1) ingo x 0let mag_divmod (u : int array) (v : int array) : int array * int array =let n = Array.length v inif n = 0 then raise Division_by_zero;if mag_compare u v < 0 then ([||], u)else if n = 1 then beginlet q, r = mag_divmod_small u v.(0) in(q, if r = 0 then [||] else [| r |])endelse beginlet s = base_bits - bit_length v.(n - 1) inlet vn = mag_shift_left_bits v s inlet un0 = mag_shift_left_bits u s inlet vl = Array.length vn inlet m = Array.length un0 - vl in(* One extra high limb so index j + vl is always in range. *)let un = Array.append un0 [| 0 |] inlet q = Array.make (m + 1) 0 in
The estimate, and its correction loop. qhat starts as a two-limb-by-one-limb quotient and is walked down until the next limb of the divisor is consistent with it.
for j = m downto 0 dolet num = (un.(j + vl) * base) + un.(j + vl - 1) inlet qhat = ref (num / vn.(vl - 1)) inlet rhat = ref (num mod vn.(vl - 1)) inlet continue_ = ref true inwhile !continue_ doif !qhat >= base || !qhat * vn.(vl - 2) > (!rhat * base) + un.(j + vl - 2)then begindecr qhat;rhat := !rhat + vn.(vl - 1);if !rhat >= base then continue_ := falseendelse continue_ := falsedone;
Multiply the divisor by the estimate and subtract it from the running remainder in one pass, tracking a multiplication carry and a subtraction borrow simultaneously.
let borrow = ref 0 and carry = ref 0 infor i = 0 to vl - 1 dolet p = (!qhat * vn.(i)) + !carry incarry := p lsr base_bits;let t = un.(i + j) - (p land base_mask) - !borrow inif t < 0 then beginun.(i + j) <- t + base;borrow := 1endelse beginun.(i + j) <- t;borrow := 0enddone;let t = un.(j + vl) - !carry - !borrow inif t < 0 then beginun.(j + vl) <- t + base;borrow := 1endelse beginun.(j + vl) <- t;borrow := 0end;
The add-back path. A borrow out of the top limb means the estimate was one too large after all - give one back by adding the divisor straight back in. This branch is famously rare (roughly two in 2^30 random inputs) which is exactly why it is worth having a randomized test that reaches it.
if !borrow <> 0 then begindecr qhat;let c = ref 0 infor i = 0 to vl - 1 dolet t = un.(i + j) + vn.(i) + !c inun.(i + j) <- t land base_mask;c := t lsr base_bitsdone;un.(j + vl) <- (un.(j + vl) + !c) land base_maskend;q.(j) <- !qhatdone;let rem = mag_normalize (Array.sub un 0 vl) in(mag_normalize q, mag_shift_right_bits rem s)end
The signed layer
Sign as an int rather than a variant, so the sign of a product is a multiplication rather than a match. Zero is the only value with sign 0, and make is the only constructor that can produce it.
type t = {sign : int; (* 1, -1, or 0 when the value is zero *)mag : int array; (* little-endian base-2^30 limbs, no leading zeros *)}let zero = { sign = 0; mag = [||] }let one = { sign = 1; mag = [| 1 |] }let minus_one = { sign = -1; mag = [| 1 |] }let make (sign : int) (mag : int array) : t =let mag = mag_normalize mag inif mag_is_zero mag then zero else { sign; mag }let neg (a : t) : t = if a.sign = 0 then zero else { a with sign = -a.sign }let abs (a : t) : t = if a.sign = 0 then zero else { a with sign = 1 }
of_int, including the one value that needs care: min_int has no positive counterpart, so negating it to get a magnitude would wrap. A logical shift reads the word as unsigned and sidesteps it entirely.
let of_int (n : int) : t =if n = 0 then zeroelse if n > 0 then { sign = 1; mag = mag_of_pos_int n }else if n <> min_int then { sign = -1; mag = mag_of_pos_int (-n) }else{sign = -1;mag =mag_normalize[|n land base_mask;(n lsr base_bits) land base_mask;(n lsr (2 * base_bits)) land base_mask;|];}
Addition dispatches on whether the signs agree; when they differ it is a subtraction of the smaller magnitude from the larger, with the sign of the larger.
let add (a : t) (b : t) : t =if a.sign = 0 then belse if b.sign = 0 then aelse if a.sign = b.sign then { sign = a.sign; mag = mag_add a.mag b.mag }else beginlet c = mag_compare a.mag b.mag inif c = 0 then zeroelse if c > 0 then make a.sign (mag_sub a.mag b.mag)else make b.sign (mag_sub b.mag a.mag)endlet sub (a : t) (b : t) : t = add a (neg b)let mul (a : t) (b : t) : t =if a.sign = 0 || b.sign = 0 then zeroelse make (a.sign * b.sign) (mag_mul a.mag b.mag)
Division truncates toward zero and the remainder takes the sign of the dividend, matching OCaml's own (/) and (mod) rather than a mathematical floor division - the test suite pins this by checking against native ints on every small pair.
let divmod (a : t) (b : t) : t * t =if b.sign = 0 then raise Division_by_zero;if a.sign = 0 then (zero, zero)else beginlet q, r = mag_divmod a.mag b.mag in(make (a.sign * b.sign) q, make a.sign r)endlet div (a : t) (b : t) : t = fst (divmod a b)let rem (a : t) (b : t) : t = snd (divmod a b)let rec gcd (a : t) (b : t) : t =if b.sign = 0 then abs a else gcd b (rem a b)let pow (a : t) (e : int) : t =if e < 0 then invalid_arg "Bigint.pow: negative exponent";let rec go acc b e =if e = 0 then accelse if e land 1 = 1 then go (mul acc b) (mul b b) (e lsr 1)else go acc (mul b b) (e lsr 1)ingo one a e
Decimal conversion
Printing divides out nine decimal digits at a time, since 10^9 is the largest power of ten a single limb division handles. Every chunk after the first is zero-padded, which is where a naive implementation drops digits.
let decimal_chunk = 1_000_000_000let decimal_chunk_digits = 9let to_string (a : t) : string =if a.sign = 0 then "0"else beginlet rec go m acc =if mag_is_zero m then accelselet q, r = mag_divmod_small m decimal_chunk ingo q (r :: acc)inmatch go a.mag [] with| [] -> "0"| first :: rest ->let buf = Buffer.create 32 inif a.sign < 0 then Buffer.add_char buf '-';Buffer.add_string buf (string_of_int first);List.iter(fun c ->Buffer.add_string buf(Printf.sprintf "%0*d" decimal_chunk_digits c))rest;Buffer.contents bufend
Parsing absorbs nine digits at a time for the same reason, so the number of bignum multiplications is a ninth of the digit count. The first chunk is deliberately the short one, so every subsequent chunk is exactly nine digits wide.
let of_string (s : string) : t =let s = String.trim s inlet len = String.length s inif len = 0 then invalid_arg "Bigint.of_string: empty string";let negative, start =match s.[0] with| '-' -> (true, 1)| '+' -> (false, 1)| _ -> (false, 0)inif start >= len then invalid_arg "Bigint.of_string: no digits";let acc = ref zero inlet chunk_mul = of_int decimal_chunk inlet i = ref start inlet first_len =let total = len - start inlet r = total mod decimal_chunk_digits inif r = 0 then decimal_chunk_digits else rinlet read_chunk n =let v = ref 0 infor _ = 1 to n dolet c = s.[!i] inif c < '0' || c > '9' theninvalid_arg (Printf.sprintf "Bigint.of_string: bad character %C" c);v := (!v * 10) + (Char.code c - Char.code '0');incr idone;!vinacc := of_int (read_chunk first_len);while !i < len dolet chunk = read_chunk decimal_chunk_digits inacc := add (mul !acc chunk_mul) (of_int chunk)done;if negative then neg !acc else !acc
Testing something that cannot be checked by eye
Two strategies, because neither alone is enough. Where values fit in a native int, check against native arithmetic directly. Where they do not, check algebraic identities that must hold regardless of magnitude.
Against native ints, exhaustively over a grid of interesting values - including the limb boundary at 2^30 and both extremes of the native range.
let test_divmod_small () =let vals = [ 1; -1; 3; -3; 7; -7; 1000; -1000; 1073741823; -1073741824 ] inlet nums = [ 0; 1; -1; 20; -20; 999999999; -999999999; max_int ] inList.iter(fun a ->List.iter(fun b ->let q, r = divmod (of_int a) (of_int b) incheck_eq (Printf.sprintf "%d / %d" a b) (of_int (a / b)) q;check_eq (Printf.sprintf "%d mod %d" a b) (of_int (a mod b)) r)vals)nums
Beyond native range, identities: a = q*b + r with |r| < |b| is the one that actually catches algorithm D bugs, including the add-back path.
let test_random_division () =Random.init 424242;for _ = 1 to 200 dolet la = 1 + Random.int 40 and lb = 1 + Random.int 20 inlet a = make 1 (random_mag la) inlet b = make 1 (random_mag lb) inif not (is_zero b) then beginlet q, r = divmod a b incheck_eq "random division identity" a (add (mul q b) r);check "random remainder in range"(compare r zero >= 0 && compare r b < 0)enddone
Karatsuba is checked against the algorithm it replaces rather than against expected values - the strongest available oracle, since the schoolbook version is simple enough to trust.
(* Random.bits () yields exactly 30 random bits, which is exactly onelimb in base 2^30. (Random.int base would raise: its bound must bestrictly below 2^30.) *)let random_mag n = Array.init n (fun _ -> Random.bits ())let test_karatsuba_agrees () =Random.init 20260919;for _ = 1 to 50 dolet la = 1 + Random.int 120 and lb = 1 + Random.int 120 inlet a = mag_normalize (random_mag la) inlet b = mag_normalize (random_mag lb) inlet school = mag_mul_school a b inlet karatsuba = mag_mul a b inincr checks;if mag_compare school karatsuba <> 0 then beginincr failures;Printf.printf "FAIL: karatsuba disagrees on %d x %d limbs\n" la lbenddone
A known-answer test worth having, because it exercises hundreds of multiplications and a long decimal conversion at once: 100! has 158 digits, begins 933262154439, and ends in exactly 24 zeros.
let rec fact n acc = if n <= 1 then acc else fact (n - 1) (mul acc (of_int n)) inlet f100 = to_string (fact 100 one) incheck "100! digit count" (String.length f100 = 158);check "100! prefix"(String.length f100 >= 12 && String.sub f100 0 12 = "933262154439");check "100! trailing zeros" (trailing_zeros f100 = 24)
The driver
Enough of a command line to poke at the library by hand.
$ ./_out/main fact 30265252859812191058636308480000000$ ./_out/main pow 2 1001267650600228229401496703205376$ ./_out/main gcd 123456789012345678901234567890 9876543210987654321098765432109000000000900000000090$ ./_out/main div 100000000000000000000000000000000 7quotient 14285714285714285714285714285714remainder 2
Download
The complete snapshot as it stands at the end of this part: bigint.ml, test_bigint.ml, main.ml. Part 2 builds rationals and the expression tree on exactly this module, and keeps its own full copy so the diff between parts is the change that post describes.