<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Valentin Balaschenko</title>
    <description>The latest articles on DEV Community by Valentin Balaschenko (@valentin_balaschenko_f03a).</description>
    <link>https://dev.to/valentin_balaschenko_f03a</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1710567%2F447da1f2-59b1-4c9c-97dc-2f9b21fb28e2.jpg</url>
      <title>DEV Community: Valentin Balaschenko</title>
      <link>https://dev.to/valentin_balaschenko_f03a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/valentin_balaschenko_f03a"/>
    <language>en</language>
    <item>
      <title>Memory in an XRPL Node: Explicit Heap Trimming after Cache Sweeps</title>
      <dc:creator>Valentin Balaschenko</dc:creator>
      <pubDate>Thu, 05 Nov 2026 16:05:46 +0000</pubDate>
      <link>https://dev.to/ripplexdev/memory-in-an-xrpl-node-explicit-heap-trimming-after-cache-sweeps-4bi9</link>
      <guid>https://dev.to/ripplexdev/memory-in-an-xrpl-node-explicit-heap-trimming-after-cache-sweeps-4bi9</guid>
      <description>&lt;p&gt;&lt;strong&gt;For operators&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you measure your node's memory, read the xrpld process, its RSS from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; or a process exporter, not the &lt;code&gt;systemctl status&lt;/code&gt; figure, which includes the kernel's page cache.&lt;/li&gt;
&lt;li&gt;If you run &lt;code&gt;xrpld&lt;/code&gt; 3.3.0 or later, your node already trims the heap after every cache sweep. There is nothing to configure.&lt;/li&gt;
&lt;li&gt;If you build or run &lt;code&gt;xrpld&lt;/code&gt; with an allocator other than glibc's, the trim does not apply.&lt;/li&gt;
&lt;li&gt;If your memory graph has become a fine sawtooth since upgrading, that is the trim working, not a fault.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/ripplexdev/memory-in-an-xrpl-node-hunting-a-leak-that-wasnt-there-2c3l"&gt;previous analysis&lt;/a&gt; identified the cause of memory growth as allocator behaviour rather than a memory leak. The node's caches released memory as expected at regular intervals, but the process's memory footprint continued to increase. This was due to &lt;a href="https://www.gnu.org/software/libc/" rel="noopener noreferrer"&gt;glibc&lt;/a&gt;'s allocator retaining freed memory instead of returning it to the operating system. The application was aware of the timing of large memory releases, but the allocator was not informed of these events.&lt;/p&gt;

&lt;p&gt;glibc provides the &lt;a href="https://man7.org/linux/man-pages/man3/malloc_trim.3.html" rel="noopener noreferrer"&gt;&lt;code&gt;malloc_trim&lt;/code&gt;&lt;/a&gt; function, which instructs the allocator to return unused memory to the kernel. This post describes the implementation of explicit heap trimming, the criteria for invoking &lt;code&gt;malloc_trim&lt;/code&gt;, the trigger points considered, why allocator-level alternatives were not pursued, associated performance costs, and the observed impact during controlled testing and production deployment on Mainnet validators.&lt;/p&gt;

&lt;p&gt;The path from finding to release took most of a year. Candidate trigger points were tested on a lab validator in November 2025, the change was merged into the development branch in February 2026 and measured in the performance lab, and it then ran on a Mainnet node through the 3.2.0 release-candidate period. It was held back from 3.2.0 as a precaution while a consensus-stability question was investigated, so 3.2.0 as released does not contain it although its release notes initially listed the change. The investigation found no effect from trimming, and the change shipped in 3.3.0 in August 2026.&lt;/p&gt;

&lt;p&gt;The change was introduced in &lt;a href="https://github.com/XRPLF/rippled/releases/tag/3.3.0" rel="noopener noreferrer"&gt;xrpld version 3.3.0&lt;/a&gt; and is now active on all nodes running that release or later. On Ripple's Mainnet validators it lowered steady-state resident memory by 13% to 23%. The following sections detail its operational effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allocator retention heuristics and explicit heap trimming
&lt;/h2&gt;

&lt;p&gt;glibc's memory allocator is designed for efficient repeated allocations by placing freed memory onto free lists, which avoids system calls for similar-sized requests. To reduce lock contention, glibc implements multiple independent heaps called arenas. Each active thread is assigned an arena, and on 64-bit systems, the allocator can create up to eight arenas per processor core (see &lt;a href="https://man7.org/linux/man-pages/man3/mallopt.3.html" rel="noopener noreferrer"&gt;mallopt(3)&lt;/a&gt;). In multi-threaded applications, this results in free memory being retained across many arenas simultaneously.&lt;/p&gt;

&lt;p&gt;Left to itself, glibc returns freed memory to the operating system in two scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Large allocations.&lt;/strong&gt; Allocations at or above the &lt;code&gt;M_MMAP_THRESHOLD&lt;/code&gt; are given their own memory mapping from the kernel with &lt;code&gt;mmap&lt;/code&gt;, and that mapping is removed as soon as they are freed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free space at the top of an arena.&lt;/strong&gt; Smaller allocations are handled within arenas. An arena releases memory only if contiguous free space at the top exceeds &lt;code&gt;M_TRIM_THRESHOLD&lt;/code&gt;, which defaults to 128 KiB. Free memory located elsewhere in the arena is retained regardless of its size.
Both thresholds are dynamic. By default, glibc increases the &lt;code&gt;mmap&lt;/code&gt; threshold when a block larger than the current threshold is freed, raising it to the size of that block, up to a maximum of 32 MiB on 64-bit systems. The trim threshold is set to twice the &lt;code&gt;mmap&lt;/code&gt; threshold. As a result, if a process occasionally frees large blocks, the allocator becomes less aggressive in trimming memory. There is no automatic adjustment in the other direction. The kernel cannot signal memory pressure to the allocator, and the allocator does not revisit previously retained memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;xrpld&lt;/code&gt;'s allocation pattern is poorly handled by these heuristics. The heap is dominated by small objects, such as tree nodes and ledger entries, allocated in large numbers into caches with varying lifetimes. When caches evict, freed chunks are interleaved with live allocations, so the top of each arena is rarely free and automatic trimming is infrequent. Freed memory therefore remains resident across many arenas, and the process grows until the retained memory matches the application's allocation churn, the rate at which it allocates and frees short-lived objects. This retained volume was the growth measured in the previous post.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;malloc_trim&lt;/code&gt; is the allocator's explicit release path. Since glibc 2.8 it iterates over every arena and returns every whole page of free memory to the kernel, wherever that page sits within the heap, using the &lt;code&gt;madvise&lt;/code&gt; system call to release pages, or &lt;code&gt;sbrk&lt;/code&gt; to shrink the top of the main heap (&lt;a href="https://man7.org/linux/man-pages/man3/malloc_trim.3.html" rel="noopener noreferrer"&gt;malloc_trim(3)&lt;/a&gt;). It is not limited to the top of the heap, which is what makes it effective where the automatic path is not. Its single argument, &lt;code&gt;pad&lt;/code&gt;, reserves an amount of free space at the top of the main heap. Only the main heap honours it. The per-thread arenas ignore it.&lt;/p&gt;

