Skip to content

feat(moe): NVMe disk tier for MoE expert banks - #337

Open
CraigStone-Dev wants to merge 34 commits into
FlashML-org:mainfrom
CraigStone-Dev:feat/moe-disk-tier-standalone
Open

feat(moe): NVMe disk tier for MoE expert banks#337
CraigStone-Dev wants to merge 34 commits into
FlashML-org:mainfrom
CraigStone-Dev:feat/moe-disk-tier-standalone

Conversation

@CraigStone-Dev

Copy link
Copy Markdown

What

Adds an optional NVMe disk tier for MoE expert banks (NVFP4 checkpoints). When the expert banks don't fit in host RAM, the tail of the bank stays on NVMe and is fetched on demand during decode, so models whose expert weights exceed RAM become runnable.

freetoken serve <ckpt> --moe-disk-tier on --expert-ram-experts 256
  • --expert-ram-experts N (per layer): first N experts resident in RAM, the rest on disk.
  • Default (flag off) is byte-identical behavior to before — the disk tier is a no-op unless enabled.
  • NVFP4 expert banks only (the layout the disk index needs); other quant formats reject the flag with a clear error.

How

  • moe/disk_tier.py (new): Nvfp4DiskIndex — a row-indexed view over the checkpoint's safetensors expert tensors (no copy of the weights; mmaps the shard files), plus a pool of background fetch workers that copy pending expert rows into the existing host-bank slots ahead of the CPU executor's need.
  • moe/host_banks.py / expert_banks.py: banks can now be partially populated; unfilled rows are marked pending and served by the fetch workers.
  • moe/offload_cache.py: pin pipeline extended — a prefix-pinned (disk-tier) layer is fetched before the GPU offload copy, integrated with the existing settle/plan flow.
  • engine/ + server/args.py: config plumbing, validation (disk tier requires decode_target == "gpu"), and the CLI flags.
  • models/nvfp4_banks.py / qwen3_5_moe/weight.py / weight.py: bank loading passes the disk tier through so the index is built from the same source spec as the RAM banks.
  • tests/moe/test_disk_tier.py (new): CPU tests for the index, fetch workers, and pending-row bookkeeping.

Validation

E2E on Qwen3.6-35B-A3B-NVFP4 (128 experts/layer, ~2.7 MB/expert) on 2× RTX PRO 4000, 62 GB RAM:

Config RAM=64 (12.5% residency)
TP=1, NVMe 4.3 GB/s 17.48 → 15.65 tok/s (−10%)
TP=1, vdisk 1.8 GB/s 19.73 → 11.06 tok/s (−44%, disk-bound)
TP=2, NVMe 4.3 GB/s 16.22 → 13.79 tok/s (−15%)

Correctness: a FT_VERIFY=1 toggle recomputes every fetched expert on CPU and compares against the disk-served result — 8604/8604 (TP=2) and 4296/4296 (TP=1) matches, zero mismatches across 300-token decodes.

The penalty is disk-bandwidth-bound, as expected: ~10% on NVMe, ~44% on a 1.8 GB/s virtual disk.

Notes

  • Stacked on current main; no TP changes in this PR (TP support is separate, see feat(models): support TP for qwen3_5_moe #104). The two touch disjoint regions of the same two files (layers/moe.py, qwen3_5_moe/weight.py) and were validated together.
  • Upstream's per-layer residency work (feat(moe): per-layer host-bank residency #112) is integrated: load_expert_banks keeps both the layer_residency and disk_tier params, and the engine validates them as mutually exclusive.

Lets the offload backend serve models whose experts don't fit in pinned
RAM: with --moe-disk-tier on --expert-ram-experts K, only the first K
experts per layer are pinned; the rest keep allocated bank rows whose
pages are released (MADV_DONTNEED) after load and are fetched from the
ORIGINAL safetensors checkpoint on slot-cache miss.

* moe/disk_tier.py: Nvfp4DiskIndex (index json + shard headers -> per
  (bank, layer, expert) byte ranges) and DiskTier (O_DIRECT preadv into a
  small pinned staging buffer, H2D into the LRU-assigned slot, miss-list
  rewrite so the existing PCIe copy path only moves RAM-resident misses).
* host_banks: HostBank.pin_prefix / release_range; PinPipeline(prefix_rows).
* nvfp4 loaders: disk_tier param -> partial pin + tail release (serial and
  parallel paths).
* offload_cache: attach_disk_tier + copy_missing hook; materialize_layer
  takes the routed ids when disk-tiered.
* offload_kernels: materialize kernel gains materialize_count (disk-tier
  prefill streams only the RAM prefix; routed disk-resident experts are
  fetched into their identity slots, so the prefill GEMM is unchanged).
* engine: --moe-disk-tier / --expert-ram-experts / --disk-fetch-workers,
  guards (native NVFP4, gpu decode, no prefill overlap, no cuda graphs).

v0 scope: native NVFP4 (triton) layout, synchronous fetch, no FTW, no
converter path. CPU unit tests: tests/moe/test_disk_tier.py.
…zero ignores DONTNEED); index offsets need 64-bit
…g the row's leading dim -> 8KB buffer, EFAULT/segfault on 512KB segments)
…+fill, not byte copy; test matches real checkpoint layout
…pool-thread stream must land before GEMM reads slots)
…rialize

The GPU slot cache is one shared pool across all layers. Prefill identity
mapping owns all of slots [0, E) per layer, but the materialize kernel only
scans slots < materialize_count (the RAM prefix), so disk slots [ram, E)
that held a previous prefill layer's experts kept their slot_for_id
entries. Decode then took phantom hits on those slots and read another
layer's weights (expert 112 verified correct at prefill, corrupted by
decode step 1). Clear the stale entries device-side before the kernel.
The RAM-slot identity check (slot e == host row e) only holds under the
prefill identity mapping; during decode the LRU owns slots 0..63. It was
firing on every decode step for layer 0 (384 experts x banks of D2H +
CPU compare per step), dragging the instrumented E2E from ~11 tok/s to
1.9. Track whether the pending miss list came from materialize_layer
(prefill) or ensure_experts (decode) and gate on it.
…iskTier init

TP=2 disktier debug (step 2): prove empirically whether the host bank rows
are full-per-rank or TP-sharded, and that the disk index's full-row
segments match them byte-for-byte. Gated on FT_DISK_TIER_VERIFY.
… ring

Each worker thread preadved the next bank's bytes into the SAME pinned
staging buffer while the previous bank's async H2D copy was still DMAing
from it. The copy is cudaMemcpyAsync: copy_ returns after ENQUEUE, and the
GPU reads the host bytes later, so the next preadv could land mid-DMA and
the slot row came out as a partial mix of two experts' data.

