perf: schema-driven byte scanner for isValid(buffer)#20
Merged
Conversation
Add a third validation path alongside the existing on-demand and DOM paths: walks the JSON buffer directly using the od_plan's shape, never invoking simdjson. Saves the simdjson on-demand floor on small documents. Eligibility is computed once at compile time (fast_scan_eligible) for schemas whose root validates an object and whose constraints fall within the v1 scope: integer/string/boolean/null, nested objects, primitive arrays, length/pattern/format/enum constraints, required tracking, additionalProperties:false. Returns scan_result::fallback at runtime on any unsupported feature (string escapes, decimal/exponent numbers, unknown keys without no_additional, integer overflow), at which point the caller resumes from the existing on-demand path on the original buffer. Hot-path optimizations: inline uint64_t key comparison for keys ≤ 8 ASCII bytes (avoids memcmp), SoA cache layout for the property dispatch loop, and SWAR scanning of string contents (8 bytes per chunk, finds '"' / '\\' / <0x20 in a single bit-trick). Numbers on the reference Fastify-style schema (8 props, 164B payload, M4 Max): 191 ns/op -> 135 ns/op (29% reduction).
…ot helpers Five micro-optimizations on top of the schema-driven scanner: * Batched key SWAR: a single 8-byte load now feeds both the terminator search and the key_first8 hash, eliminating the redundant memcpy that the first version did per property key. * Branchless skip_ws: the dense-JSON fast path is now a single `*p > 0x20` test that exits with no taken branch — the loop body only runs when whitespace is actually present. * always_inline on fast_scan_string and scan_integer_value: each was called from a single hot site and the function-call overhead dominated the body for short strings / small integers. * Length-implicit dispatch: key_first8 is masked by length at compile time, so the lookup loop drops the separate length comparison — one uint64 cmp per entry replaces (length cmp + bytes cmp). * Removed redundant pre-checks in scan_integer_value; both call sites already guarantee `p < end && (*p == '-' || *p in 0..9)`. isValid(buffer) on the reference Fastify-style schema (8 props, 164B, M4 Max): 135 ns/op -> 118 ns/op. Combined with the previous commit, total improvement vs master is 191 -> 118 (38% reduction). Verification unchanged: test.js 33/33, test_compat 6/6, test_standard_schema 16/16, run_suite 1175 passed (15 baseline failures unchanged), fuzz_differential 10000/10000 clean.
Capture property declaration order from the source schema and emit entries[] in that order (instead of "required first then unordered_map"). At validation time, the byte scanner tries entries[next_idx] first; on a hit (the common case for typed-object serialization, framework request bodies, generated code) the per-property dispatch resolves in a single uint64 cmp with no loop body. Linear scan remains as the cold path for out-of-order keys. Empirical hit rate on the reference Fastify-style schema (8 props + 3-prop nested address) is 100% — JSON.stringify output matches schema declaration order. Linear scan was already branch-predicted well, so the wall-clock gain on this 8-property workload is modest (118 -> 116 ns), but the worst-case dispatch cost no longer scales with property count: a 32-property schema with in-order JSON pays the same dispatch cost as an 8-property one. Verification: test.js 33/33, test_compat 6/6, test_standard_schema 16/16, run_suite 1175 passed (15 baseline failures unchanged), fuzz_differential 10000/10000 clean.
Cache vector .data()/.size() pointers above the per-property loop and mark check_format_by_id / fast_check_email always_inline so the format check sits inline in the dispatch hot path. ~2 ns gain on the reference schema. isValid(buffer) reference numbers (M4 Max, 7 runs): master e67ff55: 191 ns/op this commit: 115 ns/op (best 114.6, worst 120.9, median 117) 40% reduction overall vs master. Verification unchanged: 33/33 + 6/6 + 16/16 + 1175 (15 baseline) + 10k fuzz clean.
Most JSON validation traffic is dense (no whitespace between tokens).
Skip_ws's fast path already short-circuits on a non-ws byte, but the
function-call dispatch and bounds check still cost ~3-5 cycles per
invocation. Inline a single 'is byte ≤ 0x20' guard at each call site
inside the object-property loop, so dense JSON pays one load + one
predicted branch and skip_ws is only entered when whitespace is
actually present.
Combined the post-value structural check (',' / '}') into the same
"dense first, ws fallback" shape so the compiler can keep both bytes
in registers across compares.
isValid(buffer) on the reference schema (M4 Max, 7 runs):
before: 115 ns/op median (114.6 - 120.9)
after: 115 ns/op median (113.1 - 121.9, mostly 113-116)
Tightening the dense path; gain is small but cumulative with prior
commits.
Verification unchanged: 33/33 + 6/6 + 16/16 + 1175/1190 (15 baseline
fails) + 10k fuzz clean.
Windows CI was failing because MSVC doesn't recognize __attribute__((always_inline)) or __builtin_ctzll, both used by the new byte scanner. - Define ATA_ALWAYS_INLINE that maps to __forceinline on MSVC and __attribute__((always_inline)) inline elsewhere; replace the five call sites in the scanner with the macro. - Provide a __builtin_ctzll shim built on _BitScanForward64 in the existing MSVC compatibility block (sits next to __builtin_popcount). Mac build and benches unchanged: 33/33 tests, 113 ns/op.
Two regressions surfaced during a careful review of the eligibility and
dispatch paths.
* Speculation false-match on long-key entries. The hot lookup was
`hk[next_idx] == key_first8`. For schema entries with keys longer than
8 bytes (or non-ASCII keys) hk[i] was 0; for long JSON keys
key_first8 was also 0. The compare matched, and the wrong entry was
used. Fix: store HOT_KEY_SENTINEL (0xFFFFFFFFFFFFFFFF) instead of 0
for non-cached entries. Real JSON keys can never produce that value
because the scanner bails on high-bit bytes, so the comparison stays
a single uint64 cmp with no extra guard.
* Root type_mask not validated against plan structure. A schema like
{"type":"string","properties":{"x":...}} sets both type_mask=string
and plan.object. The scanner walked the object structure and
reported valid even though the root payload type was wrong. Fix: the
eligibility pass now rejects schemas where plan.object is set but
type_mask doesn't permit object (and the same for arrays); those
schemas fall through to the on-demand path, which honors type_mask.
Both bugs were latent in the merged scanner. The fuzz suite did not
catch them: random schemas use 1–2 character property names, so
key_first8 is never 0, and they never combine non-object type masks
with property declarations.
Adds tests/test_scanner_regression.js (11 cases covering long keys in
declaration / reverse order, mixed short+long keys, type-mask
mismatches, 8-char boundary keys) and wires it into CI.
Verification on M4 Pro:
- tests/test_scanner_regression.js: 11/11
- test.js: 33/33
- tests/test_compat.js: 6/6
- tests/test_standard_schema.js: 16/16
- tests/run_suite.js: 1175 passed (15 baseline failures unchanged)
- tests/fuzz_differential.js: 10000/10000 vs Ajv
- benchmark/profile_native_path.mjs: 113-116 ns/op (no regression vs
pre-fix scanner perf)
Independent code review caught a saturation gap in the on-demand / scanner required-tracking mask: required_count beyond 64 silently saturates required_mask to ~0, so a JSON payload missing a required field whose index is 64+ would still pass validation. This is pre-existing on the on-demand path; the new scanner inherits it. Set plan->supported = false in compile_od_plan when a node declares more than 64 required keys, so both the byte scanner and the on-demand fast path are bypassed and the DOM tree walker handles the schema. The walker iterates required[] directly with no such limit. Also keep a defensive guard in the scanner eligibility pass for the same condition; cheap insurance against future code that might forget to propagate the supported flag. Adds two regression tests covering the all-present and one-missing shapes for a 70-required schema, plus an 8-char-key boundary fix (slow path now promotes the original 8-byte load into the inline hash so 8-char keys take the fast dispatch instead of always falling to memcmp), and removes the unused neon_string_term_pos helper that was left behind from an earlier experiment. Verification on M4 Pro: - tests/test_scanner_regression.js: 13/13 - test.js: 33/33 - tests/test_compat.js: 6/6 - tests/test_standard_schema.js: 16/16 - tests/run_suite.js: 1175 passed (15 baseline failures unchanged) - tests/fuzz_differential.js: 10000/10000 vs Ajv - benchmark/profile_native_path.mjs: 111-117 ns/op
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a third validation path, a schema-driven byte scanner that bypasses simdjson for eligible schemas. isValid(buffer) on the Fastify-style reference schema: 191 -> 113 ns/op, 3.7x V8 JSON.parse.
Eligibility is computed once at compile time. Anything outside scope (escapes, $ref, oneOf, unevaluated*, etc.) falls back to the existing on-demand path, no behavioral change.
Test plan