&lt;p&gt;There are two main limitations to &lt;code&gt;malloc_trim&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It does not move live data.&lt;/strong&gt; Any page containing at least one live allocation remains resident, so the memory cost of fragmentation is reduced but not eliminated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is specific to glibc.&lt;/strong&gt; Other allocators, such as jemalloc or tcmalloc, do not implement this function, and instead manage memory release according to their own policies.
Two alternatives were not pursued in this work. Limiting the number of arenas with &lt;code&gt;MALLOC_ARENA_MAX&lt;/code&gt; reduces how many places free memory is retained, at the cost of more lock contention, but it does not return free pages inside an arena, which is where the growth was. Replacing the allocator with &lt;code&gt;jemalloc&lt;/code&gt; or &lt;code&gt;tcmalloc&lt;/code&gt; changes memory behaviour for every build and deployment at once. An explicit trim at a single call site was the least intrusive change, could be evaluated in isolation, and left the allocator untouched for operators who build differently. An evaluation of &lt;code&gt;jemalloc&lt;/code&gt; is under way separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;malloc_trim&lt;/code&gt; is available and can release memory that the automatic trimming path cannot reach. The primary cost is a scan of the heap and the page faults that occur when released pages are subsequently reused. The remaining question is when to call it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trigger selection
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;malloc_trim&lt;/code&gt; should be invoked only after the application has released a significant amount of memory. Invoking it at other times pays for a heap scan with little to show for it, and frequent calls compound this cost. The selection criterion was defined as points in &lt;code&gt;xrpld&lt;/code&gt; where the application knows that a large release has just completed. Five candidate locations were instrumented in &lt;a href="https://github.com/XRPLF/rippled/pull/6022" rel="noopener noreferrer"&gt;PR #6022&lt;/a&gt;, with each call tagged by its trigger and the process &lt;a href="https://en.wikipedia.org/wiki/Resident_set_size" rel="noopener noreferrer"&gt;Resident Set Size (RSS)&lt;/a&gt; logged before and after it. The sweep trigger and the two online-delete hooks ran together for 40 hours on a lab validator connected to Mainnet. The order-book and sync-completion triggers were judged by how often they fire, and the per-cache variant was superseded by the application-level call before the run.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inside each cache sweep.&lt;/strong&gt; The first implementation called &lt;code&gt;malloc_trim&lt;/code&gt; from &lt;a href="https://github.com/XRPLF/rippled/blob/develop/include/xrpl/basics/TaggedCache.h" rel="noopener noreferrer"&gt;&lt;code&gt;TaggedCache::sweep&lt;/code&gt;&lt;/a&gt;, once per cache. Several caches sweep in close succession, so this version needed a rate limiter with a five-second minimum interval and a re-entrancy guard to prevent overlapping calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After the application sweep.&lt;/strong&gt; &lt;code&gt;Application::doSweep&lt;/code&gt; executes all cache sweeps sequentially as a background job, with the interval determined by &lt;code&gt;node_size&lt;/code&gt;. Invoking &lt;code&gt;malloc_trim&lt;/code&gt; once after the final sweep captures all memory releases in the cycle (&lt;a href="https://github.com/XRPLF/rippled/pull/6022/changes/72b34e661549060fac63cb0e52eba09accdb6243" rel="noopener noreferrer"&gt;commit&lt;/a&gt;). This trigger accounted for the majority of memory reclaimed, executed at each sweep interval, and reclaimed memory on nearly every call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After online-delete rotation.&lt;/strong&gt; Two hooks in &lt;code&gt;SHAMapStoreImp&lt;/code&gt; were evaluated: one following ledger cache clearance and another after old ledger deletion (same commit). Both triggers were infrequent due to the rarity of rotation events, and most memory released during rotation was already reclaimed by the subsequent sweep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After the full order-book rebuild.&lt;/strong&gt; &lt;a href="https://github.com/XRPLF/rippled/blob/3.3.0/src/xrpld/app/ledger/OrderBookDBImpl.cpp" rel="noopener noreferrer"&gt;&lt;code&gt;OrderBookDB::update&lt;/code&gt;&lt;/a&gt; walks the ledger state every 25,600 ledgers, a constant in the code that works out to roughly once a day, and only on nodes with pathfinding enabled (&lt;a href="https://github.com/XRPLF/rippled/pull/6022/changes/ff8b4353bc7473687bbf3d2804916af2e192ab5f" rel="noopener noreferrer"&gt;commit&lt;/a&gt;). It cannot provide regular reclamation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On sync completion.&lt;/strong&gt; The transition into the &lt;code&gt;full&lt;/code&gt; operating mode in &lt;code&gt;NetworkOPs&lt;/code&gt; (&lt;a href="https://github.com/XRPLF/rippled/pull/6022/changes/2b2b361c87f725077975ee55195302e4ef17c705" rel="noopener noreferrer"&gt;commit&lt;/a&gt;), which happens once after start and then only when the node loses and regains synchronisation. Too rare for regular reclamation, and with a drawback raised in review: a node that has just regained sync is about to begin its heaviest catch-up work, so trimming at that moment means paying to release memory that will be reallocated immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmupuwr8crdgjzky8nmb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmupuwr8crdgjzky8nmb0.png" alt="Same run, calls from the clearPrior trigger" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2qlqsvdshiy2hksw02i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2qlqsvdshiy2hksw02i.png" alt="Same run, calls from the clearCaches trigger" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ini8ufc8ue961zfh7te.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ini8ufc8ue961zfh7te.png" alt="RSS before and after each malloc_trim call from the sweep trigger, over 40 hours" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RSS before and after every &lt;code&gt;malloc_trim&lt;/code&gt; call, by trigger. Lab validator connected to Mainnet, 11–13 November 2025, 40 hours. Green candles are calls after which RSS was lower, red candles calls after which it was higher. Top: the sweep trigger, firing at every sweep interval. Middle and bottom: the two online-delete triggers, firing a few dozen times over the run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/XRPLF/rippled/pull/6022#discussion_r2793438904" rel="noopener noreferrer"&gt;decision&lt;/a&gt; documented in the PR was based on two factors: the sweep-based trigger achieved the highest observed memory reclamation, and it operates within a background job context, keeping heap scan overhead off the consensus and RPC paths. Only the sweep-based trigger was retained.&lt;/p&gt;

&lt;p&gt;The best-performing trigger also determined where the call should be made from. Invoking &lt;code&gt;malloc_trim&lt;/code&gt; within each cache's sweep required throttling for safety. A single invocation after &lt;code&gt;Application::doSweep&lt;/code&gt; completes all cache sweeps does not require throttling, as the call frequency is limited by the sweep interval and each invocation addresses the full release cycle. The final implementation includes a single call in &lt;code&gt;Application::doSweep&lt;/code&gt;, with the rate limiter and re-entrancy guard removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trim padding
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;pad&lt;/code&gt; argument of &lt;code&gt;malloc_trim&lt;/code&gt; reserves free space at the top of the heap so that the next allocations do not have to grow the heap again. A non-zero reserve could in principle reduce the cost of refilling the caches after each sweep. In practice, only the main heap honours &lt;code&gt;pad&lt;/code&gt;, and the per-thread arenas that serve &lt;code&gt;xrpld&lt;/code&gt;'s worker threads ignore it, so a reserve could protect at most the main heap's share of the memory.&lt;/p&gt;

