Skip to content

fix(cuda): charge the expert tier real VRAM, not logical bytes (#687) - #1244

Open
Unknown-Findout wants to merge 4 commits into
JustVugg:devfrom
Unknown-Findout:fix/687-expert-sizing
Open

fix(cuda): charge the expert tier real VRAM, not logical bytes (#687)#1244
Unknown-Findout wants to merge 4 commits into
JustVugg:devfrom
Unknown-Findout:fix/687-expert-sizing

Conversation

@Unknown-Findout

Copy link
Copy Markdown
Contributor

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.741 is "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

cudaMalloc rounds a request up, and nothing charged the difference. Measured on an RTX 3090 (sm_86, CUDA 13.1):

request              actual      overhead
0.75 MiB          1.00 MiB        +0.25     <- int4-g64 scale array
1.00 MiB          1.00 MiB        +0.00
1.00 MiB + 1 B    2.00 MiB        +1.00
1.50 MiB          2.00 MiB        +0.50
2.00 MiB          2.00 MiB        +0.00
2.00 MiB + 1 B    4.00 MiB        +2.00
6.00 MiB          6.00 MiB        +0.00     <- int4-g64 weight tensor

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.

3 x 0.25 MiB = 0.750 MiB per expert

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

The tier now decrements remaining by 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_bytes is 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. Only remaining moves, because only remaining is a budget.

Two implementations that did not survive measurement

Both are in the source comments so nobody rebuilds them.

cudaMemGetInfo per placed expert is the obvious exact answer. It is O(live allocations):

live allocations   us/call
0                    0.52
1,000                2.64
5,000               10.80
12,000              68.20

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 cudaMalloc reserves a whole 2 MiB VMM page, so one allocation of anything smaller reads as a flat 2 MiB:

786,432 B, n=1    2.00 MiB   <- the page, not the allocation
786,432 B, n=64   1.00 MiB   <- the amortised truth

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:

  • an aligned 6 MiB request must be charged exactly 6 MiB. Without this the suite passes on a function that just doubles everything - exactly what the first draft did.
  • 500 repeat calls must move free VRAM by 0 bytes, checked by reading free VRAM rather than by timing.

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-test on sm_86, MAKE_EXIT=0, all eight:

backend_cuda        q8/q4/q2/f32/e8 correctness ok on 1 device(s)
ragged_attention    ok
fp8_warp            shared-lut 0, hw-cvt 0, mutated entry 1 mismatches (fires)
absorb_determinism  batch_differing=0/145  ragged_differing=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 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.

Unknown-Findout and others added 4 commits August 29, 2026 03:33
…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>
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