Files
2026-07-13 13:17:40 +08:00

395 lines
15 KiB
Python
Raw Permalink 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.
# Standard library imports
import logging
import time
from typing import Dict, Tuple, Iterator, Generator, Optional, Union
# Third-party imports
import torch
import torchvision
import pyarrow
import ray
import ray.train
from ray.data.collate_fn import ArrowBatchCollateFn, CollateFn
from concurrent.futures import ThreadPoolExecutor
from ray.data.dataset import TorchDeviceType
# Local imports
from benchmark_factory import BenchmarkFactory
from config import BenchmarkConfig, DataloaderType, ImageClassificationConfig
from dataloader_factory import BaseDataLoaderFactory
from torch_dataloader_factory import TorchDataLoaderFactory
from ray_dataloader_factory import RayDataLoaderFactory
from logger_utils import ContextLoggerAdapter
logger = ContextLoggerAdapter(logging.getLogger(__name__))
def mock_dataloader(
num_batches: int = 64, batch_size: int = 32
) -> Generator[Tuple[torch.Tensor, torch.Tensor], None, None]:
"""Generate mock image and label tensors for testing.
Args:
num_batches: Number of batches to generate
batch_size: Number of samples per batch
Yields:
Tuple of (image_tensor, label_tensor) for each batch
"""
device = ray.train.torch.get_device()
images = torch.randn(batch_size, 3, 224, 224).to(device)
labels = torch.randint(0, 1000, (batch_size,)).to(device)
for _ in range(num_batches):
yield images, labels
class ImageClassificationTorchDataLoaderFactory(TorchDataLoaderFactory):
"""Factory for creating PyTorch DataLoaders for image classification tasks.
Features:
- Distributed file reading with round-robin worker distribution
- Device transfer and error handling for data batches
- Configurable row limits per worker for controlled processing
- Performance monitoring and logging
"""
def __init__(self, benchmark_config: BenchmarkConfig):
super().__init__(benchmark_config)
def _calculate_rows_per_worker(
self, total_rows: int, num_workers: int
) -> Optional[int]:
"""Calculate rows per worker for balanced data distribution.
Args:
total_rows: Total rows to process across all workers (-1 for unlimited)
num_workers: Total workers (Ray workers × Torch workers)
Returns:
Rows per worker or None if no limit. Each worker gets at least 1 row.
"""
if total_rows < 0:
return None
if num_workers == 0:
return total_rows
return max(1, total_rows // num_workers)
def _get_worker_row_limits(self) -> Tuple[Optional[int], Optional[int]]:
"""Calculate row limits per worker for training and validation.
Returns:
Tuple of (training_rows_per_worker, validation_rows_per_worker)
"""
dataloader_config = self.get_dataloader_config()
num_workers = max(1, dataloader_config.num_torch_workers)
total_workers = self.benchmark_config.num_workers * num_workers
limit_training_rows_per_worker = self._calculate_rows_per_worker(
self.get_dataloader_config().limit_training_rows, total_workers
)
limit_validation_rows_per_worker = self._calculate_rows_per_worker(
self.get_dataloader_config().limit_validation_rows, total_workers
)
return limit_training_rows_per_worker, limit_validation_rows_per_worker
def create_batch_iterator(
self, dataloader: torch.utils.data.DataLoader, device: torch.device
) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Create iterator with device transfer and error handling.
Args:
dataloader: PyTorch DataLoader to iterate over
device: Target device for tensor transfer
Returns:
Iterator yielding (image_tensor, label_tensor) on target device
"""
worker_rank = ray.train.get_context().get_world_rank()
logger.info(f"Worker {worker_rank}: Starting batch iteration")
try:
last_batch_time = time.time()
for batch_idx, batch in enumerate(dataloader):
try:
# Monitor batch processing delays
current_time = time.time()
time_since_last_batch = current_time - last_batch_time
if time_since_last_batch > 10:
logger.warning(
f"Worker {worker_rank}: Long delay ({time_since_last_batch:.2f}s) "
f"between batches {batch_idx-1} and {batch_idx}"
)
# Process and transfer batch to device
images, labels = batch
logger.info(
f"Worker {worker_rank}: Processing batch {batch_idx} (shape: {images.shape}, "
f"time since last: {time_since_last_batch:.2f}s)"
)
# Transfer tensors to target device
transfer_start = time.time()
dataloader_config = self.get_dataloader_config()
images = images.to(
device, non_blocking=dataloader_config.torch_non_blocking
)
labels = labels.to(
device, non_blocking=dataloader_config.torch_non_blocking
)
transfer_time = time.time() - transfer_start
# Monitor device transfer performance
if transfer_time > 5:
logger.warning(
f"Worker {worker_rank}: Slow device transfer ({transfer_time:.2f}s) "
f"for batch {batch_idx}"
)
logger.info(
f"Worker {worker_rank}: Completed device transfer for batch {batch_idx} in "
f"{transfer_time:.2f}s"
)
last_batch_time = time.time()
yield images, labels
except Exception as e:
logger.error(
f"Worker {worker_rank}: Error processing batch {batch_idx}: {str(e)}",
exc_info=True,
)
raise
except Exception as e:
logger.error(
f"Worker {worker_rank}: Error in batch iterator: {str(e)}",
exc_info=True,
)
raise
class CustomArrowCollateFn(ArrowBatchCollateFn):
"""Custom collate function for converting Arrow batches to PyTorch tensors."""
_DEFAULT_NUM_WORKERS = 4
def __init__(
self,
dtypes: Optional[Union["torch.dtype", Dict[str, "torch.dtype"]]] = None,
device: Optional["TorchDeviceType"] = None,
pin_memory: bool = False,
num_workers: int = _DEFAULT_NUM_WORKERS,
):
"""Initialize the collate function.
Args:
dtypes: Optional torch dtype(s) for the tensors
device: Optional device to place tensors on
pin_memory: Whether to pin the memory of the created tensors
num_workers: Number of worker threads for parallel tensor conversion
Defaults to `_DEFAULT_NUM_WORKERS`.
"""
import torch
self.dtypes = dtypes
if isinstance(device, (str, int)):
self.device = torch.device(device)
else:
self.device = device
self.pin_memory = pin_memory
self.num_workers = num_workers
self._threadpool: Optional[ThreadPoolExecutor] = None
def __del__(self):
"""Clean up threadpool on destruction."""
if getattr(self, "_threadpool", None):
self._threadpool.shutdown(wait=False)
def __call__(self, batch: "pyarrow.Table") -> Tuple[torch.Tensor, torch.Tensor]:
"""Convert an Arrow batch to PyTorch tensors.
Args:
batch: PyArrow Table to convert
Returns:
Tuple of (image_tensor, label_tensor)
"""
from ray.data.util.torch_utils import (
arrow_batch_to_tensors,
)
if self.num_workers > 0 and self._threadpool is None:
self._threadpool = ThreadPoolExecutor(max_workers=self.num_workers)
# For GPU transfer, we can skip the combining chunked arrays. This is because
# we can convert the chunked arrays to corresponding numpy format and then to
# Tensors and transfer the corresponding list of Tensors to GPU directly.
# However, for CPU transfer, we need to combine the chunked arrays first
# before converting to numpy format and then to Tensors.
combine_chunks = self.device is not None and self.device.type == "cpu"
tensors = arrow_batch_to_tensors(
batch,
dtypes=self.dtypes,
combine_chunks=combine_chunks,
pin_memory=self.pin_memory,
threadpool=self._threadpool,
)
return tensors["image"], tensors["label"]
class ImageClassificationRayDataLoaderFactory(RayDataLoaderFactory):
"""Factory for creating Ray DataLoader for image classification tasks."""
def __init__(self, benchmark_config: BenchmarkConfig):
super().__init__(benchmark_config)
def _get_collate_fn(self) -> Optional[CollateFn]:
return CustomArrowCollateFn(
device=ray.train.torch.get_device(),
pin_memory=self.get_dataloader_config().ray_data_pin_memory,
)
class ImageClassificationMockDataLoaderFactory(BaseDataLoaderFactory):
"""Factory for creating mock dataloaders for testing.
Provides mock implementations of training and validation dataloaders
that generate random image and label tensors.
"""
def get_train_dataloader(
self,
) -> Generator[Tuple[torch.Tensor, torch.Tensor], None, None]:
"""Get mock training dataloader.
Returns:
Generator yielding (image_tensor, label_tensor) batches
"""
dataloader_config = self.get_dataloader_config()
return mock_dataloader(
num_batches=1024, batch_size=dataloader_config.train_batch_size
)
def get_val_dataloader(
self,
) -> Generator[Tuple[torch.Tensor, torch.Tensor], None, None]:
"""Get mock validation dataloader.
Returns:
Generator yielding (image_tensor, label_tensor) batches
"""
dataloader_config = self.get_dataloader_config()
return mock_dataloader(
num_batches=512, batch_size=dataloader_config.validation_batch_size
)
def get_imagenet_data_dirs(task_config: ImageClassificationConfig) -> Dict[str, str]:
"""Returns a dict with the root imagenet dataset directories for train/val/test,
corresponding to the data format and local/s3 dataset location."""
from image_classification.imagenet import IMAGENET_LOCALFS_SPLIT_DIRS
from image_classification.jpeg.imagenet import (
IMAGENET_JPEG_SPLIT_S3_DIRS,
)
from image_classification.parquet.imagenet import (
IMAGENET_PARQUET_SPLIT_S3_DIRS,
IMAGENET_PARQUET_SPLIT_1T_S3_DIRS,
)
from image_classification.s3_url.imagenet import (
IMAGENET_S3_URL_SPLIT_DIRS,
)
data_format = task_config.image_classification_data_format
if task_config.image_classification_local_dataset:
return IMAGENET_LOCALFS_SPLIT_DIRS
if data_format == ImageClassificationConfig.ImageFormat.JPEG:
return IMAGENET_JPEG_SPLIT_S3_DIRS
elif data_format == ImageClassificationConfig.ImageFormat.PARQUET:
if task_config.image_classification_use_1t_dataset:
return IMAGENET_PARQUET_SPLIT_1T_S3_DIRS
return IMAGENET_PARQUET_SPLIT_S3_DIRS
elif data_format == ImageClassificationConfig.ImageFormat.S3_URL:
return IMAGENET_S3_URL_SPLIT_DIRS
else:
raise ValueError(f"Unknown data format: {data_format}")
class ImageClassificationFactory(BenchmarkFactory):
def get_dataloader_factory(self) -> BaseDataLoaderFactory:
dataloader_type = self.benchmark_config.dataloader_type
task_config = self.benchmark_config.task_config
assert isinstance(task_config, ImageClassificationConfig)
data_dirs = get_imagenet_data_dirs(task_config)
data_format = task_config.image_classification_data_format
if dataloader_type == DataloaderType.MOCK:
return ImageClassificationMockDataLoaderFactory(self.benchmark_config)
elif dataloader_type == DataloaderType.RAY_DATA:
if data_format == ImageClassificationConfig.ImageFormat.JPEG:
from image_classification.jpeg.factory import (
ImageClassificationJpegRayDataLoaderFactory,
)
return ImageClassificationJpegRayDataLoaderFactory(
self.benchmark_config, data_dirs
)
elif data_format == ImageClassificationConfig.ImageFormat.PARQUET:
from image_classification.parquet.factory import (
ImageClassificationParquetRayDataLoaderFactory,
)
return ImageClassificationParquetRayDataLoaderFactory(
self.benchmark_config, data_dirs
)
elif data_format == ImageClassificationConfig.ImageFormat.S3_URL:
# NOTE: This format downloads images via ray data expressions,
# which is less efficient than native Ray Data S3 reading (JPEG format or Parquet format).
# Use this primarily for testing the S3 URL download pattern.
from image_classification.s3_url.factory import (
ImageClassificationS3UrlRayDataLoaderFactory,
)
return ImageClassificationS3UrlRayDataLoaderFactory(
self.benchmark_config, data_dirs
)
elif dataloader_type == DataloaderType.TORCH:
if data_format == ImageClassificationConfig.ImageFormat.JPEG:
from image_classification.jpeg.factory import (
ImageClassificationJpegTorchDataLoaderFactory,
)
return ImageClassificationJpegTorchDataLoaderFactory(
self.benchmark_config, data_dirs
)
elif data_format == ImageClassificationConfig.ImageFormat.PARQUET:
from image_classification.parquet.factory import (
ImageClassificationParquetTorchDataLoaderFactory,
)
return ImageClassificationParquetTorchDataLoaderFactory(
self.benchmark_config, data_dirs
)
raise ValueError(
f"Invalid dataloader configuration: {dataloader_type}\n"
f"{task_config}\n{self.benchmark_config.dataloader_config}"
)
def get_model(self) -> torch.nn.Module:
return torchvision.models.resnet50(weights=None)
def get_loss_fn(self) -> torch.nn.Module:
return torch.nn.CrossEntropyLoss()