Skip to content

feat: loader-aware background prefetch (cattensors, integrated) - #30

Open
GencSaracaydin wants to merge 12 commits into
mainfrom
genc/prefetch-integration
Open

feat: loader-aware background prefetch (cattensors, integrated)#30
GencSaracaydin wants to merge 12 commits into
mainfrom
genc/prefetch-integration

Conversation

@GencSaracaydin

@GencSaracaydin GencSaracaydin commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

feat: loader-aware background prefetch (cattensors, integrated)

Why

fal's registry spent June–July bolting an external page-cache prewarmer (cattensors) onto ~50 apps, several of which point it (or vendored copies of its reader) at .flashpack files. That composition is exactly what #28's gate fix made hazardous: an uncoordinated warm makes pages resident, the loader's mincore gate then legitimately picks buffered reads — but the warm itself cost a full extra pass over the file, and if the load starts mid-warm the two race each other for filesystem bandwidth with the gate frozen on a stale answer. Registry apps meanwhile keep a real need the warm was serving: overlapping the pack read with imports/setup work (e.g. qwen-image-2512 vendors a copy of the cattensors reader solely to warm the pack its LoRA manager re-reads on CPU).

This PR moves that capability inside flashpack, where it can cooperate with the loader instead of fighting it.

What

flashpack.prefetch_flashpack_file(path) -> FlashpackPrefetch — a cancellable, chunk-parallel, buffered page-cache warm to start before imports/setup work:

  • residency-gated: an already-hot file returns a pre-completed handle, zero I/O;
  • idempotent per path (process-wide registry; concurrent calls share one warm);
  • advisory by contract: errors are recorded on the handle, never raised — the load performs its own error-checked reads regardless.

The reader settles the prefetch before choosing an I/O path (parallel_read_into_storage): finished → the mincore gate sees a stable hot cache and routes buffered at memory speed; ≥90 % done → bounded wait for the remainder; barely started → cancel (join) and take O_DIRECT cold, which is faster from there. Partial warms and post-warm evictions self-correct because the gate re-checks residency itself. A warm can no longer race a load or flip its gate mid-flight — by construction.

