fix(cuda): charge the expert tier real VRAM, not logical bytes (#687) - #1244
Open
Unknown-Findout wants to merge 4 commits into
Open
fix(cuda): charge the expert tier real VRAM, not logical bytes (#687)#1244Unknown-Findout wants to merge 4 commits into
Unknown-Findout wants to merge 4 commits into
Conversation
…ugg#687) The auto tier decremented `remaining` by coli_cuda_tensor_bytes(), which is the LOGICAL size of an expert. The allocator takes more than that, and nothing charged the difference, so the budget drifted optimistic by a term that grew with the tier. That is why auto could claim a card to within 4 MiB and then fail every lazy dense upload afterwards while reading as healthy. THE MECHANISM. It is not workspace overhead. cudaMalloc rounds a request up, and a GLM-5.2 int4-g64 expert is six allocations of two sizes: 3x weights 6,291,456 B = 6.00 MiB already on a boundary, +0 3x scales 786,432 B = 0.75 MiB lands in 1.00 MiB, +0.25 each 0.75 MiB per expert uncounted. Measured here on sm_86; @terrizoaguimor measured 0.741 MiB/expert (sigma 0.019, five clean configurations) on H100/H200 in JustVugg#687 from the other direction, and 0.750 sits inside that interval. At the 6,235 experts auto selects on an H200 that is 4.6 GB against a flat 2 GB reserve. That reframes the fix. The conclusion in JustVugg#687 was that 0.741 is model-and-card specific and should be measured live from the first uploads rather than frozen into the source. The distrust of the constant was right and the reason was not: the term is roundup(scale_bytes) - scale_bytes, which is a property of the allocator and the model geometry, so it is knowable before a single expert is placed. No fixed-point iteration and no measurement pass over real uploads. coli_cuda_alloc_footprint() probes it, once per distinct size, cached. It is PROBED rather than modelled on purpose: the rounding is a driver and architecture property and a table fitted to one card would be silently wrong on the next. coli_cuda_tensor_vram() applies it per ALLOCATION, since the weights and the scales are separate cudaMallocs and each is rounded on its own - summing first and rounding once would miss the scale array's padding entirely, which is the whole term. coli_cuda_tensor_bytes() is untouched and still logical: it mirrors upload and free so the three cannot drift, and there is a test pinning that. m->gpu_expert_bytes also stays logical, because it is reported to the user as the tier's size and quoting allocator padding as model bytes would trade one wrong number for another. Only `remaining` moves. TWO IMPLEMENTATIONS THAT DID NOT SURVIVE MEASUREMENT, both recorded in the source so nobody rebuilds them: cudaMemGetInfo per placed expert is the obvious exact answer and it is O(live allocations) - 0.52 us empty, 68.2 us with 12,000 live. A tier that places thousands would make that quadratic. A single-allocation probe over-reports by 3x. One cudaMalloc reserves a whole 2 MiB VMM page, so one allocation of anything smaller reads as a flat 2 MiB: 786,432 B measured 2.00 MiB at n=1 and 1.00 MiB at n=64. That version would have shrunk the tier worse than the bug it fixes. Its own test caught it. Verified on Windows, RTX 3090 (sm_86), CUDA 13.1, MSVC 19.50. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight assertions against the LIVE allocator rather than against a rounding table, because the rounding is a driver and architecture property and a test that hardcoded one card's numbers would fail on the next. The load-bearing one is "footprint never below the request". The whole bug was a budget charged less than reality, so a footprint that under-reports would reintroduce it somewhere new. Two are negative controls, and they are the reason this suite means anything: an aligned 6 MiB request must be charged EXACTLY 6 MiB. Without it the suite passes on a function that simply doubles everything, which is precisely the failure the first draft had. 500 repeat calls must move free VRAM by 0 bytes. Checked by reading free VRAM rather than by timing, which would be flaky under load. The probe is also cross-checked against an independent bulk measurement in the same run, so the function and the ground truth cannot drift together. Wired in at the END of cuda-test: it is the only test here that allocates in bulk to measure an amortised cost, so it should not leave the card fragmented underneath a kernel test that follows it. Full run on RTX 3090 (sm_86), CUDA 13.1, MAKE_EXIT=0, all eight binaries: backend_cuda q8/q4/q2/f32/e8 correctness ok ragged_attention ok fp8_warp shared-lut 0, hw-cvt 0, mutated entry 1 (fires) absorb_determinism batch 0/145, ragged 0/29 fp8_cuda oracle 0 mismatches, API 0 mismatches weights_owned 8 fail cycles, 0 bytes cumulative mxfp4 ok alloc_footprint ok, per-expert unaccounted 0.7500 MiB Python suite unchanged at 696 tests, OK, skipped=52. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught this: on Windows the host does not link backend_cuda.cu, it links backend_loader.c and resolves coli_cuda_* out of coli_cuda.dll at runtime. Every exported symbol is hand-wrapped there, and I added two without wrapping them, so `make colibri CUDA_DLL=1` failed to link: undefined reference to `coli_cuda_tensor_vram' (x6 call sites) RESOLVE_OPT, not RESOLVE, and that distinction is the whole point. RESOLVE prints "missing symbol" and calls FreeLibrary, so pairing a new host with a coli_cuda.dll built before JustVugg#687 would take the ENTIRE CUDA backend down over one absent function - far worse than the over-commit this branch fixes. The e8_set_grid and fp8_set_lut entries above it set that precedent for exactly this reason and carry the same comment. The wrappers degrade to the old behaviour rather than to zero: tensor_vram -> tensor_bytes, the LOGICAL size, which is what the tier charged before this branch. An under-count, and identical to what shipped. Returning 0 would be much worse: the caller would charge nothing at all for an expert it just placed. alloc_footprint -> the request unchanged, i.e. no padding, same as before. Verified locally on the same two commands the CI job runs: make colibri CUDA_DLL=1 exit 0 (was: undefined ref) make cuda-dll CUDA_ARCH=sm_80 exit 0 and both symbols are in the DLL's PE export table beside tensor_bytes: [ 0] coli_cuda_alloc_footprint [47] coli_cuda_tensor_bytes [53] coli_cuda_tensor_vram Negative control run rather than assumed: reverting backend_loader.c alone reproduces the six undefined references, restoring it links clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_abi_is_derived_from_the_loader_source pins the loader's export surface by count, and it caught this branch: 5 optional against an asserted 3. The test's own docstring says what to do - update the counts and name the symbol you added, so the next person reading a failure gets a reason rather than a different integer - so both, and the reason is the RESOLVE_OPT choice rather than the symbols themselves. mandatory 47 -> 47 unchanged, neither symbol is required optional 3 -> 5 exports 50 -> 52 Mandatory is the number that matters here and it deliberately did not move. coli_cuda_tensor_vram and coli_cuda_alloc_footprint are resolved with RESOLVE_OPT, so a coli_cuda.dll built before JustVugg#687 leaves both pointers NULL and the wrappers fall back to the logical byte count - exactly what the expert tier charged before this branch. Adding them as mandatory would have widened the contract every Windows DLL must satisfy and taken the entire CUDA backend down on any older DLL, over a sizing refinement. Python suite 707 tests, OK, skipped=50. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unknown-Findout
force-pushed
the
fix/687-expert-sizing
branch
from
August 29, 2026 08:40
4c9549c to
4967cde
Compare
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.
Closes the sizing half of #687.
@terrizoaguimor measured an unaccounted 0.741 MiB per resident expert (sigma 0.019, five clean configurations, H100 and H200) and concluded it was a fixed workspace cost the 2 GB reserve does not cover, adding that
0.741is "this model on this card and not a constant worth freezing into the source" and that it should be measured live from the first uploads instead.The distrust of the constant was right. The reason was not, and that makes the fix simpler than proposed.
What the term actually is
cudaMallocrounds a request up, and nothing charged the difference. Measured on an RTX 3090 (sm_86, CUDA 13.1):A GLM-5.2 int4-g64 expert is six allocations of two sizes: three 6 MiB weight arrays, already on a boundary and costing nothing extra, and three 0.75 MiB scale arrays, each padded to 1 MiB.
Against 0.741 sigma 0.019, so inside the interval, from an independent direction and a different architecture.
So the term is
roundup(scale_bytes) - scale_bytes. It is a property of the allocator and the model geometry, knowable before a single expert is placed - no fixed-point iteration, and no measurement pass over real uploads. It also predicts what a different group size or hidden dim would cost, which a fitted constant cannot.The change
coli_cuda_alloc_footprint(bytes)probes the real amortised cost, once per distinct size, cached. Probed rather than modelled on purpose - the table above is not a rule this hardcodes, since the rounding is a driver and architecture property and a formula fitted to one card would be silently wrong on the next.coli_cuda_tensor_vram(t)applies it per allocation. The weights and the scales are separatecudaMallocs and each is rounded on its own; summing first and rounding once would miss the scale array's padding entirely, which is the whole term.The tier now decrements
remainingby VRAM.Two byte counts stay logical, and that separation is the design:
coli_cuda_tensor_bytes()is the LOGICAL size and remains so. Its own comment requires it to mirror upload and free exactly so the three cannot drift, and a test pins that. VRAM is a different question, so it gets a different function rather than a changed meaning for this one.m->gpu_expert_bytesis also logical. It is what gets reported as the tier's size, and quoting allocator padding to the user as model bytes would trade one wrong number for another. Onlyremainingmoves, because onlyremainingis a budget.Two implementations that did not survive measurement
Both are in the source comments so nobody rebuilds them.
cudaMemGetInfoper placed expert is the obvious exact answer. It is O(live allocations):A tier placing 6,235 experts holds 18,705 allocations, so charging it per expert is quadratic.
A single-allocation probe over-reports by 3x. One
cudaMallocreserves a whole 2 MiB VMM page, so one allocation of anything smaller reads as a flat 2 MiB:That version shipped in my first draft and would have shrunk the tier worse than the bug it fixes. Its own test caught it, which is the reason the negative controls below are there.
Verification
Eight assertions in
tests/test_alloc_footprint_cuda.cu, against the live allocator rather than a table. Two are negative controls:The probe is also cross-checked against an independent bulk measurement in the same run, so the function and its ground truth cannot drift together.
Wired in at the end of
cuda-test: it is the only test there that allocates in bulk to measure an amortised cost, so it should not leave the card fragmented underneath a kernel test that follows it.Full
make cuda-teston sm_86,MAKE_EXIT=0, all eight:Python suite
Ran 696 tests, OK, skipped=52. CPU-only build compiles clean, so the non-CUDA path is unaffected.What this does not do
It does not touch the evict-and-retry half (#696), and it does not explain why a rescued tensor computes a different answer than both the healthy GPU run and the CPU fallback. That is a separate defect and this does not go near it.
Tested on Windows 11, RTX 3090 (sm_86), CUDA 13.1 V13.1.115, MSVC 19.50.35725, GNU Make 4.4.1.