Skip to content

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

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

Avicennasis wants to merge 6 commits into
FlashML-org:mainfrom
Avicennasis:fix/cgroup-memory-limits

Conversation

@Avicennasis

@Avicennasis Avicennasis commented Sep 1, 2026 •

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) or a systemd scope with MemoryMax=N 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.

Clamping to limit - usage alone has the opposite fault. Cgroup usage includes page cache, so once a checkpoint has been read inside the container the next start sees its size as spent and drops to the serial loader, although the kernel gives that cache back on demand. MemAvailable, the other half of the minimum, already counts page cache.

Change

One stdlib-only module, freetoken/memory.py, with effective_memory_available():

effective = min(host MemAvailable, tightest finite cgroup room along the ancestry)
room      = max(0, limit - max(0, usage - cache))
cache     = max(0, inactive_file + active_file - file_dirty - file_writeback)
  • 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.
  • File cache: at every finite level memory.stat is read with a 64 KiB bound; cgroup v1 uses the hierarchical total_* counters. file, cache and shmem are not used: file includes tmpfs, which sits on the anon lists. Dirty and writeback pages are taken back out, because they cannot be dropped until they are written. Absent, unreadable, oversized, non-ASCII or malformed statistics (bad line, duplicate key, signed or overflowing counter) disable the whole subtraction.
  • 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 limit or usage files fail closed to 0.
  • The probe runs before any bank or buffer allocation; admission is the only decision point.

The loader, the bandwidth bench and the loader benchmark now go through it; benchbw's private _cgroup_mem_headroom parser is removed (one parser). The bench's clamping and synthetic-bank sizing are otherwise unchanged, and so is the loader's rule (available memory must exceed all shards plus the largest one). Mount-namespace hardening of the lookup is deliberately out of scope. scripts/gguf_check/check.py (#756) has its own MemAvailable wait loop and is not touched.

The file-cache rule is adapted from JustVugg/colibri #2397, merge commit 79046d10ab021908249d9f96a71c8788fecd20f2. Both projects use Apache-2.0. No donor code was copied. Unlike the donor, dirty and writeback pages are not counted; the measurements below are why.

