Skip to content

Preserve subnormal results in portable exp2 - #11115

Merged
thewilsonator merged 2 commits into
dlang:masterfrom
jmh530:codex/fix-exp2-subnormal
Oct 19, 2026
Merged

thewilsonator merged 2 commits into
dlang:masterfrom
jmh530:codex/fix-exp2-subnormal

Conversation

@jmh530

@jmh530 jmh530 commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #11114.

The portable exp2 implementation returns zero below the exponent of the smallest normal value, discarding representable subnormal results. For example, exp2(-1023.0) returns zero instead of double.min_normal / 2.

Changes

For inputs in the subnormal-output range, shift the exponent upward by T.mant_dig, evaluate a normal intermediate using the existing approximation, and multiply by 2^(-T.mant_dig) once to round the final result. The exponent shift is exact in this range and requires only one additional evaluation level.

Retain an early zero return below the half-smallest-subnormal boundary. At that boundary and above it, perform the final rounding instead of prematurely returning zero. This avoids relying on ldexp to round subnormal outputs: widening the cutoff alone still failed the nearest-input-above-half test on Windows LDC because its ldexp implementation truncated the result.

The ordinary-input path, approximation coefficients, and existing x87 assembly implementation are unchanged.

Regression tests cover every exactly representable integer power from the smallest normal through the smallest subnormal, public overloads for float, double, and real, and direct portable evaluation at and on either side of the half-smallest-subnormal threshold. Fractional-exponent cases check rounding near the bottom of the range. Boundary expectations use round-to-nearest, ties-to-even.

Validation

  • DMD 2.113.0 on Windows: the full std.math.exponential module tests pass in debug and optimized builds, with -preview=dip1000 as required by existing tests.
  • LDC 1.43.0 on Windows, --noasm: the full module tests pass in debug and optimized builds, also with DIP1000 enabled.
  • GDC 14.2.0 on Ubuntu/WSL: the focused new regressions pass, including portable 80-bit real. The full module suite cannot compile against this installation's older core.int128 API; the same powmod/mul signature error was reproduced with the unchanged source.
  • Independent 150-digit Decimal references cover a grid in steps of 1/16 across the subnormal-output interval and the transition to zero. DMD and GDC each pass 2,307 cases across the three types; Windows LDC passes 2,131 cases because its real has double precision. Maximum error is one smallest-subnormal unit. These checks do not establish correct rounding for every input; the existing approximation can still differ by one unit.
  • The new regressions fail on the original implementation.
  • git diff --check passes.

Performance versus the original

Baseline: upstream commit 1877367354fadf001365292d74e39ffd15c21bbc.

Benchmarks compile the original and fixed portable helper implementations under separate module names and call them through equivalent function pointers. This explicitly measures the portable implementation even where public exp2(real) uses assembly; the assembly path is not changed by this PR.

Each timed sample evaluates 512 inputs over 512 repetitions. Results feed an integer checksum of their bit patterns, avoiding extra floating-point arithmetic on subnormal outputs in the harness. Three process runs alternate baseline/fixed order on a pinned CPU. One warm-up pair is excluded, leaving 24 measured pairs per dataset and type.

The following are median nanoseconds per call, original → fixed. Compiler/environment differences mean these should be compared within each row.

Compiler Type Ordinary inputs Upper subnormal range Near zero rounding boundary
GDC float 11.59 → 11.59 1.66 → 56.00 1.67 → 56.35
GDC double 11.06 → 11.49 1.69 → 58.21 1.70 → 59.53
GDC real 67.26 → 67.91 8.40 → 223.38 8.41 → 220.43
DMD float 29.31 → 29.47 1.44 → 60.15 1.46 → 60.33
DMD double 31.16 → 31.22 1.90 → 65.74 1.90 → 65.66
DMD real 74.94 → 75.29 14.86 → 224.37 14.98 → 222.19
LDC float 7.91 → 7.91 1.73 → 53.00 1.73 → 52.68
LDC double 8.41 → 8.38 1.74 → 53.87 1.76 → 53.73
LDC real 8.38 → 8.42 1.76 → 53.79 1.74 → 54.10

Ordinary-input paired median changes were approximately +0.1% to +0.2% with DMD and -0.3% to +0.1% with LDC. GDC measured +1.1% to +4.3% in aggregate, but varied substantially by run: float ranged from -3.2% to +7.9%, and double from -1.9% to +13.0%. These small absolute differences are noisy; they do not establish exact equality of performance or a stable GDC regression.

