longcat: it generates (v0.2.70)

Two defects between a loading model and a finished clip, both found by running it.

A repo id is not a directory. models.json holds what the model page registered —
"meituan-longcat/LongCat-Video" — and resolve_model_path handed it straight to
the service, which opens config.json, the VAE and the INT8 shards by path. Every
load died with "checkpoint directory does not exist" before reading a byte.
resolve_checkpoint() maps a repo id onto its newest snapshot in the hub cache,
by hand rather than through huggingface_hub so the server's 3.13 venv and the
service's 3.10 venv cannot disagree about where the weights are, and so a
checkpoint that was never fetched stays an error instead of becoming a silent
74 GB download.

And the offload added in v0.2.67 was only half of one. It held the text encoder
off the card until encode_prompt and then brought it over WITH the DiT still
resident: 13.6 GB of INT8 DiT plus ~11 GB of UMT5-XXL is 24.8 GB on a 24 GB
card. It OOM'd on the first prompt of the first generation. Model offload means
only the active component is on the card, so they take turns now — the DiT steps
off for the encode and comes back for the loop, in a finally, because a failed
prompt that stranded the DiT on the host would leave every later generation
running at PCIe speed with nothing to say why. Peak is max(), not sum.

The placement test passed on the broken version: it asserted the encoder arrived
and left, never that the DiT was not there too. It asserts co-residency now, and
fails on the old code.