Tests

  • tests/test_memory.py (129 cases): 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. For the file cache, in v2 and v1: a 24 GiB limit with 20 GiB charged and 14 GiB of clean file cache yields 18 GiB instead of 4; file, cache and shmem are not credited; the other hierarchy's counter names are ignored; dirty and writeback pages are not room; the credit is applied at each ancestor and still clamped by the host; every malformed-statistics class subtracts nothing and never masks a malformed limit or usage file.
  • tests/moe/test_expert_bank_memory_admission.py (5 cases): 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, and keeps the parallel reader at 301 B; 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.
  • tests/moe/test_offload.py::test_host_ram_fits_parallel_sizes_subdirectory_shards (fix(models): discover safetensors shards from the index #819) faked /proc/meminfo by replacing builtins.open, which the shared resolver does not go through. It now sets the same 512 kB on the resolver; its fixture and assertion are unchanged.

These tests patch the new resolver, so they cannot run against main as written; the behavioural difference is the reproduction in the linked issue and the live read below — main's _host_ram_fits_parallel admits a load that needs 4608 MiB inside a 4096 MiB scope, this branch's admission drops to the serial build.

Validation

Environment: Ubuntu 24.04 (Linux 7.0.0, x86_64, AMD Ryzen 7 5800X, 16 threads, 121 GiB), cgroup v2, Python 3.12.3, torch 2.14.1+cpu, transformers 5.18.0, triton 3.8.0, flashlib 0.4.0, numpy 2.5.4, pytest 9.2.0; PYTHONPATH=python, no C++ extension, no flashinfer. No 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   # 134 passed
python -O -m pytest -q tests/test_memory.py tests/moe/test_expert_bank_memory_admission.py   # 134 passed
python -m pytest -q tests/        # 20 failed / 1885 passed / 1039 skipped; main (58b3ad7), same venv: 20 / 1751 / 1039, identical failure ids
ruff check <the seven files>      # 17 E741, the same 17 in the same three files on main
git diff --check origin/main..HEAD

The twenty failures are environment-bound on both trees: eleven tests/moe/test_cpu_moe_fp8_block.py cases and tests/kernels/test_swiglu_clamp.py::test_cpu_extension_supports_swiglu_clamp need the _cpu_moe C++ extension; tests/engine/test_cache_budget.py ×2 and tests/moe/test_offload.py ×1 need flashinfer; tests/models/deepseek_v41/test_engram.py::test_ftw_side_files_carry_the_tables_and_reopen needs the _row_store extension; the four tests/models/test_quant_config.py::test_scheme_for_agrees_with_the_stored_tensors cases probe model snapshots in this box's HF cache.

Can the tests fail? tests/test_memory.py run against the first commit's memory.py (clamp only) gives 28 failed / 101 passed, and against the file-cache commit before the dirty-page fix 10 failed / 119 passed. Eight single-line mutations of the admission path (guard removed, cgroup ignored, shard transient dropped, parallel reader never proposed, probe never fits, explicit override probed, figure not taken from the resolver, root glob instead of the shard index) each turn exactly the test written for it red.

Live read inside systemd-run --user --scope -p MemoryMax=4G -p MemorySwapMax=0 (Linux 7.0, cgroup v2, MGLRU on, ext4), calling the loader's _host_ram_fits_parallel on fixture checkpoints of sparse shards; one run per column:

main first commit (clamp only) this branch
figure the loader uses, empty scope 73876 MiB (host) 3834 MiB 3834 MiB
2 × 1.5 GiB shards, needs 4608 MiB admitted serial serial
figure after a 1 GiB file was written, synced and read back in the scope 74078 MiB (host) 2776 MiB 3800 MiB
2 × 1.0 GiB shards, needs 3072 MiB admitted serial admitted

Reclaim and dirty pages, measured on this memory.py before the rebase onto current main (the file is byte-identical here) and not repeated since. The same scope settings, effective_memory_available() with and without the file-cache rule:

state of the scope clamp only this branch
empty 4089 MiB 4089 MiB
2560 MiB file written, 604 MiB of it still dirty 1459 MiB 3415 MiB
the same after sync 1459 MiB 4020 MiB
plus 384 MiB on tmpfs 1074 MiB 3634 MiB

In the last state a 2358 MiB anonymous allocation (above the clamp-only figure, below this one) was touched and held with the cgroup's oom_kill at 0, and a 3938 MiB one was killed. With the file written and not synced, an allocation 300 MiB inside limit - (usage - inactive_file - active_file) was OOM-killed in 4 of 10 runs; with the file synced first, 0 of 10; sized about 300 MiB inside this branch's figure, 0 of 10. File pages another process keeps mapped and reading are counted as room: an allocation that needed them succeeded with oom_kill at 0 while the reader refaulted them from disk. The figure is an admission ceiling, not a promise that nothing gets evicted.

NOT VERIFIED: CUDA serving, a real model load, a Docker container on this head (the --memory=2g / --memory=6g probe reported earlier in this thread was on the first commit), a real cgroup v1 host (fixtures only), a kernel without MGLRU, hosted CI. memory.high is not read. File cache protected by a sibling's memory.min under a shared limit is counted although the kernel will not reclaim it.

Performance: not measured / not changed — bounded procfs and cgroup reads at admission time, one more small file per finite cgroup level.

Prepared with AI assistance; I reviewed the change and ran the validation above.

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

@Avicennasis

Copy link
Copy Markdown
Author

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 main (9535656). #418's quantization refactor rewrote expert_banks.py underneath this; the change itself is unchanged (a git range-diff against the previous head shows the import line moving and nothing else), and _host_ram_fits_parallel() still gates the parallel loader on the auto-pick path in load_expert_banks.

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 _legacy_expert_banks / _method_expert_banks, so they now stub _legacy_expert_banks — the seam load_expert_banks reaches when no quant method is bound, at the same positional argument.

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 PYTHONPATH=python since an editable install needs CUDA_HOME):

  • tests/test_memory.py + tests/moe/test_expert_bank_memory_admission.py: 47 passed, normally and under python -O.
  • Docker cgroup v2 probe against the rebased memory.py: --memory=2g → effective 2136227840, exactly memory.max − memory.current; --memory=6g → 6430826496, exact; unlimited → 57362182144 against a host MemAvailable of 57362636800 read milliseconds later.
  • Full pytest -q tests from the repo root: this branch 4 failed / 1308 passed / 427 skipped, against main at the same commit in the same venv: 4 failed / 1261 passed / 427 skipped. Identical failure sets — tests/engine/test_cache_budget.py ×2, tests/moe/test_offload.py ×1, and tests/kernels/test_swiglu_clamp.py::test_cpu_extension_supports_swiglu_clamp (the C++ extension is not built in this venv). The +47 is this PR's tests.

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 max asymmetry is as you say: v1 reports PAGE_COUNTER_MAX numerically and max is v2-only. Nobody on my side has a v1 host either, so that path stays covered by the fixture tests only.

