Skip to content

jsonvalidate

CI coverage

Valid(data []byte) bool — a drop-in replacement for encoding/json.Valid that returns the byte-identical verdict on every input while accelerating the hottest sub-scans of JSON validation with SIMD on all six 64-bit Go targets. No cgo, no GOEXPERIMENT, plain go build. As far as we know, it is the only SIMD JSON validator that covers all six of Go's vector architectures. Repository →

import "github.com/go-simd/jsonvalidate"

ok := jsonvalidate.Valid(data) // == encoding/json.Valid(data), always

Correctness is the gate, not a goal: Valid(data) == encoding/json.Valid(data) is asserted on the seed table, on 60 000 deterministic pseudo-random inputs on every arch (including under qemu), and by a go test -fuzz differential fuzzer on amd64 (AVX2 and SSE paths) and arm64. There is no input on which the answer can differ — the SIMD kernels only ever return an index the scalar reference would also have returned, and anything they don't accelerate runs through a faithful scalar reimplementation of encoding/json's scanner.

Why this exists

minio/simdjson-go is a superb full SIMD JSON parser, but it is amd64-only. jsonvalidate is scoped to validation — the common "is this valid JSON?" gate — and trades full-parse SIMD for portable SIMD on every architecture Go can target with vectors: amd64, arm64, riscv64, loong64, ppc64le and s390x.

Scope: what is SIMD, what is scalar (honest split)

A full simdjson "stage 1" computes, for a whole block, which structural characters fall inside strings. That needs a prefix-XOR over the quote bitmask, which on amd64 is a carry-less multiply (PCLMULQDQ) with no portable equivalent on the other five targets — so a single full-SIMD grammar across six arches is not feasible. jsonvalidate therefore keeps the grammar — bracket balancing, number/literal syntax, the exact 10 000-deep nesting limit, and every encoding/json acceptance rule — in a correct scalar state machine, and uses SIMD only for the two scans whose answer is a pure, position-independent "find the first byte in (or not in) a small set":

  • scanString — from just after an opening quote, find the first ", \, or control byte (< 0x20). (encoding/json does not validate UTF-8 inside strings, so a byte-class scan is exactly right.)
  • skipSpace — find the first byte that is not JSON whitespace (, \t, \n, \r).

Both kernels are shuffle/compare only — a per-byte nibble-LUT classification (c is in the set iff loLUT[c&0xF] & hiLUT[c>>4] != 0) plus a first-match extraction. No multiply, no cross-lane carry, so the same algorithm ports to all six arches. They are pure accelerators bolted onto an always-correct scalar validator.

Per-arch kernels

arch ISA classify first match
amd64 SSSE3 + AVX2 (V)PSHUFB ×2, (V)PAND (V)PMOVMSKB+BSF; AVX2 (32-byte) auto-selected via golang.org/x/sys/cpu
arm64 NEON VTBL ×2, VAND lane→GPR via VMOV V.D[i], RBIT+CLZ
riscv64 RVV VRGATHERVV ×2, VANDVV VMSNE/VMSEQ + vfirst.m
loong64 LSX VSHUFB ×2, VANDV lane→GPR via VMOVQ V.V[i], CTZV
ppc64le VSX VPERM ×2, VAND mask→GPR via MFVSRD, CNTTZD
s390x vector facility (big-endian) VPERM ×2, VN VFENEBS (find-element-not-equal w/ CC), index via VLGVB

VSX is baseline on POWER8+ and the vector facility on z13+, so ppc64le and s390x (like riscv64 and loong64) need no runtime feature dispatch — the SIMD path is the arch's only path, build-tagged. s390x is the one big-endian target: VL puts the lowest memory address in lane 0 and VFENEBS scans lane 0 upward, so the returned byte index is already the memory offset of the first match — the classification LUTs are verified in big-endian lane order by the position-dependent random/fuzz tests. The assembly is generated by go-asmgen (scan_*_gen.go; the .s files are committed).

Validation status per arch

arch how validated status
amd64 (AVX2 + SSE) native x86_64 VM, table + 60k random + -fuzz (both paths) native
arm64 native, table + 60k random + -fuzz native
riscv64 real SpacemiT X60 (RVV 1.0, GCC Compile Farm), table + 60k random + native bench native (measured)
loong64 qemu la464, table + 60k random qemu-validated
ppc64le real POWER9 (GCC Compile Farm, VSX), table + 60k random + native bench native (measured)
ppc64 (BE) real POWER9 (big-endian, scalar fallback), table + 60k random native build+test
s390x qemu (big-endian), table + 60k random qemu-validated; native perf pending

Six SIMD targets, validated on seven architectures.

Performance — honest

vs encoding/json.Valid (arm64, Apple silicon; representative, not a contest):

input jsonvalidate encoding/json ratio
string-heavy (long string values) ~710 MB/s ~515 MB/s ~1.4× faster
realistic mixed records ~465 MB/s ~490 MB/s ~on par
number-heavy array ~140 MB/s ~585 MB/s slower
compact-whitespace (4-space indent) ~215 MB/s ~490 MB/s slower

On real silicon (GCC Compile Farm, Go 1.26.4, 2026-06-26) the string fast path wins big where stdlib is slowest: string-heavy JSON runs ~8.5× the stdlib on real POWER9 (~406 vs ~48 MB/s) and ~5.2× on a real SpacemiT X60 (RVV 1.0, ~191 vs ~36.5 MB/s); number-/structure-heavy input stays on the scalar path with no speedup, the same honest split. s390x throughput is still pending native hardware.

The SIMD scans win where they apply — long string bodies, which dominate most real-world JSON (text, logs, API payloads). Where the document is mostly numbers, structure, or short (< 16-byte) whitespace runs, the scalar grammar dominates and encoding/json's long-tuned scanner is faster. jsonvalidate's value is correctness parity across six arches with a string fast path, not a universal speedup: if your payloads are number-dense, encoding/json.Valid is the better choice today (the scalar number scan is the obvious next thing to optimise). Valid allocates nothing on any path.

Coverage

100% statement coverage gated on every architecture (amd64 AVX2+SSE and arm64 natively, riscv64/loong64/ppc64le/s390x under QEMU). The seed table, 60k deterministic random inputs, and the differential fuzzer all assert exact parity with encoding/json.Valid. BSD-3-Clause.