Faster float parsing - #281
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Merging this PR will regress 3 benchmarks
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
|
@codspeedbot explain the regressions. |
This comment was marked as off-topic.
This comment was marked as off-topic.
195a41f to
7663d1f
Compare
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
@codspeedbot explain the regressions - are any of these real or just layout variation? |
One of the five is real: Two independent checks were used: the per-commit walltime trace on this branch (every commit below is rebased onto Instructions retired per parse, base
|
| Benchmark | CI walltime | Ir / parse | Branch mispredicts / parse |
|---|---|---|---|
long_significand_floats_jiter_iter |
−12.02% | 411,293 → 468,259 (+13.9%) | 2008 → 1507 |
short_floats_jiter_iter |
−9.66% | 306,729 → 265,950 (−13.3%) | 1008 → 19 |
short_numbers_jiter_value |
−6.13% | 218,120 → 215,821 (−1.1%) | 110 → 111 |
true_array_jiter_value |
−6.74% | 12,683 → 12,684 (+1 instruction) | 7 → 7 |
python_parse_string_array |
−16.88% | document contains zero digits | — |
Only long_significand_floats_jiter_iter executes more work, and the +13.9% matches the reported −12.02% closely.
The real one
min per commit (within-run stdev on these is 0.1–0.3%):
| Commit | Changes | long_sig…_iter |
short_floats_iter |
short_numbers_value |
true_array_value |
py_string_array |
|---|---|---|---|---|---|---|
b0a501e base |
— | 71.29 µs | 49.81 µs | 49.85 µs | 2.796 µs | 13.203 µs |
e0931a8 |
fast path for NumberAny only |
71.93 | 48.51 | 51.18 | 3.094 | 13.074 |
d70cec2 |
+ next_float pre-scan |
80.68 | 55.94 | 52.23 | 2.771 | 14.259 |
5f7c052 |
SWAR for ≥8-digit fractions | 80.00 | 52.04 | — | — | — |
9ac28fd head |
integer-shaped floats, benches | 81.03 | 55.14 | 53.11 | 2.998 | 15.885 |
The entire long-significand loss appears on the one commit that routes NumberFloat::decode through the pre-scan. On a ~45-significant-digit number that pre-scan is pure overhead: the integer chunk is scanned, parse_float_dot sees 16+ fraction digits and bails, and lexical then re-parses from the start.
Cost/benefit of that specific decision, measured by patching head so next_float calls parse_json_float directly (Ir per parse):
Document (_iter) |
base | head | head without the next_float pre-scan |
|---|---|---|---|
long_significand_floats |
411,293 | 468,259 (+13.9%) | 398,755 (−3.1%) |
doubles_array |
441,101 | 398,138 (−9.7%) | 420,069 (−4.8%) |
floats_array |
399,663 | 356,616 (−10.8%) | 376,830 (−5.7%) |
short_floats |
306,729 | 265,950 (−13.3%) | 285,815 (−6.8%) |
Roughly half of the _iter gain comes from the #[cold] error re-parse and inlining change alone, which costs nothing on long significands. The pre-scan adds a further ~5% on the doubles-shaped documents and −17% on long significands. Gating it (skipping the fast path when the significand is long) would keep both; leaving it as-is is defensible if you consider 20+ significant digit floats rare.
The four artefacts
python_parse_string_array—string_array.jsonis["xxx", "xxx", …]with zero digits in the file, so no number-decoding code runs at all. It climbs monotonically asnumber_decoder.rsgrows (13.07 → 14.26 → 15.89 µs) whilepython_parse_string_array_not_cached,_uniqueand_unique_not_cachedon the same document are untouched. That is code placement in thepython.rsbinary, not work.true_array_jiter_value— booleans only; head executes exactly one more instruction over the whole document. It also swings non-monotonically on this branch (2.796 → 3.094 → 2.771 → 2.998) and moved −8.7% onmainitself betweene8e4297andb0a501e, where the only diff is 3 lines ofci.yml.short_numbers_jiter_value— integers only; head runs 1.1% fewer instructions with identical mispredicts, yet is reported 6% slower.short_floats_jiter_iter— head runs 13.3% fewer instructions and 50× fewer branch mispredicts on identical code, so there is no mechanism for a real 10% loss. It also moved −7% on5f7c052(which only touches fractions of 8+ digits; this document has 1–2) and +6% on the head commit (which only touches integer-shaped floats and the big-chunk decoder, neither reachable from1.1). Its true position is somewhere in a ±7% band.
Note that disabling ASLR in #284 removed per-process randomisation but not binary-to-binary layout: adding ~250 lines to a hot module still re-lays out the whole binary deterministically, which is what these four are showing.
No code changes were pushed — this was analysis only. Happy to implement the long-significand gate and measure it if you want it fixed rather than accepted.
Summary
- Warming up the performance wizard...
- Triage the 5 flagged regressions on Faster float parsing #281 — Traced all 5 across every branch commit: the two float _iter regressions land on one commit; the other three move on commits that cannot reach the code
- Verify with deterministic instruction counts — Callgrind Ir + branch mispredicts per parse for base, head and a patched variant; one regression confirmed real, four ruled out
- Report findings — One real regression (long_significand_floats_jiter_iter), four layout artefacts; no code changes made
- Handling comment — Comment handling finished successfully
Learnings updated. See what CodSpeed remembers about this repository.
Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.
|
@davidhewitt I think on balance, this is worth merging, the only real regression |
68a4786 to
8fac87b
Compare
The previous approach combined two separately-parsed f64s, which rounds twice and so is off by 1 ulp for many values - correct parsing must round the full decimal string exactly once. Instead, for floats of the form `123.456`, assemble the full decimal mantissa in a u64 (SWAR-gated scalar or SIMD fraction scan) and convert it with lexical's own public fast-path/Eisel-Lemire pipeline, falling back to a full lexical parse for exponents or too many digits. The integer hot path is unchanged from main; NumberFloat::decode gets the lexical fast path inlined with the error re-parse split out as cold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
Restore original code shapes where the logic didn't need to change (NumberRange float arm, NumberAny map style, aarch64 chunk return), and unify the two fraction-scan branches in parse_float_dot - both now produce a fraction-only value combined once via POW_10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
An exponent terminator was already one of its documented None cases, so the caller no longer pre-checks. Also restyle decode_any_float to if/else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
Four changes from profiling doubles_array on the CodSpeed runner: - NumberFloat::decode (next_float) now scans the integer part and uses parse_float_dot like NumberAny does, instead of always running the full lexical parse - lexical's string front-end was 48% of doubles_array_jiter_iter - IntParse::Float carries the integer mantissa the chunk scan already accumulated, so parse_float_dot no longer rescans it (7%) - the NumberAny float arm uses ? instead of Result::map - the closure compiled as an outlined call costing 6% - parse_float_dot only calls try_fast_path when the mantissa can qualify; is_fast_path compiled as an outlined call and always failed for 17-digit doubles (5%) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
find_end's vector->GPR mask extraction was 7% of doubles_array_jiter_value and the fraction length sits on its critical path. The 16-digit gate has already proven the first 8 fraction bytes are digits and the terminator is in the next 8, so two u64 loads and the classic 8-digit SWAR reduction cover the whole fraction with no vector registers involved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
…oding - next_float on an integer like 123 now converts the already-scanned u64 directly instead of reparsing with lexical; the chunk covers at most 18 digits so the conversion is the single correctly-rounded step - the aarch64 big-chunk decoder checks for a float terminator before the vector reduction again; both remaining callers discard the value for floats, so it was wasted work - ~ pin lexical-parse-float: the fast path uses its doc(hidden) internals, which a minor release could reshape Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
Scientific notation like 123.456e-78 takes the pre-scan-then-fallback path, and no existing benchmark file contained a single e/E. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
For 1e3 or 123.456e-78 the fast path previously scanned the digits and then threw the work away, reparsing from the start with lexical - review measured that at 18-25% slower than main for the dot-exponent shape. The scan's mantissa now feeds the same Eisel-Lemire conversion with the explicit exponent added on, so scientific notation only falls back for exponents over 5 digits, which compute_float short-circuits to 0 or inf anyway. parse_float_dot is renamed parse_float_fast since it now covers all three shapes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
The four per-shape float benchmarks landed in the catch-all other group; they join floats_array and json_cases_floats in a floats group instead. Cells are now padded so the pipes line up in the source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
x86_64 SIMD (#279) merged after the IntChunk::Float variant gained its payload; like aarch64, the big decoder passes 0 since both callers discard the value for floats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g
05db4e0 to
3a9f9a8
Compare
NumberAny::decodeparsed a float's digits twice:IntParsescanned the integer part, then lexical re-parsed the whole number from the start. Floats of the form123.456(no exponent) now take a fast path: all digits are accumulated into au64mantissa and converted with lexical's ownNumber::try_fast_path/moderate_path(Eisel-Lemire), so the result is bit-identical to a full lexical parse. Exponents, 16+ fraction digits, 18+ integer digits, or more than 19 significant digits fall back to the full parse.Note: summing separately-parsed integer and fraction
f64s (an earlier version of this branch) rounds twice and is off by 1 ulp for values like12.12; handing lexical an exact(mantissa, exponent)pair rounds once.Separately,
NumberFloat::decodenow inlines the lexical call and moves the error re-parse into a#[cold]function.Verified against the json-cases corpus and 6M randomized floats bit-compared with
str::parse::<f64>(), on aarch64 and x86_64.Caveat: this uses lexical-parse-float's
number,parseandfloatmodules, which are public butdoc(hidden). A 1.x release that changes them would fail to compile, not mis-parse.🤖 Generated with Claude Code
https://claude.ai/code/session_01XkwvHii1SZaVsrhw5skD4g