返回 JoyAI-Echo
runtime.py
根目录 / echo_longvideo / server / runtime.py
1 """Lazy local Echo 1.5 inference runtime used by the API scheduler.
2
3 This module intentionally avoids importing Torch or the model stack at import
4 time. The FastAPI control plane and its scheduler tests can therefore run on a
5 CPU-only machine; GPU dependencies are loaded only when a worker starts.
6 """
7
8 from __future__ import annotations
9
10 import gc
11 import time
12 from dataclasses import asdict, dataclass, replace
13 from pathlib import Path
14 from typing import Any
15
16 from r2v_schema import R2VRequest, load_r2v_request
17
18
19 GIB = 1024**3
20 REPO_ROOT = Path(__file__).resolve().parent.parent
21
22 # Whole-device memory estimates validated with a 241-frame,
23 # 1280x736 request. ``auto`` uses them only to choose the residency strategy;
24 # live free memory remains the admission authority. On-disk size is not a good
25 # proxy here because packed weight size and runtime workspace differ by backend.
26 RESIDENT_DIT_PEAK_BYTES = {
27 "bf16": int(45.2 * GIB),
28 "fp8": int(31.6 * GIB),
29 "fp4": int(24.4 * GIB),
30 }
31 SWAP_DIT_PEAK_BYTES = {
32 "bf16": int(15.44 * GIB),
33 "fp8": int(15.92 * GIB),
34 "fp4": int(14.98 * GIB),
35 }
36
37
38 def resolve_dit_residency(
39 requested: str,
40 *,
41 precision: str,
42 snapshot: "ResourceSnapshot",
43 headroom_fraction: float,
44 ) -> dict[str, Any]:
45 """Select full GPU residency or layerwise swap from live device capacity."""
46
47 requested = requested.strip().lower()
48 if requested not in {"auto", "resident", "swap"}:
49 raise ValueError("ECHO_DIT_RESIDENCY must be auto, resident, or swap")
50 if precision not in RESIDENT_DIT_PEAK_BYTES:
51 raise ValueError(f"unsupported checkpoint precision for residency: {precision}")
52
53 measured_peak = RESIDENT_DIT_PEAK_BYTES[precision]
54 reserve = max(int(snapshot.gpu_total_bytes * headroom_fraction), 2 * GIB)
55 required_free = measured_peak + reserve
56 selected = (
57 requested
58 if requested != "auto"
59 else ("resident" if snapshot.gpu_free_bytes >= required_free else "swap")
60 )
61 return {
62 "requested": requested,
63 "selected": selected,
64 "precision": precision,
65 "measured_resident_peak_bytes": measured_peak,
66 "headroom_bytes": reserve,
67 "required_free_bytes": required_free,
68 "observed_free_bytes": snapshot.gpu_free_bytes,
69 "observed_total_bytes": snapshot.gpu_total_bytes,
70 }
71
72
73 @dataclass(frozen=True)
74 class ResourceSnapshot:
75 gpu_id: int
76 gpu_name: str
77 gpu_free_bytes: int
78 gpu_total_bytes: int
79 ram_available_bytes: int
80 ram_total_bytes: int
81 observed_at: float
82
83 def as_dict(self) -> dict[str, Any]:
84 payload = asdict(self)
85 payload.update(
86 {
87 "gpu_free_gib": round(self.gpu_free_bytes / GIB, 3),
88 "gpu_total_gib": round(self.gpu_total_bytes / GIB, 3),
89 "ram_available_gib": round(self.ram_available_bytes / GIB, 3),
90 "ram_total_gib": round(self.ram_total_bytes / GIB, 3),
91 }
92 )
93 return payload
94
95
96 def resolve_gpu_ids(configured: str) -> list[int]:
97 """Resolve logical CUDA device IDs after CUDA_VISIBLE_DEVICES is applied."""
98
99 import torch
100
101 count = torch.cuda.device_count()
102 if not torch.cuda.is_available() or count <= 0:
103 raise RuntimeError("the local R2V server requires at least one CUDA GPU")
104 text = configured.strip()
105 gpu_ids = (
106 list(range(count))
107 if not text
108 else [int(value.strip()) for value in text.split(",")]
109 )
110 if len(set(gpu_ids)) != len(gpu_ids):
111 raise ValueError("ECHO_GPU_IDS contains duplicate logical device IDs")
112 invalid = [gpu_id for gpu_id in gpu_ids if gpu_id < 0 or gpu_id >= count]
113 if invalid:
114 raise ValueError(
115 f"ECHO_GPU_IDS contains unavailable devices: {invalid}; visible={count}"
116 )
117 return gpu_ids
118
119
120 def probe_resources(gpu_id: int) -> ResourceSnapshot:
121 import psutil
122 import torch
123
124 with torch.cuda.device(gpu_id):
125 free_bytes, total_bytes = torch.cuda.mem_get_info(gpu_id)
126 gpu_name = torch.cuda.get_device_name(gpu_id)
127 memory = psutil.virtual_memory()
128 return ResourceSnapshot(
129 gpu_id=gpu_id,
130 gpu_name=gpu_name,
131 gpu_free_bytes=int(free_bytes),
132 gpu_total_bytes=int(total_bytes),
133 ram_available_bytes=int(memory.available),
134 ram_total_bytes=int(memory.total),
135 observed_at=time.time(),
136 )
137
138
139 class LocalModelRuntime:
140 """Own one GPU's staged condition/generator/VAE model lifecycle."""
141
142 def __init__(
143 self,
144 *,
145 gpu_id: int,
146 config_path: Path,
147 checkpoint: str | None,
148 conditioning_cache_dir: Path,
149 requests_root: Path,
150 output_root: Path,
151 dit_residency: str = "auto",
152 gpu_headroom_fraction: float = 0.05,
153 ram_headroom_fraction: float = 0.10,
154 ) -> None:
155 self.gpu_id = int(gpu_id)
156 self.config_path = config_path.expanduser().resolve()
157 if checkpoint:
158 checkpoint_path = Path(checkpoint).expanduser()
159 if not checkpoint_path.is_absolute():
160 checkpoint_path = REPO_ROOT / checkpoint_path
161 self.checkpoint = str(checkpoint_path.resolve())
162 else:
163 self.checkpoint = None
164 self.conditioning_cache_dir = conditioning_cache_dir.expanduser().resolve()
165 self.requests_root = requests_root.expanduser().resolve()
166 self.output_root = output_root.expanduser().resolve()
167 self.dit_residency = dit_residency.strip().lower()
168 if self.dit_residency not in {"auto", "resident", "swap"}:
169 raise ValueError("ECHO_DIT_RESIDENCY must be auto, resident, or swap")
170 self.gpu_headroom_fraction = float(gpu_headroom_fraction)
171 self.ram_headroom_fraction = float(ram_headroom_fraction)
172 self.engine = None
173 self.residency_plan: dict[str, Any] | None = None
174 self.state = "unloaded"
175 self.last_used_at = time.monotonic()
176
177 @property
178 def model_loaded(self) -> bool:
179 return self.engine is not None and self.engine.generator is not None
180
181 @property
182 def weights_loaded(self) -> bool:
183 return self.engine is not None and any(
184 module is not None
185 for module in (
186 self.engine.generator,
187 self.engine.video_vae,
188 self.engine.audio_vae,
189 )
190 )
191
192 @property
193 def model_location(self) -> str:
194 if self.engine is None:
195 return "unloaded"
196 return str(getattr(self.engine, "generator_location", "unloaded"))
197
198 def can_retain_generator_on_cpu(
199 self, ram_available_bytes: int, ram_reserve_bytes: int
200 ) -> bool:
201 """Choose CPU retention only when it will not consume the RAM reserve."""
202
203 if not self.model_loaded:
204 return False
205 if self.model_location == "cpu":
206 return ram_available_bytes >= ram_reserve_bytes
207 retained_bytes = int(self.engine.generation_storage_bytes())
208 return ram_available_bytes - retained_bytes >= ram_reserve_bytes
209
210 def conditioning_generator_policy(self, snapshot: ResourceSnapshot) -> str:
211 """Prefer GPU residency across a cache miss, then CPU, then release."""
212
213 if not self.model_loaded:
214 return "release"
215 engine = self._ensure_engine()
216 if self.model_location.startswith("cuda"):
217 gemma_bytes = sum(
218 path.stat().st_size
219 for path in engine.gemma_path.rglob("*.safetensors")
220 if path.is_file()
221 )
222 # Gemma language weights, Echo connectors and conditioning
223 # activations are all transient. Derive their budget from the two
224 # checkpoint sources instead of a device-specific GiB constant.
225 condition_gpu_bytes = (
226 int(gemma_bytes * 1.10)
227 + int(engine.model_checkpoint.stat().st_size * 0.15)
228 + int(snapshot.gpu_total_bytes * self.gpu_headroom_fraction)
229 )
230 if gemma_bytes > 0 and snapshot.gpu_free_bytes >= condition_gpu_bytes:
231 return "gpu"
232 ram_reserve = int(snapshot.ram_total_bytes * self.ram_headroom_fraction)
233 if self.can_retain_generator_on_cpu(snapshot.ram_available_bytes, ram_reserve):
234 return "cpu"
235 return "release"
236
237 def admission_requirements(
238 self, snapshot: ResourceSnapshot
239 ) -> tuple[int, int, dict[str, Any]]:
240 """Estimate the next load/compute working set from the selected release."""
241
242 engine = self._ensure_engine()
243 component_file_bytes = int(engine.model_checkpoint.stat().st_size)
244 modelopt_path = engine.release_checkpoint.modelopt_path
245 generator_file_bytes = (
246 int(modelopt_path.stat().st_size)
247 if modelopt_path is not None
248 else component_file_bytes
249 )
250 release_file_bytes = component_file_bytes + (
251 generator_file_bytes if modelopt_path is not None else 0
252 )
253 precision = str(engine.release_checkpoint.precision)
254 gpu_reserve = int(snapshot.gpu_total_bytes * self.gpu_headroom_fraction)
255 ram_reserve = int(snapshot.ram_total_bytes * self.ram_headroom_fraction)
256
257 if engine.config.dit_layerwise_offload:
258 # Offload profiles are dominated by activations plus prefetched
259 # blocks rather than the complete checkpoint size. Use the measured
260 # whole-device peak plus the same safety reserve as auto selection.
261 gpu_required = SWAP_DIT_PEAK_BYTES[precision] + max(gpu_reserve, 2 * GIB)
262 mode = "layerwise_offload"
263 elif self.model_loaded and self.model_location.startswith("cuda"):
264 gpu_required = int(snapshot.gpu_total_bytes * 0.20)
265 mode = "warm_gpu"
266 else:
267 if self.model_loaded:
268 model_bytes = int(engine.generation_storage_bytes())
269 else:
270 model_bytes = generator_file_bytes
271 gpu_required = model_bytes + gpu_reserve
272 if self.residency_plan is not None:
273 gpu_required = max(
274 gpu_required,
275 int(self.residency_plan["required_free_bytes"]),
276 )
277 mode = "load_from_cpu" if self.model_loaded else "cold_load"
278
279 # Cold loading maps the independent release artifacts and may create an
280 # offload pool. Warm CPU/GPU weights are already reflected in
281 # psutil.available and need only the proportional system reserve.
282 ram_required = ram_reserve
283 if not self.weights_loaded:
284 ram_required += int(release_file_bytes * 1.25)
285 residency = dict(self.residency_plan or {})
286 for key, value in tuple(residency.items()):
287 if key.endswith("_bytes"):
288 residency[f"{key.removesuffix('_bytes')}_gib"] = round(value / GIB, 3)
289 del residency[key]
290 return (
291 gpu_required,
292 ram_required,
293 {
294 "mode": mode,
295 "precision": precision,
296 "release_files_gib": round(release_file_bytes / GIB, 3),
297 "model_location": self.model_location,
298 "dit_residency": residency,
299 },
300 )
301
302 def _ensure_engine(self):
303 if self.engine is not None:
304 return self.engine
305 from inference import InferenceConfig, InferenceEngine
306 from ltx_distillation.release_checkpoint import resolve_release_checkpoint
307
308 overrides: dict[str, Any] = {
309 "device": f"cuda:{self.gpu_id}",
310 "requests_dir": str(self.requests_root),
311 "conditioning_cache_dir": str(self.conditioning_cache_dir),
312 "output_root": str(self.output_root),
313 }
314 if self.checkpoint:
315 overrides["checkpoint"] = self.checkpoint
316 config = InferenceConfig(self.config_path, **overrides)
317 release = resolve_release_checkpoint(config.checkpoint)
318 snapshot = probe_resources(self.gpu_id)
319 self.residency_plan = resolve_dit_residency(
320 self.dit_residency,
321 precision=release.precision,
322 snapshot=snapshot,
323 headroom_fraction=self.gpu_headroom_fraction,
324 )
325 config.dit_layerwise_offload = self.residency_plan["selected"] == "swap"
326 print(
327 "[Server] DiT residency "
328 f"requested={self.residency_plan['requested']} "
329 f"selected={self.residency_plan['selected']} "
330 f"precision={release.precision} "
331 f"free={snapshot.gpu_free_bytes / GIB:.2f}GiB "
332 f"required_for_resident={self.residency_plan['required_free_bytes'] / GIB:.2f}GiB",
333 flush=True,
334 )
335 if config.voice_filter.msst_device in {"auto", "cuda"}:
336 config.voice_filter = replace(
337 config.voice_filter,
338 msst_device=f"cuda:{self.gpu_id}",
339 )
340 self.engine = InferenceEngine(config)
341 return self.engine
342
343 def load_request(self, request_file: Path) -> R2VRequest:
344 engine = self._ensure_engine()
345 config = engine.config
346 return load_r2v_request(
347 request_file,
348 default_num_frames=config.num_frames,
349 default_width=config.video_width,
350 default_height=config.video_height,
351 default_seed=config.seed,
352 prompt_max_chars=config.prompt_max_chars,
353 )
354
355 def prepare_conditions(
356 self,
357 request_files: list[Path],
358 requests: list[R2VRequest],
359 stage_callback,
360 *,
361 generator_policy: str = "cpu",
362 ) -> dict[Path, Any]:
363 """Load valid caches, or make GPU room and batch cache misses."""
364
365 engine = self._ensure_engine()
366 self.state = "conditioning_cache_lookup"
367 stage_callback(self.state)
368 try:
369 return engine.prepare_conditions(request_files, requests)
370 except (FileNotFoundError, ValueError):
371 self.state = (
372 "staging_generator_on_cpu"
373 if generator_policy == "cpu" and self.model_loaded
374 else (
375 "keeping_generator_on_gpu"
376 if generator_policy == "gpu" and self.model_loaded
377 else "unloading_for_conditioning"
378 )
379 )
380 stage_callback(self.state)
381 engine.stage_generator_for_conditioning(
382 generator_policy if self.model_loaded else "release"
383 )
384 self.state = "conditioning"
385 stage_callback(self.state)
386 try:
387 return engine.prepare_conditions(
388 request_files, requests, encode_cache_misses=True
389 )
390 finally:
391 from ltx_distillation.audio_voice_filter import (
392 release_voice_filter_device,
393 )
394
395 release_voice_filter_device(self.gpu_id)
396
397 def load_cached_conditions(
398 self,
399 request_files: list[Path],
400 requests: list[R2VRequest],
401 stage_callback,
402 ) -> dict[Path, Any] | None:
403 """Fast path that never loads encoders and never mutates model residency."""
404
405 engine = self._ensure_engine()
406 self.state = "conditioning_cache_lookup"
407 stage_callback(self.state)
408 try:
409 return engine.prepare_conditions(request_files, requests)
410 except (FileNotFoundError, ValueError):
411 return None
412
413 def run(
414 self,
415 request_file: Path,
416 request: R2VRequest,
417 output_dir: Path,
418 bundle,
419 stage_callback,
420 ):
421 engine = self._ensure_engine()
422 self.state = "loading_generator"
423 engine.load_generator()
424 result = engine.run_request(
425 request_file,
426 request,
427 output_dir,
428 bundle,
429 stage_callback=self._stage_callback(stage_callback),
430 decode_generator_policy=self._decode_generator_policy,
431 )
432 self.state = "ready"
433 self.last_used_at = time.monotonic()
434 return result
435
436 def _decode_generator_policy(self) -> str:
437 """Keep weights on the fastest safe tier for the VAE decode stage."""
438
439 engine = self._ensure_engine()
440 snapshot = probe_resources(self.gpu_id)
441 residency_plan = getattr(self, "residency_plan", None)
442 if (
443 residency_plan is not None
444 and residency_plan["selected"] == "resident"
445 and engine.dit_offload is None
446 and engine.generator_location.startswith("cuda")
447 ):
448 # A resident profile keeps DiT in device memory during tiled decode
449 # and between requests. The engine catches a real decoder-placement
450 # OOM and falls back to releasing DiT, so contention remains safe.
451 return "gpu"
452 # Layerwise offload already owns a CPU pool; deactivation merely returns
453 # the active blocks to that pool and does not allocate another copy.
454 if engine.dit_offload is not None:
455 return "cpu"
456 gpu_decode_headroom = int(
457 snapshot.gpu_total_bytes * max(self.gpu_headroom_fraction, 0.20)
458 )
459 if snapshot.gpu_free_bytes >= gpu_decode_headroom:
460 return "gpu"
461 if engine.generator_location == "cpu":
462 return "cpu"
463 retained_bytes = int(engine.generation_storage_bytes())
464 ram_reserve = int(snapshot.ram_total_bytes * self.ram_headroom_fraction)
465 if snapshot.ram_available_bytes - retained_bytes >= ram_reserve:
466 return "cpu"
467 return "release"
468
469 def _stage_callback(self, callback):
470 def update(stage: str) -> None:
471 self.state = stage
472 callback(stage)
473
474 return update
475
476 def unload(self) -> None:
477 self.state = "unloading"
478 if self.engine is not None:
479 self.engine.unload_generator()
480 self.engine = None
481 gc.collect()
482 try:
483 import torch
484
485 with torch.cuda.device(self.gpu_id):
486 torch.cuda.empty_cache()
487 except (ImportError, RuntimeError):
488 pass
489 self.state = "unloaded"
490 self.last_used_at = time.monotonic()
491
491 lines PYTHON