fix: make the chunk pool's "safe to take away" predicate true, not approximately true - #37
Conversation
…proximately true Eviction eligibility was spread across five loosely-coupled fields, and each one could lie: * fail() had to set ready=True to wake a waiter -- the only lever available -- which simultaneously declared a half-written slot a finished cache entry while sibling tile tasks were still writing into it, and left the poisoned slot resident so the next epoch re-raised the stale error forever instead of refetching (#33). * unpin_all() cleared the pin map globally, so with two producers over one pool (zip(ds.train, ds.val), a documented configuration) one iteration's epoch boundary stripped the other's pins and its in-use chunks became eviction candidates mid-gather (#34). * claimed was a single bool, so one iteration's claim satisfied another's wait_ready; that iteration then gathered a chunk it never referenced, and its release decremented someone else's count (#35). All three produce *plausible* data, which throughput, shapes and smoke tests pass. A slot now carries one explicit SlotState (FILLING -> ASSEMBLED -> READY, FAILED terminal) advanced in exactly one place, plus two counters that answer one question each: writers (tile tasks *running*) and pending (tiles not yet delivered). References are owner-scoped and a reference IS that owner's claim, so claimed is gone. Every tile write happens inside pool.tile_write, whose scope releases on every exit path including cancellation at an await -- which no explicit call site covers. unpin_all() splits into release_owner() and reset_epoch_counters(): releasing references and zeroing counters are unrelated jobs on different clocks, and bundling them is why the reset had to unpin globally. Cleanup moves into _iterate's own teardown, so an abandoned pass cleans up after itself. ASSEMBLED is a real state, not a formality: the chunk transform and the persist write-back run outside the lock between the last tile landing and publication, and a predicate derived from a tile counter alone would call that window evictable. Closes #33 Closes #34 Closes #35 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxdT3e62pYT3EVMBF1sA7C
There was a problem hiding this comment.
Looks clean
Ran:
PYTHON_GIL=0 uv run --python 3.13t python -m bench.probe_decode --max-chunks 64 --repeats 5 --decode-threads 1,2,4,8 --no-raw and uv run python -m bench --engines insitu,memory --chunk-sizes 1 --epochs 2 --repeats 5 - no regression
…et starves One InSituDataset owns one ChunkPool and every active iteration shares it, but each holds its own chunk references -- so residency is the sum of their working sets, not the maximum. Sizing the default for ONE iteration is the right default and stays: the engine cannot know how many iterations a caller intends to run, and guessing high would cost memory in the single-iteration case that is almost every case. What was missing is the diagnostic. Starvation advised "raise cache_budget_bytes, or lower batch_size / block_chunks" -- correct, but not actionable when every resident chunk is legitimately referenced and the caller cannot see why. It now names how many iterations share the pool and the pattern that produces that. Owners count from mint to release rather than from their first pin: the iteration that starves BEFORE it can pin anything is exactly the one that needs naming, and counting pin-holders misses it (caught by the test, which failed on the first implementation). docs/tuning.md gains the sizing rule and the reason the default is not multiplied. The auto-sizing site and the docstrings that advertise the shared-pool configuration (buffers.BatchBuffers) carry the caveat, so it is not only in the prose. Refs #38 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxdT3e62pYT3EVMBF1sA7C
|
Pushed a second commit (`0ce254e`) after your review — flagging since it lands on an already-reviewed PR. Decision recorded: size for one is the correct default, so #38 needed no auto-sizing change. That puts the whole cost on the diagnostic, which is what this commit fixes: the starvation error now names how many iterations share the pool and the pattern that produces it, No example uses One implementation note: Verified: 299 passed / 20 skipped against a 298 / 20 baseline on the same tree and extras (delta is exactly the new test); ruff, mypy, |
Summary
Closes #33, closes #34, closes #35 — three bugs with one root cause. Also closes #38
(the sizing decision and the diagnostic that follows from it; second commit). Eviction eligibility
was
k not in self._pinned and s.ready, spread across five loosely-coupled fields(
remaining,ready,claimed,error,_pinned), and each issue is a different fieldlying:
fail()had to setready = Trueto wake a waiter, because that was the onlylever available. That simultaneously declared a half-written slot a finished cache entry
while sibling tile tasks were still writing into it, and left the poisoned slot resident,
so the next epoch took
try_admit's resident branch and re-raised the stale error foreverinstead of refetching.
unpin_all()cleared the pin map globally. With two producers over one pool(
zip(ds.train, ds.val), documented inbuffers.py:238-247), one iteration's epochboundary stripped the other's pins and its in-use chunks became eviction candidates
mid-gather.
claimedwas a single bool, so one iteration's claim satisfied another'swait_ready. That iteration then gathered a chunk it never referenced, and its releasedecremented someone else's count.
All three produce plausible data, which throughput, shapes and smoke tests all pass —
only byte fingerprints catch them.
The shape
A slot now carries one explicit
SlotState(FILLING → ASSEMBLED → READY,FAILEDterminal) advanced in exactly one place (
_advance), plus two counters that answer onequestion each:
writers— tile tasks running, so eviction is never racing a live write.pending— tiles not yet delivered, so completeness is separate from quiescence.References become owner-scoped (
dict[key, dict[owner, int]]) and a reference is thatowner's claim, so
claimedis deleted. One iteration = one owner, minted by_iterateandshared with its scheduler (
Scheduler.owner).Every tile write happens inside
pool.tile_write, a scope that releases on every exitpath — including cancellation at an
await, which an earlybreaktriggers viacancel_futures=Trueand which no explicit call site can cover.unpin_all()splits intorelease_owner()andreset_epoch_counters(). Releasingreferences and zeroing counters are unrelated jobs on different clocks, and bundling them is
why the reset had to unpin globally. Cleanup also moves into
_iterate's own teardown, soan abandoned pass cleans up after itself rather than relying on the next pass's prologue —
which, now that owners are scoped, would be a different owner and could not.
For reviewers
Behavior is meant to be unchanged for a single iteration. The one intended behavioral
change is for concurrent iterations, below.
Most valuable second look, in order:
ChunkPool._advance— the only function that moves state. Worth checking thequiesced-but-incomplete case (a cancelled pass leaves an abandoned partial: stays
FILLING, dropped byrelease_owner) and that aFAILEDslot is deliberately notdropped there, because a consumer still has to observe the error.
ASSEMBLEDis load-bearing, not ceremony. The chunk transform and the persistwrite-back run outside the lock between the last tile landing and publication, and
another thread can observe that window. A predicate derived from a tile counter alone
would call it evictable. This is the one place the design note's proposed
assembled = writers == 0 and error is Nonewould have introduced a bug.writerscounts started tasks, not planned ones. Initialising it to the tile countat admit is wrong: a cancelled pass leaves tiles never started, so the slot would never
quiesce and its budget would never be reclaimable.
pin_keysnow notifies. Now that a reference is the claim, pinning can be the eventthat satisfies a parked
wait_ready; under the old shared flag it never could.Two notes on the tests:
valid discriminator — A's own producer releases those pins in the background within
~0.3 s whether or not B ever starts (measured), so it would fail with the bug fixed. It
is replaced by a deterministic pool-level test of owner-scoped release; the byte-fingerprint
test remains the end-to-end statement.
deliver_tileis sugar overtile_write, not a second lever — it opens the same scope, sothe writer count still moves in exactly one place.
Intended behavioral change: concurrent iterations need a larger budget
Two concurrent iterations now need a budget covering both working sets; the concurrent
test went from 20 → 32 chunks. This is not a leak — verified that a single iteration grows
peak-pinned to whatever budget it is given and works at every size, unchanged. The old global
unpin was spuriously freeing the other iteration's pins, so any budget that "worked" for
zip(ds.train, ds.val)before was relying on the bug.The auto-sized default is still sized for one iteration, and that is now the settled
decision (#38): the engine cannot know how many iterations a caller intends to run, so an
automatic multiplier would silently cost memory in the single-iteration case that is almost
every case. Running several is an explicit choice, so sizing for it is the caller's.
The second commit makes that decision liveable, since the whole cost then falls on the
diagnostic. Starvation previously advised "raise cache_budget_bytes, or lower batch_size /
block_chunks" — correct, but not actionable when every resident chunk is legitimately
referenced and the caller cannot see why. It now names the cause:
Plus
docs/tuning.md§"Several iterations at once multiply the budget" with the sizing ruleand why the default is not multiplied, and the same caveat at the auto-sizing site and on the
docstring that advertises the shared-pool configuration — so it is not carried by prose alone.
No example uses
zip(ds.train, ds.val)as a runnable pattern; the mentions are the docstringsand tests that advertise it, which is where a reader learns it is supported, so those are what
carry the caveat.
active_ownerscounts from mint to release, not from first pin. My first implementationcounted owners currently holding a reference and the note never fired — the second iteration
starves before it pins anything, which is exactly the case the message exists for. The test
caught it.
Author attestation
have verified the claims made in this description.
Left unchecked deliberately — this was drafted by Claude, and that box is the human
author's to tick.
Checklist
tests/test_pool_lifecycle.pyreproduces all three; confirmedfailing before the change for the documented reasons
uv run ruff check src tests bench examples,uv run mypy src bench examplesanduv run pytest -qare green locally (313 passed, 12 skipped)docs/*.md— not done. The concurrent-iterationbudget requirement is user-facing and belongs in
docs/tuning.md; deferred to Auto-sized residency budget assumes one iteration, so concurrent iterations deadlock #38 so theguidance and the auto-sizing fix land together rather than documenting a number that is
about to change.
## UnreleasedinCHANGELOG.mdsame lock sections the completion counter already used),
Batchuntouched, parallelismstill in the event loop
ChunkPool, the scheduler, and cross-thread readiness → free-threaded runpasses: 80 passed, 1 skipped on 3.13t with
PYTHON_GIL=0(pool, lifecycle, scheduler,residency, prefetch, window); re-run after the second commit, 70 passed / 1 skipped