Skip to content

Size the automatic budget against the cgroup limit, not the host's RAM - #15

Merged
marcobambini merged 3 commits into
mainfrom
fix/cgroup-aware-budget
Aug 2, 2026
Merged

Size the automatic budget against the cgroup limit, not the host's RAM#15
marcobambini merged 3 commits into
mainfrom
fix/cgroup-aware-budget

Conversation

@marcobambini

@marcobambini marcobambini commented Aug 2, 2026

Copy link
Copy Markdown
Member

Fixes the half of #14 that is unambiguously a bug. #14 stays open for the pressure question.

The bug

waste_physical_ram() on Linux is sysconf(_SC_PHYS_PAGES) * sysconf(_SC_PAGESIZE), which reads the host's MemTotal — and reads exactly the same thing from inside a cgroup allowed a fraction of it. Every containerized run has been sizing the automatic budget against RAM it was never going to be given. A 32 GiB cgroup on a 256 GiB host sees a 224 GiB ceiling, resolves K3 at floor + 3x, and asks for 80.64 GB of a 32 GiB allowance.

This is not §16 and does not behave like it. That cliff is a performance failure with a shape — the hit rate climbs, the bytes read fall, throughput drops eightfold, and a sweep finds it. A cgroup limit is a kill: nothing degrades first and no cache policy softens it. It is the same class of bug as §27, a platform path that every green run had avoided rather than exercised. The repo ships Dockerfile.test, so it is a path we already use.

What changed

The ceiling is min(physical, cgroup limit). Everything downstream — the 7/8 reserve, the whole-working-set stepping, the floor refusal — is untouched, and so is every non-Linux host.

  • src/memory.c takes the smallest finite memory.max or memory.high across this cgroup and its ancestors. The limit is hierarchical, so a leaf saying max does not cancel a finite parent; memory.high belongs there because a group the kernel reclaims from is a group whose expert cache it takes back, which is §16's mechanism arriving by another road.
  • The ancestor walk reads every level from the composed path up to and including the mounted root. There are three shapes in the wild and they are not the same: docker run's default private namespace (0::/, limit in the root — the last read is the only one that fires); --cgroupns=host (0::/docker/<id>, which does exist because the host hierarchy is mounted, while the root has no memory.max); and a runtime mounting only the leaf, the mirror image. Reading all of them is what makes the reader not need to know which one it is. An unconfined host is still 0 — a real unified root has no memory.max.
  • New waste_usable_ram(), and waste plan --json reports usable_ram_bytes beside physical_ram_bytes. Without it a JSON reader in a container computes a ceiling the engine never used and concludes the engine ignored its own rule. tests/run.sh now asserts the budget rule against that figure — it is a capacity, so it is the same in the plan process and the info one, which a pressure reading would not have been.

What deliberately did not change

MemAvailable and memory.current are the obvious next reading and they are a different kind of number. Capacity is fixed for the life of the process; pressure moves between the read and the allocation. A budget is resolved once at waste_open and held for a whole run, so bounding it by an instantaneous sample would make the same command on the same machine two different runs — and a host that is busy now says nothing about a host that will be busy in ten minutes. Whether pressure should trim the working-set multiplier instead is #14, still open.

No new error code either: budget < floor_bytes → WASTE_E_RAM_BUDGET already meant "this machine cannot hold the floor", and it now means it in a cgroup too.

No #if __linux__

Nothing in the reader is a Linux API — it is fopen and strtoull over paths the caller supplies. Guarding it would mean the only platform that runs it is the only platform that never compiles it anywhere else, which is exactly §27. On a host with no cgroups the two paths do not open and the answer is 0, for the price of two failed fopen() calls once per waste_open.

So test_memory runs the whole policy on every host in milliseconds: the hierarchy cases, a leaf at max under a finite parent, memory.high, the namespace mismatch, /proc/self/cgroup unreadable, malformed telemetry, and a path that tries to walk out of the root it was given.

Verification

In a real cgroup. docker run --memory=6g, on a host reporting 8,319,213,568 bytes of RAM:

physical_ram_bytes  8319213568
usable_ram_bytes    6442450944   <- the limit, exactly

The suite's own budget check inside that cgroup reports usable 6.00 GB, which is the resolver consuming the number rather than the reader merely reading it. Full suite on Linux there: 24 passed, 0 failed, 16 skipped (every skip is a missing container or K3).

Both namespace modes, because they fail differently and that is the case for ending the walk on the mounted root:

mode /proc/self/cgroup composed path exists? root has memory.max? usable_ram_bytes
private (default) 0::/ n/a — is the root yes 6 GiB, exact
--cgroupns=host 0::/docker/<id> yes, host hierarchy no 5 GiB, exact

The second row also confirms what the fallback rests on: a real unified root carries no memory.max, so an unconfined host still reads 0.

The choice changes, not just the reading. On the synthetic container (floor 8,844,904, recommended 9,139,816) a 12 MiB cgroup holds floor + 3x and opens silently; a 9 MiB one puts the ceiling under the floor, so the engine runs at the floor and warns. Before this branch both read 8.32 GB and had nothing to say.

On macOS, tests/run.sh with K3 and Kimi-Linear on disk: 42 passed, 0 failed, 1 skipped, and the K3 default budget is unchanged (46.25 GB held, ceiling 46.25 GB, usable 64.00 GB) — usable == physical outside a cgroup, which is the point. Serve suite 44 OK. Both new files cross-compile clean for Windows with x86_64-w64-mingw32-gcc -Wall -Wextra.

