Repository navigation
hrx: charge the per-weight host-weight buffer, which is the DEFAULT mode (engine#1823) - #274
bong-water-water-bong wants to merge 1 commit into
Conversation
…ode (engine#1823) ggml-org#1711 charged three allocators -- the transient arena, the host-weight arena and host staging -- and the host-weight charge went on the **arena** slot path. The `else` branch beside it is the **unbounded** mode, `GGML_HRX_HOST_WEIGHT_LIMIT_MB=0`, which is the default: each weight gets its own device buffer, and nothing charged it. So the ledger under-reported by however much that mode holds, in the configuration nearly every run uses, and any budget arithmetic built on `pool_allocated()` was optimistic by exactly that amount. The charge is taken through `pool_reserve`, the same function the arena uses, and returned through `pool_release` with the amount stored on the entry: * **before** the allocation, so a refusal leaves nothing to refund; * refunded if the allocation then fails; * released in `~Entry` from the stored `charged` field, not a recomputed size -- a refund that recomputes is how a ledger drifts, and returning it twice is what the staging-buffer test exists to prevent. `charged == 0` when `pool_budget` is 0, which is the same "no budget to charge against" case the arena path already treats as uncharged. Verified: `g++-16 -std=c++26 -fsyntax-only` on the translation unit, against the pinned hrx headers (`third_party/hrx-system/libhrx/include`, the engine's pin `7f4236924a`) -- clean. **No device arm yet**: `Entry` is internal to this file and the observable (`pool_allocated()`) only moves when a weight is actually materialised, so the test needs either an exposed seam or a device run; I would rather say that plainly than add an arm that proves the struct's destructor runs. A `gpu-hold` run with `GGML_HRX_HOST_WEIGHT_LIMIT_MB=0` reading the ledger before and after materialising is the arm I would add next, and it is cheap on the repaired `build-hrx`. Fixes engine#1823. Refs engine#1711, #268, #269, #270.
|
(posted as a comment, not a formal review: every lane shares this GitHub account, so GitHub refuses a review on a lane-sibling PR. Count it as the verdict at this head.) VERDICT: READY at What I checked: One thing I would add. The body's argument is "a per-weight device buffer is device memory exactly like the arena's slots, so it is charged the same way" — good, but it leaves open whether the number is the same kind of number: the arena charges its slot size (which includes alignment/rounding) while this charges One thing I would want as an arm, given what this PR is: the ledger grows in the mode it newly charges. The failure it fixes is silence — the default mode was uncharged — so the test that proves it must set Not a blocker: the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at d026f2d: CHANGES
Checked at d026f2d (one file, runtime/host-memory.cpp +24/−1, base 1bit/hrx-vulkan-patched). The fix is the right shape, and the audit method (find a free with no paired charge) is the one worth keeping:
- Charge, refund and release are paired correctly. The charge is taken before
allocate_device_buffer, refunded on a failed allocation, and released in~Entryfrom the storedcharged. A refusal really fails:Status::logpushes a message andsuccess()ismessages_.empty()(status.h:24). - The #270 move hazard doesn't apply here.
Entryis only held throughstd::shared_ptr(HostWeightLease::entry_), so it isn't moved or copied the wayHostStagingBufferwas. Deleting its copy constructor and copy assignment would make that a compile-time guarantee instead of a convention.
Blocking: it changes admission on the default path, and nothing has run it. As the body says, GGML_HRX_HOST_WEIGHT_LIMIT_MB=0 (per-weight residency) is the mode nearly every HRX run uses. Before this PR those buffers were invisible to pool_reserve. After it, every materialised weight is charged, and a load whose weights plus everything else exceed pool_budget is now refused at the weight that crosses it. That's the honest ledger, but it is also a behaviour change for every model on this route, and the PR has no device run. The run the body already names is the gate, and it's cheap: one gpu-hold ticket with the default mode on a large dense model, reporting pool_allocated() before and after materialising, with the delta matching the weights' size, and that the model still loads and answers. If a model that loads today now refuses, that's a finding to bring here before this lands, not one for the fleet to discover. (Syntax was checked against the pinned hrx headers, per the body. I didn't repeat it.)
|
Coordination note so the merge queue does not land both: #275 is the same fix to this file ( Whichever lands, the other should close, and the two asks I raised on this PR (whether the arena charges a slot while this charges |
|
VERDICT: READY on the change, with the one ask this series has now needed three times: a ledger test — and note that this PR and #275 are two halves of one fix in the same two regions. The two PRs, against their own base (
|
|
Superseded, in my reading, by #275 ( |
Closing in favour of #275, which does the same thing with three small improvementsCompared the diffs rather than the descriptions: both charge before the allocation, both refund a failed allocation, both release in What I have handed to #275 is the one thing worth keeping: CI cannot compile this change ( And an honest note on my own attempted check: I compiled #275's |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d026f2d — with one ask about the test, since the fix is a ledger and ledgers are the thing that drifts silently.
The two decisions that make this right:
- Reserve before the allocation. "Charged BEFORE the allocation so a refusal leaves nothing to refund" is the ordering that makes the refusal path free of cleanup, and it is the opposite of the usual
alloc(); if (charge() fails) free();shape, which is where half of these bugs live. - Refund the stored amount, not a recomputed one.
entry->chargedis written once and returned in~Entry, with the note that "a refund that recomputes its size is how a ledger drifts". That is exactly right:materialized_lengthis not provably the same at destruction time as at construction, and the drift would be invisible until a budget calculation was optimistic by a few percent.
The framing of the bug is also the part worth keeping: ggml-org#1711 charged three allocators, but the charge went on the arena path while the else beside it — GGML_HRX_HOST_WEIGHT_LIMIT_MB=0, the default — gives each weight its own buffer and was uncharged. So the ledger was correct for the mode almost nobody runs and blind to the mode everybody runs, which is the worst way round.
The ask: the diff is one file, so no test came with it, and the comment says "returning it twice is what the staging-buffer test exists to prevent" — which implies there is a test that can see this. Please either point it at this path (a load, then assert the pool returns to its starting figure — a non-zero residue is exactly the uncharged mode's signature) or say in one line why the per-weight path cannot be reached from a host test. A memory ledger whose balance is only ever checked on the arena path will drift back to uncharged the first time someone adds a fourth allocator, and the comment already says the right thing; the test is what makes it stick.
Nothing else to raise: the charged != 0 guard before pool_release prevents the double refund, and the charged = 0 reset after release is what keeps that guard meaningful.
Fixes engine#1823 — the default host-weight mode allocated per-weight device buffers that nothing charged.
Why the default was the uncharged one
ggml-org#1711 charged three allocators (#268 transient arena, #269 host-weight arena, #270 host staging). The host-weight charge
went on the arena path; the
elsebranch beside it is the unbounded mode —GGML_HRX_HOST_WEIGHT_LIMIT_MB=0,which is the default — where each weight gets its own device buffer:
So
pool_allocated()under-reported by however much that mode holds, in the configuration nearly every run uses, and anybudget arithmetic built on it was optimistic by exactly that. An audit for the asymmetry — a free with no paired
charge — is what found it, which is the method worth keeping.
The change (two sites)
pool_reserve, the same function the arena path uses, so a refusal leavesnothing to refund;
~Entryfrom a storedchargedfield, not a recomputed size — a refund that recomputes is how a ledgerdrifts, and returning it twice is what the staging-buffer test (ggml-hrx: host staging buffers are charged to the pool too (engine #1711) #270) exists to prevent.
charged == 0whenpool_budgetis 0, the same "no budget to charge against" case the arena path already leavesuncharged.
Verified, and what is not
g++-16 -std=c++26 -fsyntax-onlyon the translation unit against the pinned hrx headers(
third_party/hrx-system/libhrx/include, engine pin7f4236924a) → clean.Entryis internal to this file and the observable (pool_allocated()) onlymoves when a weight is really materialised, so the test needs either an exposed seam or a device run. I would rather
say that than add an arm that only proves a destructor runs. The arm I would add next is a
gpu-holdrun withGGML_HRX_HOST_WEIGHT_LIMIT_MB=0reading the ledger before and after materialising — cheap now thatbuild-hrxisrepaired, and it would also show the size of the under-report on a real model rather than asserting it.