Add server-side frame pacing scheduler to smooth real-time stream
Browse files
app.py
CHANGED
|
@@ -81,6 +81,20 @@ MAX_BLOCKS_PER_SESSION = 512 # hard cap so a session can't run forever
|
|
| 81 |
SESSION_IDLE_TIMEOUT = 600 # seconds; janitor reaps abandoned sessions
|
| 82 |
GPU_DURATION = 120 # seconds per @spaces.GPU allocation
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
# Actions map to the 8-key one-hot the model was trained on (W A S D I J K L).
|
| 85 |
# The browser sends the currently-held key set; we translate to this dict.
|
| 86 |
KEY_ORDER = ["W", "A", "S", "D", "I", "J", "K", "L"]
|
|
@@ -292,9 +306,17 @@ def gpu_worker_thread(session: "GameSession"):
|
|
| 292 |
session.command_queue, session.seed_path, session.prompt, session.seed,
|
| 293 |
)
|
| 294 |
first = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
while not session.stop_event.is_set():
|
| 296 |
try:
|
| 297 |
-
frame,
|
| 298 |
except StopIteration:
|
| 299 |
print("Rollout generator exhausted", flush=True)
|
| 300 |
break
|
|
@@ -310,20 +332,47 @@ def gpu_worker_thread(session: "GameSession"):
|
|
| 310 |
broadcast_status("Rolling out β use WASD / IJKL to steer.")
|
| 311 |
first = False
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
now = time.time()
|
| 314 |
session.frame_times.append(now)
|
| 315 |
fps = 0.0
|
| 316 |
if len(session.frame_times) >= 2:
|
| 317 |
elapsed = session.frame_times[-1] - session.frame_times[0]
|
| 318 |
fps = (len(session.frame_times) - 1) / elapsed if elapsed > 0 else 0.0
|
| 319 |
-
# Keep only the freshest frame if the consumer fell behind
|
| 320 |
-
|
|
|
|
| 321 |
try:
|
| 322 |
session.frame_queue.get_nowait()
|
| 323 |
except queue.Empty:
|
| 324 |
break
|
| 325 |
try:
|
| 326 |
-
session.frame_queue.put_nowait((frame,
|
| 327 |
except queue.Full:
|
| 328 |
pass
|
| 329 |
finally:
|
|
@@ -377,13 +426,21 @@ def create_gpu_rollout_loop(command_queue, seed_path, prompt_text, seed):
|
|
| 377 |
if stop_requested:
|
| 378 |
break
|
| 379 |
|
|
|
|
|
|
|
|
|
|
| 380 |
pipeline.set_act(current_keys, height=STREAM_HEIGHT, width=STREAM_WIDTH,
|
| 381 |
num_frames=NUM_FPB, device=device)
|
| 382 |
lat_block = pipeline.generate_next_block(noise)
|
| 383 |
noise = torch.randn_like(noise)
|
| 384 |
frames = _decode_block_to_frames(lat_block)
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
finally:
|
| 388 |
try:
|
| 389 |
pipeline.reset_stream(batch_size=1, dtype=torch.bfloat16,
|
|
|
|
| 81 |
SESSION_IDLE_TIMEOUT = 600 # seconds; janitor reaps abandoned sessions
|
| 82 |
GPU_DURATION = 120 # seconds per @spaces.GPU allocation
|
| 83 |
|
| 84 |
+
# ββ Real-time pacing configuration βββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
# The GPU decodes a whole block (NUM_FPB frames) at once, so all of a block's
|
| 86 |
+
# frames become available in a burst. If we forward them to the browser the
|
| 87 |
+
# instant they finish, the client sees N frames clustered together followed by a
|
| 88 |
+
# gap while the next block generates β the fps counter averages out fine, but
|
| 89 |
+
# the *felt* cadence is bursty. To deliver a steady real-time stream we pace the
|
| 90 |
+
# frames of each block evenly across the time we expect one block to take
|
| 91 |
+
# (mirroring the official ABot-World web_client's block-frame spreader), and we
|
| 92 |
+
# smooth the per-block generation time with an EMA so a single slow/fast block
|
| 93 |
+
# doesn't cause a visible speed-up/slow-down. See gpu_worker_thread().
|
| 94 |
+
PACING_EMA_ALPHA = 0.25 # smoothing factor for per-block generation time
|
| 95 |
+
MIN_PACING_SLEEP = 0.004 # don't bother sleeping for sub-4ms slices
|
| 96 |
+
DEFAULT_BLOCK_SECONDS = 0.5 # initial per-block estimate before first measure
|
| 97 |
+
|
| 98 |
# Actions map to the 8-key one-hot the model was trained on (W A S D I J K L).
|
| 99 |
# The browser sends the currently-held key set; we translate to this dict.
|
| 100 |
KEY_ORDER = ["W", "A", "S", "D", "I", "J", "K", "L"]
|
|
|
|
| 306 |
session.command_queue, session.seed_path, session.prompt, session.seed,
|
| 307 |
)
|
| 308 |
first = True
|
| 309 |
+
# Steady send clock: `next_send` is the monotonic time at which the next
|
| 310 |
+
# frame *should* be delivered. Each frame's slot is one smoothed
|
| 311 |
+
# inter-frame interval after the previous, so frames leave at a constant
|
| 312 |
+
# cadence regardless of the bursty block boundaries. `block_seconds` is
|
| 313 |
+
# an EMA of measured per-block generation time (frames/block Γ· that gives
|
| 314 |
+
# the target inter-frame interval).
|
| 315 |
+
block_seconds = DEFAULT_BLOCK_SECONDS
|
| 316 |
+
next_send = None
|
| 317 |
while not session.stop_event.is_set():
|
| 318 |
try:
|
| 319 |
+
frame, block_idx, frame_idx, frames_in_block, block_elapsed = next(gen)
|
| 320 |
except StopIteration:
|
| 321 |
print("Rollout generator exhausted", flush=True)
|
| 322 |
break
|
|
|
|
| 332 |
broadcast_status("Rolling out β use WASD / IJKL to steer.")
|
| 333 |
first = False
|
| 334 |
|
| 335 |
+
# Update the smoothed per-block time on the first frame of each block
|
| 336 |
+
# (block_elapsed is constant across a block's frames).
|
| 337 |
+
if frame_idx == 0 and block_elapsed > 0:
|
| 338 |
+
block_seconds = (
|
| 339 |
+
PACING_EMA_ALPHA * block_elapsed
|
| 340 |
+
+ (1.0 - PACING_EMA_ALPHA) * block_seconds
|
| 341 |
+
)
|
| 342 |
+
fpb = max(1, frames_in_block)
|
| 343 |
+
interval = block_seconds / fpb # target seconds between frames
|
| 344 |
+
|
| 345 |
+
# ββ Steady-cadence gate ββββββββββββββββββββββββββββββββββββββββββ
|
| 346 |
+
# Hold each frame until its scheduled slot so the parent emits at a
|
| 347 |
+
# constant interval instead of dumping a whole block at once.
|
| 348 |
+
now = time.time()
|
| 349 |
+
if next_send is None:
|
| 350 |
+
next_send = now
|
| 351 |
+
sleep_for = next_send - now
|
| 352 |
+
if sleep_for > MIN_PACING_SLEEP:
|
| 353 |
+
# Wake early if a stop is requested so we stay responsive.
|
| 354 |
+
if session.stop_event.wait(timeout=sleep_for):
|
| 355 |
+
break
|
| 356 |
+
now = time.time()
|
| 357 |
+
# Advance the schedule; if we've fallen far behind (e.g. a long GPU
|
| 358 |
+
# stall), resync to now so we don't try to "catch up" in a burst.
|
| 359 |
+
next_send = max(now, next_send + interval)
|
| 360 |
+
|
| 361 |
now = time.time()
|
| 362 |
session.frame_times.append(now)
|
| 363 |
fps = 0.0
|
| 364 |
if len(session.frame_times) >= 2:
|
| 365 |
elapsed = session.frame_times[-1] - session.frame_times[0]
|
| 366 |
fps = (len(session.frame_times) - 1) / elapsed if elapsed > 0 else 0.0
|
| 367 |
+
# Keep only the freshest frame if the consumer fell behind: coalesce
|
| 368 |
+
# stale frames rather than letting them queue up and flush in a burst.
|
| 369 |
+
while session.frame_queue.qsize() > 1:
|
| 370 |
try:
|
| 371 |
session.frame_queue.get_nowait()
|
| 372 |
except queue.Empty:
|
| 373 |
break
|
| 374 |
try:
|
| 375 |
+
session.frame_queue.put_nowait((frame, block_idx, round(fps, 1)))
|
| 376 |
except queue.Full:
|
| 377 |
pass
|
| 378 |
finally:
|
|
|
|
| 426 |
if stop_requested:
|
| 427 |
break
|
| 428 |
|
| 429 |
+
# Time the full generate+decode of one block so the parent
|
| 430 |
+
# thread can pace this block's frames over that duration.
|
| 431 |
+
block_start = time.time()
|
| 432 |
pipeline.set_act(current_keys, height=STREAM_HEIGHT, width=STREAM_WIDTH,
|
| 433 |
num_frames=NUM_FPB, device=device)
|
| 434 |
lat_block = pipeline.generate_next_block(noise)
|
| 435 |
noise = torch.randn_like(noise)
|
| 436 |
frames = _decode_block_to_frames(lat_block)
|
| 437 |
+
block_elapsed = time.time() - block_start
|
| 438 |
+
n = len(frames)
|
| 439 |
+
for i, f in enumerate(frames):
|
| 440 |
+
# (frame, block_idx, frame_idx_in_block, frames_in_block,
|
| 441 |
+
# block_elapsed) β the pacing metadata lets the parent
|
| 442 |
+
# spread this block's frames evenly rather than bursting.
|
| 443 |
+
yield (f, b, i, n, block_elapsed)
|
| 444 |
finally:
|
| 445 |
try:
|
| 446 |
pipeline.reset_stream(batch_size=1, dtype=torch.bfloat16,
|