Use SWAR in cow_http1 header and path parsers#156
Conversation
|
Thanks but not that the impact in Cowboy will be limited. Most of the parsing in Cowboy happens inline in cowboy_http. |
|
Why not in parse_hd_name? I was also thinking SWAR may be a good enabler to be stricter about the allowed characters in header name and values (strictly follow ABNF). |
d8f7b36 to
d7d5485
Compare
|
Thanks — both points addressed below. The PR description and commit message are updated to reflect the changes.
Agreed. After tracing
Done — Measured impact on 20 interleaved
This is the natural next step. The relevant grammar lives in RFC 9110 — Plan:
Let me know which direction you'd like — happy to restructure scope here (bundle the strict-ABNF work into this PR, or anything else you'd prefer differently) or hold and proceed with the sequencing above. Waiting on your call before I open the cowboy follow-up. |
|
Gentle nudge on the scope question above — no rush if you're mid-release. Unless you'd prefer the strict-ABNF enforcement bundled in here, I'll keep this PR scoped as the no-behavior-change optimization (now including |
|
I will review this in details some time after today's release, haven't had time before. |
| %% | ||
| %% `bnot` is also avoided because the JIT lacks an always-small fast | ||
| %% path for it and would emit bignum fallback calls even when the | ||
| %% result fits in a small. |
There was a problem hiding this comment.
Yeah but this means there will be false positives.
There was a problem hiding this comment.
It is more of a naming issue than anything else though. It's fine to reject non-ascii most of the time.
There was a problem hiding this comment.
Agreed, it's a naming issue. I renamed the predicate macros so the all-ASCII requirement is explicit: ?are_ascii_hd_name_swar, ?are_ascii_no_cr_swar, ?are_ascii_no_hash_qmark_swar, ?are_ascii_no_hash_swar. I also rewrote the ?no_zero_byte comment to be honest that dropping bnot V is safe only because every caller gates the macro behind W band ?SWAR_MASK80 =:= 0 andalso ..., so it only ever runs on all-ASCII words. The comment now states explicitly that a window with any non-ASCII byte (>= 0x80) fails the MASK80 guard and intentionally falls to the byte-by-byte slow path, even when it has no delimiter — a deliberate fast-path miss, not a defect. No behavior change; eunit (3192) and proper green.
|
You do not need to do the respective change in Cowboy because I will do it as part of related changes. |
d7d5485 to
ba16d2f
Compare
|
Thanks, sounds good. This PR stays scoped as the no-behavior-change SWAR optimization (now also covering |
|
I think we can generalize. For example Can be And called That would make the code a lot clearer. Need to double check that the compiler does the calculation at compile time (it should) and that it works as intended. Also should double check that it works fine for This kind of general SWAR macro could be used across Cowboy, Gun and Cowlib easily. |
Applies the SWAR (SIMD Within A Register) technique from erlang/otp#10938 to four byte-by-byte parsers in `cow_http1`: - `parse_hd_name` (header name, delimiter `:`, lowercases ASCII A-Z) - `parse_hd_value` (header value, delimiter `\r`) - `parse_fullpath` (URL path, delimiters `#` and `?`) - `parse_fullpath_query` (delimiter `#`) Each scan now consumes 7 bytes per iteration on the plain-byte fast path and appends them in a single bit-string operation, replacing the previous byte-by-byte append pattern. The byte-by-byte clauses remain as a correct fallback for tails shorter than 7 bytes, for non-ASCII bytes, and for any chunk that contains a delimiter. For `parse_hd_name`, bulk-append is not raw passthrough: the 7 bytes are ASCII-lowercased in parallel via a SWAR formula (add 0x3F, add 0x25, isolate the high-bit AND result, shift to bit 5, bor with the original word) so mixed-case header names still produce the same lowercase output as the per-byte `?LOWER` slow path. A new `src/cow_swar.hrl` holds the primitives (`SWAR_MASK80`, `SWAR_MASK01`, `no_zero_byte`, `swar_lower`) plus a generalized, arity-overloaded `is_safe_ascii_swar(W, ...)` predicate, mirroring OTP's `swar_ascii.hrl`. Each of the four parser guards is now written as `?is_safe_ascii_swar(W, <delimiters>)` (e.g. `(W, $:, $\s, $\t)` for header names) instead of a hand-rolled, delimiter-specific macro. The per-delimiter broadcast constant is produced by `?SWAR_MASK01 * $C`, which the compiler constant-folds (verified: no `*` BIF is emitted and the folded 56-bit constant appears directly in the BEAM assembly), so the generalized guards compile to byte-identical code to the previous hex literals while reading far more clearly and becoming reusable across Cowlib, Cowboy and Gun. 20 interleaved runs of `horse:mod_perf(cow_http1)` (medians, ms): | Benchmark | Baseline | After | Delta | |------------------------------|---------:|------:|-------:| | `parse_headers` | 81.3 | 60.7 | -25.3% | | `parse_headers_lower` | 78.5 | 62.2 | -20.8% | | `parse_headers_mixed_case` | 94.1 | 63.5 | -32.6% | | `parse_fullpath_short` | 25.4 | 15.9 | -37.2% | | `parse_fullpath_long` | 47.4 | 23.0 | -51.5% | | `parse_fullpath_query` | 62.6 | 32.4 | -48.3% | | `parse_fullpath_fragment` | 26.3 | 17.4 | -34.1% | Benchmarks on functions outside the changed code (`parse_request_line_get_path`, `parse_status_line_*`) move within a ±2.5% noise envelope. `make eunit` 3192/3192, `make proper` and `make dialyze` clean. Six new horse benches added (two for `parse_hd_name` coverage, four for `parse_fullpath`). I tried `cow_qs:urldecode`/`parse_qs` first; that regressed 6 of 9 benchmarks because those loops already had a 4-byte AND-chain on a cheap 2-disjunction guard, where SWAR's heavier failed-guard cost outweighed the stride gain. The four sites in this PR differ in that the originals were either pure byte-by-byte with a `<<>>` accumulator (path/value/query) or used a 26-arm `?LOWER` case dispatch (name), so SWAR delivers both a 7× stride and a 7-bytes-per-append win. Refs: ninenines/cowboy#1711
ba16d2f to
108c286
Compare
|
Done — pushed as an update to this PR (kept as a single, force-pushed commit). I implemented the generalization you proposed: the four delimiter-specific predicate macros are gone, replaced by one arity-overloaded -define(is_safe_ascii_swar(W, A),
(W) band ?SWAR_MASK80 =:= 0 andalso
?no_zero_byte((W) bxor (?SWAR_MASK01 * (A)))).
-define(is_safe_ascii_swar(W, A, B),
(W) band ?SWAR_MASK80 =:= 0 andalso
?no_zero_byte((W) bxor (?SWAR_MASK01 * (A))) andalso
?no_zero_byte((W) bxor (?SWAR_MASK01 * (B)))).
-define(is_safe_ascii_swar(W, A, B, C),
(W) band ?SWAR_MASK80 =:= 0 andalso
?no_zero_byte((W) bxor (?SWAR_MASK01 * (A))) andalso
?no_zero_byte((W) bxor (?SWAR_MASK01 * (B))) andalso
?no_zero_byte((W) bxor (?SWAR_MASK01 * (C)))).Call sites: parse_hd_name(...) when ?is_safe_ascii_swar(W, $:, $\s, $\t) -> ...
parse_hd_value(...) when ?is_safe_ascii_swar(W, $\r) -> ...
parse_fullpath(...) when ?is_safe_ascii_swar(W, $#, $?) -> ...
parse_fullpath_query(...) when ?is_safe_ascii_swar(W, $#) -> ...On your three checks: (a) Compile-time multiply — confirmed. (b) Works as intended — confirmed by bytecode identity. Compiling (c) Naming is your call — I went with |
|
It does well while parsing, but I'm wondering how it compares to e.g |
This comment was marked as low quality.
This comment was marked as low quality.
|
Slop. Should have asked binary:match against a pure scan. For this case we don't need to accumulate data or anything just need to know there's a match. |
|
You're right — the first benchmark was bogus. It had SWAR scanning and accumulating into a new binary while Locate first CR (single delimiter — the
Locate first CR or LF (two delimiters — your literal example; same regime as
Reading:
Corrected picture: SWAR is the better tool for the multi-delimiter sites and for short single-delimiter scans; precompiled |
Applies the SWAR (SIMD Within A Register) technique from erlang/otp#10938 to four byte-by-byte parsers in
cow_http1:parse_hd_name(header name, delimiter:, lowercases ASCII A–Z)parse_hd_value(header value, delimiter\r)parse_fullpath(URL path, delimiters#and?)parse_fullpath_query(delimiter#)Each scan now consumes 7 bytes per iteration on the plain-byte fast path and appends them in a single bit-string operation, replacing the previous byte-by-byte append pattern. The byte-by-byte clauses remain as a correct fallback for tails shorter than 7 bytes, for non-ASCII bytes, and for any chunk that contains a delimiter.
For
parse_hd_namethe bulk-append is not raw passthrough: the 7 bytes are ASCII-lowercased in parallel via a SWAR formula (add0x3F, add0x25, isolate the high-bit AND result, shift to bit 5,borwith the original word) so mixed-case header names still produce the same lowercase output as the per-byte?LOWERslow path.A new
src/cow_swar.hrlholds the primitives (SWAR_MASK80,SWAR_MASK01,no_zero_byte,swar_lower), mirroring OTP'sswar_ascii.hrl.Performance — 20 interleaved runs of
horse:mod_perf(cow_http1), medians in msFunctions touched by SWAR:
parse_headersparse_headers_lower*parse_headers_mixed_case*parse_fullpath_shortparse_fullpath_longparse_fullpath_queryparse_fullpath_fragment* new benchmarks introduced by this PR.
Functions outside the changed code (noise envelope):
parse_request_line_get_pathparse_status_line_200parse_status_line_404parse_status_line_500parse_status_line_otherAll untouched benchmarks move within ±2.5%, confirming the added predicates introduce no lateral regression.
Validation
make eunit— 3192/3192 passmake proper— all properties passmake dialyze— cleanSix new horse benches added (two for
parse_hd_namecoverage, four forparse_fullpath).Notes
parse_hd_nameis included on @essen's suggestion in the review thread. The predicate checks all-ASCII, no:, no\s, no\t; on a match, the 7 bytes are bulk-lowercased and appended, otherwise the existing?LOWERbyte-by-byte path runs.I tried
cow_qs:urldecode/parse_qsfirst; that regressed 6 of 9 benchmarks because those loops already had a 4-byte AND-chain on a cheap 2-disjunction guard, where SWAR's heavier failed-guard cost outweighed the stride gain. The four sites in this PR differ in that the originals were either pure byte-by-byte with a<<>>accumulator (path/value/query) or used a 26-arm?LOWERcase dispatch (name), so SWAR delivers both a 7× stride and a 7-bytes-per-append win.Refs: ninenines/cowboy#1711