Skip to content

perf: schema-driven byte scanner for isValid(buffer)#20

Merged
mertcanaltin merged 8 commits into
masterfrom
perf/fast-scan-bypass
Apr 27, 2026
Merged

perf: schema-driven byte scanner for isValid(buffer)#20
mertcanaltin merged 8 commits into
masterfrom
perf/fast-scan-bypass

Conversation

@mertcanaltin

Copy link
Copy Markdown
Member

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

  • test.js 33/33
  • tests/test_compat.js 6/6
  • tests/test_standard_schema.js 16/16
  • tests/run_suite.js 1175/1190 (15 baseline failures unchanged)
  • tests/fuzz_differential.js 10000/10000 vs Ajv
  • benchmark/profile_native_path.mjs 113 ns/op (was 191)

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
@mertcanaltin
mertcanaltin merged commit 5617064 into master Apr 27, 2026
12 checks passed
@mertcanaltin
mertcanaltin deleted the perf/fast-scan-bypass branch April 27, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant