unsloth/studio/backend/routes/video.py
Daniel Han 7dbdd28161 perf(video): accuracy-first round 2 for HunyuanVideo-1.5: compile parity, cache quality presets, dual-GPU CFG
Cuts the shipped default's LPIPS vs the bit-exact reference from 0.224 to 0.139
while going faster (24.9 s to 21.2 s at 720p/33f/30 steps, 22.7x vs reference),
and makes the remaining speed/accuracy trade a user knob.

- inductor precision parity: set emulate_precision_casts=True for the regional
  compile (fused pointwise kernels kept fp32 intermediates where eager rounds to
  bf16 between ops); full-clip LPIPS vs bit-exact 0.221 to 0.052 at zero speed
  cost. Snapshot/restored with the other process-wide backend flags.
- cache x compile composition fix: diffusers cache hooks are
  torch.compiler.disable'd, so every COMPUTED step ran eager (1.69 vs 1.09
  s/step) under MagCache/FBCache in both enable orders. Re-point each hook's
  fn_ref.original_forward at a torch.compile'd wrapper of the same bound method
  (armed only where the speed layer compiled the block; restored before every
  disable_cache so the uncached path stays pristine). Balanced MagCache at 50
  steps: 1.48x to 2.17x, identical skip counts, bit-identical uncached rerun
  after enable/disable cycles.
- transformer_cache_quality knob (quality|balanced|fast; API + UI + bench)
  mapping to (threshold, max_skip_steps, retention_ratio). Auto resolves to the
  near-lossless quality preset (0.06, 2, 0.3; 1.63-1.64x at pairwise LPIPS
  0.05-0.09) for the HunyuanVideo-1.5 families and to balanced (the pre-knob
  values, byte-identical behaviour) everywhere else.
- TE auto-quant resolves dense for HunyuanVideo-1.5: TE fp8_dynamic alone moves
  the clip to LPIPS 0.236 vs bit-exact for zero speed win (the quantised encoder
  perturbs the conditioning and the trajectory amplifies it chaotically); VAE
  fp8 stays in auto (0.053, at the compile floor). Explicit schemes honored.
- dual-GPU CFG branch parallelism (new diffusion_cfg_parallel.py): transformer
  proxy + DiT replica on the most-free second CUDA device + worker thread,
  branch-routed off the pipeline's own cache_context names. Auto engages only
  where measured bit-identical (eager tier: max abs diff 0.0, 1.66x); the
  compiled stack is explicit cfg_parallel=on (1.52x over the sequential
  default; per-device compiled artifacts differ by 1 bf16 ulp/step, documented
  in the resolved record). Fail-soft gates: family allowlist, guider CFG,
  pipeline kind, dense DiT, no offload, free-VRAM check; single-GPU loads are
  untouched and the memory plan stays single-device.
- video API: the transformer_cache literal now accepts auto/magcache (an
  explicit magcache request was rejected at the pydantic layer); the mxfp8
  family deny records the round-2 measurement (block-32 MX scaling fixes the
  zero-row collapse, no black frames, but is latency-neutral at LPIPS 0.37:
  fails both ship bars).

Measured on B200 via the production lever path (video_speedmem_bench.py, which
gained a --cache-quality lever and companion-quant isolation configs). Tests:
441 passing across the video inference suite (32 new for cfg-parallel, 20 for
presets/arming, 3 for the inductor flag, 2 for TE auto-dense); ruff clean.
2026-07-10 14:30:00 +00:00

