Skip to content

Simplify and tune [l]lround[f] - #62

Open
leekillough wants to merge 1 commit into
amd:devfrom
leekillough:lround
Open

Simplify and tune [l]lround[f]#62
leekillough wants to merge 1 commit into
amd:devfrom
leekillough:lround

Conversation

@leekillough

@leekillough leekillough commented Jun 26, 2026

Copy link
Copy Markdown

Simplify and Fix lround, lroundf, llround, llroundf

Summary

The previous implementations of lroundf, llround, and llroundf used a custom multi-branch bit-manipulation algorithm (~100 lines each) to perform round-half-away-from-zero. lroundf.c and lround.c had a dead-code Windows path guarded by #ifdef WIN64 (never defined; the compiler defines _WIN64), so the Linux code path always ran on Windows. llround.c and llroundf.c were guarded by #ifdef WINDOWS (also never defined), causing them to forward all calls to FN_PROTOTYPE(lroundf) and FN_PROTOTYPE(lround) through the dispatch table. This PR replaces these four files with correct, portable ~20-line implementations, and adds conformance tests for all four functions.

Changes

Files modified: src/ref/lround.c, src/ref/lroundf.c, src/ref/llround.c, src/ref/llroundf.c.

The old algorithm extracted the exponent and significand via union type-punning, conditionally added 0.5 (if not already an integer), then re-extracted and shifted the significand to form the integer result. On Windows it also had a separate path guarded by #ifdef WIN64 (never defined in the AOCL build, which defines _WIN64).

The new implementation checks whether the input is in the valid output range, then adds a signed 0.5 bias (constructed from the sign bit of x) and casts to truncate. The cast always truncates toward zero regardless of MXCSR, making the result rounding-mode independent. Values with |x| at or above the integrality threshold (2^23 for float, 2^52 for double) are already exact integers and skip the addition, to avoid creating a spurious half-integer that would round wrong for odd integers near those thresholds. Out-of-range inputs (NaN, Inf, overflow) call feraiseexcept(FE_INVALID) directly and return LLONG_MIN / LONG_MIN.

For example, llround:

// Before: forwarded to lround through dispatch table (WINDOWS guard always false)
#ifdef WINDOWS
long long ALM_PROTO_REF(llround)(double x) { ... }
#else
long long ALM_PROTO_REF(llround)(double x) {
    return (long long)FN_PROTOTYPE(lround)(x);
}
#endif
// After: direct correct implementation
#define LLROUND_MIN       ((double)LLONG_MIN - 0.5)
#define LLROUND_MAX       ((double)LLONG_MAX + 0.5)
#define LLROUND_INRANGE(x) (((x) >= LLROUND_MIN) && ((x) < LLROUND_MAX))

long long ALM_PROTO_REF(llround)(double x)
{
    long long result = LLONG_MIN;

    if (unlikely(!LLROUND_INRANGE(x)))
    {
        feraiseexcept(FE_INVALID);
    }
    else
    {
        uint64_t ux = asuint64(x);
        if (likely((ux & POS_BITSET_DP64) < EXP_VAL_52_DP64))
            x += asdouble((ux & SIGNBIT_DP64) | HALFEXPBITS_DP64);
        result = (long long)x;
    }

    return result;
}

lround.c and lroundf.c follow the same structure but with long as the return type, so the in-range bound comparisons are platform-dependent (strict > on the lower bound for 32-bit long where both bounds are exactly representable; >= for 64-bit long where LONG_MIN rounds to itself). llroundf.c follows the same structure as llround.c using float bit-pattern constants.

Correctness fix for lround/lroundf

The old #ifdef WIN64 guard was never true. The Linux path always ran on Windows, using a 63-bit overflow limit when long is only 32 bits on Windows. Any input in [2^31, 2^63) passed the range check and returned a silently truncated garbage value instead of the LONG_MIN error sentinel.

Correctness fix for llround/llroundf

The old #ifdef WINDOWS guard was never true, so llround and llroundf always forwarded to lround/lroundf through the function-pointer dispatch table. The return type of lround is long, which is 32 bits on Windows, so llround silently returned a 32-bit value for any input, giving wrong results for inputs outside [-2^31, 2^31) even though llround should support the full 64-bit range.

Range-check bounds

