Skip to content

use digit run to determine float or integer decode - #286

Open
davidhewitt wants to merge 3 commits into
mainfrom
dh/number-event
Open

use digit run to determine float or integer decode#286
davidhewitt wants to merge 3 commits into
mainfrom
dh/number-event

Conversation

@davidhewitt

@davidhewitt davidhewitt commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Possible alternative to #281


Summary by cubic

Refactors number decoding to scan the digit run first, then decide between integer and float paths. This avoids a speculative float parse for integers and simplifies error handling by eliminating a re-parse fallback.

Changed

  • Adds find_digit_run_end SIMD helpers for x86_64 and aarch64 to locate the end of digit runs quickly.
  • Uses the digit run to pick the decode path: integers decode directly, floats fall back to a lexical parse.
  • Simplifies NumberAny decoding by treating Infinity and NaN as float inputs rather than separate enum variants.

Written for commit 87f0ec0. Summary will update on new commits.

Review in cubic

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.46405% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/jiter/src/number_decoder.rs 90.47% 8 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

Comment thread crates/jiter/src/number_decoder.rs Outdated
Comment thread crates/jiter/src/number_decoder.rs Outdated

@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 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/jiter/src/number_decoder.rs Outdated
Comment thread crates/jiter/src/number_decoder.rs Outdated
Comment thread crates/jiter/src/simd/mod.rs Outdated
Comment thread crates/jiter/src/number_decoder.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Aug 31, 2026

Copy link
Copy Markdown

Merging this PR will regress 4 benchmarks

⚡ 12 improved benchmarks
❌ 4 regressed benchmarks
✅ 51 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime bigints_array_jiter_value 83.4 µs 99.6 µs -16.31%
WallTime json_cases_error_jiter_value 13.4 ms 15.7 ms -14.74%
WallTime python_parse_true_array 3.7 µs 3.9 µs -5.87%
WallTime json_cases_ints_jiter_value 50.3 ms 53.1 ms -5.31%
WallTime short_numbers_jiter_value 49.2 µs 37.4 µs +31.38%
WallTime short_floats_jiter_value 96 µs 75.4 µs +27.26%
WallTime doubles_array_jiter_value 118.5 µs 97.8 µs +21.19%
WallTime floats_array_jiter_value 122 µs 102.2 µs +19.4%
WallTime pass1_jiter_value 10.9 µs 9.5 µs +14.86%
WallTime big_jiter_value 22 ms 19.8 ms +10.91%
WallTime true_array_jiter_value 3.1 µs 2.8 µs +10.86%
WallTime json_cases_floats_jiter_value 18.1 ms 16.5 ms +9.74%
WallTime true_array_jiter_iter 1,065 ns 979 ns +8.78%
WallTime long_significand_floats_jiter_value 93.1 µs 86.6 µs +7.49%
WallTime python_parse_x100_not_cached 135 ns 127 ns +6.3%
WallTime python_parse_massive_ints_array 709.7 µs 669.5 µs +6%

Tip

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


Comparing dh/number-event (87f0ec0) with main (9e7d52f)

Open in CodSpeed
@davidhewitt

Copy link
Copy Markdown
Contributor Author

@codspeedbot explain this regression. How does this branch compare to faster-floats branch?

@codspeed-hq

codspeed-hq Bot commented Aug 31, 2026

Copy link
Copy Markdown

@codspeedbot explain this regression. How does this branch compare to faster-floats branch?

Short version

Two of the three flagged regressions aren't real — the code they'd have to come from is instruction-identical to main. The third is about one third real. The genuine cost of this approach is somewhere else: short integers now pay a separate scan pass, and on aarch64 that scan's mask extraction is expensive.

Method: worktrees of main (9e7d52f), faster-floats (3a9f9a8) and this branch (6aeb8b1), each parsing the same documents N and 3N times under callgrind, differencing the counters — layout can't change instruction counts. Plus the flamegraphs from the actual aarch64 runner. Local builds are x86_64, so NEON-specific costs are undercounted locally; the flamegraphs cover that.

