BigInt: How V8 Implemented BigIntegers

A BigInt in V8 is a sign bit plus a vector of 64-bit digits, allocated as a single variable-length heap object. Multiplication switches to Karatsuba at 34 digits and Toom-Cook at 210.

TL;DR: A BigInt is a PrimitiveHeapObject with a 32-bit header (sign + digit count) and a flexible array of uintptr_t digits, little endian, with the invariant that the top digit is never zero. Arithmetic is implemented in src/bigint/ as a standalone library with schoolbook, Karatsuba, Toom-Cook, Burnikel-Ziegler and Barrett algorithms selected by digit count. In the JIT, a BigInt that fits in 64 bits can stay unboxed as a Word64 - that is the BigInt64 fast path - but the general case always allocates.

BigInt was added to JavaScript in 2020, and it was the first new primitive type since Symbol. Adding a primitive type to a language with a JIT compiler is not a small job: the value needs a representation, the arithmetic needs algorithms, the compiler needs a type, and the collector needs to know how to move the thing around.

V8’s BigInt is interesting because it is not implemented like Number. Numbers are unboxed doubles (or Smis) in optimized code. BigInts are always heap objects, and the only thing the JIT can do is keep small ones in a machine register.

The representation: sign plus digits

A BigInt in V8 is a PrimitiveHeapObject - an object, but one of the primitive kinds, so it lives in the heap but is not a JSReceiver:

torque
// src/objects/bigint.tq:8
extern class BigIntBase extends PrimitiveHeapObject
    generates 'BigInt';

type BigInt extends BigIntBase;

type MutableBigInt extends BigIntBase;

