fix(memory): honor cgroup memory limits when sizing expert banks - #334
fix(memory): honor cgroup memory limits when sizing expert banks#334Avicennasis wants to merge 1 commit into
Conversation
The expert-bank loader, the CPU bandwidth bench and the loader benchmark all sized host-RAM work from /proc/meminfo MemAvailable alone. Inside a container started with --memory=N that figure is the host's, not the cgroup's: the parallel expert reader was admitted against tens of GiB of "available" RAM while the cgroup could hold a fraction of it, and the process was OOM-killed mid-load. benchbw.py carried its own cgroup-v2-only parser; the loader had none. Rule: effective available memory = min(host MemAvailable, remaining finite cgroup budget). The cgroup budget is the tightest finite limit-minus-usage along the process's cgroup ancestry (v2 memory.max / memory.current with the `max` sentinel; v1 memory.limit_in_bytes / memory.usage_in_bytes with the unlimited sentinel as a fallback), resolved from /proc/self/cgroup with bounded reads and a bounded ancestor walk. Absent procfs or no visible memory controller yields None (unknown), so non-Linux hosts keep their previous best-effort behaviour and are never told they have 0 bytes; a present but malformed or unreadable control file fails closed to 0. The probe runs before any bank or buffer allocation: admission is the only decision point, because once the parallel reader's whole-shard anonymous buffers exist there is nothing reclaimable left to give back. freetoken/memory.py is stdlib-only (no torch import) so it can be loaded by path inside a slim container for diagnosis. Every production MemAvailable reader now goes through it: moe/expert_banks ._host_ram_fits_parallel, moe/benchbw._available_ram_bytes (the private _cgroup_mem_headroom parser is removed so there is one cgroup parser; the bench's clamping and synthetic-bank sizing are otherwise unchanged), and benchmarks/bench_load_weight_generic.MemSampler. Tests: tests/test_memory.py drives the resolver against fake /proc and cgroup trees under tmp_path -- unlimited, finite, nested ancestor-min, namespaced root, usage above limit, malformed, oversized, permission denied, v1 fallback and sentinel, absent files, and the hybrid `1:net_cls:/` + `0::/...` membership layout systemd and Docker both produce. tests/moe/test_expert_bank_memory_admission.py checks that the auto-admission path drops to the serial build when the fake cgroup budget (299 B) cannot hold the banks plus one shard transient (300 B) although MemAvailable is large, that an explicit --expert-load override bypasses the probe, and that the bench receives a finite budget unchanged while keeping its legacy 8 GiB fallback only for an unknown (None) probe. Validated on CPU-only Linux (Ubuntu 24.04, Docker 29.1, cgroup v2; no GPU is involved in this path): 47 tests pass under python and python -O; the full suite has the same two pre-existing failures as origin/main (tests/models/test_muse_glimmer.py) and no new ones; python:3.12-slim with --memory=2g / --memory=6g / no limit reports effective = memory.max - memory.current (2136084480 / 6432010240) and host MemAvailable respectively. Mount-namespace hardening of the cgroup lookup is deliberately out of scope for this change.
|
Thanks for this — it lands on something that bites here for a reason you did not have in front of Setup. RTX 4090 24 GB, 61 GB RAM, Ubuntu 26.04, cgroup v2 only (no v1 hierarchy mounted, so The gap, measuredEverything on this box is launched through a small wrapper that runs the server in a transient Before this PR, It does not cost the fast path hereThe obvious worry with a tighter budget is that the parallel loader stops being admitted and every Then served an agent workload for ~55 minutes without incident — 994 prefill batches, 7.3 M prefill Tests: One thing I checked that turns out to be fine, in case it saves the next readerThe It cannot: v1 and v2 use different sentinels. cgroup v1 Two observations, neither a defect
Unrelated to the code: the branch is five commits behind
Happy to test the v1 path if someone with a v1 or hybrid host wants a second pair of eyes; I only Written with AI assistance; every number above was measured on my hardware (RTX 4090, 61 GB |
Fixes #333.
Problem
The expert-bank loader (
moe/expert_banks.py::_host_ram_fits_parallel), the CPU bandwidth bench (moe/benchbw.py) and the loader benchmark (benchmarks/bench_load_weight_generic.py) size host-RAM work from/proc/meminfoMemAvailablealone. Inside a container started with--memory=N(Docker, Kubernetes) that figure is the host's, so the parallel expert reader — whose transient is one extra whole shard — is admitted against tens of GiB the cgroup will not allow, and the process is OOM-killed mid-load with no diagnostic.benchbw.pyalready carried a private cgroup-v2-only clamp, so the bench and the loader disagreed about how much RAM a container has.Change
One stdlib-only module,
freetoken/memory.py, witheffective_memory_available():memory.max/memory.current(max= unlimited), walking/proc/self/cgroupancestors with bounded reads; cgroup v1memory.limit_in_bytes/memory.usage_in_bytesfallback with the unlimited sentinel.None(unknown): non-Linux hosts keep today's behaviour and are never told they have 0 bytes. Present-but-malformed or unreadable control files fail closed to 0.Every production
MemAvailablereader now goes through it;benchbw's private_cgroup_mem_headroomparser is removed (one parser). The bench's clamping and synthetic-bank sizing are otherwise unchanged. Mount-namespace hardening of the lookup is deliberately out of scope.Tests
tests/test_memory.py: fake/proc+ cgroup trees undertmp_path— unlimited, finite, nested ancestor-min, namespaced root, usage above limit, malformed, oversized, permission denied, v1 fallback and sentinel, absent files, and the hybrid1:net_cls:/+0::/...membership layout systemd and Docker both produce.tests/moe/test_expert_bank_memory_admission.py: the auto-admission path drops to the serial build when the fake cgroup budget (299 B) cannot hold banks + one shard transient (300 B) althoughMemAvailableis large; an explicit--expert-loadoverride bypasses the probe; the bench receives a finite budget unchanged and keeps its 8 GiB fallback only for an unknown probe.These tests patch the new resolver, so they cannot run against
mainas written; the behavioural difference is the reproduction in the linked issue —main's_host_ram_fits_parallelreturnsTruefor a 9 GiB load inside a 2 GiB container, this branch's admission drops to the serial build.Validation
Environment: Ubuntu 24.04 (Linux 7.0.0, x86_64, 16 cores, 125 GiB), cgroup v2, Docker 29.1.3, Python 3.12.3, torch 2.11.0+cu130, transformers 5.16.1, triton 3.6.0. No NVIDIA GPU on this host — this path has no GPU dependency; GPU-marked tests skipped as usual and I did not reproduce an end-to-end
ft serveOOM inside a container.Live cgroup v2 probe,
python:3.12-slimwith the module loaded by path:effective_memory_available()memory.max − memory.current--memory=2g--memory=6gMemAvailable)maxPerformance: not measured / not changed — one extra bounded procfs read at admission time.
Prepared with AI assistance; I reviewed the change and ran the validation above.