fix(gguf): expand one tensor at a time instead of all of them
Dequantizing produces float32 — four times the width the weight ends up at. Every tensor was collected before any was cast, so the peak was the whole checkpoint in float32 plus a copy of each: tens of GB of transient host allocation on a video model, enough to take the machine down rather than just the load. ThreadPoolExecutor.map made it worse by submitting everything at once and buffering results in order. Now cast-and-release per tensor, with a fixed in-flight window. Peak is the finished model plus a handful of tensors. Pool capped at 4. Also prints the path and tensor count before the wait, so a load killed part way leaves evidence of which backend it chose.
This commit is contained in:
+54
-29
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import itertools
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
@@ -400,41 +401,44 @@ def _load_native(path: str) -> tuple[dict, dict, str]:
|
|||||||
|
|
||||||
Correct but not thrifty: the model ends up at its dequantized size, so this buys the
|
Correct but not thrifty: the model ends up at its dequantized size, so this buys the
|
||||||
ability to LOAD a .gguf, not GGUF's memory saving. The caller states that.
|
ability to LOAD a .gguf, not GGUF's memory saving. The caller states that.
|
||||||
|
|
||||||
|
Expansion is STREAMED. Dequantizing produces float32 — four times the size the weight
|
||||||
|
will occupy once it is cast — so collecting every tensor before converting any of them
|
||||||
|
peaks at several times the finished model in host RAM. On a video checkpoint that is
|
||||||
|
tens of gigabytes of transient allocation, which is enough to take the machine down
|
||||||
|
rather than merely the load. Each tensor is therefore cast and released as it is
|
||||||
|
produced, and only a small window is ever in flight.
|
||||||
"""
|
"""
|
||||||
import gguf
|
import gguf
|
||||||
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
reader = gguf.GGUFReader(path)
|
reader = gguf.GGUFReader(path)
|
||||||
tensors = list(reader.tensors)
|
tensors = list(reader.tensors)
|
||||||
# Dequantizing serially is where the minute goes: gguf.quants.dequantize is numpy, and a
|
raw = (gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16)
|
||||||
# video checkpoint is thousands of tensors. numpy releases the GIL for the array work, so
|
quantized = sum(1 for t in tensors if t.tensor_type not in raw)
|
||||||
# threads are real parallelism here. Bounded, not one per core: each worker holds a
|
# Bounded, and deliberately small: the window below keeps `workers * 2` float32 tensors
|
||||||
# dequantized tensor AND its copy, so an unbounded pool trades load time for a memory
|
# alive at once, and the largest weight in a video model is around a gigabyte at that
|
||||||
# spike on the largest weights.
|
# width. More threads would expand faster and spike higher; the durable speed win is the
|
||||||
workers = max(1, min(8, (os.cpu_count() or 2) - 1))
|
# dequantized cache in `_load_native_cached`, not this pool.
|
||||||
quantized = sum(
|
workers = max(1, min(4, (os.cpu_count() or 2) - 1))
|
||||||
1 for t in tensors
|
print(f"[FunPack] gguf: expanding {quantized} of {len(tensors)} tensors from "
|
||||||
if t.tensor_type not in (gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16))
|
f"{os.path.basename(path)} across {workers} thread(s). This is the slow path — "
|
||||||
|
f"minutes on a video checkpoint, and the result is cached beside the file.",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
def _dequant(tensor):
|
def _expand(tensor):
|
||||||
|
"""One tensor, fully finished: nothing float32 outlives this call."""
|
||||||
qtype = tensor.tensor_type
|
qtype = tensor.tensor_type
|
||||||
if qtype in (gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16):
|
arr = tensor.data if qtype in raw else gguf.quants.dequantize(tensor.data, qtype)
|
||||||
return tensor.data
|
if arr.dtype == np.float32:
|
||||||
# gguf.quants.dequantize is the reference implementation for every type the
|
# `.to` allocates the half-width copy and the float32 array is freed on return,
|
||||||
# package knows — do not reimplement it per quant type.
|
# so the wide form never accumulates.
|
||||||
return gguf.quants.dequantize(tensor.data, qtype)
|
t = torch.from_numpy(arr).to(torch.float16)
|
||||||
|
else:
|
||||||
if workers > 1 and len(tensors) > 1:
|
# Detach from the memory-mapped file; the reader's mapping does not outlive us.
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
t = torch.from_numpy(arr.copy())
|
||||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
||||||
arrays = list(pool.map(_dequant, tensors))
|
|
||||||
else:
|
|
||||||
arrays = [_dequant(t) for t in tensors]
|
|
||||||
|
|
||||||
sd: dict = {}
|
|
||||||
for tensor, arr in zip(tensors, arrays):
|
|
||||||
name = str(tensor.name)
|
name = str(tensor.name)
|
||||||
t = torch.from_numpy(arr.copy())
|
|
||||||
# GGUF stores dimensions in the opposite order to torch.
|
# GGUF stores dimensions in the opposite order to torch.
|
||||||
shape = tuple(int(d) for d in reversed(tensor.shape))
|
shape = tuple(int(d) for d in reversed(tensor.shape))
|
||||||
expected = 1
|
expected = 1
|
||||||
@@ -447,9 +451,30 @@ def _load_native(path: str) -> tuple[dict, dict, str]:
|
|||||||
f"{os.path.basename(path)}: tensor {name!r} dequantized to {t.numel()} "
|
f"{os.path.basename(path)}: tensor {name!r} dequantized to {t.numel()} "
|
||||||
f"elements but its header declares {shape} ({expected}). This container is "
|
f"elements but its header declares {shape} ({expected}). This container is "
|
||||||
f"not laid out the way the gguf package describes it.")
|
f"not laid out the way the gguf package describes it.")
|
||||||
if shape:
|
return name, t.reshape(shape) if shape else t
|
||||||
t = t.reshape(shape)
|
|
||||||
sd[name] = t.to(torch.float16) if t.dtype == torch.float32 else t
|
sd: dict = {}
|
||||||
|
if workers > 1 and len(tensors) > 1:
|
||||||
|
from collections import deque
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
remaining = iter(tensors)
|
||||||
|
# A fixed window rather than `pool.map`, which submits every tensor at once and
|
||||||
|
# buffers each result until the one before it is consumed — the whole model in
|
||||||
|
# float32, which is the allocation this function exists to avoid.
|
||||||
|
flight = deque(pool.submit(_expand, t)
|
||||||
|
for t in itertools.islice(remaining, workers * 2))
|
||||||
|
while flight:
|
||||||
|
name, t = flight.popleft().result()
|
||||||
|
sd[name] = t
|
||||||
|
nxt = next(remaining, None)
|
||||||
|
if nxt is not None:
|
||||||
|
flight.append(pool.submit(_expand, nxt))
|
||||||
|
else:
|
||||||
|
for tensor in tensors:
|
||||||
|
name, t = _expand(tensor)
|
||||||
|
sd[name] = t
|
||||||
|
|
||||||
note = (f"gguf: dequantized at load ({quantized} quantized tensors expanded across "
|
note = (f"gguf: dequantized at load ({quantized} quantized tensors expanded across "
|
||||||
f"{workers} thread(s)) — the file loads, but it occupies its full size in VRAM, "
|
f"{workers} thread(s)) — the file loads, but it occupies its full size in VRAM, "
|
||||||
f"and expanding it is why this is slower than a .safetensors. Install "
|
f"and expanding it is why this is slower than a .safetensors. Install "
|
||||||
|
|||||||
+6
-2
@@ -130,8 +130,12 @@ def set_fp16_accumulation(enabled):
|
|||||||
|
|
||||||
|
|
||||||
# One loaded checkpoint, so changing an inference setting on the loader does not re-read the
|
# One loaded checkpoint, so changing an inference setting on the loader does not re-read the
|
||||||
# file. Replaced whenever the key changes, so this never holds more than one model — the same
|
# file. Replaced whenever the key changes, so this never holds more than one model.
|
||||||
# lifetime ComfyUI's node-output cache already gave it.
|
#
|
||||||
|
# The reference is strong on purpose: ComfyUI drops this node's cached output BEFORE it
|
||||||
|
# re-runs it, which is exactly the moment a weak one would be dead. The cost is that this
|
||||||
|
# entry outlives a "free model and node cache", so one checkpoint stays resident until a
|
||||||
|
# different file or dtype replaces it. Loading anything else releases it.
|
||||||
_WEIGHT_CACHE: dict = {"key": None, "model": None}
|
_WEIGHT_CACHE: dict = {"key": None, "model": None}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ which backend gets picked, what happens when none is available, and whether .ggu
|
|||||||
are found at all (core's extension set excludes them, which is the whole reason this
|
are found at all (core's extension set excludes them, which is the whole reason this
|
||||||
module exists).
|
module exists).
|
||||||
"""
|
"""
|
||||||
|
import gc
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
import weakref
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
sys.path.insert(0, ".")
|
sys.path.insert(0, ".")
|
||||||
import gguf_support # noqa: E402
|
import gguf_support # noqa: E402
|
||||||
@@ -370,3 +373,95 @@ def test_a_non_tensor_value_is_named(tmp_path):
|
|||||||
def test_a_real_state_dict_passes():
|
def test_a_real_state_dict_passes():
|
||||||
import torch
|
import torch
|
||||||
gguf_support._assert_state_dict({"w": torch.zeros(2)}, "m.gguf")
|
gguf_support._assert_state_dict({"w": torch.zeros(2)}, "m.gguf")
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_gguf(monkeypatch, tensors):
|
||||||
|
"""A stand-in for the `gguf` package over `tensors` — (name, shape, values) triples.
|
||||||
|
|
||||||
|
Every tensor is reported as quantized, so `_load_native` takes its dequantize path for
|
||||||
|
all of them, and each dequantize hands back a fresh float32 array whose lifetime the
|
||||||
|
caller can track.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
fake = types.ModuleType("gguf")
|
||||||
|
|
||||||
|
class _QType:
|
||||||
|
F32 = "F32"
|
||||||
|
F16 = "F16"
|
||||||
|
Q8 = "Q8"
|
||||||
|
|
||||||
|
class _Tensor:
|
||||||
|
def __init__(self, name, shape, values):
|
||||||
|
self.name = name
|
||||||
|
# GGUF reports dimensions in the opposite order to torch.
|
||||||
|
self.shape = tuple(reversed(shape))
|
||||||
|
self.tensor_type = _QType.Q8
|
||||||
|
self.data = np.asarray(values, dtype=np.float32)
|
||||||
|
|
||||||
|
class _Reader:
|
||||||
|
def __init__(self, path):
|
||||||
|
self.tensors = [_Tensor(n, s, v) for n, s, v in tensors]
|
||||||
|
|
||||||
|
produced = []
|
||||||
|
live = []
|
||||||
|
|
||||||
|
def _dequantize(data, qtype):
|
||||||
|
# Sampled BEFORE this call's own array exists, so it counts what the loader is still
|
||||||
|
# holding on to. Peak is the whole point: every array is freed by the time the load
|
||||||
|
# returns no matter how it was written.
|
||||||
|
live.append(sum(1 for ref in produced if ref() is not None))
|
||||||
|
arr = np.asarray(data, dtype=np.float32).copy()
|
||||||
|
produced.append(weakref.ref(arr))
|
||||||
|
return arr
|
||||||
|
|
||||||
|
quants = types.ModuleType("gguf.quants")
|
||||||
|
quants.dequantize = _dequantize
|
||||||
|
fake.GGMLQuantizationType = _QType
|
||||||
|
fake.GGUFReader = _Reader
|
||||||
|
fake.quants = quants
|
||||||
|
monkeypatch.setitem(sys.modules, "gguf", fake)
|
||||||
|
monkeypatch.setitem(sys.modules, "gguf.quants", quants)
|
||||||
|
return produced, live
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_native_returns_half_width_tensors_in_torch_order(monkeypatch):
|
||||||
|
monkeypatch.setattr(gguf_support.os, "cpu_count", lambda: 2) # forces the serial path
|
||||||
|
_fake_gguf(monkeypatch, [("a", (2, 3), [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])])
|
||||||
|
|
||||||
|
sd, options, note = gguf_support._load_native("/models/m.gguf")
|
||||||
|
|
||||||
|
assert options == {}
|
||||||
|
assert list(sd) == ["a"]
|
||||||
|
assert tuple(sd["a"].shape) == (2, 3)
|
||||||
|
assert sd["a"].dtype is torch.float16
|
||||||
|
assert sd["a"][1][2].item() == 6.0
|
||||||
|
assert "1 quantized tensors" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_native_does_not_keep_every_expanded_tensor_alive(monkeypatch):
|
||||||
|
"""The float32 form is four times the finished weight, so holding all of them at once
|
||||||
|
peaks at several times the model in host RAM — enough to take a machine down on a video
|
||||||
|
checkpoint. Each one must be cast and released as it is produced."""
|
||||||
|
monkeypatch.setattr(gguf_support.os, "cpu_count", lambda: 2) # forces the serial path
|
||||||
|
produced, live = _fake_gguf(
|
||||||
|
monkeypatch, [(f"t{i}", (4, 4), [[float(i)] * 4] * 4) for i in range(24)])
|
||||||
|
|
||||||
|
gguf_support._load_native("/models/m.gguf")
|
||||||
|
|
||||||
|
assert len(produced) == 24
|
||||||
|
# Collecting them all before converting any would climb 0, 1, 2, ... 23.
|
||||||
|
assert max(live) <= 2, f"{max(live)} expanded tensors were held at once"
|
||||||
|
gc.collect()
|
||||||
|
assert [ref for ref in produced if ref() is not None] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_native_rejects_a_tensor_that_contradicts_its_header(monkeypatch):
|
||||||
|
monkeypatch.setattr(gguf_support.os, "cpu_count", lambda: 2)
|
||||||
|
_fake_gguf(monkeypatch, [("wrong", (5, 5), [1.0, 2.0, 3.0])])
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as excinfo:
|
||||||
|
gguf_support._load_native("/models/m.gguf")
|
||||||
|
|
||||||
|
assert "'wrong'" in str(excinfo.value)
|
||||||
|
assert "(25)" in str(excinfo.value)
|
||||||
|
|||||||
Reference in New Issue
Block a user