349 lines
15 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""API routes for local text-to-video inference.
The video backend is a deliberate sibling of the diffusion (image) backend, so
these routes mirror the /images/* routes one-for-one: the same validate-before-evict
load ordering, the same GPU arbiter handoff (VIDEO owner in place of DIFFUSION),
the same error boundary mapping backend exceptions to HTTP, and the same gallery
CRUD shape. The backend runs in-process and is synchronous, so the blocking
load/generate/unload calls are offloaded with asyncio.to_thread to keep the event
loop free. This module is the single error boundary: backend methods raise, we
map to HTTP here.
"""
from __future__ import annotations
import asyncio
import time
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import ValidationError
from auth.authentication import get_current_subject
from loggers import get_logger
from models.inference import (
GalleryVideo,
VideoGalleryListResponse,
VideoGenerateProgressResponse,
VideoGenerateRequest,
VideoGenerateResponse,
VideoLoadProgressResponse,
VideoLoadRequest,
VideoStatusResponse,
)
logger = get_logger(__name__)
router = APIRouter()
def _guard_video_load_against_training() -> None:
"""Refuse loading a video model while a training run is active. Unlike chat,
a video pipeline's VRAM can't be cheaply estimated before the load, so the
load is refused outright rather than fit-checked. No-op when training is
inactive or its state can't be read. Raises HTTP 409. Mirrors the image
load's _guard_diffusion_load_against_training."""
from core.training import get_training_backend
try:
llm_active = get_training_backend().is_training_active()
except Exception as e: # noqa: BLE001
logger.warning("Could not check training state for video-load guard: %s", e)
return
diffusion_active = False
try:
from core.training.diffusion_training_service import get_diffusion_training_service
diffusion_active = get_diffusion_training_service().is_active()
except Exception: # noqa: BLE001
diffusion_active = False
# An SDXL LoRA trainer runs in its own subprocess on the same GPU, so a video
# load must be refused while one is active too -- otherwise the resident pipeline
# competes with the trainer for VRAM. Symmetric with the image-load interlock.
if not llm_active and not diffusion_active:
return
raise HTTPException(
status_code = 409,
detail = (
"Can't load a video model while training is running: the video "
"pipeline would compete with the training run for GPU memory. Training "
"was left untouched. Try again after training finishes."
),
)
@router.post("/video/load", response_model = VideoStatusResponse)
async def load_video_model(
request: VideoLoadRequest, current_subject: str = Depends(get_current_subject)
):
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.gpu_arbiter import VIDEO, acquire_for, release
from core.inference.video import get_video_backend
from utils.native_path_leases import redact_native_paths
backend = get_video_backend()
try:
# Validate cheaply BEFORE touching the GPU: an unloadable pick (bad family,
# missing local checkpoint, a non-trusted non-GGUF repo) must not evict a
# working chat model and then 400.
await asyncio.to_thread(
backend.validate_load_request,
request.model_path,
gguf_filename = request.gguf_filename,
base_repo = request.base_repo,
family_override = request.family_override,
model_kind = request.model_kind,
transformer_quant = request.transformer_quant,
text_encoder_quant = request.text_encoder_quant,
vae_quant = request.vae_quant,
)
# Refuse while training is running: a multi-GB video pipeline would compete
# with the training subprocess for VRAM. Mirrors the image-load guard.
_guard_video_load_against_training()
# Take the GPU from the chat backend only when this load will actually use it,
# which is exactly the resolved device being non-CPU. A CPU-only load never
# touches GPU memory, so keying off the device (not the load) avoids wrongly
# evicting a resident chat model. Release any stale VIDEO ownership on a CPU
# load -- release() is owner-guarded, so it is a no-op when video never owned
# the GPU.
device = await asyncio.to_thread(lambda: resolve_diffusion_device_target().device)
if device != "cpu":
await asyncio.to_thread(acquire_for, VIDEO)
else:
await asyncio.to_thread(release, VIDEO)
status_dict = await asyncio.to_thread(
backend.begin_load,
request.model_path,
gguf_filename = request.gguf_filename,
base_repo = request.base_repo,
family_override = request.family_override,
hf_token = request.hf_token,
memory_mode = request.memory_mode,
speed_mode = request.speed_mode,
attention_backend = request.attention_backend,
transformer_cache = request.transformer_cache,
transformer_cache_threshold = request.transformer_cache_threshold,
transformer_cache_quality = request.transformer_cache_quality,
transformer_quant = request.transformer_quant,
text_encoder_quant = request.text_encoder_quant,
vae_quant = request.vae_quant,
cfg_parallel = request.cfg_parallel,
model_kind = request.model_kind,
)
return VideoStatusResponse(**status_dict)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code = 400, detail = redact_native_paths(str(exc)))
except RuntimeError as exc:
# A video load is already in progress.
raise HTTPException(status_code = 409, detail = str(exc))
@router.get("/video/load-progress", response_model = VideoLoadProgressResponse)
async def video_load_progress(current_subject: str = Depends(get_current_subject)):
from core.inference.video import get_video_backend
return VideoLoadProgressResponse(**get_video_backend().load_progress())
@router.post("/video/generate", response_model = VideoGenerateResponse)
async def generate_video(
request: VideoGenerateRequest, current_subject: str = Depends(get_current_subject)
):
from core.inference import video_gallery
from core.inference.video import get_video_backend
from core.inference.video_families import VIDEO_CANCELLED_MSG, VIDEO_NOT_LOADED_MSG
backend = get_video_backend()
try:
result = await asyncio.to_thread(
backend.generate,
prompt = request.prompt,
negative_prompt = request.negative_prompt,
width = request.width,
height = request.height,
num_frames = request.num_frames,
fps = request.fps,
steps = request.steps,
guidance = request.guidance,
guidance_2 = request.guidance_2,
seed = request.seed,
)
except ValueError as exc:
# Bad client input (a workflow the loaded family doesn't support) -- a 400 with
# the reason, not a generic 500.
raise HTTPException(status_code = 400, detail = str(exc))
except RuntimeError as exc:
# Only "no model loaded" / user-cancelled are client-state (409). Match the
# sentinels exactly, not as a substring, so an execution failure that merely
# contains "cancelled" can't misroute to 409 and leak that output.
msg = str(exc)
if msg in (VIDEO_NOT_LOADED_MSG, VIDEO_CANCELLED_MSG):
raise HTTPException(status_code = 409, detail = msg)
logger.error("video.generate_failed: %s", exc, exc_info = True)
raise HTTPException(status_code = 500, detail = "Video generation failed.")
except Exception as exc:
logger.error("video.generate_failed: %s", exc, exc_info = True)
raise HTTPException(status_code = 500, detail = "Video generation failed.")
# Persist the clip with its full recipe as the JSON sidecar the gallery reads back.
created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _persist() -> dict:
return video_gallery.save(
result["mp4_bytes"],
{
"prompt": request.prompt,
"negative_prompt": request.negative_prompt,
"width": result["width"],
"height": result["height"],
"num_frames": result["num_frames"],
"fps": result["fps"],
"duration_s": result["duration_s"],
"steps": result["steps"],
"guidance": result["guidance"],
"guidance_2": request.guidance_2,
"seed": result["seed"],
"has_audio": result["has_audio"],
"model": result["repo_id"],
"created_at": created_at,
},
)
try:
record = await asyncio.to_thread(_persist)
except Exception as exc: # noqa: BLE001
logger.error("video.persist_failed: %s", exc)
raise HTTPException(status_code = 500, detail = "Failed to save the generated video.")
return VideoGenerateResponse(video = GalleryVideo(**record))
@router.get("/video/generate-progress", response_model = VideoGenerateProgressResponse)
async def video_generate_progress(current_subject: str = Depends(get_current_subject)):
from core.inference.video import get_video_backend
return VideoGenerateProgressResponse(**get_video_backend().generate_progress())
@router.post("/video/generate/cancel")
async def cancel_video_generation(current_subject: str = Depends(get_current_subject)):
from core.inference.video import get_video_backend
cancelled = await asyncio.to_thread(get_video_backend().cancel_generate)
return {"cancelled": cancelled}
@router.get("/video/status", response_model = VideoStatusResponse)
async def video_status(current_subject: str = Depends(get_current_subject)):
from core.inference.video import get_video_backend
return VideoStatusResponse(**get_video_backend().status())
@router.post("/video/unload", response_model = VideoStatusResponse)
async def unload_video_model(current_subject: str = Depends(get_current_subject)):
from core.inference.gpu_arbiter import VIDEO, release
from core.inference.video import get_video_backend
backend = get_video_backend()
status_dict = await asyncio.to_thread(backend.unload)
# Drop VIDEO ownership only if nothing is resident AND no new load is in flight: a concurrent
# /video/load that re-acquired VIDEO while this (slow) unload ran must keep ownership, or a
# later chat/image load would see no owner, skip eviction, and OOM against the newly resident
# (or still in-flight) video pipeline. release() is owner-guarded and identity-less, so an
# unconditional release here would clear the newer load's claim. Mirrors the images-route
# guard (inference.py), plus the in-flight check the committed-loaded state cannot cover.
if not backend.loading_repo_ids() and not backend.status()["loaded"]:
release(VIDEO)
return VideoStatusResponse(**status_dict)
@router.get("/video/gallery", response_model = VideoGalleryListResponse)
async def list_gallery_videos(
limit: int = 50,
offset: int = 0,
current_subject: str = Depends(get_current_subject),
):
from core.inference import video_gallery
limit = max(1, min(limit, 200))
offset = max(0, offset)
# Fetch one extra to learn whether more remain, without a second scan.
records = await asyncio.to_thread(video_gallery.list_videos, limit + 1, offset)
has_more = len(records) > limit
# Build the response per record and drop any that fail schema validation: a
# sidecar with all required keys but a wrong value type (a hand-dropped or
# older-schema file) passes the presence-only read but would raise inside
# GalleryVideo(**r). Skipping it keeps one bad file from 500-ing the listing.
videos = []
for r in records[:limit]:
try:
videos.append(GalleryVideo(**r))
except ValidationError:
continue
return VideoGalleryListResponse(videos = videos, has_more = has_more)
@router.get("/video/gallery/{video_id}/file")
async def get_gallery_video_file(
video_id: str, current_subject: str = Depends(get_current_subject)
):
from core.inference import video_gallery
path = await asyncio.to_thread(video_gallery.video_path, video_id)
if path is None:
raise HTTPException(status_code = 404, detail = "Video not found.")
from fastapi.responses import FileResponse
# FileResponse streams from disk (no whole-clip buffering per request) and
# serves HTTP range requests so a direct URL can seek without a full fetch.
# Immutable content (id is unique per video), so let the browser cache it.
return FileResponse(
path,
media_type = "video/mp4",
headers = {"Cache-Control": "private, max-age=31536000, immutable"},
)
@router.get("/video/gallery/{video_id}/export")
async def export_gallery_video(
video_id: str,
format: str = "webm",
current_subject: str = Depends(get_current_subject),
):
"""Download-menu transcodes: WebM (VP9) or GIF, re-encoded on demand from the
stored MP4 (which the /file route serves verbatim). 501 with a clear message
when the codec/deps for the requested format are missing."""
from core.inference import video_gallery
fmt = format.strip().lower()
if fmt not in ("webm", "gif"):
raise HTTPException(status_code = 400, detail = "Unsupported format. Use webm or gif.")
try:
data = await asyncio.to_thread(video_gallery.transcode, video_id, fmt)
except RuntimeError as exc:
raise HTTPException(status_code = 501, detail = str(exc)) from exc
if data is None:
raise HTTPException(status_code = 404, detail = "Video not found.")
from fastapi.responses import Response
return Response(
content = data,
media_type = "video/webm" if fmt == "webm" else "image/gif",
# Transcodes are deterministic per id+format; let the browser cache them.
headers = {"Cache-Control": "private, max-age=31536000, immutable"},
)
@router.delete("/video/gallery/{video_id}")
async def delete_gallery_video(video_id: str, current_subject: str = Depends(get_current_subject)):
from core.inference import video_gallery
deleted = await asyncio.to_thread(video_gallery.delete, video_id)
if not deleted:
raise HTTPException(status_code = 404, detail = "Video not found.")
return {"deleted": True}
@router.delete("/video/gallery")
async def clear_gallery_videos(current_subject: str = Depends(get_current_subject)):
from core.inference import video_gallery
removed = await asyncio.to_thread(video_gallery.clear)
return {"removed": removed}