test_fast_load_tp_slice.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. """fast_load TP slicing (moe_ep_size 1): CPU check on synthetic shards with the checkpoint's expert shapes.
  2. Routed experts use the real shapes and dtypes (w1/w3 I8 [2304, 2560] + F8_E8M0 scale [2304, 160],
  3. w2 I8 [5120, 1152] + scale [5120, 72]; 4 target experts, 2 draft experts), next to a shared expert,
  4. non-expert tensors of every other dtype and an Engram table. For every rank at EP1 (moe_tp 4) and
  5. EP2 (moe_tp 2), in target and draft phase, the tensors come out of the wrapped ``safe_open`` the way
  6. ``buffered_multi_thread_safetensors_weights_iterator`` takes them, go through FusedMoE's own
  7. ``_load_w13`` / ``_load_w2`` when sglang is importable (else a mirror of their narrowing), and the
  8. bytes that land in the expert parameter must equal the stock ``safe_open`` path. Also: EP2 gets the
  9. exact legacy read list (byte-identical behaviour), out-of-slice access raises, the pacing budget
  10. counts the resident slice, and bounce buffers are bounded and released.
  11. """
  12. import concurrent.futures
  13. import json
  14. import os
  15. import shutil
  16. import sys
  17. import tempfile
  18. import threading
  19. import time
  20. import types
  21. os.environ["DSV41_FAST_LOAD"] = "1"
  22. os.environ.pop("DSV41_FAST_LOAD_TP_SLICE", None)
  23. sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "adapter"))
  24. import torch # noqa: E402
  25. import safetensors # noqa: E402
  26. import safetensors.torch as st # noqa: E402
  27. import fast_load as fl # noqa: E402
  28. if not hasattr(os, "posix_fadvise"): # macOS dev runs; the image is Linux
  29. os.posix_fadvise = lambda *a: None
  30. os.POSIX_FADV_DONTNEED = 4
  31. RAW_OPEN = safetensors.safe_open
  32. E8M0 = torch.float8_e8m0fnu
  33. EXPERT = {"w1.weight": ((2304, 2560), torch.int8), "w1.scale": ((2304, 160), E8M0),
  34. "w3.weight": ((2304, 2560), torch.int8), "w3.scale": ((2304, 160), E8M0),
  35. "w2.weight": ((5120, 1152), torch.int8), "w2.scale": ((5120, 72), E8M0)}
  36. N_TARGET, N_DRAFT = 4, 2
  37. g = torch.Generator().manual_seed(0)
  38. def rnd(shape, dtype):
  39. if dtype.is_floating_point and torch.finfo(dtype).bits == 8:
  40. return torch.randint(0, 256, shape, dtype=torch.uint8, generator=g).view(dtype)
  41. if dtype in (torch.int8,):
  42. return torch.randint(-128, 128, shape, dtype=dtype, generator=g)
  43. return torch.randn(shape, generator=g).to(dtype)
  44. def experts(prefix, ids):
  45. return {f"{prefix}.ffn.experts.{e}.{k}": rnd(*v) for e in ids for k, v in EXPERT.items()}
  46. def build(d):
  47. s1 = {"layers.0.attn.wq_a.weight": rnd((96, 128), torch.bfloat16),
  48. "layers.0.attn.wq_a.scale": rnd((3, 4), torch.float32),
  49. "layers.0.attn.wo_a.weight": rnd((64, 128), torch.float8_e4m3fn),
  50. "layers.0.ffn.shared_experts.w1.weight": rnd((2304, 2560), torch.int8),
  51. "layers.0.ffn.shared_experts.w2.scale": rnd((5120, 72), E8M0),
  52. "layers.0.engram.embed.weight": rnd((512, 64), torch.bfloat16)}
  53. s1.update(experts("layers.0", range(0, 2)))
  54. s2 = experts("layers.0", range(2, 4))
  55. s2["layers.0.ffn.gate.weight"] = rnd((4, 5120), torch.bfloat16)
  56. s3 = {"mtp.0.attn.wq_a.weight": rnd((96, 128), torch.bfloat16),
  57. "mtp.0.ffn.shared_experts.w2.weight": rnd((5120, 1152), torch.int8)}
  58. s3.update(experts("mtp.0", range(N_DRAFT)))
  59. files = []
  60. for i, sd in enumerate((s1, s2, s3)):
  61. p = os.path.join(d, f"model-{i + 1:05d}-of-00003.safetensors")
  62. st.save_file(sd, p)
  63. files.append(p)
  64. with open(os.path.join(d, "config.json"), "w") as f:
  65. json.dump({"text_config": {"n_routed_experts": N_TARGET}}, f)
  66. return files
  67. # ---- the engine's narrowing: FusedMoE._load_w13 / _load_w2 when sglang is importable ----
  68. try:
  69. from sglang.srt.layers.moe.fused_moe_triton.layer import FusedMoE
  70. ENGINE = "FusedMoE._load_w13/_load_w2"
  71. except Exception as exc: # noqa: BLE001
  72. FusedMoE = None
  73. ENGINE = f"mirror of FusedMoE narrowing (sglang not importable: {type(exc).__name__})"
  74. def engine_copy(lw, proj, moe_tp_rank, moe_tp_size, shape):
  75. """What one rank's expert parameter holds after the FusedMoE loader copied ``lw`` into it."""
  76. shard_dim = fl._TP_SLICE_DIM[proj]
  77. n = shape[shard_dim] // moe_tp_size
  78. if proj in ("w1", "w3"):
  79. ed_shape = (2 * n,) + tuple(shape[1:])
  80. else:
  81. ed_shape = tuple(shape[:1]) + (n,)
  82. ed = torch.zeros(ed_shape, dtype=torch.uint8).view(lw.dtype)
  83. if FusedMoE is not None:
  84. fake = types.SimpleNamespace(
  85. moe_tp_size=moe_tp_size, moe_runner_config=types.SimpleNamespace(is_gated=True),
  86. quant_method=types.SimpleNamespace(load_up_proj_weight_first=True), use_padded_loading=False,
  87. use_presharded_weights=False, use_triton_kernels=False, quant_config=None)
  88. loader = FusedMoE._load_w2 if proj == "w2" else FusedMoE._load_w13
  89. loader(fake, expert_data=ed, shard_dim=shard_dim, shard_id=proj, loaded_weight=lw, tp_rank=moe_tp_rank)
  90. else:
  91. ls = lw.shape[shard_dim] // moe_tp_size
  92. src = lw.narrow(shard_dim, ls * moe_tp_rank, ls)
  93. start = n if proj == "w1" else 0 # load_up_proj_weight_first: [w3; w1]
  94. ed.narrow(shard_dim, start, n if proj != "w2" else ed_shape[1]).copy_(src)
  95. return ed.view(torch.uint8).clone()
  96. def raw_bytes(t):
  97. return t.contiguous().view(torch.uint8).reshape(-1)
  98. def routed(name):
  99. if fl._expert_id(name) is None:
  100. return None
  101. proj, kind = name.rsplit(".", 2)[1:]
  102. return proj if proj in fl._TP_SLICE_DIM and kind in fl._TP_SLICE_KINDS else None
  103. def run_rank(files, phase, ep, tp, mode):
  104. """One rank's load of every shard through the wrapped safe_open; checks bytes, returns stats."""
  105. if mode is None:
  106. os.environ.pop("DSV41_FAST_LOAD_TP_SLICE", None)
  107. else:
  108. os.environ["DSV41_FAST_LOAD_TP_SLICE"] = mode
  109. fl._ep_info = lambda: ep
  110. fl._moe_tp_info = lambda: tp
  111. fl._state["phase"] = phase
  112. seen_plans = []
  113. orig_init = fl._EagerShard.__init__
  114. def spy(self, inner, path, names):
  115. seen_plans.append((path, names))
  116. orig_init(self, inner, path, names)
  117. fl._EagerShard.__init__ = spy
  118. Slice = fl._tp_slice_cls()
  119. stats = {"sliced": 0, "whole_eager": 0, "copies": 0, "kept_per_shard": []}
  120. before = dict(fl._state)
  121. try:
  122. for path in files:
  123. with safetensors.safe_open(path, framework="pt", device="cpu") as f, \
  124. RAW_OPEN(path, framework="pt", device="cpu") as r:
  125. got = {k: f.get_tensor(k) for k in f.keys()} # as _load_file does
  126. kept = 0
  127. eager = set(seen_plans[-1][1]) if seen_plans and seen_plans[-1][0] == path else set()
  128. for k, t in got.items():
  129. ref = r.get_tensor(k)
  130. proj = routed(k)
  131. moe_ep_owned = proj is not None and (
  132. phase == "draft" or k.startswith("mtp.") is False and ep is not None and
  133. fl._expert_id(k) // (N_TARGET // ep[1]) == ep[0])
  134. if isinstance(t, Slice):
  135. assert proj is not None and k in eager, k
  136. stats["sliced"] += 1
  137. kept += t._dsv41_resident_nbytes
  138. assert tuple(t.shape) == tuple(ref.shape) and t.dtype == ref.dtype, k
  139. elif k in eager:
  140. stats["whole_eager"] += 1
  141. kept += t.numel() * t.element_size()
  142. if moe_ep_owned and tp is not None:
  143. # the bytes that land in this rank's expert parameter
  144. a = engine_copy(t, proj, tp[0], tp[1], list(ref.shape))
  145. b = engine_copy(ref, proj, tp[0], tp[1], list(ref.shape))
  146. assert torch.equal(a, b), (k, phase, ep, tp)
  147. stats["copies"] += 1
  148. else:
  149. assert not isinstance(t, Slice), k
  150. assert t.dtype == ref.dtype and t.shape == ref.shape, k
  151. assert torch.equal(raw_bytes(t), raw_bytes(ref)), k
  152. stats["kept_per_shard"].append(kept)
  153. finally:
  154. fl._EagerShard.__init__ = orig_init
  155. stats["read"] = fl._state["bytes"] - before["bytes"]
  156. stats["resident"] = fl._state["resident"] - before["resident"]
  157. stats["plans"] = seen_plans
  158. return stats
  159. def main():
  160. d = tempfile.mkdtemp(prefix="fl_tp_slice_")
  161. try:
  162. files = build(d)
  163. fl.install_weight_utils(types.SimpleNamespace(safetensors=None))
  164. assert safetensors.safe_open is not RAW_OPEN
  165. n = fl._n_routed_experts(files[0])
  166. assert n == N_TARGET, n
  167. # -- per-expert accounting on the real shapes --
  168. info = {k: {"dtype": "I8" if v[1] == torch.int8 else "F8_E8M0", "shape": list(v[0]),
  169. "data_offsets": [0, v[0][0] * v[0][1]]} for k, v in EXPERT.items()}
  170. full = sum(i["data_offsets"][1] for i in info.values())
  171. ep1 = [fl.spec_bytes(i, fl.slice_spec("layers.0.ffn.experts.0." + k, i, (1, 4))) for k, i in info.items()]
  172. read1, kept1 = sum(r for r, _ in ep1), sum(h for _, h in ep1)
  173. assert full == 18_800_640 and read1 == 9_400_320 and kept1 == 4_700_160, (full, read1, kept1)
  174. print(f"per expert: whole {full / 1e6:.2f} MB; EP1 slice reads {read1 / 1e6:.2f} MB, keeps {kept1 / 1e6:.2f} MB")
  175. print(f"projected per rank (384 experts x 40 layers): EP2 whole owned {192 * 40 * full / 1e9:.1f} GB read; "
  176. f"EP1 sliced {384 * 40 * read1 / 1e9:.1f} GB read, {384 * 40 * kept1 / 1e9:.1f} GB kept")
  177. # -- EP2 default: exactly today's read list (a plain list = every tensor whole) --
  178. legacy_total = {}
  179. for r in range(4):
  180. ep, tp = (r // 2, 2), (r % 2, 2)
  181. for phase in ("target", "draft"):
  182. s = run_rank(files, phase, ep, tp, None)
  183. assert s["sliced"] == 0
  184. for path, names in s["plans"]:
  185. assert isinstance(names, list), "EP2 must hand _EagerShard the legacy name list"
  186. assert names == fl.needed_names(path, phase, ep if phase == "target" else None, n)
  187. want = sum(b - a for f in files
  188. for a, b in fl.needed_ranges(f, phase, ep if phase == "target" else None, n))
  189. assert s["read"] == want == s["resident"], (s["read"], want)
  190. legacy_total[(r, phase)] = s
  191. print(f"EP2 default: legacy read lists on all 4 ranks, {ENGINE} copies bitwise equal")
  192. # -- EP1: every rank slices every routed expert (target and draft) --
  193. for r in range(4):
  194. ep, tp = (0, 1), (r, 4)
  195. for phase in ("target", "draft"):
  196. s = run_rank(files, phase, ep, tp, None)
  197. n_exp = (N_TARGET if phase == "target" else N_DRAFT) * len(EXPERT)
  198. assert s["sliced"] == n_exp and s["copies"] == n_exp, (phase, s["sliced"], s["copies"])
  199. want = sum(b - a for f in files for a, b in fl.plan_ranges(
  200. f, fl.read_plan(f, phase, ep if phase == "target" else None, n, tp)))
  201. assert s["read"] == want, (s["read"], want)
  202. e2 = legacy_total[(r, phase)]
  203. # at most the EP2 bytes read, and a smaller resident window per shard
  204. assert s["read"] <= e2["read"] + 1, (phase, s["read"], e2["read"])
  205. assert max(s["kept_per_shard"]) <= max(e2["kept_per_shard"]), (s["kept_per_shard"], e2["kept_per_shard"])
  206. if phase == "target":
  207. assert s["read"] - (e2["read"] - N_TARGET // 2 * full) == N_TARGET * read1
  208. print(f"EP1: 4 ranks x (target, draft) sliced, {ENGINE} copies bitwise equal to stock")
  209. # -- EP2 with DSV41_FAST_LOAD_TP_SLICE=1: owned experts sliced by moe_tp 2 --
  210. for r in range(4):
  211. s = run_rank(files, "target", (r // 2, 2), (r % 2, 2), "1")
  212. assert s["sliced"] == N_TARGET // 2 * len(EXPERT) and s["read"] < legacy_total[(r, "target")]["read"]
  213. print("EP2 + TP_SLICE=1: owned experts sliced, bitwise equal")
  214. # -- EP1 with TP_SLICE=0: experts left to mmap, never read whole eagerly --
  215. s = run_rank(files, "target", (0, 1), (2, 4), "0")
  216. assert s["sliced"] == 0
  217. for path, names in s["plans"]:
  218. assert all(fl._expert_id(k) is None for k in names), path
  219. s = run_rank(files, "target", (0, 1), None, None) # moe_tp unknown: same guard
  220. for path, names in s["plans"]:
  221. assert all(fl._expert_id(k) is None for k in names), path
  222. print("EP1 + TP_SLICE=0 / unknown moe_tp: routed experts stay on the stock mmap path")
  223. # -- out-of-slice access raises --
  224. os.environ.pop("DSV41_FAST_LOAD_TP_SLICE", None)
  225. fl._ep_info, fl._moe_tp_info, fl._state["phase"] = (lambda: (0, 1)), (lambda: (1, 4)), "target"
  226. with safetensors.safe_open(files[0], framework="pt", device="cpu") as f, \
  227. RAW_OPEN(files[0], framework="pt", device="cpu") as r:
  228. w1 = f.get_tensor("layers.0.ffn.experts.1.w1.weight")
  229. w2 = f.get_tensor("layers.0.ffn.experts.1.w2.scale")
  230. ref1 = r.get_tensor("layers.0.ffn.experts.1.w1.weight")
  231. ref2 = r.get_tensor("layers.0.ffn.experts.1.w2.scale")
  232. rest = {k: f.get_tensor(k) for k in f.keys()}
  233. assert torch.equal(w1.narrow(0, 576, 576), ref1.narrow(0, 576, 576))
  234. assert torch.equal(torch.narrow(w1, 0, 600, 10), ref1[600:610])
  235. assert torch.equal(w1.narrow(-2, 576, 576), ref1[576:1152])
  236. assert torch.equal(raw_bytes(w2.narrow(1, 18, 18)), raw_bytes(ref2[:, 18:36]))
  237. assert w1.shape == ref1.shape and w1.size(0) == 2304 and w1.dim() == 2 and w1.device.type == "cpu"
  238. assert "TpSliceTensor" in repr(w1)
  239. bad = [lambda: w1.narrow(0, 0, 576), lambda: w1.narrow(0, 576, 577), lambda: w1.narrow(1, 0, 10),
  240. lambda: w1.narrow(0, 1100, 100), lambda: w2.narrow(0, 0, 10), lambda: w2.narrow(1, 0, 18),
  241. lambda: w1[0], lambda: w1[576:1152], lambda: w1.view(-1), lambda: w1.clone(),
  242. lambda: w1.contiguous(), lambda: w1.float(), lambda: w1.numpy(), lambda: w1.t(),
  243. lambda: w1.to("cpu"), lambda: torch.zeros(2304, 2560, dtype=torch.int8).copy_(w1),
  244. lambda: torch.equal(w1, ref1), lambda: w1.untyped_storage(), lambda: w1 + 1]
  245. for i, fn in enumerate(bad):
  246. try:
  247. fn()
  248. except RuntimeError as exc:
  249. assert "DSV41 fast load" in str(exc), (i, exc)
  250. else:
  251. raise AssertionError(f"out-of-slice access #{i} did not raise")
  252. del w1, w2, rest
  253. print(f"out-of-slice access: {len(bad)} cases raise")
  254. # -- pacing counts the resident slice, not the full shape (one buffer per tensor: a view
  255. # into a slab charges its whole slab instead, see test_fast_load_slab.py) --
  256. os.environ["DSV41_FAST_LOAD_SLAB_MB"] = "0"
  257. with safetensors.safe_open(files[1], framework="pt", device="cpu") as f:
  258. parts = [f.get_tensor(k) for k in f.keys() if k.endswith(".w1.weight") or k.endswith(".w3.weight")]
  259. os.environ.pop("DSV41_FAST_LOAD_SLAB_MB", None)
  260. one = parts[0]._dsv41_resident_nbytes
  261. full_one = parts[0].numel()
  262. assert one * 4 == full_one
  263. os.environ["DSV41_FAST_LOAD_INFLIGHT_GB"] = repr(3 * one / 2**30)
  264. def orig(*, executor, futures, use_async, func, func_args=(), func_kwargs=None):
  265. futures.append(executor.submit(func, *func_args, **(func_kwargs or {})))
  266. mod = types.SimpleNamespace(maybe_executor_submit=orig)
  267. fl.install_deepseek_v4(mod)
  268. lock, cur, peak, conc, pconc = threading.Lock(), [0], [0], [0], [0]
  269. param = torch.nn.Parameter(torch.zeros(1), requires_grad=False)
  270. def work(p, t):
  271. with lock:
  272. cur[0] += t._dsv41_resident_nbytes; conc[0] += 1
  273. peak[0], pconc[0] = max(peak[0], cur[0]), max(pconc[0], conc[0])
  274. time.sleep(0.003)
  275. with lock:
  276. cur[0] -= t._dsv41_resident_nbytes; conc[0] -= 1
  277. futures = []
  278. with concurrent.futures.ThreadPoolExecutor(24) as ex:
  279. for i in range(60):
  280. mod.maybe_executor_submit(executor=ex, futures=futures, use_async=True, func=work,
  281. func_args=(param, parts[i % len(parts)]))
  282. for fu in concurrent.futures.as_completed(futures):
  283. fu.result()
  284. assert peak[0] <= 3 * one and pconc[0] >= 2, (peak[0], pconc[0])
  285. print(f"pacing: peak {peak[0] / 1e6:.1f} MB <= budget {3 * one / 1e6:.1f} MB with {pconc[0]} slices in "
  286. f"flight (full-shape accounting would allow 1)")
  287. del parts
  288. # -- bounce buffers: one per reader thread, bounded, released --
  289. threads = int(os.environ.get("DSV41_FAST_LOAD_THREADS", "16"))
  290. assert 0 < len(fl._bounces) <= threads, len(fl._bounces)
  291. assert all(len(b) == fl._CHUNK for b in fl._bounces), [len(b) for b in fl._bounces]
  292. nb = len(fl._bounces)
  293. fl._release_all()
  294. assert not fl._bounces and fl._pool is None
  295. print(f"bounce buffers: {nb} x {fl._CHUNK >> 20} MB, released")
  296. print("fast_load TP slice OK")
  297. finally:
  298. shutil.rmtree(d, ignore_errors=True)
  299. if __name__ == "__main__":
  300. main()