perf(math): route ordinary products through MKL and preserve arithmetic

This commit is contained in:
KOKO\Mimi
2026-10-14 15:09:53 +09:00
parent b4fc1bb54b
commit 59c8e93b07
18 changed files with 793 additions and 169 deletions
@@ -5,6 +5,7 @@
#include <array>
#include <cstddef>
#include <limits>
#include <stdexcept>
namespace fesa::dense_blas_internal {
namespace {
@@ -35,5 +36,26 @@ TEST(DenseBlasInternal, CDup003CopiesZeroAndNonzeroContiguousValues) {
EXPECT_EQ(destination, source);
}
TEST(DenseBlasInternal, TransposeProductChecksShapeAndKnownRectangularAction) {
Matrix left{3U, 2U};
Matrix right{3U, 1U};
for (std::size_t row = 0; row < 3U; ++row) {
left(row, 0U) = static_cast<double>(row + 1U);
left(row, 1U) = 2.0;
right(row, 0U) = static_cast<double>(row);
}
const auto product = MultiplyTransposeLeft(left, right);
ASSERT_EQ(product.Rows(), 2U);
ASSERT_EQ(product.Columns(), 1U);
EXPECT_DOUBLE_EQ(product(0U, 0U), 8.0);
EXPECT_DOUBLE_EQ(product(1U, 0U), 6.0);
EXPECT_THROW(static_cast<void>(MultiplyTransposeLeft(left, Matrix{2U, 1U})),
std::invalid_argument);
const auto empty = MultiplyTransposeLeft(Matrix{0U, 2U}, Matrix{0U, 3U});
EXPECT_EQ(empty.Rows(), 2U);
EXPECT_EQ(empty.Columns(), 3U);
EXPECT_DOUBLE_EQ(empty(1U, 2U), 0.0);
}
} // namespace
} // namespace fesa::dense_blas_internal
@@ -0,0 +1,162 @@
#ifndef FESA_TEST_HIGH_LOW_ARITHMETIC_ORIGINAL_H_
#define FESA_TEST_HIGH_LOW_ARITHMETIC_ORIGINAL_H_
#include <cmath>
#include <cfenv>
#include <cstdint>
#include <limits>
#if defined(_M_X64) || defined(__SSE2__)
#include <xmmintrin.h>
#endif
namespace fesa::high_low_original {
/// @brief Checks the rounding and gradual-underflow premises without mutation.
inline bool ArithmeticEnvironmentSupported() {
if (std::fegetround() != FE_TONEAREST) return false;
#if defined(_M_X64) || defined(__SSE2__)
// MXCSR FTZ, DAZ and rounding-control bits must all be clear.
if ((_mm_getcsr() & 0xE040U) != 0U) return false;
#endif
return true;
}
/// @brief Two binary64 components of one physical value; raw parts are kept.
/// @note Arithmetic requires normalized finite parts and nearest rounding.
/// This is an expansion with bounded rounding, not an IEEE quad type.
struct HighLow {
double high{0};
double low{0};
HighLow() = default;
// Scalar lifting canonicalizes exact signed zero; raw pair inputs do not.
HighLow(double value) : high(value == 0 ? 0.0 : value) {}
HighLow(double high_part, double low_part)
: high(high_part), low(low_part) {}
};
/// @brief Gives an invalid arithmetic result for a checked caller to reject.
inline HighLow InvalidHighLow() {
return {std::numeric_limits<double>::quiet_NaN(), 0};
}
/// @brief Checks both physical components, before comparisons or publication.
inline bool IsFinite(HighLow value) {
return std::isfinite(value.high) && std::isfinite(value.low);
}
/// @brief Recovers the exact error of supported finite binary64 addition.
inline HighLow TwoSum(double left, double right) {
const double sum = left + right;
const double bridge = sum - left;
const double restored_left = sum - bridge;
const double error = (left - restored_left) + (right - bridge);
if (!std::isfinite(sum) || !std::isfinite(bridge) ||
!std::isfinite(restored_left) || !std::isfinite(error))
return InvalidHighLow();
return sum == 0 && error == 0 ? HighLow{} : HighLow{sum, error};
}
/// @brief Requires the raw pair to equal its nearest-rounding normalization.
inline bool IsNormalized(HighLow value) {
if (!IsFinite(value)) return false;
if (value.high == 0 && value.low == 0)
return !std::signbit(value.high) && !std::signbit(value.low);
const auto normalized = TwoSum(value.high, value.low);
return IsFinite(normalized) && normalized.high == value.high &&
normalized.low == value.low;
}
/// @brief Rounds the full physical value to one finite output value.
inline double Rounded(HighLow value) {
return IsNormalized(value) ? value.high + value.low
: std::numeric_limits<double>::quiet_NaN();
}
/// @brief Adds normalized expansions; rounding of discarded tail is bounded.
inline HighLow Add(HighLow left, HighLow right) {
if (!IsNormalized(left) || !IsNormalized(right)) return InvalidHighLow();
const auto head = TwoSum(left.high, right.high);
const auto tail = TwoSum(left.low, right.low);
const auto middle = TwoSum(head.low, tail.high);
const auto joined = TwoSum(head.high, middle.high);
const double remainder = (joined.low + middle.low) + tail.low;
if (!IsFinite(head) || !IsFinite(tail) || !IsFinite(middle) ||
!IsFinite(joined) || !std::isfinite(remainder))
return InvalidHighLow();
return TwoSum(joined.high, remainder);
}
/// @brief Negates both components without collapsing the physical value.
inline HighLow Negate(HighLow value) {
if (!IsNormalized(value)) return InvalidHighLow();
return value.high == 0 && value.low == 0
? HighLow{}
: HighLow{-value.high, -value.low};
}
/// @brief Subtracts full expansions, including cancellations between highs.
inline HighLow Subtract(HighLow left, HighLow right) {
return Add(left, Negate(right));
}
/// @brief Returns the exponent of the least nonzero binary64 significand bit.
/// @pre value is finite and nonzero; gradual underflow is enabled.
inline int LowestBitExponent(double value) {
int exponent = 0;
const double fraction = std::frexp(std::abs(value), &exponent);
auto significand = static_cast<std::uint64_t>(std::ldexp(fraction, 53));
int trailing = 0;
while ((significand & 1U) == 0U) {
significand >>= 1U;
++trailing;
}
return exponent - 53 + trailing;
}
/// @brief Gives a product and its FMA residual when the dyadic tail fits.
/// @note Rejects underflow-risk exact tails, even if the rounded high is finite.
inline HighLow TwoProduct(double left, double right) {
if (!std::isfinite(left) || !std::isfinite(right)) return InvalidHighLow();
if (left == 0 || right == 0) return {};
if (LowestBitExponent(left) + LowestBitExponent(right) < -1074)
return InvalidHighLow();
const double product = left * right;
if (!std::isfinite(product)) return InvalidHighLow();
const double error = std::fma(left, right, -product);
if (!std::isfinite(error)) return InvalidHighLow();
return TwoSum(product, error);
}
/// @brief Retains all four component products in deterministic order.
inline HighLow Multiply(HighLow left, HighLow right) {
if (!IsNormalized(left) || !IsNormalized(right)) return InvalidHighLow();
auto product = TwoProduct(left.high, right.high);
product = Add(product, TwoProduct(left.high, right.low));
product = Add(product, TwoProduct(left.low, right.high));
return Add(product, TwoProduct(left.low, right.low));
}
/// @brief Orders canonical values; 2 means unordered/unsupported input.
inline int Compare(HighLow left, HighLow right) {
if (!IsNormalized(left) || !IsNormalized(right)) return 2;
if (left.high < right.high) return -1;
if (left.high > right.high) return 1;
if (left.low < right.low) return -1;
if (left.low > right.low) return 1;
return 0;
}
/// @brief Takes the absolute value using the full physical sign.
inline HighLow Abs(HighLow value) {
const int sign = Compare(value, HighLow{});
return sign == 2 ? InvalidHighLow() : (sign < 0 ? Negate(value) : value);
}
/// @brief Refines a binary64 quotient twice using the full residual.
/// @note Unsupported products/quotient underflow propagate an invalid result.
inline HighLow Divide(HighLow numerator, HighLow denominator) {
if (!IsNormalized(numerator) || !IsNormalized(denominator) ||
Compare(denominator, HighLow{}) == 0)
return InvalidHighLow();
if (Compare(numerator, HighLow{}) == 0) return {};
const double first = numerator.high / denominator.high;
if (!std::isfinite(first) || first == 0) return InvalidHighLow();
auto quotient = HighLow{first};
for (int iteration = 0; iteration < 2; ++iteration) {
const auto residual = Subtract(numerator, Multiply(denominator, quotient));
if (!IsNormalized(residual)) return InvalidHighLow();
const double correction = residual.high / denominator.high;
if (!std::isfinite(correction) || (residual.high != 0 && correction == 0))
return InvalidHighLow();
quotient = Add(quotient, HighLow{correction});
}
return quotient;
}
} // namespace fesa::high_low_original
#endif // FESA_TEST_HIGH_LOW_ARITHMETIC_ORIGINAL_H_
@@ -3,7 +3,13 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <limits>
#include <random>
#include <vector>
#include "high_low_arithmetic_original.h"
namespace {
using namespace fesa::math_internal;
@@ -125,4 +131,92 @@ TEST(HighLowArithmetic, RejectsChangedRoundingAndFlushToZeroEnvironment) {
EXPECT_FALSE(ArithmeticEnvironmentSupported());
#endif
}
std::uint64_t Bits(const double value) {
std::uint64_t result = 0;
std::memcpy(&result, &value, sizeof(result));
return result;
}
double FromBits(const std::uint64_t bits) {
double result = 0;
std::memcpy(&result, &bits, sizeof(result));
return result;
}
void ExpectSamePair(const HighLow actual,
const fesa::high_low_original::HighLow expected) {
EXPECT_EQ(Bits(actual.high), Bits(expected.high));
EXPECT_EQ(Bits(actual.low), Bits(expected.low));
}
TEST(HighLowArithmetic, BitExponentMatchesFrozenOracleAcrossEveryExponent) {
ASSERT_TRUE(ArithmeticEnvironmentSupported());
// Every normal exponent, every possible trailing significand-bit count,
// both signs; then every subnormal single-bit and mixed-bit boundary.
for (std::uint64_t exponent = 1; exponent < 2047; ++exponent) {
for (unsigned trailing = 0; trailing <= 52U; ++trailing) {
const auto fraction = trailing == 52U ? 0ULL : 1ULL << trailing;
for (const auto sign : {0ULL, 1ULL << 63U}) {
const double value = FromBits(sign | (exponent << 52U) | fraction);
ASSERT_EQ(LowestBitExponent(value),
fesa::high_low_original::LowestBitExponent(value));
}
}
}
for (unsigned bit = 0; bit < 52U; ++bit) {
for (const auto significand : {1ULL << bit, (1ULL << 52U) - (1ULL << bit)}) {
const double value = FromBits(significand);
EXPECT_EQ(LowestBitExponent(value),
fesa::high_low_original::LowestBitExponent(value));
}
}
}
TEST(HighLowArithmetic, OptimizedArithmeticMatchesFrozenOriginalBits) {
ASSERT_TRUE(ArithmeticEnvironmentSupported());
std::vector<double> values{0.0, -0.0,
std::numeric_limits<double>::denorm_min(),
-std::numeric_limits<double>::denorm_min(),
std::numeric_limits<double>::min(),
std::nextafter(std::numeric_limits<double>::min(), 0.0),
std::numeric_limits<double>::max(),
-std::numeric_limits<double>::max(), 1.0, -1.0,
std::nextafter(1.0, 2.0), std::ldexp(1.0, -53),
std::ldexp(1.0, -54), std::numeric_limits<double>::infinity(),
-std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::quiet_NaN()};
std::mt19937_64 random{0x58a0fU};
for (int sample = 0; sample < 4000; ++sample) {
values.push_back(FromBits(random()));
}
for (std::size_t index = 0; index < values.size(); ++index) {
const double high = values[index];
const double low = values[(index * 13U + 7U) % values.size()];
const fesa::high_low_original::HighLow original{high, low};
EXPECT_EQ(IsNormalized({high, low}),
fesa::high_low_original::IsNormalized(original));
// Normalize a raw pair with the frozen operation, never the new predicate.
const auto a = fesa::high_low_original::TwoSum(high, low);
const auto b = fesa::high_low_original::TwoSum(
values[(index + 1U) % values.size()],
values[(index * 3U + 2U) % values.size()]);
ExpectSamePair(Add({a.high, a.low}, {b.high, b.low}),
fesa::high_low_original::Add(a, b));
ExpectSamePair(Multiply({a.high, a.low}, {b.high, b.low}),
fesa::high_low_original::Multiply(a, b));
ExpectSamePair(Divide({a.high, a.low}, {b.high, b.low}),
fesa::high_low_original::Divide(a, b));
ExpectSamePair(TwoProduct(high, low),
fesa::high_low_original::TwoProduct(high, low));
}
for (double high : {1.0, std::nextafter(1.0, 2.0), -1.0,
std::numeric_limits<double>::max()}) {
for (double low : {0.0, -0.0, std::ldexp(1.0, -53),
-std::ldexp(1.0, -53)}) {
EXPECT_EQ(IsNormalized({high, low}),
fesa::high_low_original::IsNormalized({high, low}));
}
}
}
} // namespace
+99
View File
@@ -3,6 +3,8 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cmath>
#include <future>
#include <limits>
#include <type_traits>
#include <utility>
@@ -131,4 +133,101 @@ TEST(SparseAssembly, PreservesExpectedStructuralZeros) {
4);
}
TEST(SparseAssembly, MultipliesRectangularCopyMoveAndConcurrentConstCalls) {
auto assembled = SparseMatrix::FromCoo(
3U, 5U, {{0U, 4U, 3.0, 0U, 0U}, {2U, 1U, -2.0, 1U, 0U}},
{{0U, 1U, 1U, 2U}, {4U, 1U}});
ASSERT_TRUE(assembled.HasValue());
const auto original = assembled.Value();
auto copied = original;
auto moved = std::move(copied);
Vector rhs{5U, 2.0};
const auto evaluate = [&rhs](const SparseMatrix& matrix) {
for (int repeat = 0; repeat < 20; ++repeat) {
const auto product = matrix.Multiply(rhs);
if (product.Size() != 3U || product[0U] != 6.0 ||
product[1U] != 0.0 || product[2U] != -4.0) return false;
}
return true;
};
auto first = std::async(std::launch::async, [&] { return evaluate(moved); });
auto second = std::async(std::launch::async, [&] { return evaluate(moved); });
EXPECT_TRUE(first.get());
EXPECT_TRUE(second.get());
EXPECT_TRUE(evaluate(original));
auto replacement = SparseMatrix::FromCoo(
1U, 1U, {{0U, 0U, 7.0, 0U, 0U}}, {{0U, 1U}, {0U}});
ASSERT_TRUE(replacement.HasValue());
moved = replacement.Value();
EXPECT_DOUBLE_EQ(moved.Multiply(Vector{1U, 3.0})[0U], 21.0);
EXPECT_TRUE(evaluate(original));
}
TEST(SparseAssembly, PreservesZeroShapesAndEmptyRows) {
auto no_rows = SparseMatrix::FromCoo(0U, 3U, {}, {{0U}, {}});
auto no_columns = SparseMatrix::FromCoo(2U, 0U, {}, {{0U, 0U, 0U}, {}});
auto no_entries = SparseMatrix::FromCoo(2U, 3U, {}, {{0U, 0U, 0U}, {}});
ASSERT_TRUE(no_rows.HasValue());
ASSERT_TRUE(no_columns.HasValue());
ASSERT_TRUE(no_entries.HasValue());
EXPECT_EQ(no_rows.Value().Multiply(Vector{3U}).Size(), 0U);
const auto empty_columns = no_columns.Value().Multiply(Vector{0U});
const auto empty_entries = no_entries.Value().Multiply(Vector{3U});
ASSERT_EQ(empty_columns.Size(), 2U);
ASSERT_EQ(empty_entries.Size(), 2U);
for (std::size_t row = 0; row < 2U; ++row) {
EXPECT_DOUBLE_EQ(empty_columns[row], 0.0);
EXPECT_DOUBLE_EQ(empty_entries[row], 0.0);
}
}
TEST(SparseAssembly, KeepsIeeeNonfiniteResultsAndRecoversAfterBadRhs) {
auto matrix = SparseMatrix::FromCoo(
2U, 2U, {{0U, 0U, 2.0, 0U, 0U}}, {{0U, 1U, 2U}, {0U, 1U}});
ASSERT_TRUE(matrix.HasValue());
Vector rhs{2U, (std::numeric_limits<double>::infinity)()};
const auto nonfinite = matrix.Value().Multiply(rhs);
EXPECT_TRUE(std::isinf(nonfinite[0U]));
EXPECT_TRUE(std::isnan(nonfinite[1U])); // Stored structural zero times Inf.
rhs[0U] = (std::numeric_limits<double>::max)();
EXPECT_TRUE(std::isinf(matrix.Value().Multiply(rhs)[0U]));
EXPECT_THROW(static_cast<void>(matrix.Value().Multiply(Vector{1U})),
std::invalid_argument);
rhs[0U] = 3.0;
rhs[1U] = 4.0;
const auto finite = matrix.Value().Multiply(rhs);
EXPECT_DOUBLE_EQ(finite[0U], 6.0);
EXPECT_DOUBLE_EQ(finite[1U], 0.0);
}
TEST(SparseAssembly, MatchesIndependentLongDoubleResidualOnUnevenRows) {
const std::vector<double> entries{1.25, -0.375, 4.5, -3.0, 0.125, 2.0};
const SparsePattern pattern{{0U, 3U, 3U, 5U, 6U}, {0U, 2U, 4U, 1U, 3U, 2U}};
std::vector<CooContribution> contributions;
for (std::size_t row = 0; row < 4U; ++row) {
for (auto position = pattern.row_offsets[row];
position < pattern.row_offsets[row + 1U]; ++position) {
contributions.push_back(
{row, pattern.column_indices[position], entries[position], row,
position});
}
}
auto matrix = SparseMatrix::FromCoo(4U, 5U, contributions, pattern);
ASSERT_TRUE(matrix.HasValue());
Vector rhs{5U};
for (std::size_t column = 0; column < 5U; ++column) {
rhs[column] = 0.3 + static_cast<double>(column) * 0.7;
}
const auto product = matrix.Value().Multiply(rhs);
for (std::size_t row = 0; row < 4U; ++row) {
long double expected = 0;
for (auto position = pattern.row_offsets[row];
position < pattern.row_offsets[row + 1U]; ++position) {
expected += static_cast<long double>(entries[position]) *
rhs[pattern.column_indices[position]];
}
EXPECT_NEAR(product[row], static_cast<double>(expected), 2.0e-14);
}
}
} // namespace