perf: Add single-pass ranged normalization to Number

IOUAmount::normalize() previously built a Number (one normalize pass to
the default Large range) and then re-normalized down to the narrower IOU
range via fromNumber (a second pass) -- two full passes where one would
do.

Add a static Number::normalizeToRange<Min,Max>(mantissa, exponent) that
normalizes raw integers straight to a target range in a single pass,
building no intermediate Number. Refactor the existing const member
overload to share one implementation, so both paths have a single source
of truth. Rewire the getSTNumberSwitchover()-true branch of
IOUAmount::normalize() to call the new primitive.

The result is bit-identical to the old two-pass path: an intermediate
pass to a strictly wider range cannot change the final narrower-range
result. Equivalence is proven by new GTests that sweep mantissa/exponent
boundaries, negatives, int64 extremes, rounding cusps, and all four
rounding modes against the prior two-pass result, plus exact-value
assertions on hand-computed cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pratik Mankawde
2026-06-02 18:56:06 +01:00
parent ad111bcc22
commit 87eb3fcf3b
3 changed files with 280 additions and 7 deletions

View File

@@ -534,7 +534,62 @@ public:
std::pair<T, int>
normalizeToRange() const;
/** Normalize raw (mantissa, exponent) integers directly to a target range.
*
* This is the construction-time counterpart of the member overload above.
* Callers that hold raw integers (e.g. IOUAmount) and want them in a
* narrow range would otherwise build a Number (one normalize pass to the
* default kRange) and then call the member normalizeToRange (a second pass
* down to the narrow range). This overload does a single pass: it converts
* the signed mantissa to its internal magnitude and normalizes straight to
* [MinMantissa, MaxMantissa], building no intermediate Number.
*
* Data flow (single pass), contrasted with the old two-pass path:
*
* two-pass: (m,e) --build Number--> [kRange/Large] --member--> [Min,Max]
* one-pass: (m,e) -------------- normalize --------------> [Min,Max]
*
* @tparam MinMantissa Lower bound of the target mantissa range; must be a
* positive power of ten.
* @tparam MaxMantissa Upper bound; must equal MinMantissa * 10 - 1.
* @tparam T Result mantissa type, int64_t or uint64_t. Defaults
* to the type of MinMantissa.
* @param mantissa Raw signed mantissa (sign is extracted internally).
* @param exponent Raw exponent.
* @return The normalized (mantissa, exponent) pair in the target range.
* A zero mantissa is returned unchanged.
* @note The result is bit-identical to the two-pass path: an intermediate
* pass to a strictly wider range cannot change the final
* narrower-range result.
* @note Thread-safety: reads the thread-local rounding mode only; holds no
* shared state of its own. Safe to call concurrently.
*
* Example (IOU range, 10^15 .. 10^16-1):
* @code
* auto [m, e] = Number::normalizeToRange<1'000'000'000'000'000,
* 9'999'999'999'999'999>(1, 0);
* // m == 1'000'000'000'000'000, e == -15
* @endcode
*/
template <
auto MinMantissa,
auto MaxMantissa,
Integral64 T = std::decay_t<decltype(MinMantissa)>>
[[nodiscard]]
static std::pair<T, int>
normalizeToRange(rep mantissa, int exponent);
private:
// Shared implementation for both normalizeToRange overloads. Takes the sign
// and internal (uint64) magnitude already separated, normalizes in place to
// [MinMantissa, MaxMantissa], and returns the signed (mantissa, exponent).
template <
auto MinMantissa,
auto MaxMantissa,
Integral64 T = std::decay_t<decltype(MinMantissa)>>
static std::pair<T, int>
normalizeToRangeImpl(bool negative, internalrep mantissa, int exponent);
static thread_local RoundingMode mode;
// The available ranges for mantissa
@@ -779,7 +834,7 @@ Number::isnormal() const noexcept
template <auto MinMantissa, auto MaxMantissa, Integral64 T>
std::pair<T, int>
Number::normalizeToRange() const
Number::normalizeToRangeImpl(bool negative, internalrep mantissa, int exponent)
{
static_assert(std::is_same_v<T, std::uint64_t> || std::is_same_v<T, std::int64_t>);
static_assert(std::is_same_v<T, std::decay_t<decltype(MinMantissa)>>);
@@ -792,10 +847,6 @@ Number::normalizeToRange() const
static_assert(kMAX % 10 == 9);
static_assert((kMAX + 1) / 10 == kMIN);
bool negative = negative_;
internalrep mantissa = mantissa_;
int exponent = exponent_;
if constexpr (std::is_unsigned_v<T>)
{
XRPL_ASSERT_PARTS(
@@ -812,6 +863,26 @@ Number::normalizeToRange() const
return std::make_pair(static_cast<T>(sign * mantissa), exponent);
}
template <auto MinMantissa, auto MaxMantissa, Integral64 T>
std::pair<T, int>
Number::normalizeToRange() const
{
// Forward this Number's already-separated internal components to the shared
// implementation. Passing mantissa_ (which may exceed kMaxRep in the Large
// range) through unchanged keeps the result byte-identical to before.
return normalizeToRangeImpl<MinMantissa, MaxMantissa, T>(negative_, mantissa_, exponent_);
}
template <auto MinMantissa, auto MaxMantissa, Integral64 T>
std::pair<T, int>
Number::normalizeToRange(rep mantissa, int exponent)
{
// Separate sign and magnitude from the raw signed mantissa, then normalize
// straight to the target range in a single pass (no intermediate Number).
return normalizeToRangeImpl<MinMantissa, MaxMantissa, T>(
mantissa < 0, externalToInternal(mantissa), exponent);
}
constexpr Number
abs(Number x) noexcept
{

View File

@@ -77,8 +77,12 @@ IOUAmount::normalize()
if (getSTNumberSwitchover())
{
Number const v{mantissa_, exponent_};
*this = fromNumber(v);
// Normalize the raw mantissa/exponent straight to the IOU range in a
// single pass. Previously this built a Number (one pass to the default
// range) and then re-normalized to the IOU range via fromNumber (a
// second pass); the static primitive collapses both into one.
std::tie(mantissa_, exponent_) =
Number::normalizeToRange<kMinMantissa, kMaxMantissa>(mantissa_, exponent_);
if (exponent_ > kMaxExponent)
Throw<std::overflow_error>("value overflow");
if (exponent_ < kMinExponent)

View File

@@ -0,0 +1,198 @@
#include <xrpl/basics/Number.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <limits>
#include <utility>
using namespace xrpl;
namespace {
// The IOUAmount mantissa range: [10^15, 10^16 - 1]. Kept here as signed
// constants so the default template parameter T resolves to std::int64_t,
// matching IOUAmount's own use of Number::normalizeToRange.
constexpr std::int64_t kMin = 1'000'000'000'000'000;
constexpr std::int64_t kMax = (kMin * 10) - 1;
// The two-pass path that the static primitive replaces: build a Number (one
// normalize pass to the default range) and then re-normalize to the narrow IOU
// range via the const member overload (a second pass).
std::pair<std::int64_t, int>
twoPass(std::int64_t mantissa, int exponent)
{
Number const v{mantissa, exponent};
return v.normalizeToRange<kMin, kMax>();
}
// The single-pass static primitive under test.
std::pair<std::int64_t, int>
onePass(std::int64_t mantissa, int exponent)
{
return Number::normalizeToRange<kMin, kMax>(mantissa, exponent);
}
} // namespace
// The static primitive must produce bit-identical (mantissa, exponent) to the
// old two-pass path across a broad sweep of inputs: values needing scale-up,
// scale-down, rounding cusps, negatives, and exponent extremes.
TEST(Number, normalizeToRangeEquivalence)
{
// A spread of mantissa magnitudes: tiny (heavy scale-up), mid, at the IOU
// floor/ceiling, beyond it (scale-down), and int64 extremes.
std::int64_t const mantissas[] = {
1,
2,
7,
9,
99,
100,
12345,
999'999'999'999'999,
kMin,
kMin + 1,
kMax,
kMax + 1,
1'234'567'890'123'456,
12'345'678'901'234'567,
std::numeric_limits<std::int64_t>::max(),
std::numeric_limits<std::int64_t>::max() - 1,
};
for (std::int64_t absM : mantissas)
{
for (std::int64_t m : {absM, -absM})
{
for (int e : {-90, -32, -1, 0, 1, 5, 32, 70})
{
auto const expected = twoPass(m, e);
auto const actual = onePass(m, e);
EXPECT_EQ(actual.first, expected.first)
<< "mantissa mismatch for m=" << m << " e=" << e;
EXPECT_EQ(actual.second, expected.second)
<< "exponent mismatch for m=" << m << " e=" << e;
}
}
}
// int64::min cannot be negated naively; externalToInternal handles it. Make
// sure the static path agrees with the two-pass path on it too.
{
std::int64_t const m = std::numeric_limits<std::int64_t>::min();
auto const expected = twoPass(m, 0);
auto const actual = onePass(m, 0);
EXPECT_EQ(actual.first, expected.first);
EXPECT_EQ(actual.second, expected.second);
}
}
// Exact, hand-computed results (state + cause), not just "equals the old path".
TEST(Number, normalizeToRangeExactValues)
{
// A single digit scales up by 15 powers of ten to reach the floor 10^15,
// with the exponent dropping by the same 15.
{
auto const [m, e] = onePass(1, 0);
EXPECT_EQ(m, kMin); // 1'000'000'000'000'000
EXPECT_EQ(e, -15);
}
// Already exactly at the floor: unchanged.
{
auto const [m, e] = onePass(kMin, 4);
EXPECT_EQ(m, kMin);
EXPECT_EQ(e, 4);
}
// Already exactly at the ceiling: unchanged.
{
auto const [m, e] = onePass(kMax, -7);
EXPECT_EQ(m, kMax); // 9'999'999'999'999'999
EXPECT_EQ(e, -7);
}
// One past the ceiling scales down by one power of ten; the dropped ones
// digit (0) truncates cleanly and the exponent rises by one.
{
auto const [m, e] = onePass(kMax + 1, 0); // 10'000'000'000'000'000
EXPECT_EQ(m, kMin); // 1'000'000'000'000'000
EXPECT_EQ(e, 1);
}
// Negative values keep their sign through normalization.
{
auto const [m, e] = onePass(-5, 0);
EXPECT_EQ(m, -5 * kMin); // -5'000'000'000'000'000
EXPECT_EQ(e, -15);
}
// Zero mantissa: the workhorse leaves it as zero (callers special-case it).
{
auto const [m, e] = onePass(0, 0);
EXPECT_EQ(m, 0);
}
}
// Equivalence must hold under every rounding mode, not just the default
// ToNearest. This is the subtlest risk: the single-pass impl hardcodes
// CuspRoundingFix::Disabled, whereas the old two-pass path ran an intermediate
// normalize to the wider range first. Sweep all four modes, including inputs
// that round at a tie (a trailing digit of exactly 5 when scaling down).
TEST(Number, normalizeToRangeAllRoundingModes)
{
// Inputs chosen so scale-down drops a non-zero (and tie) trailing digit.
std::int64_t const mantissas[] = {
15,
25,
12'345'678'901'234'565, // 17 digits, trailing 5 -> tie on the drop
99'999'999'999'999'995,
kMax + 5,
std::numeric_limits<std::int64_t>::max(),
};
for (auto mode :
{Number::RoundingMode::ToNearest,
Number::RoundingMode::TowardsZero,
Number::RoundingMode::Downward,
Number::RoundingMode::Upward})
{
for (std::int64_t absM : mantissas)
{
for (std::int64_t m : {absM, -absM})
{
for (int e : {-20, 0, 13})
{
NumberRoundModeGuard const g(mode);
auto const expected = twoPass(m, e);
auto const actual = onePass(m, e);
EXPECT_EQ(actual.first, expected.first)
<< "mantissa mismatch: mode=" << static_cast<int>(mode) << " m=" << m
<< " e=" << e;
EXPECT_EQ(actual.second, expected.second)
<< "exponent mismatch: mode=" << static_cast<int>(mode) << " m=" << m
<< " e=" << e;
}
}
}
}
}
// The refactored const member overload must forward to the static primitive
// and yield identical results for the same Number.
TEST(Number, normalizeToRangeMemberStaticConsistency)
{
std::int64_t const mantissas[] = {3, 42, kMin, kMin + 7, kMax, kMax + 1, 1'234'567'890'123'456};
for (std::int64_t absM : mantissas)
{
for (std::int64_t m : {absM, -absM})
{
for (int e : {-50, -3, 0, 11, 60})
{
Number const v{m, e};
auto const viaMember = v.normalizeToRange<kMin, kMax>();
// Feed the static the raw inputs that built the Number.
auto const viaStatic = Number::normalizeToRange<kMin, kMax>(m, e);
EXPECT_EQ(viaMember.first, viaStatic.first) << "m=" << m << " e=" << e;
EXPECT_EQ(viaMember.second, viaStatic.second) << "m=" << m << " e=" << e;
}
}
}
}