The three flagged regressions

python_parse_string_array_unique_not_cached (-15.6%) — not real. This branch only touches number decoding; a strings-only document executes none of it. Parsing string_array_unique.json costs exactly 2,332,965 instructions on main, on faster-floats and on this branch. And the sibling benchmark on the same document with the cache on (python_parse_string_array_unique) is 1.3% faster here (1.6186 ms → 1.5974 ms). This is the known layout sensitivity of the small walltime benches.

python_parse_numeric (-10%) — not real either. The Rust-side decode of that exact document ({"int": 1, "bigint": …, "float": 1.2}) is cheaper on this branch: 2,454 instructions vs 2,576 on main (-4.7%) and 2,508 on faster-floats. The benchmark is 1.4 µs of mostly CPython object construction for three numbers; a 140 ns move cannot come from three number decodes that got cheaper.

json_cases_error_jiter_value (13.45 → 14.19 ms, +5.5%) — about a third real. From the runner's flamegraph, the NumberAny::decode subtree goes 15.4 ms → 16.4 ms, i.e. +1.0 ms of a +3.0 ms total move. Inside it, find_digit_run_end is 6.5 ms (11.5% of the benchmark) with aarch64::find_end alone 5.5 ms (9.7%). Locally the error corpus is +1.2% in instructions (65.22 M → 66.00 M) and +2.6% in conditional branches. So ~1 pp of the 5.5% is the extra scan; the remainder isn't attributable to changed code.

The regression that isn't flagged

short_numbers_jiter_value is 49.2 µs on main → 51.5 µs here (+4.6%, just under the reporting threshold) and +7% vs faster-floats. Locally it's unambiguous: 216,056 → 235,486 instructions (+9.0%), conditional branches +25% (28,705 → 36,026).

The runner flamegraph shows why:

main this branch
number decode subtree 26.2 ms (58.0%) 27.8 ms (58.1%)
fused chunk decode (fallback_int::decode_int_chunk) 5.9 ms (13.1%)
find_digit_run_end 11.6 ms (24.2%)
aarch64::find_end (mask → GPR) 8.9 ms (18.5%)
decode_integer_digits 3.5 ms (7.3%)

decode_int_chunk_small found the terminator and accumulated the value in one pass. Here a 1–3 digit number is scanned with a 16-byte NEON compare plus find_end, then the same 1–3 bytes are folded again scalar in decode_integer_digits. For short runs the scan is pure overhead, and find_end's vector→GPR extraction is the most expensive part of it on aarch64.

Three ways out, if you want to keep the design: have find_digit_run_end also return the accumulated value for runs ≤ 18 digits (keeping the fused decode, and using the pure scan only for the long/bigint case); scan a scalar prefix before entering the SIMD loop, as #285 does for strings; or derive the run length from the digit mask without the full find_end extraction.

This branch vs faster-floats

Both compared against the same base (main 9e7d52f). Instructions per parse of JsonValue, x86_64, layout-free:

document main faster-floats this branch
long_significand_floats.json 506,658 515,129 (+1.7%) 446,161 (-11.9%)
bigints_array.json 363,320 355,320 (-2.2%) 296,597 (-18.4%)
floats_array.json 540,426 445,962 (-17.5%) 481,725 (-10.9%)
doubles_array.json 587,171 482,223 (-17.9%) 524,175 (-10.7%)
short_numbers.json 216,056 213,856 (-1.0%) 235,486 (+9.0%)
corpus floats 83.32 M 74.60 M (-10.5%) 74.56 M (-10.5%)
corpus ints 241.2 M 232.3 M (-3.7%) 238.9 M (-1.0%)
corpus error 65.22 M 64.62 M (-0.9%) 66.00 M (+1.2%)
string_array_unique.json 2,332,965 2,332,965 2,332,965