TP=1 rarely hit it (single rank's disk load, idle-ish stream); TP=2 doubles
the NVMe load and adds NCCL stream work, so the window opened and the run
degenerated (2/5412 verified rows wrong, both gate|up weight_scale).

Fix: a per-thread ring of _STAGING_RING pinned buffers, each armed with a
CUDA event recorded after the copy that used it; reusing a buffer waits on
that event. Exact, and the wait is a no-op whenever the ring outruns the
DMA (the normal case), so disk/GPU overlap is preserved.

Also: [verify] lines now carry phase/layer/slot and, on mismatch, the diff
span plus an overwriter hunt (which checkpoint row the slot actually
holds); _preadv_error call now passes its full signature. Test stub updated
to the ring.
Fresh ThreadPoolExecutor threads default to CUDA device 0, but a TP>1
rank lives on another device. The H2D slot copies land on the
destination tensor's device stream (copy_ guards on dst), while
ev.record() uses the thread's CURRENT stream -- so on TP=2 rank 1 the
staging-ring reuse guard waited on an idle device-0 stream and a
preadv could overwrite a pinned buffer mid-DMA.

Seen on Bandit (4.3 GB/s NVMe, TP=2, RAM=64): 6/12288 layer-0 slot
rows corrupted in one prefill (e4m3 scale banks mixed into e4m3 NaN
encodings; weight banks partial mixes; scalar-fill banks clean).
Rudi's 1.8 GB/s disk made the preadv slower than the DMA, hiding the
window.

- _staging_ring: torch.cuda.set_device(rank device) once per worker
  thread, so ev.record() lands on the stream the copies use.
- _sync_fetches: sync the rank device's default stream explicitly.
- _identify_overwriter: num_experts came from an expert ROW's
  shape[0] (1024/128/32/2048 per bank) -- the scan ran past the index
  and died with struct.error, masking overwriter attribution.
@MT-z

MT-z commented Sep 2, 2026

Copy link
Copy Markdown

Tested this PR head (623ca1d) rebased onto main (clean at a80b4d3 and 6eca2d7) on an RTX 4090 24 GB + 61 GB RAM box (Ubuntu, kernel 7.0), with three NVFP4 checkpoints: RadixArk/Qwen3.8-Flash-Next-NVFP4 (qwen4_exp), LibertAIDAI/GLM-5.3-Flash-NVFP4 (glm5_next) and ornith-ai/Ornith-1.5-35B-A3B-NVFP4 (qwen3_5_moe). The tier works end to end once the three fixes below are applied -- Qwen3.8 (63 GiB of experts on a 61 GB box) boots in 23 s at --expert-ram-experts 224 and FT_DISK_TIER_VERIFY=1 reports 0 mismatches over ~16k slot checks. Findings in severity order; diffs for 1-3 are folded at the end.

1. Load-time RAM peak is the full expert set, regardless of --expert-ram-experts

load_nvfp4_expert_source_banks (and the _parallel twin) allocate [E, ...] banks, fill every expert row from every shard, and only then call release_bank_tails (nvfp4_banks.py:153-154, 214-216 / 285-286, 341-343). So a model whose experts do not fit in RAM cannot get through the load -- which is the case the PR exists for.

Repro: GLM-5.3-Flash-NVFP4 (288 experts, 166 GiB of expert rows), --expert-ram-experts 48, 61 GB RAM. The serial loader ran at ~1.5 it/s to shard 73/118, then fell into swap (19 s/it, then 56 s/it) and had to be killed.

Fix: keep the [E, ...] allocation (rows >= K are the tier's fetch destination and must exist as address space) but never read or write them at load: the serial loader skips the row before get_tensor, the parallel loader filters at the reader so the cold 83% costs no I/O, and LayerCompletionTracker / the final placed == expected assert count rows_per_layer instead of E. Same model afterwards: 118/118 shards in 13 s, cgroup shmem 28.9 GiB (= 48/288 of 166 GiB), no swap.

2. disk_tier= only reaches qwen3_5_moe; every other NVFP4 family dies at load

models/weight.py and moe/expert_banks.py now call load_nvfp4_expert_sources(..., disk_tier=disk_tier) unconditionally, but only qwen3_5_moe/weight.py:1081 accepts the keyword. With --moe-disk-tier on:

TypeError: load_nvfp4_expert_sources() got an unexpected keyword argument 'disk_tier'

on gemma4 (weight.py:278), glm4_moe (194), glm5_next (98), minimax_m2 (91), minimax_m3 (251), qwen4_exp (292) and their _parallel variants -- 13 wrappers. The base load_nvfp4_expert_source_banks{,_parallel} already take disk_tier; the wrappers just have to forward it (mechanical; verified with inspect.signature on all seven families, exercised end to end on glm5_next and qwen4_exp).

3. 623ca1d breaks two of the PR's own CPU tests

tests/moe/test_disk_tier.py::test_fetch_pending_filters_and_rewrites and ::test_fetch_pending_all_disk_clears_list fail on the PR head and pass on its parent. _sync_fetches (disk_tier.py:328) gates the stream sync on torch.cuda.is_available(), so on a CUDA machine the CPU-bank tests fall through to torch.cuda.default_stream(cpu_device), which raises. Keying the guard on the banks' device type (self._banks[0][1].device.type != "cuda") fixes both; a CPU->CPU copy has nothing to order anyway.

4. Minor

  • disk_tier.py:540: an unconditional print("[disk-tier dbg] ...") for layer_id < 3 on every prefill.
  • disk_tier.py:544: int(cache.step.item()) is a device-to-host sync per layer on the decode path.

5. The v0 preconditions surface one boot at a time

--disable-moe-prefill-overlap, --cuda-graph-max-bs 0 and the gpu decode target are each discovered by a separate ValueError (engine.py:578/580/583), which cost three boots of a 182 GB model here. Validating them together, or listing them in --help for --moe-disk-tier, would help.

The three fixes are small commits on top of your head; I can push them as a branch or open a PR against yours -- say which you prefer.

Fix 3 -- key the fetch sync on the banks' device (0001)
diff --git a/python/freetoken/moe/disk_tier.py b/python/freetoken/moe/disk_tier.py
index 9f16063..b5c7f14 100644
--- a/python/freetoken/moe/disk_tier.py
+++ b/python/freetoken/moe/disk_tier.py
@@ -325,9 +325,13 @@ class DiskTier:
         The copies are enqueued on the pool threads' default stream; f.result() only
         waits for them to be ENQUEUED. The GEMM's stream is not ordered with that
         stream, so sync the default stream before the GEMM reads the slots."""
-        if not torch.cuda.is_available():
-            return  # CPU-only tests: the copies are synchronous CPU->CPU
-        torch.cuda.default_stream(self._banks[0][1].device).synchronize()
+        # Key this on where the BANKS live, not on whether the machine has a GPU: the
+        # disk-tier unit tests build CPU banks on a CUDA box, and default_stream() rejects
+        # a CPU device outright. There is nothing to order for a CPU->CPU copy anyway.
+        device = self._banks[0][1].device
+        if device.type != "cuda":
+            return  # CPU banks: the copies are synchronous CPU->CPU
+        torch.cuda.default_stream(device).synchronize()
 
     def _verify_slot(self, cache, layer: int, expert: int, slot: int | None = None,
                      phase: str = "prefill") -> None:
Fix 1 -- never materialize disk-resident rows at load (0002)
diff --git a/python/freetoken/models/nvfp4_banks.py b/python/freetoken/models/nvfp4_banks.py
index 68a5cfb..4386da8 100644
--- a/python/freetoken/models/nvfp4_banks.py
+++ b/python/freetoken/models/nvfp4_banks.py
@@ -152,6 +152,13 @@ def load_nvfp4_expert_source_banks(
 
     _hb = _alloc_nvfp4_host_banks(num_layers, E, H, I)  # unpinned; pinned after fill
     K = disk_tier.ram_experts if disk_tier is not None else None
+    # Rows this loader actually materializes per layer. The banks are still allocated
+    # at the full [E, ...] shape -- rows K..E-1 are the disk tier's fetch destination and
+    # must exist as address space -- but they are never read or written here, so they
+    # stay unbacked. Filling them and releasing afterwards (the previous order) made the
+    # load-time peak the FULL expert set, which is exactly the case the tier exists for:
+    # GLM-5.3-Flash (166 GiB of experts) swapped a 61 GB box to a halt at shard 73/118.
+    rows_per_layer = E if K is None else min(K, E)
     if disk_tier is not None and layer_sink is not None:
         raise NotImplementedError("disk tier: the converter (layer_sink) path is not supported yet")
     gate_up_packed = [b.tensor for b in _hb["gate_up_packed"]]
@@ -164,7 +171,7 @@ def load_nvfp4_expert_source_banks(
     from freetoken.moe.host_banks import LayerCompletionTracker, PinPipeline
 
     def _load(sink) -> int:
-        tracker = LayerCompletionTracker(E * 6, _hb, sink)
+        tracker = LayerCompletionTracker(rows_per_layer * 6, _hb, sink)
         placed = 0
         for shard in tqdm(sorted(weight_shards), desc=f"Loading {spec.desc}", disable=not primary):
             path = os.path.join(folder, shard)
@@ -172,6 +179,8 @@ def load_nvfp4_expert_source_banks(
                 for name, match, bank_layer_id in weight_shards[shard]:
                     layer = int(match.group("layer"))
                     expert = int(match.group("expert"))
+                    if expert >= rows_per_layer:
+                        continue  # disk-resident row: fetched on demand, never loaded here
                     proj = match.group("proj")
                     role = spec.proj_to_role[proj]
                     kind = _canon_kind(spec, match.group("kind"))
@@ -213,7 +222,7 @@ def load_nvfp4_expert_source_banks(
 
             release_bank_tails(_hb, E, K)
 
-    expected = num_layers * E * 6
+    expected = num_layers * rows_per_layer * 6
     assert placed == expected, f"{spec.desc}: loaded {placed} expert tensors, expected {expected}"
     return {
         "gate_up_packed": gate_up_packed,
@@ -284,6 +293,13 @@ def load_nvfp4_expert_source_banks_parallel(
 
     _hb = _alloc_nvfp4_host_banks(num_layers, E, H, I)  # unpinned; pinned after fill
     K = disk_tier.ram_experts if disk_tier is not None else None
+    # Rows this loader actually materializes per layer. The banks are still allocated
+    # at the full [E, ...] shape -- rows K..E-1 are the disk tier's fetch destination and
+    # must exist as address space -- but they are never read or written here, so they
+    # stay unbacked. Filling them and releasing afterwards (the previous order) made the
+    # load-time peak the FULL expert set, which is exactly the case the tier exists for:
+    # GLM-5.3-Flash (166 GiB of experts) swapped a 61 GB box to a halt at shard 73/118.
+    rows_per_layer = E if K is None else min(K, E)
     if disk_tier is not None and layer_sink is not None:
         raise NotImplementedError("disk tier: the converter (layer_sink) path is not supported yet")
     gate_up_packed = [b.tensor for b in _hb["gate_up_packed"]]
@@ -297,10 +313,15 @@ def load_nvfp4_expert_source_banks_parallel(
 
     # Pass 2: bulk weight/weight_scale via the common parallel reader; place by name.
     def _load(sink) -> int:
-        tracker = LayerCompletionTracker(E * 6, _hb, sink)
+        tracker = LayerCompletionTracker(rows_per_layer * 6, _hb, sink)
         placed = 0
+        def _wanted(n: str) -> bool:
+            info = weight_info.get(n)
+            # Filter at the reader so disk-resident rows cost no I/O at all, not just no write.
+            return info is not None and int(info[0].group("expert")) < rows_per_layer
+
         for name, tensor in iter_expert_tensors_parallel(
-            folder, lambda n: n in weight_info, workers=workers, chunk=chunk
+            folder, _wanted, workers=workers, chunk=chunk
         ):
             match, bank_layer_id = weight_info[name]
             layer = int(match.group("layer"))
@@ -340,7 +361,7 @@ def load_nvfp4_expert_source_banks_parallel(
 
             release_bank_tails(_hb, E, K)
 
-    expected = num_layers * E * 6
+    expected = num_layers * rows_per_layer * 6
     assert placed == expected, f"{spec.desc}: loaded {placed} expert tensors, expected {expected}"
     return {
         "gate_up_packed": gate_up_packed,
Fix 2 -- thread disk_tier through every NVFP4 family's loader (0003)
diff --git a/python/freetoken/models/gemma4/weight.py b/python/freetoken/models/gemma4/weight.py
index 43c2355..ad9c43e 100644
--- a/python/freetoken/models/gemma4/weight.py
+++ b/python/freetoken/models/gemma4/weight.py
@@ -276,7 +276,7 @@ def iter_weights_parallel(
 
 
 def load_nvfp4_expert_sources(
-    model_path: str, config, *, layer_sink=None
+    model_path: str, config, *, layer_sink=None, disk_tier=None
 ) -> dict[str, list[torch.Tensor]]:
     """CPU NVFP4 expert source banks for the offload cache; see load_nvfp4_expert_source_banks."""
     return load_nvfp4_expert_source_banks(
@@ -286,11 +286,12 @@ def load_nvfp4_expert_sources(
         drop_page_cache=drop_page_cache,
         primary=get_tp_info().is_primary(),
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
 def load_nvfp4_expert_sources_parallel(
-    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None
+    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None, disk_tier=None
 ):
     """parallel: same NVFP4 source banks via the common chunked multi-threaded reader."""
     from freetoken.models.nvfp4_banks import load_nvfp4_expert_source_banks_parallel
@@ -304,6 +305,7 @@ def load_nvfp4_expert_sources_parallel(
         workers=workers,
         chunk=chunk,
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
diff --git a/python/freetoken/models/glm4_moe/weight.py b/python/freetoken/models/glm4_moe/weight.py
index 7efc6e6..a00005e 100644
--- a/python/freetoken/models/glm4_moe/weight.py
+++ b/python/freetoken/models/glm4_moe/weight.py
@@ -191,7 +191,7 @@ def _iter_resident_weights(reader, config, primary) -> Iterator[tuple[str, torch
 # --------------------------------------------------------------------------------------
 # Routed expert host banks (NVFP4) for the offload cache.
 # --------------------------------------------------------------------------------------
-def load_nvfp4_expert_sources(model_path: str, config, *, layer_sink=None) -> dict[str, torch.Tensor]:
+def load_nvfp4_expert_sources(model_path: str, config, *, layer_sink=None, disk_tier=None) -> dict[str, torch.Tensor]:
     """Build the pinned CPU NVFP4 banks for GLM-4's routed experts.
 
     experts exist only for layers [first_k_dense_replace, num_layers) and pack by MoE layer
@@ -205,11 +205,12 @@ def load_nvfp4_expert_sources(model_path: str, config, *, layer_sink=None) -> di
         drop_page_cache=drop_page_cache,
         primary=get_tp_info().is_primary(),
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
 def load_nvfp4_expert_sources_parallel(
-    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None
+    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None, disk_tier=None
 ):
     """parallel: same NVFP4 source banks via the common chunked multi-threaded O_DIRECT reader."""
     from freetoken.models.nvfp4_banks import load_nvfp4_expert_source_banks_parallel
@@ -223,6 +224,7 @@ def load_nvfp4_expert_sources_parallel(
         workers=workers,
         chunk=chunk,
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
diff --git a/python/freetoken/models/glm5_next/weight.py b/python/freetoken/models/glm5_next/weight.py
index fda8522..2e3b7f6 100644
--- a/python/freetoken/models/glm5_next/weight.py
+++ b/python/freetoken/models/glm5_next/weight.py
@@ -95,7 +95,10 @@ def _select_expert_source_spec(model_path: str) -> Nvfp4ExpertSourceSpec:
 _KDA_IN_PROJ = ("q_proj", "k_proj", "v_proj", "b_proj", "f_a_proj", "g_a_proj")
 
 
-def load_nvfp4_expert_sources(model_path: str, config, layer_sink=None):
+def load_nvfp4_expert_sources(model_path: str, config, *, layer_sink=None, disk_tier=None):
+    # disk_tier is threaded through the same way qwen3_5_moe does it: the caller builds the
+    # NVMe tier and every NVFP4 family's loader has to pass it down, or the tail of the bank
+    # is never registered and load fails with an unexpected-kwarg TypeError.
     return load_nvfp4_expert_source_banks(
         model_path,
         config,
@@ -103,6 +106,7 @@ def load_nvfp4_expert_sources(model_path: str, config, layer_sink=None):
         drop_page_cache=drop_page_cache,
         primary=get_tp_info().is_primary(),
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
diff --git a/python/freetoken/models/minimax_m2/weight.py b/python/freetoken/models/minimax_m2/weight.py
index 103f8ec..a010225 100644
--- a/python/freetoken/models/minimax_m2/weight.py
+++ b/python/freetoken/models/minimax_m2/weight.py
@@ -92,7 +92,7 @@ def load_nvfp4_expert_sources(
     model_path: str,
     config,
     *,
-    layer_sink=None,
+    layer_sink=None, disk_tier=None,
 ) -> dict[str, torch.Tensor]:
     """CPU NVFP4 expert source banks for the offload cache; see load_nvfp4_expert_source_banks."""
     return load_nvfp4_expert_source_banks(
@@ -102,11 +102,12 @@ def load_nvfp4_expert_sources(
         drop_page_cache=drop_page_cache,
         primary=get_tp_info().is_primary(),
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
 def load_nvfp4_expert_sources_parallel(
-    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None
+    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None, disk_tier=None
 ):
     """parallel: same NVFP4 source banks via the common chunked multi-threaded O_DIRECT reader."""
     from freetoken.models.nvfp4_banks import load_nvfp4_expert_source_banks_parallel
@@ -120,6 +121,7 @@ def load_nvfp4_expert_sources_parallel(
         workers=workers,
         chunk=chunk,
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
diff --git a/python/freetoken/models/minimax_m3/weight.py b/python/freetoken/models/minimax_m3/weight.py
index e10a723..bfcfbbd 100644
--- a/python/freetoken/models/minimax_m3/weight.py
+++ b/python/freetoken/models/minimax_m3/weight.py
@@ -249,7 +249,7 @@ def iter_weights(
 
 
 def load_nvfp4_expert_sources(
-    model_path: str, config, *, layer_sink=None
+    model_path: str, config, *, layer_sink=None, disk_tier=None
 ) -> dict[str, list[torch.Tensor]]:
     """CPU NVFP4 expert source banks for the offload cache; see load_nvfp4_expert_source_banks."""
     return load_nvfp4_expert_source_banks(
@@ -259,11 +259,12 @@ def load_nvfp4_expert_sources(
         drop_page_cache=drop_page_cache,
         primary=get_tp_info().is_primary(),
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
 def load_nvfp4_expert_sources_parallel(
-    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None
+    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None, disk_tier=None
 ):
     """parallel: same NVFP4 source banks via the common chunked multi-threaded O_DIRECT reader."""
     from freetoken.models.nvfp4_banks import load_nvfp4_expert_source_banks_parallel
@@ -277,6 +278,7 @@ def load_nvfp4_expert_sources_parallel(
         workers=workers,
         chunk=chunk,
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
diff --git a/python/freetoken/models/qwen4_exp/weight.py b/python/freetoken/models/qwen4_exp/weight.py
index f8d2a74..edd38f4 100644
--- a/python/freetoken/models/qwen4_exp/weight.py
+++ b/python/freetoken/models/qwen4_exp/weight.py
@@ -289,7 +289,7 @@ def load_ple_table(model_path: str, qwen4_args, *, pin: bool = True,
 # ======================================================================================
 
 
-def load_nvfp4_expert_sources(model_path: str, config, *, layer_sink=None) -> dict:
+def load_nvfp4_expert_sources(model_path: str, config, *, layer_sink=None, disk_tier=None) -> dict:
     """Build the CPU NVFP4 expert source banks for the offload cache (gate/up fused on the output-row axis, down separate; weight_scale_2 carried as the per-row global scale)."""
     return load_nvfp4_expert_source_banks(
         model_path,
@@ -298,11 +298,12 @@ def load_nvfp4_expert_sources(model_path: str, config, *, layer_sink=None) -> di
         drop_page_cache=drop_page_cache,
         primary=get_tp_info().is_primary(),
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )
 
 
 def load_nvfp4_expert_sources_parallel(
-    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None
+    model_path: str, config, *, workers: int = 8, chunk: int = 8 << 20, layer_sink=None, disk_tier=None
 ) -> dict:
     """parallel: same NVFP4 source banks via the common chunked multi-threaded reader."""
     from freetoken.models.nvfp4_banks import load_nvfp4_expert_source_banks_parallel
@@ -316,6 +317,7 @@ def load_nvfp4_expert_sources_parallel(
         workers=workers,
         chunk=chunk,
         layer_sink=layer_sink,
+        disk_tier=disk_tier,
     )

Written with AI assistance; every number above was measured on my hardware and I can reproduce it.

- never materialize disk-resident expert rows at load: serial loader skips
  rows >= K before get_tensor, parallel loader filters at the reader, and
  the completion tracker / placed assert count rows_per_layer instead of E.
  Load-time RAM peak is now K/E of the expert set instead of the full set.
- thread disk_tier= through the remaining six NVFP4 family loaders
  (gemma4, glm4_moe, glm5_next, minimax_m2, minimax_m3, qwen4_exp) so
  --moe-disk-tier on no longer TypeErrors outside qwen3_5_moe.
- key _sync_fetches on the banks' device type instead of
  torch.cuda.is_available(): CPU-bank unit tests no longer fall through to
  torch.cuda.default_stream(cpu_device) on CUDA machines.

Diffs from MT-z's PR FlashML-org#337 review comment, applied verbatim. Verified:
tests/moe/test_disk_tier.py 6/6 pass in a CUDA container (freetoken:local,
Rudi GPU0), incl. the two previously failing fetch_pending tests;
inspect.signature confirms disk_tier on all 13 wrappers.
- gate the layer<3 prefill debug print behind FT_DISK_TIER_DEBUG instead of
  printing unconditionally.
- drop the per-layer device->host sync: cache.usage[disk] now takes the
  0-d cache.step tensor directly (same dtype/device) instead of .item().
- validate all --moe-disk-tier v0 preconditions at once and raise a single
  ValueError listing every unmet flag (each used to cost a full boot to
  discover); list the exact flags in --moe-disk-tier --help.

E2E on Rudi (freetoken:standalone-mtz = current tree, Qwen3.6-35B-A3B-NVFP4,
TP=1, GPU0): RAM=64 4296/4296 verify match, RAM=32 4902/4902 verify match,
0 mismatches; decode 11.69 tok/s median at RAM=32 (11.85 at RAM=64 hist).
@CraigStone-Dev

Copy link
Copy Markdown
Author

Thanks for the thorough review — all three fixes are applied verbatim as de43469 (no PR needed from your side), and the two minor items plus the precondition UX are in as 795659d:

  • Fix 1 (load-time RAM peak): applied to both serial and parallel loaders.
  • Fix 2 (disk_tier= threading): all six remaining families forward it now; verified with inspect.signature on all seven families (glm5_next has no _parallel variant).
  • Fix 3 (fetch sync keyed on banks' device): tests/moe/test_disk_tier.py is 6/6 green in a CUDA container, including the two fetch_pending tests you flagged.
  • Item 4: the layer<3 prefill print is now behind FT_DISK_TIER_DEBUG, and the per-layer int(cache.step.item()) sync is gone — cache.usage[disk] takes the 0-d cache.step tensor directly (same dtype/device).
  • Item 5: all four v0 preconditions are collected and raised as a single ValueError listing every unmet flag, and the exact flags are listed in --moe-disk-tier --help.

Re-validated end to end on a 2× RTX PRO 4000 box (TP=1, nvidia/Qwen3.6-35B-A3B-NVFP4, FT_DISK_TIER_VERIFY=1): RAM=64 → 4296/4296 slot checks match; RAM=32 (87.5% of experts disk-resident, exercising the new skip path harder) → 4902/4902 match, 0 mismatches, 11.69 tok/s median decode (11.85 at RAM=64).

One question on Fix 1: the unbacked rows [K, E) rely on the allocation being lazy (no physical pages until the tier fetches into them). That held on your box (28.9 GiB shmem) and ours — worth keeping in mind if anyone runs this on a kernel/mount with eager page allocation.

Happy to un-draft once you've had a look at the two new commits.

@MT-z

MT-z commented Sep 2, 2026

Copy link
Copy Markdown

Looked at both commits, and re-ran the head on my box.

Review. de43469 is byte-identical to the three fixes I tested here (diffed the two trees: no differences). In 795659d: the layer_id < 3 print behind FT_DISK_TIER_DEBUG and the combined precondition error both check out, and cache.usage[disk] = cache.step is a plain device-side broadcast of the 0-d step tensor (same dtype and device), so the per-layer D2H sync really is gone.

Re-validation on this box (RTX 4090 24 GB, 61 GB RAM), 795659d as-is on its own base:

  • Precondition check: --moe-disk-tier on without --disable-moe-prefill-overlap and without --cuda-graph-max-bs 0 now fails once, listing both: ValueError: --moe-disk-tier on: unmet preconditions: - requires --disable-moe-prefill-overlap - requires --cuda-graph-max-bs 0.
  • Ornith-1.5-35B-A3B-NVFP4, --expert-ram-experts 128 (of 256 per layer), FT_DISK_TIER_VERIFY=1: boot 31 s, 128/256 experts per layer pinned, 906 slot verifies all match=True, RAM-prefix verify mismatches=0/768, no [disk-tier dbg] output without FT_DISK_TIER_DEBUG, no tracebacks. cgroup shmem 9.9 GiB against ~10.2 GiB expected for 128/256 of the 21.8 GB expert bytes -- the tail stayed unbacked.
  • The GLM-5.3-Flash (K=48) and Qwen3.8-Flash-Next (K=224) numbers from the bug report were taken on a tree equal to de43469, so they stand for this head as well.

On the lazy [K, E) rows -- agreed it is an invariant worth stating, and it can be checked rather than assumed. What holds it up, phase by phase:

  • During load the bank is mmap.mmap(-1, size), i.e. MAP_SHARED anonymous (shmem). Untouched shmem pages cost nothing, but on shmem a read fault allocates a page too (no zero-page sharing for shared mappings), so the invariant is "nothing reads or writes rows >= K before release_bank_tails". Both loaders now honour it (the serial one skips before get_tensor, the parallel one filters at the reader), and PinPipeline(prefix_rows=K) pins only the prefix -- that matters because cudaHostRegister (and mlock) fault every page in.
  • After load release_range replaces [K, E) with a MAP_PRIVATE|MAP_ANONYMOUS mapping at the same address, so from then on the rows have plain private-anon semantics: reads map the shared zero page and only writes allocate -- and nothing writes them: the fetch path lands O_DIRECT preadv in the pinned staging buffer and goes H2D straight into the slot, never through the bank tail. That part does not depend on the shmem mount at all.

Things that would break it: MAP_POPULATE (not used), pinning or mlocking a range that covers the tail, a debug/verify pass that reads the whole bank, or THP with shmem_enabled=always|force, which can back a 2 MiB huge page on first touch of a row -- bounded to one extra huge page per touched row; untouched rows stay unbacked. Our runs were with shmem_enabled=never, enabled=madvise (Ubuntu defaults). A quick experiment confirms the read-fault part: a 64 MiB MAP_SHARED anon mapping shows 0 resident, writing a 4 MiB prefix backs exactly 4 MiB, and reading one page of the tail backs exactly one page.

Evidence from real loads: cgroup memory.stat shmem = 28.9 GiB for GLM-5.3-Flash at K=48 (= 48/288 x 166 GiB) and 30.0 GiB for Qwen3.8-Flash-Next at K=224 (~224/512 x 63.3 GiB plus staging), plus the 9.9 GiB for Ornith at K=128 on this head (above), all under MemoryMax=46G with MemorySwapMax=0 and oom_kill 0 -- eager backing would have shown up as a kill, not as a silent regression.

Suggestion: assert it cheaply at startup with mincore(2) over the tail byte range right after release_bank_tails -- one syscall per bank layer, returns per-page residency -- and log "disk tier: tail rows resident X MiB of Y GiB", warning above a small threshold (mincore also counts zero-page reads, so it is conservative; cgroup memory.stat shmem is the number that actually costs RAM). The same check makes a CPU unit test: allocate a small bank, fill the prefix, release the tail, assert zero resident pages in [K, E). Happy to send that as a follow-up if you want it in this PR.

No objection to un-drafting from my side.

Written with AI assistance; every number above was measured on my hardware and I can reproduce it.

…backed

Implements the follow-up MT-z proposed in the PR FlashML-org#337 review: the lazy-tail
invariant (nothing reads/writes rows [K, E) after release_bank_tails, so they
cost no RAM) is now checked rather than assumed.

- tail_resident_bytes(): mincore(2) over one bank's tail byte range.
- check_tail_unbacked(): runs in both NVFP4 loaders right after
  release_bank_tails; logs 'tail check rank=r/n: resident X MiB of Y MiB'
  and warns above the THP bound (one 2 MiB huge page per bank layer --
  shmem_enabled=always|force can back the prefix/tail boundary as a huge
  page; more than that means something touched the tail).
- test_tail_unbacked_after_release: small bank, fill prefix, release tail,
  assert zero resident pages in [K, E); one tail write backs exactly one page.

Verified on Rudi (shmem_enabled=always -- the config MT-z flagged as the
risky one): real boot at RAM=32 logs 'resident 0 MiB of 15172 MiB', no
warning; E2E 4902/4902 slot verifies match, 11.59 tok/s median.
@CraigStone-Dev

Copy link
Copy Markdown
Author

Great write-up — the phase-by-phase breakdown is exactly the right way to state the invariant, and I verified the load-side mechanics against the tree (in-place MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED remap, PinPipeline(prefix_rows=K)pin_prefix only, fetch path staging→H2D never through the bank tail). I also reproduced your 64 MiB read-fault experiment locally: 0 pages fresh, 1024 after a 4 MiB prefix write, 1025 after reading one tail page.

One data point from our side that makes your suggestion timely: our test box (2× RTX PRO 4000) runs shmem_enabled=always — one of the two configs you named. So rather than wait for a follow-up, I implemented the check in this PR as b602e6d:

  • tail_resident_bytes()mincore(2) over one bank's tail byte range.
  • check_tail_unbacked() — runs in both NVFP4 loaders right after release_bank_tails; logs [disk-tier] tail check rank=r/n: resident X MiB of Y MiB (warn bound Z MiB = 1x2MiB per bank layer) and warns above the THP bound (one 2 MiB huge page per bank layer, per your boundary analysis).
  • test_tail_unbacked_after_release — small bank, fill prefix, release tail, assert zero resident pages in [K, E); then one tail write backs exactly one page.

Result on the shmem_enabled=always box: real boot at --expert-ram-experts 32 (of 256) logs resident 0 MiB of 15172 MiB — the tail is fully unbacked even under the risky THP config, no warning. Full E2E still green: 4902/4902 slot verifies, 11.59 tok/s median. test_disk_tier.py is 7/7.

Un-drafting now — thanks again for the review, it caught the one bug that would have made the tier useless for its target case.

@CraigStone-Dev
CraigStone-Dev marked this pull request as ready for review September 2, 2026 23:38
@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Writing that phase-by-phase answer sent me back into the release path, because I wanted to be sure of the second half of what I had claimed: that once release_bank_tails has run, the tail rows behave like ordinary private anonymous memory. Reading release_range again with that in mind, the step does not hold. It gives back the address range, not the pages. So I measured it, on a 2 GiB bank with the whole bank written and then the 1.5 GiB tail released, inside a systemd scope with memory accounting:

tail release memory.current memory.stat shmem RSS
current release_range (munmap + MAP_FIXED) 2056 -> 2056 2048 -> 2048 2051 -> 515
shared + MADV_DONTNEED 2055 -> 2055 2048 -> 2048 2051 -> 515
shared + MADV_REMOVE 2056 -> 518 2048 -> 512 2051 -> 515
MAP_PRIVATE + MADV_DONTNEED 2053 -> 517 0 (anon instead) 2051 -> 515

RSS falls in every row, which is what makes this easy to miss. The pages are still charged: mmap(-1) is MAP_SHARED anonymous, so the mapping is backed by an internal shmem object, and the prefix VMA keeps that whole object alive. Unmapping the tail range removes the VMA but leaves the object's pages in the page cache. Only punching a hole in the object (MADV_REMOVE) or not sharing it in the first place actually returns them. Measured on the charge, the remap and the MADV_DONTNEED it replaced land in the same place, and HostBank.release() -- a whole-buffer MADV_DONTNEED -- frees nothing either. What that means for this PR: if anything ever touches the tail -- the eager-allocation case you asked about, a future verify pass, a debug read -- release_bank_tails cannot get that RAM back for the life of the process. Your new boot check catches the touch; nothing undoes it.

The one-line version of the fix is to stop sharing. mmap.mmap(-1, n) is MAP_SHARED only because that is CPython's default; passing flags=MAP_PRIVATE|MAP_ANONYMOUS gives:

  • reads of an untouched row map the shared zero page, so the invariant weakens from "nothing may read or write rows >= K" to "nothing may write them" -- a stray read pass over the tail costs 1536 MiB today and 0 with this change;
  • release_range becomes one madvise(MADV_DONTNEED) that really frees, instead of munmap + mmap(MAP_FIXED), which also removes the window where the range is unmapped and a concurrent touch would take SIGSEGV;
  • release() starts doing what its docstring says.

MADV_REMOVE on the shared mapping is the alternative and it frees correctly too, but it keeps the read hazard and keeps the special case.

To be explicit about what this is not: it frees nothing today. With the loaders as they are, rows >= K are never touched, so there is nothing to release and the A/B totals below match. The change is about what happens the first time something does touch them -- today that is silent, costly and irreversible, and the same slip is easy to make again, since the tail is ordinary addressable memory that looks free to read.

Checks before proposing it: cudaHostRegister on a private mapping and a H2D copy out of the pinned prefix both work (pin_prefix unchanged); the loaders are thread pools, and TP ranks are mp.Process under set_start_method("spawn") (server/launch.py:158) with each rank allocating its own banks, so nothing needs the mapping shared and there is no fork/COW path. One addition in the patch: with madvise instead of a remap, dropping pages under a cudaHostRegister'd range would corrupt silently, so release_range now asserts the range does not overlap the pinned prefix -- the constraint your docstring already stated.

Note for your new tail check: with a private bank the tail shows as anon rather than shmem, and the THP warn bound is unchanged (enabled=always can still back the prefix/tail boundary with one 2 MiB page).

Validation on this box (RTX 4090 24 GB, 61 GB RAM), on b602e6d with the patch:

A/B on this box, 2 runs each, alternating stock b602e6d + patch
cgroup memory.current 12.32, 12.32 GiB 12.30, 12.30 GiB
cgroup memory.peak 25.84, 25.84 GiB 25.83, 25.83 GiB
cgroup anon / shmem 2.36 / 9.86 GiB 10.89 / 1.33 GiB
slot verifies match=True / False 414 / 0 414 / 0
RAM-prefix verify mismatches=0/768 mismatches=0/768
your tail check resident 0 MiB of 8670 MiB resident 0 MiB of 8670 MiB
time to first completion 31, 31 s 26, 26 s
oom_kill 0 0

Ornith-1.5-35B-A3B-NVFP4, --expert-ram-experts 128 of 256, FT_DISK_TIER_VERIFY=1, same flags throughout, greedy output identical. An earlier pair of runs gave the same verify counts and tail check. The bank moves from shmem to anon and the totals agree to 0.02 GiB, so this costs nothing and saves nothing. The load did come out faster on the patched runs (an earlier stock run also hit 26 s), but with this many runs I would not read a speed-up into it. tests/moe/test_disk_tier.py is 7/7 with the patch (your new tail-check test included), and tests/moe/test_offload.py + tests/kernels/test_pinned_tensor.py are 32 passed.

Diff below. Happy to open it as a PR against your branch instead if you prefer -- or to leave it until after this one merges, since the mapping flag is main's code and only the release path is yours.

Patch
diff --git a/python/freetoken/moe/host_banks.py b/python/freetoken/moe/host_banks.py
index 388893c..c863f95 100644
--- a/python/freetoken/moe/host_banks.py
+++ b/python/freetoken/moe/host_banks.py
@@ -79,7 +79,7 @@ class HostBank:
 
     The buffer is rounded up to the O_DIRECT block; ``tensor`` views exactly ``nbytes``. ``backing=None`` follows ``FREETOKEN_BANK_CUDA_ALLOC``."""
 
-    __slots__ = ("tensor", "addr", "nbytes", "_buf", "_pinned", "_locked")
+    __slots__ = ("tensor", "addr", "nbytes", "_buf", "_pinned", "_pinned_bytes", "_locked")
 
     def __init__(self, shape: tuple[int, ...], dtype: torch.dtype,
                  *, backing: str | None = None):
@@ -104,11 +104,19 @@ class HostBank:
             self.addr = raw.data_ptr() + off
             assert self.addr % _BLK == 0
             self._pinned = True  # born pinned+mapped; pin() is a no-op
+            self._pinned_bytes = asize
         else:
-            self._buf = mmap.mmap(-1, asize)  # lazy: address space only, no resident pages yet
+            # MAP_PRIVATE, not CPython's default MAP_SHARED: on a shared anonymous mapping a
+            # *read* fault allocates a page (no zero-page sharing) and MADV_DONTNEED is ignored,
+            # so an untouched region is only free by convention and a freed one never comes back.
+            # Private anonymous gives both for real: reads map the shared zero page, and
+            # release_range() actually returns memory. Nothing needs the mapping to be shared --
+            # the loaders are thread pools and ranks are mp-spawned, each with its own banks.
+            self._buf = mmap.mmap(-1, asize, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS)
             _LIVE_BUFFERS.append(self._buf)
             self.addr = ctypes.addressof(ctypes.c_char.from_buffer(self._buf))
             self._pinned = False
+            self._pinned_bytes = 0
         self.tensor = torch.frombuffer(self._buf, dtype=dtype, count=self.nbytes // elsize).view(*shape)
         self._locked = False
 
@@ -140,6 +148,7 @@ class HostBank:
                 f"cudaHostRegister failed for {len(self._buf) / 2**30:.1f} GiB"
             ) from exc
         self._pinned = True
+        self._pinned_bytes = len(self._buf)
 
     def pin_prefix(self, nrows: int) -> None:
         """Pin only the first ``nrows`` rows (disk tier: the rest stays disk-resident).
@@ -160,44 +169,32 @@ class HostBank:
                 f"cudaHostRegister failed for {nbytes / 2**30:.1f} GiB prefix"
             ) from exc
         self._pinned = True
+        self._pinned_bytes = nbytes
 
     def release_range(self, offset: int, nbytes: int) -> None:
-        """Free a byte range of the backing mapping by replacing it IN PLACE with a
-        fresh MAP_PRIVATE anonymous mapping at the same virtual address.
-
-        HostBank's buffer is a MAP_SHARED /dev/zero mapping (CPython's
-        ``mmap(-1)``), and the kernel silently ignores MADV_DONTNEED on shared
-        mappings -- the pages would stay resident. Replacing the range with a
-        private zero mapping frees them while keeping every existing pointer
-        and torch view valid (same address). The range must be page-aligned
-        and must not overlap a pinned prefix (the disk tier's unpinned tails).
-        """
-        import ctypes
+        """Free a byte range of the backing mapping with MADV_DONTNEED.
+
+        The bank is a MAP_PRIVATE anonymous mapping, so dropping a range frees the
+        pages outright and a later read faults the shared zero page again; every
+        existing pointer and torch view stays valid (the mapping is never replaced).
 
-        _BLK = 4096
+        The range must be page-aligned and must not overlap the pinned prefix:
+        dropping pages under a cudaHostRegister'd range corrupts silently, so it is
+        asserted here rather than left to the caller (the disk tier's unpinned tails).
+        """
         assert offset % _BLK == 0 and nbytes % _BLK == 0, (
             "release_range: page-aligned range required")
-        libc = ctypes.CDLL("libc.so.6", use_errno=True)
-        libc.munmap.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
-        libc.munmap.restype = ctypes.c_int
-        libc.mmap.restype = ctypes.c_void_p
-        libc.mmap.argtypes = [
-            ctypes.c_void_p, ctypes.c_size_t, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_long]
-        addr = self.addr + offset
-        if libc.munmap(addr, nbytes) != 0:
-            raise OSError(ctypes.get_errno(), "munmap failed")
-        PROT_READ_WRITE = 3
-        MAP_PRIVATE_ANON = 0x22  # MAP_PRIVATE | MAP_ANONYMOUS
-        MAP_FIXED = 0x10
-        MAP_FAILED = (1 << 64) - 1
-        new_addr = libc.mmap(addr, nbytes, PROT_READ_WRITE, MAP_PRIVATE_ANON | MAP_FIXED, -1, 0)
-        if new_addr in (None, MAP_FAILED):
-            raise OSError(ctypes.get_errno(), "mmap(MAP_FIXED) failed")
-        assert new_addr == addr, "MAP_FIXED returned a different address"
+        assert offset >= self._pinned_bytes, (
+            f"release_range: [{offset}, {offset + nbytes}) overlaps the pinned prefix "
+            f"[0, {self._pinned_bytes})")
+        if nbytes:
+            self._buf.madvise(mmap.MADV_DONTNEED, offset, nbytes)
+
     def release(self) -> None:
         """Drop the resident pages; the address space stays valid, the contents become undefined.
 
-        For buffers that are done being read (the converter). No-op for born-pinned banks: registered pages cannot be dropped."""
+        For buffers that are done being read (the converter). No-op for born-pinned banks: registered pages cannot be dropped.
+        (This frees memory only because the mapping is MAP_PRIVATE; the kernel ignores MADV_DONTNEED on shared ones.)"""
         if self._pinned:
             return
         self._buf.madvise(mmap.MADV_DONTNEED)

Written with AI assistance; every number above was measured on my hardware and I can reproduce it.

@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Found one more while stress-testing the tier on a deliberately small card: FT_DISK_TIER_VERIFY=1 crashes any boot that captures CUDA graphs, whether or not the tier is on.

Repro on this box with no tier flags at all (RTX 4090, 22.67 GiB free):

FT_DISK_TIER_VERIFY=1 ft serve --model-path ornith-ai/Ornith-1.5-35B-A3B-NVFP4 \
  --moe-cache-auto --memory-ratio 0.9 --kv-reserve-tokens 8192
Capturing graphs: bs = 4 | avail_mem = 0.78 GiB
[copy-miss] layer=0 fused=True n=8 evict=[0, 1, 2, 3] src=[74, 112, 160, 196]
torch.AcceleratorError: CUDA error: operation not permitted when stream is capturing   (cudaErrorStreamCaptureUnsupported)
torch.AcceleratorError: CUDA error: operation failed due to a previous error during capture   (cudaErrorStreamCaptureInvalidated)

The frame is offload_cache.py:1037, in copy_missing:

if os.environ.get("FT_DISK_TIER_VERIFY") and layer_id == 0:
    print(f"[copy-miss] layer={layer_id} fused={self._copy_fused_ok} "
          f"n={int(self.num_indices.item())} "
          f"evict={self.evict_slots[:4].cpu().tolist()} "
          f"src={self.src_indices[:4].cpu().tolist()}", flush=True)

copy_missing runs inside the captured decode graph, and .item() / .cpu() are device-to-host syncs, which capture forbids.

Two things make it easy to hit:

  • The gate is the environment variable alone. The other two verify sites reachable with the tier off check the tier first (offload_cache.py:1068 tests self._disk_tier is not None, layers/moe.py:315 tests cache.disk_tier_enabled); this one does not, so it fires with --moe-disk-tier off entirely.
  • With the tier on it can never fire, because v0 requires --cuda-graph-max-bs 0. So it only appears when the variable is still exported from an earlier tier session -- which is exactly how I hit it: every graph-capturing run in my harness failed, and I first misread it as a small-card problem.

Not memory related: it fails identically at 22.67 GiB free and at 10.29 GiB free, and lowering --memory-ratio to 0.85 or 0.8 does not help. With the variable unset, the same 10.29 GiB configuration boots in 21 s (2950 slots) and serves.

Suggested fix -- gate it like its neighbours and skip it while capturing:

if (self._disk_tier is not None and layer_id == 0
        and os.environ.get("FT_DISK_TIER_VERIFY")
        and not torch.cuda.is_current_stream_capturing()):

or simply drop the print: verify_decode_mapping and verify_ram already cover the same ground, and both are properly gated.

While I was there, the rest of the low-VRAM picture came out clean: with VRAM ballasted down to 10.29 GiB free, Ornith-1.5-35B-A3B-NVFP4 boots through the tier at --expert-ram-experts 128 in 25 s (2910 slots, 252 slot verifies all matching, tail check resident 0 MiB of 8670 MiB), and --expert-ram-experts 256 is rejected with a legible --expert-ram-experts must be in (0, 256).

Written with AI assistance; every number above was measured on my hardware and I can reproduce it.

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.

2 participants