Add online current-tail LoRA benchmark

This commit is contained in:
Xin Wang
2026-08-23 15:10:16 +08:00
parent 1069a0beee
commit dc932cc165
2 changed files with 236 additions and 17 deletions
+8 -1
View File
@@ -192,6 +192,7 @@ def reconstruct_open_stream_sequence(
store: CachedBlockStore, store: CachedBlockStore,
static: dict[str, Any], static: dict[str, Any],
anchor: dict[str, Any], anchor: dict[str, Any],
current_piece: dict[str, torch.Tensor] | None = None,
) -> tuple[torch.Tensor, torch.LongTensor]: ) -> tuple[torch.Tensor, torch.LongTensor]:
"""Materialize one differentiable Thinker input from frozen AV embeddings. """Materialize one differentiable Thinker input from frozen AV embeddings.
@@ -219,7 +220,13 @@ def reconstruct_open_stream_sequence(
last_shift = 0 last_shift = 0
for sequence_index, (block_index, tail_steps) in enumerate(selected): for sequence_index, (block_index, tail_steps) in enumerate(selected):
piece = store.variant(block_index, tail_steps) is_current = sequence_index == len(selected) - 1
piece = (
current_piece
if is_current and current_piece is not None
else store.variant(block_index, tail_steps)
)
validate_piece(piece)
local_payload_positions = _strip_position_batch(piece["payload_position_ids"]) local_payload_positions = _strip_position_batch(piece["payload_position_ids"])
local_payload_base = int(local_payload_positions.min().item()) local_payload_base = int(local_payload_positions.min().item())
if first_payload_base is None: if first_payload_base is None:
+228 -16
View File
@@ -19,6 +19,7 @@ import importlib.metadata
import json import json
import math import math
import random import random
import time
from pathlib import Path from pathlib import Path
from typing import Any, Sequence from typing import Any, Sequence
@@ -42,6 +43,7 @@ from omni_cached_av import (
collate_cached_sequences, collate_cached_sequences,
forward_last_state, forward_last_state,
load_json, load_json,
pieces_to_cache,
reconstruct_open_stream_sequence, reconstruct_open_stream_sequence,
save_json, save_json,
torch_load, torch_load,
@@ -82,6 +84,24 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument("--gradient-accumulation-steps", type=int, default=1) parser.add_argument("--gradient-accumulation-steps", type=int, default=1)
parser.add_argument("--num-workers", type=int, default=0) parser.add_argument("--num-workers", type=int, default=0)
parser.add_argument("--block-cache-size", type=int, default=16) parser.add_argument("--block-cache-size", type=int, default=16)
parser.add_argument(
"--online-current-tail",
action="store_true",
help=(
"Benchmark complete-2s-block caching: read completed history from "
"cache but recompute the current 0.2--1.8s AV tail from source media"
),
)
parser.add_argument(
"--easycom-session-dir",
type=Path,
help="Original Session_N media; required by --online-current-tail",
)
parser.add_argument("--frame-cache-size", type=int, default=64)
parser.add_argument("--decoder", choices=("decord", "pynvcodec"), default="decord")
parser.add_argument("--decoder-gpu-id", type=int, default=0)
parser.add_argument("--decoder-threads", type=int, default=1)
parser.add_argument("--max-open-readers", type=int, default=1)
parser.add_argument("--learning-rate", type=float, default=2e-5) parser.add_argument("--learning-rate", type=float, default=2e-5)
parser.add_argument("--head-learning-rate", type=float, default=1e-4) parser.add_argument("--head-learning-rate", type=float, default=1e-4)
parser.add_argument("--weight-decay", type=float, default=0.01) parser.add_argument("--weight-decay", type=float, default=0.01)
@@ -144,11 +164,13 @@ class OmniTurnCacheDataset(Dataset[dict[str, Any]]):
max_examples: int, max_examples: int,
seed: int, seed: int,
block_cache_size: int, block_cache_size: int,
defer_current_tail: bool = False,
) -> None: ) -> None:
if target_override is not None and len(cache_dirs) != 1: if target_override is not None and len(cache_dirs) != 1:
raise ValueError("--target can override only one --cache-dir") raise ValueError("--target can override only one --cache-dir")
self.sources: list[dict[str, Any]] = [] self.sources: list[dict[str, Any]] = []
self.examples: list[tuple[int, dict[str, Any]]] = [] self.examples: list[tuple[int, dict[str, Any]]] = []
self.defer_current_tail = bool(defer_current_tail)
hidden_size: int | None = None hidden_size: int | None = None
reference_contract: tuple[Any, ...] | None = None reference_contract: tuple[Any, ...] | None = None
@@ -224,11 +246,6 @@ class OmniTurnCacheDataset(Dataset[dict[str, Any]]):
def __getitem__(self, index: int) -> dict[str, Any]: def __getitem__(self, index: int) -> dict[str, Any]:
source_index, anchor = self.examples[index] source_index, anchor = self.examples[index]
source = self.sources[source_index] source = self.sources[source_index]
embeddings, positions = reconstruct_open_stream_sequence(
store=source["store"],
static=source["static"],
anchor=anchor,
)
target_index = int(anchor["target_index"]) target_index = int(anchor["target_index"])
targets = torch.from_numpy( targets = torch.from_numpy(
np.array( np.array(
@@ -243,12 +260,139 @@ class OmniTurnCacheDataset(Dataset[dict[str, Any]]):
raise RuntimeError( raise RuntimeError(
f"Unexpected future target shape {tuple(targets.shape)} at {target_index}" f"Unexpected future target shape {tuple(targets.shape)} at {target_index}"
) )
return { item: dict[str, Any] = {
"inputs_embeds": embeddings,
"position_ids": positions,
"targets": targets, "targets": targets,
"step_index": target_index, "step_index": target_index,
} }
if self.defer_current_tail:
item["source_index"] = source_index
item["anchor"] = anchor
else:
embeddings, positions = reconstruct_open_stream_sequence(
store=source["store"],
static=source["static"],
anchor=anchor,
)
item["inputs_embeds"] = embeddings
item["position_ids"] = positions
return item
def _collate_deferred_tail(batch: Sequence[dict[str, Any]]) -> dict[str, Any]:
if len(batch) != 1:
raise ValueError("--online-current-tail benchmark currently requires batch size 1")
return batch[0]
class OnlineCurrentTailMaterializer:
"""Recompute only the current partial block while history stays cached."""
def __init__(
self,
*,
dataset: OmniTurnCacheDataset,
model: torch.nn.Module,
processor: Any,
session_dir: Path,
frame_cache_size: int,
decoder: str,
decoder_gpu_id: int,
decoder_threads: int,
max_open_readers: int,
) -> None:
if len(dataset.sources) != 1:
raise ValueError("Online-tail benchmark supports exactly one cache directory")
from streaming_turn_model_directly import (
CausalSourceAV,
_easycom_directory_specs,
)
self.dataset = dataset
self.model = model
source = dataset.sources[0]
manifest = source["manifest"]
config = core.StreamConfig(
step_seconds=float(manifest["step_seconds"]),
native_block_seconds=float(manifest["native_block_seconds"]),
memory_seconds=float(manifest["memory_seconds"]),
video_fps=float(manifest["video_fps"]),
video_min_pixels=int(manifest["video_min_pixels"]),
video_max_pixels=int(manifest["video_max_pixels"]),
)
media = CausalSourceAV(
_easycom_directory_specs(session_dir),
output_fps=config.video_fps,
required_duration=float(manifest["cached_end_seconds"]),
frame_cache_size=frame_cache_size,
decoder_backend=decoder,
decoder_gpu_id=decoder_gpu_id,
decoder_threads=decoder_threads,
max_open_readers=max_open_readers,
)
self.extractor = core.OpenStreamReplayExtractor(
model, processor, config, fixed_grid_av=media
)
self.step_seconds = config.step_seconds
self.block_seconds = config.native_block_seconds
self.steps_per_block = int(source["static"]["steps_per_block"])
self.calls = 0
self.online_av_calls = 0
self.total_seconds = 0.0
self.online_av_seconds = 0.0
def reset_timing(self) -> None:
self.calls = 0
self.online_av_calls = 0
self.total_seconds = 0.0
self.online_av_seconds = 0.0
@torch.no_grad()
def __call__(self, item: dict[str, Any]) -> dict[str, Any]:
started = time.perf_counter()
source_index = int(item["source_index"])
source = self.dataset.sources[source_index]
anchor = item["anchor"]
tail_steps = int(anchor["tail_steps"])
current_piece = None
if tail_steps < self.steps_per_block:
av_started = time.perf_counter()
block_start = int(anchor["block_index"]) * self.block_seconds
interval_end = block_start + tail_steps * self.step_seconds
pieces = self.extractor._prepare_pieces(
video_path=None,
audio_path=None,
interval=(block_start, interval_end),
use_audio_in_video=True,
)
closure_embeddings = self.model.get_input_embeddings()(
pieces.closure_ids
)
current_piece = pieces_to_cache(
pieces,
closure_embeddings=closure_embeddings,
floating_dtype=torch.bfloat16,
)
self.online_av_seconds += time.perf_counter() - av_started
self.online_av_calls += 1
embeddings, positions = reconstruct_open_stream_sequence(
store=source["store"],
static=source["static"],
anchor=anchor,
current_piece=current_piece,
)
batch = collate_cached_sequences(
[
{
"inputs_embeds": embeddings,
"position_ids": positions,
"targets": item["targets"],
"step_index": item["step_index"],
}
]
)
self.total_seconds += time.perf_counter() - started
self.calls += 1
return batch
def _load_initial_head( def _load_initial_head(
@@ -350,6 +494,7 @@ def _evaluate_loss(
device: torch.device, device: torch.device,
model_dtype: torch.dtype, model_dtype: torch.dtype,
max_batches: int, max_batches: int,
materialize_batch: Any | None = None,
) -> float: ) -> float:
language_was_training = language_model.training language_was_training = language_model.training
head_was_training = head.training head_was_training = head.training
@@ -359,6 +504,8 @@ def _evaluate_loss(
for batch_index, batch in enumerate(loader): for batch_index, batch in enumerate(loader):
if max_batches > 0 and batch_index >= max_batches: if max_batches > 0 and batch_index >= max_batches:
break break
if materialize_batch is not None:
batch = materialize_batch(batch)
inputs_embeds = batch["inputs_embeds"].to( inputs_embeds = batch["inputs_embeds"].to(
device=device, dtype=model_dtype, non_blocking=True device=device, dtype=model_dtype, non_blocking=True
) )
@@ -398,6 +545,17 @@ def main(argv: Sequence[str] | None = None) -> None:
raise ValueError("--max-steps must be positive") raise ValueError("--max-steps must be positive")
if args.batch_size <= 0 or args.gradient_accumulation_steps <= 0: if args.batch_size <= 0 or args.gradient_accumulation_steps <= 0:
raise ValueError("Batch size and gradient accumulation must be positive") raise ValueError("Batch size and gradient accumulation must be positive")
if args.online_current_tail:
if args.easycom_session_dir is None:
raise ValueError(
"--easycom-session-dir is required by --online-current-tail"
)
if len(args.cache_dir) != 1:
raise ValueError("Online-tail benchmark requires exactly one --cache-dir")
if args.batch_size != 1:
raise ValueError("Online-tail benchmark currently requires --batch-size 1")
if args.num_workers != 0:
raise ValueError("Online-tail benchmark currently requires --num-workers 0")
if args.num_workers != 0: if args.num_workers != 0:
print( print(
"Warning: each worker owns an independent block-file LRU; use 0 for " "Warning: each worker owns an independent block-file LRU; use 0 for "
@@ -427,6 +585,12 @@ def main(argv: Sequence[str] | None = None) -> None:
max_examples=args.max_examples, max_examples=args.max_examples,
seed=args.seed, seed=args.seed,
block_cache_size=args.block_cache_size, block_cache_size=args.block_cache_size,
defer_current_tail=args.online_current_tail,
)
collate_fn = (
_collate_deferred_tail
if args.online_current_tail
else collate_cached_sequences
) )
generator = torch.Generator().manual_seed(args.seed) generator = torch.Generator().manual_seed(args.seed)
train_loader = DataLoader( train_loader = DataLoader(
@@ -436,7 +600,7 @@ def main(argv: Sequence[str] | None = None) -> None:
num_workers=args.num_workers, num_workers=args.num_workers,
pin_memory=True, pin_memory=True,
persistent_workers=args.num_workers > 0, persistent_workers=args.num_workers > 0,
collate_fn=collate_cached_sequences, collate_fn=collate_fn,
generator=generator, generator=generator,
) )
eval_loader = DataLoader( eval_loader = DataLoader(
@@ -445,14 +609,14 @@ def main(argv: Sequence[str] | None = None) -> None:
shuffle=False, shuffle=False,
num_workers=0, num_workers=0,
pin_memory=True, pin_memory=True,
collate_fn=collate_cached_sequences, collate_fn=collate_fn,
) )
print( print(
f"Cached training examples: {len(dataset)}, hidden_size={dataset.hidden_size}, " f"Cached training examples: {len(dataset)}, hidden_size={dataset.hidden_size}, "
f"batch={args.batch_size} x accum={args.gradient_accumulation_steps}" f"batch={args.batch_size} x accum={args.gradient_accumulation_steps}"
) )
base_model, _ = core.load_qwen_omni( base_model, processor = core.load_qwen_omni(
args.model_id, args.model_id,
dtype=args.dtype, dtype=args.dtype,
device_map=args.device, device_map=args.device,
@@ -471,11 +635,30 @@ def main(argv: Sequence[str] | None = None) -> None:
target_modules=target_modules, target_modules=target_modules,
gradient_checkpointing=args.gradient_checkpointing, gradient_checkpointing=args.gradient_checkpointing,
) )
# Cached embeddings make both modality towers unnecessary during training. materialize_batch: OnlineCurrentTailMaterializer | None = None
# Keeping only the language model materially reduces L20 memory use. if args.online_current_tail:
del base_model assert args.easycom_session_dir is not None
gc.collect() materialize_batch = OnlineCurrentTailMaterializer(
torch.cuda.empty_cache() dataset=dataset,
model=base_model,
processor=processor,
session_dir=args.easycom_session_dir,
frame_cache_size=args.frame_cache_size,
decoder=args.decoder,
decoder_gpu_id=args.decoder_gpu_id,
decoder_threads=args.decoder_threads,
max_open_readers=args.max_open_readers,
)
print(
"Cache policy benchmark: complete 2s history cached; "
"current 0.2--1.8s tail computed online"
)
else:
# All AV phases are cached, so the modality towers can be discarded.
del base_model
del processor
gc.collect()
torch.cuda.empty_cache()
model_dtype = next(language_model.parameters()).dtype model_dtype = next(language_model.parameters()).dtype
head = _load_initial_head( head = _load_initial_head(
args.head_checkpoint, args.head_checkpoint,
@@ -507,8 +690,11 @@ def main(argv: Sequence[str] | None = None) -> None:
device=device, device=device,
model_dtype=model_dtype, model_dtype=model_dtype,
max_batches=args.eval_max_batches, max_batches=args.eval_max_batches,
materialize_batch=materialize_batch,
) )
print(f"Initial cached loss: {initial_loss:.6f}") print(f"Initial cached loss: {initial_loss:.6f}")
if materialize_batch is not None:
materialize_batch.reset_timing()
language_model.train() language_model.train()
head.train() head.train()
optimizer.zero_grad(set_to_none=True) optimizer.zero_grad(set_to_none=True)
@@ -520,6 +706,7 @@ def main(argv: Sequence[str] | None = None) -> None:
unit="update", unit="update",
disable=args.no_progress, disable=args.no_progress,
) )
training_started = time.perf_counter()
for update_step in range(1, args.max_steps + 1): for update_step in range(1, args.max_steps + 1):
accumulated_loss = 0.0 accumulated_loss = 0.0
for _ in range(args.gradient_accumulation_steps): for _ in range(args.gradient_accumulation_steps):
@@ -528,6 +715,8 @@ def main(argv: Sequence[str] | None = None) -> None:
except StopIteration: except StopIteration:
iterator = iter(train_loader) iterator = iter(train_loader)
batch = next(iterator) batch = next(iterator)
if materialize_batch is not None:
batch = materialize_batch(batch)
inputs_embeds = batch["inputs_embeds"].to( inputs_embeds = batch["inputs_embeds"].to(
device=device, dtype=model_dtype, non_blocking=True device=device, dtype=model_dtype, non_blocking=True
) )
@@ -575,6 +764,15 @@ def main(argv: Sequence[str] | None = None) -> None:
flush=True, flush=True,
) )
progress.close() progress.close()
training_seconds = time.perf_counter() - training_started
online_timing = None
if materialize_batch is not None:
online_timing = {
"materialize_calls": materialize_batch.calls,
"materialize_seconds": materialize_batch.total_seconds,
"online_av_calls": materialize_batch.online_av_calls,
"online_av_seconds": materialize_batch.online_av_seconds,
}
final_loss = _evaluate_loss( final_loss = _evaluate_loss(
language_model, language_model,
@@ -583,6 +781,7 @@ def main(argv: Sequence[str] | None = None) -> None:
device=device, device=device,
model_dtype=model_dtype, model_dtype=model_dtype,
max_batches=args.eval_max_batches, max_batches=args.eval_max_batches,
materialize_batch=materialize_batch,
) )
adapter_dir = output_dir / "adapter" adapter_dir = output_dir / "adapter"
language_model.save_pretrained(adapter_dir) language_model.save_pretrained(adapter_dir)
@@ -610,6 +809,9 @@ def main(argv: Sequence[str] | None = None) -> None:
"num_examples": len(dataset), "num_examples": len(dataset),
"initial_loss": initial_loss, "initial_loss": initial_loss,
"final_loss": final_loss, "final_loss": final_loss,
"training_seconds": training_seconds,
"updates_per_second": args.max_steps / training_seconds,
"online_tail_timing": online_timing,
"loss_reduction_ratio": ( "loss_reduction_ratio": (
(initial_loss - final_loss) / initial_loss if initial_loss else None (initial_loss - final_loss) / initial_loss if initial_loss else None
), ),
@@ -625,6 +827,16 @@ def main(argv: Sequence[str] | None = None) -> None:
} }
save_json(output_dir / "train_summary.json", summary) save_json(output_dir / "train_summary.json", summary)
print(f"Final cached loss: {final_loss:.6f} (initial {initial_loss:.6f})") print(f"Final cached loss: {final_loss:.6f} (initial {initial_loss:.6f})")
print(
f"Training speed: {training_seconds:.3f}s / {args.max_steps} updates = "
f"{args.max_steps / training_seconds:.3f} update/s"
)
if online_timing is not None:
print(
"Online current-tail AV: "
f"{online_timing['online_av_seconds']:.3f}s / "
f"{online_timing['online_av_calls']} calls"
)
print(f"Saved LoRA adapter to {adapter_dir}") print(f"Saved LoRA adapter to {adapter_dir}")
print(f"Saved turn head to {output_dir / 'turn_head.pt'}") print(f"Saved turn head to {output_dir / 'turn_head.pt'}")
print(f"Peak CUDA allocated: {summary['peak_cuda_allocated_gib']:.2f} GiB") print(f"Peak CUDA allocated: {summary['peak_cuda_allocated_gib']:.2f} GiB")