diff --git a/include/xrpl/basics/MallocTrim.h b/include/xrpl/basics/MallocTrim.h new file mode 100644 index 00000000000..b518bafd194 --- /dev/null +++ b/include/xrpl/basics/MallocTrim.h @@ -0,0 +1,70 @@ +#ifndef XRPL_BASICS_MALLOCTRIM_H_INCLUDED +#define XRPL_BASICS_MALLOCTRIM_H_INCLUDED + +#include + +#include +#include + +namespace ripple { + +// ----------------------------------------------------------------------------- +// Allocator interaction note: +// - This facility invokes glibc's malloc_trim(0) on Linux/glibc to request that +// ptmalloc return free heap pages to the OS. +// - If an alternative allocator (e.g. jemalloc or tcmalloc) is linked or +// preloaded (LD_PRELOAD), calling glibc's malloc_trim typically has no effect +// on the *active* heap. The call is harmless but may not reclaim memory +// because those allocators manage their own arenas. +// - Only glibc sbrk/arena space is eligible for trimming; large mmap-backed +// allocations are usually returned to the OS on free regardless of trimming. +// - Call at known reclamation points (e.g., after cache sweeps / online delete) +// and consider rate limiting to avoid churn. +// ----------------------------------------------------------------------------- + +struct MallocTrimReport +{ + bool supported{false}; + int trimResult{-1}; + long rssBeforeKB{-1}; + long rssAfterKB{-1}; + + [[nodiscard]] long + deltaKB() const noexcept + { + if (rssBeforeKB < 0 || rssAfterKB < 0) + return 0; + return rssAfterKB - rssBeforeKB; + } +}; + +/** + * @brief Attempt to return freed memory to the operating system. + * + * On Linux with glibc malloc, this issues ::malloc_trim(0), which may release + * free space from ptmalloc arenas back to the kernel. On other platforms, or if + * a different allocator is in use, this function is a no-op and the report will + * indicate that trimming is unsupported or had no effect. + * + * @param tag Optional identifier for logging/debugging purposes. + * @param journal Journal for diagnostic logging. + * @return Report containing before/after metrics and the trim result. + * + * @note If an alternative allocator (jemalloc/tcmalloc) is linked or preloaded, + * calling glibc's malloc_trim may have no effect on the active heap. The + * call is harmless but typically does not reclaim memory under those + * allocators. + * + * @note Only memory served from glibc's sbrk/arena heaps is eligible for trim. + * Large allocations satisfied via mmap are usually returned on free + * independently of trimming. + * + * @note Intended for use after operations that free significant memory (e.g., + * cache sweeps, ledger cleanup, online delete). Consider rate limiting. + */ +MallocTrimReport +mallocTrim(std::optional const& tag, beast::Journal journal); + +} // namespace ripple + +#endif diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index 07db489a8f7..a67351cbaae 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -347,6 +347,13 @@ class SHAMap void invariants() const; + /** Log tree structure statistics for debugging/monitoring + @param j Journal to log to + @param mapName Name to identify this map in logs + */ + void + logTreeStats(beast::Journal j, std::string const& mapName) const; + private: using SharedPtrNodeStack = std::stack< std::pair, SHAMapNodeID>>; diff --git a/src/libxrpl/basics/MallocTrim.cpp b/src/libxrpl/basics/MallocTrim.cpp new file mode 100644 index 00000000000..a68831d1c9f --- /dev/null +++ b/src/libxrpl/basics/MallocTrim.cpp @@ -0,0 +1,121 @@ +#include +#include + +#include + +#include +#include + +#if defined(__GLIBC__) && BOOST_OS_LINUX +#include +#include + +namespace { +pid_t const cachedPid = ::getpid(); +} // namespace +#endif + +namespace ripple { + +namespace detail { + +#if defined(__GLIBC__) && BOOST_OS_LINUX + +long +parseVmRSSkB(std::string const& status) +{ + std::istringstream iss(status); + std::string line; + + while (std::getline(iss, line)) + { + // Allow leading spaces/tabs before the key. + auto const firstNonWs = line.find_first_not_of(" \t"); + if (firstNonWs == std::string::npos) + continue; + + constexpr char key[] = "VmRSS:"; + constexpr auto keyLen = sizeof(key) - 1; + + // Require the line (after leading whitespace) to start with "VmRSS:". + // Check if we have enough characters and the substring matches. + if (firstNonWs + keyLen > line.size() || + line.substr(firstNonWs, keyLen) != key) + continue; + + // Move past "VmRSS:" and any following whitespace. + auto pos = firstNonWs + keyLen; + while (pos < line.size() && + std::isspace(static_cast(line[pos]))) + { + ++pos; + } + + long value = -1; + if (std::sscanf(line.c_str() + pos, "%ld", &value) == 1) + return value; + + // Found the key but couldn't parse a number. + return -1; + } + + // No VmRSS line found. + return -1; +} + +#endif // __GLIBC__ && BOOST_OS_LINUX + +} // namespace detail + +MallocTrimReport +mallocTrim( + [[maybe_unused]] std::optional const& tag, + beast::Journal journal) +{ + MallocTrimReport report; + +#if !(defined(__GLIBC__) && BOOST_OS_LINUX) + JLOG(journal.debug()) << "malloc_trim not supported on this platform"; +#else + + report.supported = true; + + if (journal.debug()) + { + auto readFile = [](std::string const& path) -> std::string { + std::ifstream ifs(path); + if (!ifs.is_open()) + return {}; + return std::string( + std::istreambuf_iterator(ifs), + std::istreambuf_iterator()); + }; + + std::string const tagStr = tag.value_or("default"); + std::string const statusPath = + "/proc/" + std::to_string(cachedPid) + "/status"; + + auto const statusBefore = readFile(statusPath); + report.rssBeforeKB = detail::parseVmRSSkB(statusBefore); + + report.trimResult = ::malloc_trim(0); + + auto const statusAfter = readFile(statusPath); + report.rssAfterKB = detail::parseVmRSSkB(statusAfter); + + JLOG(journal.debug()) + << "malloc_trim tag=" << tagStr << " result=" << report.trimResult + << " rss_before=" << report.rssBeforeKB << "kB" + << " rss_after=" << report.rssAfterKB << "kB" + << " delta=" << report.deltaKB() << "kB"; + } + else + { + report.trimResult = ::malloc_trim(0); + } +#endif + + return report; +} + +} // namespace ripple diff --git a/src/libxrpl/shamap/SHAMap.cpp b/src/libxrpl/shamap/SHAMap.cpp index ef27d37ed35..40c09178fd3 100644 --- a/src/libxrpl/shamap/SHAMap.cpp +++ b/src/libxrpl/shamap/SHAMap.cpp @@ -1240,4 +1240,72 @@ SHAMap::invariants() const node->invariants(true); } +void +SHAMap::logTreeStats(beast::Journal j, std::string const& mapName) const +{ + struct Stats + { + std::uint32_t totalNodes = 0; + std::uint32_t innerNodes = 0; + std::uint32_t leafNodes = 0; + std::uint32_t maxDepth = 0; + std::array depthCount; + Stats() + { + depthCount.fill(0); + } + } stats; + + std::function const&, std::uint32_t)> + traverse; + traverse = [&](intr_ptr::SharedPtr const& node, + std::uint32_t depth) { + if (!node) + return; + + stats.totalNodes++; + stats.depthCount[depth]++; + stats.maxDepth = std::max(stats.maxDepth, depth); + + if (node->isInner()) + { + stats.innerNodes++; + auto inner = dynamic_cast(node.get()); + if (inner) + { + for (int i = 0; i < branchFactor; ++i) + { + if (auto child = inner->getChild(i)) + { + traverse(child, depth + 1); + } + } + } + } + else + { + stats.leafNodes++; + } + }; + + traverse(root_, 0); + + JLOG(j.info()) << "SHAMap (" << mapName + << ") stats: total_nodes=" << stats.totalNodes + << ", inner_nodes=" << stats.innerNodes + << ", leaf_nodes=" << stats.leafNodes + << ", max_depth=" << stats.maxDepth; + + std::ostringstream hist; + hist << "Depth histogram: { "; + for (std::uint32_t d = 0; d <= stats.maxDepth; ++d) + { + if (stats.depthCount[d] > 0) + hist << d << ": " << stats.depthCount[d] << ", "; + } + hist << "}"; + JLOG(j.debug()) << "SHAMap (" << mapName << ") " << hist.str(); +} + } // namespace ripple diff --git a/src/tests/libxrpl/basics/MallocTrim.cpp b/src/tests/libxrpl/basics/MallocTrim.cpp new file mode 100644 index 00000000000..dd691180d23 --- /dev/null +++ b/src/tests/libxrpl/basics/MallocTrim.cpp @@ -0,0 +1,207 @@ +#include + +#include + +#include + +using namespace ripple; + +#if defined(__GLIBC__) && BOOST_OS_LINUX +namespace ripple::detail { +long +parseVmRSSkB(std::string const& status); +} // namespace ripple::detail +#endif + +TEST_CASE("MallocTrimReport structure") +{ + // Test default construction + MallocTrimReport report; + CHECK(report.supported == false); + CHECK(report.trimResult == -1); + CHECK(report.rssBeforeKB == -1); + CHECK(report.rssAfterKB == -1); + CHECK(report.deltaKB() == 0); + + // Test deltaKB calculation - memory freed + report.rssBeforeKB = 1000; + report.rssAfterKB = 800; + CHECK(report.deltaKB() == -200); + + // Test deltaKB calculation - memory increased + report.rssBeforeKB = 500; + report.rssAfterKB = 600; + CHECK(report.deltaKB() == 100); + + // Test deltaKB calculation - no change + report.rssBeforeKB = 1234; + report.rssAfterKB = 1234; + CHECK(report.deltaKB() == 0); +} + +#if defined(__GLIBC__) && BOOST_OS_LINUX +TEST_CASE("parseVmRSSkB") +{ + using ripple::detail::parseVmRSSkB; + + // Test standard format + { + std::string status = "VmRSS: 123456 kB\n"; + long result = parseVmRSSkB(status); + CHECK(result == 123456); + } + + // Test with multiple lines + { + std::string status = + "Name: rippled\n" + "VmPeak: 1234567 kB\n" + "VmSize: 1234567 kB\n" + "VmRSS: 987654 kB\n" + "VmData: 123456 kB\n"; + long result = parseVmRSSkB(status); + CHECK(result == 987654); + } + + // Test with minimal whitespace + { + std::string status = "VmRSS: 42 kB"; + long result = parseVmRSSkB(status); + CHECK(result == 42); + } + + // Test with extra whitespace + { + std::string status = "VmRSS: 999999 kB"; + long result = parseVmRSSkB(status); + CHECK(result == 999999); + } + + // Test with tabs + { + std::string status = "VmRSS:\t\t12345 kB"; + long result = parseVmRSSkB(status); + // Note: tabs are not explicitly handled as spaces, this documents + // current behavior + CHECK(result == 12345); + } + + // Test zero value + { + std::string status = "VmRSS: 0 kB\n"; + long result = parseVmRSSkB(status); + CHECK(result == 0); + } + + // Test missing VmRSS + { + std::string status = + "Name: rippled\n" + "VmPeak: 1234567 kB\n" + "VmSize: 1234567 kB\n"; + long result = parseVmRSSkB(status); + CHECK(result == -1); + } + + // Test empty string + { + std::string status = ""; + long result = parseVmRSSkB(status); + CHECK(result == -1); + } + + // Test malformed data (VmRSS but no number) + { + std::string status = "VmRSS: \n"; + long result = parseVmRSSkB(status); + // sscanf should fail to parse and return -1 unchanged + CHECK(result == -1); + } + + // Test malformed data (VmRSS but invalid number) + { + std::string status = "VmRSS: abc kB\n"; + long result = parseVmRSSkB(status); + // sscanf should fail and return -1 unchanged + CHECK(result == -1); + } + + // Test partial match (should not match "NotVmRSS:") + { + std::string status = "NotVmRSS: 123456 kB\n"; + long result = parseVmRSSkB(status); + CHECK(result == -1); + } +} +#endif + +TEST_CASE("mallocTrim basic functionality") +{ + beast::Journal journal{beast::Journal::getNullSink()}; + + // Test with no tag + { + MallocTrimReport report = mallocTrim(std::nullopt, journal); + +#if defined(__GLIBC__) && BOOST_OS_LINUX + // On Linux with glibc, should be supported + CHECK(report.supported == true); + // trimResult should be 0 or 1 (success indicators) + CHECK(report.trimResult >= 0); +#else + // On other platforms, should be unsupported + CHECK(report.supported == false); + CHECK(report.trimResult == -1); + CHECK(report.rssBeforeKB == -1); + CHECK(report.rssAfterKB == -1); +#endif + } + + // Test with tag + { + MallocTrimReport report = + mallocTrim(std::optional("test_tag"), journal); + +#if defined(__GLIBC__) && BOOST_OS_LINUX + CHECK(report.supported == true); + CHECK(report.trimResult >= 0); +#else + CHECK(report.supported == false); +#endif + } +} + +TEST_CASE("mallocTrim with debug logging") +{ + beast::Journal journal{beast::Journal::getNullSink()}; + + MallocTrimReport report = + mallocTrim(std::optional("debug_test"), journal); + +#if defined(__GLIBC__) && BOOST_OS_LINUX + CHECK(report.supported == true); + // The function should complete without crashing +#else + CHECK(report.supported == false); +#endif +} + +TEST_CASE("mallocTrim repeated calls") +{ + beast::Journal journal{beast::Journal::getNullSink()}; + + // Call malloc_trim multiple times to ensure it's safe + for (int i = 0; i < 5; ++i) + { + MallocTrimReport report = mallocTrim( + std::optional("iteration_" + std::to_string(i)), + journal); + +#if defined(__GLIBC__) && BOOST_OS_LINUX + CHECK(report.supported == true); + CHECK(report.trimResult >= 0); +#else + CHECK(report.supported == false); +#endif + } +} diff --git a/src/xrpld/app/ledger/detail/LedgerMaster.cpp b/src/xrpld/app/ledger/detail/LedgerMaster.cpp index 0c3b3266d9e..3fb6fb62b26 100644 --- a/src/xrpld/app/ledger/detail/LedgerMaster.cpp +++ b/src/xrpld/app/ledger/detail/LedgerMaster.cpp @@ -261,6 +261,13 @@ LedgerMaster::setValidLedger(std::shared_ptr const& l) (void)max_ledger_difference_; mValidLedgerSeq = l->info().seq; + if (l->info().seq % 100 == 0) + { + beast::Journal statsJournal = app_.journal("SHAMapStats"); + l->stateMap().logTreeStats(statsJournal, "AccountStateMap"); + l->txMap().logTreeStats(statsJournal, "TransactionMap"); + } + app_.getOPs().updateLocalTx(*l); app_.getSHAMapStore().onLedgerClosed(getValidatedLedger()); mLedgerHistory.validatedLedger(l, consensusHash); diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp index 2ba66309458..1b6886ae32f 100644 --- a/src/xrpld/app/main/Application.cpp +++ b/src/xrpld/app/main/Application.cpp @@ -37,6 +37,7 @@ #include #include +#include #include #include #include @@ -1106,6 +1107,8 @@ class ApplicationImp : public Application, public BasicApp << "; size after: " << cachedSLEs_.size(); } + mallocTrim(std::optional("doSweep"), m_journal); + // Set timer to do another sweep later. setSweepTimer(); } diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 963f3dc3ea3..a2d7d9c8774 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -34,6 +34,7 @@ #include #include +#include #include #include #include @@ -2546,10 +2547,14 @@ NetworkOPsImp::setMode(OperatingMode om) if (mMode == om) return; + auto const oldMode = mMode.load(std::memory_order_relaxed); mMode = om; accounting_.mode(om); + if (oldMode != OperatingMode::FULL && om == OperatingMode::FULL) + mallocTrim(std::optional("SyncComplete"), m_journal); + JLOG(m_journal.info()) << "STATE->" << strOperatingMode(); pubServer(); } diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp index d7711455408..ed45b0adb9f 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.cpp +++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -545,6 +546,8 @@ SHAMapStoreImp::clearCaches(LedgerIndex validatedSeq) { ledgerMaster_->clearLedgerCachePrior(validatedSeq); fullBelowCache_->clear(); + + mallocTrim(std::optional("clearCaches"), journal_); } void @@ -610,6 +613,8 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) }); if (healthWait() == stopping) return; + + mallocTrim(std::optional("clearPrior"), journal_); } SHAMapStoreImp::HealthResult