The near-minimum-normal dataset also stayed close: within roughly 1.2% on Windows and 2.9% on GDC. For true-underflow inputs, the additional cutoff check has a small absolute cost in some cases: DMD float changed from about 1.70 to 1.95 ns, DMD portable real from 14.91 to 16.08 ns, and LDC double/real by roughly 0.1–0.2 ns. Overflow-input measurements stayed within about 1.2%.

Subnormal performance tradeoff

The substantial subnormal slowdown is localized to results the original implementation incorrectly replaced with zero. The fixed implementation performs range reduction, approximation, scaling, and subnormal rounding.

For float/double, this changes roughly 1–2 ns into 53–66 ns. Portable 80-bit real changes roughly 8–15 ns into 220–224 ns on this machine. These measurements compare correct computation with the original early zero return. They are whole-call timings, without isolated profiling of the rounding instructions.

Builds use GDC -O2, DMD -O -inline, and LDC -O2 --enable-inlining --noasm, without fast-math. GDC runs under WSL; DMD and LDC run on Windows. IEEE 128-bit real was not available for testing.

This PR was written entirely by an LLM/AI.

AI-Assisted-By: Codex (GPT-6 Astra Medium)

@jmh530

jmh530 commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

#11116 is merged, fixing both upstream comment-spacing violations. Could a maintainer rerun Buildkite on this PR against the updated master?

@thewilsonator

Copy link
Copy Markdown
Contributor

Try rebasing on top of master now that it has that commit.

@jmh530
jmh530 force-pushed the codex/fix-exp2-subnormal branch from 867ea7a to 7ca5aca Compare October 15, 2026 14:56
@0xEAB

0xEAB commented Oct 15, 2026

Copy link
Copy Markdown
Member
std/internal/math/gammafunction.d(1914:1)[warn]: same visibility attribute used as defined on line 1815.

Looks like a previous commit broke that one.

@jmh530

jmh530 commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

std/internal/math/gammafunction.d(1914:1)[warn]: same visibility attribute used as defined on line 1815.

Looks like a previous commit broke that one.

Fixed in commit 26d4c26

@jmh530

jmh530 commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

I checked the macOS log: debug64 fails the existing assertion isIdentical(pow(0.0, 6.0), 0.0) at std/math/exponential.d:560. The release64 exponential tests pass.

The assertion and the zero-input path in _powImpl are unchanged by this PR, and that path returns before reaching exp2. The job runs x86-64 DMD through Rosetta on an ARM macOS runner. I haven’t confirmed the cause or reproduced this on the unchanged baseline; is this a known runner/compiler issue?

Separately, Buildkite #10491 fails during installation of pre-commit because pip cannot uninstall Debian’s packaging 24.0 package, before compilation or style checks.

Do not return zero as soon as the input falls below the exponent of the
smallest normal value. Preserve representable subnormal results and values
above the half-smallest-subnormal boundary that round up instead of to zero.

Evaluate this range after an exact mantissa-width exponent shift, then scale
the normal intermediate with one final multiplication. This also avoids
truncation of subnormal results by some ldexp implementations. Keep the
existing evaluation path for ordinary inputs.

Test exact powers throughout the subnormal range for float, double, and real,
including the public overloads. Exercise the portable implementation directly
at and around the rounding boundary and at fractional exponents. Subnormal
evaluations now perform the computation that the original early return skipped;
benchmark results and the resulting performance tradeoff are documented in
the PR description.

Fixes dlang#11114

AI-Assisted-By: Codex (GPT-6 Astra Medium)
Return zero explicitly at half the smallest subnormal so excess
intermediate precision does not retain a nonzero halfway result.

Store fractional regression results in their declared floating-point
type before checking subnormal rounding, since x87 registers can retain
excess precision for float and double.

AI-Assisted-By: Codex (GPT-6 Astra Medium)
@jmh530
jmh530 force-pushed the codex/fix-exp2-subnormal branch from 26d4c26 to 9656d13 Compare October 19, 2026 13:41
@jmh530

jmh530 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Rebased to master, prior failing MacOS x64 test no longer failing

@thewilsonator
thewilsonator merged commit a17028b into dlang:master Oct 19, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

exp2 returns zero for representable subnormal results in the portable implementation

3 participants