Skip to content

Faster float parsing - #281

Open
samuelcolvin wants to merge 13 commits into
mainfrom
faster-floats
Open

Faster float parsing#281
samuelcolvin wants to merge 13 commits into
mainfrom
faster-floats

Conversation

@samuelcolvin

@samuelcolvin samuelcolvin commented Aug 29, 2026

Copy link
Copy Markdown
Member

NumberAny::decode parsed a float's digits twice: IntParse scanned the integer part, then lexical re-parsed the whole number from the start. Floats of the form 123.456 (no exponent) now take a fast path: all digits are accumulated into a u64 mantissa and converted with lexical's own Number::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 like 12.12; handing lexical an exact (mantissa, exponent) pair rounds once.

Separately, NumberFloat::decode now 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, parse and float modules, which are public but doc(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

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.31902% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/jiter/src/number_decoder.rs 96.25% 5 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 29, 2026

Copy link
Copy Markdown

Merging this PR will regress 3 benchmarks

⚡ 8 improved benchmarks
❌ 5 (👁 2) regressed benchmarks
✅ 54 untouched benchmarks
🆕 2 new benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime unicode_dense_jiter_iter 752 ns 799 ns -5.88%
WallTime unicode_dense_jiter_value 763 ns 809 ns -5.69%
WallTime long_significand_floats_jiter_value 93.1 µs 98.2 µs -5.16%
WallTime doubles_array_jiter_value 118.5 µs 102.1 µs +16.02%
WallTime short_floats_jiter_value 96 µs 85 µs +12.89%
WallTime big_jiter_iter 14.2 ms 12.9 ms +10.38%
WallTime floats_array_jiter_value 122 µs 111.2 µs +9.68%
WallTime big_jiter_value 22 ms 20.5 ms +7.23%
WallTime pass2_jiter_iter 1,045 ns 987 ns +5.88%
WallTime pass1_jiter_value 10.9 µs 10.3 µs +5.8%
WallTime doubles_array_jiter_iter 74 µs 70 µs +5.66%
🆕 WallTime exponent_floats_jiter_iter N/A 94.5 µs N/A
🆕 WallTime exponent_floats_jiter_value N/A 127.4 µs N/A
👁 WallTime sentence_jiter_value 1.1 µs 1.2 µs -5.95%
👁 WallTime long_significand_floats_jiter_iter 71.3 µs 82.9 µs -14.01%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing faster-floats (3a9f9a8) with main (9e7d52f)

Open in CodSpeed

@samuelcolvin

Copy link
Copy Markdown
Member Author

@codspeedbot explain the regressions.

@codspeed-hq

This comment was marked as off-topic.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread crates/jiter/src/number_decoder.rs
@samuelcolvin

Copy link
Copy Markdown
Member Author

@codspeedbot explain the regressions - are any of these real or just layout variation?

@codspeed-hq

codspeed-hq Bot commented Aug 31, 2026

Copy link
Copy Markdown

@codspeedbot explain the regressions - are any of these real or just layout variation?

One of the five is real: long_significand_floats_jiter_iter. The other four are layout.

Two independent checks were used: the per-commit walltime trace on this branch (every commit below is rebased onto b0a501e, so base and all heads share the same environment), and a deterministic instruction count from callgrind. The second one is usable locally because decode_int_chunk_small is fallback_int::decode_int_chunk on every architecture — the number-decoding path an x86_64 build runs is the same code the aarch64 runner runs. Layout cannot change instruction counts.

Instructions retired per parse, base b0a501e → head 9ac28fd

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_arraystring_array.json is ["xxx", "xxx", …] with zero digits in the file, so no number-decoding code runs at all. It climbs monotonically as number_decoder.rs grows (13.07 → 14.26 → 15.89 µs) while python_parse_string_array_not_cached, _unique and _unique_not_cached on the same document are untouched. That is code placement in the python.rs binary, 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% on main itself between e8e4297 and b0a501e, where the only diff is 3 lines of ci.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% on 5f7c052 (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 from 1.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.

@samuelcolvin

Copy link
Copy Markdown
Member Author

@davidhewitt I think on balance, this is worth merging, the only real regression long_significand_floats_jiter_iter is a price worth paying for faster in parsing in most cases.

samuelcolvin and others added 13 commits August 31, 2026 13:42
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
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