jagat-primitive-org Claude Opus 5 (1M context) commited on
Commit
4bb7690
·
verified ·
1 Parent(s): 5e57b1d

Preflight: measure available memory and the actual shard bytes

Browse files

Two fixes to the VLLM_PLE_QUANT_RESIDENT preflight, both reproduced on one
RTX PRO 6000 with a 176 GiB host before and after.

1. SC_AVPHYS_PAGES is MemFree, so it excludes the page cache that this path
fills, and inside a container it reports the host's pages rather than the
cgroup's. It failed both ways: with the cache warm the check refused on a
host with 173.7 GiB of MemAvailable and 1.9 GiB free, and inside
--memory=24g it read 71.45 GiB and would have waved a 29.8 GiB copy into a
24 GiB cage for the OOM killer. Now reads MemAvailable, and in a container
the cgroup limit less its reclaimable file pages -- memory.current counts
the checkpoint we just streamed, which the kernel reclaims rather than
OOM over, so subtracting it is what keeps the container case honest.
Returns None when neither can be read, and the check is then skipped:
failing fast is a convenience, not a correctness gate.

2. rows*width is exact for no layout. It omits the scales, leaving fp8 1.19
GiB short (the unsafe direction, and the layout most people run), and
ignores 4-bit packing, running 1.60x over on int4 and 1.78x on nvfp4.
Summing the shard files gives 48.88 / 29.80 / 26.82 GiB, and 29.80 is
exactly the anon delta the cgroup measured during the int4 boot.

Also makes the posix_fadvise error path real: it returns the errno instead
of setting it, so the bare except OSError could never fire.

README documents the flag as a cold-start feature with the measured numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. README.md +29 -0
  2. worker_image_quant.py +89 -9
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
@@ -346,8 +346,76 @@ class _PleQuantTable:
346
 
347
  resident = os.environ.get("VLLM_PLE_QUANT_RESIDENT", "0") == "1"
348
 
349
- def _avail_bytes() -> int:
350
- return os.sysconf("SC_AVPHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
  def _drop_cache(path: str) -> None:
353
  """Release the page cache for a shard we have already copied.
@@ -364,10 +432,15 @@ class _PleQuantTable:
364
  return
365
  try:
366
  libc = ctypes.CDLL("libc.so.6", use_errno=True)
367
- libc.posix_fadvise(
 
 
368
  ctypes.c_int(fd), ctypes.c_long(0), ctypes.c_long(0),
369
  ctypes.c_int(POSIX_FADV_DONTNEED),
370
  )
 
 
 
371
  except OSError:
372
  pass
373
  finally:
@@ -392,18 +465,25 @@ class _PleQuantTable:
392
  if resident:
393
  # Preflight: the table size is known before any of it is read, so an
394
  # undersized host should fail now rather than part-way through.
395
- need = total_rows * width
 
 
 
 
 
 
396
  avail = _avail_bytes()
397
- if need > avail:
398
  raise MemoryError(
399
  f"PLE resident mode needs about {need / 2**30:.1f} GiB for the "
400
- f"table but only {avail / 2**30:.1f} GiB of host RAM is "
401
- "available; unset VLLM_PLE_QUANT_RESIDENT to serve it mmapped"
402
  )
403
  logger.info(
404
  "PLE quant table: resident mode, copying about %.1f GiB into "
405
- "anonymous RAM (%.1f GiB available)",
406
- need / 2**30, avail / 2**30,
 
407
  )
408
 
409
  for n in range(n_shards):
 
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.
 
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:
 
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):