Compare commits

...

6 Commits

Author SHA1 Message Date
Pratik Mankawde
0480d951e6 micro benchmark tests
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-06-03 13:23:15 +01:00
Pratik Mankawde
14fef306dd clang-tidy fixes
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-06-03 11:30:40 +01:00
Pratik Mankawde
d3d2cf0c9a Merge branch 'develop' into pratik/ranged-normalize-number-at-construction 2026-06-02 19:03:00 +01:00
Pratik Mankawde
87eb3fcf3b 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>
2026-06-02 18:56:06 +01:00
Vito Tumas
225ed204ad test: Suppress invariant-failure logs in Vault and LoanBroker bug-regression tests (#7379) 2026-06-02 17:12:09 +00:00
Ayaz Salikhov
ad111bcc22 ci: Patch binaries in nix-based images and test in every distro (#7376)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 13:51:20 +00:00
12 changed files with 674 additions and 61 deletions

24
docker/check-tool-versions.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Verify that every tool expected in the Nix CI env is present and runnable.
set -euo pipefail
ccache --version
clang --version
clang++ --version
clang-format --version
cmake --version
conan --version
g++ --version
gcc --version
gcovr --version
git --version
less --version
make --version
mold --version
ninja --version
perl --version
pkg-config --version
pre-commit --version
python3 --version
run-clang-tidy --help
vim --version

View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Install sanitizer runtime libraries required to run binaries compiled with:
# -fsanitize=address → libasan.so.8
# -fsanitize=thread → libtsan.so.2
# -fsanitize=undefined → libubsan.so.1
#
# The exact SONAMEs required depend on the compiler toolchain used to build the
# test binaries (see nix/ci-env.nix). If the toolchain is bumped and SONAMEs
# change, update the list below (or detect them from the binaries).
#
# Supported base images:
# debian:bookworm
# ubuntu:20.04
# rhel:9
# nixos/nix — tests are skipped; this script is not called
set -euo pipefail
if [ ! -f /etc/os-release ]; then
echo "ERROR: /etc/os-release not found; cannot detect OS" >&2
exit 1
fi
# shellcheck source=/dev/null
. /etc/os-release
echo "Detected OS: ${ID} ${VERSION_ID:-}"
case "${ID}" in
debian)
apt-get update -y
apt-get install -y --no-install-recommends \
libasan8 \
libtsan2 \
libubsan1
apt-get clean
rm -rf /var/lib/apt/lists/*
;;
ubuntu)
apt-get update -y
apt-get install -y --no-install-recommends \
gnupg \
software-properties-common
add-apt-repository -y ppa:ubuntu-toolchain-r/test
apt-get update -y
apt-get install -y --no-install-recommends \
libasan8 \
libtsan2 \
libubsan1
apt-get clean
rm -rf /var/lib/apt/lists/*
;;
rhel | centos | rocky | almalinux)
dnf install -y \
libasan8 \
libtsan2 \
libubsan
dnf clean -y all
rm -rf /var/cache/dnf/*
;;
*)
echo "ERROR: unsupported OS '${ID}'. Supported: debian, ubuntu, rhel-family" >&2
exit 1
;;
esac
# Verify that every expected library is now resolvable by the dynamic linker.
missing=0
for lib in libasan.so.8 libtsan.so.2 libubsan.so.1; do
if ldconfig -p | grep -q "${lib}"; then
echo "OK: ${lib} found"
else
echo "ERROR: ${lib} not found after installation" >&2
missing=$((missing + 1))
fi
done
if [ "${missing}" -ne 0 ]; then
echo "ERROR: ${missing} library/libraries missing" >&2
exit 1
fi
echo "All sanitizer runtime libraries installed successfully."

View File

@@ -32,7 +32,7 @@ FROM ${BASE_IMAGE} AS final
ARG BASE_IMAGE
# bash is not located at /bin/bash in nixos/nix, so we need to create a symlink to it.
RUN if [ -d /nix ]; then \
RUN if echo "${BASE_IMAGE}" | grep -qiE 'nixos'; then \
ln -s /root/.nix-profile/bin/bash /bin/bash; \
fi
@@ -65,38 +65,44 @@ if [ ! -e "${target}" ]; then
fi
EOF
RUN <<EOF
ccache --version
clang --version
clang++ --version
clang-format --version
cmake --version
conan --version
g++ --version
gcc --version
gcovr --version
git --version
make --version
mold --version
ninja --version
perl --version
pkg-config --version
pre-commit --version
python3 --version
run-clang-tidy --help
vim --version
EOF
COPY docker/check-tool-versions.sh /tmp/check-tool-versions.sh
RUN /tmp/check-tool-versions.sh
# Sanity-check that the sanitizer runtimes shipped with g++/clang++ are able to build binaries
# Sanity-check that the g++/clang++ are able to build binaries, including sanitizer-instrumented ones.
COPY docker/test_files/cpp_sources/ /tmp/cpp_sources/
COPY docker/test_files/compile-cpp-sources.sh /tmp/compile-cpp-sources.sh
RUN /tmp/compile-cpp-sources.sh /tmp/cpp_sources /tmp/bins
# Sanity-check that the built binaries are able to run.
# We only support running the test binaries on Ubuntu and NixOS right now (will be fixed in the future)
#
# When build and test images will be separate, we will be to run on vanilla images.
COPY docker/test_files/run-test-binaries.sh /tmp/run-test-binaries.sh
RUN if echo "${BASE_IMAGE}" | grep -qiE '(ubuntu|nixos)'; then \
/tmp/run-test-binaries.sh /tmp/bins; \
# Tester: start from a clean BASE_IMAGE, install sanitizer runtime libraries,
# and run the compiled test binaries to verify they execute correctly.
FROM ${BASE_IMAGE} AS tester
ARG BASE_IMAGE
# bash is not located at /bin/bash in nixos/nix, so we need to create a symlink to it.
RUN if echo "${BASE_IMAGE}" | grep -qiE 'nixos'; then \
ln -s /root/.nix-profile/bin/bash /bin/bash; \
fi
SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
# Sanity-check that the built binaries run correctly in the vanilla base image, with the necessary sanitizer runtime libraries installed.
COPY docker/install-sanitizer-libs.sh /tmp/install-sanitizer-libs.sh
COPY docker/test_files/run-test-binaries.sh /tmp/run-test-binaries.sh
COPY --from=final /tmp/bins /tmp/bins
RUN <<EOF
if echo "${BASE_IMAGE}" | grep -qiE 'nixos'; then
echo "Skipping runnning binaries on NixOS."
else
/tmp/install-sanitizer-libs.sh
/tmp/run-test-binaries.sh /tmp/bins
fi
touch /tmp/tests-passed
EOF
# Output: the final image, gated on a successful test run in the tester stage.
# Copying the sentinel from tester creates a hard build dependency: if the test
# run above failed, this stage — and the overall build — fails too.
FROM final
COPY --from=tester /tmp/tests-passed /tmp/tests-passed

View File

@@ -20,14 +20,21 @@ function compile() {
local src="${src_dir}/${name}.cpp"
local binary="${dst_dir}/${name}-${compiler}"
echo "=== Compile ${name} with ${compiler} ==="
cmd="${compiler} -std=c++23 -O1 -g \
echo "=== Compiling ${name} with ${compiler} ==="
# Always statically link libstdc++ so the test binary does not depend on
# the host's libstdc++.so.6 version.
local compile_cmd="${compiler} -std=c++23 -O1 -g \
-pthread \
-Wl,--dynamic-linker=${loader} \
-static-libstdc++ \
${san_flag} \
${src} -o ${binary}"
echo "Command: ${cmd}"
eval "${cmd}"
echo "Compile cmd: ${compile_cmd}"
eval "${compile_cmd}"
echo "=== Patching ${binary} to use ${loader} as PT_INTERP ==="
local patch_cmd="patchelf --set-interpreter ${loader} --remove-rpath ${binary}"
echo "Patch cmd: ${patch_cmd}"
eval "${patch_cmd}"
}
declare -A sanitize=(

View File

@@ -7,6 +7,8 @@ set -eo pipefail
bins_dir="${1:?usage: $0 <bins_dir>}"
failed_binaries=()
# Run a binary and verify its exit code and output.
# Usage: run <binary> <expected_output> <expected_rc>
function run() {
@@ -18,27 +20,34 @@ function run() {
out_file="$(mktemp)"
echo "=== Run ${binary} ==="
local rc=0
"${binary}" >"${out_file}" 2>&1 || rc=$?
set +e
"${binary}" >"${out_file}" 2>&1
local rc=$?
set -e
cat "${out_file}"
local failed=0
if [ "${expected_rc}" = "nonzero" ]; then
if [ "${rc}" -eq 0 ]; then
echo "ERROR: expected non-zero exit code from ${binary}, got ${rc}" >&2
exit 1
failed=1
fi
elif [ "${rc}" -ne "${expected_rc}" ]; then
echo "ERROR: expected exit code ${expected_rc} from ${binary}, got ${rc}" >&2
exit 1
failed=1
fi
grep -q "${expected_output}" "${out_file}" ||
{
echo "ERROR: expected '${expected_output}' from ${binary}" >&2
exit 1
}
echo "OK: '${expected_output}' detected"
if ! grep -q "${expected_output}" "${out_file}"; then
echo "ERROR: expected '${expected_output}' from ${binary}" >&2
failed=1
fi
if [ "${failed}" -eq 0 ]; then
echo "OK: '${expected_output}' detected"
else
failed_binaries+=("${binary}")
fi
}
declare -A expect=(
@@ -52,6 +61,15 @@ declare -A expect=(
for compiler in g++ clang++; do
for name in regular asan tsan ubsan; do
binary="${bins_dir}/${name}-${compiler}"
if [ "${name}" = "tsan" ] && [ "${compiler}" = "g++" ] &&
grep -qi 'debian' /etc/os-release 2>/dev/null &&
[ "$(uname -m)" = "aarch64" ]; then
echo "=== Skipping ${binary} (tsan-g++ unsupported on Debian ARM64) ==="
echo " NOTE: to enable it, add --security-opt seccomp=unconfined to your docker run command"
continue
fi
if [ "${name}" = "regular" ]; then
expected_rc=0
else
@@ -60,3 +78,9 @@ for compiler in g++ clang++; do
run "${binary}" "${expect[$name]}" "${expected_rc}"
done
done
if [ "${#failed_binaries[@]}" -gt 0 ]; then
echo "ERROR: the following binaries failed:" >&2
printf ' %s\n' "${failed_binaries[@]}" >&2
exit 1
fi

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

@@ -15,6 +15,7 @@ in
git
gnumake
llvmPackages_22.clang-tools
less # needed for git diff
mold
ninja
patchelf

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

@@ -1,5 +1,6 @@
#include <test/jtx/Account.h>
#include <test/jtx/CaptureLogs.h>
#include <test/jtx/Env.h>
#include <test/jtx/JTx.h>
#include <test/jtx/TestHelpers.h>
@@ -52,6 +53,7 @@
#include <array>
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string_view>
#include <tuple>
@@ -1708,7 +1710,8 @@ class LoanBroker_test : public beast::unit_test::Suite
Account const alice("alice");
auto const withFix = features[fixCleanup3_2_0];
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<CaptureLogs>(&logs));
env.fund(XRP(100'000), issuer, alice);
env.close();

View File

@@ -1,6 +1,7 @@
#include <test/jtx/AMM.h>
#include <test/jtx/AMMTest.h>
#include <test/jtx/Account.h>
#include <test/jtx/CaptureLogs.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
@@ -60,6 +61,7 @@
#include <cstdint>
#include <functional>
#include <limits>
#include <memory>
#include <optional>
#include <string>
#include <tuple>
@@ -6630,7 +6632,8 @@ class Vault_test : public beast::unit_test::Suite
"fixed-asset amount with impaired loan"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
{
@@ -6748,7 +6751,8 @@ class Vault_test : public beast::unit_test::Suite
"burn is rejected while loss outstanding"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || f.sharesLender == 0)
{
@@ -7074,7 +7078,8 @@ class Vault_test : public beast::unit_test::Suite
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
@@ -7150,7 +7155,8 @@ class Vault_test : public beast::unit_test::Suite
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
@@ -7226,7 +7232,8 @@ class Vault_test : public beast::unit_test::Suite
enum class DestKind : bool { ThirdParty = false, Self = true };
auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
@@ -7331,7 +7338,8 @@ class Vault_test : public beast::unit_test::Suite
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
@@ -7414,7 +7422,8 @@ class Vault_test : public beast::unit_test::Suite
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
@@ -7489,7 +7498,8 @@ class Vault_test : public beast::unit_test::Suite
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
Env env(*this, features);
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const owner{"owner"};

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 const absM : mantissas)
{
for (std::int64_t const m : {absM, -absM})
{
for (int const 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 const absM : mantissas)
{
for (std::int64_t const m : {absM, -absM})
{
for (int const 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 const absM : mantissas)
{
for (std::int64_t const m : {absM, -absM})
{
for (int const 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;
}
}
}
}

View File

@@ -0,0 +1,176 @@
#include <xrpl/basics/Number.h>
#include <gtest/gtest.h>
#include <array>
#include <chrono>
#include <cstdint>
#include <iostream>
using namespace xrpl;
namespace NumberNormalizeBenchNs {
constexpr std::int64_t kBenchMin = 1'000'000'000'000'000;
constexpr std::int64_t kBenchMax = (kBenchMin * 10) - 1;
template <typename T>
void
doNotOptimize(T const& val)
{
asm volatile("" : : "r,m"(val) : "memory");
}
std::array<std::pair<std::int64_t, int>, 12> const kTestInputs = {{
{1, 0},
{7, 3},
{12345, -2},
{999'999'999, 0},
{999'999'999'999, 5},
{kBenchMin, 0},
{kBenchMax, -3},
{kBenchMax + 1, 0},
{1'234'567'890'123'456, 0},
{99'999'999'999'999'999, 0},
{1'234'567'890'123'456'789, 0},
{static_cast<std::int64_t>(9'000'000'000'000'000'000ull), 0},
}};
inline std::pair<std::int64_t, int>
twoPassNormalize(std::int64_t mantissa, int exponent)
{
Number const v{mantissa, exponent};
return v.normalizeToRange<kBenchMin, kBenchMax>();
}
inline std::pair<std::int64_t, int>
singlePassNormalize(std::int64_t mantissa, int exponent)
{
return Number::normalizeToRange<kBenchMin, kBenchMax>(mantissa, exponent);
}
constexpr int kWarmupIterations = 100'000;
constexpr int kBenchIterations = 5'000'000;
} // namespace NumberNormalizeBenchNs
using namespace NumberNormalizeBenchNs;
TEST(NumberNormalizeBench, SinglePassVsTwoPassPerformance)
{
for (int i = 0; i < kWarmupIterations; ++i)
{
for (auto const& [m, e] : kTestInputs)
{
doNotOptimize(twoPassNormalize(m, e));
doNotOptimize(singlePassNormalize(m, e));
}
}
auto const twoStart = std::chrono::steady_clock::now();
for (int i = 0; i < kBenchIterations; ++i)
{
for (auto const& [m, e] : kTestInputs)
{
doNotOptimize(twoPassNormalize(m, e));
}
}
auto const twoEnd = std::chrono::steady_clock::now();
auto const oneStart = std::chrono::steady_clock::now();
for (int i = 0; i < kBenchIterations; ++i)
{
for (auto const& [m, e] : kTestInputs)
{
doNotOptimize(singlePassNormalize(m, e));
}
}
auto const oneEnd = std::chrono::steady_clock::now();
auto const twoNs =
std::chrono::duration_cast<std::chrono::nanoseconds>(twoEnd - twoStart).count();
auto const oneNs =
std::chrono::duration_cast<std::chrono::nanoseconds>(oneEnd - oneStart).count();
double const twoPerCall = static_cast<double>(twoNs) / (kBenchIterations * kTestInputs.size());
double const onePerCall = static_cast<double>(oneNs) / (kBenchIterations * kTestInputs.size());
double const speedup = twoPerCall / onePerCall;
std::cout << "\n=== Single-Pass vs Two-Pass Normalize ===\n";
std::cout << "Iterations: " << kBenchIterations << " x " << kTestInputs.size()
<< " inputs = " << (kBenchIterations * kTestInputs.size()) << " calls\n";
std::cout << "Two-pass (old): " << twoPerCall << " ns/call (" << twoNs << " ns total)\n";
std::cout << "Single-pass (new): " << onePerCall << " ns/call (" << oneNs << " ns total)\n";
std::cout << "Speedup: " << speedup << "x\n";
std::cout << "==========================================\n\n";
if (speedup > 1.0)
{
std::cout << "Single-pass is FASTER by " << ((speedup - 1.0) * 100.0) << "%\n";
}
else
{
std::cout << "Two-pass is faster by " << ((1.0 / speedup - 1.0) * 100.0) << "%\n";
}
}
TEST(NumberNormalizeBench, SingleVsTwoPassBreakdown)
{
struct InputCategory
{
char const* name;
std::int64_t mantissa;
int exponent;
};
std::array<InputCategory, 6> const categories = {{
{.name = "1 (far from range)", .mantissa = 1, .exponent = 0},
{.name = "12345 (moderate)", .mantissa = 12345, .exponent = 0},
{.name = "10^12 (close)", .mantissa = 1'000'000'000'000, .exponent = 0},
{.name = "10^15 (in range)", .mantissa = kBenchMin, .exponent = 0},
{.name = "10^16 (1 over)", .mantissa = kBenchMax + 1, .exponent = 0},
{.name = "10^18 (far over)", .mantissa = 1'234'567'890'123'456'789, .exponent = 0},
}};
constexpr int kIters = 10'000'000;
std::cout << "\n=== Single vs Two Pass: Per-Input Breakdown ===\n";
std::cout << "Input | 2-pass ns | 1-pass ns | Speedup\n";
std::cout << "-------------------------|-----------|-----------|--------\n";
for (auto const& cat : categories)
{
for (int i = 0; i < 100'000; ++i)
{
doNotOptimize(twoPassNormalize(cat.mantissa, cat.exponent));
doNotOptimize(singlePassNormalize(cat.mantissa, cat.exponent));
}
auto const ts = std::chrono::steady_clock::now();
for (int i = 0; i < kIters; ++i)
{
doNotOptimize(twoPassNormalize(cat.mantissa, cat.exponent));
}
auto const te = std::chrono::steady_clock::now();
auto const os = std::chrono::steady_clock::now();
for (int i = 0; i < kIters; ++i)
{
doNotOptimize(singlePassNormalize(cat.mantissa, cat.exponent));
}
auto const oe = std::chrono::steady_clock::now();
double const twoNs =
static_cast<double>(
std::chrono::duration_cast<std::chrono::nanoseconds>(te - ts).count()) /
kIters;
double const oneNs =
static_cast<double>(
std::chrono::duration_cast<std::chrono::nanoseconds>(oe - os).count()) /
kIters;
double const speedup = twoNs / oneNs;
printf("%-25s| %9.2f | %9.2f | %.2fx\n", cat.name, twoNs, oneNs, speedup);
}
std::cout << "================================================\n\n";
}