Skip to content

fix(memory): honor cgroup memory limits when sizing expert banks - #334

Open
Avicennasis wants to merge 1 commit into
FlashML-org:mainfrom
Avicennasis:fix/cgroup-memory-limits
Open

fix(memory): honor cgroup memory limits when sizing expert banks#334
Avicennasis wants to merge 1 commit into
FlashML-org:mainfrom
Avicennasis:fix/cgroup-memory-limits

Conversation

@Avicennasis

Copy link
Copy Markdown

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/meminfo MemAvailable alone. 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.py already 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, with effective_memory_available():

effective = min(host MemAvailable, tightest finite cgroup budget along the ancestry)
  • cgroup v2: memory.max / memory.current (max = unlimited), walking /proc/self/cgroup ancestors with bounded reads; cgroup v1 memory.limit_in_bytes / memory.usage_in_bytes fallback with the unlimited sentinel.
  • Absent procfs / no memory controller → 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.
  • The probe runs before any bank or buffer allocation; admission is the only decision point.

Every production MemAvailable reader now goes through it; benchbw's private _cgroup_mem_headroom parser 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 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: the auto-admission path drops to the serial build when the fake cgroup budget (299 B) cannot hold banks + one shard transient (300 B) although MemAvailable is large; an explicit --expert-load override 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 main as written; the behavioural difference is the reproduction in the linked issue — main's _host_ram_fits_parallel returns True for 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 serve OOM inside a container.

python -m pytest -q tests/test_memory.py tests/moe/test_expert_bank_memory_admission.py   # 47 passed
python -O -m pytest -q tests/test_memory.py tests/moe/test_expert_bank_memory_admission.py   # 47 passed
python -m pytest -q tests/        # same two pre-existing failures as main (tests/models/test_muse_glimmer.py), no new ones
ruff check / ruff format --check  # clean on new files; remaining E741 are pre-existing lines
git diff --check origin/main..HEAD

Live cgroup v2 probe, python:3.12-slim with the module loaded by path:

container effective_memory_available() memory.max − memory.current
--memory=2g 2 136 084 480 2 136 084 480
--memory=6g 6 432 010 240 6 432 010 240
no limit 36 877 078 528 (= host MemAvailable) max

Performance: 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.

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.
@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Thanks for this — it lands on something that bites here for a reason you did not have in front of
you. Your write-up is about containers (docker --memory=N, Kubernetes); the shape I run is a
systemd scope, and the same bug is there. So this is an independent confirmation from a
different deployment, plus a serving run on top of it.

Setup. RTX 4090 24 GB, 61 GB RAM, Ubuntu 26.04, cgroup v2 only (no v1 hierarchy mounted, so
I cannot exercise that path). This PR applied on main @ 03c28d2 plus PR #337 @ a6bd5c0.

The gap, measured

Everything on this box is launched through a small wrapper that runs the server in a transient
systemd scope with MemoryMax=46G. Same interpreter, same call, only the scope differs:

outside any scope                   55.9 GiB
inside MemoryMax=46G                46.0 GiB   == the scope's memory.max

Before this PR, _host_ram_fits_parallel sized the parallel expert reader's one-extra-shard
transient from /proc/meminfo MemAvailable alone. On this box that is the host's ~56 GiB, so
roughly 10 GiB the cgroup would never hand out was inside the admission decision. Same failure
you describe for containers, reached from a different direction — worth knowing that the fix is not
container-specific.

It does not cost the fast path here

The obvious worry with a tighter budget is that the parallel loader stops being admitted and every
boot goes serial. It does not, at least on this checkpoint:

ornith-ai/Ornith-1.5-35B-A3B-NVFP4, --vision, --kv-reserve-tokens 262144, cap 46G
  expert banks: slow path (parallel build)
  Loading experts (parallel): 21.8G/21.8G [00:05<00:00, 4.62GB/s]
  API server is ready  (12 s from launch)

Then served an agent workload for ~55 minutes without incident — 994 prefill batches, 7.3 M prefill
tokens, peak context ~140 k, cgroup high-water 19.5 GiB of the 46 GiB cap.

Tests: tests/test_memory.py 43 passed, tests/moe/test_expert_bank_memory_admission.py 4 passed.
Across the 84 test files this PR could plausibly touch, taken before and after: 7 failures either
way, all pre-existing ULP-noise torch.equal comparisons. Nothing gained, nothing broken.

One thing I checked that turns out to be fine, in case it saves the next reader

The "max" handling looks asymmetric at first read — _cgroup_limit_remaining special-cases the
literal max only for v2 (if not v1 and limit_text in ("max", "max\n")), while v1 unlimited is
caught further down by the numeric _CGROUP_V1_UNLIMITED_MIN threshold. My first reaction was that
a v1 host writing max would fall through to the malformed cgroup memory limit raise.

It cannot: v1 and v2 use different sentinels. cgroup v1 memory.limit_in_bytes reports
unlimited as 9223372036854771712 (PAGE_COUNTER_MAX), and the string max is a v2 convention
only. 9223372036854771712 > 1 << 60, so the numeric branch catches it. The asymmetry is correct,
not an oversight. Flagging it only because it reads like one, and someone will raise it eventually.

Two observations, neither a defect

  • The ancestor-walk termination guard is duplicated verbatim between _cgroup_control_directory and
    _cgroup_hierarchy_remaining. Both are right; it is a maintenance hazard if the walk ever changes.
  • effective_memory_available() does a full /proc/meminfo read plus a full cgroup walk on each
    call, and the bench sampler calls it every 0.2 s. Invisible next to a model load, and the sampler
    only wants a low-water mark, so a cached bound would do — mentioning it as an observation, not a
    request.

Unrelated to the code: the branch is five commits behind

e05cff8 (Aug 31) is the base here, and main has since taken #311, #332, #336, #343 and #329.
GitHub still calls it cleanly mergeable and a local rebase onto 03c28d2 applies without conflict
with all 47 tests passing, so nothing is broken — just worth refreshing before it lands, since the
main it will actually merge into is not the one it was tested against.

Happy to test the v1 path if someone with a v1 or hybrid host wants a second pair of eyes; I only
have v2 here, so that half of the code is unexercised by anything I ran.

Written with AI assistance; every number above was measured on my hardware (RTX 4090, 61 GB
RAM) and I can reproduce it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MoE expert-bank host-RAM check ignores cgroup memory limits (containers over-admit and get OOM-killed)

2 participants