Skip to content

perf: faster float decoding with Eisel-Lemire and eight-digit runs - #7

Draft
lalinsky wants to merge 2 commits into
mainfrom
perf-eisel-lemire
Draft

lalinsky wants to merge 2 commits into
mainfrom
perf-eisel-lemire

Conversation

@lalinsky

Copy link
Copy Markdown
Owner

Floats outside Clinger's exact range (mantissa ≥ 2^53, e.g. coordinates with 16-17 significant digits) used to go back to std.fmt.parseFloat as text, which rescanned them with its general parser. On canada.json that was about 20% of decode time.

  • src/decimal_float.zig: a new std-only module, toFloat(T, mantissa, exp10, truncated) ?T for f32/f64, which tries Clinger's path and then Eisel-Lemire on the digits fastFloat already collected. It returns null for the rare inputs neither can round, and std.fmt.parseFloat stays the fallback. The power-of-five table is the one from std's convert_eisel_lemire.zig, which derives from fast_float. std already has this algorithm, but only behind parseFloat's text parser, so a public "digits to float" API like this one is what could be upstreamed.
  • Fraction digits are read eight at a time with one 64-bit load and a few multiplies, as simdjson does. Integer parts are rarely long enough to pay for the check.

Benchmarks

i7-6700, median of 5 runs, decode:

main this PR
canada.json 3.95 ms 3.11 ms (−21%)
twitter.json 424 µs 433 µs
small.json 320 ns 340 ns

On a standalone canada decode binary, cycles per decode drop from 20.3M to 13.1M. The twitter and small changes are code layout on this Skylake. small.json has a single float, and a standalone binary shows +0.5% instructions and the same cycles.

Tests

  • toFloat against std.fmt.parseFloat, bit for bit, on:
    • edge cases: ties, subnormals, the max/inf boundary, f32 limits
    • 200k random decimals
    • 100k truncated (20-40 digit) decimals
  • The eight-digit helper against parseInt, and against every non-digit byte in every position.
  • 50k random JSON number texts decoded as f64 and f32 and compared with std.

Passes on Zig 0.16 and 0.17, natively and on s390x, and the JSONTestSuite conformance run passes.

Floats past Clinger's exact range, which is most coordinates with 16-17
significant digits, used to be handed back to std.fmt.parseFloat as text,
which scanned the digits a second time with its general parser. The
digits fastFloat already collected now go to Eisel-Lemire directly, in a
new std-only module, src/decimal_float.zig, that also holds the Clinger
path. std.fmt.parseFloat remains the fallback for the rare cases neither
can round.
A float's fraction usually carries most of its digits, so fastFloat now
checks for runs of eight digits with one 64-bit load, and combines them
into the mantissa with a few multiplies, as simdjson does. The byte loop
picks up whatever is left. Integer parts are rarely long enough to be
worth the check.
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