For llround, LLROUND_MIN = (double)LLONG_MIN - 0.5 evaluates to -2^63 (the subtraction of 0.5 is swamped by the 2048 ULP at that magnitude), so -2^63 itself is a valid input that passes the >= check. LLROUND_MAX = (double)LLONG_MAX + 0.5 evaluates to 2^63 (LLONG_MAX rounds up to 2^63 in double), which overflows, so a strict < is used on the upper bound.

For lroundf on Windows (32-bit long), the bounds are constructed at compile time using (float)LONG_MIN and (float)LONG_MAX + 0.5f by the same reasoning. The integrality threshold uses EXP_VAL_23_F32 (2^23 as a float bit pattern) and EXP_VAL_52_DP64 (2^52 as a double bit pattern) from libm_util_amd.h.

Hot path code generation

Clang compiles the signed-half addition pattern to: vandpd/vandps (mask the sign bit from ux), vorpd/vorps (inject the 0.5 exponent via HALFEXPBITS), vaddsd/vaddss (add), vcvttsd2si/vcvttss2si (convert-with-truncation). The range check and integrality check are predicted-not-taken forward branches to the cold path, so the hot path has no taken branches.

Performance

Benchmarked on AMD Ryzen 9 9950X (Zen 5, 5 GHz), Windows 11, Clang/LLVM 20 build.
14 seeds × 2 reps = 28 measurements, ABCDDCBA palindrome pattern; median of 28 reported.
Throughput in Mcalls/s; ratios > 1.00 mean PR is faster than the reference.

Function PR DEV Intel UCRT PR/DEV PR/Intel PR/UCRT
lround 1123 916 419 487 1.23 2.68 2.31
lroundf 822 1018 524 410 0.81 1.57 2.00
llround 870 873 279 439 1.00 3.11 1.98
llroundf 998 1001 451 362 1.00 2.21 2.76

lround improves by 23% because the new correct implementation is faster than the old buggy Linux code path that silently ran on Windows due to the dead #ifdef WIN64 guard.

lroundf appears to regress at 0.81x, but the DEV baseline always returned 0 on Windows for 32-bit long inputs due to the same #ifdef WIN64 defect, making it artificially fast; the PR is the first correct implementation and the apparent regression is not real.

llroundf appears at parity (1.00x), but DEV forwarded to the same buggy lroundf through the dispatch table (dead #ifdef WINDOWS guard), so the DEV baseline was also artificially fast; the PR is the first correct implementation.

llround is genuinely at parity with DEV.

AOCL outperforms Intel on all four functions by 2-3x.

Tests

Conformance tests covering lround, lroundf, llround, and llroundf are added in the ulp_threshold PR, in gtests/lround/lround_test.cc and gtests/lround/test_lround_data.h. They are separated because the test callbacks require the extended-precision MPFR infrastructure introduced by ulp_threshold. The tests use direct TEST() cases that call the amd_* entry points, check the return value, and verify that FE_INVALID is raised (or not raised) for each case. Test cases include: zero, negative zero, exact integers, round-half-away-from-zero cases (0.5 rounds to 1, -0.5 rounds to -1, 2.5 rounds to 3, -4.5 rounds to -5, etc.), large exact integers (2^52, 2^52+1 for double; 2^23 for float), the largest double and float values representable below 2^63, -2^63 = LLONG_MIN (a valid non-overflowing input), and out-of-range inputs (NaN, sNaN, +/-Inf, +2^63) that must return LLONG_MIN and raise FE_INVALID.

@leekillough

Copy link
Copy Markdown
Author

All CI tests pass.

@leekillough
leekillough force-pushed the lround branch 2 times, most recently from d62dec6 to 7804613 Compare July 10, 2026 21:14
@leekillough
leekillough force-pushed the lround branch 3 times, most recently from 49f2d83 to 7d01508 Compare July 29, 2026 00:36
used a custom multi-branch bit-manipulation algorithm (~100 lines
each) to perform round-half-away-from-zero.

`lroundf.c` and `lround.c` had a dead-code Windows path guarded by
`#ifdef WIN64` (never defined; the compiler defines `_WIN64`), so
the Linux code path always ran on Windows. `llround.c` and
`llroundf.c` were guarded by `#ifdef WINDOWS` (also never defined),
causing them to forward all calls to `FN_PROTOTYPE(lroundf)` and
`FN_PROTOTYPE(lround)` through the dispatch table. This PR replaces
these four files with correct, portable ~20-line implementations.
@leekillough
leekillough force-pushed the lround branch 2 times, most recently from 4da35ff to b0f2853 Compare August 6, 2026 00:14
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