The JSON Parser: How V8 Parses JSON Fast
V8's JSON parser is a hand-written, table-driven scanner with a SIMD string scanner, a Smi-first number path, and a feedback-map trick that predicts the shape of the next object in an array.
TL;DR:
JSON.parsedoes not use the JavaScript parser at all. V8 has a separate hand-written parser (src/json/json-parser.cc) that is driven by two 256-entry lookup tables, scans strings with Highway SIMD, parses numbers Smi-first, and — the cleverest bit — takes the map of the previous element in a JSON array and uses it to predict the shape of the next one, so it can allocate the object with the right fields already in place.
JSON is the most common data format in the world, and the most common
operation on it is JSON.parse of a large payload. It is also the easiest
format to parse, and V8 treats it that way: the JSON parser shares no
code with the JavaScript parser. It is a purpose-built machine with its own
token type, its own scanner and its own object builder.
The interesting parts are not the grammar. They are the parts where V8 cheats.
Two parsers, and only one of them is JavaScript
The public entry point is a C++ builtin, not a Torque one:
// src/builtins/builtins-json.cc:16
// ES6 section 24.3.1 JSON.parse.
BUILTIN(JsonParse) {
HandleScope scope(isolate);
Handle<Object> source = args.atOrUndefined(isolate, 1);
Handle<Object> reviver = args.atOrUndefined(isolate, 2);
Handle<String> string;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, string,
Object::ToString(isolate, source));
string = String::Flatten(isolate, string);
RETURN_RESULT_OR_FAILURE(
isolate,
String::IsOneByteRepresentationUnderneath(*string)
? JsonParser<uint8_t>::Parse(isolate, string, reviver, std::nullopt)
: JsonParser<uint16_t>::Parse(isolate, string, reviver,
std::nullopt));
}Two things happen here that matter.
First, the source is flattened. A JSON payload that arrives as a sliced
or cons string is turned into a flat sequential string, because the parser
wants a raw char* it can walk with pointer arithmetic.
Second, the parser is instantiated on the character width of the string.
JsonParser<uint8_t> and JsonParser<uint16_t> are two separate
compilations of the same template, and the one-byte version is faster
because it can move 16 bytes at a time in the SIMD string scanner and
because a one-byte string can be represented by a smaller heap object.
Note also that Object::ToString is called before the parser starts.
That is the one place where JSON.parse can run user code: if you pass an
object with a toString method, it gets called. After that, the parse
itself is user-code-free.
The token type
JSON’s grammar is small enough that the whole token set fits in a uint8_t:
// src/json/json-parser.h:152
enum class JsonToken : uint8_t {
NUMBER,
STRING,
LBRACE,
RBRACE,
LBRACK,
RBRACK,
TRUE_LITERAL,
FALSE_LITERAL,
NULL_LITERAL,
WHITESPACE,
COLON,
COMMA,
ILLEGAL,
EOS
};The parser is not a real tokeniser in the JavaScript-parser sense. There
is no token stream: there is a cursor over the characters and a single
next_ field holding “what character class comes next”. peek() is
essentially free:
// src/json/json-parser.cc:645
template <typename Char>
JsonToken JsonParser<Char>::peek() const {
// Check that the next token was scanned before using peek().
DCHECK_IMPLIES(!is_at_end(),
GetTokenForCharacter(CurrentCharacter()) == next_);
return next_;
}The classification is a table lookup, and the table is generated at compile time from a constexpr function:
// src/json/json-parser.cc:45
constexpr JsonToken GetOneCharJsonToken(uint8_t c) {
// clang-format off
return
c == '"' ? JsonToken::STRING :
IsDecimalDigit(c) ? JsonToken::NUMBER :
c == '-' ? JsonToken::NUMBER :
c == '[' ? JsonToken::LBRACK :
c == '{' ? JsonToken::LBRACE :
c == ']' ? JsonToken::RBRACK :
c == '}' ? JsonToken::RBRACE :
c == 't' ? JsonToken::TRUE_LITERAL :
c == 'f' ? JsonToken::FALSE_LITERAL :
c == 'n' ? JsonToken::NULL_LITERAL :
c == ' ' ? JsonToken::WHITESPACE :
c == '\t' ? JsonToken::WHITESPACE :
c == '\r' ? JsonToken::WHITESPACE :
c == '\n' ? JsonToken::WHITESPACE :
c == ':' ? JsonToken::COLON :
c == ',' ? JsonToken::COMMA :
JsonToken::ILLEGAL;
// clang-format on
}and the table itself is a 256-entry array built by a macro that expands the 0..127 range twice:
// src/json/json-parser.cc:68
// Table of one-character tokens, by character (0x00..0xFF only).
static const constexpr JsonToken one_char_json_tokens[256] = {
#define CALL_GET_SCAN_FLAGS(N) GetOneCharJsonToken(N),
INT_0_TO_127_LIST(CALL_GET_SCAN_FLAGS)
#undef CALL_GET_SCAN_FLAGS
#define CALL_GET_SCAN_FLAGS(N) GetOneCharJsonToken(128 + N),
INT_0_TO_127_LIST(CALL_GET_SCAN_FLAGS)
#undef CALL_GET_SCAN_FLAGS
};There is a second table, character_json_scan_flags, which packs three
independent pieces of information into one byte per character: the escape
kind (b, t, n, f, r, u, ", \\, /, or illegal), whether
the character can terminate a string, and whether it can be part of a
number. All three are BitFields over the same byte:
// src/json/json-parser.cc:105
constexpr uint8_t GetJsonScanFlags(uint8_t c) {
// clang-format off
return (c == 'b' ? EscapeKindField::encode(EscapeKind::kBackspace)
: c == 't' ? EscapeKindField::encode(EscapeKind::kTab)
: c == 'n' ? EscapeKindField::encode(EscapeKind::kNewLine)
: c == 'f' ? EscapeKindField::encode(EscapeKind::kFormFeed)
: c == 'r' ? EscapeKindField::encode(EscapeKind::kCarriageReturn)
: c == 'u' ? EscapeKindField::encode(EscapeKind::kUnicode)
: c == '"' ? EscapeKindField::encode(EscapeKind::kSelf)
: c == '\\' ? EscapeKindField::encode(EscapeKind::kSelf)
: c == '/' ? EscapeKindField::encode(EscapeKind::kSelf)
: EscapeKindField::encode(EscapeKind::kIllegal)) |
(c < 0x20 ? MayTerminateStringField::encode(true)
: c == '"' ? MayTerminateStringField::encode(true)
: c == '\\' ? MayTerminateStringField::encode(true)
: MayTerminateStringField::encode(false)) |
NumberPartField::encode(c == '.' ||
c == 'e' ||
c == 'E' ||
IsDecimalDigit(c) ||
c == '-' ||
c == '+');
// clang-format on
}One byte per character, three questions answered. This is the kind of thing that only matters when you are parsing hundreds of megabytes of JSON, which is exactly the workload JSON parsers exist for.
Numbers: Smi first
JSON.parse must produce a number, and JavaScript has two number
representations: Smis (tagged integers) and heap numbers (doubles). The
parser tries Smi first, and only falls back to a double.
The Smi path is a hand-rolled digit loop with a hard length limit of nine digits, which is exactly the range of a 32-bit Smi:
// src/json/json-parser.cc:1824
} else {
const Char* smi_start = cursor_;
static_assert(Smi::IsValid(-999999999));
static_assert(Smi::IsValid(999999999));
const int kMaxSmiLength = 9;
int32_t i = 0;
const Char* stop = cursor_ + kMaxSmiLength;
if (stop > end_) stop = end_;
while (cursor_ < stop && IsDecimalDigit(*cursor_)) {
i = (i * 10) + ((*cursor_) - '0');
cursor_++;
}Nine digits, accumulated in an int32_t, no allocation, no floating
point. If the character after the digits is not a number part, the value
is a Smi and the parse is over:
// src/json/json-parser.cc:1844
c = CurrentCharacter();
if (!base::IsInRange(c, 0,
static_cast<int32_t>(unibrow::Latin1::kMaxChar)) ||
!IsNumberPart(character_json_scan_flags[c])) {
// Smi.
// TODO(verwaest): Cache?
*result_smi = i * sign;
return false;
}Only if there is a fraction or an exponent does the parser fall back to
StringToDouble, and even then it re-checks whether the double happens to
be integral:
// src/json/json-parser.cc:1883
base::Vector<const Char> chars(start, cursor_ - start);
*result_double =
StringToDouble(chars,
NO_CONVERSION_FLAG, // Hex, octal or trailing junk.
std::numeric_limits<double>::quiet_NaN());
DCHECK(!std::isnan(*result_double));
// The result might still be a smi even if it has a decimal part.
return !DoubleToSmiInteger(*result_double, result_smi);NO_CONVERSION_FLAG matters: StringToDouble will happily parse 0x10
or 010 as a number if you let it, and JSON forbids both. The flag says
“no hex, no octal, no trailing junk” — and the parser has already
validated the grammar, so the flag is belt and braces.
There is one more wrinkle: the array path avoids boxing numbers at all. While an array is being parsed, numbers are accumulated in two flat vectors, one of Smis and one of doubles, and the array’s elements kind is decided at the end:
// src/json/json-parser.cc:1368
// Avoid allocating HeapNumbers until we really need to.
bool saw_double = false;
bool success = false;
DCHECK_EQ(double_elements_.size(), 0);
DCHECK_EQ(smi_elements_.size(), 0);
// GetNextNonWhitespaceToken was performed by Check(JsonToken::RBRACK).
while (peek() == JsonToken::NUMBER) {
double current_double;
int current_smi;
if (ParseJsonNumberAsDoubleOrSmi(¤t_double, ¤t_smi)) {
saw_double = true;
double_elements_.push_back(current_double);
} else {So [1,2,3] becomes a PACKED_SMI_ELEMENTS array with no HeapNumber
allocations at all, and [1.5,2.5] becomes a PACKED_DOUBLE_ELEMENTS
array, also with no boxing. The “double” array is built as a
FixedDoubleArray, which stores raw doubles.
Strings: SIMD scanning and a two-pass decode
String scanning is the hot loop, and it is vectorised. The scanner looks
for three things at once — a control character, a quote, or a backslash —
using Highway (hwy), which means the code is portable across SSE, AVX,
NEON and RISC-V without a macro per architecture:
// src/json/json-parser.cc:2097
while (true) {
if constexpr (sizeof(Char) == 2) {
// While all characters seen so far are Latin1 (<= 0xff), also check for
// characters > 0xff so `bits` tracks whether the string can be one-byte
// converted.
if (V8_LIKELY(bits <= unibrow::Latin1::kMaxChar)) {
for (; cursor_ + (stride - 1) < end_; cursor_ += stride) {
const auto input = hw::LoadU(tag, cursor_);
// TODO(floitsch): use operators for the comparisons when they are
// available on RISC-V.
const auto result = hw::Or(
hw::Or(hw::Lt(input, mask_0x20), hw::Eq(input, mask_quote)),
hw::Or(hw::Eq(input, mask_backslash), hw::Gt(input, mask_0xff)));
if (V8_LIKELY(hw::AllFalse(tag, result))) continue;
cursor_ += hw::FindKnownFirstTrue(tag, result);
break;
}The bits accumulator is a nice trick: for a two-byte source string, the
parser tracks whether any character seen so far exceeds 0xff. If none
has, the decoded string can be stored as a one-byte string, which halves
its memory footprint. The SIMD loop checks for “greater than 0xff” in the
same comparison as the escape characters, so it costs nothing extra.
The scan does not decode. It records the range of the string in the source, plus a flag saying whether it contained escapes:
// src/json/json-parser.cc:2157
if (*cursor_ == '"') {
uint32_t end = position();
advance();
uint32_t length = end - offset;
bool convert = sizeof(Char) == 1 ? bits > unibrow::Latin1::kMaxChar
: bits <= unibrow::Latin1::kMaxChar;
return JsonString(start, length, convert, needs_internalization,
has_escape);
}The decoding happens later, in DecodeString, and it is a separate pass
over the range. That is a deliberate split: the vast majority of JSON
strings have no escapes, and for those the decode is a plain memcpy
(called CopyChars in the source). Only strings with escapes pay for the
second pass.
The escape decoder is a switch on a table-driven escape kind, and the
\uXXXX case is where the surrogate pair logic lives:
// src/json/json-parser.cc:2053
case EscapeKind::kUnicode: {
base::uc32 value = 0;
for (int i = 0; i < 4; i++) {
value = value * 16 + base::HexValue(*++cursor);
}
if (value <=
static_cast<base::uc32>(unibrow::Utf16::kMaxNonSurrogateCharCode)) {
*sink++ = value;
length--;
} else {
SBXCHECK_GE(length, 2);
*sink++ = unibrow::Utf16::LeadSurrogate(value);
*sink++ = unibrow::Utf16::TrailSurrogate(value);
length -= 2;
}
break;
}Note what this does not do: it does not check that a lone surrogate is
well-formed, and it does not combine surrogate pairs. A \uD83D\uDE00
escape produces two UTF-16 code units, exactly as the JSON spec requires.
V8’s strings are UTF-16, and JSON escapes are defined in terms of UTF-16
code units, so there is nothing to combine.
The offset bookkeeping in the scanner is where the length of the
decoded string is tracked while the encoded length is being scanned:
// src/json/json-parser.cc:2196
// \uXXXX results in either 1 or 2 Utf16 characters, depending on
// whether the decoded value requires a surrogate pair.
offset += 5 - (value > static_cast<base::uc32>(
unibrow::Utf16::kMaxNonSurrogateCharCode));Six source characters (\uXXXX) become one or two UTF-16 units, so the
decoded length is shorter than the source length. The scanner tracks that
so the destination buffer can be sized exactly.
The JsonString union: a range or an index
The result of a string scan is a JsonString, and it is a tagged union
with a genuinely surprising second case:
// src/json/json-parser.h:29
class JsonString final {
public:
JsonString()
: start_(0),
length_(0),
needs_conversion_(false),
internalize_(false),
has_escape_(false),
is_index_(false) {}
explicit JsonString(uint32_t index)
: index_(index),
length_(0),
needs_conversion_(false),
internalize_(false),
has_escape_(false),
is_index_(true) {}JsonString is either a range into the source buffer (start_,
length_) or an array index (index_). Which one it is depends on
where it came from. A JSON object key that looks like an array index
(“0”, “1”, “42”) is not a string at all — it is an element index, and
the parser must know that so it can put the value in the elements backing
store rather than in a named property.
ScanJsonPropertyKey detects this with a fast scan over the digits, and
it is careful to handle the escaped form ("1" written as "\u0031"):
// src/json/json-parser.cc:680
JsonString JsonParser<Char>::ScanJsonPropertyKey(JsonContinuation* cont) {
{
DisallowGarbageCollection no_gc;
const Char* start = cursor_;
base::uc32 first = CurrentCharacter();
if (first == '\\' && NextCharacter() == 'u') first = ScanUnicodeCharacter();
if (IsDecimalDigit(first)) {
if (first == '0') {
if (NextCharacter() == '"') {
advance();
// Record element information.
cont->elements++;
DCHECK_LE(0, cont->max_index);
return JsonString(0);
}
} else {
uint32_t index = first - '0';
while (true) {
cursor_ = std::find_if(cursor_ + 1, end_, [&index](Char c) {
return !IsDecimalDigit(c) || !TryAddArrayIndexChar(&index, c);
});TryAddArrayIndexChar exists so that an index that overflows 32 bits
stops being an index and becomes a string again — {"4294967296": 1} is a
named property, not an element.
Internalization: a budget, not a policy
Property keys must be internalized strings, because object property names are compared by pointer. Values usually are not. V8 draws the line with a heuristic and a hard budget:
// src/json/json-parser.cc:1945
if (!string.has_escape()) {
bool should_internalize = string.internalize();
// For non-escaped strings, try to internalize the following candidates:
// 1. Property keys (string.internalize() is true in that case).
// 2. Short one-byte strings. If we have a budget for heuristic,
// we can consider inserting them. Otherwise, at least try to reuse
// existing internalized strings without inserting new ones.
constexpr int kMaxInternalizedStringValueLength = 10;
if (!should_internalize && sizeof(Char) == 1 &&
string.length() < kMaxInternalizedStringValueLength) {
if (remaining_heuristic_internalizations_ > 0) {
--remaining_heuristic_internalizations_;
should_internalize = true;
} else {The budget is a flag with a default of 100 000:
// src/flags/flag-definitions.h:882
DEFINE_UINT(json_parse_max_heuristically_internalized_strings, 100'000,
"Maximum number of strings that are eagerly internalized during "
"JSON.parse() based on heuristics. 0 means only strings that "
"must be internalized e.g. property keys will be internalized.")The reasoning is a memory/value trade-off. If a JSON payload contains 100 000 distinct short strings, internalizing all of them adds 100 000 entries to the string table, and the string table is a weak reference — those strings can be collected, but the table entries and the hash computation are not free.
Once the budget is exhausted, the parser does something cleverer than giving up: it looks up the string in the string table without inserting it, and reuses the existing internalized copy if there is one:
// src/json/json-parser.cc:1966
} else {
base::Vector<const Char> chars(chars_ + string.start(),
string.length());
SequentialStringKey<Char> key(chars, HashSeed(isolate_),
string.needs_conversion());
auto existing =
isolate_->string_table()->TryLookupKey(isolate_, &key);
if (existing) return handle(*existing.value(), isolate_);
}So a payload of {"status":"ok","status":"ok","status":"ok",...} costs
one internalization and then a hash lookup per subsequent occurrence,
instead of one internalization per occurrence. The parser is not
internalizing because it is lazy; it is internalizing because it is
repeated.
The shape-prediction trick
Here is the part worth reading the source for. When the parser is inside a JSON array and has just parsed an object, it takes that object’s map and uses it as feedback for the next object:
// src/json/json-parser.cc:1442
Handle<Object> value;
if (V8_UNLIKELY(!ParseJsonValueRecursive().ToHandle(&value))) return {};
element_stack_.emplace_back(value);
while (Check<JsonToken::COMMA>()) {
Handle<Map> feedback;
if (IsJSObject(*value)) {
Tagged<Map> maybe_feedback = Cast<JSObject>(*value)->map();
// Don't consume feedback from objects with a map that's detached
// from the transition tree.
if (!maybe_feedback->IsDetached(isolate_)) {
feedback = handle(maybe_feedback, isolate_);
}
}
if (V8_UNLIKELY(!ParseJsonValueRecursive(feedback).ToHandle(&value))) {
return {};
}
element_stack_.emplace_back(value);
}The first element of the array is parsed the slow way. The second element is parsed with the first element’s map as a prediction: “this object will probably have the same fields in the same order”. The third gets the second’s map, and so on. If the prediction holds — same keys, same order — the object can be built in one shot, with the fields written directly into the object, bypassing the generic transition machinery entirely.
The prediction is checked in ParseJsonObjectProperties with a byte
comparison against the expected key, and the check is a pointer-and-length
comparison against the internalized key, not a string comparison:
// src/json/json-parser.cc:1096
template <typename Char>
bool JsonParser<Char>::FastKeyMatch(const uint8_t* key_chars,
uint32_t key_length,
JsonString scanned_key) {
return key_length == scanned_key.length() &&
CompareCharsEqual(key_chars, chars_ + scanned_key.start(), key_length);
}And when the prediction holds all the way through, BuildJsonObject
allocates the object with the feedback map and writes the fields directly:
// src/json/json-parser.cc:816
// Fast path for homogeneous object arrays:
// If ParseJsonObjectProperties<FastIterableState::kJsonFast> matched all
// property keys against the feedback map in exact sequence without any
// trailing properties (fast_keys_matched == true) and the object contains no
// indexed array elements (cont.elements == 0), we can bypass
// JSDataObjectBuilder entirely.
The “no indexed array elements” condition matters: the fast path sets the elements backing store to the empty fixed array, and the map’s elements kind has to be compatible with that. And every value is checked against the representation the feedback map expects, with a bail-out to the generic builder if a value does not fit:
// src/json/json-parser.cc:851
Tagged<Object> value = *property_stack_[start + i].value;
Representation rep = details.representation();
if (!Object::FitsRepresentation(value, rep)) {
fast_path_valid = false;
break;
}This is the same idea as the inline cache that remembers a map, applied to a data format: the shape of the previous element predicts the shape of the next one, and if the prediction holds you skip the expensive generic path.
The reviver: a second pass, with snapshots
JSON.parse(text, reviver) runs the reviver after the parse, in a
separate walk over the already-built object graph. The walk is
JsonParseInternalizer, and it is where user code finally runs.
The interesting part is the snapshot mechanism. The spec says that if
the reviver replaces the holder’s value with something else, the
source context argument is not passed. To implement that, the parser
records, for every primitive value, the source text it came from, and
for every object, a table of its children’s source texts:
// src/json/json-parser.cc:1476
// When should_track_json_source is true, we use val_node to record current
// JSON value's parse node.
//
// For primitive values, the val_node is the source string of the JSON value.
//
// For JSObject values, the val_node is an ObjectHashTable in which the key is
// the property name and the first value is the property value's parse node.
Collecting all that is expensive, so V8 only does it when the reviver actually needs it. The check is on the reviver’s arity, and it is documented in the source:
// src/json/json-parser.cc:2229
bool collect_source_strings = IsCallable(*reviver);
if (IsJSFunction(*reviver)) {
Handle<SharedFunctionInfo> reviver_sfi(Cast<JSFunction>(*reviver)->shared(),
isolate);
if (reviver_sfi->CanOnlyAccessFixedFormalParameters() &&
reviver_sfi->internal_formal_parameter_count_without_receiver() < 3) {
// The reviver function has only one or two arguments, so we don't need
// to collect the information on the source for each primitive value.
//
// Note that on the first run, we don't get here because the optimizers
// have not set enough information on the shared function info. So we
// will do the slow JSON parsing initially until the compiler has
// optimized more.
collect_source_strings = false;
}
}That comment is a small lesson in how V8 features interact: the feedback
has to be warm before the optimisation kicks in, so the first few calls
to JSON.parse with a reviver pay the full price. The optimisation is
driven by TurboFan’s inference about the reviver’s formal parameters, not
by the parser.
The snapshot check itself is a SameValue comparison against the value
the parser produced:
// src/json/json-parser.cc:190
ReviverMode reviver_mode;
if (initial_reviver_mode == kWithSource &&
!Object::SameValue(*value, *snapshot)) {
reviver_mode = NoSource(initial_reviver_mode);
} else {
reviver_mode = initial_reviver_mode;
}JSON.stringify: two implementations
The stringifier has the same two-speed structure, and the fast path is gated on a flag and on the absence of a replacer or indent:
// src/json/json-stringifier.cc:3582
bool CanUseFastStringifier(DirectHandle<JSAny> replacer,
DirectHandle<Object> gap) {
// TODO(pthier): Support gap on fast-path.
return v8_flags.json_stringify_fast_path && IsUndefined(*replacer) &&
IsUndefined(*gap);
}The fast stringifier (FastJsonStringifier) is a completely separate
implementation with its own buffer, its own continuation stack and its own
escape logic. It only handles objects whose prototype is the pristine
Object.prototype or Array.prototype and whose map has no “interesting
properties” (that is, no toJSON):
// src/json/json-stringifier.cc:2371
V8_INLINE bool CanFastSerializeJSArrayFastPath(Tagged<JSArray> object,
Tagged<HeapObject> initial_proto,
Isolate* isolate) {
// Check if the prototype is the initial array prototype without interesting
// properties (toJSON).
Tagged<Map> map = object->map();
if (V8_UNLIKELY(map->may_have_interesting_properties())) return false;
Tagged<HeapObject> proto = map->prototype();
// Note: This will also fail for sub-objects in different native contexts.
// This should be rare and bailing-out to the slow-path is fine.
return V8_LIKELY(proto == initial_proto);
}The “no user code” guarantee is exactly this check. The fast path never
calls toJSON, never reads a getter, never coerces a key with toString
— because it only runs on objects that provably have none of those. The
slow path calls all of them:
// src/json/json-stringifier.cc:990
DirectHandle<JSAny> initial_value = object;
if (!IsSmi(*object)) {
InstanceType instance_type =
Cast<HeapObject>(*object)->map()->instance_type();
if ((InstanceTypeChecker::IsJSReceiver(instance_type) &&
MayHaveInterestingProperties(isolate_, Cast<JSReceiver>(*object))) ||
InstanceTypeChecker::IsBigInt(instance_type)) {
if (!need_stack_ && stack_nesting_level_ > 0) {
need_stack_ = true;
return NEED_STACK;
}
need_stack_ = true;
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate_, object, ApplyToJsonFunction(object, key), EXCEPTION);
}
}The escaping of string contents is vectorised too, with both a SWAR (32/64-bit-at-a-time) path and a SIMD path:
// src/json/json-stringifier.cc:3521
template <typename Char>
template <typename SrcChar>
bool FastJsonStringifier<Char>::AppendStringSIMD(
const SrcChar* chars, size_t length,
const DisallowGarbageCollection& no_gc) {The SIMD loop tests four things at once — control characters, the quote, the backslash, and (for two-byte strings) the surrogate range:
// src/json/json-stringifier.cc:3547
const auto has_lt_0x20 = hw::Lt(input, mask_0x20);
const auto has_0x22 = hw::Eq(input, mask_0x22);
const auto has_0x5c = hw::Eq(input, mask_0x5c);
auto result = hw::Or(hw::Or(has_lt_0x20, has_0x22), has_0x5c);
if constexpr (sizeof(SrcChar) == sizeof(base::uc16)) {
// Checks input >= 0xd800 && input <= 0xdfff.
const auto has_surrogate =
hw::Eq(hw::And(input, mask_0xf800), mask_0xd800);
result = hw::Or(result, has_surrogate);
}Lone surrogates have to be escaped in JSON output (\udXXX), because
JSON text must be valid Unicode, and a lone surrogate is not. That is why
the surrogate check is in the vectorised loop at all.
One more small thing worth stealing: the stringifier caches the escaped form of property keys, because a JSON array of objects with the same keys repeats the same key string over and over. The cache is a 64-entry direct-mapped table indexed by the key’s pointer, and it is cleared on every GC because the GC can move strings:
// src/json/json-stringifier.cc:330
//
// - Internalized, sequential, one-byte string
// - Contains no characters which need escaping
//
// This can be helpful because it's common for JSON to have lists of similar
// objects. Since property keys are internalized, we will see identical key
// pointers again and again, and we can use a fast path to copy those keys to
// the output. However, strings can be externalized any time JS runs, so the
// caller is responsible for checking whether a string is still the expected
// type. This cache is cleared on GC, since the GC could move those strings.
// Using Handles for the cache has been tried, but is too expensive to set up
// when JSON.stringify is called for tiny inputs.
Proxies and the guarantee
JSON.parse never produces a proxy and never calls user code while
parsing. It builds plain objects and plain arrays, and the only JavaScript
that can run during a JSON.parse is (a) the toString coercion of the
argument, which happens before the parser starts, and (b) the reviver,
which happens after the whole graph exists.
Proxies are a stringifier problem, and they are handled on the slow path:
// src/json/json-stringifier.cc:1101
// Go to slow path for global proxy and objects requiring access checks.
if (deferred_string_key) SerializeDeferredKey(comma, key);
if (InstanceTypeChecker::IsJSProxy(instance_type)) {
return SerializeJSProxy(Cast<JSProxy>(object), key);
}SerializeJSProxy calls Object::IsArray and then reads the length
through the proxy, which means the proxy’s traps run, which means user
code runs. If the proxy has been revoked in the meantime, the trap
machinery throws:
// src/common/message-template.h:319
T(ProxyRevoked, "Cannot perform '%' on a proxy that has been revoked") \
The interesting detail is where the check lives. It is not in the JSON
code at all. The JSON stringifier just calls Object::IsArray and
Object::GetLengthFromArrayLike; the proxy implementation is what
notices that the handler is no longer a JSReceiver and throws. The
JSON code has no idea that proxies can be revoked — it only knows that
some objects need the slow path.
Trade-offs
What it buys. JSON.parse of a 50 MB payload runs at a few hundred
MB/s, which is roughly an order of magnitude faster than the equivalent
JavaScript eval or Function construction, because none of the
JavaScript machinery is involved. The SIMD string scanner, the Smi-first
number path, the shape prediction and the internalization budget are each
worth a large factor on their own.
What it costs.
- A second implementation. There are two JSON parsers (
uint8_tanduint16_t), two JSON stringifiers (fast and slow), and aJsonParserinsrc/torque/ls/that is a third, unrelated JSON parser for the Torque language server. Every JSON grammar change has to be made in at least the two real ones. - Heuristics with cliffs. The internalization budget is a cliff: the 100 001st short string behaves differently from the 100 000th. The shape prediction is a cliff: an array of objects where one object has an extra key throws the prediction away for that element.
- Warm-up.* The reviver fast path depends on TurboFan having inferred
the reviver’s arity. Until then, every
JSON.parsewith a reviver collects the full source text of every value. - The fast stringifier does less.
JSON.stringifywith atoJSONmethod, a replacer function, an indent, a getter, or a proxy all fall off the fast path. The fast path is not “faster at the same thing”; it is “the same thing, for a restricted class of inputs”.
The bottom line
V8’s JSON parser is not a special case of the JavaScript parser. It is a separate, hand-written, table-driven machine that makes three specific decisions differently from the JavaScript parser: it keeps a raw pointer into the source instead of building tokens, it scans strings with SIMD instead of a character loop, and it predicts object shapes from the previous object rather than from a call-site feedback vector.
The mental model to keep: the JSON parser is a shape-aware allocator that happens to read text. The interesting part is not the grammar — that is trivial — but the decisions about what to allocate and when to believe the input will look like the last one.
The thing people get wrong
“JSON.parse is fast because it is a small grammar” is half true. The
grammar is small, but the values are not: a JSON payload is a big
graph of objects and arrays, and the cost of building that graph
dominates. The parser’s real trick is not the scanning — it is that
[{"a":1},{"a":2},{"a":3}] costs about the same as {"a":1}, because
the second and third objects are built from the first object’s map.
And the corollary: JSON.parse is not a way to avoid allocation. It
allocates a full object graph, and the objects it allocates are ordinary
V8 objects with ordinary maps, ordinary property backing stores and
ordinary strings. The only thing it avoids is running JavaScript.