Files
hao-ai-lab-FastVideo/fastvideo/eval/evaluator.py
T
shaoxiongduan de3cd6aab2 [feat] eval: accept video paths at the worker boundary; bound batch memory
``Evaluator.evaluate`` and the one-shot ``evaluate()`` helper now accept
``video`` / ``reference`` as either a pre-loaded ``(T, C, H, W)`` tensor
or a path-like (``str`` / ``Path``). Paths are decoded inside the
worker thread that picks the sample up — see
``EvalWorker._resolve_video_input``.

Why this matters for batch eval: the dispatcher in
``Evaluator.evaluate(samples=[...])`` submits every sample to the
``ThreadPoolExecutor`` upfront. With pre-loaded tensors that meant
every video in the batch was resident in CPU RAM at once (≈ 3 GB per
video at 1088×1920×121); a full benchmark of hundreds of clips would
OOM before scoring started.

With paths, the queued futures hold cheap strings; only ``num_gpus``
videos are decoded concurrently. Peak resident memory becomes
``O(num_gpus)`` instead of ``O(len(samples))``. No dispatcher rewrite
needed — the change is ~10 lines at the worker boundary.

* ``EvalWorker.evaluate`` now normalizes ``sample["video"]`` and
  ``sample["reference"]`` through ``_resolve_video_input`` (path →
  ``load_video`` decode; ``(1, T, C, H, W)`` → squeeze; tensor →
  passthrough). Metrics keep the existing contract: by the time
  ``compute()`` runs, ``sample["video"]`` is always a 4-D tensor.
* ``score_folder.py`` and ``bench_vbench.py`` simplified to pass paths
  directly. ``bench_physics_iq.py`` already did.
* ``fastvideo.eval.api.evaluate`` signature widened to ``Tensor | str |
  Path``; the worker handles the decode either way.

Tests: new ``test_evaluator_paths.py`` covers the kwargs form, the
samples-list form, mixing paths and tensors in the same batch, parity
between path-form and tensor-form scores, and the missing-path
exception path. End-to-end ``score_folder.py`` smoke run confirmed.
2026-05-07 20:29:04 +00:00

154 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""User-facing scorer.
Layering (mirrors FastVideo's VideoGenerator → Worker pattern, but
in-process)::
Evaluator ← user-facing; round-robins samples across workers
└── EvalWorker × N ← single-GPU; owns metric replicas
The constructor builds one :class:`EvalWorker` per GPU and loads every
metric on every worker eagerly. :meth:`evaluate` is the single entry
point: pass kwargs for one sample, or pass a list of sample dicts to
fan-out across GPU replicas — same method, return type follows the
input shape.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Iterable
from fastvideo.eval.registry import list_metrics, resolve_group
from fastvideo.eval.types import MetricResult
from fastvideo.eval.worker import EvalWorker
class Evaluator:
"""Pre-initialized scorer for repeated evaluation.
Parameters
----------
metrics : list[str] | str
Metric names, group prefixes (``"vbench"``), or ``"all"``.
device : str
Single-GPU device (e.g. ``"cuda:0"``). Ignored when *num_gpus* > 1.
num_gpus : int
Number of GPU replicas. Each gets its own :class:`EvalWorker`.
compile : bool
Apply :func:`torch.compile` to each metric's ``_model``.
"""
def __init__(
self,
metrics: list[str] | str = "all",
device: str = "cuda:0",
num_gpus: int = 1,
compile: bool = False,
) -> None:
names = _resolve_metric_names(metrics)
if num_gpus > 1:
self._workers = [EvalWorker(names, f"cuda:{i}", compile=compile) for i in range(num_gpus)]
else:
self._workers = [EvalWorker(names, device, compile=compile)]
self._pool = (ThreadPoolExecutor(max_workers=num_gpus) if num_gpus > 1 else None)
@property
def num_gpus(self) -> int:
return len(self._workers)
@property
def metric_names(self) -> list[str]:
return self._workers[0].metric_names
def evaluate(
self,
samples: Iterable[dict] | None = None,
**kwargs,
) -> dict[str, MetricResult] | list[dict[str, MetricResult]]:
"""Score one sample (kwargs form) or many samples (list form).
``video`` and ``reference`` may be either a pre-loaded
``(T, C, H, W)`` tensor or a path-like (``str`` / ``Path``).
Paths are decoded inside the worker thread that picks up the
sample, so memory stays bounded by ``num_gpus`` even when
thousands of paths are queued — see ``score_folder.py`` for
the canonical pattern.
One sample::
ev.evaluate(video=tensor, text_prompt="...", fps=24.0)
ev.evaluate(video="path/to/clip.mp4", fps=24.0)
Many samples — fan out across GPU replicas, results in input order::
ev.evaluate(samples=[
{"video": "a.mp4", "reference": "ref_a.mp4"},
{"video": "b.mp4", "reference": "ref_b.mp4"},
...
])
Multi-GPU dispatch fires automatically iff ``num_gpus > 1`` *and*
the list form is used. The kwargs form always runs on worker 0;
if you have a single sample but multiple GPUs, wrap it in a
one-element list to use the pool, or just accept that a single
call uses one GPU — that's fine.
"""
if samples is None:
return self._workers[0].evaluate(**kwargs)
samples = list(samples)
if self._pool is None or len(samples) <= 1:
return [self._workers[0].evaluate(**s) for s in samples]
n = len(self._workers)
# Round-robin: worker i handles samples i, i+n, i+2n, ...
futures = [self._pool.submit(self._workers[idx % n].evaluate, **sample) for idx, sample in enumerate(samples)]
return [f.result() for f in futures]
def release_cuda_memory(self) -> None:
"""Free CUDA caches on every replica without dropping models."""
for w in self._workers:
w.release_cuda_memory()
def unload(self) -> None:
"""Drop metric refs on every replica. Reverse with :meth:`reload`."""
for w in self._workers:
w.unload()
def reload(self) -> None:
"""Rebuild metrics dropped by :meth:`unload`."""
for w in self._workers:
w.reload()
def shutdown(self) -> None:
"""Tear down the worker thread pool. Idempotent."""
if self._pool is not None:
self._pool.shutdown(wait=True)
self._pool = None
def create_evaluator(
metrics: list[str] | str = "all",
device: str = "cuda:0",
num_gpus: int = 1,
compile: bool = False,
) -> Evaluator:
return Evaluator(metrics=metrics, device=device, num_gpus=num_gpus, compile=compile)
def _resolve_metric_names(metrics: list[str] | str) -> list[str]:
"""Resolve metric names, supporting groups (``"vbench"``) and ``"all"``."""
if metrics == "all":
return list_metrics()
if isinstance(metrics, str):
metrics = [metrics]
seen: set[str] = set()
names: list[str] = []
for m in metrics:
group = resolve_group(m)
for n in (group if group is not None else [m]):
if n not in seen:
seen.add(n)
names.append(n)
return names