MT-z added a commit to MT-z/FreeToken that referenced this pull request Sep 16, 2026
…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>
MT-z added a commit to MT-z/FreeToken that referenced this pull request Sep 16, 2026
…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.
JUNQINGV587 added a commit to JUNQINGV587/FreeToken that referenced this pull request Sep 26, 2026
Port of upstream PR FlashML-org#334: the admission check read host-wide MemAvailable,
so inside a --memory-limited container it could over-admit expert banks and
OOM. New torch-free python/freetoken/memory.py resolver combines MemAvailable
with the tightest finite cgroup v1/v2 budget (fail-closed); expert_banks,
benchbw and the load bench share it.
jomcgi added a commit to jomcgi-org/freetoken-fork that referenced this pull request Oct 1, 2026
* fix(engine): bound host memory governor by cgroup limits

The expert-tier governor derived pinned/pager budgets from /proc/meminfo
only, so under a container or systemd MemoryMax it could budget past what
the cgroup allows. Take min(host, cgroup headroom) and log which bound
applied. No change when memory.max is "max" (or no memory controller).

Ports the stdlib resolver and tests from FlashML-org/FreeToken#334
(Avicennasis, head de9df4a7): cgroup v2 ancestor walk, v1 fallback, fake
filesystem tests. Adds cgroup_memory_headroom() so the governor can report
the bound separately. Loader/bench call sites are not wired here.

Refs #22

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(engine): use cgroup limit for governor total, not headroom

Total is min(MemTotal, tightest memory.max); available stays
min(MemAvailable, limit - current), so the default reserve no longer
shrinks as the process allocates. Adds cgroup_memory_bounds() to
freetoken.memory returning (limit, headroom).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 3, 2026
…t's MemAvailable

Inside a container /proc/meminfo describes the host, so the parallel
expert reader was admitted against RAM the cgroup will not grant and the
load was OOM-killed without a diagnostic. available_host_memory() clamps
MemAvailable to the tightest cgroup v2/v1 limit along the ancestry and
is now used by the expert-bank loader, ft bench bw and the load bench.

Alternative to upstream PR FlashML-org#334: about a fifth of its size, and it counts
reclaimable page cache (inactive_file) as free like MemAvailable does,
instead of charging memory.current in full, which reads a container that
just read its checkpoint as having no room left.

Co-authored-by: Léon Avic Simmons <Avicennasis@gmail.com>
Assisted-by: Claude Opus 5.5
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 3, 2026
Logs the low-RAM loading batch pulled forward: FlashML-org#334, FlashML-org#233, FlashML-org#144 and FlashML-org#56.

