File size: 40,713 Bytes
d710727 b0a01d0 d710727 b0a01d0 d710727 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 | """Packed on-disk storage for LTX-2.5 encoder precision plans.
`ltx_fake_quantize` and `ltx_gptq` store quantized values expanded back to
BF16, so a 4.5-bit plan still costs 26 GB on disk. This module is the missing
second half: the same values in their native widths.
The format is one safetensors file. A raw tensor keeps its checkpoint name. A
quantized tensor is split into named parts:
* nvfp4 - ``{name}::nvfp4_codes`` (uint8, two 4-bit codes per byte, even
column in the low nibble; a code is ``sign << 3 | magnitude`` indexing
``E2M1_LEVELS``), ``{name}::nvfp4_group_scale`` (float8_e4m3fn,
[out, in/16]), ``{name}::nvfp4_global_scale`` (float32 scalar), and - when
the GPTQ path smoothed the tensor - ``{name}::pre_quant_scale`` (float32,
[in], the AWQ scale the stored weight was *divided* by).
* int8 - ``{name}::int8`` and ``{name}::int8_scale`` (float32, [out]).
Exactness is the design constraint, not an aspiration: unpacking replays the
producers' arithmetic - ``(sign * level) * effective`` then the pre-scale
division, in float32, cast to the stored dtype last - so a packed tensor
dequantizes to the same values the BF16 fake-quant file would have carried.
The one tolerated difference is that int8 cannot store a negative zero, so a
``-0.0`` produced by ``round()`` collapses to ``+0.0``; ``torch.equal`` treats
the two as equal and the packer counts them rather than hiding them.
Group scales are recorded as the exact float8 bytes the quantizer used, not
recomputed from the output. Recomputing them is impossible in general: the
GPTQ column loop derives each group's scale from weights that were already
compensated, and the AWQ division afterwards destroys the grid alignment that
recovery would need. That is why packing happens inside the build
(`ltx_gptq --packed-output`) instead of as a post-pass over the artifact.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parent))
# --- inlined from ltx_fake_quantize -----------------------------------------
#: The eight magnitudes E2M1 can represent, and the midpoints between them that
#: decide which one a value rounds to.
E2M1_LEVELS = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
E2M1_MIDPOINTS = (0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5.0)
NVFP4_GROUP = 16
#: E4M3's largest finite value; the per-tensor scale is chosen so the per-group
#: scales divided by it land inside this range.
E4M3_MAX = 448.0
GPU_TENSOR_LIMIT = 1_000_000_000
# --- inlined from ltx_prompt_embedding_gate ---------------------------------
#: Lightricks re-exported the encoder under the naming Google used before the
#: `gemma4_unified` port landed in `transformers`. Without these rules
#: `from_pretrained` reports every checkpoint tensor UNEXPECTED and every
#: parameter MISSING, and hands back a randomly initialised model that looks
#: like it loaded.
LTX_RENAMES = [
(r"^model\.", "language_model."),
(r"^vision_model\.", "embed_vision."),
(r"^audio_projector\.", "embed_audio."),
(r"^multi_modal_projector\.embedding_projection",
"embed_vision.multimodal_embedder.embedding_projection"),
]
def register_ltx_renames() -> None:
from transformers.conversion_mapping import (
get_checkpoint_conversion_mapping,
register_checkpoint_conversion_mapping,
)
from transformers.core_model_loading import WeightRenaming
existing = get_checkpoint_conversion_mapping("gemma4_unified") or []
rules = [WeightRenaming(source_patterns=s, target_patterns=t)
for s, t in LTX_RENAMES]
register_checkpoint_conversion_mapping(
"gemma4_unified", rules + existing, overwrite=True)
# ----------------------------------------------------------------------------
FORMAT_VERSION = "haverbex-packed-v1"
#: Part suffixes. `::` cannot appear in a checkpoint tensor name, so packed
#: parts can never collide with a raw tensor.
CODES = "::nvfp4_codes"
GROUP_SCALE = "::nvfp4_group_scale"
GLOBAL_SCALE = "::nvfp4_global_scale"
PRE_SCALE = "::pre_quant_scale"
INT8 = "::int8"
INT8_SCALE = "::int8_scale"
_SUFFIXES = (CODES, GROUP_SCALE, GLOBAL_SCALE, PRE_SCALE, INT8, INT8_SCALE)
#: Parts indexed by output row, so a chunked read can slice them. The other two
#: are not: `GLOBAL_SCALE` is a scalar and `PRE_SCALE` is per *input* channel.
_ROW_SLICEABLE = (CODES, GROUP_SCALE, INT8, INT8_SCALE)
#: Part suffix -> buffer name on a resident module. `::` is illegal in a
#: `register_buffer` name, so the mapping cannot be derived.
_BUFFER_NAMES = {CODES: "codes", GROUP_SCALE: "group_scale",
GLOBAL_SCALE: "global_scale", PRE_SCALE: "pre_scale",
INT8: "ints", INT8_SCALE: "int8_scale"}
def nibble_pack(codes: torch.Tensor) -> torch.Tensor:
"""[out, in] uint8 (values 0..15) -> [out, in/2], even column low nibble."""
if codes.shape[-1] % 2:
raise ValueError(f"odd inner width {codes.shape[-1]} cannot nibble-pack")
return codes[..., 0::2] | (codes[..., 1::2] << 4)
def nibble_unpack(packed: torch.Tensor) -> torch.Tensor:
out = torch.empty(*packed.shape[:-1], packed.shape[-1] * 2, dtype=torch.uint8,
device=packed.device)
out[..., 0::2] = packed & 0x0F
out[..., 1::2] = packed >> 4
return out
def encode_nvfp4_column(normalized: torch.Tensor, codes: torch.Tensor) -> torch.Tensor:
"""4-bit code for one already-bucketized column: sign bit over magnitude.
`torch.sign` maps an exact zero to 0, which multiplies out to ``+0.0``; the
encoding gives it sign bit 0 so decode lands on the same ``+0.0``.
"""
return codes.to(torch.uint8) | ((normalized < 0).to(torch.uint8) << 3)
def decode_nvfp4(codes: torch.Tensor, group_scale: torch.Tensor,
global_scale: torch.Tensor,
pre_scale: torch.Tensor | None) -> torch.Tensor:
"""Replay of `NVFP4Column.quantize` / `quantize_nvfp4`, in float32.
`levels` is built on the codes' device: at load time everything is on the
CPU, but a `PackedLinear` decodes wherever its weight lives.
"""
levels = torch.tensor(E2M1_LEVELS, dtype=torch.float32, device=codes.device)
sign = torch.where((codes & 0x8) != 0, -1.0, 1.0)
magnitude = levels[(codes & 0x7).long()]
effective = group_scale.float() * global_scale.float()
effective = torch.where(effective > 0, effective, torch.ones_like(effective))
out_features, in_features = codes.shape
grid = (sign * magnitude).reshape(out_features, in_features // NVFP4_GROUP,
NVFP4_GROUP)
grid = (grid * effective.unsqueeze(-1)).reshape(out_features, in_features)
if pre_scale is not None:
grid = grid / pre_scale.float()
return grid
def decode_int8(ints: torch.Tensor, scale: torch.Tensor,
pre_scale: torch.Tensor | None) -> torch.Tensor:
"""Replay of `quantize_int8` / `Int8Column.quantize`, in float32."""
grid = ints.float() * scale.float().unsqueeze(-1)
if pre_scale is not None:
grid = grid / pre_scale.float()
return grid
def pack_rtn_nvfp4(w: torch.Tensor) -> tuple[torch.Tensor, dict]:
"""`ltx_fake_quantize.quantize_nvfp4` with the codes and scales kept.
Returns the dequantized float32 tensor (identical to what the fake
quantizer produces) and the packed parts.
"""
out_features, in_features = w.shape
if in_features % NVFP4_GROUP:
raise ValueError(f"nvfp4 needs a multiple of {NVFP4_GROUP}, got {in_features}")
amax = w.abs().amax()
if amax == 0:
raise ValueError("all-zero tensor should be stored raw, not packed")
global_scale = amax / (E2M1_LEVELS[-1] * E4M3_MAX)
groups = w.reshape(out_features, in_features // NVFP4_GROUP, NVFP4_GROUP)
group_amax = groups.abs().amax(dim=-1, keepdim=True)
scale = (group_amax / E2M1_LEVELS[-1] / global_scale).to(torch.float8_e4m3fn)
effective = scale.float() * global_scale
effective = torch.where(effective > 0, effective, torch.ones_like(effective))
levels = torch.tensor(E2M1_LEVELS, device=w.device, dtype=w.dtype)
midpoints = torch.tensor(E2M1_MIDPOINTS, device=w.device, dtype=w.dtype)
normalized = groups / effective
codes = torch.bucketize(normalized.abs(), midpoints, out_int32=True)
dequant = (torch.sign(normalized) * levels[codes] * effective).reshape(
out_features, in_features)
packed_codes = (codes.to(torch.uint8)
| ((normalized < 0).to(torch.uint8) << 3)).reshape(
out_features, in_features)
parts = {
CODES: nibble_pack(packed_codes).cpu(),
GROUP_SCALE: scale.squeeze(-1).cpu(),
GLOBAL_SCALE: global_scale.detach().float().reshape(1).cpu(),
}
return dequant, parts
def pack_rtn(writer: PackWriter, name: str, tensor: torch.Tensor,
width: float, device: str) -> torch.Tensor:
"""`ltx_fake_quantize.quantize_tensor`, but keeping the codes.
Returns the fake-quantized tensor in the original dtype so the caller's
BF16 write path is unchanged. Same device policy as the original: a tensor
whose float32 form exceeds `GPU_TENSOR_LIMIT` is quantized on the CPU.
"""
original = tensor.dtype
where = "cpu" if tensor.numel() * 4 > GPU_TENSOR_LIMIT else device
w = tensor.to(where, torch.float32)
if width == 4.5:
if w.abs().amax() == 0:
# quantize_nvfp4 returns an all-zero tensor unchanged; store it raw
writer.store_raw(name, tensor)
return tensor
dequant, parts = pack_rtn_nvfp4(w)
result = dequant.to("cpu", original)
writer.store_nvfp4(name, parts, result)
else:
dequant, parts = pack_rtn_int8(w)
result = dequant.to("cpu", original)
writer.store_int8(name, parts, result)
return result
def pack_rtn_int8(w: torch.Tensor) -> tuple[torch.Tensor, dict]:
"""`ltx_fake_quantize.quantize_int8` with the integers kept."""
scale = w.abs().amax(dim=-1, keepdim=True) / 127.0
scale = torch.where(scale > 0, scale, torch.ones_like(scale))
ints = (w / scale).round().clamp_(-127, 127)
dequant = ints * scale
parts = {
INT8: ints.to(torch.int8).cpu(),
INT8_SCALE: scale.squeeze(-1).float().cpu(),
}
return dequant, parts
class PackWriter:
"""Accumulates packed parts and raw tensors, then writes one file.
Held in RAM rather than streamed: the whole point of the format is that
the payload is ~8.5 GB, which fits beside the build. Every quantized store
verifies round-trip equality against the reference tensor before
accepting it - a packed file that does not reproduce its builder's values
must not be creatable through this class.
"""
def __init__(self) -> None:
self.tensors: dict[str, torch.Tensor] = {}
self.kinds: dict[str, str] = {}
self.negative_zero_collapses = 0
def store_raw(self, name: str, tensor: torch.Tensor) -> None:
if any(s in name for s in _SUFFIXES):
raise ValueError(f"raw name collides with a part suffix: {name}")
self.tensors[name] = tensor.detach().contiguous().cpu()
self.kinds[name] = "raw"
def _verify(self, name: str, reference: torch.Tensor) -> None:
got = unpack_tensor(self.tensors, name, self.kinds[name],
reference.dtype)
if not torch.equal(got, reference.cpu()):
diff = (got.float() - reference.float().cpu()).abs()
raise SystemExit(
f"packed round-trip mismatch on {name}: max {diff.max():.3e} at "
f"{int(diff.argmax())} - refusing to write a lossy pack")
signs = got.signbit() != reference.cpu().signbit()
self.negative_zero_collapses += int(signs.sum())
def store_nvfp4(self, name: str, parts: dict, reference: torch.Tensor,
pre_scale: torch.Tensor | None = None) -> None:
for suffix, tensor in parts.items():
self.tensors[name + suffix] = tensor.contiguous()
if pre_scale is not None:
self.tensors[name + PRE_SCALE] = pre_scale.detach().float().cpu()
self.kinds[name] = "nvfp4"
self._verify(name, reference)
def store_int8(self, name: str, parts: dict, reference: torch.Tensor,
pre_scale: torch.Tensor | None = None) -> None:
for suffix, tensor in parts.items():
self.tensors[name + suffix] = tensor.contiguous()
if pre_scale is not None:
self.tensors[name + PRE_SCALE] = pre_scale.detach().float().cpu()
self.kinds[name] = "int8"
self._verify(name, reference)
def save(self, path: Path, metadata: dict[str, str]) -> None:
from safetensors.torch import save_file
payload = {
"format": FORMAT_VERSION,
"kinds": json.dumps(self.kinds, separators=(",", ":")),
"negative_zero_collapses": str(self.negative_zero_collapses),
**metadata,
}
path.parent.mkdir(parents=True, exist_ok=True)
save_file(self.tensors, str(path), metadata=payload)
def unpack_tensor(tensors: dict, name: str, kind: str,
dtype: torch.dtype) -> torch.Tensor:
if kind == "raw":
return tensors[name]
pre = tensors.get(name + PRE_SCALE)
if kind == "nvfp4":
grid = decode_nvfp4(nibble_unpack(tensors[name + CODES]),
tensors[name + GROUP_SCALE],
tensors[name + GLOBAL_SCALE],
pre)
elif kind == "int8":
grid = decode_int8(tensors[name + INT8], tensors[name + INT8_SCALE], pre)
else:
raise ValueError(f"unknown kind {kind!r} for {name}")
return grid.to(dtype)
class PackedCheckpoint:
"""Read-side API mirroring `safetensors.safe_open` for packed files."""
def __init__(self, path: Path | str) -> None:
from safetensors import safe_open
self.path = Path(path)
self._file = safe_open(str(self.path), framework="pt")
self.metadata = self._file.metadata() or {}
if self.metadata.get("format") != FORMAT_VERSION:
raise SystemExit(
f"{path}: format {self.metadata.get('format')!r}, "
f"this reader speaks {FORMAT_VERSION}")
self.kinds: dict[str, str] = json.loads(self.metadata["kinds"])
self._present = set(self._file.keys())
def __enter__(self) -> PackedCheckpoint:
return self
def __exit__(self, *exc) -> None:
return None
def keys(self) -> list[str]:
return list(self.kinds)
def get_tensor(self, name: str, dtype: torch.dtype = torch.bfloat16):
kind = self.kinds[name]
if kind == "raw":
return self._file.get_tensor(name)
parts = {name + s: self._file.get_tensor(name + s)
for s in _SUFFIXES if name + s in self._present}
return unpack_tensor(parts, name, kind, dtype)
def get_parts(self, name: str) -> dict:
"""The stored parts of a packed tensor, keyed by bare suffix, undecoded.
What a resident module needs: the bytes as written, with no decode and
no reassembly.
"""
return {s: self._file.get_tensor(name + s)
for s in _SUFFIXES if name + s in self._present}
def row_count(self, name: str) -> int:
"""Output rows of a packed tensor, without decoding it."""
kind = self.kinds[name]
key = name if kind == "raw" else name + (CODES if kind == "nvfp4" else INT8)
return self._file.get_slice(key).get_shape()[0]
def get_tensor_rows(self, name: str, start: int, stop: int,
dtype: torch.dtype = torch.bfloat16):
"""Output rows `[start, stop)` of a tensor, decoding only those rows.
Lets a caller work through a table that does not fit beside the model -
the LTX aggregates are 3.08 GB each in float32. Row-sliced parts are
the ones indexed by output channel; the global scale is a scalar and
the AWQ pre-scale is per *input* channel, so both are read whole.
"""
kind = self.kinds[name]
if kind == "raw":
return self._file.get_slice(name)[start:stop].to(dtype)
parts = {}
for suffix in _SUFFIXES:
key = name + suffix
if key not in self._present:
continue
parts[key] = (self._file.get_slice(key)[start:stop]
if suffix in _ROW_SLICEABLE
else self._file.get_tensor(key))
return unpack_tensor(parts, name, kind, dtype)
def open_maybe_packed(path: Path | str):
"""`safe_open` for BF16 checkpoints, `PackedCheckpoint` for packed ones.
Both expose `keys()` and `get_tensor(name)`, which is all the aggregate
and asset readers use. Detection is by content, not extension: a packed
file carries `format` metadata that a plain checkpoint does not.
"""
from safetensors import safe_open
handle = safe_open(str(path), framework="pt")
if (handle.metadata() or {}).get("format") == FORMAT_VERSION:
return PackedCheckpoint(path)
return handle
def install_resident(model, reader: PackedCheckpoint, wanted: dict, rename,
device: str | None = None, fold_pre_scale: bool = False):
"""Replace the quantized modules of a meta-device model with packed ones.
Two passes, because a module has to exist before its bias can be fed into
it: first swap every module whose weight is packed, then feed the raw
tensors - norms, layer scalars, and any bias - into whatever module now
sits at that path.
The coverage guard is the same one the dequantized path uses, and it is
what makes a silent rename failure impossible: every parameter the model
declared must be accounted for, either by a swap or by a raw feed.
"""
from accelerate.utils import set_module_tensor_to_device
if device is None:
device = "cuda" if torch.cuda.is_available() else "cpu"
swapped, raw_names = set(), []
for source_name in reader.keys(): # noqa: SIM118 - PackedCheckpoint, not a dict
param_name = rename(source_name)
if param_name not in wanted:
continue # aggregates and asset blobs live outside the module tree
if reader.kinds[source_name] == "raw":
raw_names.append((source_name, param_name))
continue
if not param_name.endswith(".weight"):
raise SystemExit(
f"{source_name} is packed but renames to {param_name}, which is "
"not a module weight; a resident build has nowhere to put it")
path = param_name[: -len(".weight")]
module = model.get_submodule(path)
parts = {k: v.to(device) for k, v in reader.get_parts(source_name).items()}
if isinstance(module, torch.nn.Embedding):
# Subclasses carry forward behaviour a bare gather would lose -
# Gemma's scales by ~sqrt(hidden). Anything else must stop the build.
extra = set(dict(module.named_buffers()))- {"embed_scale"}
if type(module) is not torch.nn.Embedding and extra:
raise SystemExit(
f"{path} is a {type(module).__name__} carrying {sorted(extra)}; "
"PackedEmbedding only reproduces embed_scale")
packed = PackedEmbedding(parts[INT8], parts[INT8_SCALE],
embed_scale=getattr(module, "embed_scale", None),
padding_idx=module.padding_idx)
elif isinstance(module, torch.nn.Linear):
packed = PackedLinear(reader.kinds[source_name], parts,
module.out_features, module.in_features,
fold_pre_scale=fold_pre_scale)
else:
raise SystemExit(f"{path} is a {type(module).__name__}, and this "
"build only knows how to pack Linear and Embedding")
parent_path, _, attribute = path.rpartition(".")
setattr(model.get_submodule(parent_path) if parent_path else model,
attribute, packed)
swapped.add(param_name)
# Second pass, after every swap: a bias whose module was replaced lands on
# the new module's buffer, which only exists once the swap has happened.
for source_name, param_name in raw_names:
value = reader.get_tensor(source_name, wanted[param_name].dtype).to(device)
path, _, attribute = param_name.rpartition(".")
owner = model.get_submodule(path) if path else model
if isinstance(owner, (PackedLinear, PackedEmbedding)):
# `set_module_tensor_to_device` reads the current value to find its
# device, and a packed module declares `bias` as a None buffer.
owner.register_buffer(attribute, value, persistent=False)
else:
set_module_tensor_to_device(model, param_name, device, value=value)
assigned = swapped | {p for _, p in raw_names}
missing = [n for n in wanted if n not in assigned and "rotary" not in n]
if missing:
raise SystemExit(
f"{len(missing)} parameters were not fed from the packed file, "
f"e.g. {missing[:5]}. The packed names do not match the model; "
"extend LTX_RENAMES.")
model.eval()
print(f"resident packed model on {device}: "
f"{resident_bytes(model) / 2**30:.3f} GiB", flush=True)
return model
def resident_bytes(model) -> int:
"""Everything the model holds: packed buffers plus whatever stayed dense.
Counted once. A packed module's parts are registered buffers, so walking
`model.buffers()` after adding `packed_bytes()` counts them twice - which
is what the first A7 run reported (13.4 GiB against an allocator peak of
7.75 GiB). Packed buffers are collected by identity first and skipped in
the dense pass.
"""
seen, total = set(), 0
for module in model.modules():
if isinstance(module, (PackedLinear, PackedEmbedding)):
total += module.packed_bytes()
for name in (*_BUFFER_NAMES.values(), "bias"):
tensor = getattr(module, name, None)
if tensor is not None:
seen.add(id(tensor))
for tensor in list(model.parameters()) + list(model.buffers()):
if id(tensor) in seen or tensor.device.type == "meta":
continue
seen.add(id(tensor))
total += tensor.numel() * tensor.element_size()
return total
def check_gpu_kernels(device: str | None = None) -> None:
"""Refuse a wheel that has no kernels for this card, and say what to install.
Nothing in this format needs anything unusual from a GPU - no fp8 units, no
minimum compute capability, no bf16 tensor cores. What can be missing is
PyTorch's own kernels: the current default wheel on PyPI is a cu130 build,
and cu130 dropped Volta.
Left alone, that surfaces as
CUDA error: no kernel image is available for execution on the device
raised from the first kernel launch - which is after an 8.46 GB load, inside
somebody else's library, and long after `torch.cuda.is_available()` returned
True. Checking `get_arch_list` costs nothing and moves the failure to the
place where the fix makes sense.
"""
if device is not None and not str(device).startswith("cuda"):
return
if not torch.cuda.is_available():
return
major, minor = torch.cuda.get_device_capability(0)
arch = f"sm_{major}{minor}"
compiled = torch.cuda.get_arch_list()
# An empty list means a build that does not report them; do not guess.
if not compiled or arch in compiled:
return
name = torch.cuda.get_device_name(0)
raise SystemExit(
f"this torch ({torch.__version__}) has no kernels for {name} ({arch}).\n"
f"It was built for {', '.join(compiled)}, and the first CUDA op would "
f"fail with 'no kernel image is available for execution on the device'.\n"
f"The model is fine - it needs no custom kernels. Install a torch built "
f"for your card, e.g. for {arch}:\n"
f" pip install torch --index-url https://download.pytorch.org/whl/cu128\n"
f"or pass device='cpu' to load without touching the GPU.")
def load_packed_model(model_dir: str, packed_path: Path | str,
gpu_budget: str | None = None, resident: bool = False,
fold_pre_scale: bool = False, device: str | None = None):
"""Build the encoder from a packed file, split across GPU and CPU.
Mirrors what `AutoModel.from_pretrained(..., device_map="auto")` does for
the BF16 checkpoint: same conversion-mapping renames, same guard that
every parameter was actually fed from the file. Dequantization happens
tensor-by-tensor, so peak memory is one dequantized tensor above the
final footprint - the 26 GB BF16 file never exists.
Note what the default does and does not save. The weights land as BF16, so
the *resident* footprint is the same 26 GB the BF16 checkpoint would take;
what packing buys there is disk and the load-time peak. `gpu_budget`
defaults to 13 GiB - what is left of a 16 GB card - and `LTX_PACKED_GPU_BUDGET`
raises it on a larger card, where the whole encoder fits and the CPU
offload that budget forces is pure slowdown.
With `resident`, the quantized tensors stay in their native widths behind
`PackedLinear` / `PackedEmbedding` and no device map is needed: the model
costs what the file costs. `fold_pre_scale` is passed through to the
linears and is not bit-identical - see `PackedLinear`.
"""
import os
import re
check_gpu_kernels(device)
if gpu_budget is None:
gpu_budget = os.environ.get("LTX_PACKED_GPU_BUDGET", "13GiB")
from accelerate import infer_auto_device_map, init_empty_weights
from accelerate.utils import set_module_tensor_to_device
from transformers import AutoConfig, AutoModel
from transformers.conversion_mapping import get_checkpoint_conversion_mapping
register_ltx_renames()
rules = get_checkpoint_conversion_mapping("gemma4_unified") or []
def rename(name: str) -> str:
for rule in rules:
sources = rule.source_patterns
targets = rule.target_patterns
if isinstance(sources, str):
sources, targets = [sources], [targets]
for source, target in zip(sources, targets, strict=True):
new = re.sub(source, target, name)
if new != name:
return new
return name
config = AutoConfig.from_pretrained(model_dir)
# `from_config` does not resolve the attention implementation the way
# `from_pretrained` does; left unset, a standalone build falls back to
# eager and mis-applies the boolean SDPA mask (see ltx_gptq).
config._attn_implementation = "sdpa"
if hasattr(config, "text_config"):
config.text_config._attn_implementation = "sdpa"
with init_empty_weights():
model = AutoModel.from_config(config)
model = model.to(torch.bfloat16)
reader = PackedCheckpoint(packed_path)
wanted = dict(model.state_dict())
if resident:
return install_resident(model, reader, wanted, rename,
device=device, fold_pre_scale=fold_pre_scale)
device_map = infer_auto_device_map(
model, max_memory={0: gpu_budget, "cpu": "40GiB"},
dtype=torch.bfloat16, no_split_module_classes=["Gemma4UnifiedTextDecoderLayer"])
# `set_module_tensor_to_device` places tensors but installs no hooks, so a
# split map produces a model that only fails once a forward crosses the
# boundary - as a device mismatch deep inside a layernorm. Refuse it here
# instead, and say what to do about it.
placements = set(device_map.values())
if len(placements) > 1:
raise SystemExit(
f"the packed model does not fit in {gpu_budget} and would be split "
# str(): a device map mixes GPU ordinals with "cpu", and sorting
# those against each other is a TypeError - which is how this
# message first announced itself.
f"across {sorted(map(str, placements))}. This loader dispatches no "
"hooks, so a "
"split model raises mid-forward. Raise LTX_PACKED_GPU_BUDGET, or use "
"resident=True, which needs only what the file costs.")
def target_device(param_name: str):
candidate = param_name
while candidate:
if candidate in device_map:
return device_map[candidate]
candidate = candidate.rsplit(".", 1)[0] if "." in candidate else ""
return device_map.get("", "cpu")
assigned = set()
for source_name in reader.keys(): # noqa: SIM118 - PackedCheckpoint, not a dict
param_name = rename(source_name)
if param_name not in wanted:
continue # aggregates and asset blobs live outside the module tree
value = reader.get_tensor(source_name, wanted[param_name].dtype)
set_module_tensor_to_device(model, param_name, target_device(param_name),
value=value)
assigned.add(param_name)
missing = [name for name in wanted
if name not in assigned and "rotary" not in name]
if missing:
raise SystemExit(
f"{len(missing)} parameters were not fed from {packed_path}, "
f"e.g. {missing[:5]}. The packed names do not match the model; "
"extend LTX_RENAMES.")
model.eval()
return model
# ---------------------------------------------------------------------------
# Resident modules
#
# `load_packed_model` dequantizes to BF16, which saves the load-time peak and
# nothing else: the model that comes out is the same 26.264 GB it always was.
# These modules keep the packed widths in memory and dequantize inside
# `forward`, which is what turns 8.463 GB on disk into 8.463 GB resident.
#
# Affordable here because the encoder runs once per prompt - no KV cache, no
# autoregressive loop. Measured on this checkpoint's real shapes at 1024
# tokens, a full dequantize costs 9-12% of the matmul it feeds.
# ---------------------------------------------------------------------------
class PackedLinear(torch.nn.Module):
"""`nn.Linear` whose weight is stored in its native widths.
`forward` rebuilds the weight with the same arithmetic `unpack_tensor` uses
at load time, so its output is bit-identical to the dequantized model's.
That equality is the point: without it a resident run cannot be compared
against any figure recorded from the dequantized path.
`fold_pre_scale` trades the equality for speed. AWQ smoothing is
``W' = W * s`` and the stored weight carries the closing ``/ s``, so
``x @ (Q/s).T == (x/s) @ Q.T`` - the division can move onto the activation
and shrink an ``[out, in]`` elementwise pass to a ``[tokens, in]`` one. It
changes float rounding order, so it is off by default and its difference is
measured rather than assumed.
"""
def __init__(self, kind: str, parts: dict, out_features: int,
in_features: int, bias: torch.Tensor | None = None,
dtype: torch.dtype = torch.bfloat16,
fold_pre_scale: bool = False) -> None:
super().__init__()
if kind not in ("nvfp4", "int8"):
raise ValueError(f"{kind!r} is not a packed weight kind")
self.kind = kind
self.out_features = out_features
self.in_features = in_features
self.compute_dtype = dtype
for suffix, buffer in _BUFFER_NAMES.items():
tensor = parts.get(name_part(suffix, parts))
self.register_buffer(buffer, tensor, persistent=False)
self.register_buffer("bias", None if bias is None else bias.detach(),
persistent=False)
self.fold_pre_scale = bool(fold_pre_scale) and self.pre_scale is not None
def packed_bytes(self) -> int:
"""What this module actually costs, for a device map that cannot infer it."""
total = 0
for buffer in (*_BUFFER_NAMES.values(), "bias"):
tensor = getattr(self, buffer, None)
if tensor is not None:
total += tensor.numel() * tensor.element_size()
return total
@property
def weight(self) -> torch.Tensor:
"""The dequantized weight.
A property rather than a buffer so that code reaching for
`module.weight` - `transformers` does, in places - still works, at the
cost of materializing it for that one call.
"""
return self.dequantize()
def dequantize(self, pre_scale: bool = True) -> torch.Tensor:
pre = self.pre_scale if (pre_scale and not self.fold_pre_scale) else None
if self.kind == "nvfp4":
grid = decode_nvfp4(nibble_unpack(self.codes), self.group_scale,
self.global_scale, pre)
else:
grid = decode_int8(self.ints, self.int8_scale, pre)
return grid.to(self.compute_dtype)
def forward(self, x: torch.Tensor) -> torch.Tensor:
if self.fold_pre_scale:
x = x / self.pre_scale.to(x.dtype)
return torch.nn.functional.linear(x, self.dequantize(), self.bias)
def extra_repr(self) -> str:
return (f"in_features={self.in_features}, out_features={self.out_features}, "
f"kind={self.kind}, packed={self.packed_bytes() / 2**20:.1f} MiB"
+ (", folded" if self.fold_pre_scale else ""))
class PackedEmbedding(torch.nn.Module):
"""`nn.Embedding` over int8 rows with a per-row scale.
`model.embed_tokens` is [262144, 3840] - 1.008 GB packed against 2.016 GB
BF16, the largest single tensor in the file, and the one DmitryDB's recipe
keeps in BF16 as a precision island. Quantizing it is most of our disk
advantage, and half of that is given back if it dequantizes at load.
Gathering before scaling means only the rows a prompt touches are ever
dequantized, which is a few thousand of 262144.
`embed_scale` is not optional in practice. Gemma's embedding is a
`Gemma4UnifiedTextScaledWordEmbedding`, whose forward is
``super().forward(ids) * embed_scale`` with `embed_scale` around sqrt(3840).
Replacing the module without carrying that factor would drop every
embedding by ~62x and still produce plausible-looking tensors, so the caller
must read it off the module it is replacing.
"""
def __init__(self, ints: torch.Tensor, scale: torch.Tensor,
dtype: torch.dtype = torch.bfloat16,
embed_scale: torch.Tensor | None = None,
padding_idx: int | None = None) -> None:
super().__init__()
self.num_embeddings, self.embedding_dim = ints.shape
self.compute_dtype = dtype
self.padding_idx = padding_idx
self.register_buffer("ints", ints, persistent=False)
self.register_buffer("int8_scale", scale, persistent=False)
self.register_buffer("embed_scale", embed_scale, persistent=False)
def packed_bytes(self) -> int:
return (self.ints.numel() * self.ints.element_size()
+ self.int8_scale.numel() * self.int8_scale.element_size())
@property
def weight(self) -> torch.Tensor:
return decode_int8(self.ints, self.int8_scale, None).to(self.compute_dtype)
def forward(self, index: torch.Tensor) -> torch.Tensor:
rows = self.ints[index].float()
scale = self.int8_scale[index].unsqueeze(-1).float()
out = (rows * scale).to(self.compute_dtype)
if self.embed_scale is not None:
# Cast then multiply, in that order, because that is what
# `Gemma4UnifiedTextScaledWordEmbedding.forward` does.
out = out * self.embed_scale.to(self.compute_dtype)
return out
def extra_repr(self) -> str:
scaled = "" if self.embed_scale is None else f", embed_scale={float(self.embed_scale):.4g}"
return (f"{self.num_embeddings}, {self.embedding_dim}, kind=int8, "
f"packed={self.packed_bytes() / 2**20:.1f} MiB{scaled}")
def name_part(suffix: str, parts: dict) -> str:
"""The key in `parts` carrying `suffix`, or a miss the caller tolerates.
`parts` comes either straight from a packer (keys are bare suffixes) or
from a checkpoint read (keys are `name + suffix`), and both are worth
supporting so a test does not have to fabricate tensor names.
"""
if suffix in parts:
return suffix
for key in parts:
if key.endswith(suffix):
return key
return suffix
def packed_linear_apply(reader, name: str, x: torch.Tensor,
bias: torch.Tensor | None = None, rows: int = 512,
dtype: torch.dtype = torch.float32) -> torch.Tensor:
"""`F.linear(x, W, bias)` for a packed W, `rows` output rows at a time.
The two LTX aggregate tables are [4096, 188160] and [2048, 188160]. Reading
one whole and casting it to float32 costs 3.08 GB, which is why
`apply_aggregates` only runs after the encoder has been unloaded. Chunking
is over *output* rows, so the contraction over `in_features` is untouched
and the result is bit-identical to decoding the table in one piece.
"""
out_features = reader.row_count(name)
pieces = []
for start in range(0, out_features, rows):
stop = min(start + rows, out_features)
weight = reader.get_tensor_rows(name, start, stop, dtype)
piece = None if bias is None else bias[start:stop]
pieces.append(torch.nn.functional.linear(x, weight, piece))
del weight
return torch.cat(pieces, dim=-1)
def cli_info(args) -> int:
reader = PackedCheckpoint(args.packed)
from collections import Counter
counts = Counter(reader.kinds.values())
size = Path(args.packed).stat().st_size
print(f"{args.packed}: {size / 1e9:.3f} GB, {dict(counts)}")
print(f"negative-zero collapses at pack time: "
f"{reader.metadata.get('negative_zero_collapses')}")
for key in sorted(set(reader.metadata) - {"kinds"}):
print(f" {key}: {reader.metadata[key][:100]}")
return 0
def cli_verify(args) -> int:
"""Compare every tensor against a reference BF16 checkpoint, streaming."""
from safetensors import safe_open
reader = PackedCheckpoint(args.packed)
reference = safe_open(str(args.reference), framework="pt")
names = set(reference.keys())
missing = sorted(set(reader.kinds) - names)
extra = sorted(names - set(reader.kinds))
if missing or extra:
raise SystemExit(f"tensor sets differ: missing {missing[:5]}, extra {extra[:5]}")
worst = 0
for i, name in enumerate(sorted(names)):
want = reference.get_tensor(name)
got = reader.get_tensor(name, want.dtype)
if not torch.equal(got, want):
raise SystemExit(f"{name}: dequantized values differ from reference")
worst += int((got.signbit() != want.signbit()).sum())
if i % 100 == 0:
print(f" {i}/{len(names)} verified", flush=True)
print(f"all {len(names)} tensors value-exact; {worst} zero-sign differences")
return 0
def cli_emit_bf16(args) -> int:
"""Materialize a plain BF16 checkpoint a stock loader can read."""
from safetensors.torch import save_file
reader = PackedCheckpoint(args.packed)
tensors = {}
for name in sorted(reader.kinds):
tensors[name] = reader.get_tensor(name, torch.bfloat16)
save_file(tensors, str(args.output))
print(f"wrote {args.output} ({Path(args.output).stat().st_size / 1e9:.2f} GB)")
return 0
def main() -> int:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("info").add_argument("packed")
p = sub.add_parser("verify")
p.add_argument("packed")
p.add_argument("--reference", required=True)
p = sub.add_parser("emit-bf16")
p.add_argument("packed")
p.add_argument("--output", required=True)
args = parser.parse_args()
return {"info": cli_info, "verify": cli_verify,
"emit-bf16": cli_emit_bf16}[args.command](args)
if __name__ == "__main__":
raise SystemExit(main())
|