Speed up parsing: branch-free exponent, UTF-16 steps, clang parity - #416
Open
Algunenano wants to merge 8 commits into
Open
Algunenano wants to merge 8 commits into
Algunenano wants to merge 8 commits into
Conversation
…ults The sign of the exponent, and with it whether an out-of-range value underflows or overflows, is as unpredictable as the input. Parse the sign without a branch, check both ends of the range with one comparison, and select zero vs infinity and the out-of-range error without branching on the case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Clang kept from_chars_fixed_format out of line, so every conversion paid for the call and its six register saves and restores. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r clang If the exponent passed that test, the mantissa failed it, so it is not zero and the exponent is within range. GCC carries this into compute_float by itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GCC computed the digit twice, and the in-loop exponent saturation became a select on the path to the power of ten. Saturate only for more than 18 exponent digits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A remaining 4-7 digit run takes one SIMD step instead of the scalar loop, as for char. The 8-digit step combines digit pairs with pmaddwd (NEON vmulq/vpaddlq) instead of parse_eight_digits_unrolled on packed bytes, whose three 64-bit constants made GCC 14/15 spill on the digit chain. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The '0' offset is linear, so it is subtracted once from i * 10^8, which is ready early, instead of from each unit on the digit chain. Clang also extracts both halves with one movq. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
As in the fraction tail and the exponent: clang computed the digit twice, once for the test and once for the value. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
@lemire I'd say this is ready to review. I've added a couple of extra commits and run benchmark in more machines (my local 7950X3D, EC2 Intel Xeon 6975P-C and Graviton 4). Out of all the configurations the only slow down appears with the Intel 6975P-C, on GCC 16.2.0 (exactly, 16.2.1 changes the codegen) and it's gone with different layout so in general it positive, both with x86 and ARM, and with clang matching gcc's performance. I've also included a final section of things I've attempted that didn't work (either my ideas, or ideas posted in issues/forks) in case somebody wants to have a look. |
Algunenano
marked this pull request as ready for review
September 25, 2026 13:28
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Parsing gets faster on every compiler I tested (GCC 11-16 and clang 17-22), with two layout-dependent exceptions on one CPU explained below. There are three themes: fewer branch mispredictions on exponents, tweaking the source so clang generates code similar to GCC's, and faster UTF-16 digit parsing. Changes were judged by the benchmark on every compiler: using only instruction and spill counts turned out to be poor predictors.
Please note that the changes can be reviewed per commit.
Exponents without mispredictions: The sign of the exponent is as unpredictable as the input, and for out-of-range values it also decides between underflow and overflow. Three places branched on it; now none do. All three are needed: removing only the first moves the misprediction to the other two for
float, where most of these values go out of range. Found withperf record -bon exponent-heavy input, where the exponent sign test was the most mispredicted branch in the parser.|rather than||: GCC turns the||into a branch on "has a sign", which is the same bit when positive exponents are written without+.compute_float: both ends of the range are checked with one unsigned comparison, and zero vs infinity is selected without a branch.from_chars_float_advanced: still one branch marked unlikely, as in Fold the format at compile time under clang, mark cold checks unlikely #413, but tested with one comparison (the exponent is zero or infinite), and the error is selected inside it. Clang otherwise splits the condition into one jump per case.Clang on par with GCC: Inspired by #413: I compared the code both compilers generate and changed the source where clang did clearly worse. Neither commit changes GCC's code.
from_charsunder clang (fastfloat_clang_really_inline, as in Fold the format at compile time under clang, mark cold checks unlikely #413). Clang kept it as a call; GCC already inlines it.compute_float's range check is skipped under clang when the exponent passed Clinger's range test. GCC derives this by itself. The zero mantissa check stays (a zero mantissa can get there when the rounding mode is not to nearest).Leaner digit loops, and faster UTF-16:
cmovper digit on the path to the power of ten.charalready has, so a remaining 4-7 digit run skips the serial scalar loop.pmaddwdby 10,1 then 100,1; NEONvmulq/vpaddlq), on the characters, with the'0'offset taken offi * 10^8, which is ready early.parse_eight_digits_unrolledon the packed bytes needs three 64-bit constants in general registers.Register pressure under GCC (raised in #414): I could reproduce it with GCC 14 and 15 only, not 13 or 16. In the UTF-16 path, GCC spilled values around the 8-unit loop, and with the fraction change also the value inside
parse_eight_digits_unrolled, on the mantissa chain. The vector pair combining removes those spills. The same spill remains once per value in thecharpath on GCC 15: every alternative I found costs clang (see the discarded list).Measurements: AMD Ryzen 9 7950X3D
Codegen changes between compiler versions and stalls differ between CPUs, even of the same architecture, so these numbers are specific to this setup:
gccDocker images) and the current ones from the distro (Arch): GCC 16.2.1 (20260810) and clang 22.1.8.-O3 -DNDEBUG -static, no-march.benchmarks/benchmark.cppfrommain(with Average branch misses over the repetitions in the benchmark #415), one run per build (it repeats 1000 times internally). canada and mesh are the bundled files. HIGGS is real data in NumPy's default%.18eformat: the first 200k values of the UCI HIGGS dataset (first 100k rows, one value per line). Every value has an exponent, withe-01ande+00about evenly split. I've uploaded a copy of the exact dataset I used to https://pastila.nl/?cafebabe/85969be244622b169374bb7a15e768a2#Aun4ig1uL6K4xv4RkNJ3hg==Cycles per float,
main→ this PR:Cycles per float
Instructions per float go down in all 60 configurations (GCC 3-62, clang 28-76). The two 0.99x cells have 11-12 fewer instructions. Branch misses per float drop on HIGGS from 1.09-1.11 to 0.70-0.71 with every compiler, and do not change elsewhere (within ±0.02).
Clang now runs at 0.90x-1.02x of GCC 16's speed; on
mainit was 0.71x-0.96x. What remains on canada and mesh is mostly scheduling (same instructions and misses, more memory stalls) and, on mesh, GCC specializing the path where the token ends right after the integer part, which is benchmark-shaped (each value its own string).Measurements: Intel Xeon 6975P-C
gccDocker images), and the distro ones (Ubuntu 22.04): GCC 11.4.0 and clang 17.0.6, 18.1.8, 19.1.7, 20.1.8, 21.1.8 and 22.1.8.Cycles per float,
main→ this PR:Cycles per float
Instructions per float go down in all 132 configurations (GCC 2-63, clang 22-92). Branch misses per float drop on HIGGS from 1.07-1.12 to 0.68-0.75, except with clang 17 and 18, which are already at 0.65-0.69 on
main(so their HIGGS gains are smaller). They do not change elsewhere.With this PR, clang 19-22 are roughly on par with GCC here; on
mainthey were about 15-25% slower on canada and mesh.The only slowdowns are GCC 16 ASCII 64-bit on canada (0.87x) and mesh (0.94x). Three more runs gave the same (0.88x and 0.96x). This is code layout. Most of it comes from 784cd67 (canada: 37.0 → 41.1 cycles per float), which barely changes the code GCC 16 generates. The extra cycles match the extra slots where the front end delivers no micro-ops (
IDQ_UOPS_NOT_DELIVERED.CORE, 11.5G → 16.1G over the whole run), while almost all micro-ops come from the decoded instruction cache in both builds. Rebuilding both commits with-falign-functions=64 -falign-loops=32flips the result: 37.5 → 34.9, so this PR is 7% faster there. The smaller step at 3e852fe (35.9 → 37.3) also mostly goes away with 64-byte alignment (35.5 → 35.7). GCC 11 canada ASCII 64-bit (0.98x) is neutral over re-runs (36.8 → 37.0-37.2).Almost all of this work was on x86-64. The NEON versions of the UTF-16 steps are included; I did not tune anything specifically for aarch64, but it was measured on real hardware (below).
Measurements: AWS Graviton4 (Neoverse-V2)
c8g.metal-48xl(bare metal), pinned to core 7.main).Cycles per float,
main→ this PR:Cycles per float
Instructions per float go down in 58 of 60 configurations (by up to 23 with GCC, 21-67 with clang); the other two are GCC 14 mesh ASCII 64-bit (+0.1) and HIGGS UTF-16 64-bit (+1.3), which are still 1.03x and 1.08x faster. Branch misses per float drop on HIGGS from 1.16-1.50 to 0.68-0.90 with GCC and from 1.05-1.08 to 0.67-0.75 with clang, and do not change elsewhere (within ±0.02). There are no slowdowns; the smallest gain is 1.00x (GCC 14 canada ASCII 32-bit).
With this PR, clang 19-22 run at 0.90x-1.05x of GCC 14's speed except on mesh ASCII (0.85x-0.87x); on
mainthey were at 0.72x-0.98x. The test suite (including the supplemental tests) passes on this machine with GCC 14 and clang 22.Tried and discarded
valueat the cold calls): neutral.-msse4.2or higher.compute_product_approximation: HIGGS 1.12x-1.28x (branch misses 0.71 → 0.31 per float), but canada 0.89x-0.98x and mesh 32-bit 0.91x-0.97x. A trade I left out; it is a small change if you want it.parse_eight_digits_unrolled(16- then 32-bit lanes, as in Lemire's "Quickly parsing eight digits"): removes the GCC 15 spill in thecharpath, but its chain is 3 cycles longer; clang mesh 64-bit 0.95x-0.96x.mov+movd+pshufd.exponent: 3-12 more instructions per value on GCC 13-16. Its register pressure is GCC keeping the sign character instead of a bool, which is also the cheapest form.start_digitsin the long-mantissa path to free a register: fewer spills, but 0.91x-0.97x on GCC 14 and 16 UTF-16.parsed_number_string_ttypes (bitfield flags,int32_texponent): moves spills from one GCC version to another, and it changes the API.Disclosure
Most of the analysis, benchmarking and coding in this PR was done with an LLM (Claude). I guided it, gave ideas and mostly kept pushing it until I was happy, and I reviewed the code.