Assisted-by: Claude Opus 5.5
@Avicennasis
Avicennasis force-pushed the fix/cgroup-memory-limits branch from 056b258 to f3c9982 Compare October 15, 2026 19:10
@Avicennasis

Copy link
Copy Markdown
Author

Rebased onto current main (0bca88f). git merge-tree named one conflicting file, python/freetoken/moe/benchbw.py: #662 (block-fp8 experts on the cpu and hybrid backends) added from freetoken.moe.bench_profile import PROFILE_VERSION at the import position where this PR adds from freetoken.memory import effective_memory_available. The resolution keeps both lines in module order; nothing else in the file was in conflict, and expert_banks.py and bench_load_weight_generic.py merged on their own under #601/#603/#662/#670/#689. git range-diff against the previous head (056b258) shows only that one import line moving; the diffstat is the same six files, +763/−42. main still carries benchbw.py's private cgroup-v2-only clamp (/sys/fs/cgroup/memory.max, line 162), which this PR replaces with the shared effective_memory_available(); after the rebase it is still the only /proc/meminfo or memory.max reader left outside freetoken/memory.py, and the branch has none.

Re-ran on a CPU-only Ubuntu 24.04 x86_64 box (AMD Ryzen 7 5800X, 16 threads, 125 GiB, no GPU; Python 3.12.3, torch 2.14.1+cpu, triton 3.8.0, transformers 5.16.1, flashlib 0.4.0, numpy 2.5.2, pytest 9.1.1; PYTHONPATH=python, no C++ extension, no flashinfer), in a venv built fresh against this main's pins (#630 and #669 moved torch, triton and flashlib since the last run):

  • tests/test_memory.py + tests/moe/test_expert_bank_memory_admission.py (both added by this PR): 47 passed in 29 s.
  • Full pytest -q tests from the repo root: this branch 20 failed / 1595 passed / 652 skipped (6:08), against main at 0bca88f in the same venv 20 failed / 1548 passed / 652 skipped (5:51). Identical failure sets (+47 passed is this PR's two test files), all environment-bound on this main: the eleven tests/moe/test_cpu_moe_fp8_block.py cases (feat(cpu-moe): serve block-fp8 experts on the cpu and hybrid backends #662) and tests/kernels/test_swiglu_clamp.py::test_cpu_extension_supports_swiglu_clamp need the _cpu_moe C++ extension, which is not built here; tests/engine/test_cache_budget.py ×2 and tests/moe/test_offload.py ×1 need flashinfer (Attention backend 'fi' requires flashinfer); tests/models/deepseek_v41/test_engram.py::test_ftw_side_files_carry_the_tables_and_reopen needs the _row_store extension; and the four tests/models/test_quant_config.py::test_scheme_for_agrees_with_the_stored_tensors cases probe model snapshots in this box's HF cache. No daemon flake this time.
  • ruff check on the six files: the six E741 (I) hits in benchbw.py are main's, shifted by the lines this PR removes; nothing new.

Nothing in the change touches a kernel or a model family; the clamp is read once, where the parallel reader and the two benches size their host-RAM work.

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.
The two loader tests replaced expert_banks._legacy_expert_banks and passed
dtype= to load_expert_banks. Both went away with the q4_0 provider (FlashML-org#690),
so on current main the tests stop at the monkeypatch (AttributeError)
before any admission code runs.

They now stand in for _method_expert_banks, the builder a raw checkpoint
reaches, pass a bound-method stand-in (kind NONE), and read the chosen
mode by parameter name from the real builder's signature instead of by
position. The download_hf_weight patch is dropped: the shard list now
comes from the loader's iter_weight_files, which resolves a local
directory itself.

The auto-admission test is two-sided: 299 bytes of cgroup room against
the 300 the banks and one shard need gives the serial build, 301 keeps
the parallel reader. With only the serial half, a loader that never
proposed the parallel reader would pass without reaching the probe.
…resolver

test_host_ram_fits_parallel_sizes_subdirectory_shards (FlashML-org#819) fakes
/proc/meminfo by replacing builtins.open. _host_ram_fits_parallel no
longer opens that file itself: it asks effective_memory_available(),
which reads through pathlib, so the fake is never consulted, the probe
sees the machine's real figure and the test fails on any host with more
than 3 MiB available.

The test's subject is which shards get sized, not how the figure is
read, so it now sets the same 512 kB at the name the probe calls. The
fixture and the assertion are unchanged.
The oversized fixture was 65 KiB of "x", which the line parser rejects
on its own, so the case passed with the 64 KiB bound removed. Use
70,000 bytes of distinct well-formed counters instead.
The file LRU lists include pages that are dirty or under writeback. They
cannot be dropped until written, and a cgroup at its limit is OOM-killed
before that finishes. Take file_dirty + file_writeback (v1: total_dirty +
total_writeback) back out of the file-cache credit, clamped at zero.
@Avicennasis
Avicennasis force-pushed the fix/cgroup-memory-limits branch from f3c9982 to 323fc93 Compare November 13, 2026 07:09
@Avicennasis

Copy link
Copy Markdown
Author

Restacked onto current main (58b3ad7), 109 commits past the previous base (0bca88f), and the file-cache follow-up I had been holding back is now on top, so this is six commits instead of one. The previous head was f3c9982. The description is updated to match.

The restack. The commit rebased without a conflict; git range-diff shows two context lines changing under it and nothing else. But a clean merge was not a green one: on current main the commit by itself turns three tests red that are not red on main (full suite, same environment: 23 failed against 20).

  • This PR's two loader tests replaced expert_banks._legacy_expert_banks and passed dtype= to load_expert_banks. feat(gguf): serve GGUF checkpoints on vendored ggml-cuda kernels #690 removed both, so the tests stopped at the monkeypatch with AttributeError before any admission code ran. They now stand in for _method_expert_banks, the builder a raw checkpoint reaches, and read the chosen mode by parameter name from the real builder's signature instead of by position. The auto-admission test is two-sided now: 299 bytes of cgroup room against the 300 the banks and one shard need gives the serial build, 301 keeps the parallel reader. With only the serial half, a loader that never proposed the parallel reader would have passed without reaching the probe.
  • tests/moe/test_offload.py::test_host_ram_fits_parallel_sizes_subdirectory_shards (fix(models): discover safetensors shards from the index #819) fakes /proc/meminfo by replacing builtins.open. With this PR _host_ram_fits_parallel asks effective_memory_available(), which reads through pathlib, so the fake is never consulted and the test fails (assert not True) on any host with more than 3 MiB available. I changed that test to set the same 512 kB at the name the probe calls; its fixture and assertion are untouched. It is the one edit here to a file this PR did not add or already change. If you would rather keep that test as written, the alternative is to make memory.py read /proc/meminfo in a way the test can intercept; the test change is its own commit (a51a212), so it comes off without touching the rest.

Eight single-line mutations of the admission path (guard removed, cgroup ignored, shard transient dropped, parallel reader never proposed, probe never fits, explicit override probed, figure not taken from the resolver, root glob instead of the shard index) each turn exactly the test written for it red.

None of the 109 commits changed the arithmetic this PR feeds. _host_ram_fits_parallel still ends in avail > sum(sizes) + max(sizes); #819 changed where the shard list comes from, and this PR changes only where avail comes from. benchbw.py still carries the private cgroup-v2-only clamp this PR removes, and still halves the figure the same way. One new reader of /proc/meminfo has appeared outside the package since the last rebase, the wait loop in scripts/gguf_check/check.py (#756); I left it alone.

File cache counted as room (the last three commits). In September MT-z checked the obvious worry with a tighter budget, that the parallel loader stops being admitted, and did not hit it. There is one common case where it does happen: cgroup usage includes page cache, so once a checkpoint has been read inside the container or scope, the next start sees its size as spent. MemAvailable, the other half of the minimum, already counts page cache. At every finite level the resolver now reads memory.stat and uses

room  = max(0, limit - max(0, usage - cache))
cache = max(0, inactive_file + active_file - file_dirty - file_writeback)

with the hierarchical total_* counters on cgroup v1. file, cache and shmem are not used, because file includes tmpfs, which sits on the anon lists. Dirty and writeback pages are taken back out: they cannot be dropped until they are written. Statistics that are absent, unreadable, oversized or malformed disable the whole subtraction; a malformed limit or usage file still fails closed to 0. The rule is adapted from JustVugg/colibri #2397 (Apache-2.0 on both sides, no code copied), minus the dirty pages.

One live read per column, inside systemd-run --user --scope -p MemoryMax=4G -p MemorySwapMax=0 (Linux 7.0, cgroup v2), calling the loader's _host_ram_fits_parallel on fixture checkpoints of sparse shards:

main 58b3ad7 first commit (clamp only) this head
figure the loader uses, empty scope 73876 MiB (host) 3834 MiB 3834 MiB
2 × 1.5 GiB shards, needs 4608 MiB admitted serial serial
figure after a 1 GiB file was written, synced and read back in the scope 74078 MiB (host) 2776 MiB 3800 MiB
2 × 1.0 GiB shards, needs 3072 MiB admitted serial admitted

Gate. CPU-only Ubuntu 24.04 x86_64 box (AMD Ryzen 7 5800X, 16 threads, 121 GiB, no GPU; Python 3.12.3, torch 2.14.1+cpu, triton 3.8.0, transformers 5.18.0, flashlib 0.4.0, numpy 2.5.4, safetensors 0.8.0, pytest 9.2.0; PYTHONPATH=python, no C++ extension, no flashinfer; huggingface_hub 2.4.0 and pillow 13.0.0 in this venv sit above the declared ranges, the same venv for both trees):

  • tests/test_memory.py + tests/moe/test_expert_bank_memory_admission.py: 134 passed (129 + 5). Against the first commit's memory.py, tests/test_memory.py gives 28 failed / 101 passed; against the file-cache commit before the dirty-page fix, 10 failed / 119 passed.
  • tests/moe/test_offload.py: 1 failed / 21 passed / 30 skipped on both trees, the same test (it needs flashinfer).
  • Full pytest -q tests from the repo root: this branch 20 failed / 1885 passed / 1039 skipped (2944 collected, 1:34), against main at 58b3ad7 in the same venv 20 failed / 1751 passed / 1039 skipped (2810 collected, 1:50). Identical failure sets by test id and by failure message; +134 passed is this PR's two test files. All twenty are environment-bound, the same kinds as in October: the eleven tests/moe/test_cpu_moe_fp8_block.py cases and tests/kernels/test_swiglu_clamp.py::test_cpu_extension_supports_swiglu_clamp need the _cpu_moe C++ extension; tests/engine/test_cache_budget.py ×2 and tests/moe/test_offload.py ×1 need flashinfer; tests/models/deepseek_v41/test_engram.py::test_ftw_side_files_carry_the_tables_and_reopen needs the _row_store extension; and the four tests/models/test_quant_config.py::test_scheme_for_agrees_with_the_stored_tensors cases probe model snapshots in this box's HF cache.
  • ruff check on the seven files: 17 E741 hits, the same 17 in the same three files on main; nothing new. ruff format --check would reformat tests/test_memory.py; the repository declares no formatter, so I left it.

Not exercised here: CUDA, a real checkpoint or model load, a Docker container on this head, a real cgroup v1 host (fixtures only), a kernel without MGLRU, hosted CI. The reclaim and dirty-page measurements in the description were taken before this rebase on the same memory.py (byte-identical here) and were not repeated today. memory.high is still not read, and file cache protected by a sibling's memory.min under a shared limit is counted although the kernel will not reclaim it.

This branch has not been deployed

No deployments
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