jagat-primitive-org mgabor3141 commited on
Commit
a0fa93f
·
1 Parent(s): 4f861b6

Optional resident mode for the quantized tables (VLLM_PLE_QUANT_RESIDENT) (#2)

Browse files

- Optional resident mode for the quantized tables (VLLM_PLE_QUANT_RESIDENT) (5e57b1daaed1c6161fbbdbbe226cd6b680bfb777)
- Preflight: measure available memory and the actual shard bytes (4bb76906884e27b8e5030d7d607db255684ce239)


Co-authored-by: mgabor <mgabor3141@users.noreply.huggingface.co>

Files changed (2) hide show
  1. README.md +29 -0
  2. worker_image_quant.py +151 -6
README.md CHANGED
@@ -54,6 +54,35 @@ Drop `-e VLLM_GDN_DECODE_KERNEL=triton` when serving the plain NVFP4 build. No c
54
  memory cap needed: unlike the BF16 disk path, the quantized tables fit the page cache next to
55
  checkpoint streaming. Two flags were added to the block on 2026-09-06 after a field report, and both failures reproduce on our box: the image's default `--max-num-seqs` (1024) exceeds the Mamba state blocks left at 0.92 utilization (598) and CUDA graph capture aborts, and at the native 262,144 context with MTP the KV cache needs 7.57 GiB where 5.95 GiB is left after the draft head. 32,768 / 36 is what the table below was measured with; the report ran 135,168 / 64 with MTP.
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  Hosts with less than about 100 GB of RAM plus swap: the `ple_layer_quant.py` in this repo since 2026-09-06 builds the 95 GB table parameter on the meta device, so the offload worker no longer requests that allocation and `vm.overcommit_memory` can stay at its default. Before that revision the worker constructed the BF16 parameter first and the kernel's heuristic refused it (`DefaultCPUAllocator: can't allocate memory: you tried to allocate 102400491520 bytes`), which a 64 GB host reported and we reproduced under an emulated 67 GiB commit limit; the revised file boots under the same limit and scores 77.0 (three runs: 76.0, 76.0, 79.0, against 78.2 for the same table with the previous overlay) on the tool-calling suite. The reporter then confirmed it on the real machine (64 GB, CommitLimit 40 GiB, kernel-default overcommit): 341 s to healthy, KV pool unchanged at 216,778 tokens, host RAM about 10 GB, with the previous overlay still failing in the same session ([log](https://github.com/MarcoPizeta/flash-next-rtxpro6000-bench/blob/main/logs/test-revised-ple-overlay-no-overcommit-20260906.log)). Re-download the overlay if yours predates 2026-09-06. The BF16 disk overlay on the model cards still needs `sysctl vm.overcommit_memory=1` on such hosts.
58
 
59
  ## Measured
 
54
  memory cap needed: unlike the BF16 disk path, the quantized tables fit the page cache next to
55
  checkpoint streaming. Two flags were added to the block on 2026-09-06 after a field report, and both failures reproduce on our box: the image's default `--max-num-seqs` (1024) exceeds the Mamba state blocks left at 0.92 utilization (598) and CUDA graph capture aborts, and at the native 262,144 context with MTP the KV cache needs 7.57 GiB where 5.95 GiB is left after the draft head. 32,768 / 36 is what the table below was measured with; the report ran 135,168 / 64 with MTP.
56
 
57
+ ### Optional: keep the table in anonymous RAM
58
+
59
+ `-e VLLM_PLE_QUANT_RESIDENT=1` copies the shards into anonymous memory at load instead of
60
+ serving them mmapped. Unset, nothing changes. Contributed by
61
+ [mgabor3141](https://huggingface.co/primitive-ai/Qwen3.8-Flash-Next-PLE-quant/discussions/2).
62
+
63
+ This buys cold-start, not throughput. Measured on one RTX PRO 6000, INT4 table, mixed
64
+ checkpoint, page cache dropped before each arm:
65
+
66
+ | | first request | warm asymptote | requests to within 5% |
67
+ |---|---|---|---|
68
+ | mmapped | 19.0 tok/s | 93.0 | ~150 |
69
+ | resident | 74.3 | 96.8 | ~3 |
70
+ | mmapped, MTP nspec 3 | 12.6 | 125.4 | ~125 |
71
+ | resident, MTP nspec 3 | 86.5 | 128.2 | ~25 |
72
+
73
+ Warm, the two are 2–4% apart and the MTP pair is inside the run-to-run spread. Cold they are
74
+ not close: loading the 172 GB checkpoint evicts the sidecar from page cache, so the mmapped
75
+ table serves its first request at 19 tok/s and takes roughly 150 requests to converge.
76
+ Speculation makes that worse, not better. `FileHugePages` is 0 on this kernel while the
77
+ resident copy shows 32 GiB of `anon_thp`, which is where the warm 2–4% comes from.
78
+
79
+ Worth the RAM if the server restarts often or takes a burst right after boot; not otherwise.
80
+ KV pool is unchanged either way (426,639 tokens without MTP, 134,192 with, identical in both
81
+ arms), and the copy costs 4–11 s of boot. The table needs 48.9 / 29.8 / 26.8 GiB of anonymous
82
+ memory for the fp8 / int4 / nvfp4 sidecars, on top of the roughly 8 GiB the worker already
83
+ holds. A preflight refuses with a clear message rather than OOM-ing part-way in; it reads
84
+ `MemAvailable` and, in a container, the cgroup limit less its reclaimable page cache.
85
+
86
  Hosts with less than about 100 GB of RAM plus swap: the `ple_layer_quant.py` in this repo since 2026-09-06 builds the 95 GB table parameter on the meta device, so the offload worker no longer requests that allocation and `vm.overcommit_memory` can stay at its default. Before that revision the worker constructed the BF16 parameter first and the kernel's heuristic refused it (`DefaultCPUAllocator: can't allocate memory: you tried to allocate 102400491520 bytes`), which a 64 GB host reported and we reproduced under an emulated 67 GiB commit limit; the revised file boots under the same limit and scores 77.0 (three runs: 76.0, 76.0, 79.0, against 78.2 for the same table with the previous overlay) on the tool-calling suite. The reporter then confirmed it on the real machine (64 GB, CommitLimit 40 GiB, kernel-default overcommit): 341 s to healthy, KV pool unchanged at 216,778 tokens, host RAM about 10 GB, with the previous overlay still failing in the same session ([log](https://github.com/MarcoPizeta/flash-next-rtxpro6000-bench/blob/main/logs/test-revised-ple-overlay-no-overcommit-20260906.log)). Re-download the overlay if yours predates 2026-09-06. The BF16 disk overlay on the model cards still needs `sysctl vm.overcommit_memory=1` on such hosts.
87
 
88
  ## Measured
worker_image_quant.py CHANGED
@@ -340,8 +340,112 @@ class _PleQuantTable:
340
  import json
341
  import os
342
 
 
 
343
  from safetensors import safe_open
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  meta = json.load(open(os.path.join(quant_dir, "META.json")))
346
  self.layout = meta["layout"]
347
  assert meta["rows"] == total_rows and meta["width"] == width, (
@@ -358,19 +462,60 @@ class _PleQuantTable:
358
  else:
359
  key = "weight_i4"
360
  self._q, self._s, self._s2 = [], [], []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  for n in range(n_shards):
362
- f = safe_open(os.path.join(quant_dir, f"shard_{n}.safetensors"),
363
- framework="pt")
364
- self._q.append(f.get_tensor(key))
365
- self._s.append(f.get_tensor("weight_scale"))
 
 
 
 
 
 
 
366
  self._s2.append(
367
  f.get_tensor("weight_scale_2").item()
368
  if "weight_scale_2" in f.keys() else 1.0
369
  )
370
  self.width = width
371
  self._lut = None
372
- logger.info("PLE quant table: %s, %d shards mmapped from %s",
373
- self.layout, n_shards, quant_dir)
 
 
 
 
 
 
 
 
 
 
374
 
375
  def gather_into(self, ids: torch.Tensor, out: torch.Tensor) -> None:
376
  ids = ids.long()
 
340
  import json
341
  import os
342
 
343
+ import ctypes
344
+
345
  from safetensors import safe_open
346
 
347
+ resident = os.environ.get("VLLM_PLE_QUANT_RESIDENT", "0") == "1"
348
+
349
+ def _cgroup_headroom() -> int | None:
350
+ """Bytes this cgroup may still allocate, or None when unlimited.
351
+
352
+ `memory.current` counts the page cache too, and on this path most of
353
+ it is the checkpoint we just streamed, which the kernel will reclaim
354
+ rather than OOM over. Subtract the file pages, the same way
355
+ MemAvailable does at the host level, or a container that has read a
356
+ large checkpoint refuses a copy it could serve.
357
+ """
358
+ for limit_p, usage_p, stat_p, keys in (
359
+ ("/sys/fs/cgroup/memory.max",
360
+ "/sys/fs/cgroup/memory.current",
361
+ "/sys/fs/cgroup/memory.stat",
362
+ ("inactive_file", "active_file")),
363
+ ("/sys/fs/cgroup/memory/memory.limit_in_bytes",
364
+ "/sys/fs/cgroup/memory/memory.usage_in_bytes",
365
+ "/sys/fs/cgroup/memory/memory.stat",
366
+ ("total_inactive_file", "total_active_file")),
367
+ ):
368
+ try:
369
+ raw = open(limit_p).read().strip()
370
+ if raw == "max":
371
+ return None
372
+ limit = int(raw)
373
+ # cgroup v1 spells "unlimited" as a sentinel near 2**63
374
+ if limit >= (1 << 62):
375
+ return None
376
+ usage = int(open(usage_p).read().strip())
377
+ except (OSError, ValueError):
378
+ continue
379
+ reclaimable = 0
380
+ try:
381
+ for line in open(stat_p):
382
+ field, _, value = line.partition(" ")
383
+ if field in keys:
384
+ reclaimable += int(value)
385
+ except (OSError, ValueError):
386
+ pass
387
+ return max(limit - max(usage - reclaimable, 0), 0)
388
+ return None
389
+
390
+ def _avail_bytes() -> int | None:
391
+ """Memory the copy can actually claim, or None if it cannot be read.
392
+
393
+ SC_AVPHYS_PAGES is MemFree: it excludes the page cache, which on this
394
+ path is largely the sidecar and the checkpoint, and it reports the
395
+ host's pages even inside a container with `--memory`. Both errors
396
+ bite: a host with 173 GiB of MemAvailable and 1.9 GiB free refuses a
397
+ copy that would succeed, and a capped container passes the check and
398
+ is killed part-way through it.
399
+
400
+ None means "could not tell", and the caller then proceeds. This
401
+ preflight exists to fail fast and legibly, not to gate correctness;
402
+ a platform whose memory it cannot read should still be able to boot.
403
+ """
404
+ avail = None
405
+ try:
406
+ for line in open("/proc/meminfo"):
407
+ if line.startswith("MemAvailable:"):
408
+ avail = int(line.split()[1]) * 1024
409
+ break
410
+ except (OSError, ValueError, IndexError):
411
+ pass
412
+ if avail is None:
413
+ try:
414
+ avail = os.sysconf("SC_AVPHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
415
+ except (ValueError, OSError):
416
+ return None
417
+ headroom = _cgroup_headroom()
418
+ return avail if headroom is None else min(avail, headroom)
419
+
420
+ def _drop_cache(path: str) -> None:
421
+ """Release the page cache for a shard we have already copied.
422
+
423
+ Without this the cgroup briefly accounts both the anonymous copy and
424
+ the file pages it came from. The kernel would reclaim the latter under
425
+ pressure anyway; doing it eagerly keeps the steady-state footprint
426
+ honest and predictable.
427
+ """
428
+ POSIX_FADV_DONTNEED = 4
429
+ try:
430
+ fd = os.open(path, os.O_RDONLY)
431
+ except OSError:
432
+ return
433
+ try:
434
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
435
+ # posix_fadvise returns the errno instead of setting it, so a
436
+ # bare try/except here would never fire.
437
+ rc = libc.posix_fadvise(
438
+ ctypes.c_int(fd), ctypes.c_long(0), ctypes.c_long(0),
439
+ ctypes.c_int(POSIX_FADV_DONTNEED),
440
+ )
441
+ if rc != 0:
442
+ logger.debug("posix_fadvise(DONTNEED) on %s returned %d",
443
+ path, rc)
444
+ except OSError:
445
+ pass
446
+ finally:
447
+ os.close(fd)
448
+
449
  meta = json.load(open(os.path.join(quant_dir, "META.json")))
450
  self.layout = meta["layout"]
451
  assert meta["rows"] == total_rows and meta["width"] == width, (
 
462
  else:
463
  key = "weight_i4"
464
  self._q, self._s, self._s2 = [], [], []
465
+ if resident:
466
+ # Preflight: the table size is known before any of it is read, so an
467
+ # undersized host should fail now rather than part-way through.
468
+ # The shard files are the copy, so measure them. rows*width is exact
469
+ # for no layout: it omits the scales (fp8 is 1.19 GiB short) and
470
+ # ignores 4-bit packing (int4 1.60x over, nvfp4 1.78x).
471
+ need = sum(
472
+ os.path.getsize(os.path.join(quant_dir, f"shard_{n}.safetensors"))
473
+ for n in range(n_shards)
474
+ )
475
+ avail = _avail_bytes()
476
+ if avail is not None and need > avail:
477
+ raise MemoryError(
478
+ f"PLE resident mode needs about {need / 2**30:.1f} GiB for the "
479
+ f"table but only {avail / 2**30:.1f} GiB is available to this "
480
+ "process; unset VLLM_PLE_QUANT_RESIDENT to serve it mmapped"
481
+ )
482
+ logger.info(
483
+ "PLE quant table: resident mode, copying about %.1f GiB into "
484
+ "anonymous RAM (%s available)",
485
+ need / 2**30,
486
+ "headroom unknown" if avail is None else f"{avail / 2**30:.1f} GiB",
487
+ )
488
+
489
  for n in range(n_shards):
490
+ shard_path = os.path.join(quant_dir, f"shard_{n}.safetensors")
491
+ f = safe_open(shard_path, framework="pt")
492
+ q = f.get_tensor(key)
493
+ s = f.get_tensor("weight_scale")
494
+ if resident:
495
+ # clone() copies into anonymous, THP-eligible memory. Do it per
496
+ # shard so the transient cost is one shard, not one table.
497
+ q = q.clone()
498
+ s = s.clone()
499
+ self._q.append(q)
500
+ self._s.append(s)
501
  self._s2.append(
502
  f.get_tensor("weight_scale_2").item()
503
  if "weight_scale_2" in f.keys() else 1.0
504
  )
505
  self.width = width
506
  self._lut = None
507
+ if resident:
508
+ del f
509
+ for n in range(n_shards):
510
+ _drop_cache(os.path.join(quant_dir, f"shard_{n}.safetensors"))
511
+ logger.info(
512
+ "PLE quant table: %s, %d shards resident in anonymous RAM from %s "
513
+ "(no disk reads on the gather path)",
514
+ self.layout, n_shards, quant_dir,
515
+ )
516
+ else:
517
+ logger.info("PLE quant table: %s, %d shards mmapped from %s",
518
+ self.layout, n_shards, quant_dir)
519
 
520
  def gather_into(self, ids: torch.Tensor, out: torch.Tensor) -> None:
521
  ids = ids.long()