Head-to-head on CodSpeed (walltime, aarch64) agrees on the shape: this branch wins long_significand_floats (+14.0% value, +15.3% iter), short_floats (+13.0% value), floats_array (+6.8%), bigints_array (+5.0%); faster-floats wins short_numbers (+6.6%), json_cases_error (+6.6%) and big_jiter_iter (+7.0%, though both are ahead of main there).

Read together: faster-floats is the stronger of the two on the plain double shape, because it feeds the mantissa it already scanned straight into Eisel-Lemire; it does nothing for long significands. This branch is the stronger one on long significands and big integers and matches it on the mixed corpus floats, but it charges every short integer for a scan it used to get for free. Fixing the fused decode for the ≤ 18-digit case would remove the one real regression here and make the two approaches complementary rather than alternatives.

No code changes were made — this was an analysis request.

Summary
  • Warming up the performance wizard...
  • Triage the 3 regressions on use digit run to determine float or integer decode #286 — Two of the three are layout drift (string path is instruction-identical); json_cases_error is ~1/3 real, and the genuine cost is the extra digit-run scan on short integers
  • Compare dh/number-event with faster-floats — CodSpeed head-to-head plus local callgrind instruction counts on the same documents for main / faster-floats / this branch
  • Write up findings — Analysis posted; no code changes were needed, nothing committed
  • 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.

@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 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/jiter/src/number_decoder.rs Outdated

@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.

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/jiter/src/simd/fallback_int.rs">

<violation number="1" location="crates/jiter/src/simd/fallback_int.rs:24">
P2: When `num-bigint` is enabled, negative integers with exactly 4,300 digits are rejected as out of range. Returning `None` here routes them through the scanner, whose limit includes the leading minus sign; base the scan limit on `digit_start` or otherwise exclude the sign from the 4,300-digit count.</violation>

<violation number="2" location="crates/jiter/src/simd/fallback_int.rs:24">
P2: For floats with 2+ fractional digits (e.g. `1.23`, `1.234`, `12.34`, `1.23e5`) the `None` check only tests `data.get(index + 3)`, so a digit in the fractional part triggers the digit-run scanner even though the integer part is shorter than four digits. These numbers now pay a SIMD `find_digit_run_end` scan plus a float-marker check before `parse_json_float`, where the previous code returned `IntChunk::Float` directly from `decode_int_chunk_limit::<4>`. Four-digit integers likewise re-scan and re-fold the leading digits. This extra work is a likely source of the regression you asked about; consider also checking `data.get(index+1)`/`data.get(index+2)` (or only returning `None` when all four bytes are digits) so short floats keep the direct `Float` path.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

