weight_snapshot.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """Local-disk snapshot of processed weights, for fast restarts.
  2. The first boot loads the checkpoint as usual and writes, per rank, every tensor
  3. reachable from the processed model. Later boots build the model through the
  4. normal path on dummy weights (seconds: the slow part of a real load is reading
  5. and handling the checkpoint, not processing) and then overwrite every tensor in
  6. place from the snapshot. Building through the normal path keeps every object,
  7. view and kernel exactly as a real load makes them; vLLM's pre-processed rebuild
  8. (weights_already_processed) does not, e.g. it breaks the MLA absorbed weights'
  9. views of kv_b_proj and the router's reference to e_score_correction_bias.
  10. Enabled by VLLM_WEIGHT_SNAPSHOT_DIR. VLLM_WEIGHT_SNAPSHOT_TAG joins the key for
  11. image-level patches that the vLLM version string cannot see. A snapshot is used
  12. only when its key matches exactly and it covers every tensor of the model;
  13. otherwise the rank loads normally and writes a new one. Names start with
  14. SNAPSHOT_VERSION, and opening the directory deletes snapshots of any other
  15. version.
  16. """
  17. import hashlib
  18. import json
  19. import os
  20. import re
  21. import shutil
  22. import time
  23. from dataclasses import asdict
  24. import torch
  25. from torch import nn
  26. from vllm.logger import init_logger
  27. logger = init_logger(__name__)
  28. # Bump when a change to weight processing or to this file's format makes
  29. # existing snapshots wrong in a way the key cannot see.
  30. SNAPSHOT_VERSION = 2
  31. # A snapshot or a partly written one, of any version (version 1 had no prefix).
  32. _SNAPSHOT_NAME = re.compile(r"(v\d+-)?(target|draft)-tp\d+of\d+-dp\d+-[0-9a-f]{16}(\.tmp\d+)?")
  33. _ALIGN = 4096
  34. _CHUNK = 64 << 20 # bytes per read; two pinned buffers of this size
  35. def _dtype(name: str) -> torch.dtype:
  36. return getattr(torch, name.removeprefix("torch."))
  37. class Snapshot:
  38. def __init__(self, path: str, key: dict):
  39. self.path = path
  40. self.key = key
  41. # ---- key ----------------------------------------------------------------
  42. @classmethod
  43. def for_model(cls, vllm_config, model_config) -> "Snapshot | None":
  44. """The snapshot slot for this rank and model, or None when disabled.
  45. Must run before weights load: processing may mutate the quant config
  46. that the key hashes.
  47. """
  48. base = os.environ.get("VLLM_WEIGHT_SNAPSHOT_DIR")
  49. if not base:
  50. return None
  51. from vllm.distributed import (
  52. get_dp_group,
  53. get_tensor_model_parallel_rank,
  54. get_tensor_model_parallel_world_size,
  55. )
  56. from vllm.model_executor.model_loader.weight_cache.protocol import WeightCacheKey
  57. spec = vllm_config.speculative_config
  58. is_draft = spec is not None and model_config is spec.draft_model_config
  59. dp = get_dp_group()
  60. key = asdict(
  61. WeightCacheKey.from_model_config(
  62. model_config,
  63. tp_size=get_tensor_model_parallel_world_size(),
  64. tp_rank=get_tensor_model_parallel_rank(),
  65. is_draft=is_draft,
  66. dp_size=dp.world_size,
  67. dp_rank=dp.rank_in_group,
  68. )
  69. )
  70. key["tag"] = os.environ.get("VLLM_WEIGHT_SNAPSHOT_TAG", "")
  71. # The indexer's top-k scratch buffer has one row per batched token.
  72. key["max_num_batched_tokens"] = vllm_config.scheduler_config.max_num_batched_tokens
  73. # The MoE backend decides the processed expert layout (marlin repacks
  74. # and drops the input scales flashinfer_cutlass keeps), so a snapshot
  75. # from one backend cannot load under another.
  76. key["moe_backend"] = str(getattr(vllm_config.kernel_config, "moe_backend", ""))
  77. digest = hashlib.sha256(json.dumps(key, sort_keys=True).encode()).hexdigest()[:16]
  78. role = "draft" if is_draft else "target"
  79. name = f"v{SNAPSHOT_VERSION}-{role}-tp{key['tp_rank']}of{key['tp_size']}-dp{key['dp_rank']}-{digest}"
  80. _remove_other_versions(base)
  81. return cls(os.path.join(base, name), key)
  82. def complete(self) -> bool:
  83. return os.path.isfile(os.path.join(self.path, "COMPLETE"))
  84. # ---- save ---------------------------------------------------------------
  85. def save(self, model: nn.Module) -> None:
  86. """Write every tensor reachable from the processed model, by path."""
  87. t0 = time.monotonic()
  88. tmp = self.path + f".tmp{os.getpid()}"
  89. shutil.rmtree(tmp, ignore_errors=True)
  90. os.makedirs(tmp)
  91. entries, aliases, canonical = [], {}, {}
  92. offset = 0
  93. with open(os.path.join(tmp, "data.bin"), "wb") as f:
  94. # Parameters and buffers first, so they are the entries and the
  95. # caches that alias them (which a dummy build lacks) the aliases.
  96. for path, t in sorted(_tensors(model).items(), key=lambda kv: "|" in kv[0]):
  97. # A view that repeats one already written (same bytes, same
  98. # layout) is restored by writing that one.
  99. ident = (t.untyped_storage().data_ptr(), t.storage_offset(), tuple(t.shape),
  100. tuple(t.stride()), t.dtype, t.device.type)
  101. if ident in canonical:
  102. aliases[path] = canonical[ident]
  103. continue
  104. canonical[ident] = path
  105. flat = t.detach().contiguous().reshape(-1).view(torch.uint8)
  106. nbytes = flat.numel()
  107. for s in range(0, nbytes, _CHUNK):
  108. f.write(memoryview(flat[s:s + _CHUNK].cpu().numpy()))
  109. pad = -nbytes % _ALIGN
  110. if pad:
  111. f.write(b"\0" * pad)
  112. entries.append({"path": path, "dtype": str(t.dtype), "shape": list(t.shape),
  113. "offset": offset, "nbytes": nbytes})
  114. offset += nbytes + pad
  115. manifest = {"key": self.key, "entries": entries, "aliases": aliases,
  116. "module_attrs": _module_flags(model), "bytes": offset}
  117. with open(os.path.join(tmp, "manifest.json"), "w") as f:
  118. json.dump(manifest, f)
  119. open(os.path.join(tmp, "COMPLETE"), "w").close()
  120. shutil.rmtree(self.path, ignore_errors=True)
  121. os.rename(tmp, self.path)
  122. logger.info("Weight snapshot: wrote %d tensors (+%d views), %.1f GiB to %s in %.1f s",
  123. len(entries), len(aliases), offset / 2**30, self.path, time.monotonic() - t0)
  124. # ---- restore ------------------------------------------------------------
  125. @torch.no_grad()
  126. def restore(self, model: nn.Module) -> None:
  127. """Overwrite, in place, every tensor of a model that went through the
  128. normal load and processing path on dummy weights.
  129. The normal path builds every object, view and kernel exactly as a real
  130. load does; only the bytes are dummy, and this replaces all of them.
  131. Reads with O_DIRECT through two pinned buffers so the next read
  132. overlaps the previous copy.
  133. """
  134. t0 = time.monotonic()
  135. with open(os.path.join(self.path, "manifest.json")) as f:
  136. manifest = json.load(f)
  137. if manifest["key"] != self.key: # the digest collided or the file was edited
  138. raise RuntimeError(f"weight snapshot {self.path} does not match this model")
  139. live = _tensors(model)
  140. # Caches that a model's own load_weights hangs on a module (the DFlash
  141. # drafter's fused KV weight, say) do not exist after a dummy build.
  142. # Recreate those that sit directly on a module; anything deeper fails.
  143. modules = dict(model.named_modules(remove_duplicate=False))
  144. created = 0
  145. for e in manifest["entries"]:
  146. path = e["path"]
  147. if path in live or "|" not in path:
  148. continue
  149. module_name, attr = path.split("|", 1)
  150. module = modules.get(module_name)
  151. if module is None or "." in attr or hasattr(module, attr):
  152. continue
  153. ref = next(iter(live.values()))
  154. t = torch.empty(e["shape"], dtype=_dtype(e["dtype"]), device=ref.device)
  155. object.__setattr__(module, attr, t)
  156. live[path] = t
  157. created += 1
  158. missing = [e["path"] for e in manifest["entries"] if e["path"] not in live]
  159. if missing:
  160. raise RuntimeError(f"weight snapshot {self.path}: {len(missing)} tensors have no counterpart, "
  161. f"e.g. {missing[:3]}; delete the snapshot")
  162. covered = {e["path"] for e in manifest["entries"]} | set(manifest["aliases"])
  163. uncovered = [p for p in live if p not in covered]
  164. if uncovered:
  165. raise RuntimeError(f"weight snapshot {self.path}: {len(uncovered)} tensors would keep dummy "
  166. f"values, e.g. {uncovered[:3]}; delete the snapshot")
  167. bufs = [torch.empty(_CHUNK, dtype=torch.uint8, pin_memory=True) for _ in range(2)]
  168. views = [memoryview(b.numpy()) for b in bufs]
  169. done = [torch.cuda.Event(), torch.cuda.Event()]
  170. stream = torch.cuda.Stream()
  171. stream.wait_stream(torch.cuda.current_stream())
  172. fd = os.open(os.path.join(self.path, "data.bin"), os.O_RDONLY | os.O_DIRECT)
  173. turn, late = 0, []
  174. try:
  175. for e in manifest["entries"]:
  176. t = live[e["path"]]
  177. if str(t.dtype) != e["dtype"] or list(t.shape) != e["shape"]:
  178. raise RuntimeError(f"weight snapshot: {e['path']} is {t.dtype}{list(t.shape)}, "
  179. f"snapshot has {e['dtype']}{e['shape']}")
  180. # Contiguous tensors take the bytes directly; others via a
  181. # contiguous staging copy, written back once the stream is done.
  182. dst = t if t.is_contiguous() else torch.empty(t.shape, dtype=t.dtype, device=t.device)
  183. raw = dst.reshape(-1).view(torch.uint8)
  184. on_gpu = t.device.type == "cuda"
  185. n = e["nbytes"]
  186. for s in range(0, n, _CHUNK):
  187. m = min(_CHUNK, n - s)
  188. done[turn].synchronize()
  189. got = os.preadv(fd, [views[turn][:(m + _ALIGN - 1) // _ALIGN * _ALIGN]], e["offset"] + s)
  190. assert got >= m, f"short read in {e['path']}"
  191. with torch.cuda.stream(stream):
  192. raw[s:s + m].copy_(bufs[turn][:m], non_blocking=on_gpu)
  193. done[turn].record(stream)
  194. turn ^= 1
  195. if dst is not t:
  196. late.append((t, dst))
  197. stream.synchronize()
  198. for t, dst in late:
  199. t.copy_(dst)
  200. # An alias is the same memory as its entry in the saving boot. Here
  201. # it may not be: a recreated cache is new memory, so the parameter
  202. # it aliased would keep its dummy values without this copy.
  203. copied = 0
  204. for path, src in manifest["aliases"].items():
  205. if path not in live: # a cache the dummy build lacks; the model rebuilds it
  206. continue
  207. a, s = live[path], live[src]
  208. if a.data_ptr() != s.data_ptr() or a.stride() != s.stride():
  209. a.copy_(s)
  210. copied += 1
  211. torch.cuda.synchronize()
  212. finally:
  213. os.close(fd)
  214. # The dummy build already set every flag from this boot's config;
  215. # the saved ones come from the boot that wrote the snapshot, whose
  216. # config (speculation, say) may differ. Report differences only.
  217. differ = [f"{m}.{k}: {getattr(modules[m], k, None)!r} here, {v!r} in snapshot"
  218. for m, attrs in manifest.get("module_attrs", {}).items() if m in modules
  219. for k, v in attrs.items() if getattr(modules[m], k, None) != v]
  220. if differ:
  221. logger.warning("Weight snapshot: %d module flags differ from the saving boot, e.g. %s",
  222. len(differ), differ[:3])
  223. logger.info("Weight snapshot: restored %d tensors (+%d views, %d copied), %d recreated caches, "
  224. "%.1f GiB from %s in %.1f s", len(manifest["entries"]), len(manifest["aliases"]), copied,
  225. created, manifest["bytes"] / 2**30, self.path, time.monotonic() - t0)
  226. def _remove_other_versions(base: str) -> None:
  227. """Delete every snapshot in base whose version is not SNAPSHOT_VERSION.
  228. Other files in base are left alone. Snapshots of this version for other
  229. TP layouts or keys stay.
  230. """
  231. current = f"v{SNAPSHOT_VERSION}-"
  232. try:
  233. names = os.listdir(base)
  234. except FileNotFoundError:
  235. return
  236. for n in names:
  237. if not _SNAPSHOT_NAME.fullmatch(n) or n.startswith(current):
  238. continue
  239. path = os.path.join(base, n)
  240. try:
  241. size = os.path.getsize(os.path.join(path, "data.bin"))
  242. except OSError:
  243. size = 0
  244. shutil.rmtree(path, ignore_errors=True)
  245. logger.info("Weight snapshot: removed %s (%.1f GiB), not version %d", path, size / 2**30, SNAPSHOT_VERSION)
  246. def _module_flags(model: nn.Module) -> dict:
  247. """Plain public attributes of every module: the flags processing sets."""
  248. out = {}
  249. for name, module in model.named_modules():
  250. attrs = {k: v for k, v in vars(module).items()
  251. if not k.startswith("_") and k != "training"
  252. and (v is None or isinstance(v, (bool, int, float, str)))}
  253. if attrs:
  254. out[name] = attrs
  255. return out
  256. # ---- verification (VLLM_WEIGHT_SNAPSHOT_VERIFY=1) ------------------------------
  257. _SKIP_ATTRS = ("_modules", "_forward_hooks", "_forward_pre_hooks", "_backward_hooks",
  258. "_backward_pre_hooks", "_state_dict_hooks", "_state_dict_pre_hooks",
  259. "_load_state_dict_pre_hooks", "_load_state_dict_post_hooks")
  260. def _walk(model: nn.Module, describe: bool = False, roots=()) -> dict:
  261. """Every tensor and scalar reachable from the model's modules, by path:
  262. parameters and buffers, and whatever quant methods, kernels and attention
  263. backends hang off the modules.
  264. With describe, also records the identity of every function and the type
  265. of every object, and follows closures, bound methods and partials without
  266. entering the VllmConfig. verify uses it to find state that save skips."""
  267. import functools
  268. import types
  269. out, seen = {}, set()
  270. # Registered modules are walked from named_modules; any other module (a MoE
  271. # kernel held by a quant method, say) is walked where it is found.
  272. registered = {id(m) for _, m in model.named_modules(remove_duplicate=False)}
  273. def visit(obj, path, depth):
  274. if isinstance(obj, torch.Tensor):
  275. out[path] = obj
  276. return
  277. if obj is None or isinstance(obj, (bool, int, float, str)):
  278. out[path] = obj
  279. return
  280. if depth > 8 or id(obj) in seen or id(obj) in registered or isinstance(obj, type):
  281. return
  282. if describe and type(obj).__name__ == "VllmConfig" and not path.startswith("config"):
  283. return
  284. if isinstance(obj, (types.FunctionType, types.MethodType, types.BuiltinFunctionType)):
  285. if not describe:
  286. return
  287. seen.add(id(obj))
  288. if isinstance(obj, types.MethodType):
  289. out[path] = f"<method {obj.__func__.__qualname__}>"
  290. visit(obj.__self__, f"{path}.__self__", depth + 1)
  291. elif isinstance(obj, types.FunctionType):
  292. out[path] = f"<function {obj.__module__}.{obj.__qualname__}>"
  293. for i, cell in enumerate(obj.__closure__ or ()):
  294. try:
  295. visit(cell.cell_contents, f"{path}.<cell{i}>", depth + 1)
  296. except ValueError: # empty cell
  297. pass
  298. else:
  299. out[path] = f"<builtin {getattr(obj, '__qualname__', obj)!s}>"
  300. return
  301. seen.add(id(obj))
  302. if describe:
  303. out[f"{path}.__class__"] = type(obj).__qualname__
  304. if isinstance(obj, dict):
  305. items = [(repr(k), v) for k, v in obj.items()]
  306. elif isinstance(obj, (list, tuple)):
  307. items = [(str(i), v) for i, v in enumerate(obj)]
  308. elif describe and isinstance(obj, functools.partial):
  309. items = [("func", obj.func), ("args", obj.args), ("keywords", obj.keywords)]
  310. elif hasattr(type(obj), "__dict__") and isinstance(getattr(obj, "__dict__", None), dict):
  311. items = list(obj.__dict__.items())
  312. else:
  313. items = []
  314. if describe:
  315. for cls in type(obj).__mro__:
  316. for s in cls.__dict__.get("__slots__", ()):
  317. if s in ("__dict__", "__weakref__"):
  318. continue
  319. try:
  320. items.append((s, object.__getattribute__(obj, s)))
  321. except Exception: # unset slot
  322. pass
  323. for k, v in items:
  324. visit(v, f"{path}.{k}", depth + 1)
  325. for name, module in model.named_modules(remove_duplicate=False):
  326. for k, v in vars(module).items():
  327. if k in _SKIP_ATTRS:
  328. continue
  329. if k in ("_parameters", "_buffers"):
  330. for pk, pv in v.items():
  331. if pv is not None:
  332. out[f"{name}.{pk}"] = pv
  333. continue
  334. visit(v, f"{name}|{k}", 0)
  335. for path, obj in roots:
  336. try:
  337. visit(obj, path, 0)
  338. except Exception as e: # lazy-import placeholders and the like
  339. out[path] = f"<unwalkable {type(e).__name__}>"
  340. return out
  341. def dump_state(model: nn.Module, vllm_config, path: str) -> None:
  342. """Write, one line per path, every tensor checksum and scalar reachable
  343. from the model, the VllmConfig and the vllm and flashinfer module
  344. globals. Lines hold no addresses, so two boots can be diffed."""
  345. import functools
  346. import sys
  347. import types
  348. roots = [("config", vllm_config)]
  349. for mname, mod in sorted(sys.modules.items()):
  350. if not mname.startswith(("vllm", "flashinfer")) or mod is None:
  351. continue
  352. for k, v in sorted(vars(mod).items()):
  353. if isinstance(v, (types.ModuleType, type)) or k.startswith("__"):
  354. continue
  355. if isinstance(v, functools._lru_cache_wrapper):
  356. roots.append((f"global:{mname}.{k}.cache_size", v.cache_info().currsize))
  357. if not isinstance(v, (types.FunctionType, types.BuiltinFunctionType)):
  358. roots.append((f"global:{mname}.{k}", v))
  359. lines = []
  360. for p, x in _walk(model, describe=True, roots=roots).items():
  361. if isinstance(x, torch.Tensor):
  362. if x.device.type == "meta":
  363. v = f"meta {x.dtype} {list(x.shape)}"
  364. else:
  365. flat = x.detach().contiguous().reshape(-1).view(torch.uint8)
  366. pad = (-flat.numel()) % 4
  367. if pad:
  368. flat = torch.cat([flat, flat.new_zeros(pad)])
  369. w = flat.view(torch.int32)
  370. s = sum(int(c.to(torch.int64).sum()) for c in w.split(1 << 26)) if w.numel() else 0
  371. v = f"{x.dtype} {list(x.shape)} {x.stride()} {s}"
  372. else:
  373. v = repr(x)
  374. if " at 0x" in v:
  375. v = v.split(" at 0x")[0] + ">"
  376. lines.append(f"{p}\t{v}")
  377. with open(path, "w") as f:
  378. f.write("\n".join(sorted(lines)) + "\n")
  379. logger.warning("State dump: %d paths to %s", len(lines), path)
  380. def _tensors(model: nn.Module) -> dict:
  381. """Every tensor reachable from the model, by path: parameters, buffers,
  382. and those held by quant methods, kernels and attention backends. Skips the
  383. shared vllm_config and the KV-cache placeholders bound after loading."""
  384. return {p: x for p, x in _walk(model).items()
  385. if isinstance(x, torch.Tensor) and x.device.type != "meta"
  386. and "vllm_config" not in p and not p.endswith("|kv_cache")}
  387. def verify(snap_model: nn.Module, ref_model: nn.Module) -> None:
  388. """Log every tensor or scalar that differs between a snapshot-built model
  389. and a normally loaded one."""
  390. a, b = _walk(snap_model, describe=True), _walk(ref_model, describe=True)
  391. bad = []
  392. # vllm_config is shared by both models and points into the snapshot's own
  393. # layer registry, so everything under it is noise here.
  394. a = {k: v for k, v in a.items() if "vllm_config" not in k}
  395. b = {k: v for k, v in b.items() if "vllm_config" not in k}
  396. for path in sorted(set(a) | set(b)):
  397. if path.endswith("|kv_cache"): # bound later, when the KV cache is allocated
  398. continue
  399. if path not in a or path not in b:
  400. bad.append(f"{path}: only in {'snapshot' if path in a else 'reference'}")
  401. continue
  402. x, y = a[path], b[path]
  403. if isinstance(x, torch.Tensor) != isinstance(y, torch.Tensor):
  404. bad.append(f"{path}: tensor vs {type(y).__name__}")
  405. elif isinstance(x, torch.Tensor):
  406. if x.device.type == "meta" or y.device.type == "meta":
  407. bad.append(f"{path}: meta tensor ({x.device.type} vs {y.device.type})")
  408. elif x.dtype != y.dtype or x.shape != y.shape:
  409. bad.append(f"{path}: {x.dtype}{list(x.shape)} vs {y.dtype}{list(y.shape)}")
  410. elif x.numel() and not torch.equal(x.contiguous().reshape(-1).view(torch.uint8).to(y.device),
  411. y.contiguous().reshape(-1).view(torch.uint8)):
  412. d = (x.float().to(y.device) - y.float()).abs()
  413. bad.append(f"{path}: {x.dtype}{list(x.shape)} values differ, max |d| {d.max().item():.3e}, "
  414. f"{(d != 0).float().mean().item() * 100:.1f}% of elements")
  415. elif x != y:
  416. bad.append(f"{path}: {x!r} vs {y!r}")
  417. # Layout, which the byte comparison above cannot see: strides, and which
  418. # tensors share one storage (a kernel may read one past its neighbour).
  419. def groups(walk):
  420. g = {}
  421. for path, x in walk.items():
  422. if isinstance(x, torch.Tensor) and x.device.type != "meta" and "vllm_config" not in path:
  423. g.setdefault(x.untyped_storage().data_ptr(), []).append(path)
  424. return {tuple(sorted(v)) for v in g.values() if len(v) > 1}
  425. ga, gb = groups(a), groups(b)
  426. for grp in sorted(gb - ga):
  427. bad.append(f"LAYOUT: reference shares one storage across {len(grp)} tensors, snapshot does not: {list(grp)[:4]}")
  428. for grp in sorted(ga - gb):
  429. bad.append(f"LAYOUT: snapshot shares one storage across {len(grp)} tensors, reference does not: {list(grp)[:4]}")
  430. for path in sorted(set(a) & set(b)):
  431. x, y = a[path], b[path]
  432. if (isinstance(x, torch.Tensor) and isinstance(y, torch.Tensor) and "vllm_config" not in path
  433. and x.device.type != "meta" and y.device.type != "meta" and x.shape == y.shape
  434. and (x.stride() != y.stride() or x.storage_offset() != y.storage_offset())):
  435. bad.append(f"LAYOUT: {path}: stride {x.stride()} offset {x.storage_offset()} vs "
  436. f"stride {y.stride()} offset {y.storage_offset()}")
  437. bad.sort(key=lambda s: not s.startswith("LAYOUT")) # layout findings first
  438. logger.warning("Weight snapshot verify: %d paths compared, %d differ", len(set(a) | set(b)), len(bad))
  439. for line in bad[:2000]:
  440. logger.warning(" snapshot differs: %s", line)