read_flashpack_file(..., eager_cpu=) — per-call choice of eager parallel CPU materialization (True), lazy mmap (False), or the FLASHPACK_CPU_PARALLEL_READ env opt-in (None, default; unchanged behavior). revert_from_file accepts the same knob (default stays lazy — full-pack RSS is the wrong default for streaming consumers like the CLI unpack path; pass eager_cpu=True for full-read consumers, where #28 measured the eager path 2.2× faster cold).

Measurements

H100 fal runners, JuiceFS /data, 24 GB pack, device=cuda, 10 s of simulated setup work, every load bit-verified against checksums computed by a different node (seeder boot_id 5d15f771…, bench e812b261…), cache tiers enforced and labeled with juicefs warmup --evict/--check. (Harness lives in fal's setup-time comparison tooling, not this repo — methodology: scenarios below, cache tiers enforced+verified per-scenario, page-cache eviction via fadvise, mount-cache eviction via juicefs warmup --evict.)

tier (--check before load) scenario load call total incl. 10 s work
net-cold (0 % cached, DC peer holds blocks) load-only 35.1 s 35.1 s
net-cold prefetch-overlap 15.4 s 26.0 s
object-cold (0 % cached, no peer → GCS) load-only 33.2–42.5 s 33.2–42.5 s
object-cold prefetch-overlap 9.3–11.5 s 20.1–22.2 s
local-cache-warm (fadvise) load-only 2.26 s 2.26 s
local-cache-warm prefetch-overlap 1.91 s 12.8 s (≈ work window)
local-cache-warm settle-cancel 2.32 s 2.67 s
local-cache-warm load-hot 1.90 s 1.90 s

Reading it as an app would experience it (setup = 10 s of imports + the pack load):

  • object-cold cold start: 47.9 s → 21.2 s (2.3×); net-cold: 45.1 s → 26.0 s (1.7×). The 10 s window only warmed ~60 % of 24 GB — the settle policy cancelled the in-flight warm (progress < 0.9) and finished O_DIRECT, and the partial warm still paid: the load call dropped 2.3–4.6×.
  • Warm node: parity, not regression. After a completed prefetch the residency gate routes buffered at hot speed (1.91 s ≈ load-hot 1.90 s); a load racing a just-started warm cancels it for a noise-level 0.06–0.35 s.
  • The honesty row quantifies the anti-pattern this PR retires: sequential prefetch-then-load on a warm-cache node is 9.5 s vs 2.26 s — a 4.2× worsening. That is exactly what an uncoordinated external prewarmer (cattensors / warm_up_directory) does when it isn't overlapped or gated; the integrated API makes it structurally impossible (residency no-op + settle).
  • Side quest that proves the eager_cpu path: the seeding harness itself first tried to checksum the 24 GB pack via the default lazy mmap walk and blew a 55-minute budget; with eager_cpu=True the identical read took 38 s (>85×). That is the capture_original_weights shape.

Real-app validation (post-scenario-bench)

End-to-end A/B on H100 runners where the ONLY difference is one prefetch_flashpack_file() line before real cold imports (torch+transformers+diffusers, fresh interpreter per sample), pack state --evict+fadvise-verified 0% before every run:

  • Synthetic 24 GB pack: prefetch won every pairing — e.g. import-symmetric pairs: totals 31.9→27.7 s and 28.1→20.0 s; loads 28.2→23.4 s and 24.4→15.6 s. In every sample the warm was only 12–30 % done when the load arrived; the settle cancelled it and the partially-warmed blocks (now in the mount's local cache) still cut the O_DIRECT load.
  • qwen-image-2512's real 25 GB int8 pack: first attempt exposed the SC_AVPHYS_PAGES guard bug (fixed here — on production nodes the page cache keeps free-RAM ≈ 0, silently no-oping every warm). Post-fix: prefetch at parity-to-winning (loads 26.4–33.1 s vs 33.7–36.8 s control).
  • Literal before/after on fresh nodes (released flashpack==0.4.0 control vs this PR, real pack, cold venvs, evict-verified): totals 111.4–122.0 s → 64.1 s (~-45 %); loads 56.8–58.1 s → 27.9 s from a 2-thread warm that reached only ~20 % before the settle cancelled and banked it. Imports were not degraded by the gentle warm.
  • One catastrophic outlier (never reproduced across 8 further samples): an 8-thread warm overlapping a cold-venv import phase inflated imports 42→189 s — the warm's mount-cache writeback competes with venv reads on the same NVMe. This drove the default down to 2 warm threads (FLASHPACK_PREFETCH_THREADS): measured, 2 threads deliver ≥ the load benefit of 8 with far less collateral I/O.

Tests

  • tests/test_prefetch.py — 22 hermetic CI tests + 1 gpu-marked: bit-identity after prefetch, settle policy (cancel-barely-started / wait-nearly-resident / residency-vs-bytes-read / bounded wait-timeout / noop), in-flight end-to-end cancel through a real eager load, prefetch-vs-load race, concurrent prefetch idempotency, fork-child settle, memory guard, symlink aliasing, error-is-advisory, hot-file no-op, eager_cpu plumbing incl. kill-switch downgrade, eager/lazy revert equality.
  • Full suite green on ubuntu (py3.10–3.12) and windows (py3.12); adversarially reviewed (fork/race/timeout findings fixed pre-merge).

Registry follow-ups this unlocks (separate PRs)

  • qwen_image_2512.py deletes _warm_file_parallel_chunked / _start_weights_page_cache_warm (vendored cattensors) → prefetch_flashpack_file(transformer_pack) at setup start + eager_cpu=True in capture_original_weights.
  • wan_vace / editto drop their git-pinned cattensors ("flashpack-read patch") in favor of the native prefetch.
  • The improve-setup-time skill's C4 row gets a supported, gate-safe answer for pack prewarming.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK

GencSaracaydin and others added 5 commits July 21, 2026 10:28
Ports the useful half of the internal cattensors prewarmer into
flashpack and couples it to the parallel reader so a warm can never
race a load or flip its O_DIRECT gate:

- flashpack.prefetch_flashpack_file(path): cancellable chunk-parallel
  buffered page-cache warm, residency-gated (>=0.9 no-ops), idempotent
  per path via a process-wide registry. Start it before imports/setup
  work; the load then reads a hot cache at memory speed.
- parallel_read_into_storage() settles any registered prefetch before
  choosing an I/O path: done -> hot-cache buffered via the mincore
  gate; nearly done (>=0.9) -> bounded wait; barely started -> cancel
  and take O_DIRECT. Partial warms and evictions self-correct because
  the gate re-checks residency itself.
- read_flashpack_file() gains eager_cpu: explicit per-call choice of
  eager parallel CPU materialization vs lazy mmap (None defers to the
  FLASHPACK_CPU_PARALLEL_READ opt-in). revert_from_file() defaults to
  eager: reverting reads every tensor by definition.
- scripts/bench_prefetch.py: overlap/settle/hot scenarios, bit-verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
…ndings)

Adversarial review fixes on the prefetch integration:

- fork safety: os.register_at_fork clears the registry and re-arms the
  locks in children (reader threads never survive a fork; an inherited
  handle could stall settle 120s or deadlock on a lock held at fork)
- cancel/_start race: thread spawning is serialized against cancel via a
  spawn lock, so settle can never join an unstarted thread (RuntimeError)
  and a cancelled handle never spawns readers
- bounded everything: cancel() takes a join_timeout; the settle path uses
  it (15s) so a reader wedged in preadv on a dead mount is abandoned as a
  daemon thread instead of hanging the load
- settle gates on RESIDENCY, not bytes-read: a large warm that already
  evicted its own head is cancelled (waiting would buy a stale cache and
  lose the O_DIRECT path)
- prefetch skips files larger than available physical memory (a warm that
  evicts itself is pure waste)
- registry: keyed by realpath (symlink aliases share one warm); settled
  handles are replaced instead of pinning the path forever; settle keeps
  the handle registered while settling so concurrent same-path loads all
  settle the same warm
- revert_from_file default back to lazy (eager_cpu=None): eager is now
  strictly opt-in — full-pack RSS is the wrong default for streaming
  consumers like the CLI unpack path
- tests: 10 new cases (fork child, concurrent prefetch, prefetch-vs-load
  race, residency-vs-progress gate, bounded wait timeout, memory guard,
  symlink alias, cold natural completion, re-prefetch semantics, gpu-
  marked CUDA settle)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
- --net-cold-packs: copies never read by this node, one scenario each,
  run first (any full read warms the mount cache for that file forever)
- --expect-checksums: bit-verification against sums computed by the
  seeding node, so the reference load cannot warm the bench node
- record page-cache residency right before each post-prefetch load
  (diagnoses mounts that do not retain the cache across opens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
- --juicefs-bin enables per-scenario mount-cache eviction (--evict) and
  tier labeling (--check: LOCAL/REMOTE/object-store) so net-cold and
  object-cold rows are enforced+verified, not assumed from job placement
- --object-cold: locally-written copy purged from the mount cache -> no
  DC peer holds it -> true object-store-cold loads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
@GencSaracaydin

Copy link
Copy Markdown
Contributor Author

@codex review
@claude review
@cursor review

…no-op

The prefetch module deliberately no-ops off POSIX (pre-completed handle,
zero reads). Four tests staged a LIVE warm and asserted its mechanics —
impossible on windows-latest. Mark them posix_only and add a platform-
independent test pinning the documented non-POSIX behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
@GencSaracaydin

Copy link
Copy Markdown
Contributor Author

@codex review

@GencSaracaydin
GencSaracaydin requested a review from jfischoff July 21, 2026 23:27
…ernal setup-time harness

The measured numbers in the PR description stand (methodology documented
there); the harness itself will live in a general setup-time comparison
tool rather than this repo. No changes to package code or tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
@GencSaracaydin

Copy link
Copy Markdown
Contributor Author

self review: should remove the benchmarking references as a seperate harness will be handling that

@GencSaracaydin GencSaracaydin left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are windows fallback features (like parallel_read available which are completely unnecessary, remove those)

GencSaracaydin and others added 4 commits July 21, 2026 16:50
…ux) runners

- _read_chunk_buffered preadv-only (no pread fallback)
- no non-POSIX no-op branch in prefetch_flashpack_file
- register_at_fork hasattr kept strictly as import-safety for the
  packing side of the repo, which still CI-tests Windows
- drop the non-POSIX-behavior test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
sysconf(SC_AVPHYS_PAGES) counts only FREE pages; on a long-running node
the page cache keeps that near zero, so the oversized-file guard
spuriously no-oped every prefetch. Caught live on a production H100
against qwen-image-2512s real 25 GB pack (prefetch reported done with
zero reads; the load ran fully cold). Use /proc/meminfo MemAvailable,
which includes reclaimable cache. Also expose handle.skipped_reason
("hot"/"memory"/"empty") so a silently-skipped warm is diagnosable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
The warm has the whole import/setup window; it does not need to
saturate. Every block it pulls is also written to the mount local
cache, so an aggressive warm competes with co-located reads (cold venv
imports, neighbors) on the same NVMe. A/B on production H100s against
qwen-image-2512s real 25 GB pack: 2 threads gave at least the load
benefit of 8 (26.4-27.9 s vs 33.1 s cold loads) with far less
collateral I/O; the one catastrophic sample (189 s imports) came from
the aggressive default overlapping a cold venv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
…switch only

Addresses the PR review: the engine targets fal Linux runners, so the
POSIX platform term (and the helper that existed to carry it) is gone.
parallel_read_supported and the eager_cpu override now consult only the
FLASHPACK_PARALLEL_READ kill switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
@GencSaracaydin

Copy link
Copy Markdown
Contributor Author

Addressed across three commits:

  • b326532 removed the behavioral fallbacks (preadv-only reads, no non-POSIX no-op branch, dropped the non-POSIX test)
  • 9ff6549 gentle 2-thread warm default from the production A/B
  • 188c7dc removes parallel_read_available entirely — no platform gating anywhere; parallel_read_supported and the eager_cpu override consult only the FLASHPACK_PARALLEL_READ kill switch

The one platform-conditional left is the hasattr(os, "register_at_fork") guard, kept strictly as import-safety: flashpack/__init__ imports the prefetch module, and the repo's packing side still runs windows-latest CI (#17) — without it import flashpack crashes there. It guards the import, not behavior; happy to drop it too if the Windows CI job is on its way out.

test_cuda_posix_default_on asserted the old posix gate that 188c7dc
removed; the contract is now "on by default, FLASHPACK_PARALLEL_READ is
the only switch".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBPf2mGKiK6P6WjN785SK
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