/// at least four digits are present so the caller can scan the run from its SIMD-friendly start.
#[inline(always)]
pub(crate) fn decode_number_prefix(data: &[u8], index: usize) -> Option<(IntChunk, usize)> {
if data.get(index + 3).is_some_and(u8::is_ascii_digit) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When num-bigint is enabled, negative integers with exactly 4,300 digits are rejected as out of range. Returning None here routes them through the scanner, whose limit includes the leading minus sign; base the scan limit on digit_start or otherwise exclude the sign from the 4,300-digit count.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/jiter/src/simd/fallback_int.rs, line 24:

<comment>When `num-bigint` is enabled, negative integers with exactly 4,300 digits are rejected as out of range. Returning `None` here routes them through the scanner, whose limit includes the leading minus sign; base the scan limit on `digit_start` or otherwise exclude the sign from the 4,300-digit count.</comment>

<file context>
@@ -17,23 +17,14 @@ pub(crate) fn find_digit_run_end(data: &[u8], mut index: usize, limit: usize) ->
-            + u64::from(digits[3] & 0x0f);
-        (IntChunk::Ongoing(value), index + 4)
+pub(crate) fn decode_number_prefix(data: &[u8], index: usize) -> Option<(IntChunk, usize)> {
+    if data.get(index + 3).is_some_and(u8::is_ascii_digit) {
+        None
     } else {
</file context>
/// at least four digits are present so the caller can scan the run from its SIMD-friendly start.
#[inline(always)]
pub(crate) fn decode_number_prefix(data: &[u8], index: usize) -> Option<(IntChunk, usize)> {
if data.get(index + 3).is_some_and(u8::is_ascii_digit) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: For floats with 2+ fractional digits (e.g. 1.23, 1.234, 12.34, 1.23e5) the None check only tests data.get(index + 3), so a digit in the fractional part triggers the digit-run scanner even though the integer part is shorter than four digits. These numbers now pay a SIMD find_digit_run_end scan plus a float-marker check before parse_json_float, where the previous code returned IntChunk::Float directly from decode_int_chunk_limit::<4>. Four-digit integers likewise re-scan and re-fold the leading digits. This extra work is a likely source of the regression you asked about; consider also checking data.get(index+1)/data.get(index+2) (or only returning None when all four bytes are digits) so short floats keep the direct Float path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/jiter/src/simd/fallback_int.rs, line 24:

<comment>For floats with 2+ fractional digits (e.g. `1.23`, `1.234`, `12.34`, `1.23e5`) the `None` check only tests `data.get(index + 3)`, so a digit in the fractional part triggers the digit-run scanner even though the integer part is shorter than four digits. These numbers now pay a SIMD `find_digit_run_end` scan plus a float-marker check before `parse_json_float`, where the previous code returned `IntChunk::Float` directly from `decode_int_chunk_limit::<4>`. Four-digit integers likewise re-scan and re-fold the leading digits. This extra work is a likely source of the regression you asked about; consider also checking `data.get(index+1)`/`data.get(index+2)` (or only returning `None` when all four bytes are digits) so short floats keep the direct `Float` path.</comment>

<file context>
@@ -17,23 +17,14 @@ pub(crate) fn find_digit_run_end(data: &[u8], mut index: usize, limit: usize) ->
-            + u64::from(digits[3] & 0x0f);
-        (IntChunk::Ongoing(value), index + 4)
+pub(crate) fn decode_number_prefix(data: &[u8], index: usize) -> Option<(IntChunk, usize)> {
+    if data.get(index + 3).is_some_and(u8::is_ascii_digit) {
+        None
     } else {
</file context>
Suggested change
if data.get(index + 3).is_some_and(u8::is_ascii_digit) {
if data.get(index + 3).is_some_and(u8::is_ascii_digit)
&& data.get(index + 1).is_some_and(u8::is_ascii_digit)
&& data.get(index + 2).is_some_and(u8::is_ascii_digit)
{

@samuelcolvin samuelcolvin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

generally LGTM, a few questions and needs a bit more docs.

const JSON_FMT: u128 = lexical_format::JSON;

#[inline(always)]
fn parse_json_float(data: &[u8], start: usize, allow_inf_nan: bool) -> JsonResult<(f64, usize)> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

could do with a one-line docstring.


#[cold]
#[inline(never)]
fn float_error(data: &[u8], start: usize, allow_inf_nan: bool) -> JsonResult<(f64, usize)> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same.

NumberInt::Int(value)
}

fn decode_integer_digits(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

docstring.

Comment on lines +293 to +297
value *= match chunk_len {
16 => 10u64.pow(16),
18 => 10u64.pow(18),
_ => 10u64.pow(chunk_len as u32),
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this really worthwhile? can we use ONGOING_CHUNK_MULTIPLIER?


impl AbstractNumberDecoder for NumberAny {
fn decode(data: &[u8], index: usize, first: u8, allow_inf_nan: bool) -> JsonResult<(Self, usize)> {
fn decode(data: &[u8], mut index: usize, first: u8, allow_inf_nan: bool) -> JsonResult<(Self, usize)> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we add a docstring here explaining the general approach and rationale.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants