Loading filled host RAM 20->40GB while streaming from SSD and never released it; inference then re-served the whole model per AR step. Teardown against core's aimdo/DynamicVRAM machinery, measured on the real 9.47GB 7B fp8 and 5.16GB 1.5B bf16 checkpoints, found four loader defects plus one that was never ours to fix. Defects fixed (each with before/after numbers): * Quant pass 1 pre-read each dequant-at-load layer's scale with safetensors.safe_open + get_tensor. On Windows one such call commits ~1x file size as private, untouched memory, pinned for the lifetime of the returned tensor: +9,050MB on the 7B fp8, invisible to both the working-set counter and the storage census. Now maps the file once through core's aimdo arm and clones only the tiny scales. Whole load: 1.51x -> 0.13x file size; retained after free 11.3GB -> 96MB. * Quant families were excluded from core's dynamic patcher by an inherited "quant streams natively" stop condition. Because a legacy patcher has nowhere to page from, those routes cloned every tensor into host RAM and fully H2D'd it. select_patcher_class now follows core's availability rule for every family, with gguf_block still excluded for a measured reason (GGUFTensor.from_reader_tensor clones the reader's view). * replace_linears_for_quant built its resident modules outside the meta context: 8.08GB of never-written host allocation per 7B fp8 load. * Dormant weight_function/bias_function double application: core already applies them inside cast_bias_weight (ops.py:431-438). Exonerated with numbers, not assumed: the dense read path is core's own (13.5MB private for a 5.16GB file), and core's file->VRAM paging read is cache-clean (+0.13GB per 2GB). Only host-side view reads reproduce the reported 1:1 RAM at 0.55GB/s signature. Inference: reproduced headlessly that when the tree does not fit in the VRAM that is actually free, every forward re-reads every weight from the checkpoint file (3072/3072 file reads, 21ms -> 125ms per step). Our wrappers are protocol-correct; residency is stable at 100% with headroom. [vvpull] now reports resident vs reread with bytes and free VRAM so one live line settles it, behind VIBEVOICE_VBAR_OBSERVER=0. Not changed after being tried and reverted: deriving fast_disk from the checkpoint. It measured as a no-op on the dev host but converted RAM-speed pinned re-reads into disk-speed reads live, which made loading dramatically slower. Reverted in full; see the report for the probe-design lesson. Adds host-RAM instrumentation ([vvrss] with machine-level start/end, [vvcensus] storage census, tts-generate bracket), four standalone probes, and ~4000 lines of tests pinning the invariants above.
251 lines
9.3 KiB
Python
251 lines
9.3 KiB
Python
r"""Standalone PROBE — who retains the SECOND copy during a streamed read.
|
|
|
|
The 7B fp8 report (docs/2026-09-29-dynamic-vram-port-and-int64-conv-regression.md)
|
|
measured a load-time peak against an 8.82 GiB file that reached ~2x, and the
|
|
extra ~1x needed a retainer. Two candidate shapes:
|
|
|
|
H1 "get_tensor is an mmap VIEW" -> the per-tensor ``clone()`` at
|
|
modules/external_loader.py:1047 is a real second copy sitting beside the
|
|
read's own ~1x, and that is the ~2x.
|
|
H2 "get_tensor is an OWNED COPY" -> the mapping is never a factor and the
|
|
extra ~1x must come from somewhere else entirely.
|
|
|
|
An earlier revision of this probe settled VIEW vs COPY by trying to delete
|
|
the checkpoint and treating a refusal as proof of a live mapping. That
|
|
evidence is INVALID: on this host ``os.remove`` fails with a plain access
|
|
denial (WinError 5) even after every ``get_tensor`` result has been dropped,
|
|
so a refusal cannot be distinguished from an ordinary permission failure.
|
|
It is replaced here by :func:`is_view`, which asks the Win32 region API what
|
|
kind of region the tensor's bytes actually live in — ``MEM_MAPPED`` backed by
|
|
the checkpoint file, against a ``MEM_PRIVATE`` heap control. That is a direct
|
|
answer, not an inference from byte counts or from file-delete semantics.
|
|
|
|
An earlier revision also gave a contradictory reading (ws flat while private
|
|
commit grew) because the read finished inside one sample interval, so the
|
|
only reading taken was the endpoint. This revision removes that ambiguity
|
|
three ways:
|
|
|
|
* the read is SLOWED (a fixed sleep per tensor) so the sampler sees the
|
|
interior, not just the endpoints;
|
|
* VIEW vs COPY is settled STRUCTURALLY, see above;
|
|
* a WARM-UP read runs first, so the allocator is not handing out virgin pages
|
|
that the first-touch cost would attribute to the measurement.
|
|
|
|
Run (headless, no ComfyUI server):
|
|
COMFYUI_ROOT='C:\_Dev\ComfyUI_dev\ComfyUI_t211c130p313\ComfyUI' \
|
|
"C:/_Dev/ComfyUI_dev/ComfyUI_t211c130p313/python_embeded/python.exe" \
|
|
tests/probe_stream_reader_retention.py
|
|
|
|
Nothing is downloaded and no host path is baked in: the probe BUILDS its own
|
|
synthetic safetensors file in a temp dir.
|
|
"""
|
|
|
|
import ctypes
|
|
import gc
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
sys.path.insert(0, os.environ.get(
|
|
"COMFYUI_ROOT", r"C:\_Dev\ComfyUI_dev\ComfyUI_t211c130p313\ComfyUI"))
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
import torch # noqa: E402
|
|
import psutil # noqa: E402
|
|
|
|
from safetensors import safe_open # noqa: E402
|
|
import safetensors.torch as st # noqa: E402
|
|
|
|
MEM_PRIVATE = 0x20000
|
|
MEM_MAPPED = 0x40000
|
|
_REGION_TYPES = {MEM_PRIVATE: "MEM_PRIVATE", MEM_MAPPED: "MEM_MAPPED",
|
|
0x1000000: "MEM_IMAGE"}
|
|
|
|
|
|
class _MEMORY_BASIC_INFORMATION(ctypes.Structure): # noqa: N801 - win32 name
|
|
_fields_ = [
|
|
("BaseAddress", ctypes.c_void_p),
|
|
("AllocationBase", ctypes.c_void_p),
|
|
("AllocationProtect", ctypes.c_ulong),
|
|
("PartitionId", ctypes.c_ushort),
|
|
("RegionSize", ctypes.c_size_t),
|
|
("State", ctypes.c_ulong),
|
|
("Protect", ctypes.c_ulong),
|
|
("Type", ctypes.c_ulong),
|
|
]
|
|
|
|
|
|
MB = 1024 * 1024
|
|
GIB = 1 << 30
|
|
# ~0.5 GiB of bf16 weights in 32 tensors, shaped like a real checkpoint: a few
|
|
# large matrices plus small ones, so per-tensor size VARIES.
|
|
N_TENSORS = 32
|
|
TENSOR_MB = 16
|
|
SLOW_READ_S = 0.05
|
|
|
|
|
|
def snapshot() -> dict:
|
|
proc = psutil.Process(os.getpid())
|
|
info = proc.memory_info()
|
|
return {
|
|
"ws": int(info.rss),
|
|
"uss": int(proc.memory_full_info().uss),
|
|
"private": int(getattr(info, "private", info.rss)),
|
|
}
|
|
|
|
|
|
class Peak:
|
|
"""Background peak sampler, the same shape as the shipped RssSampler."""
|
|
|
|
def __init__(self, interval: float = 0.02):
|
|
self.interval = interval
|
|
self.peak = {"ws": 0, "uss": 0, "private": 0}
|
|
self.start = snapshot()
|
|
self._stop = threading.Event()
|
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _run(self):
|
|
while not self._stop.is_set():
|
|
self._take()
|
|
self._stop.wait(self.interval)
|
|
|
|
def _take(self) -> dict:
|
|
reading = snapshot()
|
|
for key in self.peak:
|
|
self.peak[key] = max(self.peak[key], reading[key])
|
|
return reading
|
|
|
|
def close(self) -> "Peak":
|
|
self._stop.set()
|
|
self._thread.join(timeout=2.0)
|
|
self.end = self._take()
|
|
return self
|
|
|
|
|
|
def build_file(path: str) -> int:
|
|
tensors = {}
|
|
for i in range(N_TENSORS):
|
|
n = TENSOR_MB * MB // 2 # bf16 => half the bytes
|
|
# Vary the size so the allocator cannot serve every tensor from one
|
|
# warm block: a uniform size would let a fragmentation-free reading
|
|
# of "no extra bytes" hide behind reuse.
|
|
n -= i * 16 * 1024
|
|
tensors[f"layer{i}.weight"] = torch.zeros(
|
|
n // 4096, 4096, dtype=torch.bfloat16)
|
|
tensors["layer0.bias"] = torch.zeros(4096, dtype=torch.bfloat16)
|
|
st.save_file(tensors, path)
|
|
return os.path.getsize(path)
|
|
|
|
|
|
def _region_of(address: int) -> tuple:
|
|
"""``(region type name, backing file)`` for ``address`` via Win32.
|
|
|
|
``VirtualQuery`` says what KIND of region the bytes are — ``MEM_MAPPED``
|
|
for a mapping, ``MEM_PRIVATE`` for a heap allocation — and
|
|
``GetMappedFileNameW`` names the file behind a mapped region. This is the
|
|
replacement for the old delete-based test, which could not tell a live
|
|
mapping from an ordinary access denial.
|
|
"""
|
|
if sys.platform != "win32": # pragma: no cover - this host is Windows
|
|
return ("unsupported", "")
|
|
mbi = _MEMORY_BASIC_INFORMATION()
|
|
kernel32 = ctypes.windll.kernel32
|
|
if not kernel32.VirtualQuery(
|
|
ctypes.c_void_p(address), ctypes.byref(mbi), ctypes.sizeof(mbi)
|
|
):
|
|
return ("unknown", "")
|
|
name = _REGION_TYPES.get(int(mbi.Type), f"0x{int(mbi.Type):x}")
|
|
buf = ctypes.create_unicode_buffer(1024)
|
|
fn = ctypes.windll.psapi.GetMappedFileNameW
|
|
fn.restype = ctypes.c_ulong
|
|
fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_wchar_p,
|
|
ctypes.c_ulong]
|
|
copied = fn(kernel32.GetCurrentProcess(), ctypes.c_void_p(address), buf, 1024)
|
|
return (name, buf.value if copied else "")
|
|
|
|
|
|
def is_view(path: str, key: str) -> bool:
|
|
"""True iff ``get_tensor`` handed back a live mapping of ``path``.
|
|
|
|
Decided by the region the tensor's bytes occupy, cross-checked against a
|
|
heap allocation's region. The backing-file comparison is done on the file
|
|
NAME because ``GetMappedFileNameW`` answers in NT namespace
|
|
(``\\Device\\HarddiskVolume3\\...``) while ``os.path.realpath`` answers in
|
|
DOS namespace (``C:\\...``); comparing the full strings would report a
|
|
mismatch for the file itself.
|
|
"""
|
|
with safe_open(path, framework="pt", device="cpu") as f:
|
|
tensor = f.get_tensor(key)
|
|
kind, backing = _region_of(tensor.data_ptr())
|
|
control_kind, _ = _region_of(torch.zeros(1024, dtype=torch.bfloat16).data_ptr())
|
|
name = os.path.basename(path).lower()
|
|
is_mapping = kind == "MEM_MAPPED" and name in backing.lower()
|
|
print(
|
|
f"[vvprobe] region({key!r}) = {kind} backing={backing or '-'} | "
|
|
f"heap control = {control_kind} -> "
|
|
f"{'VIEW' if is_mapping else 'OWNED COPY'}"
|
|
)
|
|
return is_mapping
|
|
|
|
|
|
def read_retaining(path: str, clone: bool, slow: bool) -> dict:
|
|
"""Stream every tensor, RETAIN it (and optionally clone it), return peaks.
|
|
|
|
Retention is a first-class term: the loaded model holds every assigned
|
|
tensor, so the list is the model, not scratch space.
|
|
"""
|
|
held = []
|
|
peak = Peak()
|
|
with safe_open(path, framework="pt", device="cpu") as f:
|
|
for key in f.keys():
|
|
tensor = f.get_tensor(key)
|
|
held.append(tensor.clone() if clone else tensor)
|
|
if slow:
|
|
time.sleep(SLOW_READ_S)
|
|
peak.close()
|
|
retained = sum(t.untyped_storage().nbytes() for t in held)
|
|
return {"peak": peak, "retained": retained, "n": len(held)}
|
|
|
|
|
|
def report(name: str, result: dict, file_bytes: int) -> None:
|
|
peak, start = result["peak"].peak, result["peak"].start
|
|
print(
|
|
f"[vvprobe] {name:20s} file={file_bytes / GIB:.2f}GiB "
|
|
f"retained={result['retained'] / GIB:.2f}GiB n={result['n']} "
|
|
f"peak_ws={(peak['ws'] - start['ws']) / GIB:+.2f}GiB "
|
|
f"peak_uss={(peak['uss'] - start['uss']) / GIB:+.2f}GiB "
|
|
f"peak_priv={(peak['private'] - start['private']) / GIB:+.2f}GiB"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
tmp = tempfile.mkdtemp(prefix="vv_stream_probe_")
|
|
path = os.path.join(tmp, "synthetic.safetensors")
|
|
file_bytes = build_file(path)
|
|
print(f"[vvprobe] built {file_bytes} B at {path}")
|
|
print(f"[vvprobe] safetensors.get_tensor returns a VIEW: "
|
|
f"{is_view(path, 'layer0.weight')}")
|
|
|
|
# WARM-UP: one full read, discarded, so the allocator and the page cache
|
|
# are not in their virgin state during the measured reads.
|
|
read_retaining(path, clone=True, slow=False)
|
|
gc.collect()
|
|
time.sleep(0.5)
|
|
|
|
for clone in (False, True):
|
|
gc.collect()
|
|
time.sleep(0.3)
|
|
result = read_retaining(path, clone=clone, slow=True)
|
|
report("read+retain CLONE" if clone else "read+retain VIEW", result,
|
|
file_bytes)
|
|
del result
|
|
gc.collect()
|
|
time.sleep(0.5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|