Skip to content

Run the SSE4.2 span scanners on default x86-64 builds via runtime dispatch - #896

Open
jenswl wants to merge 1 commit into
nodejs:mainfrom
jenswl:perf/sse42-dispatch
Open

jenswl wants to merge 1 commit into
nodejs:mainfrom
jenswl:perf/sse42-dispatch

Conversation

@jenswl

@jenswl jenswl commented Nov 8, 2026

Copy link
Copy Markdown

Problem

llparse has emitted SSE4.2 span scanners (_mm_cmpestri) for the header_value, header_value_relaxed and header_field_general states since #24 (2019), but they are guarded by #ifdef __SSE4_2__. Node.js builds with -O3 and no x64 -march, and so do the Debian/Ubuntu/Fedora/Alpine packages — so every shipped x86-64 binary contains zero pcmpestri and the scanners are dead code. They are live only where the build baseline is already ≥ x86-64-v2 (e.g. RHEL rebuilds) and on arm64 (NEON).

Passing -msse4.2 globally was rejected for good reasons (#58), and the 2019 runtime-dispatch attempt (#26) checked CPU features inside the state machine on every scan and measured slower.

Change

Compile the generated parser a second time with -msse4.2 under renamed internal symbols, and let llhttp_execute() pick it once per process with __builtin_cpu_supports("sse4.2"):

  • bin/generate.ts additionally emits build/c/llhttp_sse42_rename.h — #defines that rename the defined llhttp__internal* symbols to *_sse42, derived from the generated source so it stays correct across grammar changes — and the 7-line build/c/llhttp_sse42.c wrapper that includes the parser under those renames. The imported callbacks (llhttp__on_*, llhttp__before_*, llhttp__after_*) keep their names and stay shared.
  • src/native/api.c dispatches in llhttp_execute on x86-64 GCC/clang builds compiled without -msse4.2. Both instantiations come from the same generated source, so the llhttp_t layout is identical and a parser may be driven by either.
  • Makefile and CMakeLists.txt build the second object with -msse4.2, on x86-64 only, skipped on MSVC (no __builtin_cpu_supports). -march ≥ x86-64-v2 builds are unchanged (the dispatch compiles out; the main path already has the scanners).

It is one well-predicted branch per llhttp_execute call (per TCP chunk), not per byte or per span — the objection raised against #26 and in #58.

Measurements (AMD Zen 5)

Standalone harness against this parser (8 interleaved runs, 95% CI of the time delta):

request ns/req base → dispatch delta
4-header GET 69.7 → 66.4 −4.8% [−6.5, −3.0]
32-header GET 561.5 → 496.6 −11.6% [−12.6, −10.5]
browser GET (long Cookie) 405.9 → 332.8 −18.0% [−19.0, −17.1]
10 KiB POST 136.4 → 125.2 −8.2% [−10.5, −5.9]

perf stat (browser): instructions −43%, branches −47%, branch-misses −62%. Requests with short header values (the 4-header case) benefit least, as expected (the 16-byte block rarely fires).

Node benchmark/http/bench-parser.js via benchmark/compare.js --runs 30 (Welch t-test), a node built from this parser vs. the stock one:

improvement p
32 headers +4.81% 2.8e-8
16 headers +3.85% 2.8e-6
8 headers +2.34% 1.6e-3
4 headers +2.19% 4.1e-2

Binary size: the second parser copy adds ≈ 30 KB of .text; only one copy is hot in any process.

Correctness

A libFuzzer differential harness (ASan + UBSan) drove the baseline and the SSE4.2 instantiation with identical random chunking and lenient-flag combinations, comparing the error code and the full callback sequence (kind, offset, length, state). ≈ 5.5e8 executions, 0 divergences, no sanitizer report. The 16-byte SSE loads stay in bounds — the generated code already guards endp - p >= 16. Built and verified standalone (generator + make): base object 0 pcmpestri, SSE object 3; libllhttp.a exports both llhttp__internal_execute and llhttp__internal_execute_sse42; a program linked against it parses correctly with the SSE path selected.

Fuzzing also surfaced a pre-existing infinite loop (unrelated to this change): with lenient_header_value_relaxed set, malformed header values can make llhttp__internal__run spin — identically in the scalar and SSE builds. It looks fixed on main (#882, unreleased in 9.4.3); happy to file a separate issue with the reproducer.

Notes

Companion one-liner for Node: deps/llhttp/llhttp.gyp needs the same second target with -msse4.2 once a release includes this — I can open that PR against nodejs/node after a release. If you'd prefer llparse to emit the llhttp__internal__c_* helpers as static, the rename header could shrink to just the two entry points; glad to take whichever direction you prefer.

🤖 Generated with Claude Code

https://claude.ai/code/session_0139J1kq4EkxBPrZELPKPfF3

…patch

llparse has emitted SSE4.2 span scanners (`_mm_cmpestri`) for the
header-value and header-field states since 2019, but they are guarded by
`#ifdef __SSE4_2__`. Node.js and the common Linux distributions build with
no `-march`, so every shipped x86-64 binary contains zero `pcmpestri` and
the fast path is dead; it is live only where the build baseline is
>= x86-64-v2 (e.g. RHEL). Passing `-msse4.2` globally was rejected before,
and an earlier per-call dispatch attempt measured slower.

This compiles the generated parser a second time with `-msse4.2` under
renamed internal symbols and lets `llhttp_execute()` select it once per
process with `__builtin_cpu_supports("sse4.2")`:

- bin/generate.ts additionally emits `llhttp_sse42_rename.h` (renames the
  defined `llhttp__internal*` symbols, derived from the generated source so
  it stays correct across grammar changes) and the small `llhttp_sse42.c`
  wrapper that includes the generated parser under those renames. The
  imported callbacks (`llhttp__on_*`) keep their names and stay shared.
- src/native/api.c dispatches in `llhttp_execute` on x86-64 GCC/clang builds
  that were compiled without `-msse4.2`. Both instantiations come from the
  same source, so the parser state layout is identical.
- Makefile and CMakeLists build the second object with `-msse4.2`, on
  x86-64 only, skipped on MSVC.

One well-predicted branch per `llhttp_execute` call (per TCP chunk), not
per byte or per span.

Measurements (Zen 5, this parser; standalone harness, 8 interleaved runs, 95% CI):
  4-header GET    -4.8% [-6.5, -3.0]
  32-header GET  -11.6% [-12.6, -10.5]
  browser GET    -18.0% [-19.0, -17.1]   (instructions -43%)
  10 KiB POST     -8.2% [-10.5, -5.9]
Node benchmark/http/bench-parser.js (compare.js, 30 runs, Welch): +4.8% (32
headers, p=3e-8), +3.9% (16), +2.3% (8), +2.2% (4); all significant.

Correctness: a libFuzzer differential harness (ASan+UBSan) drove the base
and SSE4.2 instantiations with identical random chunking and lenient-flag
combinations, comparing error code and the full callback sequence; ~5.5e8
executions, 0 divergences. The 16-byte SSE loads stay in bounds (the
generated code guards `endp - p >= 16`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139J1kq4EkxBPrZELPKPfF3

This branch has not been deployed

No deployments
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