&lt;p&gt;Four 12-hour test runs were executed on the lab validator connected to Mainnet. Trimming was invoked from &lt;code&gt;Application::doSweep&lt;/code&gt; with &lt;code&gt;pad&lt;/code&gt; set to 0, 256 KiB, 1 MiB, and 16 MiB. RSS was sampled at 10-second intervals, and the duration of each trim operation was logged. To eliminate the impact of the initial ramp-up, RSS statistics are reported from the second hour of each run onward.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;pad&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Mean RSS (GiB)&lt;/th&gt;
&lt;th&gt;P95 RSS (GiB)&lt;/th&gt;
&lt;th&gt;Trim calls&lt;/th&gt;
&lt;th&gt;Mean trim (ms)&lt;/th&gt;
&lt;th&gt;P95 trim (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;15.64&lt;/td&gt;
&lt;td&gt;16.22&lt;/td&gt;
&lt;td&gt;670&lt;/td&gt;
&lt;td&gt;163&lt;/td&gt;
&lt;td&gt;393&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 KiB&lt;/td&gt;
&lt;td&gt;15.91&lt;/td&gt;
&lt;td&gt;16.50&lt;/td&gt;
&lt;td&gt;706&lt;/td&gt;
&lt;td&gt;171&lt;/td&gt;
&lt;td&gt;373&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 MiB&lt;/td&gt;
&lt;td&gt;16.01&lt;/td&gt;
&lt;td&gt;16.65&lt;/td&gt;
&lt;td&gt;645&lt;/td&gt;
&lt;td&gt;179&lt;/td&gt;
&lt;td&gt;416&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 MiB&lt;/td&gt;
&lt;td&gt;15.77&lt;/td&gt;
&lt;td&gt;16.72&lt;/td&gt;
&lt;td&gt;621&lt;/td&gt;
&lt;td&gt;166&lt;/td&gt;
&lt;td&gt;372&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The differences are small. Mean RSS varies by 0.4 GiB across the four settings, under 3%, and mean trim duration by 16 ms, about 10%. Each setting was run once, in sequence, against whatever Mainnet traffic arrived during its twelve hours, and variation between runs from load alone is of the same order. The comparison can therefore rule out a large effect but cannot rank the settings. A small effect is also what the semantics predict, since only the main heap honours the reserve.&lt;/p&gt;

&lt;p&gt;The default was kept. &lt;code&gt;pad&lt;/code&gt; is zero, and it is a compile-time constant rather than a configuration setting, so that the change adds no tuning surface to the node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory results
&lt;/h2&gt;

&lt;p&gt;The change was measured in three tests: a lab machine connected to Mainnet, a closed performance network under a fixed transaction load, and two of Ripple's Mainnet validators over the summer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lab machine
&lt;/h3&gt;

&lt;p&gt;The lab validator connected to Mainnet ran two builds on the same hardware and settings: the unmodified binary, and a binary with the trim triggers as they stood in November 2025, which called &lt;code&gt;malloc_trim&lt;/code&gt; from the application sweep and from the two online-delete points, of which only the sweep call shipped. RSS and Referenced memory were sampled every 10 seconds, and each trim call recorded the process RSS before and after it. The baseline ran for about 24 hours, the trimmed build for about 40, and one run was made of each. The trimmed run is the one shown in the candle charts in the Trigger selection section.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Baseline.&lt;/strong&gt; RSS grew from 14.0 GiB to 32.9 GiB over 24 hours and was still rising when the run ended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trimmed.&lt;/strong&gt; RSS climbed to 16 GiB within the first hour, to 17 GiB by the sixth, and then stayed within an 18–20 GiB band for the rest of the 40 hours, drifting upward at about 30 MB per hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At 24 hours of process life,&lt;/strong&gt; RSS was 18.3 GiB in the trimmed build against 32.9 GiB in the baseline, 44% lower. The trimmed value is read from the trim log's per-call RSS record, so the two processes are compared at the same age.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over the 40 hours,&lt;/strong&gt; the trimmed build made 1,389 calls, 1,173 of them from the sweep. 91% of them reduced RSS, by a median of 75 MB and a maximum of 1.38 GB, and the observed RSS decrease across all calls summed to about 147 GB, against a net RSS growth of about 14 GB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSS minus Referenced&lt;/strong&gt; in the trimmed steady state was about 0.2 GiB on a 19 GiB process, about 1%, so almost all resident memory had been accessed since the window opened.
### Performance network&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The performance lab runs a closed five-validator network under a fixed transaction load. Comparison tests with and without &lt;code&gt;malloc_trim&lt;/code&gt; each ran for five hours. Validator nodes used 2.58 GB less RSS with trimming enabled, a reduction of 14.8%. Client-handler nodes used 3.33 GB less, a reduction of 18.2%. The CPU and consensus-timing measurements from the same runs are in the Overheads section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mainnet validators
&lt;/h3&gt;

&lt;p&gt;Two of Ripple's validators connected to Mainnet, one on the default UNL (dUNL) and one not, ran 3.2.0 without heap trimming from 16 June to 31 July 2026 and 3.3.0 with it from 7 August. The trim call was removed from 3.2.0 the day before its release, so the 3.2.0 builds in this comparison do not contain it, although the release notes initially listed the change. The week between the windows saw the 3.2.1 hotfix release and an unrelated network incident, and was excluded. The 3.2.0 windows close before the incident, and leaving out their final three days changes the plateau means by less than 0.05 GiB. Process RSS is recorded by Prometheus from the &lt;a href="https://github.com/ncabatoff/process-exporter" rel="noopener noreferrer"&gt;process exporter&lt;/a&gt;, and both windows on both nodes open on a fresh start of the process, so the two versions are compared at the same age. The figures below are the plateau each node settled at after its first five days.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;th&gt;3.2.0, no trim&lt;/th&gt;
&lt;th&gt;3.3.0, trim&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Non-dUNL validator (&lt;code&gt;online_delete&lt;/code&gt; 4,096)&lt;/td&gt;
&lt;td&gt;20.1 GiB&lt;/td&gt;
&lt;td&gt;17.4 GiB&lt;/td&gt;
&lt;td&gt;−2.7 GiB, −13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dUNL validator (&lt;code&gt;online_delete&lt;/code&gt; 50,000)&lt;/td&gt;
&lt;td&gt;19.4 GiB&lt;/td&gt;
&lt;td&gt;15.0 GiB&lt;/td&gt;
&lt;td&gt;−4.4 GiB, −23%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzwkz0eqfxk08n9xtesn4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzwkz0eqfxk08n9xtesn4.png" alt="Resident memory of two Mainnet validators, 3.2.0 without trim and 3.3.0 with trim, by days since process start" width="799" height="605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Resident memory of two of Ripple's Mainnet validators, 3.2.0 without heap trimming and 3.3.0 with a trim after every sweep, each window opening on a fresh start and plotted by days since start. Dotted lines are the plateau means after day five. The dUNL node's two-day rhythm is online delete: it rotates its history every 50,000 ledgers, about every two days, and under 3.3.0 each rotation's release leaves RSS instead of staying with the allocator. The non-dUNL node rotates every 4,096 ledgers, every few hours, so its releases are small and its line is smooth.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is a comparison between versions, and 3.3.0 differs from 3.2.0 in more than the trim call. The reduction is nonetheless attributable to trimming: nothing else in the release targets memory retention, the production graphs show the sawtooth of release and regrowth that trimming produces, and the reduction is in line with the lab and performance-network tests, where &lt;code&gt;malloc_trim&lt;/code&gt; was the only difference between the test runs compared.&lt;/p&gt;

&lt;p&gt;The Mainnet validators test has the most weight of the three, since these are production nodes. The windows are 44 and 38 days long, the savings appear on both nodes, and they are well above the ordinary drift either node shows over a window of that length.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overheads
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Trim latency
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/XRPLF/rippled/blob/3.3.0/src/libxrpl/basics/MallocTrim.cpp" rel="noopener noreferrer"&gt;&lt;code&gt;MallocTrim&lt;/code&gt;&lt;/a&gt; helper was instrumented to measure the cost of each call. With debug logging enabled, the helper records, for every invocation, the process RSS before and after execution and the wall-clock duration.&lt;/p&gt;

&lt;p&gt;On the lab validator connected to Mainnet, a sweep trim took a median of 141 ms and a 95th percentile of 340 ms, at roughly one call per minute, which is a fraction of a per cent of the sweep interval, spent on a background thread. The four 12-hour padding runs, with 620 to 706 sweep trims each, gave the same picture, with mean durations of 163 to 179 ms. The duration of a call grows with the size of the heap and with the amount of memory freed since the previous sweep, so nodes with a different &lt;code&gt;node_size&lt;/code&gt; or &lt;code&gt;online_delete&lt;/code&gt; configuration may see longer calls.&lt;/p&gt;

&lt;p&gt;The trim operation executes as part of the background sweep job, once all cache sweeps have completed and outside any cache lock, running on the sweep thread. In glibc, &lt;code&gt;malloc_trim&lt;/code&gt; acquires each arena's lock sequentially during trimming. Threads attempting allocation or free operations on a locked arena are blocked until the lock is released. The following subsection presents consensus-timing measurements to assess the practical impact of these waits.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU and consensus timing
&lt;/h3&gt;

&lt;p&gt;In the performance lab, a five-validator network was used to execute comparison tests with and without &lt;code&gt;malloc_trim&lt;/code&gt;, each for five hours under a fixed transaction load. CPU usage on validator nodes increased by 0.7% with trimming enabled, while client-handler nodes showed a 3.4% decrease. Both changes are small. Consensus timing was evaluated using &lt;a href="https://en.wikipedia.org/wiki/Effect_size#Cliff's_delta" rel="noopener noreferrer"&gt;Cliff's delta&lt;/a&gt; with &lt;a href="https://en.wikipedia.org/wiki/False_discovery_rate#Benjamini%E2%80%93Hochberg_procedure" rel="noopener noreferrer"&gt;Benjamini–Hochberg&lt;/a&gt; correction across all metrics. No changes were detected in ledger close interval, its 80th percentile, voting duration, or transaction-set acquisition time. Per-transaction apply, flush, and build times showed statistically significant differences, as expected given the number of ledgers processed, but all effect sizes were below the 0.147 threshold considered negligible by this method. The test was repeated several times with consistent results, and the figures above are from one round.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stability
&lt;/h3&gt;

&lt;p&gt;Two checks were made on whether trimming affects a node's participation in the network.&lt;/p&gt;

&lt;p&gt;The first comparison was made during the 3.2.0 release-candidate period, when a Mainnet node ran candidate builds with the trim call for 22 effective days and a candidate build without it for 4.65 days. Departures from the &lt;code&gt;full&lt;/code&gt; state occurred at rates of 2.41 and 2.15 times per day, with median durations of 3.16 and 3.13 seconds, respectively. Consensus step-outs were observed at 2.23 and 2.15 times per day. None of these differences, nor the difference in daily counts of &lt;code&gt;SHAMapMissingNode&lt;/code&gt; events, was statistically significant. The comparison is limited by the short duration of the window without the call, so only large effects can be excluded.&lt;/p&gt;

&lt;p&gt;The second check was conducted as part of an investigation into slow initial synchronisation on Devnet during the 3.3.0 release candidates, where new nodes required 36 to 59 minutes to reach full synchronisation. Trimming was evaluated as a potential cause. In the slowest of three instrumented runs, trimming accounted for 305 calls totalling 72 seconds during a 59-minute sync, representing about 2% of wall time, with approximately 19 seconds occurring within the synchronisation window. Sync time did not vary with sweep interval settings of 60, 600, or default values, and trim pauses did not align with the acquisition timeouts that defined the slow runs. The investigation found no evidence that trimming contributed to slow sync times. The causes of the slowdown lay elsewhere and are outside the scope of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outstanding questions and next steps
&lt;/h2&gt;

&lt;p&gt;Introducing explicit heap trimming after the cache sweep consistently reduced resident memory usage on &lt;code&gt;xrpld&lt;/code&gt; nodes across all measured environments: by 13% to 23% on Ripple's Mainnet validators, by 15% to 18% in the performance network, and by 44% on the lab machine under the heavier load of late 2025. The call cost a fraction of a per cent of the sweep interval on a background thread, and had no measurable effect on consensus timing or node stability. This change was released in version 3.3.0, implemented at a single call site without additional configuration parameters.&lt;/p&gt;

&lt;p&gt;The observed reduction in resident memory varied widely: 23% and 13% on the two Mainnet validators, 15% to 18% on the performance network, and 44% on the lab machine. This variation exceeds what can be explained by measurement differences alone and requires further investigation. One hypothesis fits the evidence so far: the reclaimed memory is memory the allocator retained after short-lived allocations were freed, so its volume follows the rate of allocation churn, which in turn follows transaction load. The largest reduction was recorded in November 2025, during a period of network-wide memory growth, while the Mainnet measurements were made in the summer of 2026. If transaction volume or composition during that period drove the growth of retained memory, the saving would shrink as load normalised. The stabilisation of untrimmed validators at a plateau is consistent with this hypothesis. Verification will require historical correlation of network load with memory metrics and controlled lab experiments with varied transaction mixes.&lt;/p&gt;

&lt;p&gt;Several areas for further optimisation have been identified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trim frequency.&lt;/strong&gt; Performing trimming on every sweep is straightforward but may not be the best schedule. Adjusting the schedule to trim every few sweeps, or only when a sweep releases memory above a defined threshold, could reduce the number of trimming operations while retaining most of the memory savings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Padding.&lt;/strong&gt; The initial four single runs excluded a large effect from &lt;code&gt;pad&lt;/code&gt; and did not allow for ranking of different settings. Conducting repeated, interleaved runs is necessary to determine whether maintaining a small memory reserve provides measurable benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Residual growth.&lt;/strong&gt; The gradual increase in memory usage observed with trimming enabled reflects the live working set, which cannot be reduced through allocator configuration. Addressing this requires revisiting the cache experiments from part one and re-running them in the updated lab environment with trimming enabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allocation churn at the source.&lt;/strong&gt; Trimming returns memory after the application has freed it. It does nothing to reduce how much the application allocates and frees in the first place, and that churn is what causes the fragmentation the trim works around. The complementary work is to find which code paths allocate the most, using the same profiling as in part one, and to reduce it where possible: allocating fewer short-lived objects, or reusing them instead of recreating them.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>xrpl</category>
      <category>performance</category>
      <category>linux</category>
      <category>memory</category>
    </item>
    <item>
      <title>Memory in an XRPL Node: Hunting a Leak That Wasn't There</title>
      <dc:creator>Valentin Balaschenko</dc:creator>
      <pubDate>Thu, 05 Nov 2026 15:13:41 +0000</pubDate>
      <link>https://dev.to/ripplexdev/memory-in-an-xrpl-node-hunting-a-leak-that-wasnt-there-2c3l</link>
      <guid>https://dev.to/ripplexdev/memory-in-an-xrpl-node-hunting-a-leak-that-wasnt-there-2c3l</guid>
      <description>&lt;p&gt;&lt;strong&gt;For operators&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you measure your node's memory, read the xrpld process, its RSS from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; or a process exporter, not the &lt;code&gt;systemctl status&lt;/code&gt; figure, which includes the kernel's page cache.&lt;/li&gt;
&lt;li&gt;If you run &lt;code&gt;xrpld&lt;/code&gt; 3.3.0 or later, your node already trims the heap after every cache sweep. There is nothing to configure.&lt;/li&gt;
&lt;li&gt;If you build or run &lt;code&gt;xrpld&lt;/code&gt; with an allocator other than glibc's, the trim does not apply.&lt;/li&gt;
&lt;li&gt;If your memory graph has become a fine sawtooth since upgrading, that is the trim working, not a fault.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Memory ballooning
&lt;/h2&gt;

&lt;p&gt;Memory growth in XRPL nodes was observed in late summer 2025. Operators reported increasing memory usage on their validators that did not decrease, including on nodes configured identically to others that were unaffected, and across different hardware platforms. The cause turned out not to be a memory leak but the memory allocator retaining memory the application had already freed, and a fix shipped in xrpld 3.3.0. This post describes how that was established.&lt;/p&gt;

&lt;p&gt;A memory regression identified in one release during that period was addressed within weeks, but this had no observable impact for operators. The observed memory behaviour existed before the regression and persisted after the fix. The underlying issue remained unresolved at the time. Operators monitoring node caches asked why memory did not come back after a cache sweep, the routine pass that clears expired entries.&lt;/p&gt;

&lt;p&gt;Early in the community discussion, it became clear that consistent measurement was essential. Some reports referenced memory usage from &lt;code&gt;systemctl status&lt;/code&gt;, which reflects the memory of the service's &lt;a href="https://docs.kernel.org/admin-guide/cgroup-v2.html#memory" rel="noopener noreferrer"&gt;cgroup&lt;/a&gt;. A cgroup is the kernel's resource-accounting container that &lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html" rel="noopener noreferrer"&gt;systemd&lt;/a&gt; wraps around every service. In contrast, per-process readings from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; or &lt;code&gt;smaps&lt;/code&gt; report only the process memory. These two metrics often differed by several gigabytes due to the kernel's page cache. When &lt;code&gt;xrpld&lt;/code&gt; (formerly &lt;code&gt;rippled&lt;/code&gt;), the XRPL node software, reads from or writes to its database files, the kernel caches these file pages in memory. This cached memory is not included in the process's &lt;a href="https://en.wikipedia.org/wiki/Resident_set_size" rel="noopener noreferrer"&gt;Resident Set Size (RSS)&lt;/a&gt;, the kernel's count of pages held in RAM by the process, because &lt;code&gt;xrpld&lt;/code&gt;'s database library, &lt;a href="https://github.com/CPPAlliance/NuDB" rel="noopener noreferrer"&gt;NuDB&lt;/a&gt;, uses standard file I/O rather than memory mapping. The kernel charges this cached memory to the service's cgroup because &lt;code&gt;xrpld&lt;/code&gt; initiated the I/O. On busy nodes, the page cache can reach several gigabytes, increasing as the node accesses its database and decreasing when the kernel reclaims memory or when &lt;a href="https://xrpl.org/docs/infrastructure/configuration/data-retention/online-deletion" rel="noopener noreferrer"&gt;online delete&lt;/a&gt; removes old database files. These fluctuations can occur within minutes and are not controlled by the application. As a result, the same machine can report very different memory usage figures depending on the measurement method, leading to differing interpretations by operators.&lt;/p&gt;

&lt;p&gt;On a dedicated &lt;code&gt;xrpld&lt;/code&gt; machine, it may appear that the distinction between process memory and total memory usage is irrelevant, since it is all the node's memory in the end. However, the two types of memory are managed differently. The kernel controls the page cache, which grows to use available RAM and causes the system-wide memory figure to reflect total machine capacity rather than the process state. In contrast, the process's own memory cannot be dropped by the kernel, only swapped, and it is the only part that code changes can affect. Per-process memory is therefore the right measure of the application's own behaviour, and all subsequent analysis uses it. &lt;em&gt;RSS&lt;/em&gt; is the coarsest of these, since it counts every resident page whether or not the process still touches it. The next section adds a finer one, the working set, which counts only the memory the process has actually accessed over a period.&lt;/p&gt;

&lt;p&gt;A memory leak was the first suspicion. Agreeing on how to measure memory made it possible to test that suspicion against the alternatives: normal cache operation, a feature retaining more state than expected, or an issue below the application layer. Without the ability to answer this reliably, for any &lt;code&gt;xrpld&lt;/code&gt; version and whatever mix and volume of traffic the node handles, using trusted metrics, any conclusions would be speculative.&lt;/p&gt;

&lt;p&gt;A toolset was assembled to collect and analyse the relevant measurements. The following analysis describes the investigation, the findings, the attempted fixes, and the eventual realisation that the root cause was located in a different layer of the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolset
&lt;/h2&gt;

&lt;p&gt;The investigation focused on three key questions, with the requirement that no conclusion would rest on a single tool.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What are the primary sources of the increased memory usage?&lt;/li&gt;
&lt;li&gt;What mechanisms are responsible for the observed memory growth on some nodes but not others? For instance, which operational flows, features, or traffic patterns trigger it?&lt;/li&gt;
&lt;li&gt;How reliable are the measurements under real-world conditions? This had to be answered before the other two meant anything.
The previous section established that the focus is on process memory, not system-wide memory. The remaining concern is the reliability of each measurement tool. Each instrument has limitations: &lt;em&gt;RSS&lt;/em&gt; can be affected by &lt;a href="https://www.gnu.org/software/libc/" rel="noopener noreferrer"&gt;glibc&lt;/a&gt; allocator behaviour, flame graphs may attribute bytes to generic frames if stack traces are incomplete, and the kernel's &lt;em&gt;Referenced&lt;/em&gt; bit provides only an approximation. Using multiple tools and cross-verifying results is necessary to ensure accuracy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The approach uses three measurement methods, with visualisation applied to the collected data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://man7.org/linux/man-pages/man5/proc.5.html" rel="noopener noreferrer"&gt;procfs&lt;/a&gt;-based working-set tool, specifically &lt;a href="https://github.com/brendangregg/wss" rel="noopener noreferrer"&gt;Brendan Gregg's &lt;code&gt;wss.pl&lt;/code&gt;&lt;/a&gt;, uses &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/clear_refs&lt;/code&gt; and &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/smaps&lt;/code&gt; to estimate Working Set Size (WSS) over a defined window. This tool is integrated with a custom sampling loop that timestamps each reading and records gross totals. It is a low-overhead, always-on, coarse-grained measurement.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ebpf.io" rel="noopener noreferrer"&gt;eBPF&lt;/a&gt;/&lt;a href="https://github.com/iovisor/bcc" rel="noopener noreferrer"&gt;BCC&lt;/a&gt; &lt;a href="https://github.com/iovisor/bcc/blob/master/tools/memleak.py" rel="noopener noreferrer"&gt;&lt;code&gt;memleak&lt;/code&gt;&lt;/a&gt; traces allocator calls using uprobes, which are dynamic tracepoints on user-space functions, with aggregation performed in the kernel. This method is lightweight and provides call stacks for outstanding allocations.&lt;/li&gt;
&lt;li&gt;A custom glibc shim, implemented as an &lt;code&gt;LD_PRELOAD&lt;/code&gt; library, is inserted by the dynamic loader ahead of libc to intercept every allocator call. It interposes on &lt;code&gt;malloc&lt;/code&gt;, &lt;code&gt;free&lt;/code&gt;, &lt;code&gt;calloc&lt;/code&gt;, &lt;code&gt;realloc&lt;/code&gt;, &lt;code&gt;posix_memalign&lt;/code&gt;, and related functions to capture each allocation with a backtrace. Unlike the sampling tools, it records every allocation and every free, so it can answer questions about individual allocations that aggregated data cannot. The cost is a several-fold slowdown and a dedicated run per question, so it serves as the ground truth rather than as a monitoring tool.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.brendangregg.com/flamegraphs.html" rel="noopener noreferrer"&gt;Flame graphs&lt;/a&gt; are generated using Brendan Gregg's &lt;a href="https://github.com/brendangregg/FlameGraph" rel="noopener noreferrer"&gt;FlameGraph&lt;/a&gt; toolkit, with input from the eBPF data, to visualise and analyse call-stack data.
These tools represent a trade-off between overhead and detail. The investigation begins with coarse, low-overhead measurements, and more detailed analysis is performed only when initial results indicate where to focus.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Layer 1: procfs-based WSS
&lt;/h3&gt;

&lt;p&gt;The primary objective is to determine whether the additional resident memory is memory the process keeps accessing, or memory it holds but has not touched for some time, such as cache entries that were filled once and never read again. &lt;em&gt;RSS&lt;/em&gt; alone cannot make this distinction, as it counts all pages in RAM regardless of recent access.&lt;/p&gt;

&lt;p&gt;The Linux kernel maintains a per-page &lt;em&gt;Referenced&lt;/em&gt; bit, which is set when a page is accessed and can be cleared on demand. &lt;code&gt;wss.pl&lt;/code&gt; uses this mechanism by clearing all bits, waiting for a specified interval, and then measuring how much of the process's memory has been accessed during that period, representing the working set for the window. In cumulative mode (&lt;code&gt;-C&lt;/code&gt;), the tool clears the bits once and observes &lt;em&gt;Referenced&lt;/em&gt; increasing toward &lt;em&gt;RSS&lt;/em&gt; as pages are accessed. Only the clearing operation is costly, so sampling frequency can be adjusted as needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vr0c5kwz2p5p7559eep.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vr0c5kwz2p5p7559eep.png" alt="RSS vs Referenced over 11.5 hours, cumulative working-set view" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Eleven and a half hours of the ballooning on our System76 lab machine, September 2025 — &lt;code&gt;wss.pl&lt;/code&gt; in cumulative mode, 60 s samples. RSS climbs at ~0.74 GiB/h — spikes partially recover, but the floor only rises. Referenced converges toward RSS — the growth is being accessed, not sitting cold — and the band between them narrows from ~9 GiB to ~3.8 GiB.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The key metric is the difference between &lt;em&gt;RSS&lt;/em&gt; and &lt;em&gt;Referenced&lt;/em&gt;, representing resident memory that has not been accessed since the start of the measurement. This includes caches, fragmented allocator heaps, and retained but idle structures. The gap narrows over the run as &lt;em&gt;Referenced&lt;/em&gt; approaches &lt;em&gt;RSS&lt;/em&gt;, indicating that most held memory has been accessed since the start of the measurement. However, the &lt;em&gt;Referenced&lt;/em&gt; bit does not distinguish between untouched pages containing live but inactive data and other types of memory. Accurately identifying the nature of this gap is central to the investigation.&lt;/p&gt;

&lt;p&gt;Leaks associated with growing live structures, such as caches, queues, or in-flight buffers, result in &lt;em&gt;Referenced&lt;/em&gt; increasing in parallel with &lt;em&gt;RSS&lt;/em&gt;, since the leaked memory continues to be accessed. Cold leaks, where memory is allocated but the pointer is lost and never accessed again, appear only as an expanding gap and cannot be distinguished from other untouched memory using this method. This limitation is one reason WSS is not used in isolation.&lt;/p&gt;

&lt;p&gt;Two caveats:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;clear_refs&lt;/code&gt; mechanism provides only an approximate measurement. The kernel traverses page tables to set and read the &lt;em&gt;Referenced&lt;/em&gt; bits, introducing a small amount of latency during sampling, around 5% according to Gregg's documentation. Excessive sampling can interfere with the kernel's page-aging information and affect memory reclamation decisions. To mitigate these effects, long sampling intervals are used, and measurements are conducted primarily in a controlled environment.&lt;/li&gt;
&lt;li&gt;Tracking memory utilisation using this method is considered experimental and is documented as such in sources including &lt;a href="https://lwn.net/Articles/642202/" rel="noopener noreferrer"&gt;LWN&lt;/a&gt; and &lt;a href="https://www.brendangregg.com/blog/2018-01-17/measure-working-set-size.html" rel="noopener noreferrer"&gt;Brendan Gregg's WSS post&lt;/a&gt;. This approach provides a baseline measurement rather than a definitive conclusion.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Layer 2: eBPF + BCC memleak
&lt;/h3&gt;

&lt;p&gt;WSS indicates that memory usage is increasing but does not identify the specific code locations responsible. To obtain this information, BCC's &lt;code&gt;memleak&lt;/code&gt; tool is used.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;memleak&lt;/code&gt; attaches uprobes to the allocator entry points in libc for a specific process. Each allocation is recorded with its size and stack trace, and each free operation removes the corresponding record. After a period of execution, &lt;code&gt;memleak&lt;/code&gt; reports the call stacks associated with the largest outstanding allocations. By sampling these reports at regular intervals, it is possible to track how much memory each subsystem has allocated and not yet freed over time.&lt;/p&gt;

&lt;p&gt;This approach is feasible for production workloads because BCC aggregates data within the kernel, transmitting only summary information to user space. Streaming every malloc event instead would produce more data than could be collected without slowing a process that allocates as frequently as an XRPL node. Testing showed no measurable impact on node throughput. For accurate stack traces, builds must include debug symbols and frame pointers. Without them, call stacks may be incomplete.&lt;/p&gt;

&lt;p&gt;For analysis beyond the largest allocation sites, the byte-attributed data is processed with &lt;a href="https://github.com/brendangregg/FlameGraph/blob/master/flamegraph.pl" rel="noopener noreferrer"&gt;&lt;code&gt;flamegraph.pl&lt;/code&gt;&lt;/a&gt;. The width of each box represents the number of bytes attributed to that stack, with the largest boxes indicating the heaviest allocation sites. This visualisation enables rapid identification of the heaviest allocation sites.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: the glibc shim
&lt;/h3&gt;

&lt;p&gt;eBPF aggregates allocations by call stack, which identifies the subsystem responsible but not the individual allocation, its size, or the order in which allocations and frees occurred. The glibc shim records exactly that, every allocation and free with a backtrace, at the cost of slowing allocation-intensive code several-fold. It is therefore reserved for targeted runs that answer a specific question about individual allocations. The remainder of this analysis did not need that level of detail, so only the first two layers are used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the bytes: TaggedCache
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/XRPLF/rippled/blob/develop/include/xrpl/shamap/README.md" rel="noopener noreferrer"&gt;SHAMap&lt;/a&gt;, which maintains the ledger state, does not manage the lifetimes of its nodes directly. This responsibility is handled by the &lt;a href="https://github.com/XRPLF/rippled/blob/develop/include/xrpl/basics/TaggedCache.h" rel="noopener noreferrer"&gt;TaggedCache&lt;/a&gt;, a partitioned, expiry-based cache that supports the &lt;a href="https://github.com/XRPLF/rippled/blob/develop/include/xrpl/shamap/TreeNodeCache.h" rel="noopener noreferrer"&gt;TreeNodeCache&lt;/a&gt; (for tree nodes), the &lt;a href="https://github.com/XRPLF/rippled/blob/develop/include/xrpl/shamap/FullBelowCache.h" rel="noopener noreferrer"&gt;FullBelowCache&lt;/a&gt; (tracking locally complete subtrees), and several additional structures. Profiling a live node with &lt;code&gt;memleak&lt;/code&gt; identified the TaggedCache subsystem as the primary consumer of outstanding memory. Key components include &lt;code&gt;canonicalize&lt;/code&gt;, which consolidates node copies into a single shared instance, the refresh loop (&lt;code&gt;freshenCaches&lt;/code&gt;) that copies cached entries to the new database file during online delete, and sweep threads responsible for evicting expired entries. Most of the remaining memory usage was attributed to the creation of SHAMap nodes and items managed by the cache, including fetch-and-deserialise operations and inbound ledger-data handlers. The node was configured with &lt;a href="https://xrpl.org/docs/infrastructure/installation/capacity-planning" rel="noopener noreferrer"&gt;&lt;code&gt;node_size&lt;/code&gt;&lt;/a&gt; set to &lt;code&gt;medium&lt;/code&gt;, which determines cache sizes in &lt;code&gt;xrpld&lt;/code&gt;, and online delete was enabled.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fru9wf14sd375yjkl101p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fru9wf14sd375yjkl101p.png" alt="Flame graph: outstanding heap bytes at peak, cache-related stacks highlighted" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Peak outstanding allocations were measured on a test-lab validator connected to Mainnet in September 2025. Here, 'outstanding' refers to memory allocated after &lt;code&gt;memleak&lt;/code&gt; was attached and not yet freed, representing allocations since tracer attachment rather than the process's total memory usage. The graph displays the total outstanding memory as its full width, with each category's share indicated by horizontal width: dark for TaggedCache machinery, light for cache-managed objects, and grey for other allocations. Stack traces were recoloured by category for clarity. The original &lt;a href="https://github.com/XRPLF/rippled/pull/6022#issuecomment-6659691076" rel="noopener noreferrer"&gt;flame graph&lt;/a&gt; is available for reference.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The data was collected with &lt;code&gt;node_size&lt;/code&gt; set to &lt;code&gt;medium&lt;/code&gt;, indicating that the observed memory usage was not due to unusually large cache configurations. This pattern persisted across varying traffic levels. In all peak snapshots of outstanding memory, 84–99.7% was attributed to cache operations, primarily under &lt;code&gt;SHAMapStoreImp::freshenCaches&lt;/code&gt;, which re-populates the new database file from cache keys during online delete. Transient memory spikes were also associated with cache maintenance, proportional to the cache's key population.&lt;/p&gt;

&lt;p&gt;A flame graph attributes memory usage to the code responsible for allocation, not to the structure that ultimately holds the memory. In this codebase, allocation occurs during fetch and deserialisation, and nodes are subsequently managed by the cache through &lt;code&gt;canonicalize&lt;/code&gt;. Therefore, the data identifies a likely source of memory usage, but confirming this would require further experimental validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache experiments
&lt;/h2&gt;

&lt;p&gt;At this stage a likely cause had been identified. Over several months, a series of experiments was conducted to make the cache smaller, cheaper, or better behaved. Each experiment tested a specific theory regarding memory usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory: some caches may be unnecessary.&lt;/strong&gt; The initial approach was to disable the TaggedCache and observe the impact (&lt;a href="https://github.com/XRPLF/rippled/pull/5439" rel="noopener noreferrer"&gt;#5439&lt;/a&gt;, with initial commits titled "TEST: Disable tagged cache to measure performance"). Disabling the TaggedCache revealed its role as both a cache and the canonicalization point, as well as its function in warming the new database file during online-delete rotation. Complete removal was not feasible. The cache could only be made smaller or shorter-lived, and because rotation warms the new database file from the cache's keys, shrinking it trades memory for post-rotation latency. The same pull request then focused on the NodeObject cache in &lt;code&gt;DatabaseNodeImp&lt;/code&gt;, which stored recently read database records in memory despite the underlying database engine and kernel page cache already retaining these records. Removing this cache reduced memory usage by 3–5% with a minor increase in CPU use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory: the cache structure leads to memory waste on large hardware.&lt;/strong&gt; The TaggedCache is partitioned per hardware thread, causing fixed overhead to scale with the number of cores rather than the workload. Reducing the partition count by half (&lt;a href="https://github.com/XRPLF/rippled/pull/5442" rel="noopener noreferrer"&gt;#5442&lt;/a&gt;) resulted in a 5–6% memory reduction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory: cache entries persist longer than necessary.&lt;/strong&gt; The sweep evicts entries that are unreferenced or expired. To test this, a probe build (&lt;a href="https://github.com/XRPLF/rippled/pull/5910" rel="noopener noreferrer"&gt;#5910&lt;/a&gt;) set all entries to expire immediately, converting each sweep into a full eviction. This approach reduced memory usage by 8–10%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory: some structures are never cleaned.&lt;/strong&gt; The &lt;code&gt;LedgerHistory::ledgersByIndex_&lt;/code&gt; map, which associates ledger sequence numbers with hashes, was not subject to sweeping and accumulated entries indefinitely. This resulted in a small but unbounded growth, a logical leak: entries were added for every validated ledger and never removed (&lt;a href="https://github.com/XRPLF/rippled/pull/5461" rel="noopener noreferrer"&gt;#5461&lt;/a&gt;). It was the only such leak the investigation found. The fix in &lt;a href="https://github.com/XRPLF/rippled/pull/5874" rel="noopener noreferrer"&gt;#5874&lt;/a&gt; bounds the map on nodes with online delete enabled. Full-history nodes, which keep every ledger, still accumulate about 1.4 MB per day. The growth was measured in kilobytes per hour while the overall issue was measured in gigabytes, so it did not account for the observed memory growth.&lt;/p&gt;

&lt;p&gt;Individually, each change showed progress. However, the combined effect was less than expected. While the sum of individual improvements suggested a 20% reduction, the cumulative measurement in a single build was only 5–7%. This discrepancy indicated either unreliable measurements or a lower bound imposed by factors outside the application. Both explanations turned out to be true, and the second was the larger problem. The experiments remain relevant, each demonstrating measurable effects with tools that have since been improved. Further testing in a rebuilt lab environment is planned, but the focus has shifted to addressing the underlying constraint.&lt;/p&gt;

&lt;p&gt;Of the four changes, the NodeObject cache removal (&lt;a href="https://github.com/XRPLF/rippled/pull/5439" rel="noopener noreferrer"&gt;#5439&lt;/a&gt;) was merged and later reverted as a precaution, without any problem having been attributed to it, the partition-count reduction (&lt;a href="https://github.com/XRPLF/rippled/pull/5442" rel="noopener noreferrer"&gt;#5442&lt;/a&gt;) remains open, the sweep-everything build (&lt;a href="https://github.com/XRPLF/rippled/pull/5910" rel="noopener noreferrer"&gt;#5910&lt;/a&gt;) was a probe never intended to ship, and the &lt;code&gt;ledgersByIndex_&lt;/code&gt; fix (&lt;a href="https://github.com/XRPLF/rippled/pull/5874" rel="noopener noreferrer"&gt;#5874&lt;/a&gt;) was merged in October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Released memory is not reclaimed
&lt;/h2&gt;

&lt;p&gt;In October 2025, the investigation moved from periodic snapshots to continuous monitoring. The &lt;code&gt;memleak&lt;/code&gt;-based sampler measured TaggedCache's outstanding bytes, everything its stacks had allocated and not yet freed, every few tens of seconds during hour-long runs. In parallel, WSS tracked &lt;em&gt;RSS&lt;/em&gt; and &lt;em&gt;Referenced&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The results were clear. The outstanding allocations from the cache subsystem consistently returned to near zero on each sweep cycle, which occurred approximately every two minutes. The sweep-everything probe from &lt;a href="https://github.com/XRPLF/rippled/pull/5910" rel="noopener noreferrer"&gt;#5910&lt;/a&gt;, which evicts the entire cache at each sweep, amplified this effect. Allocation and deallocation rates matched in real time on the unmodified build. Despite this, &lt;em&gt;RSS&lt;/em&gt; and &lt;em&gt;Referenced&lt;/em&gt; memory continued to increase.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0qu6oe7ugrktc824ls6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0qu6oe7ugrktc824ls6.png" alt="TaggedCache outstanding bytes vs RSS/Referenced, 72 minutes, develop build" width="800" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Seventy-two minutes on the unmodified develop build, connected to Mainnet, 21 October 2025. Top: bytes allocated by TaggedCache-machinery stacks and not yet freed — the &lt;code&gt;memleak&lt;/code&gt; sampler, ~25 s snapshots. &lt;code&gt;memleak&lt;/code&gt; reports the ten largest outstanding stacks per snapshot, so TaggedCache absent from the top ten plots as zero. Peak 64 MiB, median 2 MiB, every excursion collapsing within minutes (sweep interval ≈ 121 s). Bottom: the same process on the same clock — RSS climbs 13.7 → 15.5 GiB (+1.7 GiB, ~1.5 GiB/h) while Referenced converges toward it (&lt;code&gt;wss.pl&lt;/code&gt;, cumulative mode). That rate is faster than September's 0.74 GiB/h — it varied with the run and the node's age — but the direction never did. One of four captures that day across both builds — the cache's outstanding bytes stayed in the MiB range in every one of them. This run is shown because it has a full RSS/Referenced companion on the same clock.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This combination of results rules out an application-level cause for the growth, including the cache. The cache's outstanding allocations are measured in MiB, while the observed memory growth is in GiB. Across all stacks reported by &lt;code&gt;memleak&lt;/code&gt;, the median outstanding memory was 0.02 GiB, increasing by approximately 0.1 GiB during the run, compared to 1.7 GiB of &lt;em&gt;RSS&lt;/em&gt; growth. Anything accumulating toward that figure would have appeared in the top ten stacks. Memory that the application has freed but the kernel still counts as resident, and that is still being touched, is not a leak and does not indicate cache malfunction. This behaviour is the allocator's.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;xrpld&lt;/code&gt; calls &lt;code&gt;free&lt;/code&gt;, glibc's malloc typically retains the pages on its internal free lists, anticipating future reuse by the application. The kernel cannot request memory back from the allocator. Under memory pressure it can drop page cache or swap pages out, but it cannot instruct malloc to release its holdings. glibc only returns free space at the end of each heap when the unused tail exceeds a threshold. Freed memory blocks that are interleaved with live allocations, as is common with caches containing objects of mixed lifetimes, remain stranded within the heap. This is fragmentation. The allocation pattern of an XRPL node, which involves bursts of millions of small, short-lived tree nodes at sweep and rotation boundaries, and the use of per-thread arenas (each busy thread receives its own &lt;a href="https://sourceware.org/glibc/wiki/MallocInternals" rel="noopener noreferrer"&gt;heap region&lt;/a&gt;), leads to fragmentation in every arena. This accumulates to gigabytes of memory that are free from the application's perspective but remain resident according to system metrics.&lt;/p&gt;

&lt;p&gt;The key distinction is between memory &lt;strong&gt;released&lt;/strong&gt; by the application and memory &lt;strong&gt;reclaimed&lt;/strong&gt; by the allocator. The application can release memory, but only the allocator can reclaim it. There was no mechanism in the node's code path to connect these actions.&lt;/p&gt;

&lt;p&gt;The investigation focused on identifying a memory leak. No leak of any consequence was found. The allocator was retaining memory as part of a general-purpose strategy that is poorly suited to this workload. The application can precisely identify when memory is no longer needed, but lacks a mechanism to inform the allocator.&lt;/p&gt;

&lt;p&gt;There is a solution available. glibc provides &lt;a href="https://man7.org/linux/man-pages/man3/malloc_trim.3.html" rel="noopener noreferrer"&gt;&lt;code&gt;malloc_trim&lt;/code&gt;&lt;/a&gt;, which can be invoked by &lt;code&gt;xrpld&lt;/code&gt; at an appropriate point in its operation. The implementation details, performance impact, and effects on memory usage in Mainnet validators will be discussed in the next post.&lt;/p&gt;

&lt;p&gt;The diagnostic toolset fulfilled its purpose by identifying the root cause: the allocator handled &lt;code&gt;xrpld&lt;/code&gt;'s allocation pattern poorly, and neither side had a way to tell the other what it knew.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acknowledgements
&lt;/h2&gt;

&lt;p&gt;This investigation began with reports from operators running the software on Mainnet, who measured the growth on their own nodes, compared notes on how to measure it, and kept asking the questions that turned out to matter. Thanks are due to them, to those who reviewed the change in the open-source repository, and to those who reviewed these two posts.&lt;/p&gt;

</description>
      <category>xrpl</category>
      <category>performance</category>
      <category>ebpf</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
