10 KiB
Low-memory reclaim and userspace compression
The Steam session monitor uses the existing libblsession.so page-out request to reclaim
private anonymous launcher memory. The default remains a request 20 seconds after game focus,
with five minutes between requests. With client compression enabled, low system headroom can
also trigger a request while a game is loading, before its first focused window.
Every five seconds the monitor reads MemAvailable, swap headroom and memory PSI. Low
headroom means less than one fifth of total RAM (bounded to 512–1536 MiB), or at least 1%
some avg10 memory stall with less than one third of RAM available. Pressure requests target
only the launcher processes already recognized by the monitor, never the game. They require
at least 128 MiB launcher RSS and enough free swap to cover its anonymous RSS while leaving
one fifth of swap (at least 256 MiB) free. File-backed resident pages do not consume this
budget: the page-out helper targets private anonymous mappings. If RssAnon is absent from a
process's status, its full RSS is used instead. The regular automatic client request uses the
same swap guard. An explicit session RAM cap retains its existing behavior.
Pressure requests are at least 30 seconds apart. Reclaiming less than 16 MiB doubles the
retry delay, up to five minutes. After game exit, speculative launcher readback is deferred
until the session is under any explicit RAM cap, pressure has subsided, and available RAM
can cover twice the launcher's swap while leaving the headroom reserve. Normal demand paging
continues while readback is deferred; Steam does not have to wait for the monitor to recover.
Missing memory counters disable automatic pressure reclaim and speculative readback. Missing
PSI alone still permits the MemAvailable fallback. No root settings, swap sizes, compression
algorithms, Android process priorities or game-specific settings are changed.
A bounded live test of the pressure helper on the FIT during Caravan SandWitch loading requested page-out at 20:52:37 UTC, 18 seconds before the installed timed reclaimer. Aggregate launcher RSS fell from 608 to 283 MiB across that request; this is summed process RSS, not a measurement of net physical RAM saved. The game reached its main menu at 60 FPS. The test ran the candidate monitor functions alongside the old APK's monitor, not a newly built APK; the old monitor's later request is visible separately at 20:52:55 in the session log. This does not establish gameplay performance or resolve Stellar Blade's larger memory failure.
Continuing an existing Caravan save reproduced a whole-session low-memory kill with the
pressure helper running. Android recorded ApplicationExitInfo reason LOW_MEMORY, and
lmkd killed the foreground app with oom_score_adj=0 because the device was not responding.
The last half-second sample before the kill still had 2,172,612 KiB swap free. Over the
captured interval, system unevictable memory grew from 1,955,932 to 3,236,276 KiB and shmem
from 1,690,276 to 2,965,900 KiB, while the game's private swap reached 1,916,136 KiB. The
pressure helper found the launcher already largely reclaimed: its first request at Continue
reduced aggregate launcher RSS only from 263 to 255 MiB and backed off. Thus launcher reclaim
does not resolve this load failure, and this run did not exhaust swap. The unevictable/shmem
growth needs allocation attribution before treating it as a driver or GPU-memory diagnosis.
Pressure reclaim: compressing idle game memory
The monitor also compresses the game session itself when observed pressure approaches the
system kill boundary. There is no RAM-size gate. The trigger is absolute real headroom, not a
percentage: Android's lmkd killed a session on a 7.5 GiB device with
490 MiB still available, so a 95% used threshold (about 400 MiB left on 8 GB) is already past the
kill. A world load added 1 GiB of GPU memory in about ten seconds, and compressing 1.4 GiB took
2.6 seconds, so the request has to leave room for both.
The session request runs when real headroom falls below a quarter of RAM (bounded to
768–2560 MiB), or below one and a half times that with at least 1% some or full avg10
memory stall. Real headroom subtracts positive growth in KGSL allocation, Unevictable, and
Shmem from MemAvailable; a rapid fall also qualifies near twice the headroom. The monitor
may first request page-out of idle Steam and hidden launchers, subject to the Performance
setting and client reclaim limits. When both qualify, both requests are sent before waiting
for either to complete, so a slow launcher acknowledgement does not delay the game request. Until the client
request finishes, its anonymous RSS remains reserved from the session's swap budget. Completion
and backoff are measured against the processes that received each request, even if a later scan
finds different processes. The session request targets processes that loaded
libblsession.so: the game, Wine and its services, and excludes Steam and hidden launchers.
Requests are at least 30 seconds apart and need the same
swap reserve as launcher requests. When the session has read back more than half of what the
previous request compressed, that memory is in use: the request is skipped and the wait doubles,
up to five minutes. A request whose memory stayed compressed resets it to 30 seconds. Below half
the headroom the wait is ignored and only the 30-second spacing applies. The state resets when
the game exits.
Measured on a Retroid Pocket Duo (7.5 GiB, kernel 6.6, 6 GiB lzo-rle zram plus a 4 GiB swap file) entering the world in World of Warcraft: Forever, one run each:
| No session page-out | Session compressed at character select | |
|---|---|---|
| available before Enter World | 1,569–1,800 MiB | 2,329 MiB |
| game resident / in zram before Enter World | 1,919 / 13 MiB | 409 / 1,523 MiB |
| GPU (KGSL) memory during the load | 0.97 → 1.99 GiB | 0.96 → 1.99 GiB |
| lowest available | 491 MiB | 623 MiB |
| result | LOW_MEMORY kill of the whole session 58 s after Enter World |
in the world at 35 FPS after three minutes |
The killed run had 7.4 GiB of swap free at the time: reclaim did not keep up with the GPU allocation, not swap capacity. Little Nightmares at the same settings compressed from 1.41 GiB to 0.25 GiB resident in 2.6 seconds (1.1 GiB stored in about 0.5 GiB of zram) and read back 70 MiB in the following minute at its menu and 440 MiB while loading the next area; frame rate stayed at 44–46 FPS with one or two short frame-time spikes. These are single runs, not averages.
On the PR build without any manual step, the monitor compressed WoW before character select and again during the load; the game entered the world and stayed there at 33 FPS. Once in the world, available memory settled near 1 GiB, under the headroom, and the first version of the policy compressed 200–400 MiB every 30 seconds while the game read back about 15 MiB/s. The read-back check therefore compares against the last request instead of all compressed memory.
A second run on that build showed the trigger arriving too late: character select sat at
1.5–1.6 GiB available, just above the original one-fifth headroom, so nothing was compressed
there. During the load, available memory crossed the headroom 9 seconds after Enter World, but the
launcher check ran first and waited 12 seconds for acknowledgements; the game request started at
21 seconds, moved about 200 MiB, and lmkd ended the session at 30 seconds. The quarter-of-RAM
level starts reclaim earlier. Sending both requests before waiting removes the launcher
acknowledgement delay; it does not guarantee that reclaim can keep up with every loading peak.
Without root an app cannot write its memory cgroup's memory.reclaim (owned by system),
process_madvise on another process returns EPERM, PAGEMAP_SCAN is absent before Linux 6.7,
and KGSL's per-process GPU reclaim state is root-only. Page-out therefore runs inside each
process through the preload, and GPU memory itself cannot be compressed from userspace.
CRAM and the bounded pager experiment
Meta's CRAM proposal uses memory compression hardware to allow byte/cacheline reads without software decompression faults. A userspace software pager cannot reproduce that hardware property. This is separate from Valve's device memory cgroup prioritization work.
tools/experiments/anonymous-memory-pager.c is an opt-in feasibility probe, not a preload or
runtime component. Build it for the guest architecture with a Linux compiler:
cc -O2 -Wall -Wextra -Werror -pthread tools/experiments/anonymous-memory-pager.c -ldl -o /tmp/anonymous-memory-pager
/tmp/anonymous-memory-pager 64
It requires liblz4.so.1 and UFFD_USER_MODE_ONLY. The requested arena is bounded to
1–256 MiB. Half its pages contain a repeating byte and half contain pseudorandom bytes;
these are synthetic data, not a game-memory compression estimate. The probe registers only
its own arena, packs compressed or incompressible pages into a separate store, discards the
original physical pages, then restores them on demand with UFFDIO_COPY. It checks the full
checksum and subsequent writes. A separate child checks kernel access to an evicted buffer.
The compressed store is retained until the probe ends; this is one eviction/restore cycle,
without hot/cold selection, concurrent eviction, lifecycle hooks or game integration.
On the Pocket FIT Elite's Android 6.6.82 kernel, tested from DroidDeck's unprivileged guest:
| Measurement | Result |
|---|---|
unrestricted userfaultfd |
EPERM |
| userspace-only creation and API negotiation | available |
| arena / compressed payload | 64 MiB / 33,767,424 bytes |
| process RSS before / after eviction | 68,864 / 36,340 KiB |
| restored pages / checksum / writes | 16,384 / intact / intact |
| resident scan / compress and evict / faulted scan | 46.851 / 17.289 / 446.524 ms |
| kernel pipe write from evicted buffer | EFAULT |
These timings include the full checksum scan and are one synthetic run, not a comparison
against zram or a game-performance result. The normal unrestricted pager interface was denied;
/dev/userfaultfd was also inaccessible. The kernel-access result is a hard integration
constraint: a transparent heap-wide pager cannot assume every access comes from userspace.
Wine/FEX signal handling, syscall buffers, shared and GPU memory, mapping changes, concurrent
writes and allocation overhead would all need a correctness design before enabling such a
pager in a real game. The experiment is deliberately excluded from runtime builds.