fix(memory): honor cgroup memory limits when sizing expert banks - #334
Avicennasis wants to merge 1 commit into
Conversation
|
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 |
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.
de9df4a to
056b258
Compare
|
Thanks for the systemd-scope run — that is exactly the non-container shape the write-up could not cover, and 55.9 → 46.0 GiB is the same bug from the other direction. Rebased onto current The only edit beyond that is in the two admission tests, which stub the builder to observe which loader was chosen: #418 renamed it and split it into Re-ran on a CPU-only Ubuntu 24.04 x86_64 box (16 cores, 125 GiB, cgroup v2, Docker 29.1.3; Python 3.12, torch 2.11.0+cu130, transformers 5.17.0, pytest 9.1.1, run with
Nothing in the change touches a kernel, which is why a CPU-only box is an honest environment for it; the loader path it gates is host-RAM admission. Both of your observations are right. I have left them out of this PR so the reviewed core stays as it was, and will fold them into a follow-up (dedupe the ancestor-walk guard; cache the bound for the bench sampler) if you want them. The |
…lashML-org#334) Upstream FlashML-org#334 by Avicennasis, taken unmodified. Adds `freetoken/memory.py::effective_memory_available()` = min(host MemAvailable, tightest finite cgroup limit) and routes the expert-bank loader (`_host_ram_fits_parallel`), `benchbw` and the loader benchmark through it. Why this box wants it: everything here is launched through `ft-capped`, which runs the server in a systemd scope with MemoryMax=46G. Before this, `_host_ram_fits_parallel` sized the parallel expert reader's one-extra-shard transient from /proc/meminfo MemAvailable alone -- the HOST's figure -- so the fast path was admitted against ~10 GiB the cgroup would never allow, and the load died to the OOM killer with no diagnostic. Measured here after applying, same interpreter, same call: outside any scope 55.9 GiB inside MemoryMax=46G (ft-capped's shape) 46.0 GiB <- cgroup memory.max So the admission decision now sees the cap. tests/test_memory.py 43 passed, tests/moe/test_expert_bank_memory_admission.py 4 passed (both new upstream). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…org#418), without FlashML-org#337 FlashML-org#418 replaced the per-family quantization branches with QuantConfig / QuantScheme / QuantMethod, and moved expert-bank ownership into the MoE kernel. 16 files conflicted; they were not 16 equal problems. Five were mechanical: * engine/cache_budget.py -- take FlashML-org#418's max_slots parameter (it replaces the hardcoded 992 marlin cap) and keep our +1 dummy-page reservation on the KV floor, with its test. * engine/config.py -- keep the fields, take the renamed-flag wording. * kernel/triton/fp8_pertensor_linear.py -- FlashML-org#418 removed the BaseOP layer, so only the round_e4m3 import is still needed; the e4m3 rounding fix itself is untouched. * models/qwen3_5_moe/model.py -- FlashML-org#418's ParallelLMHead takes a quant_config and selects the NVFP4 W4A16 kernel itself, which subsumes our Nvfp4LMHead special case (and the ~1 GB bf16 dequant it existed to avoid). Take theirs, keep the vision tower line. The other eleven were all FlashML-org#337's NVMe disk tier, whose plumbing point no longer exists: FlashML-org#418 deleted the per-model load_nvfp4_expert_sources(..., disk_tier=) in favour of a one-line nvfp4_expert_spec() plus expert_pieces, so the tier would have to be re-attached to the kernel-owned layout rather than merged. MT chose to drop it and rebuild it fresh, so it is removed whole -- moe/disk_tier.py and its test, the three CLI flags, the three EngineConfig fields, and every call site in layers/moe.py, moe/offload_cache.py, moe/expert_banks.py and engine/engine.py. It is recoverable from 9834eb3 / e4b565c. Three files needed hunk-level care because other work shares them, and a wholesale --theirs would have dropped it silently: * moe/expert_banks.py -- take main, then re-apply FlashML-org#334's effective_memory_available() (cgroup-aware host RAM) on top. * models/qwen3_5_moe/weight.py -- the vision rename hooks auto-merged outside the conflict markers; resolve per hunk so they survive. * engine/engine.py -- take main for the two bank-binding hunks, keep ours for the --moe-collect-stats routing histogram (main still has OffloadMoeCache.collect_decode_freq). layers/moe.py and moe/offload_cache.py turned out to differ from main only by the disk tier, so both are now identical to it. The dead make_offload_moe_cache hook went with the disk-tier branch that read it; nothing in the tree implements it. MT's serving command still parses: --moe-backend offload folds into moe_strategy='offload' and every other flag is unchanged, with one new deprecation warning at start-up. Assisted-by: Claude Opus 5 Tests: the merged tree ran 10 failed / 1904 passed / 92 skipped; the pre-merge try/all ran 8 failed / 1905 passed / 60 skipped, and the two failure sets differ by exactly the two above, both since fixed and verified in isolation (4 passed, and the admission test does fail when effective_memory_available's result is discarded). The confirmation run of the whole suite after the fix was interrupted at 50%, so "8 failed after the fix" is derived from those two facts, not measured end to end.
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.