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>
- README.md +29 -0
- 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 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|