[test]: CPU regression tests for dynamic batching scheduler and signature fixes (#1453)

Covers the review's adversarial probes: expired-deadline greedy drain
coalesces jobs queued behind an in-flight generation (delay>0 no longer
degrades to sequential), submit-after-stop raises, stop() fails
still-queued futures without hanging, a dispatch exception fans out to
every future in the group, a crashed _run task fails in-flight and
queued futures and rejects later submits, the pipeline capability gate
rejects non-opted-in pipelines (DMD/causal always vetoed), pre-set
latents are never merged, and distinct opaque objects are conservatively
incompatible. Verified the deadline and gate tests fail on the pre-fix
scheduler.
This commit is contained in:
SolitaryThinker
2026-07-11 18:35:45 -07:00
parent 302f79b6f4
commit e35fa5452a
2 changed files with 252 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU regression tests for the dynamic VideoBatchScheduler (no GPU needed)."""
from __future__ import annotations
import asyncio
import threading
from types import SimpleNamespace
import pytest
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.configs.pipelines.wan import (
FastWan2_1_T2V_480P_Config,
SelfForcingWanT2V480PConfig,
WanT2V480PConfig,
)
from fastvideo.entrypoints.openai.batching import VideoBatchScheduler
class _FakeBatchGenerator:
def __init__(self):
self.calls: list[list[dict]] = []
def generate_video_batch(self, request_kwargs):
self.calls.append([dict(item) for item in request_kwargs])
return [{"prompts": item["prompt"], "video_path": item["output_path"]} for item in request_kwargs]
class _BlockingFirstCallGenerator(_FakeBatchGenerator):
"""Blocks the first generate_video_batch call until released.
Lets a test deterministically hold one generation "in flight" while more
requests queue up behind it.
"""
def __init__(self):
super().__init__()
self.started = threading.Event()
self.release = threading.Event()
self._first_call = True
def generate_video_batch(self, request_kwargs):
if self._first_call:
self._first_call = False
self.started.set()
assert self.release.wait(timeout=10)
return super().generate_video_batch(request_kwargs)
def _batch_scheduler_args(**overrides):
defaults = dict(
model_path="test-model",
batching_mode="dynamic",
batching_max_size=2,
batching_delay_ms=25.0,
enable_batching_metrics=False,
pipeline_config=PipelineConfig(supports_dynamic_batching=True),
)
defaults.update(overrides)
return SimpleNamespace(**defaults)
def _job_kwargs(tmp_path, index: int) -> dict:
return {
"prompt": f"p{index}",
"height": 256,
"width": 256,
"num_frames": 1,
"num_inference_steps": 2,
"seed": index,
"output_path": str(tmp_path / f"video_{index}.mp4"),
"save_video": False,
}
def test_expired_deadline_coalesces_jobs_queued_behind_inflight_generation(tmp_path):
"""Jobs whose delay deadline expired while a generation was in flight must
still coalesce (greedy drain), not dispatch as batches of 1."""
async def run():
generator = _BlockingFirstCallGenerator()
scheduler = VideoBatchScheduler(generator, _batch_scheduler_args(batching_max_size=4, batching_delay_ms=25.0))
await scheduler.start()
try:
loop = asyncio.get_running_loop()
first = asyncio.ensure_future(scheduler.submit("req-1", _job_kwargs(tmp_path, 1)))
await asyncio.wait_for(loop.run_in_executor(None, generator.started.wait, 5), timeout=10)
rest = [asyncio.ensure_future(scheduler.submit(f"req-{i}", _job_kwargs(tmp_path, i))) for i in (2, 3, 4)]
# Let the in-queue jobs outlive the 25 ms batching window while the
# first generation is still blocking the scheduler.
await asyncio.sleep(0.1)
generator.release.set()
await asyncio.wait_for(asyncio.gather(first, *rest), timeout=10)
finally:
await scheduler.stop()
return [[item["prompt"] for item in call] for call in generator.calls]
assert asyncio.run(run()) == [["p1"], ["p2", "p3", "p4"]]
def test_submit_after_stop_raises(tmp_path):
async def run():
scheduler = VideoBatchScheduler(_FakeBatchGenerator(), _batch_scheduler_args())
await scheduler.start()
await scheduler.stop()
with pytest.raises(RuntimeError, match="stopped"):
await scheduler.submit("req-1", _job_kwargs(tmp_path, 1))
asyncio.run(run())
def test_stop_fails_jobs_still_queued_without_hanging(tmp_path):
"""stop() during an in-flight generation must fail the still-queued jobs
instead of stranding their futures forever."""
async def run():
generator = _BlockingFirstCallGenerator()
scheduler = VideoBatchScheduler(generator, _batch_scheduler_args())
await scheduler.start()
loop = asyncio.get_running_loop()
first = asyncio.ensure_future(scheduler.submit("req-1", _job_kwargs(tmp_path, 1)))
await asyncio.wait_for(loop.run_in_executor(None, generator.started.wait, 5), timeout=10)
queued = [asyncio.ensure_future(scheduler.submit(f"req-{i}", _job_kwargs(tmp_path, i))) for i in (2, 3)]
await asyncio.sleep(0) # let the queued submits enqueue
stop_task = asyncio.ensure_future(scheduler.stop())
await asyncio.sleep(0) # let stop() mark the scheduler stopped
generator.release.set()
result = await asyncio.wait_for(first, timeout=10)
assert result["prompts"] == "p1"
for task in queued:
with pytest.raises(RuntimeError, match="stopped before dispatch"):
await asyncio.wait_for(task, timeout=10)
await asyncio.wait_for(stop_task, timeout=10)
asyncio.run(run())
def test_dispatch_exception_fails_all_group_futures(tmp_path):
class _ExplodingGenerator:
def generate_video_batch(self, request_kwargs):
raise ValueError("generation exploded")
async def run():
scheduler = VideoBatchScheduler(_ExplodingGenerator(), _batch_scheduler_args())
await scheduler.start()
try:
return await asyncio.wait_for(
asyncio.gather(
scheduler.submit("req-1", _job_kwargs(tmp_path, 1)),
scheduler.submit("req-2", _job_kwargs(tmp_path, 2)),
return_exceptions=True,
),
timeout=10,
)
finally:
await scheduler.stop()
results = asyncio.run(run())
assert len(results) == 2
for result in results:
assert isinstance(result, ValueError)
assert "generation exploded" in str(result)
def test_run_crash_fails_inflight_and_queued_futures(tmp_path):
"""If the _run task ever dies, every waiting future must fail instead of
hanging, and later submits must be rejected."""
async def run():
scheduler = VideoBatchScheduler(_FakeBatchGenerator(), _batch_scheduler_args())
async def boom(first):
raise RuntimeError("kaboom")
scheduler._collect_batch = boom # type: ignore[method-assign]
await scheduler.start()
results = await asyncio.wait_for(
asyncio.gather(
scheduler.submit("req-1", _job_kwargs(tmp_path, 1)),
scheduler.submit("req-2", _job_kwargs(tmp_path, 2)),
return_exceptions=True,
),
timeout=10,
)
with pytest.raises(RuntimeError, match="stopped"):
await scheduler.submit("req-3", _job_kwargs(tmp_path, 3))
return results
results = asyncio.run(run())
assert len(results) == 2
for result in results:
assert isinstance(result, RuntimeError)
assert "crashed" in str(result)
def test_scheduler_start_rejects_pipeline_without_capability(tmp_path):
async def run():
scheduler = VideoBatchScheduler(
_FakeBatchGenerator(),
_batch_scheduler_args(pipeline_config=PipelineConfig()),
)
with pytest.raises(RuntimeError, match="does not support dynamic request batching"):
await scheduler.start()
assert scheduler._task is None
asyncio.run(run())
def test_dynamic_batching_capability_gate_defaults():
# Default: every pipeline is excluded until its family opts in.
assert PipelineConfig().dynamic_batching_supported() is False
# The verified generic Wan denoising path opts in.
assert WanT2V480PConfig().dynamic_batching_supported() is True
# DMD and causal denoising variants are always vetoed (shared-RNG risk),
# even though they inherit the Wan opt-in flag.
assert FastWan2_1_T2V_480P_Config().dynamic_batching_supported() is False
assert SelfForcingWanT2V480PConfig().dynamic_batching_supported() is False
@@ -53,5 +53,31 @@ def test_can_dynamic_batch_rejects_image_conditioning() -> None:
assert result.reason == "image_path"
def test_can_dynamic_batch_rejects_preset_latents() -> None:
first = _request()
second = _request(latents=object())
result = can_dynamic_batch(first, second)
assert result.can_batch is False
assert result.reason == "latents"
def test_signature_treats_distinct_opaque_objects_as_incompatible() -> None:
class Opaque:
pass
first = _request("first")
second = _request("second")
distinct = can_dynamic_batch(first, second, base_extra={"foo": Opaque()}, candidate_extra={"foo": Opaque()})
assert distinct.can_batch is False
shared = Opaque()
same_object = can_dynamic_batch(first, second, base_extra={"foo": shared}, candidate_extra={"foo": shared})
assert same_object.can_batch is True
def test_resolution_key_uses_generation_shape() -> None:
assert resolution_key(_request(height=720, width=1280, num_frames=81)) == "720x1280x81"