Skip to content

perf: speed up subnormal float parsing - #1237

Open
arindamsikder wants to merge 2 commits into
msgspec:mainfrom
arindamsikder:perf/subnormal-float-parsing
Open

arindamsikder wants to merge 2 commits into
msgspec:mainfrom
arindamsikder:perf/subnormal-float-parsing

Conversation

@arindamsikder

Copy link
Copy Markdown

Summary

Speed up JSON and lax string-to-float decoding of subnormal numbers without changing their IEEE-754 results.

Problem

#1232 reports extremely slow decoding of [5e-324] * 65_536. Exponents below -307 currently go straight to the exact high-precision decimal parser. On this Linux x86-64 / CPython 3.11.15 / GCC 15 host, the reported array takes about 1.075 seconds on current main.

Solution

  • Extend the generated powers-of-ten table to -342 (35 additional entries), retaining the existing generator and attribution.
  • Round tiny values directly in units of 2**-1074. Bound the approximate product by an interval less than two high-word units wide; if that interval touches a halfway boundary, keep the exact fallback. This avoids double rounding, handles underflow and carry into the normal range, and guards all 64-bit shifts.
  • Retain the existing truncated-mantissa endpoint check, exact HPD fallback, overflow behavior, Decimal/float_hook paths and integer behavior.
  • Add 530 parameterized correctness cases covering both signs, signed zero, table boundaries, exact halfway neighbours, JSON numeric keys and lax conversion. Add five CodSpeed benchmark inputs, including ordinary-float controls; no wall-clock threshold is added to CI.

Local medians from seven baseline/fixed rounds, reversing their order every round; each array has 65,536 values and outputs are checked bit-for-bit outside the timed region:

Value main (ms) this branch (ms) main / branch
5e-324 1075.204 4.029 266.85x
1e-320 1264.685 4.997 253.11x
2.225073858507201e-308 1257.558 5.391 233.26x
1e-300 3.836 3.849 1.00x
1.23456789 3.645 3.650 1.00x
123456.78901234567 4.830 4.788 1.01x

The ordinary controls changed by +0.35%, +0.14%, and -0.88% respectively in these medians. These are local measurements, not cross-platform guarantees. Ambiguous and very long decimal strings may still use HPD; this is not a general parser rewrite.

Testing

All builds/tests ran offline in a credential-free Bubblewrap namespace with environment, filesystem, network and source-import canaries. Equivalent underlying commands from the just recipes were invoked directly using the prepared dependency layer:

  • python setup.py build_ext --inplace --force — passed; the same pre-existing _POSIX_C_SOURCE redefinition warning appears on main.
  • python scripts/generate_atof_consts.py — generated the checked-in table.
  • pytest -o testpaths=tests/unit -p no:cacheprovider --basetemp=/tmp/pytest -q -rs — 6,987 passed, 156 skipped. Pristine main with the same final dependency layer: 6,457 passed, 156 skipped. Skips are existing Python-version/optional-feature guards.
  • pytest --doctest-modules --pyargs msgspec -p no:cacheprovider --basetemp=/tmp/pytest -q — 7 passed.
  • ruff check, ruff format --check, python -m codespell_lib — passed (Ruff 0.16.9, Codespell 2.4.3). Unchanged main also passes these hooks.
  • mypy tests/typing, pyright --pythonpath <resolved CPython executable> tests/typing, pyrefly check tests/typing, python scripts/stubtest.py — passed. Pyrefly emits the expected PYTHONPATH-environment notice; no type diagnostics. Explicit Pyright runtime selection avoids a sandbox interpreter-probe error.
  • pytest tests/prof/perf/test_encode_decode.py -k 'decode_small_floats and amd' --codspeed --codspeed-mode walltime --codspeed-warmup-time 0.1 --codspeed-max-time 0.2 -p no:cacheprovider --basetemp=/tmp/pytest -q — 5 passed. This uses current pytest-codspeed 5.0.3 flags; the legacy --benchmark-name / --benchmark-disable-gc recipe flags were not accepted by this installation.
  • External numerical harness: 6,317,572 mantissa/exponent pairs against strtod, compiled with -fsanitize=undefined -fno-sanitize-recover=all; repeated with -U__SIZEOF_INT128__ to exercise the portable multiplication path. Both passed: 6,312,610 accepted approximations, 4,962 exact-fallback requests, no mismatches or sanitizer findings.
  • External Python differential probe: 60,000 signed decimal strings, including long/truncated mantissas, checked against float(text).hex() through JSON and lax conversion — passed.
  • Performance RED/GREEN/sabotage: a local diagnostic asserting subnormal parsing is below 20x the ordinary-float control failed on original code (289x), passed with this change (1.10x), failed again against the preserved original build (277x), and passed after restoring the fixed build (1.10x). Correctness tests intentionally also pass on main: this fixes performance, not rounding semantics. The diagnostic timing threshold is not in CI.
  • Independent read-only AI diff review passed with no security or logic findings. Its suggestion for direct fast-path coverage was exercised by the external native harness.

Not run locally: non-Linux platforms, other Python versions, full Sphinx docs build (no docs changed), and hosted CI. Maintainer review and CI remain required; no merge is requested outside normal controls.

Related Issue

Fixes #1232.

Automation disclosure

Implemented and validated by Hermes DEV, an autonomous coding agent working for arindamsikder, with a separate AI reviewer. No human pre-review is claimed.

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

I am not a float expert, but I know one. I will ask this person to make a proper review.

Comment thread src/msgspec/_core.c
}
else {
if (MS_UNLIKELY(exponent > 288 || exponent < -307)) {
if (MS_UNLIKELY(exponent > 288 || exponent < -342)) {

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.

please, document where these magic numbers come from.

Comment thread src/msgspec/atof.h Outdated
uint64_t x_hi = x.hi;
uint64_t x_lo = x.lo;

/* Round small values directly in units of 2**-1074, without first

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.

Please, link this issue in the code.

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.

Also, this seems to be inspired by / lifted from the sources I suggested in #1232 (comment). We should document this. Both to credit the original authors / honour licenses, and also to know where the implementation comes from, in case something needs checking

Copilot AI 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.

🔵 Needs a closer look

Low-level IEEE-754 rounding changes warrant maintainer review and hosted cross-platform CI validation.

0 open findings

What changed in this PR

Optimizes subnormal float parsing while preserving IEEE-754 rounding behavior.

Changes:

  • Extends powers-of-ten support through 1e-342.
  • Adds direct subnormal rounding with exact-parser fallback.
  • Adds correctness and performance coverage.
File Description
src/​msgspec/​atof.h Implements subnormal fast-path rounding.
src/​msgspec/​atof_consts.h Adds generated powers-of-ten constants.
src/​msgspec/​_core.c Expands the fast-path exponent range.
scripts/​generate_atof_consts.py Generates the expanded table.
tests/​unit/​test_json.py Tests rounding boundaries and decoding paths.
tests/​prof/​perf/​test_encode_decode.py Benchmarks small-float decoding.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

This branch has not been deployed

No deployments
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.

Decoding subnormal numbers is really slow

4 participants