mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
144 lines
9.9 KiB
Markdown
144 lines
9.9 KiB
Markdown
# 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 entire RSS while leaving
|
||
one fifth of swap (at least 256 MiB) free. 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.
|
||
|
||
## Low-RAM devices: compressing idle game memory
|
||
|
||
On devices with at most 13 GiB `MemTotal` (every 12 GB phone and handheld and below), the
|
||
monitor also compresses the game session itself when memory runs out. The trigger is absolute
|
||
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 available memory falls below a quarter of RAM (bounded to
|
||
768–2560 MiB, 1.85 GiB on an 8 GB device), or below one and a half times that with at least 1%
|
||
`some avg10` memory stall. One request pages out every session process that loaded
|
||
`libblsession.so`: the game, Wine and its services, plus the launcher processes unless their
|
||
compression is turned off, under a single wait. It runs before the launcher-only check, which
|
||
can wait up to 12 seconds for acknowledgements. While a game runs and available memory is under
|
||
twice that level, memory is read every second instead of every five. 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. Devices above 13 GiB keep the
|
||
launcher-only behaviour.
|
||
|
||
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 and the game-first single request address both.
|
||
|
||
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](https://lpc.events/event/20/contributions/2424/) 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:
|
||
|
||
```sh
|
||
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.
|