Number to String: How V8 Prints a Double
Turning a double into a string is a shortest-representation problem, and V8 solves it with Dragonbox, a hand-rolled digit formatter, and a per-isolate cache of the last number-to-string conversions.
TL;DR:
String(x)for a double is a shortest round-trip problem — find the fewest decimal digits that read back as the same double. V8 solves it with jkj’s Dragonbox (src/numbers/conversions.cc:1161), formats the resulting digits with a hand-written two-digits-at-a-time routine, and then memoises the whole thing in a per-isolateSmiStringCache/DoubleStringCache. The threshold where1e21becomes1e+21is not a special case: it falls out of the ECMA-262 formatting rules, which V8 implements literally.
Number.prototype.toString looks like the simplest thing in the language:
print the digits. It is not. The specification demands the shortest
decimal representation that round-trips, and that is a genuinely hard
search problem — you have to know exactly where the boundaries of the
floating-point interval around your number are, and that requires exact
arithmetic on 53-bit significands scaled by arbitrary powers of ten.
V8’s answer has three layers: an algorithm, a formatter, and a cache.
What “shortest” means
The rule, from ECMA-262 section 6.1.6.1.20 (Number::toString): given a
positive finite number x, find the integer k and the decimal digits
s such that reading s × 10^(n−k) back gives x exactly, and k is
as small as possible. If there are two such s of the same length, pick
the one closer to x; if they are equally close, pick the larger.
That last clause is the reason this is hard. “Closest to x” means
comparing against the exact midpoints between adjacent doubles, and
those midpoints are not representable in binary floating point at all.
The three modes V8’s dtoa layer supports make the distinction explicit:
// src/base/numbers/dtoa.h:15
enum DtoaMode {
// Return the shortest correct representation.
// For example the output of 0.299999999999999988897 is (the less accurate but
// correct) 0.3.
DTOA_SHORTEST,
// Return a fixed number of digits after the decimal point.
// For instance fixed(0.1, 4) becomes 0.1000
// If the input number is big, the output will be big.
DTOA_FIXED,
// Return a fixed number of digits, no matter what the exponent is.
DTOA_PRECISION
};DTOA_SHORTEST is Number.prototype.toString. DTOA_FIXED is
toFixed. DTOA_PRECISION is toPrecision and toExponential. The
buffer bound follows from the first mode:
// src/base/numbers/dtoa.h:28
// The maximal length of digits a double can have in base 10 as returned by
// 'DoubleToAscii'. This does neither include sign, decimal point nor exponent.
// For example DoubleToAscii(-3.5844466002796428e+298, ..., buffer, ...) will
// fill buffer with the string "35844466002796428", while sign and decimal point
// position will be provided through additional output arguments.
// kBase10MaximalLength refers to the maximal length of this string. Note that
// DoubleToAscii null-terminates its input. So the given buffer should be at
// least kBase10MaximalLength + 1 characters long.
const int kBase10MaximalLength = 17;Seventeen. A double has 53 bits of significand, and 2^53 ≈ 9.007 × 10^15, so any double needs at most 17 decimal digits to round-trip. Everything downstream is sized around that number.
The primary path: Dragonbox
Here is the interesting bit, and it is a change most write-ups of V8 still get wrong. The shortest-representation path in this checkout is not Grisu3 and not Dragon4 — it is Dragonbox, jkj’s table-free implementation of the Ryu-style shortest-representation algorithm:
// src/numbers/conversions.cc:1160
SimpleStringBuilder builder(buffer.begin(), buffer.size());
auto d = jkj::dragonbox::to_decimal(v);
if (d.is_negative) builder.AddCharacter('-');
// Only in debug-builds the buffer is null-terminated.
constexpr int kDecimalRepLength =
base::kBase10MaximalLength + (DEBUG_BOOL ? 1 : 0);
char decimal_rep[kDecimalRepLength];
int length = SignificandToChars(d.significand, decimal_rep);to_decimal returns a significand (a uint64_t holding the decimal
digits) and an exponent (a power of ten). The number is
significand × 10^exponentand that is all the algorithm gives you. The formatting decisions — where the decimal point goes, whether to use exponential notation — are made afterwards, by the ECMAScript rules, and have nothing to do with the representation algorithm. That separation is what makes the code easy to read.
(The third_party/dragonbox directory in this checkout is a stub: the
actual sources are fetched by DEPS. The interface V8 uses is the one
above.)
The digit-formatting helper is V8’s own, and the comment says where it came from:
// src/numbers/conversions.cc:42
// SignificandToChars and its helpers are heavily inspired by
// dragonbox::to_chars.
// See //third_party/dragonbox/src/source/dragonbox_to_chars.cc
The trick in SignificandToChars is to split the number into blocks and
convert each block with a multiplication trick rather than with division:
// src/numbers/conversions.cc:204
uint8_t SignificandToChars(uint64_t n, char* buffer) {
DCHECK_LT(n, 99999999999999999); // Only supports up to 17 digits
if (n >= 100'000'000) {
// If we have at least 9 digits, split into 2 blocks. The second one always
// has exactly 8 digits.
uint32_t first_block = static_cast<uint32_t>(n / 100'000'000);
uint32_t second_block = static_cast<uint32_t>(n % 100'000'000);
uint8_t first_block_digits = ConvertUpTo9Digits(first_block, buffer);
Convert8Digits(second_block, buffer + first_block_digits);
return first_block_digits + 8;
} else {
return ConvertUpTo9Digits(static_cast<uint32_t>(n), buffer);
}
}And Convert8Digits converts eight digits at once with a single
multiply-shift:
// src/numbers/conversions.cc:106
static void Convert8Digits(uint32_t n, char* buffer) {
static constexpr uint32_t kUint32Mask = kMaxUInt32;
// 281474978 = ceil(2^48 / 1'000'000) + 1
uint64_t prod = n * 281474978LL;
prod >>= 16;
prod += 1;
Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer);
prod = (prod & kUint32Mask) * 100;
Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer + 2);
prod = (prod & kUint32Mask) * 100;
Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer + 4);
prod = (prod & kUint32Mask) * 100;
Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer + 6);
}The constant 281474978 is ceil(2^48 / 10^6) + 1. Multiplying by it and
shifting right by 16 gives you the first two digits, and the +1 is the
rounding correction. Then it peels off two digits at a time with a
multiply-by-100 and a mask. A 200-entry lookup table of digit pairs
(kRadix100Table) does the final conversion to characters.
This is the standard “itoa by multiplication” technique, and it is
noticeably faster than the obvious n % 10 loop because the divisions
disappear and the compiler can keep the whole thing in registers.
The formatting rules, and where 1e21 comes from
Once you have the digits and the exponent, the rest is a direct transcription of the specification. V8 implements it as four cases, annotated with the spec step numbers:
// src/numbers/conversions.cc:1175
int decimal_point = length + d.exponent;
if (length <= decimal_point && decimal_point <= 21) {
// ECMA-262 section 9.8.1 step 6.
builder.AddString(decimal_rep, length);
builder.AddPadding('0', decimal_point - length);
} else if (0 < decimal_point && decimal_point <= 21) {
// ECMA-262 section 9.8.1 step 7.
builder.AddSubstring(decimal_rep, decimal_point);
builder.AddCharacter('.');
builder.AddString(decimal_rep + decimal_point, length - decimal_point);
} else if (decimal_point <= 0 && decimal_point > -6) {
// ECMA-262 section 9.8.1 step 8.
builder.AddStringLiteral("0.");
builder.AddPadding('0', -decimal_point);
builder.AddString(decimal_rep, length);
} else {
// ECMA-262 section 9.8.1 step 9 and 10 combined.
builder.AddCharacter(decimal_rep[0]);
if (length != 1) {
builder.AddCharacter('.');
builder.AddString(decimal_rep + 1, length - 1);
}
builder.AddCharacter('e');
builder.AddCharacter((decimal_point >= 0) ? '+' : '-');
int exponent = decimal_point - 1;
if (exponent < 0) exponent = -exponent;
builder.AddExponent(exponent);
}decimal_point is the number of digits before the decimal point. The
magic number 21 is right there in the comparison, and it is the
answer to “where does exponential notation kick in”:
1e21 → "1e+21" (decimal_point == 22, so the last branch)
1e20 → "100000000000000000000"
1e-6 → "0.000001" (decimal_point == -5, third branch)
1e-7 → "1e-7" (decimal_point == -6, last branch)The > -6 in the third branch is the other side of the same rule: below
1e-6, JavaScript switches to exponential notation. The asymmetry
between the two thresholds (21 above, 6 below) is the spec’s, and it is
one of the odd corners of the language that everyone learns the hard way.
decimal_point is computed as length + d.exponent, and length is the
number of significant digits. So the four cases are really about where
the decimal point falls relative to the digit string, not about the
magnitude of the number.
Two special cases are handled before the algorithm even runs: NaN,
Infinity and -Infinity are constants, and an integral double that
fits in an int32 takes a completely different path:
// src/numbers/conversions.cc:1153
default: {
if (IsInt32Double(v)) {
// This will trigger if v is -0 and -0.0 is stringified to "0".
// (see ES section 7.1.12.1
// https://tc39.es/ecma262/#sec-tostring-applied-to-the-number-type)
return IntToStringView(FastD2I(v), buffer);
}IsInt32Double is a range check plus an integrality test, and it is
worth it: String(42) should not go through a shortest-representation
search at all. It is also how -0 becomes "0".
The number-to-string cache
The expensive part of String(x) is not the formatting — it is that
programs convert the same numbers over and over. arr.map(String),
id columns, log formatting: the same handful of doubles, thousands of
times.
V8 keeps two per-isolate caches for this. The Smi cache is a
FixedArray of key/value pairs:
// src/objects/number-string-cache.h:19
// Used for mapping non-zero Smi to Strings.
V8_OBJECT class SmiStringCache : public FixedArray {
V8_IT_NO_AUTO_CHECKER;
public:
using Super = FixedArray;
// Empty entries are initialized with this sentinel (both key and value).
static constexpr Tagged<Smi> kEmptySentinel = Smi::zero();
static constexpr int kEntryKeyIndex = 0;
static constexpr int kEntryValueIndex = 1;
static constexpr uint32_t kEntrySize = 2;
static constexpr uint32_t kInitialSize = 128;and the double cache is a separate object with an unaligned double key:
// src/objects/number-string-cache.h:76
// Used for mapping raw doubles to Strings.
V8_OBJECT class DoubleStringCache : public HeapObject {
V8_IT_NO_AUTO_DISPATCH;
public:
V8_OBJECT struct Entry {
UnalignedDoubleMember key_;
TaggedMember<UnionOf<Smi, String>> value_;
} V8_OBJECT_END;The double cache is keyed on the bit pattern of the double, and the hash is the xor of its two 32-bit halves:
// src/objects/number-string-cache-inl.h:115
// static
InternalIndex DoubleStringCache::GetEntryFor(uint64_t number_bits,
uint32_t capacity) {
uint32_t hash = static_cast<uint32_t>(number_bits) ^
static_cast<uint32_t>(number_bits >> 32);
uint32_t mask = capacity - 1;
return InternalIndex{hash & mask};
}Note the consequence: the cache is not numerically aware. +0.0 and
-0.0 have different bit patterns and therefore different cache
entries, even though they stringify identically. That is fine — the
cache is a memo, not a canonicaliser — and it is why the double cache
stores the raw bits rather than a normalised value.
Both caches start small and grow when they first collide. This is a nice piece of laziness: most programs never convert more than a hundred distinct numbers, so the cache starts at 128 entries and only allocates the full table when an entry is actually overwritten:
// src/objects/number-string-cache-inl.h:77
// Switch to full-size mode if the entry is already occupied.
if (cache->capacity() == kInitialSize &&
cache->get(entry_index + kEntryKeyIndex) != kEmptySentinel &&
!isolate->MemorySaverModeEnabled()) {
// Allocate full-size cache, recompute the entry and proceed with adding
// the entry.
uint32_t full_size = std::bit_ceil(v8_flags.smi_string_cache_size.value());
CHECK_LT(kInitialSize, full_size);
CHECK_LE(full_size, kMaxCapacity);
// TODO(ishell): consider copying entries from previous table.
cache = SmiStringCache::New(isolate, full_size);
isolate->heap()->SetSmiStringCache(*cache);The full sizes are flags:
// src/flags/flag-definitions.h:894
DEFINE_UINT(smi_string_cache_size, 8192, "Full size of SmiStringCache")
DEFINE_UINT(double_string_cache_size, 4096, "Full size of DoubleStringCache")8192 Smi entries and 4096 double entries, in a direct-mapped table (no
probe sequence: one hash, one slot, and a collision evicts). And note the
TODO(ishell): when the cache grows, the old entries are thrown away.
The comment says so, and it is a real (if minor) regression for a
program that fills 128 entries and then converts the same 128 again.
The C++ entry point is Factory::DoubleToString, and it shows the whole
policy in one function — try the cache, compute, store:
// src/heap/factory-base.cc:1011
InternalIndex entry = InternalIndex::NotFound();
uint64_t value_bits = 0;
if constexpr (kCanUseCache) {
if (mode != NumberCacheMode::kIgnore) {
value_bits = base::bit_cast<uint64_t>(value);
entry = DoubleStringCache::GetEntryFor(isolate(), value_bits);
}
if (mode == NumberCacheMode::kBoth) {
Handle<Object> cached =
DoubleStringCache::Get(isolate(), entry, value_bits);
if (!IsUndefined(*cached)) return Cast<String>(cached);
}
}The NumberCacheMode has three values — kIgnore (don’t touch the
cache), kBoth (read and write) and presumably kUpdate (write only) —
and the comment above explains why the cache is main-thread only:
// src/heap/factory-base.cc:1007
// LocalFactory does not have access to number string cache, only
// main thread Factory does (since it's a mutable root).
constexpr bool kCanUseCache = std::is_same_v<Impl, Factory>;The preallocated strings
Before the cache, there is an even cheaper layer: a table of 100 pre-built strings for the integers 0 to 99.
// src/common/globals.h:3383
constexpr uint32_t kPreallocatedNumberStringTableSize = 100;The table is built at heap setup, and the digits 0 to 9 are taken straight from the single-character string roots:
// src/heap/setup-heap-internal.cc:1333
static_assert(kPreallocatedNumberStringTableSize >= 10);
for (int i = 0; i < 10; ++i) {
RootIndex root_index = RootsTable::SingleCharacterStringIndex('0' + i);
Tagged<String> str =
Cast<String>(factory->read_only_roots().object_at(root_index));
DCHECK(ReadOnlyHeap::Contains(str));
preallocated_number_string_table->set(i, str);
}
// This code duplicates FactoryBase::SmiToNumber.
for (uint32_t i = 10; i < kPreallocatedNumberStringTableSize; ++i) {
std::string_view string = IntToStringView(i, buffer);
Handle<String> str = factory->InternalizeString(
base::OneByteVector(string.data(), string.length()));The “this code duplicates FactoryBase::SmiToNumber” comment is the
kind of thing that shows up in every code base with a bootstrapping
problem: the table has to exist before the factory can make strings, so
one of the two has to be written twice, and a LINT check keeps them in
sync.
The lookup in generated code is a bounds check and a load:
// src/codegen/code-stub-assembler.cc:10061
TNode<String> CodeStubAssembler::TryMatchPreallocatedNumberString(
TNode<Int32T> value, Label* bailout) {
GotoIf(Uint32GreaterThanOrEqual(
value, Uint32Constant(kPreallocatedNumberStringTableSize)),
bailout);
TNode<FixedArray> table =
CAST(LoadRoot(RootIndex::kPreallocatedNumberStringTable));
TNode<Object> result =
UnsafeLoadFixedArrayElement(table, ChangeInt32ToIntPtr(value));
return CAST(result);
}The fast path in generated code
Number.prototype.toString is a Torque builtin, and for radix 10 it
calls straight into the CSA NumberToString macro:
// src/builtins/number.tq:197
// 6. If radixNumber = 10, return ! ToString(x).
if (radixNumber == 10) {
return NumberToString(x);
}The CSA macro has two tiers: a Smi path and a heap-number path, and the heap-number path first tries to normalise the double into a Smi:
// src/codegen/code-stub-assembler.cc:10128
TNode<String> CodeStubAssembler::NumberToString(TNode<Number> input,
Label* bailout) {
...
BIND(&if_heap_number);
TNode<HeapNumber> heap_number_input = CAST(input);
TNode<Float64T> float64_input = LoadHeapNumberValue(heap_number_input);
{
Comment("NumberToString - HeapNumber");
// Try normalizing the HeapNumber.
smi_input = TryFloat64ToSmi(float64_input, ¬_smi);
Goto(&if_smi);
}3.0 is a HeapNumber on the heap, but it is integral and fits in a
Smi, so it is converted to a Smi and printed through the Smi path. That
is a very common case — any number that came out of integer arithmetic
and was boxed — and it turns a shortest-representation search into an
integer-to-string conversion.
The Smi path in generated code probes the cache directly, in assembly:
// src/codegen/code-stub-assembler.cc:10178
// Load the number string cache.
TNode<SmiStringCache> cache = CAST(LoadRoot(RootIndex::kSmiStringCache));
// Make the hash mask from the length of the number string cache. It
// contains two elements (number and string) for each cache entry.
TNode<Uint32T> cache_length =
LoadFixedArrayBaseLengthAsUint32(ReinterpretCast<FixedArray>(cache));
TNode<Int32T> one = Int32Constant(1);
TNode<Word32T> mask = Int32Sub(Word32Shr(cache_length, one), one);
// Load the smi key, make sure it matches the smi we're looking for.
TNode<Word32T> hash = Word32And(int32_value, mask);
TNode<IntPtrT> entry_index = Signed(ChangeUint32ToWord(Int32Add(hash, hash)));and the double path is a bit more elaborate, because the key is a 64-bit value stored in two halves:
// src/codegen/code-stub-assembler.cc:10264
// Make a hash from the two 32-bit values of the double.
TNode<Int32T> low = Signed(Float64ExtractLowWord32(input));
TNode<Int32T> high = Signed(Float64ExtractHighWord32(input));
TNode<Word32T> entry = Word32And(Word32Xor(low, high), mask);
TNode<IntPtrT> entry_offset = DoubleStringCacheEntryToOffset(entry);When the cache misses, the generated code bails out to the runtime rather than trying to compute the string itself — the double-to-string computation is far too big to want in the middle of a builtin:
// src/codegen/code-stub-assembler.cc:10299
BIND(&runtime);
{
// No cache entry, go to the runtime.
result = CAST(
CallRuntime(Runtime::kNumberToStringSlow, NoContextConstant(), input));
Goto(&done);
}So the division of labour is: generated code handles the cache hit, the runtime handles the computation. The cache hit path is a handful of instructions; the miss path is a runtime call.
The other dtoa: Grisu3 and Dragon4
The Grisu3/Dragon4 machinery in src/base/numbers/ is still in the
tree, and it is still used — just not for Number.prototype.toString’s
shortest mode any more. base::DoubleToAscii is the older entry point
and it does have a fast path with a fallback:
// src/base/numbers/dtoa.cc:58
bool fast_worked;
switch (mode) {
case DTOA_SHORTEST:
fast_worked = FastDtoa(v, FAST_DTOA_SHORTEST, 0, buffer, length, point);
break;
case DTOA_FIXED:
fast_worked = FastFixedDtoa(v, requested_digits, buffer, length, point);
break;
case DTOA_PRECISION:
fast_worked = FastDtoa(v, FAST_DTOA_PRECISION, requested_digits, buffer,
length, point);
break;
default:
UNREACHABLE();
}
if (fast_worked) return;
// If the fast dtoa didn't succeed use the slower bignum version.
BignumDtoaMode bignum_mode = DtoaToBignumDtoaMode(mode);
BignumDtoa(v, bignum_mode, requested_digits, buffer, length, point);
buffer[*length] = '\0';FastDtoa is Grisu3, and the interesting part is that it is not
always correct — it computes a candidate using a 64-bit approximation
of a power of ten, and then has to prove that the candidate is
unambiguous. The function that does the proving is called RoundWeed,
and its comment includes an ASCII diagram of the interval arithmetic:
// src/base/numbers/fast-dtoa.cc:43
static bool RoundWeed(char* last_digit, uint64_t distance_too_high_w,
uint64_t unsafe_interval, uint64_t rest,
uint64_t ten_kappa, uint64_t unit) {The logic: the buffer currently holds a decimal in the unsafe interval between the two neighbouring doubles. If it is in the safe interval, it definitely round-trips. If it is in the unsafe interval but not the safe one, Grisu3 cannot prove anything and returns false, and the caller falls back to the exact algorithm.
The exact algorithm is BignumDtoa — Dragon4 with arbitrary-precision
integers, the “slow dtoa” of the brief:
// src/base/numbers/bignum-dtoa.cc:96
bool need_boundary_deltas = (mode == BIGNUM_DTOA_SHORTEST);
InitialScaledStartValues(v, estimated_power, need_boundary_deltas, &numerator,
&denominator, &delta_minus, &delta_plus);
// We now have v = (numerator / denominator) * 10^estimated_power.
FixupMultiply10(estimated_power, is_even, decimal_point, &numerator,
&denominator, &delta_minus, &delta_plus);It computes the exact boundaries m- and m+ of the interval of
doubles that round to v, as rationals with bignum numerators and
denominators, and then generates digits until the candidate is strictly
between them. That is the “shortest and closest” guarantee, obtained by
brute force.
toFixed and toPrecision: a different algorithm
toFixed and toPrecision do not want the shortest representation.
They want a specific number of digits, correctly rounded, and the
rounding rule is different: halfway cases round away from zero, not
to even. (1.005).toFixed(2) is "1.00" in JavaScript, and
(0.15).toFixed(2) is "0.15", and these are exact-arithmetic
questions.
The DTOA_FIXED mode has its own fast path and its own exact fallback:
// src/base/numbers/fixed-dtoa.h:15
// Produces digits necessary to print a given number with
// 'fractional_count' digits after the decimal point.
// ...
// Halfway cases are rounded towards +/-Infinity (away from 0). The call
// FastFixedDtoa(0.15, 2, ...) thus returns buffer = "2", decimal_point = 0.
Note the difference in the halfway rule between the two modes. The shortest mode rounds halfway cases up (to the larger magnitude), per the spec:
// src/base/numbers/dtoa.h:42
// - SHORTEST: produce the least amount of digits for which the internal
// identity requirement is still satisfied. If the digits are printed
// (together with the correct exponent) then reading this number will give
// 'v' again. The buffer will choose the representation that is closest to
// 'v'. If there are two at the same distance, than the one farther away
// from 0 is chosen (halfway cases - ending with 5 - are rounded up).
The exact fallback uses the same rule, with the tie broken by the significand rather than by the digit parity:
// src/base/numbers/bignum-dtoa.h:39
// 'v'. If there are two at the same distance, than the number is round up.
// src/base/numbers/bignum-dtoa.cc:67
bool is_even = (significand & 1) == 0;That single bit of the double’s significand is what decides which of the
two neighbouring decimals is generated when the candidate sits exactly on
a boundary: is_even tells GenerateShortestDigits whether the lower
boundary delta_minus is inclusive, so the same double always prints
as the same string. It is the reason String(0.1) is stable across
platforms — a tie is not broken by the rounding mode of the host FPU but
by the bits of the value itself.
toFixed also has a magnitude cutoff, and it is the same 1e21:
// src/numbers/conversions.cc:1229
std::string_view DoubleToFixedStringView(double value, int f,
base::Vector<char> buffer) {
const double kFirstNonFixed = 1e21;Above that, toFixed falls back to the plain toString formatting,
because there is no fixed-point representation of 1e21 that fits in
the buffer anyway (the integer part alone has 22 digits, and the
spec’s limit is 21 digits before the point).
And the digit limit is 100:
// src/numbers/conversions.h:69
// The limit for the fractionDigits/precision for toFixed, toPrecision
// and toExponential.
constexpr int kMaxFractionDigits = 100;
constexpr int kDoubleToFixedMaxDigitsBeforePoint = 21;which is why (1).toFixed(101) throws a RangeError.
The other direction
For completeness, the parsing direction is not V8’s own code any more
either. StringToDouble uses fast_float:
// src/numbers/conversions.cc:892
auto ret =
fast_float::from_chars(current_uc, end_uc, value,
static_cast<fast_float::chars_format>(
fast_float::chars_format::general |
fast_float::chars_format::no_infnan |
fast_float::chars_format::allow_leading_plus));fast_float is a std::from_chars implementation built on the Eisel-
Lemire algorithm, and it is exact — it returns the correctly rounded
double for any input, with no fallback needed. So the round trip
String(Number(x)) → parse → double is exact by construction, and
Number(String(x)) === x holds for every finite double.
Trade-offs
What it buys. Number-to-string is fast enough that nobody notices it. A double prints in a few tens of nanoseconds, the common case (a Smi, or a repeated value) is a cache hit that costs a load and a compare, and the algorithm is correct to the last bit.
What it costs.
- The shortest-representation problem is genuinely hard. Dragonbox uses a large amount of precomputed table data (the “cache” of 128-bit multiples of powers of ten) and a non-obvious implementation. It is not code you can read and modify.
- Two dtoa implementations coexist.
src/base/numbers/has Grisu3, Dragon4 and fixed-dtoa;src/numbers/conversions.cchas Dragonbox. The shortest mode ofbase::DoubleToAsciiis essentially dead code for thetoStringpath but is still reachable from other callers, and the two have different halfway-rounding behaviour. - The cache grows by throwing itself away. The first collision at 128 entries discards the entire table and replaces it with an 8192 entry one, and the old contents are not copied over. A program that converts 129 distinct numbers in a loop pays for the conversion of all 129 again after the resize.
- The cache is direct-mapped. One hash, one slot, no probing. Two hot numbers that hash to the same slot will evict each other forever, and the cache becomes a treadmill.
- The double cache is not numerically aware.
0and-0are different keys, andNaN— of which there are many bit patterns — gets whatever slot its bits hash to.
The bottom line
Converting a double to a string in V8 is three separable problems, and V8 solves each one in the right place.
The representation problem (find the shortest round-tripping
digits) is solved by Dragonbox, which returns a significand and an
exponent and nothing else. The formatting problem (where does the
decimal point go, do we use exponential notation) is solved by a direct
transcription of the ECMA-262 rules, and the 21 and -6 in that code
are the whole reason String(1e21) is "1e+21". The performance
problem (the same numbers keep coming back) is solved by a per-isolate
cache that memoises the result, backed by a table of 100 pre-built
strings for the small integers.
The mental model to keep: the algorithm produces digits, the spec produces format, and the cache produces speed — and the boundaries between the three are exactly where the code is.
The thing people get wrong
“V8 uses Grisu3 with a Dragon4 fallback” is the standard story, and it
is now out of date. In this checkout the shortest-representation path
for Number.prototype.toString is jkj’s Dragonbox, and it has no
fallback at all — it is exact by construction. The Grisu3 code is still
in the tree, but it now serves the fixed and precision modes (and the
legacy base::DoubleToAscii entry point), not toString.
The other thing people get wrong is the threshold. It is not “V8 uses
exponential notation for large numbers” — it is that the spec says
so, with a specific and slightly asymmetric rule: fixed notation for
1e-6 through 1e20, exponential outside that. A language that
switches notation at 1e21 and at 1e-7 is not making a random
choice; it is following a rule that was written down in 1999 and has
been frozen ever since.