From 79a7120e5fcf38505150488289fa36cb2765842a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 17:31:02 -0500 Subject: [PATCH] Speed up JSON.parse and JSON.stringify scanning - The number and whitespace scanners keep the cursor in locals and write p->pos once: the runtime builds without strict aliasing, so a loop on p->pos stored it after every byte. - Typed arrays gather parsed elements in a 32-slot run and append them with scr_arr_push_many instead of one push per element. - The string scan shared by parse and stringify takes the index of the first quote, backslash or control byte from the marked word on little-endian hosts, and checks the last 1-7 bytes of a string as one word, so a short plain string needs no byte loop. - Typed record parsing computes once per record schema and parse which field names hold no byte JSON would escape, instead of testing the name on every direct key match. - The mantissa converts to double through int64, and the positioned error report stays out of line. --- packages/runtime/src/scr_json.c | 196 ++++++++++++++++++++++---------- 1 file changed, 139 insertions(+), 57 deletions(-) diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 9fae63ba7..89c098fa6 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -311,9 +311,10 @@ void scr_jb_put_f64(ScrJsonBuf *b, double v) { /* Find the next quote, backslash or control byte. memcpy keeps the word * loads unaligned-safe, and the length guard never reads past the input. - * The masks only decide whether to inspect a word byte by byte: borrow - * propagation may mark extra lanes, so it must not determine a byte index. - * High UTF-8 bytes are ordinary data, independent of host byte order. */ + * Borrow propagation can mark extra lanes, but only above a lane that + * holds a special byte, so on little-endian hosts the lowest marked lane + * is the answer; other hosts inspect the marked word byte by byte. High + * UTF-8 bytes are ordinary data. */ static size_t scr_json_plain_bytes(const char *data, size_t len) { const uint64_t ones = UINT64_C(0x0101010101010101); const uint64_t high = UINT64_C(0x8080808080808080); @@ -326,9 +327,41 @@ static size_t scr_json_plain_bytes(const char *data, size_t len) { uint64_t special = ((quotes - ones) & ~quotes) | ((slashes - ones) & ~slashes) | ((word - ones * 0x20) & ~word); - if (special & high) break; + special &= high; + if (special) { +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + /* A borrow only marks lanes above a lane that really matched, so + * the lowest marked lane is the first special byte. */ + return i + (size_t)__builtin_ctzll(special) / 8; +#else + break; +#endif + } i += sizeof word; } + /* The last 1..7 bytes as one word: two overlapping 4-byte loads, or the + * first, middle and last byte of a shorter rest with the remaining lanes + * filled with a plain byte. A plain rest (the common case: most strings + * are short) ends the scan; otherwise the bytes are inspected one by one. */ + size_t rest = len - i; + if (rest) { + uint64_t word; + if (rest >= 4) { + uint32_t lo, hi; + memcpy(&lo, data + i, sizeof lo); + memcpy(&hi, data + len - 4, sizeof hi); + word = (uint64_t)lo | (uint64_t)hi << 32; + } else { + word = (uint64_t)(unsigned char)data[i] | (uint64_t)(unsigned char)data[i + rest / 2] << 8 | + (uint64_t)(unsigned char)data[len - 1] << 16 | UINT64_C(0x4141414141000000); + } + uint64_t quotes = word ^ (ones * '"'); + uint64_t slashes = word ^ (ones * '\\'); + uint64_t special = ((quotes - ones) & ~quotes) | + ((slashes - ones) & ~slashes) | + ((word - ones * 0x20) & ~word); + if (!(special & high)) return len; + } while (i < len) { unsigned char c = (unsigned char)data[i]; if (c < 0x20 || c == '"' || c == '\\') break; @@ -9008,6 +9041,11 @@ typedef struct { ScrDynRegion *region; /* tags completed containers (ScrDynRegion); may be NULL */ struct ScrJsonShape *shapes; /* key-sequence transitions (scr_json_object) */ uint32_t nshapes, shape_cap; + /* Schema records whose plain field names are known (scr_jschema_plain): + * bit i of the mask is set when field i's name holds no byte JSON text + * would escape. */ + const struct ScrJsonSchema *plain_schema[4]; + uint64_t plain_mask[4]; } ScrJsonP; static void scr_json_throw(const ScrJsonP *p, const char *msg) { @@ -9015,7 +9053,10 @@ static void scr_json_throw(const ScrJsonP *p, const char *msg) { scr_throw_error_msg(SCR_ERR_SYNTAX, msg, strlen(msg)); } -static void scr_json_throw_pos(const ScrJsonP *p, const char *what, size_t pos) { +/* Errors stay out of line so the scanners that report them keep their + * registers for the common path. */ +static __attribute__((noinline, cold)) void scr_json_throw_pos(const ScrJsonP *p, const char *what, + size_t pos) { if (p->quiet) return; char buf[128]; int n = snprintf(buf, sizeof buf, "%s in JSON at position %zu", what, pos); @@ -9036,11 +9077,15 @@ static void scr_json_throw_token(const ScrJsonP *p) { } static void scr_json_ws(ScrJsonP *p) { - while (p->pos < p->len) { - char c = p->s[p->pos]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') p->pos++; + /* A local cursor, as in scr_json_number_value. */ + const char *s = p->s; + size_t pos = p->pos; + while (pos < p->len) { + char c = s[pos]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') pos++; else break; } + p->pos = pos; } /* Append the WTF-8 encoding of a code point or lone surrogate unit; a low @@ -9191,7 +9236,14 @@ static const double scr_json_pow10[23] = { * (thrown unless quiet). Shared by the dyn tree and the schema parser so * both produce bit-identical values. */ static bool scr_json_number_value(ScrJsonP *p, double *out) { - size_t start = p->pos; + /* The cursor and text stay in locals: the runtime builds without strict + * aliasing, so a byte read through p->s could alias p->pos, and a loop + * on p->pos would store it on every digit. p->pos is written once, when + * the token ends. */ + const char *s = p->s; + const size_t len = p->len; + const size_t start = p->pos; + size_t pos = start; /* Grammar validation and value accumulation in one pass. Clinger's fast * path: with at most 15 significant digits the mantissa is exact in a * double, and scaling by an exact power of ten (|exp| <= 22) rounds @@ -9200,35 +9252,37 @@ static bool scr_json_number_value(ScrJsonP *p, double *out) { int ndig = 0; /* significant digits folded into mant */ int exp10 = 0; /* decimal exponent (fraction shift + explicit exponent) */ bool neg = false, precise = true; - if (p->s[p->pos] == '-') { + if (s[pos] == '-') { neg = true; - p->pos++; - if (p->pos >= p->len || p->s[p->pos] < '0' || p->s[p->pos] > '9') { - scr_json_throw_pos(p, "No number after minus sign", p->pos); + pos++; + if (pos >= len || s[pos] < '0' || s[pos] > '9') { + p->pos = pos; + scr_json_throw_pos(p, "No number after minus sign", pos); return false; } } - if (p->s[p->pos] == '0') { - p->pos++; + if (s[pos] == '0') { + pos++; } else { - while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { + while (pos < len && s[pos] >= '0' && s[pos] <= '9') { if (ndig < 15) { - mant = mant * 10 + (uint64_t)(p->s[p->pos] - '0'); + mant = mant * 10 + (uint64_t)(s[pos] - '0'); ndig++; } else { precise = false; } - p->pos++; + pos++; } } - if (p->pos < p->len && p->s[p->pos] == '.') { - p->pos++; - if (p->pos >= p->len || p->s[p->pos] < '0' || p->s[p->pos] > '9') { - scr_json_throw_pos(p, "Unterminated fractional number", p->pos); + if (pos < len && s[pos] == '.') { + pos++; + if (pos >= len || s[pos] < '0' || s[pos] > '9') { + p->pos = pos; + scr_json_throw_pos(p, "Unterminated fractional number", pos); return false; } - while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { - unsigned digit = (unsigned)(p->s[p->pos] - '0'); + while (pos < len && s[pos] >= '0' && s[pos] <= '9') { + unsigned digit = (unsigned)(s[pos] - '0'); if (mant == 0 && digit == 0) { exp10--; /* leading fractional zeros scale without a digit */ } else if (ndig < 15) { @@ -9238,29 +9292,33 @@ static bool scr_json_number_value(ScrJsonP *p, double *out) { } else { precise = false; } - p->pos++; + pos++; } } - if (p->pos < p->len && (p->s[p->pos] == 'e' || p->s[p->pos] == 'E')) { - p->pos++; + if (pos < len && (s[pos] == 'e' || s[pos] == 'E')) { + pos++; bool eneg = false; - if (p->pos < p->len && (p->s[p->pos] == '+' || p->s[p->pos] == '-')) { - eneg = p->s[p->pos] == '-'; - p->pos++; + if (pos < len && (s[pos] == '+' || s[pos] == '-')) { + eneg = s[pos] == '-'; + pos++; } - if (p->pos >= p->len || p->s[p->pos] < '0' || p->s[p->pos] > '9') { - scr_json_throw_pos(p, "Exponent part is missing a number", p->pos); + if (pos >= len || s[pos] < '0' || s[pos] > '9') { + p->pos = pos; + scr_json_throw_pos(p, "Exponent part is missing a number", pos); return false; } int ev = 0; - while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { - if (ev < 100000) ev = ev * 10 + (p->s[p->pos] - '0'); - p->pos++; + while (pos < len && s[pos] >= '0' && s[pos] <= '9') { + if (ev < 100000) ev = ev * 10 + (s[pos] - '0'); + pos++; } exp10 += eneg ? -ev : ev; } + p->pos = pos; if (precise && exp10 >= -22 && exp10 <= 22) { - double v = (double)mant; /* exact: mant < 10^15 < 2^53 */ + /* exact: mant < 10^15 < 2^53; the signed conversion is one + * instruction on x86-64, the unsigned one a sequence */ + double v = (double)(int64_t)mant; if (exp10 > 0) v *= scr_json_pow10[exp10]; else if (exp10 < 0) v /= scr_json_pow10[-exp10]; *out = neg ? -v : v; @@ -9270,7 +9328,7 @@ static bool scr_json_number_value(ScrJsonP *p, double *out) { * grammar above is a strict subset of what strtod accepts). The ScrStr * data is NUL-terminated, and strtod stops at the first non-number char, * so parsing from `start` reads exactly the validated token. */ - *out = strtod(p->s + start, NULL); + *out = strtod(s + start, NULL); return true; } @@ -9867,26 +9925,38 @@ static __attribute__((noinline)) bool scr_jschema_absent(const ScrJsonSchema *s, return true; } -/* The quoted key at p->pos when it is exactly `f`'s name, written without - * escapes: the bytes consumed (name length + 2), else 0. Names of more - * than eight bytes, and names holding a byte JSON text would escape, - * take the general key scan. */ +/* Bit i set when field i's name (at most eight bytes) holds no quote, + * backslash or control byte, so text that spells it unescaped is exactly + * that key. Names are schema constants: each record schema computes its + * mask once per parse, in a four-entry table. */ +static uint64_t scr_jschema_plain(ScrJsonP *p, const ScrJsonSchema *s) { + size_t slot = ((uintptr_t)s >> 4) & 3; + if (p->plain_schema[slot] == s) return p->plain_mask[slot]; + uint64_t mask = 0; + for (size_t i = 0; i < s->nfields && i < 64; i++) { + const ScrJsonSchemaField *f = &s->fields[i]; + bool plain = f->name_len > 0 && f->name_len <= 8; + for (size_t k = 0; plain && k < f->name_len; k++) { + unsigned char c = (unsigned char)f->name[k]; + plain = c >= 0x20 && c != '"' && c != '\\'; + } + if (plain) mask |= (uint64_t)1 << i; + } + p->plain_schema[slot] = s; + p->plain_mask[slot] = mask; + return mask; +} + +/* The quoted key at p->pos when it is exactly `f`'s name, a plain name of + * at most eight bytes (scr_jschema_plain): the bytes consumed (name + * length + 2), else 0. Other names take the general key scan. */ static inline size_t scr_jschema_key_direct(const ScrJsonP *p, const ScrJsonSchemaField *f) { size_t len = f->name_len; size_t at = p->pos + 1; - if (len == 0 || len > 8 || len >= p->len - at) return 0; + if (len >= p->len - at) return 0; const char *text = p->s + at; - uint64_t word = scr_key_short_word(f->name, len); - if (text[len] != '"' || scr_key_short_word(text, len) != word) return 0; - /* The packed word holds every name byte (lengths one to three use its - * low three bytes). A quote, backslash or control byte sends the key to - * the general scan, as in scr_json_plain_bytes. */ - const uint64_t ones = UINT64_C(0x0101010101010101), high = UINT64_C(0x8080808080808080); - uint64_t quotes = word ^ (ones * '"'), slashes = word ^ (ones * '\\'); - uint64_t special = ((quotes - ones) & ~quotes) | ((slashes - ones) & ~slashes) | - ((word - ones * 0x20) & ~word); - uint64_t bytes = len >= 4 ? ~UINT64_C(0) : UINT64_C(0xffffff); - return (special & high & bytes) ? 0 : len + 2; + if (text[len] != '"' || scr_key_short_word(text, len) != scr_key_short_word(f->name, len)) return 0; + return len + 2; } /* An undeclared member of a record with an index signature, after its @@ -10014,6 +10084,7 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot const uint64_t all = n == 64 ? ~(uint64_t)0 : (((uint64_t)1 << n) - 1); uint64_t seen = 0; size_t guess = 0; + const uint64_t plain = scr_jschema_plain(p, s); scr_json_ws(p); if (p->pos < p->len && p->s[p->pos] == '}') { p->pos++; @@ -10025,7 +10096,8 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot /* Members usually arrive in declaration order: try the successor of * the previous match, directly against the text, before scanning. */ size_t idx = n; - size_t direct = guess < n ? scr_jschema_key_direct(p, &s->fields[guess]) : 0; + size_t direct = + guess < n && (plain >> guess & 1) ? scr_jschema_key_direct(p, &s->fields[guess]) : 0; if (direct) { idx = guess; p->pos += direct; @@ -10104,13 +10176,21 @@ static bool scr_jschema_array(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot * out->ptr = arr; return true; } + /* Elements gather in a local run and append in bulk (scr_arr_push_many, + * which moves references in as scr_arr_push_ref does). */ + uint64_t run[32]; + size_t n = 0; for (;;) { ScrJsonSlot v; if (!scr_jschema_member(p, elem, &v)) goto fail; switch (elem->kind) { - case SCR_JSCHEMA_F64: scr_arr_push_f64(arr, v.f); break; - case SCR_JSCHEMA_BOOL: scr_arr_push_bool(arr, v.b); break; - default: scr_arr_push_ref(arr, v.ptr); break; /* moves in */ + case SCR_JSCHEMA_F64: memcpy(&run[n++], &v.f, sizeof v.f); break; + case SCR_JSCHEMA_BOOL: run[n++] = v.b ? 1 : 0; break; + default: run[n++] = (uint64_t)(uintptr_t)v.ptr; break; + } + if (n == sizeof run / sizeof run[0]) { + scr_arr_push_many(arr, n, run); + n = 0; } scr_json_ws(p); if (p->pos >= p->len) goto fail; @@ -10120,10 +10200,12 @@ static bool scr_jschema_array(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot * } if (p->s[p->pos] != ']') goto fail; p->pos++; + scr_arr_push_many(arr, n, run); out->ptr = arr; return true; } fail: + scr_arr_push_many(arr, n, run); /* the array releases what it holds */ s->release(arr); return false; }