Still not a throughput row. The K3 case that motivates the change — 80.64 GB asked of a 32 GiB allowance — is derived from the resolver's own rule, not run.

Adjacent, on purpose

GATES.md Gate 7 is open over this same resolver, but it asks about the quantum. This changes the ceiling and leaves the quantum alone.

Bumps to 0.6.3 with the CHANGELOG entry in the same commit, since a new exported symbol is what WASTE_VERSION_NUMBER exists to gate. Happy to drop that if you would rather batch the release.

🤖 Generated with Claude Code

marcobambini and others added 3 commits August 2, 2026 10:33
The resolver had one machine number in it and on Linux it was the wrong
one. waste_physical_ram() is sysconf(_SC_PHYS_PAGES) there, which reports
the host's MemTotal and reports exactly the same thing from inside a
cgroup allowed a fraction of it. Every containerized run has therefore
been sizing against RAM it was never going to get: K3 in a 32 GiB cgroup
on a 256 GiB host resolves floor + 3x, asks for about 80 GB, and is
killed.

This is not §16 and does not behave like it. That cliff is a performance
failure with a shape — hit rate climbs, bytes read fall, throughput drops
eightfold, and a sweep finds it. A cgroup limit is a kill: nothing
degrades first and no cache policy softens it. It is the same class of
bug as §27, a platform path every green run had avoided rather than
exercised.

So the ceiling is min(physical, cgroup limit) and everything downstream —
the 7/8 reserve, the whole-working-set stepping, the floor refusal — is
untouched. src/memory.c takes the smallest finite memory.max or
memory.high across this cgroup and its ancestors, since the limit is
hierarchical and a leaf saying "max" does not cancel a finite parent, and
a group the kernel reclaims from is a group whose expert cache it takes
back. The walk ends on the mounted root rather than one level short,
which is what covers `docker run` without a private cgroup namespace:
/proc/self/cgroup names a host path that does not exist under the mount,
every composed directory misses, and the limit that applies is in the
root itself.

Current pressure is deliberately left out. MemAvailable and
memory.current are the obvious next reading and they are a different kind
of number — capacity is fixed for the life of the process, pressure moves
between the read and the allocation. A budget is resolved once at
waste_open and held for a whole run, so bounding it by an instantaneous
sample would make the same command on the same machine two different
runs. Whether it should trim the multiplier instead is #14, still open,
and the proposal there is what found this.

The reader takes its paths as parameters, so there is no #if __linux__ in
it: nothing it does is a Linux API, and guarding it would mean the only
platform that runs it is the only platform that never compiles it
anywhere else. test_memory therefore runs the whole policy on every host
in milliseconds — the hierarchy cases, the namespace mismatch, malformed
telemetry, and a path that tries to walk out of the root it was given.

Not measured, and not a throughput row: this is arithmetic on a number
that was provably the wrong one. The synthetic hierarchy is the coverage;
no run inside a real cgroup was made for this commit.

Closes nothing — #14 stays open for the pressure question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit's method note said nothing had been measured, which
was true when it was written and is not now. Docker came up on the second
try, so the whole thing has been run where it fires.

`docker run --memory=6g` on a host reporting 8,319,213,568 bytes:
usable_ram_bytes comes back 6,442,450,944, the limit exactly. The suite's
own budget check inside that cgroup reports "usable 6.00 GB", which is the
resolver consuming the number rather than the reader merely reading it —
24 passed, 0 failed, 16 skipped on Linux there.

Both cgroup namespace modes were exercised, and they fail differently,
which is the case for ending the walk *on* the mounted root rather than
one level short. Private namespace: /proc/self/cgroup is 0::/ and the
limit is in the root. --cgroupns=host: /proc/self/cgroup is
0::/docker/<id>, that path does exist on the mounted host hierarchy, and
/sys/fs/cgroup/memory.max does not — which is the assumption the fallback
rests on, that a real unified root carries no limit and an unconfined host
still reads 0. Both return the limit exactly.

The choice changes and not only the reading: on the synthetic container a
12 MiB cgroup holds floor + 3x and opens silently, a 9 MiB one puts the
ceiling under the floor so the engine runs at the floor and says so.
Before this branch both read 8.32 GB and had nothing to say.

Still not a throughput row, and the K3 case that motivates the change —
80.64 GB asked of a 32 GiB allowance — is derived from the resolver's own
rule rather than run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment on the ancestor walk described one variant as though it were
the general case: /proc/self/cgroup naming a path that does not exist
under the mount. Running it found that is the third shape, not the first.
The docker default is a private namespace, 0::/ with the limit in the
root; --cgroupns=host reads 0::/docker/<id> and that path *does* exist,
because the host hierarchy is mounted, while the root has no memory.max
at all. A runtime mounting only the leaf gives the mirror image.

The walk was already right for all three — reading every level from the
composed path up to and including the root is what makes it not need to
know which one it is. Only the explanation was wrong, and it was wrong in
the direction of sounding measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marcobambini
marcobambini merged commit 6931570 into main Aug 2, 2026
9 checks passed
@marcobambini
marcobambini deleted the fix/cgroup-aware-budget branch August 2, 2026 08:54
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.

1 participant