Measured end to end on the 3090: 33 frames at 480x832, distilled stage, 16 steps
at 9.4 s/it, 13.6 GB resident, HTTP 200 with the mp4.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185zEBiy3KWB37xNGSrxL7w
parent 3532636c
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.2.69" __version__ = "0.2.70"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -131,8 +131,9 @@ def resolve_model_path(model_name: str, config: dict = None) -> str: ...@@ -131,8 +131,9 @@ def resolve_model_path(model_name: str, config: dict = None) -> str:
for key in ("model_path", "path", "model_id", "repo_id"): for key in ("model_path", "path", "model_id", "repo_id"):
value = cfg.get(key) value = cfg.get(key)
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():
return os.path.expanduser(value.strip()) return common().resolve_checkpoint(value.strip())
return model_name or "meituan-longcat/LongCat-Video" return common().resolve_checkpoint(
model_name or "meituan-longcat/LongCat-Video")
def service_key(model_path: str, config: dict = None) -> str: def service_key(model_path: str, config: dict = None) -> str:
......
"""models.json stores a repo id; everything downstream opens files by path.
"meituan-longcat/LongCat-Video" is what the model page registers, and it was handed
to the service unchanged — which failed every load with "checkpoint directory does not
exist: meituan-longcat/LongCat-Video" before a byte was read.
"""
import importlib.util
import os
import pathlib
import sys
import pytest
ROOT = pathlib.Path(__file__).resolve().parents[1]
COMMON = ROOT / "tools" / "longcat_common.py"
@pytest.fixture
def LC():
spec = importlib.util.spec_from_file_location("lc_common_resolve", COMMON)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_cache(tmp_path, repo="meituan-longcat/LongCat-Video", revision="abc123"):
snap = (tmp_path / "hub" / ("models--" + repo.replace("/", "--"))
/ "snapshots" / revision)
snap.mkdir(parents=True)
return snap
def test_a_repo_id_resolves_to_its_snapshot(LC, tmp_path, monkeypatch):
snap = _make_cache(tmp_path)
monkeypatch.setenv("HF_HOME", str(tmp_path))
assert LC.resolve_checkpoint("meituan-longcat/LongCat-Video") == str(snap)
def test_an_existing_directory_is_left_alone(LC, tmp_path):
assert LC.resolve_checkpoint(str(tmp_path)) == str(tmp_path)
def test_an_absolute_path_is_never_treated_as_a_repo_id(LC, tmp_path, monkeypatch):
"""A path with a slash in it is still a path."""
monkeypatch.setenv("HF_HOME", str(tmp_path))
missing = str(tmp_path / "not-there")
assert LC.resolve_checkpoint(missing) == missing
def test_an_unknown_repo_comes_back_unchanged(LC, tmp_path, monkeypatch):
"""So the existing "does not exist" message names what was asked for, rather than
some invented cache path the user never mentioned."""
monkeypatch.setenv("HF_HOME", str(tmp_path))
assert LC.resolve_checkpoint("nobody/nothing") == "nobody/nothing"
def test_it_never_downloads(LC):
"""The model page owns downloads. A resolver that falls back to the hub would turn
a misconfigured entry into a silent 74 GB pull."""
import ast
tree = ast.parse(COMMON.read_text(encoding="utf-8"))
fn = next(n for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name == "resolve_checkpoint")
# The prose may well mention the hub; what matters is that nothing imports or
# calls it.
imported = {a.name.split(".")[0] for n in ast.walk(fn)
if isinstance(n, ast.Import) for a in n.names}
imported |= {(n.module or "").split(".")[0] for n in ast.walk(fn)
if isinstance(n, ast.ImportFrom)}
assert "huggingface_hub" not in imported, f"imports {imported}"
called = {n.func.id for n in ast.walk(fn)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
assert "snapshot_download" not in called
@pytest.mark.parametrize("var", ["HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"])
def test_the_hub_cache_variables_are_honoured(LC, tmp_path, monkeypatch, var):
"""The container sets these, and the service's venv must land on the same store as
the server's or it will load a different checkpoint than the one that was quantised.
"""
snap = _make_cache(tmp_path)
for clear in ("HF_HOME", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"):
monkeypatch.delenv(clear, raising=False)
monkeypatch.setenv(var, str(tmp_path / "hub"))
assert LC.resolve_checkpoint("meituan-longcat/LongCat-Video") == str(snap)
def test_the_newest_revision_wins(LC, tmp_path, monkeypatch):
"""A re-pull leaves the old snapshot behind; loading it would serve weights that
do not match what was just downloaded."""
old = _make_cache(tmp_path, revision="old")
new = _make_cache(tmp_path, revision="new")
os.utime(old, (1000, 1000))
os.utime(new, (2000, 2000))
monkeypatch.setenv("HF_HOME", str(tmp_path))
assert LC.resolve_checkpoint("meituan-longcat/LongCat-Video") == str(new)
def test_the_service_resolves_before_it_validates(LC):
"""checkpoint_problems() is what produced the error, so resolution has to happen
first or the message is about a repo id that was never going to be a directory."""
src = (ROOT / "tools" / "longcat_service.py").read_text(encoding="utf-8")
assert "LC.resolve_checkpoint(body.get(\"model\")" in src
...@@ -127,6 +127,42 @@ def test_the_encoder_visits_the_card_to_encode_and_leaves_again(service): ...@@ -127,6 +127,42 @@ def test_the_encoder_visits_the_card_to_encode_and_leaves_again(service):
during = pipe.coresident[-1] during = pipe.coresident[-1]
assert during["text_encoder"] == "cuda", "it has to be on the card to encode" assert during["text_encoder"] == "cuda", "it has to be on the card to encode"
assert pipe.text_encoder.device == "cpu", "and has to go back afterwards" assert pipe.text_encoder.device == "cpu", "and has to go back afterwards"
assert pipe.dit.device == "cuda", "the DiT has to come back for the denoising loop"
def test_the_dit_and_the_encoder_are_never_on_the_card_together(service):
"""The whole point. 13.6 GB of INT8 DiT plus ~11 GB of UMT5-XXL is 24.8 GB on a
24 GB card: bringing the encoder over WITHOUT moving the DiT off is an OOM, and it
is the one this fixture failed to catch the first time. Peak must be max(), not sum.
"""
pipe = FakePipeline()
service._place_pipeline(pipe, "model")
pipe.encode_prompt("two fighters circling", device="cuda")
for snapshot in pipe.coresident:
on_card = [name for name, dev in snapshot.items() if dev == "cuda"]
assert not ("dit" in on_card and "text_encoder" in on_card), (
f"both were resident at once: {snapshot}")
def test_the_dit_comes_back_even_if_the_encode_fails(service):
"""Otherwise a failed prompt leaves the DiT on the host and the next generation
runs at PCIe speed with no indication why."""
class Failing(FakePipeline):
def encode_prompt(self, prompt, device=None, **kwargs):
self._snapshot()
raise RuntimeError("bad prompt")
pipe = Failing()
service._place_pipeline(pipe, "model")
with pytest.raises(RuntimeError, match="bad prompt"):
pipe.encode_prompt("x")
assert pipe.dit.device == "cuda"
assert pipe.text_encoder.device == "cpu"
def test_a_failed_encode_still_puts_the_encoder_back(service): def test_a_failed_encode_still_puts_the_encoder_back(service):
......
...@@ -228,6 +228,44 @@ def duration_seconds(segments: int, fps: int = DEFAULT_FPS, ...@@ -228,6 +228,44 @@ def duration_seconds(segments: int, fps: int = DEFAULT_FPS,
return frames_for(segments, num_frames, cond_frames) / float(max(1, fps)) return frames_for(segments, num_frames, cond_frames) / float(max(1, fps))
def resolve_checkpoint(ref: str) -> str:
"""A checkpoint reference resolved to a directory on disk.
models.json stores what the model page registered — "meituan-longcat/LongCat-Video",
a repo id, not a path. Everything downstream opens files BY PATH (config.json, the
VAE, the INT8 shards), so handing the id straight on made every load fail with
"checkpoint directory does not exist" before a byte was read.
Resolved against the HF hub cache by hand rather than through huggingface_hub, so
the server's 3.13 venv and the service's 3.10 venv cannot disagree about where the
weights are, and so a checkpoint that was never downloaded stays an error instead of
becoming a 74 GB download nobody asked for. An unresolvable reference is returned
unchanged, which leaves the existing "does not exist" message to name it.
"""
import glob
if not ref:
return ref
path = os.path.expanduser(ref)
if os.path.isdir(path) or os.path.isabs(path) or "/" not in ref:
return path
roots = [os.environ[v] for v in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE")
if os.environ.get(v)]
if os.environ.get("HF_HOME"):
roots.append(os.path.join(os.environ["HF_HOME"], "hub"))
roots.append(os.path.expanduser("~/.cache/huggingface/hub"))
folder = "models--" + ref.replace("/", "--")
for root in roots:
snaps = [d for d in glob.glob(os.path.join(root, folder, "snapshots", "*"))
if os.path.isdir(d)]
if snaps:
# Newest wins, so a re-pulled revision beats a stale one left behind.
return max(snaps, key=os.path.getmtime)
return path
def checkpoint_problems(checkpoint_dir: str, stages=()) -> list: def checkpoint_problems(checkpoint_dir: str, stages=()) -> list:
"""Human-readable reasons this directory cannot serve ``stages``, or []. """Human-readable reasons this directory cannot serve ``stages``, or [].
......
...@@ -360,7 +360,25 @@ def _place_pipeline(pipe, offload): ...@@ -360,7 +360,25 @@ def _place_pipeline(pipe, offload):
inner = pipe.encode_prompt # the bound method, before it is shadowed below inner = pipe.encode_prompt # the bound method, before it is shadowed below
def _move_dit(device):
"""The DiT and the LoRA networks hanging off it, the way upstream's to() does."""
if pipe.dit is None:
return
pipe.dit = pipe.dit.to(device, non_blocking=True)
if getattr(pipe.dit, "lora_dict", None):
for network in pipe.dit.lora_dict.values():
for lora in network.loras:
lora.to(device, non_blocking=True)
def encode_prompt(*args, **kwargs): def encode_prompt(*args, **kwargs):
# They take TURNS. The INT8 DiT is 13.6 GB resident and the encoder is ~11 GB:
# bringing the encoder over while the DiT stays is 24.8 GB on a 24 GB card,
# which is the OOM that model offload exists to prevent. Peak is max(DiT,
# encoder), not their sum. The swap costs a round trip over PCIe, once per
# generation, against a denoising loop measured in minutes.
_move_dit("cpu")
gc.collect()
torch.cuda.empty_cache()
pipe.text_encoder = pipe.text_encoder.to("cuda", non_blocking=True) pipe.text_encoder = pipe.text_encoder.to("cuda", non_blocking=True)
try: try:
return inner(*args, **kwargs) return inner(*args, **kwargs)
...@@ -370,11 +388,12 @@ def _place_pipeline(pipe, offload): ...@@ -370,11 +388,12 @@ def _place_pipeline(pipe, offload):
pipe.text_encoder = pipe.text_encoder.to("cpu") pipe.text_encoder = pipe.text_encoder.to("cpu")
gc.collect() gc.collect()
torch.cuda.empty_cache() torch.cuda.empty_cache()
_move_dit("cuda")
# Set on the INSTANCE: all four generate_* methods call self.encode_prompt. # Set on the INSTANCE: all four generate_* methods call self.encode_prompt.
pipe.encode_prompt = encode_prompt pipe.encode_prompt = encode_prompt
log("offload: the DiT and VAE are resident; the text encoder (~11 GB) comes over " log("offload: the DiT and VAE are resident; the text encoder (~11 GB) and the DiT "
"only to encode the prompt") "swap places for encode_prompt so only one of them is ever on the card")
return pipe return pipe
...@@ -762,7 +781,7 @@ def generate(body: dict) -> dict: ...@@ -762,7 +781,7 @@ def generate(body: dict) -> dict:
raise ValueError("prompt is required") raise ValueError("prompt is required")
stages = LC.resolve_stages(body.get("quality") or "fast", body.get("stage") or "") stages = LC.resolve_stages(body.get("quality") or "fast", body.get("stage") or "")
checkpoint = body.get("model") or _state["model"] checkpoint = LC.resolve_checkpoint(body.get("model") or _state["model"])
problems = LC.checkpoint_problems(checkpoint, stages) problems = LC.checkpoint_problems(checkpoint, stages)
if problems: if problems:
raise ValueError("; ".join(problems)) raise ValueError("; ".join(problems))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment