DEV Community

Cover image for Native BigInt. Faster than decimal.js
soso
soso

Posted on

Native BigInt. Faster than decimal.js

You've probably seen this one:

0.1 + 0.2; // 0.30000000000000004
Enter fullscreen mode Exit fullscreen mode

The usual answer is "just use decimal.js". And honestly, that's good advice. decimal.js, big.js and bignumber.js are great libraries. But they were all written before JavaScript had BigInt, so under the hood they keep digits in arrays and do every multiplication and division by hand, in JavaScript.

That made me curious. What if a decimal library just handed the hard part to BigInt and let V8 do the work in native code?

So I built bigint-decimal to find out.

import { BigintDecimal } from "bigint-decimal";

new BigintDecimal("0.1").plus("0.2").toString(); // "0.3"

const total = new BigintDecimal("19.99")
  .times(3)        // 3 items
  .times("1.0825") // plus 8.25% tax
  .round(2);       // rounded to cents

total.toString(); // "64.92"
Enter fullscreen mode Exit fullscreen mode

The basic trick

The idea is almost embarrassingly simple. A number like 123.45 is stored as the integer 12345n, plus a note that there are 2 decimal places.

That's it. Adding, subtracting and multiplying are then just normal BigInt math, so they're exact. And they're fast, because V8 already uses clever algorithms for big numbers (Karatsuba for multiplication, Burnikel–Ziegler for division). I didn't have to write any of that.

The fun part: sqrt, exp, log, sin...

Exact arithmetic was the easy bit. I also wanted almost the whole Math object, sqrt, exp, log, sin, atan2, pow and so on, working at any precision you ask for:

BigintDecimal.setPrecisionGlobal(50);
BigintDecimal.PI.toString();
// "3.14159265358979323846264338327950288419716939937511"
Enter fullscreen mode Exit fullscreen mode

This is where I went down a rabbit hole. Most of it came from reading Fredrik Johansson's papers on computing elementary functions, and a few ideas made a huge difference:

  • Inside the math functions, numbers are stored in binary instead of decimal. That sounds like a small detail, but it means rescaling a number is a bit shift instead of a big division.
  • exp(x) gets split into 2^n · exp(r) where r is tiny, and multiplying by 2^n is, again, just a shift.
  • At high precision, the Taylor series use something called rectangular splitting. Instead of one expensive multiplication per term, you need roughly the square root of that.
  • Constants like π are computed with the Chudnovsky series and binary splitting, the same method used for world-record π computations.

Every result is computed with some extra digits and rounded once at the end. sqrt and cbrt are always correctly rounded, even in the annoying case where the true answer lands exactly halfway between two possible results.

OK, but is it actually faster?

I wrote a benchmark comparing it with decimal.js, bignumber.js and big.js. Every library computes the same thing to the same number of digits, and each time is an average over 16 different inputs (Node 24, Ryzen 9 7940HS).

At 20 digits, which is what most apps need:

Test Speed-up bigint-decimal decimal.js bignumber.js big.js
Basic arithmetic 1.4× 1.11 µs 1.58 µs 3.09 µs 3.13 µs
sqrt 6× 1.02 µs 6.37 µs 6.08 µs 43 µs
exp 10× 2.88 µs 29 µs — —
log 14× 1.85 µs 25 µs — —
atan 45× 3.65 µs 163 µs — —

At 1000 digits, the gap gets kind of ridiculous:

Test Speed-up bigint-decimal decimal.js bignumber.js big.js
Basic arithmetic 1.4× 3.85 µs 5.32 µs 15 µs 68 µs
sqrt 30× 64 µs 2.6 ms 1.9 ms 195 ms
exp 169× 224 µs 37.8 ms — —
log 586× 215 µs 126 ms — —
atan 2826× 734 µs 2074 ms — —

("—" means that library doesn't have the function.)

Before anyone asks: no, it's not faster because it cuts corners. At 1000 digits, both libraries give correctly rounded results. I checked. And decimal.js actually can't go much higher for log and pow. Somewhere around 1025 digits it throws Precision limit exceeded, because its ln(10) constant is a fixed 1025-digit string.

The benchmark is in the repo, so please don't just take my word for it. Run npm run bench and see what you get.

Making sure it's right

Being fast and wrong is easy, so honestly most of my time went into testing:

  • About 300 results, from 20 up to 2000 digits, are compared digit by digit with Python's decimal module. That includes the nasty cases: asin right next to 1, tan right next to π/2, sin(1e300).
  • Arithmetic and rounding on random huge numbers are checked against plain BigInt math.
  • I also tried mutation testing. I deliberately broke the code in 60 different ways to see if the tests would notice. They caught every bug that could change a result.

And it paid off. The tests found real bugs before release. asin was losing precision right next to ±1, exact halfway results were rounding the wrong way, and tan was throwing an error near π/2 where the real answer is just a very big number.

What it doesn't do (yet)

I don't want to oversell this:

  • Precision means decimal places, not significant digits. So really tiny results round to 0.
  • div cuts off extra digits instead of rounding them.
  • There's only one rounding mode, and no formatting helpers like toFixed. If you need those, decimal.js is still the more complete library.

Try it

npm install bigint-decimal
Enter fullscreen mode Exit fullscreen mode

Everything's on GitHub: code, docs and the benchmark. I'd really love feedback, especially if you need high-precision math in JavaScript, or you know a trick I missed.

Top comments (2)

Collapse
 
florian131313 profile image
G •

The 10x to 2800x in the title runs from exp at 20 digits to atan at 1000 digits. Basic arithmetic stays at 1.4x in both tables. That is the row the 19.99 example actually hits.

Collapse
 
generalsimus profile image
soso •

It’s just clickbait, and it’s fixed 🫠