Convert<BigInt, MutableBigInt>(i: MutableBigInt): BigInt {
  dcheck(bigint::IsCanonicalized(i));
  return %RawDownCast<BigInt>(Convert<BigIntBase>(i));

The MutableBigInt / BigInt split is a type-level distinction enforced by the C++ type system, and it exists for one reason: a BigInt that is being computed is allowed to have leading zero digits and to have the sign of zero set, and a BigInt that is finished is not. The conversion is the point where the invariant is enforced:

torque
// src/objects/bigint.tq:15
Convert<BigInt, MutableBigInt>(i: MutableBigInt): BigInt {
  dcheck(bigint::IsCanonicalized(i));
  return %RawDownCast<BigInt>(Convert<BigIntBase>(i));
}

A dcheck - so in a debug build, converting a non-canonical BigInt is caught, and in a release build it is your problem.

The C++ class is where the layout lives:

cpp
// src/objects/bigint.h:156
  // sign() == true means negative.
  inline bool sign() const {
    return SignBits::decode(bitfield_.load(std::memory_order_relaxed));
  }

  inline digit_t digit(uint32_t n) const {
    SLOW_DCHECK(n < length());
    return raw_digits()[n].value();
  }

  bool is_zero() const { return length() == 0; }

  std::atomic_uint32_t bitfield_;
#ifdef BIGINT_NEEDS_PADDING
  char padding_[4];
#endif
  FLEXIBLE_ARRAY_MEMBER(UnalignedValueMember<digit_t>, raw_digits);

Three things to notice.

The digits are a flexible array member. The BigInt object is a header plus the digits, allocated in one go. There is no separate array object, no pointer chase, and the digit count is known from the header. This is why a BigInt is a PrimitiveHeapObject with a variable size.

The digit type is uintptr_t. On a 64-bit platform that is 64 bits; on a 32-bit platform, 32 bits. The library in src/bigint/ is written against digit_t and adapts.

The header is 32 bits and atomic. The sign and the length live in the same word, and the comment explains why:

cpp
// src/objects/bigint.h:113
  // Sign and length are stored in the same bitfield.  Since the GC needs to be
  // able to read the length concurrently, the getters and setters are atomic.
  // We intentionally use all available bits, so that decoding the length
  // field is just a "shr" instruction (and needs no bit mask).
  // As a safeguard against malicious heap corruption, we limit the length
  // field to the minimum size needed (24 on 64-bit, 25 on 32-bit). This

“Decoding the length field is just a shr instruction” - that is a real design constraint. The GC has to read the length of a BigInt to know how big it is, and it has to do it while the mutator might be running. So the length is at the top of the word and the sign is at the bottom, and the decoding is a shift rather than a shift-and-mask.

And the length is deliberately not the full remaining 31 bits. The comment says why: “prevents overflow bugs when adding two BigInt lengths”. A BigInt can be at most about a billion bits, and the length limit keeps length_a + length_b from wrapping:

cpp
// src/objects/bigint.h:107
  static const uint32_t kMaxBitsBits = 30;
  static const uint32_t kMaxLength =
      ((1 << kMaxBitsBits) - 1) / (kSystemPointerSize * kBitsPerByte);
  static const uint32_t kMaxBits =
      kMaxLength * kSystemPointerSize * kBitsPerByte;  // ~1 billion.

Little endian, with a non-zero top digit. The digits are stored least significant first, and the canonical form has no leading zero digits. That invariant is what makes length() == 0 mean “zero” and what lets the comparison operators work by walking from the top down.

The canonicalisation happens in one place:

cpp
// src/objects/bigint.cc:317
void MutableBigInt::CanonicalizeSlow(Tagged<MutableBigInt> result,
                                     uint32_t old_length) {
  uint32_t new_length = old_length;
  if (new_length > 0) [[likely]] {
    DCHECK_EQ(0, result->digit(new_length - 1));
    new_length--;
    while (new_length > 0 && result->digit(new_length - 1) == 0) new_length--;
  }
  uint32_t to_trim = old_length - new_length;
  if (to_trim != 0) {
    Heap* heap = Isolate::Current()->heap();
    if (!HeapLayout::InAnyLargeSpace(result)) {
      uint32_t old_size =
          ALIGN_TO_ALLOCATION_ALIGNMENT(BigInt::SizeFor(old_length));
      uint32_t new_size =
          ALIGN_TO_ALLOCATION_ALIGNMENT(BigInt::SizeFor(new_length));
      heap->NotifyObjectSizeChange(result, old_size, new_size,
                                   ClearRecordedSlots{false});
    }
    result->set_length(new_length, kReleaseStore);

    // Canonicalize -0n.
    if (new_length == 0) {
      result->set_sign(false);
      // TODO(jkummerow): If we cache a canonical 0n, return that here.
    }
  }
  DCHECK_IMPLIES(result->length() > 0,
                 result->digit(result->length() - 1) != 0);  // MSD is non-zero.
  // Callers that don't require trimming must ensure this themselves.
  DCHECK_IMPLIES(result->length() == 0, result->sign() == false);

Three details worth pausing on. First, the trim happens after the allocation, so the heap has to be told the object shrank - that is the NotifyObjectSizeChange call, which adjusts the page’s accounting.

Second, -0n is canonicalised to 0n by clearing the sign. This is JavaScript semantics, not bigint semantics: -0n === 0n is true.

Third, the TODO at the end: V8 does not cache a canonical zero BigInt, so every 0n in your program is a separate heap object. That is a real, if minor, allocation cost.

Allocating one

A BigInt is allocated with AllocateRawWithImmortalMap, which is the “raw” allocator - the object has a variable size, so it cannot go through the normal Allocate path:

cpp
// src/heap/factory-base.cc:1105
template <typename Impl>
Handle<FreshlyAllocatedBigInt> FactoryBase<Impl>::NewBigInt(
    uint32_t length, AllocationType allocation) {
  if (length > BigInt::kMaxLength) {
    base::FatalNoSecurityImpact("Fatal JavaScript invalid size error %d",
                                length);
    UNREACHABLE();
  }
  Tagged<HeapObject> result = AllocateRawWithImmortalMap(
      BigInt::SizeFor(length), allocation, read_only_roots().bigint_map());
  DisallowGarbageCollection no_gc;
  Tagged<FreshlyAllocatedBigInt> bigint = Cast<FreshlyAllocatedBigInt>(result);
  bigint->clear_padding();
  return handle(bigint, isolate());

SizeFor is just the header plus the digits:

cpp
// src/objects/bigint.h:276
  inline static uint32_t SizeFor(uint32_t length) {
    return sizeof(BigInt) + length * kDigitSize;
  }

and the maximum length is derived from the bitfield budget:

cpp
// src/objects/bigint.h:107
  static const uint32_t kMaxBitsBits = 30;
  static const uint32_t kMaxLength =
      ((1 << kMaxBitsBits) - 1) / (kSystemPointerSize * kBitsPerByte);
  static const uint32_t kMaxBits =
      kMaxLength * kSystemPointerSize * kBitsPerByte;  // ~1 billion.

A billion bits, so about 125 megabytes. “Somewhat arbitrarily chosen”, as the comment says.

The FreshlyAllocatedBigInt type is the returned handle type, and the comment explains the cast restrictions it enforces:

cpp
// src/objects/bigint.h:175
V8_OBJECT class FreshlyAllocatedBigInt : public BigIntBase {
  V8_IT_REUSE_PARENT;
  // This class is essentially the publicly accessible abstract version of
  // MutableBigInt (which is a hidden implementation detail). It serves as
  // the return type of Factory::NewBigInt, and makes it possible to enforce
  // casting restrictions:
  // - FreshlyAllocatedBigInt can be cast explicitly to MutableBigInt
  //   (with MutableBigInt::Cast) for initialization.
  // - MutableBigInt can be cast/converted explicitly to BigInt
  //   (with MutableBigInt::MakeImmutable); is afterwards treated as readonly.
  // - No accidental implicit casting is possible from BigInt to MutableBigInt

So the type system enforces the invariant that you cannot mutate a BigInt that has already been published.

The digit library

The arithmetic lives in src/bigint/, and it is a standalone library - it does not know anything about the V8 heap, it just operates on arrays of digit_t. The interface is documented in the header:

cpp
// src/bigint/bigint.h:240
//
// The operations are divided into two groups: "fast" (O(n) with small
// coefficient) operations are exposed directly as free functions, "slow"
// operations are split into a free function handling simple cases and
// a method on a {Processor} object for larger cases, which provides
// support for interrupting execution via the {Platform}'s {InterruptRequested}
// mechanism when it takes too long. These functions return a {Status} value.

// Returns r such that r < 0 if A < B; r > 0 if A > B; r == 0 if A == B.
inline int Compare(Digits A, Digits B);

// Z := X + Y. Returns Z's top digit.

Note the Processor and Platform split: the algorithms are pure, but the memory comes from a pluggable allocator, and long-running operations can be interrupted. That matters, because a BigInt multiplication of two million-bit numbers is a long-running operation that must not be uninterruptible.

The digit type is chosen by the platform:

cpp
// src/bigint/bigint.h:41
static constexpr int kLog2DigitBits = 5;
#elif UINTPTR_MAX == 0xFFFFFFFFFFFFFFFF
// 64-bit platform.
static constexpr int kLog2DigitBits = 6;
#if defined(__SIZEOF_INT128__)
using twodigit_t = __uint128_t;
#define HAVE_TWODIGIT_T 1
#endif  // defined(__SIZEOF_INT128__)
#else
#error Unsupported platform.
#endif
static constexpr int kDigitBits = 1 << kLog2DigitBits;
static_assert(kDigitBits == 8 * sizeof(digit_t), "inconsistent type sizes");

// Describes an array of digits, also known as a BigInt. Unsigned.

On 64-bit with __int128 support - which is every modern platform - the “two digit” type is __uint128_t, so a 64x64 multiply is one instruction. On a 32-bit platform, or on 64-bit without __int128, the multiply has to be done by hand:

cpp
// src/bigint/bigint-inl.h:464
// Returns the low half of the result. High half is in {high}.
inline digit_t digit_mul(digit_t a, digit_t b, digit_t* high) {
#if HAVE_TWODIGIT_T
  twodigit_t result = twodigit_t{a} * b;
  *high = result >> kDigitBits;
  return static_cast<digit_t>(result);
#else
  // Multiply in half-pointer-sized chunks.
  // For inputs [AH AL]*[BH BL], the result is:
  //
  //            [AL*BL]  // r_low
  //    +    [AL*BH]     // r_mid1
  //    +    [AH*BL]     // r_mid2
  //    + [AH*BH]        // r_high
  //    = [R4 R3 R2 R1]  // high = [R4 R3], low = [R2 R1]
  //
  // Where of course we must be careful with carries between the columns.
  digit_t a_low = a & kHalfDigitMask;
  digit_t a_high = a >> kHalfDigitBits;
  digit_t b_low = b & kHalfDigitMask;
  digit_t b_high = b >> kHalfDigitBits;

  digit_t r_low = a_low * b_low;
  digit_t r_mid1 = a_low * b_high;
  digit_t r_mid2 = a_high * b_low;
  digit_t r_high = a_high * b_high;

The twodigit_t typedef is the whole story of 32-bit versus 64-bit performance, and it is why V8 supports both: the library is portable, but the constant factors differ by a lot.

Multiplication: four algorithms

The dispatch on size is in ProcessorImpl::MultiplyLarge:

cpp
// src/bigint/bigint-internal.cc:50
  DCHECK(X.len() >= Y.len());
  CHECK(X.len() <= kMaxNumDigits);
#if !V8_ADVANCED_BIGINT_ALGORITHMS
  return MultiplyKaratsuba(Z, X, Y);
#else
  if (Y.len() < config::kToomThreshold) return MultiplyKaratsuba(Z, X, Y);
  if (Y.len() < config::kFftThreshold) return MultiplyToomCook(Z, X, Y);
  return MultiplyFFT(Z, X, Y);
#endif
}

Status Processor::MultiplyLarge(RWDigits& Z, Digits& X, Digits& Y) {
  ProcessorImpl* impl = static_cast<ProcessorImpl*>(this);

and the thresholds are constants with comments explaining why they are what they are:

cpp
// src/bigint/bigint-inl.h:48

// The thresholds between schoolbook algorithms and the next best alternative
// are very clear-cut on all platforms.
constexpr uint32_t kKaratsubaThreshold = 34;
constexpr uint32_t kBurnikelThreshold = 57;
constexpr uint32_t kNewtonInversionThreshold = 25;

// On x64, Toom and Karatsuba have very similar performance between about
// 200-400 digits; on ia32, Toom tends to win for more than about 200.

34 digits is 2176 bits on a 64-bit platform. Below that, schoolbook multiplication - the naive O(n^2) loop - wins, because Karatsuba’s constant factor is worse and the crossover point is high.

The schoolbook multiply itself is a tight loop over digit_mul:

cpp
// src/bigint/bigint-inl.h:754
inline digit_t MultiplySingle(RWDigits Z, Digits X, digit_t y) {
  DCHECK(y != 0);
  digit_t carry = 0;
  digit_t high = 0;
  for (uint32_t i = 0; i < X.len(); i++) {
    digit_t new_high;
    digit_t low = digit_mul(X[i], y, &new_high);
    Z[i] = digit_add3(low, high, carry, &carry);
    high = new_high;
  }
  digit_t top = carry + high;
  Z[X.len()] = top;
  for (uint32_t i = X.len() + 1; i < Z.len(); i++) [[unlikely]] {
    Z[i] = top = 0;
  }
  return top;
}

The high / carry split is the classic “accumulate the high halves separately” trick that keeps the inner loop free of 128-bit adds.

And the smallest case - multiplying by a single digit - is special-cased before anything else:

cpp
// src/bigint/bigint-internal.cc:32
  if (X.len() == 0 || Y.len() == 0) return Z.Clear();
  if (X.len() < Y.len()) std::swap(X, Y);
  if (Y.len() == 1) {
    MultiplySingle(Z, X, Y[0]);
    AddWorkEstimate(X.len());
    return;
  }
  if (Y.len() < config::kKaratsubaThreshold) {
    MultiplySchoolbook(Z, X, Y);
    AddWorkEstimate(X.len() * Y.len());
    return;
  }
  return MultiplyLarge(Z, X, Y);
}

void ProcessorImpl::MultiplyLarge(RWDigits& Z, Digits& X, Digits& Y) {
  DCHECK(IsDigitNormalized(X));
  DCHECK(IsDigitNormalized(Y));
  DCHECK(X.len() >= Y.len());
  CHECK(X.len() <= kMaxNumDigits);

Note AddWorkEstimate: the multiplication accounts for its own cost, so the interruption mechanism knows when to check for a termination request.

Division: Burnikel-Ziegler and Barrett

Division is harder, and V8 has three algorithms for it. The dispatch:

cpp
// src/bigint/bigint-internal.cc:91
Status Processor::ModuloLarge(RWDigits& R, Digits& A, Digits& B) {
  ProcessorImpl* impl = static_cast<ProcessorImpl*>(this);
  DCHECK(IsDigitNormalized(A));
  DCHECK(IsDigitNormalized(B));
  DCHECK(B.len() > 1);
  if (B.len() < config::kBurnikelThreshold) {
    RWDigits Q(nullptr, 0);
    impl->DivideSchoolbook(Q, R, A, B);
    return impl->get_and_clear_status();
  }
  CHECK(A.len() < kMaxNumDigits);
  uint32_t q_len = DivideResultLength(A, B);
  ScratchDigits Q(q_len, platform());
#if !V8_ADVANCED_BIGINT_ALGORITHMS

and, for the “advanced algorithms” build:

cpp
// src/bigint/bigint-internal.cc:101
  CHECK(A.len() < kMaxNumDigits);
  uint32_t q_len = DivideResultLength(A, B);
  ScratchDigits Q(q_len, platform());
#if !V8_ADVANCED_BIGINT_ALGORITHMS
  impl->DivideBurnikelZiegler(Q, R, A, B);
#else
  if (B.len() < config::kBarrettThreshold || A.len() == B.len()) {
    impl->DivideBurnikelZiegler(Q, R, A, B);
  } else {
    impl->DivideBarrett(Q, R, A, B);
  }
#endif

The thresholds differ between 32-bit and 64-bit platforms, and the comments say why:

cpp
// src/bigint/bigint-inl.h:58
#if UINTPTR_MAX == 0xFFFFFFFF
// 32-bit platform.
constexpr uint32_t kFftThreshold = 1100;
constexpr uint32_t kFftInnerThreshold = 200;

// Burnikel and Barrett take turns at winning between 10K-20K digits.
// From 22K upwards, Barrett is clear winner.
constexpr uint32_t kBarrettThreshold = 22000;

constexpr uint32_t kToStringFastThreshold = 38;
constexpr uint32_t kFromStringLargeThreshold = 165;

Schoolbook division is Knuth’s algorithm D, and V8’s implementation follows the book so closely that the variable names are Knuth’s:

cpp
// src/bigint/div-schoolbook.cc:123
void ProcessorImpl::DivideSchoolbook(RWDigits& Q, RWDigits& R, Digits& A,
                                     Digits& B) {
  DCHECK(B.len() >= 2);        // Use DivideSingle otherwise.
  DCHECK(A.len() >= B.len());  // No-op otherwise.
  DCHECK(Q.len() == 0 || QLengthOK(Q, A, B));
  DCHECK(R.len() == 0 || R.len() >= B.len());
  // The unusual variable names inside this function are consistent with
  // Knuth's book, as well as with Go's implementation of this algorithm.
  // Maintaining this consistency is probably more useful than trying to
  // come up with more descriptive names for them.
  const uint32_t n = B.len();
  const uint32_t m = A.len() - n;

The digit division itself is the interesting bit, because a 128-by-64 division has no single machine instruction on most architectures. V8’s solution is a multiplicative inverse, precomputed once per divisor:

cpp
// src/bigint/div-schoolbook.cc:43
// {DivideSchoolbook} below performs 2-by-1 digit divisions. On arm/arm64, there
// is no matching machine instruction for that, and the fallback is slow.
// Fortunately, the divisor is always the same, so a faster alternative is to
// precompute its modular multiplicative inverse, and then replace the division
// with a multiplication-based instruction sequence.
// On x64 this is approximately as fast as using {digit_div} directly.
// Note: this relies on {divisor} being left-shifted such that its most
// significant bit is set, making this technique difficult to apply to
// {DivideSingle}.

and the division becomes a multiply, an add, and a conditional correction:

cpp
// src/bigint/div-schoolbook.cc:61
  digit_t div(digit_t high, digit_t low, digit_t* remainder) {
    // Paper: u1 = high, u0 = low, d = divisor, v = inverse,
    // "mod β" means "truncate to digit_t width".
    // 1. <q1, q0> ← v*u1
    digit_t q1;
    digit_t q0 = digit_mul(high, inverse_, &q1);
    // 2. <q1, q0> ← <q1, q0> + <u1, u0>
    digit_t carry;
    q0 = digit_add2(q0, low, &carry);
    q1 = digit_add3(q1, high, carry, &carry);
    // 3. q1 ← (q1 + 1) mod β
    q1++;
    // 4. r ← (u0 − q1*d) mod β
    digit_t r = low - q1 * divisor_;
    // 5. if r > q0:  // Unpredictable condition
    if (r > q0) {
      // 6. q1 ← (q1 − 1) mod β
      q1--;
      // 7. r ← (r + d) mod β
      r += divisor_;
    }
    // 8 if r >= d:  // Unlikely condition
    if (r >= divisor_) [[unlikely]] {
      // 9. q1 ← q1 + 1

This is the classic Granlund-Montgomery division trick, and the comment labels the branch “Unpredictable condition” - it is rare, but it is what makes the result correct.

Interruption

The Processor has a work_estimate_ counter and a threshold:

cpp
// src/bigint/bigint-internal.h:66
  // enough not to appear stuck, rarely enough not to cause noticeable
  // overhead).
  static const uintptr_t kWorkEstimateThreshold = 5000000;

  void AddWorkEstimate(uintptr_t estimate) {
    work_estimate_ += estimate;
    if (work_estimate_ >= kWorkEstimateThreshold) {
      work_estimate_ = 0;
      if (platform_->InterruptRequested()) {
        status_ = Status::kInterrupted;
      }

Five million “mul-equivalents” between checks. At a billion multiplies per second that is a few milliseconds - so a huge BigInt multiplication is checked for a termination request every few milliseconds, and can be abandoned.

The JIT: BigInt64 and the general case

Here is where the performance cliff is.

TurboFan’s type lattice has dedicated BigInt types:

cpp
// src/compiler/turbofan-types.h:171
  V(SignedBigInt64,               kUnsignedBigInt63 | kNegativeBigInt63) \
  V(UnsignedBigInt64,             kUnsignedBigInt63 | kOtherUnsignedBigInt64) \
  V(BigInt,                       kSignedBigInt64 | kOtherUnsignedBigInt64 | \
                                  kOtherBigInt) \
  V(Numeric,                      kNumber | kBigInt) \

The BigInt64 type means “a BigInt whose value fits in a signed 64-bit integer”. The check for it is done at runtime in generated code, and it is not cheap:

cpp
// src/compiler/turboshaft/machine-lowering-reducer-inl.h:326
          // We have to perform check for BigInt64 range.
          V<Word32> bitfield = __ template LoadField<Word32>(
              input, AccessBuilder::ForBigIntBitfield());
          GOTO_IF(__ Word32Equal(bitfield, 0), done, 1);

          // Length must be 1.
          V<Word32> length_field =
              __ Word32BitwiseAnd(bitfield, BigInt::LengthBits::kMask);
          GOTO_IF_NOT(__ Word32Equal(length_field,
                                     uint32_t{1} << BigInt::LengthBits::kShift),
                      done, 0);

          // Check if it fits in 64 bit signed int.
          V<Word64> lsd = __ template LoadField<Word64>(
              input, AccessBuilder::ForBigIntLeastSignificantDigit64());

Read the comments: length must be exactly 1, the magnitude must fit in int64_t, and then there is a special case for int64_t::min:

cpp
// src/compiler/turboshaft/machine-lowering-reducer-inl.h:345
          // The BigInt probably doesn't fit into signed int64. The only
          // exception is int64_t::min. We check for this.
          V<Word32> sign =
              __ Word32BitwiseAnd(bitfield, BigInt::SignBits::kMask);
          V<Word32> sign_check = __ Word32Equal(sign, BigInt::SignBits::kMask);
          GOTO_IF_NOT(sign_check, done, 0);

          V<Word32> min_check =
              __ Word64Equal(lsd, std::numeric_limits<int64_t>::min());
          GOTO_IF(min_check, done, 1);

This is the cost of the fast path: several branches and a load, every time, just to find out whether you can take the fast path.

And note what the type system says about the result. ToBigIntConvertNumber

  • the operation that turns a Number into a BigInt - refines the type based on the input’s range:
cpp
// src/compiler/operation-typer.cc:322
Type OperationTyper::ToBigIntConvertNumber(Type type) {
  if (type.Is(Type::Unsigned32OrMinusZero())) {
    return Type::UnsignedBigInt63();
  } else if (type.Is(Type::Signed32OrMinusZero())) {
    return Type::SignedBigInt64();
  }

  bool maybe_number =

UnsignedBigInt63 is the analogue of a Smi for BigInts: a value that fits in 63 bits unsigned. It is a subset of UnsignedBigInt64, which is a subset of SignedBigInt64, which is a subset of BigInt.

So the type lattice has a BigInt equivalent of the Smi/HeapNumber split, and it is used for exactly one thing: knowing whether the value can be represented in a machine register.

The conversion operator

The lowering that turns a tagged BigInt into a Word64 is TruncateBigIntToWord64:

cpp
// src/compiler/turboshaft/assembler.h:2350
  V<Word64> TruncateBigIntToWord64(V<BigInt> bigint) {
    DCHECK(Is64());
    return V<Word64>::Cast(TruncateJSPrimitiveToUntagged(
        bigint, TruncateJSPrimitiveToUntaggedOp::UntaggedKind::kInt64,
        TruncateJSPrimitiveToUntaggedOp::InputAssumptions::kBigInt));
  }

and the reverse, ConvertInt64ToBigInt, is where a BigInt gets created from a machine integer:

cpp
// src/compiler/turboshaft/assembler.h:2270
  V<BigInt> ConvertInt64ToBigInt(V<Word64> input) {
    DCHECK(Is64());
    return V<BigInt>::Cast(ConvertUntaggedToJSPrimitive(
        input, ConvertUntaggedToJSPrimitiveOp::JSPrimitiveKind::kBigInt,
        RegisterRepresentation::Word64(),
        ConvertUntaggedToJSPrimitiveOp::InputInterpretation::kSigned,
        CheckForMinusZeroMode::kCheckForMinusZero));
  }

This allocates. Every ConvertInt64ToBigInt is a heap allocation of a one-digit BigInt. So the “fast path” is: check the BigInt fits in 64 bits (cost: branches), do the arithmetic in a register (fast), then allocate a BigInt for the result (cost: a GC allocation).

That is the performance cliff. A BigInt addition in optimized code is not an add; it is a check, an add, and an allocation.

The general case

When the values do not fit in 64 bits, or the feedback says so, the operation goes to a builtin that calls into the C++ library. TurboShaft emits a call to BigIntAddNoThrow and friends:

cpp
// src/compiler/turboshaft/machine-lowering-reducer-inl.h:4734
      case BigIntBinopOp::Kind::kAdd:
        return Builtin::kBigIntAddNoThrow;
      case BigIntBinopOp::Kind::kSub:
        return Builtin::kBigIntSubtractNoThrow;
      case BigIntBinopOp::Kind::kMul:
        return Builtin::kBigIntMultiplyNoThrow;
      case BigIntBinopOp::Kind::kDiv:
        return Builtin::kBigIntDivideNoThrow;
      case BigIntBinopOp::Kind::kMod:
        return Builtin::kBigIntModulusNoThrow;
      case BigIntBinopOp::Kind::kBitwiseAnd:
        return Builtin::kBigIntBitwiseAndNoThrow;
      case BigIntBinopOp::Kind::kBitwiseOr:
        return Builtin::kBigIntBitwiseOrNoThrow;

and those builtins are Torque functions that dispatch into the C++ implementation. The Torque-level BigIntAdd handles the sign logic:

torque
// src/builtins/builtins-bigint.tq:175
macro BigIntAddImpl(implicit context: Context)(x: BigInt, y: BigInt): BigInt
    labels BigIntTooBig {
  const xsign = ReadBigIntSign(x);
  const ysign = ReadBigIntSign(y);
  if (xsign == ysign) {
    // x + y == x + y
    // -x + -y == -(x + y)
    return MutableBigIntAbsoluteAdd(x, y, xsign) otherwise BigIntTooBig;
  }

  // x + -y == x - y == -(y - x)
  // -x + y == y - x == -(x - y)
  if (BigIntCompareAbsolute(x, y) >= 0) {
    return MutableBigIntAbsoluteSub(x, y, xsign);
  }
  return MutableBigIntAbsoluteSub(y, x, InvertSign(xsign));
}

The “NoThrow” variant exists so the JIT can call it without a deopt-on-throw check. It signals the BigIntTooBig condition with a Smi sentinel:

torque
// src/builtins/builtins-bigint.tq:193
builtin BigIntAddNoThrow(implicit context: Context)(x: BigInt, y: BigInt):
    BigInt|Smi {
  try {
    return BigIntAddImpl(x, y) otherwise BigIntTooBig;
  } label BigIntTooBig {
    // Smi sentinal is used to signal BigIntTooBig exception.
    return Convert<Smi>(0);
  }
}

A Smi zero as the sentinel - which is why the return type is BigInt|Smi. Smi zero is not a BigInt, so there is no ambiguity.

Maglev’s position

Maglev, the mid-tier compiler, is less aggressive here. It has the same kBigInt64 feedback hint, but it does not specialise on it:

cpp
// src/maglev/maglev-graph-builder.cc:2095
      break;
    case BinaryOperationHint::kBigInt:
    case BinaryOperationHint::kBigInt64:
      // TODO(jgruber): Specialize kBigInt64 with Int64 arithmetic instead of
      // sharing the arbitrary-precision BigInt path.
      if constexpr (BigIntBinaryOperationHasBuiltin(kOperation)) {
        ValueNode* left = LoadRegister(0);
        ValueNode* right = GetAccumulator();
        RETURN_IF_ABORT(BuildCheckBigInt(left));
        RETURN_IF_ABORT(BuildCheckBigInt(right));
        return SetAccumulator(

The TODO is honest: Maglev does not have the BigInt64 fast path, so a BigInt-heavy program that gets to Maglev but not TurboFan gets the slow path through the whole builtin.

And the same TODO appears for comparisons:

cpp
// src/maglev/maglev-graph-builder.cc:2530
    }
    case CompareOperationHint::kBigInt64:
    case CompareOperationHint::kBigInt: {
      // TODO(jgruber): Specialize kBigInt64 with Int64 comparisons instead of

So the “BigInt64” specialisation is a TurboFan-only feature, and even there it is a check-and-branch rather than a true unboxed representation.

Where the “Smi path” actually is

To be precise about this: BigInt has no Smi representation in the sense that Number does. A BigInt is always a heap object. What exists is the UnsignedBigInt63 type, which tells the compiler that the value fits in 63 bits - and the BigInt64 fast path, which lets the arithmetic happen in a machine register.

The conversion back to a Number does have a Smi path, though:

cpp
// src/objects/bigint.cc:1091
DirectHandle<Number> BigInt::ToNumber(Isolate* isolate,
                                      DirectHandle<BigInt> x) {
  if (x->is_zero()) return direct_handle(Smi::zero(), isolate);
  if (x->length() == 1 && x->digit(0) < Smi::kMaxValue) {
    int value = static_cast<int>(x->digit(0));
    if (x->sign()) value = -value;
    return direct_handle(Smi::FromInt(value), isolate);
  }
  double result = MutableBigInt::ToDouble(x);
  return isolate->factory()->NewHeapNumber(result);
}

A one-digit BigInt that fits in a Smi becomes a Smi; anything else becomes a HeapNumber. And the conversion to a double is careful about the bit layout:

cpp
// src/objects/bigint.cc:1103
double MutableBigInt::ToDouble(DirectHandle<BigIntBase> x) {
  if (x->is_zero()) return 0.0;
  uint32_t x_length = x->length();
  digit_t x_msd = x->digit(x_length - 1);
  uint32_t msd_leading_zeros = base::bits::CountLeadingZeros(x_msd);
  uint32_t x_bitlength = x_length * kDigitBits - msd_leading_zeros;
  if (x_bitlength > 1024) return x->sign() ? -V8_INFINITY : V8_INFINITY;

A BigInt with more than 1024 bits cannot be represented as a double, so it becomes infinity. That is lossy, and it is exactly the sort of thing that makes Number(bigint) a bad idea.

To-string and from-string

Converting a BigInt to a decimal string is its own problem, because decimal is not a power of two. V8 has a dedicated formatter with a “classic” and a “fast” algorithm:

cpp
// src/bigint/tostring.cc:564
// Factored out so that tests can call it.
void ProcessorImpl::ToStringImpl(char* out, uint32_t* out_length, Digits& X,
                                 int radix, bool sign, bool fast) {
#if DEBUG
  for (uint32_t i = 0; i < *out_length; i++) out[i] = kStringZapValue;
#endif
  ToStringFormatter formatter(X, radix, sign, out, *out_length, this);
  if (IsPowerOfTwo(radix)) {
    formatter.BasePowerOfTwo();
#if V8_ADVANCED_BIGINT_ALGORITHMS
  } else if (fast) {
    formatter.Start();
    formatter.Fast();
    if (should_terminate()) return;
#else
    USE(fast);
#endif  // V8_ADVANCED_BIGINT_ALGORITHMS
  } else {
    formatter.Start();
    formatter.Classic();
  }
  uint32_t excess = formatter.Finish();
  *out_length -= excess;
  memset(out + *out_length, 0, excess);
}

Power-of-two radices (2, 4, 8, 16, 32) have their own path, because they are just bit extraction. Everything else - including decimal 10, the common case - uses the classic algorithm, which divides by the radix repeatedly.

And the fast algorithm only kicks in for large inputs, because it has a worse constant factor:

cpp
// src/bigint/bigint-inl.h:78
constexpr uint32_t kToStringFastThreshold = 23;

23 digits, so 1472 bits on a 64-bit platform. Below that, the classic repeated-division algorithm is fine.

Trade-offs

What it buys. Arbitrary precision arithmetic with a representation that is compact (one word per 64 bits of magnitude, plus a four-byte header), GC friendly (a single object, no interior pointers), and portable (the same source compiles for 32-bit and 64-bit).

What it costs.

  • Everything is a heap object. A BigInt is never a Smi and never a HeapNumber. 0n allocates. 1n + 1n allocates.
  • The fast path allocates too. Converting the Word64 result of the BigInt64 arithmetic back into a BigInt is a heap allocation, so the “fast” path is fast arithmetic plus a slow allocation.
  • The type check is not free. Determining whether a BigInt fits in 64 bits takes several branches and a load, and it happens on every operation.
  • Maglev does not have the fast path at all. The kBigInt64 hint is recognised but not specialised, so a program that lives at Maglev gets the full builtin call.
  • Division is expensive. Burnikel-Ziegler and Barrett are sophisticated algorithms, and the crossover points are high (57 digits for Burnikel, 13000 for Barrett on 64-bit).
  • The interrupt check is a tax on every large operation. Five million mul-equivalents between checks of a termination request.

The bottom line

The mental model: a BigInt is a sign bit and a vector of 64-bit words, in one heap object, with a strict canonical form. The canonical form - no leading zeros, no negative zero, no trailing garbage - is what makes the arithmetic library simple and the comparison operators cheap.

The second mental model: BigInt is the only primitive in V8 that the JIT cannot keep in a register. Numbers are Smi or HeapNumber and the compiler picks; BigInts are always objects. The BigInt64 type is the closest thing V8 has, and it is a type-level promise, checked at runtime on every operation, whose result still has to be boxed.

The third: the algorithms are not V8’s. src/bigint/ is a self-contained library with its own allocator abstraction and its own interrupt mechanism. V8’s job is to call it correctly, to select the right algorithm by size, and to keep the garbage collector out of it.

The thing people get wrong

The belief that BigInt is “a Number that is allowed to be bigger”. It is not. It is a different representation, a different set of algorithms, and a different type in the compiler’s lattice. 1n + 1 throws a TypeError; so does 1n + 1.0. The mixing rules are not a coercion detail, they are the whole point of having two numeric types.

The second belief is that the BigInt64 fast path makes BigInt arithmetic as fast as Number arithmetic. It does not, for two reasons: the check that decides whether you can take the fast path runs every time, and the result still has to be allocated as a heap object. A loop that adds BigInts in optimized code is doing an allocation per iteration.

The third is that a BigInt is a “boxed” number, like a HeapNumber. It is not boxed in the sense of wrapping a primitive - it is the primitive, stored inline in a variable-length heap object with no separate payload. That is why the digit count is stored in the object header rather than next to it, and why the header has to be atomic.

V8InternalsBigintCompilerPerformance