feat: SIMD-accelerated glz::validate_utf8 (NEON/AVX2/SSSE3) with scalar fallback - #2629
feat: SIMD-accelerated glz::validate_utf8 (NEON/AVX2/SSSE3) with scalar fallback#2629stephenberry wants to merge 3 commits into
Conversation
Make the public stateless glz::validate_utf8 a thin wrapper over utf8_stream_validator (one-shot consume() + complete()) and delete its separate byte-by-byte implementation. This collapses the two UTF-8 validators introduced in #2625 into a single engine: the decoding rules (overlong, surrogate, and range checks) now live in one place and cannot drift, and the public API gets the faster adaptive ASCII-skip algorithm. The WebSocket single-frame text path drops its local utf8_stream_validator and calls validate_utf8(payload, length) again, now equivalent. The member utf8_validator_ stays reserved for multi-read and fragmented text streams, and the close-reason path keeps using the stateless validate_utf8. Verified behavior-identical to the pre-rewrite implementation: a 21.5M-case differential fuzz against a frozen copy of the old byte-by-byte validator finds zero divergences, and the existing utf8 and websocket test suites pass.
Add a SIMD UTF-8 range validator (Lemire/simdjson algorithm) for NEON, AVX2, and SSSE3, with the scalar utf8_stream_validator as the fallback. glz::validate_utf8 dispatches to it for buffers >= 512 bytes, where the fixed per-call SIMD overhead is amortized; smaller buffers and builds without SIMD keep the scalar path, so no input size regresses. Throughput on Apple M1 (NEON) vs the scalar path: ~+100% on large ASCII and ~+300% on multibyte-heavy text (the scalar codepoint-by-codepoint path's weak spot). Correctness: the NEON, AVX2, and SSSE3 implementations were each fuzzed against an independent scalar oracle (19M cases per ISA; the x86 paths run via Rosetta on the dev machine), and a new differential test in utf8_test cross-checks the dispatched validate_utf8 against a scalar reference on every CI architecture.
The NEON UTF-8 validator uses vqtbl1q_u8 (128-bit table lookup) and vmaxvq_u8 (horizontal max), which are AArch64-only. 32-bit ARM (AArch32/ armv7) defines __ARM_NEON and so GLZ_USE_NEON, but lacks these intrinsics, breaking the cross-compiled armv7 build. Require __aarch64__/_M_ARM64 for the NEON path; armv7 falls back to the scalar validator (matching simdjson, whose NEON kernel is likewise AArch64-only).
|
Hi Stephen, I'm slowly getting started with SIMD and wanted to ask you a question that's been on my mind for a while regarding Glaze and other projects that use SIMD without runtime dispatch. If it's not too much trouble, could you please explain why Glaze uses compile-time dispatch to determine the executable instruction set? As far as I understand, for server applications this is definitely more useful than runtime dispatch, since you can build the project with I understand that this would likely have a slight impact on the instruction cache, degrade (or probably even remove) possibility for inlining, etc., which would result in a slight performance drop. But what about client applications, for example, where it’s unknown which instruction sets are supported? Or am I underestimating the cost of runtime dispatch, and is it actually much more expensive than I think? |
|
@annihilatorq, I don't think the cost of runtime dispatch would be too large for bulk SIMD operations like UTF8 validation. In my experience, the benefits of |
Summary
Makes
glz::validate_utf8substantially faster, especially for large and multibyte-heavy text, by adding a SIMD UTF-8 range validator with a scalar fallback. Builds on the earlier consolidation in this PR (validate_utf8 backed by a singleutf8_stream_validatorengine).Two layers:
glz::validate_utf8runs one pass ofutf8_stream_validator(consume+complete). Single decoding engine, no rule duplication.glaze/simd/utf8.hpp, dispatched fromvalidate_utf8.The 512 B threshold is chosen so no input size regresses — below it, the fixed per-call SIMD block/remainder overhead doesn't pay off, so scalar wins; above it, SIMD wins decisively.
Performance (Apple M1, NEON, vs the scalar path)
The multibyte case is the headline: the scalar path validates codepoint-by-codepoint (~2.4 GB/s); the SIMD path validates 16/32 bytes per iteration regardless of content (~10 GB/s).
Correctness
glz::validate_utf8(with dispatch) was differentially verified on all four configs — NEON, AVX2, SSSE3, x86_64 scalar baseline — across sizes spanning the 512 B threshold. 0 mismatches.utf8_test.cppcross-checksvalidate_utf8against a scalar reference (~300k cases/run, sizes weighted around 512 B), so every CI architecture verifies its own SIMD path going forward.utf8_test(now 18 tests / 227 asserts),websocket_close_test, andwebsocket_client_testpass.Notes / portability
GLZ_USE_*SIMD convention: AVX2 if__AVX2__, else the 128-bit path ifGLZ_USE_SSE2 && __SSSE3__, else NEON on AArch64, else scalar. A default-baseline x86_64 build (no-mssse3/AVX2) uses the scalar path — no runtime CPU dispatch.parse.hppnow includesglaze/simd/utf8.hpp(which pulls the intrinsics header on SIMD targets). Disable withGLZ_DISABLE_SIMD.glz::validate_utf8remains a public, stateless API with unchanged semantics.