Skip to content

BF16 DiT (~62 GB) on a 64 GB Mac: adaptive residency grows to 50/50 and the model ends up cycling through the compressor + swap #4

Description

@skymars13

Setup: MacBook Pro M5 Max 64 GB · macOS 26.5.2 · Vpipe Manager v0.1.23 (CLI helper) · MiniMax-H3 FL2VA upstream BF16 DiT + BF16 text encoder (local diffusers layout, unregistered, probed fine) · larryvrh Turbo v4-600 runtime LoRA · 544×960 · 124 frames · steps=4.

Log (works as designed, clip comes out fine):

MiniMax-H3 DiT 0 of 50 blocks pinned at load (none fit beside the other models;
the resident set grows into free RAM as the denoise runs)
DiT residency ended at 50 of 50 blocks (61566 MB), 0 pinned at load

Activity Monitor at the same moment:

  • Memory used 45.39 GB + cached files 18.54 GB = 63.93 ≈ 64.00 (RAM completely full)
  • App 3.41 GB · wired 7.95 GB · compressed 33.08 GB · swap used 28.74 GB
  • compressed + swap ≈ 62 GB ≈ the entire BF16 DiT

So the 50/50 "resident" set is not physically resident: macOS moved most of it into the compressor pool and swap, and the per-step sequential scan re-inflates the blocks in order, every step — hidden streaming through the swapfile instead of the checkpoint.

Why admission gets fooled (hypothesis, from the residency notes in metal-minimax-h3-transformer.h): admission checks system free headroom, and macOS keeps manufacturing "free" RAM by compressing the very blocks the DiT admitted earlier. The ratchet never fires because OS-side eviction is invisible to the process — the blocks still look resident. On 16 GB the gap is so large that growth stops early; a ~62 GB model on a 64 GB box sits exactly in the regime where the loop can run to 100%.

Impact

  • ~29 GB of swap writes per clip (the DiT is dropped after each clip, so every generation re-grows and re-spills) — real SSD wear for anyone generating many clips a day
  • ~10–15 s/step of decompress + swap-in tax
  • the rest of the machine is squeezed out while the run lasts

Suggestion: an env override for the residency reserve, e.g.

std::size_t reserve = need + (1ull << 30);
if (const char* e = std::getenv("VPIPE_H3_RESERVE_GB")) {
  const std::size_t want = (std::size_t)(std::atof(e) * 1073741824.0);
  if (want > reserve) reserve = want;
}
_h3_dit->set_residency_reserve(reserve);

VPIPE_H3_RESERVE_GB=22 on this box should stop growth around ~25 blocks and stream the rest from the checkpoint (clean file reads, zero swap). Alternatively, admission could compare the process's own phys_footprint against physical RAM instead of trusting system free memory.

Update — measured with the patch applied (same box, same clip spec, VPIPE_H3_RESERVE_GB=22):

stock v0.1.23 patched (22 GB reserve)
residency ended at 50 of 50 (61,566 MB) 21 of 50 (25,857 MB)
swap growth during run ~29 GB none
steps (4 NFE, 544×960×124f) 94.1 / 67.8 / 70.6 / 71.6 s
end-to-end ~12–15 min 6 min 30 s

Capping the growth didn't just stop the swap — it more than halved the wall time. The hidden per-step tax of re-inflating the over-grown set through the compressor/swap was far larger than the honest streaming reads it replaced.

I'm testing a locally patched build with exactly this change and can report numbers (residency count, step time, swap) if useful. Thanks for the project — everything else (probe-the-checkpoint detection on an unregistered diffusers layout, runtime LoRA, BF16 encoder streaming) worked out of the box.


A working patch (with the measurements above) is ready to cherry-pick:
https://github.com/skymars13/vpipe/tree/reserve-env-cap — commit 74cceca, one file, default behavior unchanged (the env only ever raises the reserve).

PR creation on this repo is limited to collaborators, so filing as an issue instead — happy to open a PR if you enable it, or just take the commit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions