Size the automatic budget against the cgroup limit, not the host's RAM - #15
Merged
Conversation
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>
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the half of #14 that is unambiguously a bug. #14 stays open for the pressure question.
The bug
waste_physical_ram()on Linux issysconf(_SC_PHYS_PAGES) * sysconf(_SC_PAGESIZE), which reads the host'sMemTotal— 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 atfloor + 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.ctakes the smallest finitememory.maxormemory.highacross this cgroup and its ancestors. The limit is hierarchical, so a leaf sayingmaxdoes not cancel a finite parent;memory.highbelongs 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.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 nomemory.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 nomemory.max.waste_usable_ram(), andwaste plan --jsonreportsusable_ram_bytesbesidephysical_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.shnow asserts the budget rule against that figure — it is a capacity, so it is the same in theplanprocess and theinfoone, which a pressure reading would not have been.What deliberately did not change
MemAvailableandmemory.currentare 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 atwaste_openand 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_BUDGETalready 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
fopenandstrtoullover 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 failedfopen()calls once perwaste_open.So
test_memoryruns the whole policy on every host in milliseconds: the hierarchy cases, a leaf atmaxunder a finite parent,memory.high, the namespace mismatch,/proc/self/cgroupunreadable, 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: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:
/proc/self/cgroupmemory.max?usable_ram_bytes0::/--cgroupns=host0::/docker/<id>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 + 3xand 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.shwith 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 == physicaloutside a cgroup, which is the point. Serve suite 44 OK. Both new files cross-compile clean for Windows withx86_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_NUMBERexists to gate. Happy to drop that if you would rather batch the release.🤖 Generated with Claude Code