refactor(benchmark): delegate model loading to tasks and support CIFAR-100

- Extract model loading logic from benchmark CLI into task-owned prepare_benchmark
- Add RetrievalEncoder class wrapping DINO with optional hash compression
- Add accelerate dependency for device management  
- Switch dataset from CIFAR-10 to CIFAR-100 with fine_label column
This commit is contained in:
2026-05-09 15:12:19 +08:00
parent 0fbcd915bd
commit ab616528b4
7 changed files with 1828 additions and 1226 deletions

View File

@@ -1,18 +1,42 @@
"""Benchmark runner for executing evaluations.""" """Benchmark runner for executing evaluations."""
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable, cast
import lancedb import lancedb
from benchmarks.datasets import HuggingFaceDataset, LocalDataset from benchmarks.datasets import HuggingFaceDataset, LocalDataset
from benchmarks.tasks import get_task from benchmarks.tasks import get_task
from configs.models import BenchmarkConfig, DatasetSourceConfig from configs.models import BenchmarkConfig, DatasetSourceConfig, ModelConfig
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
console = Console() console = Console()
def _create_task(config: BenchmarkConfig, model_config: ModelConfig | None) -> Any:
"""Create benchmark task with task-specific model settings.
Args:
config: Benchmark configuration.
model_config: Optional model configuration for task-owned loading.
Returns:
Benchmark task instance.
"""
task_kwargs: dict[str, Any] = {"top_k": config.task.top_k}
if config.task.type == "retrieval" and model_config is not None:
task_kwargs.update(
{
"dino_model": model_config.dino_model,
"compression_dim": model_config.compression_dim,
"compressor_path": model_config.compressor_path,
}
)
return get_task(config.task.type, **task_kwargs)
def create_dataset(config: DatasetSourceConfig) -> Any: def create_dataset(config: DatasetSourceConfig) -> Any:
"""Create a dataset instance from configuration. """Create a dataset instance from configuration.
@@ -130,10 +154,30 @@ def _print_benchmark_info(
console.print(table) console.print(table)
def _print_benchmark_results(results: dict[str, Any]) -> None:
"""Print benchmark results using Rich table.
Args:
results: Final benchmark metrics.
"""
table = Table(title="Benchmark Results", show_header=False)
table.add_column("Metric", style="cyan", no_wrap=True)
table.add_column("Value", style="green")
for key, value in results.items():
if isinstance(value, float):
table.add_row(key, f"{value:.4f}")
continue
table.add_row(key, str(value))
console.print(table)
def run_benchmark( def run_benchmark(
model: Any, model: Any,
processor: Any, processor: Any,
config: BenchmarkConfig, config: BenchmarkConfig,
model_config: ModelConfig | None = None,
model_name: str = "model", model_name: str = "model",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run benchmark evaluation. """Run benchmark evaluation.
@@ -148,6 +192,7 @@ def run_benchmark(
model: Feature extraction model. model: Feature extraction model.
processor: Image preprocessor. processor: Image preprocessor.
config: Benchmark configuration. config: Benchmark configuration.
model_config: Optional model configuration for task-owned loading.
model_name: Model name for table naming. model_name: Model name for table naming.
Returns: Returns:
@@ -171,6 +216,23 @@ def run_benchmark(
f"Dataset {config.dataset.path} does not have train/test splits" f"Dataset {config.dataset.path} does not have train/test splits"
) )
task = _create_task(config, model_config)
resolver = getattr(task, "prepare_benchmark", None)
if callable(resolver):
prepare_benchmark = cast(
Callable[[Any, Any, str], tuple[Any, Any, str]],
resolver,
)
model, processor, model_name = prepare_benchmark(
model,
processor,
model_name,
)
if model is None or processor is None:
raise ValueError("Benchmark task did not provide a valid model and processor")
# Infer vector dimension from a sample # Infer vector dimension from a sample
sample = train_dataset[0] sample = train_dataset[0]
sample_image = sample["img"] sample_image = sample["img"]
@@ -191,16 +253,17 @@ def run_benchmark(
f"[yellow]Table '{table_name}' already has {table_count} entries, skipping database build.[/yellow]" f"[yellow]Table '{table_name}' already has {table_count} entries, skipping database build.[/yellow]"
) )
else: else:
# Create and run benchmark task
task = get_task(config.task.type, top_k=config.task.top_k)
console.print( console.print(
f"[cyan]Building database[/cyan] with {len(train_dataset)} training samples..." f"[cyan]Building database[/cyan] with {len(train_dataset)} training samples..."
) )
task.build_database(model, processor, train_dataset, table, config.batch_size) task.build_database(model, processor, train_dataset, table, config.batch_size)
table_count = table.count_rows()
_print_benchmark_info(config, vector_dim, table_name, table_count)
# Run evaluation (results with Rich table will be printed by the task) # Run evaluation (results with Rich table will be printed by the task)
task = get_task(config.task.type, top_k=config.task.top_k)
console.print(f"[cyan]Evaluating[/cyan] on {len(test_dataset)} test samples...") console.print(f"[cyan]Evaluating[/cyan] on {len(test_dataset)} test samples...")
results = task.evaluate(model, processor, test_dataset, table, config.batch_size) results = task.evaluate(model, processor, test_dataset, table, config.batch_size)
_print_benchmark_results(results)
return results return results

View File

@@ -1,17 +1,62 @@
"""Retrieval task for benchmark evaluation (Recall@K).""" """Retrieval task for benchmark evaluation (Recall@K)."""
from typing import Any from typing import TYPE_CHECKING, Any, cast
import lancedb import lancedb
import pyarrow as pa import pyarrow as pa
import torch
import torch.nn.functional as F
from benchmarks.base import BaseBenchmarkTask from benchmarks.base import BaseBenchmarkTask
from benchmarks.tasks.registry import RegisterTask from benchmarks.tasks.registry import RegisterTask
from compressors.model_loader import get_dino_dim, load_dino_model, load_hash_compressor
from configs import cfg_manager
from rich.progress import track from rich.progress import track
from torch import nn from torch import nn
from torch.utils.data import DataLoader from torch.utils.data import DataLoader
from transformers import BitImageProcessor from transformers import BitImageProcessor
from utils.feature_extractor import extract_batch_features, infer_vector_dim from utils.feature_extractor import extract_batch_features, infer_vector_dim
if TYPE_CHECKING:
from compressors.hash_compressor import HashCompressor
class RetrievalEncoder(nn.Module):
"""Benchmark encoder for DINO and optional hash compression."""
def __init__(
self,
dino: nn.Module,
compressor: "HashCompressor | None" = None,
) -> None:
"""Initialize retrieval encoder.
Args:
dino: DINO backbone used for feature extraction.
compressor: Optional hash compressor for recall evaluation.
"""
super().__init__()
self.dino: nn.Module = dino
self.compressor: HashCompressor | None = compressor
def forward(self, inputs: Any) -> torch.Tensor:
"""Encode processor inputs into benchmark vectors.
Args:
inputs: Batched processor outputs.
Returns:
Float tensor used for LanceDB insertion and retrieval.
"""
outputs = self.dino(**inputs)
tokens = outputs.last_hidden_state
if self.compressor is None:
features = tokens.mean(dim=1)
return F.normalize(features, dim=-1)
bits = self.compressor.encode(tokens)
return bits.to(dtype=torch.float32)
def _build_eval_schema(vector_dim: int) -> pa.Schema: def _build_eval_schema(vector_dim: int) -> pa.Schema:
"""Build PyArrow schema for evaluation database table. """Build PyArrow schema for evaluation database table.
@@ -35,7 +80,7 @@ def _establish_eval_database(
processor: BitImageProcessor, processor: BitImageProcessor,
model: nn.Module, model: nn.Module,
table: lancedb.table.Table, table: lancedb.table.Table,
dataloader: DataLoader, dataloader: DataLoader[Any],
) -> None: ) -> None:
"""Extract features from training images and store them in a database table. """Extract features from training images and store them in a database table.
@@ -47,11 +92,12 @@ def _establish_eval_database(
""" """
# Extract all features using the utility function # Extract all features using the utility function
all_features = extract_batch_features(processor, model, dataloader) all_features = extract_batch_features(processor, model, dataloader)
config = cfg_manager.get()
# Store features to database # Store features to database
global_idx = 0 global_idx = 0
for batch in track(dataloader, description="Storing eval database"): for batch in track(dataloader, description="Storing eval database"):
labels = batch["label"] labels = batch[config.benchmark.dataset.label_column]
labels_list = labels.tolist() labels_list = labels.tolist()
batch_size = len(labels_list) batch_size = len(labels_list)
@@ -72,7 +118,7 @@ def _evaluate_recall(
processor: BitImageProcessor, processor: BitImageProcessor,
model: nn.Module, model: nn.Module,
table: lancedb.table.Table, table: lancedb.table.Table,
dataloader: DataLoader, dataloader: DataLoader[Any],
top_k: int, top_k: int,
) -> tuple[int, int]: ) -> tuple[int, int]:
"""Evaluate Recall@K by searching the database for each test image. """Evaluate Recall@K by searching the database for each test image.
@@ -89,13 +135,14 @@ def _evaluate_recall(
""" """
# Extract all features using the utility function # Extract all features using the utility function
all_features = extract_batch_features(processor, model, dataloader) all_features = extract_batch_features(processor, model, dataloader)
config = cfg_manager.get()
correct = 0 correct = 0
total = 0 total = 0
feature_idx = 0 feature_idx = 0
for batch in track(dataloader, description=f"Evaluating Recall@{top_k}"): for batch in track(dataloader, description=f"Evaluating Recall@{top_k}"):
labels = batch["label"] labels = batch[config.benchmark.dataset.label_column]
labels_list = labels.tolist() labels_list = labels.tolist()
for j in range(len(labels_list)): for j in range(len(labels_list)):
@@ -123,14 +170,79 @@ def _evaluate_recall(
class RetrievalTask(BaseBenchmarkTask): class RetrievalTask(BaseBenchmarkTask):
"""Retrieval evaluation task (Recall@K).""" """Retrieval evaluation task (Recall@K)."""
def __init__(self, top_k: int = 10): def __init__(
self,
top_k: int = 10,
dino_model: str = "facebook/dinov2-large",
compression_dim: int = 512,
compressor_path: str | None = None,
):
"""Initialize retrieval task. """Initialize retrieval task.
Args: Args:
top_k: Number of top results to retrieve for recall calculation. top_k: Number of top results to retrieve for recall calculation.
dino_model: DINO model name used for feature extraction.
compression_dim: Output dimension of the hash compressor.
compressor_path: Optional path to trained hash compressor weights.
""" """
super().__init__(top_k=top_k) super().__init__(top_k=top_k)
self.top_k = top_k self.top_k = top_k
self.dino_model = dino_model
self.compression_dim = compression_dim
self.compressor_path = compressor_path
self._processor: BitImageProcessor | None = None
self._model: nn.Module | None = None
self._model_name = "hash_compressor" if compressor_path else "dinov2"
def prepare_benchmark(
self,
model: Any,
processor: Any,
model_name: str = "model",
) -> tuple[nn.Module, BitImageProcessor, str]:
"""Resolve benchmark resources for this task.
Args:
model: Optional pre-built model from the caller.
processor: Optional pre-built processor from the caller.
model_name: Fallback table model name.
Returns:
Tuple of benchmark model, processor, and resolved model name.
"""
if model is not None and processor is not None:
return (
cast(nn.Module, model),
cast(BitImageProcessor, processor),
model_name,
)
self._ensure_resources_loaded()
return (
cast(nn.Module, self._model),
cast(BitImageProcessor, self._processor),
self._model_name,
)
def _ensure_resources_loaded(self) -> None:
"""Lazy-load retrieval benchmark resources."""
if self._processor is not None and self._model is not None:
return
processor, dino = load_dino_model(self.dino_model)
compressor = None
if self.compressor_path is not None:
compressor = load_hash_compressor(
input_dim=get_dino_dim(self.dino_model),
hash_bits=self.compression_dim,
compressor_path=self.compressor_path,
)
compressor.eval()
self._processor = processor
self._model = RetrievalEncoder(dino=dino, compressor=compressor)
self._model.eval()
def build_database( def build_database(
self, self,

View File

@@ -1,79 +1,26 @@
from typing import Any, Optional, cast
import typer import typer
from commands import app from commands import app
@app.command() @app.command()
def benchmark( def benchmark(
ctx: typer.Context, _ctx: typer.Context,
model_path: Optional[str] = typer.Option( model_path: str | None = typer.Option(
None, "--model", "-m", help="Path to compressor model weights" None, "--model", "-m", help="Path to compressor model weights"
), ),
): ):
import torch
import torch.nn.functional as F
from benchmarks import run_benchmark from benchmarks import run_benchmark
from configs import cfg_manager from configs import cfg_manager
from transformers import AutoImageProcessor, AutoModel, BitImageProcessor
from utils import get_device
config = cfg_manager.get() config = cfg_manager.get()
benchmark_cfg = config.benchmark benchmark_cfg = config.benchmark
device = get_device()
model_cfg = config.model
processor = cast(
BitImageProcessor,
AutoImageProcessor.from_pretrained(model_cfg.dino_model, device_map=device),
)
# Load DINO model for feature extraction
dino = AutoModel.from_pretrained(model_cfg.dino_model, device_map=device)
dino.eval()
# Optional hash compressor
compressor = None
if model_path: if model_path:
from compressors import HashCompressor config.model.compressor_path = model_path
compressor = HashCompressor( _ = run_benchmark(
input_dim=model_cfg.compression_dim, model=None,
hash_bits=model_cfg.compression_dim, processor=None,
)
compressor.load_state_dict(torch.load(model_path))
compressor.to(device)
compressor.eval()
# Create wrapper with extract_features method
class DinoFeatureExtractor:
def __init__(self, dino, compressor=None):
self.dino = dino
self.compressor = compressor
def extract_features(self, images: list) -> torch.Tensor:
inputs = processor(images, return_tensors="pt").to(device)
with torch.no_grad():
outputs = self.dino(**inputs)
features = outputs.last_hidden_state.mean(dim=1)
features = F.normalize(features, dim=-1)
return features
def encode(self, images: list) -> torch.Tensor:
if self.compressor is None:
return self.extract_features(images)
tokens = self.dino(
**processor(images, return_tensors="pt").to(device)
).last_hidden_state
_, _, bits = self.compressor(tokens)
return bits
model = DinoFeatureExtractor(dino, compressor)
run_benchmark(
model=model,
processor=processor,
config=benchmark_cfg, config=benchmark_cfg,
model_name="dinov2", model_config=config.model,
model_name="hash_compressor" if config.model.compressor_path else "dinov2",
) )

View File

@@ -24,9 +24,9 @@ dataset:
benchmark: benchmark:
dataset: dataset:
source_type: "huggingface" source_type: "huggingface"
path: "uoft-cs/cifar10" path: "uoft-cs/cifar100"
img_column: "img" img_column: "img"
label_column: "label" label_column: "fine_label"
task: task:
name: "recall_at_k" name: "recall_at_k"
type: "retrieval" type: "retrieval"

View File

@@ -3,11 +3,13 @@
from typing import Any, List, Union, cast from typing import Any, List, Union, cast
import torch import torch
from aiohttp.web import get
from PIL import Image from PIL import Image
from rich.progress import track
from torch import nn from torch import nn
from torch.utils.data import DataLoader from torch.utils.data import DataLoader
from transformers import BitImageProcessor from transformers import BitImageProcessor
from rich.progress import track from utils import get_device
def _extract_features_from_output(output: Any) -> torch.Tensor: def _extract_features_from_output(output: Any) -> torch.Tensor:
@@ -26,6 +28,7 @@ def _extract_features_from_output(output: Any) -> torch.Tensor:
return cast(torch.Tensor, output) return cast(torch.Tensor, output)
@torch.no_grad()
def infer_vector_dim( def infer_vector_dim(
processor: BitImageProcessor, processor: BitImageProcessor,
model: nn.Module, model: nn.Module,
@@ -41,12 +44,8 @@ def infer_vector_dim(
Returns: Returns:
Vector dimension. Vector dimension.
""" """
device = next(model.parameters()).device
model.eval()
with torch.no_grad():
inputs = processor(images=sample_image, return_tensors="pt") inputs = processor(images=sample_image, return_tensors="pt")
inputs.to(device) inputs.to(get_device())
output = model(inputs) output = model(inputs)
features = _extract_features_from_output(output) features = _extract_features_from_output(output)
@@ -69,11 +68,8 @@ def extract_single_image_feature(
Returns: Returns:
The extracted CLS token feature vector as a list of floats. The extracted CLS token feature vector as a list of floats.
""" """
device = next(model.parameters()).device
model.eval()
inputs = processor(images=image, return_tensors="pt") inputs = processor(images=image, return_tensors="pt")
inputs.to(device, non_blocking=True) inputs.to(get_device(), non_blocking=True)
outputs = model(inputs) outputs = model(inputs)
features = _extract_features_from_output(outputs) # [1, D] features = _extract_features_from_output(outputs) # [1, D]
@@ -98,8 +94,7 @@ def extract_batch_features(
Returns: Returns:
Tensor of shape [batch_size, feature_dim]. Tensor of shape [batch_size, feature_dim].
""" """
device = next(model.parameters()).device device = get_device()
model.eval()
# Handle DataLoader input # Handle DataLoader input
if isinstance(images, DataLoader): if isinstance(images, DataLoader):

View File

@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"accelerate>=1.13.0",
"altair>=6.0.0", "altair>=6.0.0",
"dash>=3.4.0", "dash>=3.4.0",
"dash-ag-grid>=33.3.3", "dash-ag-grid>=33.3.3",

2760
uv.lock generated

File diff suppressed because it is too large Load Diff