Spaces:
Running on Zero
Running on Zero
File size: 11,672 Bytes
642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c 257e290 642c64c | 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 | """Write the conditioning tensor an LTX-2.5 encoder produces, so a DiT can use it.
The gate (`ltx_prompt_embedding_gate.py`) scores an encoder without rendering.
This is its sibling and shares its entire forward path: same tokenization, same
49-slot hidden stack, same `apply_aggregates`. The difference is the output -
the gate compares two encoders and prints a verdict, this writes the tensor.
Why it has to exist at all: ComfyUI cannot load our compressed encoder. Its GGUF
path gates text encoders on `TXT_ARCH_LIST`, which carries `gemma3` but not
`gemma4`, and its native quantisation kernels need compute capability 8.9. On a
V100 there is no route from a compressed LTX-2.5 encoder into a ComfyUI graph.
So the encoder runs here, out of process, and hands over the one tensor the DiT
actually consumes.
That tensor is `torch.cat((video, audio), dim=-1)`, [T, 6144] - exactly what
`LTXAVTEModel.encode_token_weights` returns, and everything downstream treats it
as one opaque block. `apply_aggregates` already produces the two halves.
Running out of process also solves the memory problem rather than working around
it. The encoder is 26 GB of BF16 and the DiT is 10.6 GB; neither fits beside the
other on a 16 GB card. Here the encoder loads, emits, and exits, and the card is
empty again before the DiT is asked for.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from ltx_prompt_embedding_gate import (
apply_aggregates,
assert_fully_loaded,
check_aggregates_present,
hidden_stack,
hidden_state_artifact,
register_ltx_renames,
weights_path,
)
from pacific.ltx_av import load_event_set
#: ComfyUI pads every LTX prompt to this length on the left
#: (`Gemma3_12BTokenizer`, `comfy/text_encoders/lt.py:76`: `min_length=1024`,
#: `pad_left=True`). It is also exactly the encoder's `sliding_window`, so the
#: real tokens sit at the top of a window that is otherwise entirely padding.
COMFY_MIN_LENGTH = 1024
#: `special_tokens={"start": 2, "pad": 0}` in the same constructor. The HF
#: tokenizer for this checkpoint adds neither - verified, its first id for
#: "A humanoid robot..." is 236776, not 2 - so the start token is prepended here
#: or the encoder never sees a sentence begin.
COMFY_BOS = 2
COMFY_PAD = 0
def hidden_stack_comfy(
model, tokenizer, prompts: list[str], device: str, min_length: int = COMFY_MIN_LENGTH
) -> list:
"""Per prompt, the raw [49, T, 3840] Transformer stack for ComfyUI tokens.
`hidden_stack` in the gate runs the prompt unpadded, which is the right
choice for comparing two encoders to each other: both sides get the same
treatment and the difference between them is the only thing measured. It is
the wrong choice for feeding a DiT, because the DiT was trained on what the
vendor pipeline emits, and that differs in two ways:
* a start token. ComfyUI tokenizes with `add_bos=True`; this checkpoint's HF
tokenizer does not add one.
* position. ComfyUI left-pads to 1024 and then takes `position_ids =
arange(seq_len)` over the *padded* sequence (`llama.py:683`), so a
68-token prompt occupies RoPE positions 956..1023. Unpadded it occupies
0..67. Same tokens, different angles in every rotary layer.
Masking makes the padding itself inert - `enable_attention_masks=True` in
`Gemma3_12BModel` - so this is not about the pad values. It is about where
on the rotary circle the real tokens land, and the DiT has an opinion.
Transformers already final-normalizes only the last returned slot. Preserve
every raw intermediate slot, then select the real-token suffix.
The trailing slice reproduces `out[:, :, -sum(attention_mask):]` from
`LTXAVTEModel.encode_token_weights`.
"""
import torch
out = []
for prompt in prompts:
ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
ids = [COMFY_BOS, *ids]
real = len(ids)
if real > min_length:
raise SystemExit(
f"prompt is {real} tokens, past the {min_length} ComfyUI pads to; "
"the vendor path would not have padded it either"
)
pad = min_length - real
input_ids = torch.tensor([[COMFY_PAD] * pad + ids], device=device)
attention_mask = torch.tensor([[0] * pad + [1] * real], device=device)
# Passed explicitly rather than left to `transformers`, which derives
# positions from the mask in some versions - that would put the real
# tokens back at 0..T-1 and undo the entire point of padding here.
position_ids = torch.arange(min_length, device=device).unsqueeze(0)
with torch.no_grad():
states = model(
input_ids=input_ids,
attention_mask=attention_mask,
position_ids=position_ids,
output_hidden_states=True,
).hidden_states
stacked = torch.stack([state[0, -real:] for state in states])
out.append(stacked.to(torch.float32).cpu())
return out
def load_prompt_file(path: Path) -> list[str]:
"""One prompt per line; blank lines and `#` comments ignored.
Prompts are long enough that a multi-line format would be nicer to read and
far easier to get wrong - a stray blank line silently splits one prompt into
two, and the resulting clip is of half a sentence. One line each is the
format that cannot do that.
"""
if path.suffix == ".jsonl":
return [event["prompt"] for event in load_event_set(path)]
lines = [line.strip() for line in path.read_text().splitlines()]
return [line for line in lines if line and not line.startswith("#")]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--model-dir", required=True, help="encoder dir: config, tokenizer and model.safetensors"
)
parser.add_argument("--prompts", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--label", default="", help="build name recorded in the file, e.g. r45c-gptq"
)
parser.add_argument("--checkpoint-sha256", default="")
parser.add_argument("--profile-hash", default="")
parser.add_argument("--audio-aggregate-checkpoint", type=Path)
parser.add_argument("--video-aggregate-checkpoint", type=Path)
parser.add_argument("--hidden-stacks-output", type=Path)
parser.add_argument(
"--device-map",
default="auto",
help="26 GB of BF16 does not fit 16 GB of VRAM; 'auto' splits it",
)
parser.add_argument(
"--packed-checkpoint",
type=Path,
help="load weights from a ltx_packed_codec artifact "
"instead of model.safetensors; --model-dir still "
"supplies config and tokenizer",
)
parser.add_argument(
"--resident",
action="store_true",
help="keep the packed weights packed and dequantize in "
"forward: 8.5 GB resident instead of 26.3 GB",
)
parser.add_argument(
"--fold-pre-scale",
action="store_true",
help="with --resident, apply the AWQ scale to the "
"activation instead of the weight; faster and not "
"bit-identical",
)
parser.add_argument(
"--comfy-padding",
action="store_true",
help="reproduce ComfyUI's BOS and 1024-token left pad; "
"required for output a DiT will accept",
)
args = parser.parse_args()
import torch
from transformers import AutoModel, AutoTokenizer
prompts = load_prompt_file(args.prompts)
prompt_ids = (
[event["id"] for event in load_event_set(args.prompts)]
if args.prompts.suffix == ".jsonl"
else [f"prompt-{index:02d}" for index in range(len(prompts))]
)
if not prompts:
raise SystemExit(f"{args.prompts} has no prompts")
print(f"{len(prompts)} prompts from {args.prompts}", flush=True)
model_dir = Path(args.model_dir)
checkpoint = args.packed_checkpoint or weights_path(model_dir)
check_aggregates_present(checkpoint)
tokenizer = AutoTokenizer.from_pretrained(args.model_dir)
if args.packed_checkpoint:
from ltx_packed_codec import load_packed_model
model = load_packed_model(
args.model_dir,
args.packed_checkpoint,
resident=args.resident,
fold_pre_scale=args.fold_pre_scale,
)
else:
register_ltx_renames()
model, info = AutoModel.from_pretrained(
args.model_dir,
dtype=torch.bfloat16,
device_map=args.device_map,
output_loading_info=True,
)
assert_fully_loaded(info)
model.eval()
device = str(next(model.parameters()).device)
print(f"{args.model_dir}: loaded on {device}", flush=True)
capture = hidden_stack_comfy if args.comfy_padding else hidden_stack
print(f"forward path: {capture.__name__}", flush=True)
stacks = capture(model, tokenizer, prompts, device)
slots, _, hidden = stacks[0].shape
del model
torch.cuda.empty_cache()
print(f"{len(stacks)} stacks, {slots} slots x {hidden}", flush=True)
if args.hidden_stacks_output is not None:
args.hidden_stacks_output.parent.mkdir(parents=True, exist_ok=True)
torch.save(
hidden_state_artifact(
prompt_ids=prompt_ids,
prompts=prompts,
comfy_padding=args.comfy_padding,
hidden_stacks=stacks,
),
args.hidden_stacks_output,
)
print(f"wrote {args.hidden_stacks_output}", flush=True)
branch_checkpoints = {
branch: path
for branch, path in {
"audio": args.audio_aggregate_checkpoint,
"video": args.video_aggregate_checkpoint,
}.items()
if path is not None
}
for source in branch_checkpoints.values():
check_aggregates_present(source)
branches = apply_aggregates(stacks, checkpoint, branch_checkpoints)
# The DiT sees one [T, 6144] block. Keeping the halves separate here would
# push the concatenation - and the chance of getting its order backwards -
# into the render script, where it would be checked by nothing.
conditioning = []
for prompt, branch in zip(prompts, branches, strict=True):
merged = torch.cat((branch["video"], branch["audio"]), dim=-1)
conditioning.append(merged.to(torch.float32).contiguous())
print(f" {tuple(merged.shape)} {prompt[:60]}", flush=True)
widths = {tuple(c.shape[1:]) for c in conditioning}
if widths != {(6144,)}:
raise SystemExit(f"expected a 6144-wide conditioning, got {widths}")
args.output.parent.mkdir(parents=True, exist_ok=True)
torch.save(
hidden_state_artifact(
label=args.label or model_dir.name,
model_dir=str(model_dir),
checkpoint=str(checkpoint),
checkpoint_sha256=args.checkpoint_sha256,
profile_hash=args.profile_hash,
aggregate_checkpoints={
branch: str(path) for branch, path in branch_checkpoints.items()
},
comfy_padding=args.comfy_padding,
prompt_ids=prompt_ids,
prompts=prompts,
conditioning=conditioning,
),
args.output,
)
print(f"wrote {args.output}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
|