feat: compress idle game memory before low-RAM devices run out (#2187)

This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-11-06 19:49:06 -05:00
1 parent f33d5b9e09
commit 066834d17d
4 files changed
+336 -6

No files matched your search

+57
View File
@@ -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
@@ -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
+3 -2
View File
@@ -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 ]; }
+182
View File
@@ -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()