From 87eb3fcf3b49c43383f1b2d4ce6c48cea8811934 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:56:06 +0100 Subject: [PATCH] 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(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) --- include/xrpl/basics/Number.h | 81 +++++++++++- src/libxrpl/protocol/IOUAmount.cpp | 8 +- src/tests/libxrpl/basics/Number.cpp | 198 ++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 src/tests/libxrpl/basics/Number.cpp diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index 93bef82a8c..0b89951153 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -534,7 +534,62 @@ public: std::pair 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> + [[nodiscard]] + static std::pair + 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> + static std::pair + 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 std::pair -Number::normalizeToRange() const +Number::normalizeToRangeImpl(bool negative, internalrep mantissa, int exponent) { static_assert(std::is_same_v || std::is_same_v); static_assert(std::is_same_v>); @@ -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) { XRPL_ASSERT_PARTS( @@ -812,6 +863,26 @@ Number::normalizeToRange() const return std::make_pair(static_cast(sign * mantissa), exponent); } +template +std::pair +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(negative_, mantissa_, exponent_); +} + +template +std::pair +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( + mantissa < 0, externalToInternal(mantissa), exponent); +} + constexpr Number abs(Number x) noexcept { diff --git a/src/libxrpl/protocol/IOUAmount.cpp b/src/libxrpl/protocol/IOUAmount.cpp index d214995809..75e8c88f17 100644 --- a/src/libxrpl/protocol/IOUAmount.cpp +++ b/src/libxrpl/protocol/IOUAmount.cpp @@ -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(mantissa_, exponent_); if (exponent_ > kMaxExponent) Throw("value overflow"); if (exponent_ < kMinExponent) diff --git a/src/tests/libxrpl/basics/Number.cpp b/src/tests/libxrpl/basics/Number.cpp new file mode 100644 index 0000000000..e7bb03177d --- /dev/null +++ b/src/tests/libxrpl/basics/Number.cpp @@ -0,0 +1,198 @@ +#include + +#include + +#include +#include +#include + +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 +twoPass(std::int64_t mantissa, int exponent) +{ + Number const v{mantissa, exponent}; + return v.normalizeToRange(); +} + +// The single-pass static primitive under test. +std::pair +onePass(std::int64_t mantissa, int exponent) +{ + return Number::normalizeToRange(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::max(), + std::numeric_limits::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::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::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(mode) << " m=" << m + << " e=" << e; + EXPECT_EQ(actual.second, expected.second) + << "exponent mismatch: mode=" << static_cast(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(); + // Feed the static the raw inputs that built the Number. + auto const viaStatic = Number::normalizeToRange(m, e); + EXPECT_EQ(viaMember.first, viaStatic.first) << "m=" << m << " e=" << e; + EXPECT_EQ(viaMember.second, viaStatic.second) << "m=" << m << " e=" << e; + } + } + } +}