From 066834d17dddc36cef7b489589e80bd8dc797deb Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Fri, 6 Nov 2026 19:49:06 -0500 Subject: [PATCH] feat: compress idle game memory before low-RAM devices run out (#2187) --- docs/development/low-memory.md | 57 ++++++ .../overlay/usr/local/bin/droiddeck-session | 98 +++++++++- tools/tests/test_client_zram_focus.py | 5 +- tools/tests/test_memory_pressure.py | 182 ++++++++++++++++++ 4 files changed, 336 insertions(+), 6 deletions(-) diff --git a/docs/development/low-memory.md b/docs/development/low-memory.md index 398dbf913..4b8e9b459 100644 --- a/docs/development/low-memory.md +++ b/docs/development/low-memory.md @@ -41,6 +41,63 @@ reduced aggregate launcher RSS only from 263 to 255 MiB and backed off. Thus lau 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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index b8137ddc1..c468dbf5f 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -840,6 +840,9 @@ case $mode in _bl_headroom=$((_bl_mem_total / 5)) [ "$_bl_headroom" -ge $((512 * 1024)) ] || _bl_headroom=$((512 * 1024)) [ "$_bl_headroom" -le $((1536 * 1024)) ] || _bl_headroom=$((1536 * 1024)) + _bl_tier_headroom=$((_bl_mem_total / 4)) + [ "$_bl_tier_headroom" -ge $((768 * 1024)) ] || _bl_tier_headroom=$((768 * 1024)) + [ "$_bl_tier_headroom" -le $((2560 * 1024)) ] || _bl_tier_headroom=$((2560 * 1024)) _bl_swap_reserve=$((_bl_swap_total / 5)) [ "$_bl_swap_reserve" -ge $((256 * 1024)) ] || _bl_swap_reserve=$((256 * 1024)) } @@ -884,6 +887,88 @@ case $mode in _bl_pressure_backoff=30 fi } + bl_memory_critical() { + [ "$_bl_mem_total" -gt 0 ] && [ "$_bl_available" -ge 0 ] && + { [ "$_bl_available" -lt "$_bl_tier_headroom" ] || + { [ "$_bl_psi" -ge 1 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 3 / 2)) ]; }; } + } + bl_low_ram() { + [ "$_bl_mem_total" -gt 0 ] && [ "$_bl_mem_total" -le $((13 * 1024 * 1024)) ] + } + bl_session_pressure() { + bl_memory_read + [ "$_bl_game" = 1 ] && bl_low_ram && bl_memory_critical || return 0 + local _wait=$_bl_tier_backoff _urgent=0 _before _pids _targets + [ "$_bl_available" -ge $((_bl_headroom / 2)) ] || _urgent=1 _wait=30 + [ "$_bl_tier_at" -lt 0 ] || [ $((SECONDS - _bl_tier_at)) -ge "$_wait" ] || return 0 + if [ "$_bl_tier_at" -ge 0 ] && [ "$_bl_tier_pushed" -gt 0 ]; then + bl_tier_readback + if [ "$_bl_tier_back" -le $((_bl_tier_pushed / 2)) ]; then + _bl_tier_backoff=30 + elif [ "$_urgent" = 0 ]; then + _bl_tier_backoff=$((_bl_tier_backoff * 2 > 300 ? 300 : _bl_tier_backoff * 2)) + _bl_tier_at=$SECONDS _bl_tier_pushed=0 + step "zRAM: the game read back most of its last page-out, next try in ${_bl_tier_backoff} s" + return 0 + fi + fi + _bl_tier_at=$SECONDS + bl_zram_pick game + bl_zram_usage "${_pids[@]}" > /dev/null + [ "$_bl_rss" -ge $((256 * 1024)) ] || return 0 + if [ "$_bl_swap_total" -le 0 ] || [ $((_bl_swap_free - _bl_rss)) -lt "$_bl_swap_reserve" ]; then + step "zRAM: low-memory page-out skipped, swap headroom too small ($((_bl_swap_free / 1024)) MB free)" + return 0 + fi + _targets=("${_pids[@]}") _before=$_bl_swap + bl_zram_send 1 "low memory (available $((_bl_available / 1024)) MB), compressing idle game memory" game + bl_zram_usage "${_targets[@]}" > /dev/null + _bl_tier_pushed=$((_bl_swap - _before)) + bl_tier_note "${_targets[@]}" + if [ "${_bl_pageout_complete:-0}" = 1 ] && [ "${BL_ZRAM_CLIENT:-1}" = 1 ] && [ "${#_bl_pids[@]}" -gt 0 ]; then + _bl_pressure_at=$SECONDS _bl_packed=$SECONDS + fi + } + bl_proc_start() { + local _s= + _bl_start= + read -r _s 2>/dev/null < "/proc/$1/stat" || return 1 + _s=${_s##*) } + set -- $_s + [ "$1" != Z ] && [ "$#" -ge 20 ] || return 1 + _bl_start=${20} + } + bl_tier_note() { + local _p + _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=() + for _p in "$@"; do + bl_proc_start "$_p" || continue + bl_zram_usage "$_p" > /dev/null + _bl_tier_targets+=("$_p") _bl_tier_swaps+=("$_bl_swap") _bl_tier_starts+=("$_bl_start") + done + } + bl_tier_readback() { + local _i _back=0 + for _i in "${!_bl_tier_targets[@]}"; do + bl_proc_start "${_bl_tier_targets[$_i]}" && [ "$_bl_start" = "${_bl_tier_starts[$_i]}" ] || continue + bl_zram_usage "${_bl_tier_targets[$_i]}" > /dev/null + [ "${_bl_tier_swaps[$_i]}" -le "$_bl_swap" ] || _back=$((_back + _bl_tier_swaps[_i] - _bl_swap)) + done + _bl_tier_back=$_back + } + bl_watch_nap() { + local _i + if [ "$_bl_game" = 1 ] && bl_low_ram && [ "$_bl_available" -ge 0 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 2)) ]; then + for _i in 1 2 3 4 5; do + nap 1 + bl_memory_read + bl_memory_critical && return 0 + done + else + nap 5 + fi + return 0 + } bl_session_others() { local _p _bl_session=() @@ -892,7 +977,11 @@ case $mode in done } bl_zram_pick() { - if [ "$1" = session ]; then + if [ "$1" = game ]; then + bl_session_others + _pids=("${_bl_session[@]}") + [ "${BL_ZRAM_CLIENT:-1}" != 1 ] || _pids+=("${_bl_pids[@]}") + elif [ "$1" = session ]; then bl_session_others _pids=("${_bl_session[@]}") else @@ -954,7 +1043,7 @@ case $mode in [ "$_bl_ram_cap" -gt 0 ] && { [ "$_bl_measured" -lt 0 ] || [ $((SECONDS - _bl_measured)) -ge 30 ]; } } _bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30 - _bl_pressure_at=-1 _bl_pressure_backoff=30 + _bl_pressure_at=-1 _bl_pressure_backoff=30 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_back=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=() while kill -0 $$ 2>/dev/null; do bl_zram_scan bl_memory_read @@ -981,6 +1070,7 @@ case $mode in _bl_backoff=30 fi fi + bl_session_pressure bl_client_pressure if [ "$_bl_game" = 1 ] && bl_game_focused; then if [ "$_bl_since" -lt 0 ] || [ "$_bl_since_apps" != "$_bl_apps" ]; then @@ -995,7 +1085,7 @@ case $mode in if [ "$_bl_packed" -ge 0 ]; then _bl_unpack=1 fi - _bl_since=-1 _bl_packed=-1 + _bl_since=-1 _bl_packed=-1 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=() else _bl_since=-1 fi @@ -1003,7 +1093,7 @@ case $mode in bl_zram_send 0 "memory recovered, reading the client back" client _bl_unpack=0 fi - nap 5 + bl_watch_nap done ) & fi diff --git a/tools/tests/test_client_zram_focus.py b/tools/tests/test_client_zram_focus.py index 62bb89d37..5cd7d3cd4 100644 --- a/tools/tests/test_client_zram_focus.py +++ b/tools/tests/test_client_zram_focus.py @@ -81,7 +81,7 @@ class ClientZramFocusTest(unittest.TestCase): def test_refocus_restarts_the_delay_and_game_exit_restores_a_packed_client(self): source = SCRIPT.read_text() start = source.index(' if [ "$_bl_game" = 1 ] && bl_game_focused; then') - stop = source.index(' nap 5', start) + stop = source.index(' bl_watch_nap\n', start) body = source[start:stop] harness = ''' _bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_unpack=0 _bl_apps=" 413150" @@ -112,7 +112,7 @@ tick 60 0 0 def monitor_harness(self): source = SCRIPT.read_text() start = source.index(' bl_zram_scan\n bl_memory_read\n') - stop = source.index(' nap 5', start) + stop = source.index(' bl_watch_nap\n', start) return ''' _bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30 _bl_apps=" 413150" @@ -121,6 +121,7 @@ step() { :; } bl_zram_scan() { :; } bl_memory_read() { :; } bl_client_pressure() { :; } +bl_session_pressure() { :; } bl_client_can_pageout() { return 0; } bl_client_can_readback() { [ "${readback:-1}" = 1 ]; } bl_game_focused() { [ "$focused" = 1 ]; } diff --git a/tools/tests/test_memory_pressure.py b/tools/tests/test_memory_pressure.py index 96c16ec15..8aa2d1f2c 100644 --- a/tools/tests/test_memory_pressure.py +++ b/tools/tests/test_memory_pressure.py @@ -137,5 +137,187 @@ echo "backoff:$_bl_pressure_backoff" self.assertEqual(['no'], self.run_policy('if bl_client_can_readback; then echo yes; else echo no; fi')) +class SessionPressureTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.memory() + + def memory(self, available=1024, swap_free=6144, total=7168, swap_total=8192, psi='0.00'): + (self.root / 'meminfo').write_text( + f'MemTotal: {total * 1024} kB\nMemAvailable: {available * 1024} kB\n' + f'SwapTotal: {swap_total * 1024} kB\nSwapFree: {swap_free * 1024} kB\n') + (self.root / 'pressure').write_text(f'some avg10={psi} avg60=0.00 avg300=0.00 total=1234\n') + + def run_policy(self, body): + functions = '\n'.join(function_source(name) for name in ( + 'bl_memory_read', 'bl_memory_tight', 'bl_memory_critical', 'bl_low_ram', + 'bl_session_pressure', 'bl_watch_nap', 'bl_zram_pick', 'bl_tier_note', 'bl_tier_readback')) + functions = functions.replace('/proc/meminfo', str(self.root / 'meminfo')) + functions = functions.replace('/proc/pressure/memory', str(self.root / 'pressure')) + harness = ''' +set -u +_bl_game=1 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_back=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=() _bl_pids=(999) +_bl_pressure_at=-1 _bl_packed=-1 +rss=$((1536 * 1024)) swap=0 pushed=$((1024 * 1024)) alive=999 started=100 complete=1 +step() { :; } +bl_proc_start() { _bl_start=; case " $alive " in *" $1 "*) _bl_start=$started; return 0 ;; esac; return 1; } +nap() { echo "nap:$1"; } +bl_session_others() { _bl_session=(321 654); } +bl_zram_usage() { _bl_rss=$rss _bl_swap=$swap; } +bl_zram_send() { echo "send:$1:$3"; rss=$((rss - pushed)); swap=$((swap + pushed)); _bl_pageout_complete=$complete; } +''' + functions + '\nbl_memory_read\n' + body + result = run_bash(harness) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('', result.stderr) + return result.stdout.splitlines() + + def test_low_headroom_compresses_the_game_session_at_most_every_30_seconds(self): + self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy(''' +SECONDS=0; bl_session_pressure +SECONDS=10; bl_session_pressure +SECONDS=29; bl_session_pressure +SECONDS=30; bl_session_pressure +''')) + + def test_twelve_gigabyte_devices_are_included_and_larger_ones_are_not(self): + for total, expected in ((11264, ['send:1:game']), (12288, ['send:1:game']), (15360, [])): + with self.subTest(total=total): + self.memory(total=total) + self.assertEqual(expected, self.run_policy('bl_session_pressure')) + + def test_no_game_does_not_compress_the_session(self): + self.assertEqual([], self.run_policy('_bl_game=0; bl_session_pressure')) + + def test_enough_available_memory_does_not_compress_even_with_stalls(self): + self.memory(available=3072, psi='5.00') + self.assertEqual([], self.run_policy('bl_session_pressure')) + + def test_stalls_with_moderate_headroom_compress_before_the_hard_limit(self): + self.memory(available=2048, psi='1.50') + self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure')) + + def test_moderate_headroom_without_stalls_waits(self): + self.memory(available=2048) + self.assertEqual([], self.run_policy('bl_session_pressure')) + + def test_game_reading_back_most_of_a_pageout_skips_and_doubles_the_wait(self): + self.assertEqual(['send:1:game', 'backoff:60', 'send:1:game', 'backoff:60', + 'send:1:game', 'backoff:30'], self.run_policy(''' +pushed=$((512 * 1024)) +SECONDS=0; bl_session_pressure +swap=$((swap / 4)) +SECONDS=30; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +SECONDS=60; bl_session_pressure +SECONDS=90; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +SECONDS=150; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +''')) + + def test_repeated_read_back_keeps_doubling_up_to_five_minutes(self): + self.assertEqual(['backoff:300'], self.run_policy(''' +_bl_tier_backoff=240 _bl_tier_at=0 _bl_tier_targets=(999) _bl_tier_swaps=($((1024 * 1024))) _bl_tier_starts=(100) _bl_tier_pushed=$((1024 * 1024)) swap=0 +SECONDS=240; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +''')) + + def test_nearly_out_of_memory_ignores_the_backoff_but_keeps_30_seconds(self): + self.memory(available=512) + self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy(''' +_bl_tier_backoff=300 +SECONDS=0; bl_session_pressure +swap=0 +SECONDS=20; bl_session_pressure +SECONDS=30; bl_session_pressure +''')) + + def test_small_swap_headroom_skips_the_pageout(self): + self.memory(swap_free=1536, swap_total=2048) + self.assertEqual([], self.run_policy('bl_session_pressure')) + + def test_small_session_is_left_alone(self): + self.assertEqual([], self.run_policy('rss=$((128 * 1024)); bl_session_pressure')) + + def test_watch_polls_every_second_near_the_limit_and_stops_when_critical(self): + self.memory(available=2560) + self.assertEqual(['nap:1', 'nap:1', 'nap:1', 'nap:1', 'nap:1'], self.run_policy('bl_watch_nap')) + self.memory(available=1024) + self.assertEqual(['nap:1'], self.run_policy('_bl_available=$((2560 * 1024)); bl_watch_nap')) + self.memory(available=4096) + self.assertEqual(['nap:5'], self.run_policy('bl_watch_nap')) + self.assertEqual(['nap:5'], self.run_policy('_bl_game=0; bl_watch_nap')) + + def test_one_push_compresses_the_game_and_the_launcher_and_restarts_the_launcher_wait(self): + self.assertEqual(['send:1:game', 'pids:321 654 999', 'client:0:0'], self.run_policy(''' +SECONDS=0; bl_session_pressure +bl_zram_pick game; echo "pids:${_pids[*]}" +echo "client:$_bl_pressure_at:$_bl_packed" +''')) + + def test_launcher_compression_turned_off_keeps_the_launcher_out(self): + self.assertEqual(['pids:321 654', 'client:-1:-1'], self.run_policy(''' +BL_ZRAM_CLIENT=0 +SECONDS=0; bl_session_pressure +bl_zram_pick game; echo "pids:${_pids[*]}" +echo "client:$_bl_pressure_at:$_bl_packed" +''')[1:]) + + def test_low_ram_devices_compress_with_a_quarter_of_memory_left(self): + self.memory(available=1700) + self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure')) + self.memory(available=1900) + self.assertEqual([], self.run_policy('bl_session_pressure')) + + + def test_a_compressed_process_that_exits_is_not_counted_as_read_back(self): + self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy(''' +SECONDS=0; bl_session_pressure +alive=""; swap=0 +SECONDS=30; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +''')) + + def test_a_process_that_starts_after_a_pageout_is_not_in_its_read_back(self): + self.assertEqual(['send:1:game', 'targets:999', 'send:1:game', 'backoff:30'], self.run_policy(''' +SECONDS=0; bl_session_pressure +echo "targets:${_bl_tier_targets[*]}" +alive="999 1234" +SECONDS=30; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +''')) + + def test_an_incomplete_pageout_does_not_restart_the_launcher_wait(self): + self.assertEqual(['send:1:game', 'client:-1:-1'], self.run_policy(''' +complete=0 +SECONDS=0; bl_session_pressure +echo "client:$_bl_pressure_at:$_bl_packed" +''')) + + + def test_a_reused_process_id_is_not_counted_as_read_back(self): + self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy(''' +SECONDS=0; bl_session_pressure +started=200; swap=0 +SECONDS=30; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +''')) + + def test_process_start_comes_from_stat_and_zombies_have_none(self): + stat = self.root / 'stat' + source = function_source('bl_proc_start').replace('/proc/$1/stat', str(stat)) + fields = ' '.join(['0'] * 17) + for state, expected in (('S', ['ok:4242']), ('Z', ['gone:'])): + with self.subTest(state=state): + stat.write_text(f'77 (Wow (arm64).exe) {state} 1 {fields} 4242 99 0\n') + result = run_bash(source + '\nif bl_proc_start 77; then echo "ok:$_bl_start"; else echo "gone:$_bl_start"; fi') + self.assertEqual(expected, result.stdout.split()) + stat.unlink() + result = run_bash(source + '\nbl_proc_start 77 || echo gone') + self.assertEqual(['gone'], result.stdout.split()) + + if __name__ == '__main__': unittest.main()