chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,36 @@
from abc import ABC, abstractmethod
from config import BenchmarkConfig
from dataloader_factory import BaseDataLoaderFactory
class BenchmarkFactory(ABC):
def __init__(self, benchmark_config: BenchmarkConfig):
self.benchmark_config = benchmark_config
self.dataloader_factory = self.get_dataloader_factory()
self.dataset_creation_time = 0
@abstractmethod
def get_dataloader_factory(self) -> BaseDataLoaderFactory:
"""Create the appropriate dataloader factory for this benchmark."""
raise NotImplementedError
# TODO: These can probably be moved to the train loop runner,
# since xgboost does not require instantiating the model
# and loss function in this way.
@abstractmethod
def get_model(self):
raise NotImplementedError
@abstractmethod
def get_loss_fn(self):
raise NotImplementedError
def get_train_dataloader(self):
return self.dataloader_factory.get_train_dataloader()
def get_val_dataloader(self):
return self.dataloader_factory.get_val_dataloader()
def get_dataloader_metrics(self):
return self.dataloader_factory.get_metrics()
@@ -0,0 +1,7 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
flags:
enable_ray_container_cgroup_isolation: false
head_node:
instance_type: g4dn.8xlarge
@@ -0,0 +1,7 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
flags:
enable_ray_container_cgroup_isolation: false
head_node:
instance_type: g4dn.12xlarge
@@ -0,0 +1,10 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
head_node:
instance_type: m5.4xlarge
worker_nodes:
- instance_type: g4dn.12xlarge
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
@@ -0,0 +1,22 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
head_node:
instance_type: m5.4xlarge
worker_nodes:
- name: worker_node_gpu
instance_type: g4dn.12xlarge
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
# New SDK's `resources` is a full override (not a merge), so we must
# include `GPU: 4` explicitly to preserve the g4dn.12xlarge's natural
# GPU count. CPU: 0 keeps this group reserved for GPU workloads only.
resources:
CPU: 0
GPU: 4
- name: worker_node_cpu
instance_type: m5.4xlarge
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
@@ -0,0 +1,12 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
zones:
- us-west1-b
head_node:
instance_type: n1-standard-16
worker_nodes:
- instance_type: n1-standard-64-nvidia-tesla-t4-4
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
@@ -0,0 +1,10 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
head_node:
instance_type: m5.4xlarge
worker_nodes:
- instance_type: g4dn.12xlarge
min_nodes: 8
max_nodes: 8
market_type: ON_DEMAND
@@ -0,0 +1,10 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
head_node:
instance_type: m5.4xlarge
worker_nodes:
- instance_type: p4d.24xlarge
min_nodes: 1
max_nodes: 1
market_type: ON_DEMAND
@@ -0,0 +1,16 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
head_node:
instance_type: m5.4xlarge
worker_nodes:
- instance_type: m5.12xlarge
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
# New SDK's `resources` is a full override (not a merge), so we must
# include `CPU: 48` explicitly to preserve the m5.12xlarge's natural
# vCPU count alongside the custom MOCK_GPU resource.
resources:
CPU: 48
MOCK_GPU: 4
@@ -0,0 +1,16 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
head_node:
instance_type: m5.4xlarge
worker_nodes:
- name: gpu-worker-node
instance_type: g4dn.12xlarge
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
- name: cpu-worker-node
instance_type: r6i.4xlarge
min_nodes: 0
max_nodes: 4
market_type: ON_DEMAND
@@ -0,0 +1,16 @@
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
head_node:
instance_type: m5.4xlarge
worker_nodes:
- name: gpu-worker-node
instance_type: g4dn.12xlarge
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
- name: cpu-worker-node
instance_type: r6i.4xlarge
min_nodes: 4
max_nodes: 4
market_type: ON_DEMAND
+181
View File
@@ -0,0 +1,181 @@
import argparse
import enum
from typing import ClassVar
from pydantic import BaseModel, Field
class DataloaderType(enum.Enum):
RAY_DATA = "ray_data"
MOCK = "mock"
TORCH = "torch"
class DataLoaderConfig(BaseModel):
train_batch_size: int = 32
limit_training_rows: int = 1000000 # Use -1 for unlimited
validation_batch_size: int = 256
limit_validation_rows: int = 50000 # Use -1 for unlimited
class TaskConfig(BaseModel):
TASK_NAME: ClassVar[str] = "base"
class ImageClassificationConfig(TaskConfig):
TASK_NAME: ClassVar[str] = "image_classification"
class ImageFormat(enum.Enum):
JPEG = "jpeg"
PARQUET = "parquet"
S3_URL = "s3_url"
image_classification_local_dataset: bool = False
image_classification_data_format: ImageFormat = ImageFormat.PARQUET
# When True and data_format=PARQUET, read from the larger
# IMAGENET_PARQUET_SPLIT_1T_S3_ROOT dataset instead of the default
# parquet_split root. Used by the slow-consumer benchmarks to sustain
# backpressure.
image_classification_use_1t_dataset: bool = False
class RecsysConfig(TaskConfig):
TASK_NAME: ClassVar[str] = "recsys"
class RayDataConfig(DataLoaderConfig):
# NOTE: Optional[int] doesn't play well with argparse.
local_buffer_shuffle_size: int = -1
enable_operator_progress_bars: bool = True
ray_data_prefetch_batches: int = 4
ray_data_override_num_blocks: int = -1
locality_with_output: bool = False
actor_locality_enabled: bool = True
enable_shard_locality: bool = True
preserve_order: bool = False
ray_data_pin_memory: bool = False
class TorchConfig(DataLoaderConfig):
num_torch_workers: int = 8
torch_dataloader_timeout_seconds: int = 300
torch_pin_memory: bool = True
torch_non_blocking: bool = True
torch_prefetch_factor: int = -1
class BenchmarkConfig(BaseModel):
# ScalingConfig
num_workers: int = 1
# Elastic scaling range. If both are set > 0, use (min_workers, max_workers).
min_workers: int = 0
max_workers: int = 0
# Run CPU training where train workers request a `MOCK_GPU` resource instead.
mock_gpu: bool = False
# FailureConfig
max_failures: int = 0
task: str = "image_classification"
task_config: TaskConfig = Field(
default_factory=lambda: TaskConfig(),
)
# Data
dataloader_type: DataloaderType = DataloaderType.RAY_DATA
dataloader_config: DataLoaderConfig = Field(
default_factory=lambda: DataLoaderConfig(),
)
# Training
num_epochs: int = 1
skip_train_step: bool = False
# Simulates a slow training consumer by sleeping for this many seconds
# after each training step. Used to benchmark dataloader behavior under
# consumer back-pressure. 0 disables the sleep.
train_step_sleep_s: float = 0.0
# Maximum number of training batches per worker per epoch. When reached,
# the epoch ends early regardless of dataset size. -1 disables the cap.
# Used with slow-consumer benchmarks to bound wall-clock without
# truncating the data source.
max_train_batches: int = -1
# Checkpointing
checkpoint_every_n_steps: int = -1
# Validation
validate_every_n_steps: int = -1
skip_validation_step: bool = False
skip_validation_at_epoch_end: bool = False
# Logging
log_metrics_every_n_steps: int = 512
def _is_pydantic_model(field_type) -> bool:
"""Check if a type is a subclass of Pydantic's BaseModel."""
return isinstance(field_type, type) and issubclass(field_type, BaseModel)
def _str_to_bool(value: str) -> bool:
"""Convert a string to a boolean value."""
if value.lower() == "true":
return True
elif value.lower() == "false":
return False
raise argparse.ArgumentTypeError(f"'True' or 'False' expected, got '{value}'")
def _add_field_to_parser(parser: argparse.ArgumentParser, field: str, field_info):
field_type = field_info.annotation
if field_type is bool:
parser.add_argument(f"--{field}", type=_str_to_bool, default=field_info.default)
else:
parser.add_argument(f"--{field}", type=field_type, default=field_info.default)
def cli_to_config(benchmark_config_cls=BenchmarkConfig) -> BenchmarkConfig:
parser = argparse.ArgumentParser()
nested_fields = []
for field, field_info in benchmark_config_cls.model_fields.items():
# Skip nested configs for now
if _is_pydantic_model(field_info.annotation):
nested_fields.append(field)
continue
_add_field_to_parser(parser, field, field_info)
top_level_args, _ = parser.parse_known_args()
# Handle nested configs that depend on top-level args
nested_configs = {}
for nested_field in nested_fields:
nested_parser = argparse.ArgumentParser()
nested_config_cls = benchmark_config_cls.model_fields[nested_field].annotation
if nested_config_cls == DataLoaderConfig:
if top_level_args.dataloader_type == DataloaderType.RAY_DATA:
nested_config_cls = RayDataConfig
elif top_level_args.dataloader_type == DataloaderType.TORCH:
nested_config_cls = TorchConfig
if nested_config_cls == TaskConfig:
if top_level_args.task == ImageClassificationConfig.TASK_NAME:
nested_config_cls = ImageClassificationConfig
elif top_level_args.task == RecsysConfig.TASK_NAME:
nested_config_cls = RecsysConfig
for field, field_info in nested_config_cls.model_fields.items():
_add_field_to_parser(nested_parser, field, field_info)
args, _ = nested_parser.parse_known_args()
nested_configs[nested_field] = nested_config_cls(**vars(args))
return benchmark_config_cls(**vars(top_level_args), **nested_configs)
if __name__ == "__main__":
config = cli_to_config()
print(config)
@@ -0,0 +1,7 @@
"""Constants shared across the benchmarks."""
class DatasetKey:
TRAIN = "train"
VALID = "val"
TEST = "test"
@@ -0,0 +1,31 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, Iterator, Tuple
import logging
import torch
from config import BenchmarkConfig, DataLoaderConfig
logger = logging.getLogger(__name__)
class BaseDataLoaderFactory(ABC):
"""Base class for creating and managing dataloaders."""
def __init__(self, benchmark_config: BenchmarkConfig):
self.benchmark_config = benchmark_config
def get_dataloader_config(self) -> DataLoaderConfig:
return self.benchmark_config.dataloader_config
@abstractmethod
def get_train_dataloader(self) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
pass
@abstractmethod
def get_val_dataloader(self) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
pass
def get_metrics(self) -> Dict[str, Any]:
"""Return metrics about dataloader performance."""
return {}
@@ -0,0 +1 @@
"""Ray Train benchmark elastic test helpers."""
@@ -0,0 +1,105 @@
import json
import os
import pprint
import time
import ray
import ray.train
from ray._private.test_utils import safe_write_to_results_json
from ray.train.torch import TorchTrainer
from ray.train.v2._internal.util import date_str
from config import cli_to_config
from image_classification.factory import ImageClassificationFactory
from elastic_training.resource_schedule import (
MockResourceAvailabilityUpdater,
ResourceAvailabilityEvent,
generate_schedule,
)
from train_benchmark import (
METRICS_OUTPUT_PATH,
get_datasets_and_data_config,
train_fn_per_worker,
)
def main():
config = cli_to_config()
print("\nBenchmark config:\n" + pprint.pformat(config.__dict__, indent=2))
factory = ImageClassificationFactory(config)
# Resolve num_workers based on min_workers and max_workers.
if config.min_workers and config.max_workers:
num_workers = (config.min_workers, config.max_workers)
else:
num_workers = config.num_workers
updater_actor = ray.remote(num_cpus=0)(MockResourceAvailabilityUpdater).remote(
resource_key="GPU"
)
ray.get(updater_actor.__ray_ready__.remote())
interval_s = 60 * 5
schedule = generate_schedule(
resource_availability_options=[4, 8, 16, 32],
duration_s=60 * 60,
interval_s=interval_s,
seed=777777,
)
# Make sure the run can finish at the end of the schedule.
schedule.append(
ResourceAvailabilityEvent(
time_s=schedule[-1].time_s + interval_s, resource_units=32
)
)
execute_schedule_fut = updater_actor.execute_schedule.remote(schedule)
datasets, data_config = get_datasets_and_data_config(factory)
start_time = time.perf_counter()
trainer = TorchTrainer(
train_loop_per_worker=train_fn_per_worker,
train_loop_config={"factory": factory},
scaling_config=ray.train.ScalingConfig(num_workers=num_workers, use_gpu=True),
run_config=ray.train.RunConfig(
storage_path=f"{os.environ['ANYSCALE_ARTIFACT_STORAGE']}/train_benchmark/",
name=f"{config.task}-{date_str(include_ms=True)}",
failure_config=ray.train.FailureConfig(max_failures=len(schedule)),
),
datasets=datasets,
dataset_config=data_config,
)
trainer.fit()
end_time = time.perf_counter()
e2e_time = end_time - start_time
with open(METRICS_OUTPUT_PATH, "r") as f:
metrics = json.load(f)
# Includes recovery time across resource updates.
total_rows_processed = metrics["train/rows_processed-total"]
metrics["e2e_throughput"] = total_rows_processed / e2e_time
metrics["e2e_time"] = e2e_time
safe_write_to_results_json(metrics)
final_metrics_str = (
f"\nTotal training time: {e2e_time} seconds\n"
+ "Final metrics:\n"
+ "-" * 80
+ "\n"
+ pprint.pformat(metrics)
+ "\n"
+ "-" * 80
)
print(final_metrics_str)
ray.get(execute_schedule_fut)
ray.get(updater_actor.shutdown.remote())
if __name__ == "__main__":
# Workers need to access the working directory module.
ray.init(runtime_env={"working_dir": os.path.dirname(os.path.dirname(__file__))})
main()
@@ -0,0 +1,208 @@
from dataclasses import dataclass
from enum import Enum
import logging
import random
import time
from typing import List
import uuid
import psutil
import ray
from ray.data._internal.cluster_autoscaler import (
ResourceRequestPriority,
get_or_create_autoscaling_coordinator,
)
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
from ray.util.state import list_actors
logger = logging.getLogger(__name__)
@ray.remote(num_cpus=0)
def kill_process(pid):
proc = psutil.Process(pid)
proc.kill()
class MockResourceRequestPriority(Enum):
OVERRIDE = ResourceRequestPriority.HIGH.value + 1
@dataclass
class ResourceAvailabilityEvent:
time_s: int
resource_units: int
class ResourceAvailabilityUpdater:
def __init__(self, starting_resource_units: int = 0, resource_key: str = "GPU"):
self._starting_resource_units = starting_resource_units
self._resource_key = resource_key
def execute_schedule(self, schedule: List[ResourceAvailabilityEvent]):
pass
def shutdown(self):
pass
class MockResourceAvailabilityUpdater(ResourceAvailabilityUpdater):
def __init__(self, starting_resource_units: int = 0, resource_key: str = "GPU"):
super().__init__(starting_resource_units, resource_key)
self._coord = get_or_create_autoscaling_coordinator()
self._clear_all_requests()
logging.basicConfig(level=logging.INFO)
logger.info(
"Initializing resource availability: '%s': %s",
resource_key,
starting_resource_units,
)
self._total_resource_units = int(ray.cluster_resources()[resource_key])
self._dummy_requester_ids = [
self._get_requester_id()
for _ in range(self._total_resource_units - starting_resource_units)
]
self._request(self._dummy_requester_ids)
def _request(self, requester_ids):
futs = []
for requester_id in requester_ids:
fut = self._coord.request_resources.remote(
requester_id=requester_id,
resources=[{self._resource_key: 1.0}],
expire_after_s=10000,
priority=MockResourceRequestPriority.OVERRIDE,
)
futs.append(fut)
ray.get(futs)
def _cancel(self, requester_ids):
futs = []
for requester_id in requester_ids:
fut = self._coord.cancel_request.remote(requester_id=requester_id)
futs.append(fut)
ray.get(futs)
def _clear_all_requests(self):
def clear_all_requests(coord_self):
coord_self._ongoing_reqs = {}
ray.get(self._coord.__ray_call__.remote(clear_all_requests))
def _get_requester_id(self):
return f"dummy_{uuid.uuid4().hex[:6]}"
def _kill_random_train_worker(self):
actors = list_actors(
filters=[("class_name", "=", "RayTrainWorker"), ("state", "=", "ALIVE")]
)
if not actors:
return
actor_to_kill = random.choice(actors)
logger.info("Killing random train worker: %s", actor_to_kill)
strategy = NodeAffinitySchedulingStrategy(
node_id=actor_to_kill.node_id, soft=False
)
ray.get(
kill_process.options(scheduling_strategy=strategy).remote(actor_to_kill.pid)
)
def execute_schedule(self, schedule: List[ResourceAvailabilityEvent]):
schedule_str = " -> ".join(
f"({event.time_s:.0f}s, {self._resource_key}: {event.resource_units})"
for event in schedule
)
logger.info("Executing availability schedule: %s", schedule_str)
start_time = time.time()
for event in schedule:
curr_time_s = time.time() - start_time
time.sleep(max(0, event.time_s - curr_time_s))
logger.info("Executing scheduled event: %s", event)
curr_withheld = len(self._dummy_requester_ids)
curr_available = self._total_resource_units - curr_withheld
if curr_available == event.resource_units:
logger.info(
"No change in availability: %s -> %s",
curr_available,
event.resource_units,
)
continue
if curr_available > event.resource_units:
num_units_to_withhold = curr_available - event.resource_units
new_requesters = [
self._get_requester_id() for _ in range(num_units_to_withhold)
]
logger.info(
"Reducing availability from %s to %s",
curr_available,
event.resource_units,
)
# If reducing resources, kill a worker process to trigger recovery.
self._kill_random_train_worker()
self._request(new_requesters)
self._dummy_requester_ids += new_requesters
else:
num_to_cancel = event.resource_units - curr_available
self._dummy_requester_ids, ids_to_cancel = (
self._dummy_requester_ids[num_to_cancel:],
self._dummy_requester_ids[:num_to_cancel],
)
logger.info(
"Increasing availability from %s to %s",
curr_available,
event.resource_units,
)
self._cancel(ids_to_cancel)
def shutdown(self):
self._cancel(self._dummy_requester_ids)
def generate_schedule(
resource_availability_options: list,
duration_s: int = 60,
interval_s: int = 5,
seed: int = 42,
) -> List[ResourceAvailabilityEvent]:
random.seed(seed)
num_updates = duration_s // interval_s
curr_idx = random.choice(range(len(resource_availability_options)))
schedule = [
ResourceAvailabilityEvent(
time_s=0, resource_units=resource_availability_options[curr_idx]
)
]
for i in range(1, num_updates):
# Weights are chosen to bias schedules towards the max workers.
weights = None
if curr_idx == 0:
choices = [0, 1]
elif curr_idx == len(resource_availability_options) - 1:
choices = [-1, 0]
weights = [20, 80]
else:
choices = [-1, 0, 1]
weights = [25, 25, 50]
random_update = random.choices(choices, weights=weights)[0]
curr_idx += random_update
schedule.append(
ResourceAvailabilityEvent(
time_s=i * interval_s,
resource_units=resource_availability_options[curr_idx],
)
)
return schedule
@@ -0,0 +1,394 @@
# 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()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
#!/bin/bash
set -e # Exit on any error
DATA_DIR="/mnt/local_storage/imagenet"
ZIP_NAME="imagenet-64k.zip"
ZIP_URL="s3://anyscale-imagenet/ILSVRC/Data/CLS-LOC/$ZIP_NAME"
ZIP_PATH="$DATA_DIR/$ZIP_NAME"
TRAIN_DIR="$DATA_DIR/train"
if [ ! -d "$TRAIN_DIR" ]; then
echo "Downloading and extracting ImageNet subset to $DATA_DIR..."
mkdir -p "$DATA_DIR"
pushd "$DATA_DIR" || exit
echo "Fetching $ZIP_URL..."
aws s3 cp "$ZIP_URL" "$ZIP_PATH"
echo "Unzipping..."
unzip -q "$ZIP_NAME"
rm "$ZIP_NAME"
popd || exit
else
echo "Dataset already exists at $TRAIN_DIR. Skipping download and unzip."
fi
echo "Duplicating images in-place..."
python3 <<EOF
import shutil
from pathlib import Path
from tqdm import tqdm
dataset_dir = Path("$TRAIN_DIR")
for class_dir in tqdm(sorted(dataset_dir.iterdir()), desc="Processing classes"):
if not class_dir.is_dir():
continue
# Skip if already duplicated
if any(class_dir.glob("*_copy1.JPEG")):
print(f"Skipping {class_dir.name} (already duplicated)")
continue
for img_path in class_dir.glob("*.JPEG"):
for i in range(1, 8):
copy_name = img_path.stem + f"_copy{i}" + img_path.suffix
copy_path = class_dir / copy_name
shutil.copy2(img_path, copy_path)
EOF
echo "Image duplication complete."
@@ -0,0 +1,214 @@
# Standard library imports
import logging
from typing import Dict
# Third-party imports
import torchvision
from torch.utils.data import IterableDataset
import pyarrow.fs
# Ray imports
import ray.train
from ray.data.datasource.partitioning import Partitioning
# Local imports
from constants import DatasetKey
from config import BenchmarkConfig
from image_classification.factory import (
ImageClassificationRayDataLoaderFactory,
ImageClassificationTorchDataLoaderFactory,
)
from image_classification.imagenet import get_transform
from s3_reader import AWS_REGION
from .imagenet import get_preprocess_map_fn
from .jpeg_iterable_dataset import S3JpegImageIterableDataset
from s3_jpeg_reader import S3JpegReader
from logger_utils import ContextLoggerAdapter
logger = ContextLoggerAdapter(logging.getLogger(__name__))
class ImageClassificationJpegRayDataLoaderFactory(
ImageClassificationRayDataLoaderFactory
):
"""Factory for creating Ray DataLoader for JPEG image classification.
Extends ImageClassificationRayDataLoaderFactory to provide:
1. S3 filesystem configuration with boto credentials
2. Ray dataset creation with partitioning by class
3. Resource allocation for concurrent validation
4. Image preprocessing with optional random transforms
"""
def __init__(self, benchmark_config: BenchmarkConfig, dataset_dirs: Dict[str, str]):
super().__init__(benchmark_config)
self._dataset_dirs = dataset_dirs
def get_s3fs_with_boto_creds(
self, connection_timeout: int = 60, request_timeout: int = 60
) -> pyarrow.fs.S3FileSystem:
"""Create S3 filesystem with boto credentials.
Args:
connection_timeout: Timeout for establishing connection in seconds
request_timeout: Timeout for requests in seconds
Returns:
Configured S3FileSystem instance with boto credentials
"""
import boto3
credentials = boto3.Session().get_credentials()
s3fs = pyarrow.fs.S3FileSystem(
access_key=credentials.access_key,
secret_key=credentials.secret_key,
session_token=credentials.token,
region=AWS_REGION,
connect_timeout=connection_timeout,
request_timeout=request_timeout,
)
return s3fs
def get_ray_datasets(self) -> Dict[str, ray.data.Dataset]:
"""Get Ray datasets for training and validation.
Creates training and validation datasets with:
1. Partitioning by class for efficient data loading
2. Image preprocessing with optional random transforms
3. Resource allocation for concurrent validation
4. Row limits based on benchmark configuration
Returns:
Dictionary containing:
- "train": Training dataset with random transforms
- "val": Validation dataset without transforms
"""
train_dir = self._dataset_dirs[DatasetKey.TRAIN]
# TODO: The validation dataset directory is not partitioned by class.
val_dir = train_dir
filesystem = (
self.get_s3fs_with_boto_creds() if train_dir.startswith("s3://") else None
)
# Create training dataset with class-based partitioning
train_partitioning = Partitioning(
"dir", base_dir=train_dir, field_names=["class"]
)
train_ds = ray.data.read_images(
train_dir,
mode="RGB",
include_paths=False,
partitioning=train_partitioning,
filesystem=filesystem,
).map(get_preprocess_map_fn(random_transforms=True))
if self.get_dataloader_config().limit_training_rows > 0:
train_ds = train_ds.limit(self.get_dataloader_config().limit_training_rows)
# Create validation dataset with same partitioning
val_partitioning = Partitioning("dir", base_dir=val_dir, field_names=["class"])
val_ds = ray.data.read_images(
val_dir,
mode="RGB",
include_paths=False,
partitioning=val_partitioning,
filesystem=filesystem,
).map(get_preprocess_map_fn(random_transforms=False))
if self.get_dataloader_config().limit_validation_rows > 0:
val_ds = val_ds.limit(self.get_dataloader_config().limit_validation_rows)
return {
DatasetKey.TRAIN: train_ds,
DatasetKey.VALID: val_ds,
}
class ImageClassificationJpegTorchDataLoaderFactory(
ImageClassificationTorchDataLoaderFactory, S3JpegReader
):
"""Factory for creating PyTorch DataLoaders for JPEG image classification.
Features:
- S3-based JPEG file reading with round-robin worker distribution
- Device transfer and error handling for data batches
- Row limits per worker for controlled processing
- Dataset caching for efficiency
"""
def __init__(self, benchmark_config: BenchmarkConfig, data_dirs: Dict[str, str]):
super().__init__(benchmark_config)
S3JpegReader.__init__(self) # Initialize S3JpegReader to set up _s3_client
self._data_dirs = data_dirs
self._cached_datasets = None
def get_iterable_datasets(self) -> Dict[str, IterableDataset]:
"""Get train and validation datasets with worker-specific configurations.
Returns:
Dictionary containing:
- "train": Training dataset with random transforms
- "val": Validation dataset without transforms
"""
if self._cached_datasets is not None:
return self._cached_datasets
if self._data_dirs[DatasetKey.TRAIN].startswith("s3://"):
return self._get_iterable_datasets_s3()
else:
return self._get_iterable_datasets_local()
def _get_iterable_datasets_local(self) -> Dict[str, IterableDataset]:
"""Get train and validation datasets from local filesystem."""
train_dir = self._data_dirs[DatasetKey.TRAIN]
val_dir = self._data_dirs[DatasetKey.VALID]
train_dataset = torchvision.datasets.ImageFolder(
root=train_dir,
transform=get_transform(to_torch_tensor=True, random_transforms=True),
)
val_dataset = torchvision.datasets.ImageFolder(
root=val_dir,
transform=get_transform(to_torch_tensor=True, random_transforms=False),
)
return {
DatasetKey.TRAIN: train_dataset,
DatasetKey.VALID: val_dataset,
}
def _get_iterable_datasets_s3(self) -> Dict[str, IterableDataset]:
"""Get train and validation datasets from S3."""
train_dir = self._data_dirs[DatasetKey.TRAIN]
# Get row limits for workers and total processing
(
limit_training_rows_per_worker,
limit_validation_rows_per_worker,
) = self._get_worker_row_limits()
# Get file URLs for training and validation
train_file_urls = val_file_urls = self._get_file_urls(train_dir)
train_ds = S3JpegImageIterableDataset(
file_urls=train_file_urls,
random_transforms=True,
limit_rows_per_worker=limit_training_rows_per_worker,
)
# TODO: IMAGENET_JPEG_SPLIT_S3_DIRS["val"] does not have the label
# partitioning like "train" does. So we use "train" for validation.
val_ds = S3JpegImageIterableDataset(
file_urls=val_file_urls,
random_transforms=False,
limit_rows_per_worker=limit_validation_rows_per_worker,
)
self._cached_datasets = {
DatasetKey.TRAIN: train_ds,
DatasetKey.VALID: val_ds,
}
return self._cached_datasets
@@ -0,0 +1,57 @@
import torch
import numpy as np
from typing import Dict, Union, Callable
from PIL import Image
from constants import DatasetKey
from image_classification.imagenet import (
get_transform,
IMAGENET_WNID_TO_ID,
)
IMAGENET_JPEG_SPLIT_S3_ROOT = "s3://anyscale-imagenet/ILSVRC/Data/CLS-LOC"
IMAGENET_JPEG_SPLIT_S3_DIRS = {
DatasetKey.TRAIN: f"{IMAGENET_JPEG_SPLIT_S3_ROOT}/train",
DatasetKey.VALID: f"{IMAGENET_JPEG_SPLIT_S3_ROOT}/val",
DatasetKey.TEST: f"{IMAGENET_JPEG_SPLIT_S3_ROOT}/test",
}
def get_preprocess_map_fn(
random_transforms: bool = True,
) -> Callable[[Dict[str, Union[np.ndarray, str]]], Dict[str, torch.Tensor]]:
"""Get a map function that transforms a row to the format expected by the training loop.
Args:
random_transforms: Whether to use random transforms for training
Returns:
A function that takes a row dict with:
- "image": numpy array in HWC format
- "class": WNID string
The output is a dict with "image" and "label" keys.
"""
crop_resize_transform = get_transform(
to_torch_tensor=True, random_transforms=random_transforms
)
def map_fn(row: Dict[str, Union[np.ndarray, str]]) -> Dict[str, torch.Tensor]:
"""Process a single row into the expected format.
Args:
row: Dict containing "image" and "class" keys
Returns:
Dict with "image" and "label" keys
"""
# Convert NumPy HWC image to PIL
image_pil = Image.fromarray(row["image"])
# Apply transform (includes ToTensor + Normalize)
image = crop_resize_transform(image_pil)
# Convert label
label = IMAGENET_WNID_TO_ID[row["class"]]
return {"image": image, "label": label}
return map_fn
@@ -0,0 +1,266 @@
# Standard library imports
import io
import logging
import time
from typing import Iterator, List, Optional, Tuple, Callable
# Third-party imports
import numpy as np
from PIL import Image as PILImage
import torch
from torch.utils.data import IterableDataset
# Ray imports
import ray
import ray.train
# Local imports
from s3_jpeg_reader import S3JpegReader
from .imagenet import get_preprocess_map_fn
from logger_utils import ContextLoggerAdapter
logger = ContextLoggerAdapter(logging.getLogger(__name__))
class S3JpegImageIterableDataset(S3JpegReader, IterableDataset):
"""An iterable dataset that loads images from S3-stored JPEG files.
Features:
- Direct image fetching from S3
- Optional random transforms for training
- Row limits per worker for controlled processing
- Progress logging and performance metrics
"""
# Constants
LOG_FREQUENCY = 1000 # Log progress every 1000 rows
def __init__(
self,
file_urls: List[str],
random_transforms: bool = True,
limit_rows_per_worker: Optional[int] = None,
):
"""Initialize the dataset.
Args:
file_urls: List of S3 URLs to load
random_transforms: Whether to use random transforms for training
limit_rows_per_worker: Maximum number of rows to process per worker
"""
super().__init__()
self.file_urls = file_urls
self.limit_rows_per_worker = limit_rows_per_worker
self.random_transforms = random_transforms
worker_rank = ray.train.get_context().get_world_rank()
logger.info(
f"Worker {worker_rank}: Initialized with {len(file_urls)} files"
f"{f' (limit: {limit_rows_per_worker} rows)' if limit_rows_per_worker else ''}"
)
def _get_worker_info(self) -> Tuple[int, int]:
"""Get current worker information.
Returns:
Tuple of (worker_id, num_workers)
"""
worker_info = torch.utils.data.get_worker_info()
worker_id = worker_info.id if worker_info else 0
num_workers = worker_info.num_workers if worker_info else 1
return worker_id, num_workers
def _has_reached_row_limit(self, rows_processed: int) -> bool:
"""Check if we've reached the row limit per worker.
Args:
rows_processed: Number of rows processed so far
Returns:
True if we've reached the limit, False otherwise
"""
return (
self.limit_rows_per_worker is not None
and rows_processed >= self.limit_rows_per_worker
)
def _log_progress(
self, worker_id: int, rows_processed: int, last_log_time: float
) -> float:
"""Log processing progress and return updated last_log_time.
Args:
worker_id: ID of the current worker
rows_processed: Number of rows processed so far
last_log_time: Time of last progress log
Returns:
Updated last_log_time
"""
if rows_processed % self.LOG_FREQUENCY == 0:
current_time = time.time()
elapsed_time = current_time - last_log_time
rows_per_second = (
self.LOG_FREQUENCY / elapsed_time if elapsed_time > 0 else 0
)
logger.info(
f"Worker {worker_id}: Processed {rows_processed} rows "
f"({rows_per_second:.2f} rows/sec)"
)
return current_time
return last_log_time
def _fetch_image(self, file_url: str) -> Tuple[str, Optional[PILImage.Image]]:
"""Fetch a single image from S3.
Args:
file_url: S3 URL to fetch (e.g., "s3://bucket/path/to/image.jpg")
Returns:
Tuple of (file_url, PIL Image) where PIL Image is None if fetch failed
"""
worker_id, _ = self._get_worker_info()
try:
bucket = file_url.replace("s3://", "").split("/")[0]
key = "/".join(file_url.replace("s3://", "").split("/")[1:])
response = self.s3_client.get_object(Bucket=bucket, Key=key)
image_data = response["Body"].read()
image = PILImage.open(io.BytesIO(image_data))
return file_url, image
except Exception as e:
logger.error(
f"Worker {worker_id}: Error fetching image from {file_url}: {str(e)}",
exc_info=True,
)
return file_url, None
def _process_image(
self,
image: PILImage.Image,
file_url: str,
preprocess_fn: Callable,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Process a single image and convert to tensors.
Args:
image: PIL Image to process
file_url: URL of the image file
preprocess_fn: Preprocessing function to apply
Returns:
Tuple of (image_tensor, label_tensor)
"""
try:
# Convert to RGB and numpy array
if image.mode != "RGB":
image = image.convert("RGB")
image_array = np.array(image, dtype=np.uint8)
# Ensure HWC format (Height x Width x Channels)
if len(image_array.shape) == 2: # Grayscale
image_array = np.stack([image_array] * 3, axis=-1)
elif len(image_array.shape) == 3 and image_array.shape[0] == 3: # CHW
image_array = np.transpose(image_array, (1, 2, 0))
wnid = file_url.split("/")[-2] # Extract WNID from path
processed = preprocess_fn({"image": image_array, "class": wnid})
image = torch.as_tensor(processed["image"], dtype=torch.float32)
label = torch.as_tensor(processed["label"], dtype=torch.int64)
return image, label
except Exception as e:
logger.error(
f"Error processing {file_url}: {str(e)}",
exc_info=True,
)
raise
def _process_files(
self, files_to_read: List[str], preprocess_fn: Callable, worker_id: int
) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Process multiple files and yield processed rows.
Args:
files_to_read: List of file URLs to process
preprocess_fn: Preprocessing function to apply
worker_id: ID of the current worker
Yields:
Tuple of (image_tensor, label_tensor)
"""
rows_processed = 0
last_log_time = time.time()
total_start_time = time.time()
for file_url in files_to_read:
if self._has_reached_row_limit(rows_processed):
logger.info(f"Worker {worker_id}: Reached row limit: {rows_processed}")
break
file_url, image = self._fetch_image(file_url)
if image is None:
continue
try:
image, label = self._process_image(
image,
file_url,
preprocess_fn,
)
rows_processed += 1
last_log_time = self._log_progress(
worker_id, rows_processed, last_log_time
)
yield image, label
except Exception:
continue
# Log final statistics
total_time = time.time() - total_start_time
logger.info(
f"Worker {worker_id}: Finished: {rows_processed} rows in {total_time:.2f}s "
f"({rows_processed/total_time:.2f} rows/sec)"
)
def __iter__(self) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Iterate through the dataset and yield (image, label) tensors.
Yields:
Tuple[torch.Tensor, torch.Tensor]: (image, label) tensors
Raises:
Exception: If there's a fatal error during iteration
"""
try:
# Get worker info for file distribution
worker_id, num_workers = self._get_worker_info()
logger.info(f"Worker {worker_id}/{num_workers}: Starting")
# Distribute files among workers
files_to_read = (
self.file_urls
if num_workers == 1
else self.file_urls[worker_id::num_workers]
)
logger.info(f"Worker {worker_id}: Processing {len(files_to_read)} files")
# Initialize preprocessing function
preprocess_fn = get_preprocess_map_fn(
random_transforms=self.random_transforms
)
# Process files and yield results
yield from self._process_files(files_to_read, preprocess_fn, worker_id)
except Exception as e:
logger.error(
f"Worker {worker_id}: Fatal error: {str(e)}",
exc_info=True,
)
raise
@@ -0,0 +1,139 @@
# Standard library imports
import logging
from typing import Dict, Optional
# Third-party imports
from torch.utils.data import IterableDataset
import ray
import ray.data
import ray.train
# Local imports
from constants import DatasetKey
from config import BenchmarkConfig
from image_classification.factory import (
ImageClassificationRayDataLoaderFactory,
ImageClassificationTorchDataLoaderFactory,
)
from .imagenet import get_preprocess_map_fn
from .parquet_iterable_dataset import S3ParquetImageIterableDataset
from s3_parquet_reader import S3ParquetReader
logger = logging.getLogger(__name__)
class ImageClassificationParquetRayDataLoaderFactory(
ImageClassificationRayDataLoaderFactory
):
"""Factory for creating Ray DataLoader for Parquet image classification.
Features:
- Parquet file reading with column selection
- Image decoding and preprocessing
- Resource allocation for concurrent validation
- Row limits based on benchmark configuration
"""
def __init__(
self, benchmark_config: BenchmarkConfig, data_dirs: Dict[str, str]
) -> None:
super().__init__(benchmark_config)
self._data_dirs = data_dirs
def get_ray_datasets(self) -> Dict[str, ray.data.Dataset]:
"""Get Ray datasets for training and validation.
Returns:
Dictionary containing:
- "train": Training dataset with random transforms
- "val": Validation dataset without transforms
"""
# Create training dataset with image decoding and transforms
train_ds = ray.data.read_parquet(
self._data_dirs[DatasetKey.TRAIN],
columns=["image", "label"],
).map(get_preprocess_map_fn(decode_image=True, random_transforms=True))
if self.get_dataloader_config().limit_training_rows > 0:
train_ds = train_ds.limit(self.get_dataloader_config().limit_training_rows)
# Create validation dataset without random transforms
val_ds = ray.data.read_parquet(
self._data_dirs[DatasetKey.TRAIN],
columns=["image", "label"],
).map(get_preprocess_map_fn(decode_image=True, random_transforms=False))
if self.get_dataloader_config().limit_validation_rows > 0:
val_ds = val_ds.limit(self.get_dataloader_config().limit_validation_rows)
return {
DatasetKey.TRAIN: train_ds,
DatasetKey.VALID: val_ds,
}
class ImageClassificationParquetTorchDataLoaderFactory(
ImageClassificationTorchDataLoaderFactory, S3ParquetReader
):
"""Factory for creating PyTorch DataLoaders for Parquet image classification.
Features:
- Parquet file reading with row count-based distribution
- Worker-based file distribution for balanced workloads
- Row limits per worker for controlled processing
- Dataset instance caching for efficiency
"""
def __init__(
self, benchmark_config: BenchmarkConfig, data_dirs: Dict[str, str]
) -> None:
"""Initialize factory with benchmark configuration.
Args:
benchmark_config: Configuration for benchmark parameters
"""
super().__init__(benchmark_config)
S3ParquetReader.__init__(
self
) # Initialize S3ParquetReader to set up _s3_client
self.train_url = data_dirs[DatasetKey.TRAIN]
self._cached_datasets: Optional[Dict[str, IterableDataset]] = None
def get_iterable_datasets(self) -> Dict[str, IterableDataset]:
"""Get train and validation datasets with worker-specific configurations.
Returns:
Dictionary containing:
- "train": Training dataset with random transforms
- "val": Validation dataset without transforms
"""
if self._cached_datasets is not None:
return self._cached_datasets
# Get row limits for workers and total processing
(
limit_training_rows_per_worker,
limit_validation_rows_per_worker,
) = self._get_worker_row_limits()
# Create training dataset
train_file_urls = self._get_file_urls(self.train_url)
train_ds = S3ParquetImageIterableDataset(
file_urls=train_file_urls,
random_transforms=True,
limit_rows_per_worker=limit_training_rows_per_worker,
)
# Create validation dataset
val_file_urls = train_file_urls
val_ds = S3ParquetImageIterableDataset(
file_urls=val_file_urls,
random_transforms=False,
limit_rows_per_worker=limit_validation_rows_per_worker,
)
self._cached_datasets = {
DatasetKey.TRAIN: train_ds,
DatasetKey.VALID: val_ds,
}
return self._cached_datasets
@@ -0,0 +1,69 @@
import io
import numpy as np
from typing import Dict, Union, Callable
from PIL import Image
from torchvision.transforms.functional import pil_to_tensor
from constants import DatasetKey
from image_classification.imagenet import (
get_transform,
IMAGENET_WNID_TO_ID,
)
IMAGENET_PARQUET_SPLIT_S3_ROOT = (
"s3://ray-benchmark-data-internal-us-west-2/imagenet/parquet_split"
)
IMAGENET_PARQUET_SPLIT_S3_DIRS = {
DatasetKey.TRAIN: f"{IMAGENET_PARQUET_SPLIT_S3_ROOT}/train",
DatasetKey.VALID: f"{IMAGENET_PARQUET_SPLIT_S3_ROOT}/val",
DatasetKey.TEST: f"{IMAGENET_PARQUET_SPLIT_S3_ROOT}/test",
}
# Much larger parquet dataset used to sustain Ray Data backpressure in the
# slow-consumer ingest benchmarks.
IMAGENET_PARQUET_SPLIT_1T_S3_ROOT = (
"s3://ray-benchmark-data-internal-us-west-2/imagenet/parquet_split_1t"
)
IMAGENET_PARQUET_SPLIT_1T_S3_DIRS = {
DatasetKey.TRAIN: f"{IMAGENET_PARQUET_SPLIT_1T_S3_ROOT}/train",
DatasetKey.VALID: f"{IMAGENET_PARQUET_SPLIT_1T_S3_ROOT}/val",
DatasetKey.TEST: f"{IMAGENET_PARQUET_SPLIT_1T_S3_ROOT}/test",
}
def get_preprocess_map_fn(
decode_image: bool = True, random_transforms: bool = True
) -> Callable[[Dict[str, Union[bytes, str]]], Dict[str, Union[np.ndarray, int]]]:
"""Get a map function that transforms a row of the dataset to the format
expected by the training loop.
Args:
decode_image: Whether to decode the image bytes into a tensor
random_transforms: Whether to use random transforms for training
Returns:
A function that takes a row dict and returns a processed dict.
Input row dict should have:
- "image": bytes or tensor in CHW format
- "label": WNID string
Output dict has:
- "image": np.array of the transformed, normalized image
- "label": An integer index of the WNID
"""
crop_resize_transform = get_transform(
to_torch_tensor=False, random_transforms=random_transforms
)
def map_fn(row: Dict[str, Union[bytes, str]]) -> Dict[str, Union[np.ndarray, int]]:
assert "image" in row and "label" in row, row.keys()
if decode_image:
row["image"] = pil_to_tensor(Image.open(io.BytesIO(row["image"]))) / 255.0
row["image"] = np.array(crop_resize_transform(row["image"]))
row["label"] = IMAGENET_WNID_TO_ID[row["label"]]
return {"image": row["image"], "label": row["label"]}
return map_fn
@@ -0,0 +1,273 @@
# Standard library imports
from typing import List, Tuple, Optional, Iterator, Callable
import logging
import io
import time
# Third-party imports
import pandas as pd
import pyarrow.parquet as pq
import torch
from torch.utils.data import IterableDataset
# Ray imports
import ray
import ray.train
# Local imports
from s3_parquet_reader import S3ParquetReader
from .imagenet import get_preprocess_map_fn
from logger_utils import ContextLoggerAdapter
logger = ContextLoggerAdapter(logging.getLogger(__name__))
# TODO Look into https://github.com/webdataset/webdataset for more canonical way to do data
# distribution between Ray Train and Torch Dataloader workers.
class S3ParquetImageIterableDataset(S3ParquetReader, IterableDataset):
"""An iterable dataset that loads images from S3-stored Parquet files.
This dataset:
1. Reads Parquet files from S3 one row group at a time
2. Processes images with optional random transforms
3. Yields (image, label) tensors
4. Supports row limits per worker for controlled data processing
"""
LOG_FREQUENCY = 1000 # Log progress every 1000 rows
def __init__(
self,
file_urls: List[str],
random_transforms: bool = True,
limit_rows_per_worker: Optional[int] = None,
):
"""Initialize the dataset.
Args:
file_urls: List of S3 URLs to load
random_transforms: Whether to use random transforms for training
limit_rows_per_worker: Maximum number of rows to process per worker (None for all rows)
"""
super().__init__()
self.file_urls = file_urls
self.limit_rows_per_worker = limit_rows_per_worker
self.random_transforms = random_transforms
worker_rank = ray.train.get_context().get_world_rank()
logger.info(
f"Worker {worker_rank}: Initialized with {len(file_urls)} files"
f"{f' (limit: {limit_rows_per_worker} rows)' if limit_rows_per_worker else ''}"
)
def _get_worker_info(self) -> Tuple[int, int]:
"""Get current worker information.
Returns:
Tuple of (worker_id, num_workers)
"""
worker_info = torch.utils.data.get_worker_info()
worker_id = worker_info.id if worker_info else 0
num_workers = worker_info.num_workers if worker_info else 1
return worker_id, num_workers
def _has_reached_row_limit(self, rows_processed: int) -> bool:
"""Check if we've reached the row limit per worker.
Args:
rows_processed: Number of rows processed so far
Returns:
True if we've reached the limit, False otherwise
"""
return (
self.limit_rows_per_worker is not None
and rows_processed >= self.limit_rows_per_worker
)
def _log_progress(
self, worker_id: int, rows_processed: int, last_log_time: float
) -> float:
"""Log processing progress and return updated last_log_time.
Args:
worker_id: ID of the current worker
rows_processed: Number of rows processed so far
last_log_time: Time of last progress log
Returns:
Updated last_log_time
"""
if rows_processed % self.LOG_FREQUENCY == 0:
current_time = time.time()
elapsed_time = current_time - last_log_time
rows_per_second = (
self.LOG_FREQUENCY / elapsed_time if elapsed_time > 0 else 0
)
logger.info(
f"Worker {worker_id}: Processed {rows_processed} rows "
f"({rows_per_second:.2f} rows/sec)"
)
return current_time
return last_log_time
def _read_parquet_file(self, file_url: str) -> Iterator[pd.DataFrame]:
"""Read a Parquet file from S3 one row group at a time.
This method:
1. Fetches the Parquet file from S3
2. Reads it row group by row group
3. Converts each row group to a pandas DataFrame
Args:
file_url: S3 URL of the Parquet file
Yields:
DataFrame containing one row group at a time
Raises:
Exception: If there's an error reading the file
"""
try:
start_time = time.time()
worker_id, _ = self._get_worker_info()
logger.info(f"Worker {worker_id}: Reading Parquet file: {file_url}")
# Get parquet file metadata
bucket, key = self._parse_s3_url(file_url)
response = self.s3_client.get_object(Bucket=bucket, Key=key)
parquet_file = pq.ParquetFile(io.BytesIO(response["Body"].read()))
num_row_groups = parquet_file.num_row_groups
logger.info(
f"Worker {worker_id}: Found {num_row_groups} row groups in {file_url}"
)
for row_group in range(num_row_groups):
# Read row group and convert to pandas
table = parquet_file.read_row_group(row_group)
df = table.to_pandas()
yield df
total_time = time.time() - start_time
logger.info(
f"Worker {worker_id}: Completed reading {file_url} in {total_time:.2f}s"
)
except Exception as e:
worker_id, _ = self._get_worker_info()
logger.error(
f"Worker {worker_id}: Error reading file {file_url}: {str(e)}",
exc_info=True,
)
raise
def _process_file(
self,
file_url: str,
preprocess_fn: Callable,
) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Process a single file and yield processed rows.
Args:
file_url: URL of the file to process
preprocess_fn: Preprocessing function to apply
Yields:
Tuple of (image_tensor, label_tensor)
"""
for df in self._read_parquet_file(file_url):
for _, row in df.iterrows():
try:
# Process row and convert to tensors
processed = preprocess_fn(row)
image = torch.as_tensor(processed["image"], dtype=torch.float32)
label = torch.as_tensor(processed["label"], dtype=torch.int64)
yield image, label
except Exception:
continue
def _process_files(
self, files_to_read: List[str], preprocess_fn: Callable, worker_id: int
) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Process multiple files and yield processed rows.
Args:
files_to_read: List of file URLs to process
preprocess_fn: Preprocessing function to apply
worker_id: ID of the current worker
Yields:
Tuple of (image_tensor, label_tensor)
"""
rows_processed = 0
last_log_time = time.time()
total_start_time = time.time()
for file_url in files_to_read:
if self._has_reached_row_limit(rows_processed):
logger.info(f"Worker {worker_id}: Reached row limit: {rows_processed}")
break
for image, label in self._process_file(file_url, preprocess_fn):
if self._has_reached_row_limit(rows_processed):
break
rows_processed += 1
last_log_time = self._log_progress(
worker_id, rows_processed, last_log_time
)
yield image, label
# Log final statistics
total_time = time.time() - total_start_time
logger.info(
f"Worker {worker_id}: Finished: {rows_processed} rows in {total_time:.2f}s "
f"({rows_processed/total_time:.2f} rows/sec)"
)
def __iter__(self) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Main iteration method that processes files and yields (image, label) tensors.
This method:
1. Distributes files among workers
2. Processes rows with image transforms
3. Converts to tensors
4. Respects row limits per worker
Yields:
Tuple of (image_tensor, label_tensor)
Raises:
Exception: If there's a fatal error during processing
"""
try:
# Get worker info for file distribution
worker_id, num_workers = self._get_worker_info()
logger.info(f"Worker {worker_id}/{num_workers}: Starting")
# Initialize preprocessing function
preprocess_fn = get_preprocess_map_fn(
decode_image=True, random_transforms=self.random_transforms
)
# Distribute files among workers
files_to_read = (
self.file_urls
if num_workers == 1
else self.file_urls[worker_id::num_workers]
)
logger.info(f"Worker {worker_id}: Processing {len(files_to_read)} files")
# Process files and yield results
yield from self._process_files(files_to_read, preprocess_fn, worker_id)
except Exception as e:
logger.error(
f"Worker {worker_id}: Fatal error: {str(e)}",
exc_info=True,
)
raise
@@ -0,0 +1,77 @@
# Standard library imports
import logging
from typing import Dict
# Third-party imports
import ray.data
# Local imports
from constants import DatasetKey
from config import BenchmarkConfig
from image_classification.factory import ImageClassificationRayDataLoaderFactory
from .imagenet import (
create_s3_url_dataset,
)
logger = logging.getLogger(__name__)
class ImageClassificationS3UrlRayDataLoaderFactory(
ImageClassificationRayDataLoaderFactory
):
"""Factory for creating Ray DataLoader that downloads images from S3 URLs.
This factory:
1. Lists JPEG files from S3 using boto3
2. Creates a Ray dataset from the file records
3. Uses map_batches to download and process images from S3
This approach separates file listing from image downloading, which can be
more efficient for certain workloads as it allows parallel downloads during
the map_batches execution on CPU workers.
"""
def __init__(
self, benchmark_config: BenchmarkConfig, data_dirs: Dict[str, str]
) -> None:
super().__init__(benchmark_config)
self._data_dirs = data_dirs
def get_ray_datasets(self) -> Dict[str, ray.data.Dataset]:
"""Get Ray datasets for training and validation.
Returns:
Dictionary containing:
- "train": Training dataset with random transforms
- "val": Validation dataset without transforms
"""
dataloader_config = self.get_dataloader_config()
# Create training dataset
train_limit = (
dataloader_config.limit_training_rows
if dataloader_config.limit_training_rows > 0
else None
)
train_ds = create_s3_url_dataset(
data_dir=self._data_dirs[DatasetKey.TRAIN],
random_transforms=True,
limit_rows=train_limit,
)
# Create validation dataset
val_limit = (
dataloader_config.limit_validation_rows
if dataloader_config.limit_validation_rows > 0
else None
)
val_ds = create_s3_url_dataset(
data_dir=self._data_dirs[DatasetKey.TRAIN],
random_transforms=False,
limit_rows=val_limit,
)
return {
DatasetKey.TRAIN: train_ds,
DatasetKey.VALID: val_ds,
}
@@ -0,0 +1,251 @@
"""ImageNet dataset loading via S3 URL download with Ray Data expressions.
This module provides dataset loading that:
1. Lists JPEG files from S3 using boto3 (parallelized via Ray tasks)
2. Creates a Ray dataset from the file records
3. Uses Ray Data expressions (alpha) to download image bytes efficiently
4. Uses map_batches to decode and process images
This approach leverages Ray Data's expressions API for optimized parallel I/O,
separating the download step from image processing for better throughput.
"""
import io
import logging
from functools import lru_cache
from typing import Callable, Dict, List, Optional, Tuple
import boto3
import numpy as np
from PIL import Image
from torchvision.transforms.functional import pil_to_tensor
import ray.data
from ray.data.expressions import download
from constants import DatasetKey
from image_classification.imagenet import (
get_transform,
IMAGENET_WNID_TO_ID,
)
logger = logging.getLogger(__name__)
# S3 configuration for ImageNet JPEG data
AWS_REGION = "us-west-2"
S3_ROOT = "s3://anyscale-imagenet/ILSVRC/Data/CLS-LOC"
IMAGENET_S3_URL_SPLIT_DIRS = {
DatasetKey.TRAIN: f"{S3_ROOT}/train",
DatasetKey.VALID: f"{S3_ROOT}/val",
DatasetKey.TEST: f"{S3_ROOT}/test",
}
def _get_class_labels(bucket: str, prefix: str) -> List[str]:
"""Get all class label directories from S3.
Args:
bucket: S3 bucket name
prefix: S3 prefix path
Returns:
List of class label directory names
"""
from typing import Set
# Ensure prefix ends with /
if prefix and not prefix.endswith("/"):
prefix += "/"
# List directories using delimiter
s3_client = boto3.client("s3", region_name=AWS_REGION)
paginator = s3_client.get_paginator("list_objects_v2")
# Use delimiter to get "directory" level
labels: Set[str] = set()
for page in paginator.paginate(Bucket=bucket, Prefix=prefix, Delimiter="/"):
# CommonPrefixes contains the "directories"
for common_prefix in page.get("CommonPrefixes", []):
prefix_path = common_prefix["Prefix"]
# Extract the directory name
label = prefix_path.rstrip("/").split("/")[-1]
labels.add(label)
return sorted(labels)
@ray.remote
def _list_files_for_label(
bucket: str, prefix: str, label: str
) -> List[Tuple[str, str]]:
"""Ray task to list all image files for a specific label.
Args:
bucket: S3 bucket name
prefix: S3 prefix (parent directory)
label: Class label (subdirectory name)
Returns:
List of tuples with (file_path, class_name)
"""
s3_client = boto3.client("s3", region_name=AWS_REGION)
paginator = s3_client.get_paginator("list_objects_v2")
# Construct the full prefix for this label
label_prefix = f"{prefix}/{label}/" if prefix else f"{label}/"
file_records = []
for page in paginator.paginate(Bucket=bucket, Prefix=label_prefix):
for obj in page.get("Contents", []):
key = obj["Key"]
if key.lower().endswith((".jpg", ".jpeg")):
file_path = f"s3://{bucket}/{key}"
file_records.append((file_path, label))
return file_records
@lru_cache(maxsize=8)
def _list_s3_image_files_cached(data_dir: str) -> Tuple[Tuple[str, str], ...]:
"""Cached implementation of S3 file listing using Ray tasks for parallelism.
Returns a tuple of tuples for hashability (required by lru_cache).
"""
logger.info(f"Listing JPEG files from {data_dir}...")
# Parse S3 URL: s3://bucket/prefix
s3_path = data_dir
if s3_path.startswith("s3://"):
s3_path = s3_path[5:]
parts = s3_path.split("/", 1)
bucket = parts[0]
prefix = parts[1].rstrip("/") if len(parts) > 1 else ""
# Get all class labels
labels = _get_class_labels(bucket, prefix)
logger.info(
f"Found {len(labels)} class labels, launching Ray tasks for parallel listing..."
)
# Launch Ray tasks for each label
futures = [_list_files_for_label.remote(bucket, prefix, label) for label in labels]
# Wait for all tasks to complete and aggregate results
results = ray.get(futures)
# Flatten the list of lists
file_records = []
for records in results:
file_records.extend(records)
logger.info(f"Listed and cached {len(file_records)} JPEG files")
return tuple(file_records)
def list_s3_image_files(data_dir: str) -> List[Dict[str, str]]:
"""List JPEG files from S3 with class labels extracted from path.
Results are cached to avoid repeated S3 listings.
Args:
data_dir: S3 path to list files from (e.g., "s3://bucket/prefix")
Returns:
List of dicts with "path" (S3 URL) and "class" (WNID) keys
"""
cached_records = _list_s3_image_files_cached(data_dir)
return [{"path": path, "class": cls} for path, cls in cached_records]
def get_process_batch_fn(
random_transforms: bool = True,
label_to_id_map: Optional[Dict[str, int]] = None,
) -> Callable[[Dict[str, np.ndarray]], Dict[str, np.ndarray]]:
"""Get a map_batches function that processes pre-downloaded image bytes.
This function expects image bytes to already be downloaded (via Ray Data
expressions) and handles decoding and transformations.
Args:
random_transforms: Whether to use random transforms for training
label_to_id_map: Mapping from WNID strings to integer IDs
Returns:
A function suitable for use with dataset.map_batches()
"""
if label_to_id_map is None:
label_to_id_map = IMAGENET_WNID_TO_ID
transform = get_transform(
to_torch_tensor=False, random_transforms=random_transforms
)
def process_batch(
batch: Dict[str, np.ndarray],
) -> Dict[str, np.ndarray]:
"""Process pre-downloaded image bytes.
Args:
batch: Dict with "bytes" (image data) and "class" arrays
Returns:
Dict with "image" (numpy array) and "label" (int) arrays
"""
processed_images = []
labels = []
image_bytes_list = list(batch["bytes"])
classes = list(batch["class"])
for data, wnid in zip(image_bytes_list, classes):
# Decode and transform image
image_pil = Image.open(io.BytesIO(data)).convert("RGB")
image_tensor = pil_to_tensor(image_pil) / 255.0
processed_image = np.array(transform(image_tensor))
processed_images.append(processed_image)
# Convert label
labels.append(label_to_id_map[wnid])
return {
"image": np.stack(processed_images),
"label": np.array(labels),
}
return process_batch
def create_s3_url_dataset(
data_dir: str,
random_transforms: bool = True,
limit_rows: Optional[int] = None,
) -> ray.data.Dataset:
"""Create a Ray dataset that downloads images from S3 URLs.
Uses Ray Data expressions (alpha) for efficient parallel downloads,
then map_batches for image decoding and transformations.
Args:
data_dir: S3 path to the image directory
random_transforms: Whether to use random transforms
limit_rows: Optional row limit
Returns:
Ray dataset with "image" and "label" columns
"""
file_records = list_s3_image_files(data_dir)
ds = ray.data.from_items(file_records)
if limit_rows is not None and limit_rows > 0:
ds = ds.limit(limit_rows)
# Download image bytes using Ray Data expressions (alpha)
# This enables optimized parallel I/O managed by Ray Data
ds = ds.with_column("bytes", download("path"))
# Process downloaded bytes (decode and transform)
process_fn = get_process_batch_fn(random_transforms=random_transforms)
ds = ds.map_batches(process_fn)
return ds
@@ -0,0 +1,55 @@
import logging
import inspect
from typing import Any, Dict, Tuple, Union
class ContextLoggerAdapter(logging.LoggerAdapter):
def __init__(
self, logger: logging.Logger, extra: Union[Dict[str, Any], None] = None
) -> None:
"""Initialize the logger adapter.
Args:
logger: The logger to wrap
extra: Extra data to include in log records
"""
super().__init__(logger, extra or {})
def process(self, msg: str, kwargs: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
"""Process a log message and add context information.
Args:
msg: The log message to process
kwargs: Additional keyword arguments for logging
Returns:
Tuple containing:
- The processed message with context prefix
- The original kwargs
"""
# Get the frame that called the logging method
# Go up 3 frames: process -> log -> info/error/etc -> actual caller
frame = inspect.currentframe()
if (
frame
and frame.f_back
and frame.f_back.f_back
and frame.f_back.f_back.f_back
):
frame = frame.f_back.f_back.f_back
class_name = getattr(frame.f_locals.get("self"), "__class__", None)
func_name = frame.f_code.co_name
# Create the prefix with class and function context
prefix = (
f"[{class_name.__name__}.{func_name}]"
if class_name
else f"[{func_name}]"
)
else:
prefix = "[unknown]"
# Add any extra context from the adapter
# Don't modify kwargs directly as it causes issues with level handling
return f"{prefix} {msg}", kwargs
@@ -0,0 +1,281 @@
import logging
import threading
import time
from abc import abstractmethod
from typing import Any, Dict, Optional
import ray
import ray.train
from ray._private.internal_api import get_memory_info_reply, get_state_from_address
from ray.data import Dataset
from ray.data.collate_fn import CollateFn
from constants import DatasetKey
from config import BenchmarkConfig, RayDataConfig
from dataloader_factory import BaseDataLoaderFactory
logger = logging.getLogger(__name__)
SPILL_MONITOR_ACTOR_NAME = "spill_metrics_monitor"
SPILL_MONITOR_ACTOR_NAMESPACE = "_spill_metrics_monitor"
@ray.remote(num_cpus=0)
class SpillMetricsMonitor:
"""Actor that periodically polls object store spill metrics
to compute peak and average spilling rates (GB/min).
GB/min is used instead of GB/s because object store spilling rates are
typically small fractions of a GB per second, making GB/s values hard to
read and compare (e.g. 0.0021 GB/s vs 0.13 GB/min). GB/min produces
more human-friendly numbers while still matching the 60-second poll
interval naturally.
A single instance is shared across all workers via a named actor.
"""
def __init__(self, poll_interval_s: float = 60.0):
self._poll_interval_s = poll_interval_s
self._count = 0
self._sum_gb_min = 0.0
self._max_gb_min = 0.0
self._lock = threading.Lock()
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def _get_spilled_bytes(self) -> int:
memory_info = get_memory_info_reply(
get_state_from_address(ray.get_runtime_context().gcs_address)
)
return memory_info.store_stats.spilled_bytes_total
def _poll_loop(self) -> None:
prev_spilled_bytes: Optional[int] = None
prev_time: Optional[float] = None
while True:
time.sleep(self._poll_interval_s)
try:
current_bytes = self._get_spilled_bytes()
current_time = time.monotonic()
if prev_spilled_bytes is not None and prev_time is not None:
delta_bytes = current_bytes - prev_spilled_bytes
delta_time = current_time - prev_time
if delta_time > 0 and delta_bytes >= 0:
rate_gb_min = (delta_bytes / (1024**3)) / delta_time * 60
with self._lock:
self._count += 1
self._sum_gb_min += rate_gb_min
self._max_gb_min = max(self._max_gb_min, rate_gb_min)
prev_spilled_bytes = current_bytes
prev_time = current_time
except Exception as e:
logger.warning(f"SpillMetricsMonitor: poll failed: {e}")
def get_metrics(self) -> Dict[str, float]:
with self._lock:
count = self._count
sum_gb_min = self._sum_gb_min
max_gb_min = self._max_gb_min
if count == 0:
return {}
return {
"object_store_spilling_peak_gb_min": round(max_gb_min, 4),
"object_store_spilling_avg_gb_min": round(sum_gb_min / count, 4),
}
def get_or_create_spill_metrics_monitor(
poll_interval_s: float = 60.0,
) -> ray.actor.ActorHandle:
return SpillMetricsMonitor.options(
name=SPILL_MONITOR_ACTOR_NAME,
namespace=SPILL_MONITOR_ACTOR_NAMESPACE,
get_if_exists=True,
lifetime="detached",
).remote(poll_interval_s=poll_interval_s)
class RayDataLoaderFactory(BaseDataLoaderFactory):
def __init__(self, benchmark_config: BenchmarkConfig) -> None:
super().__init__(benchmark_config)
self._ray_ds_iterators = {}
self._spill_monitor: Optional[ray.actor.ActorHandle] = None
dataloader_config = self.get_dataloader_config()
assert isinstance(dataloader_config, RayDataConfig), type(dataloader_config)
# Configure Ray Data settings.
data_context = ray.data.DataContext.get_current()
data_context.enable_operator_progress_bars = (
dataloader_config.enable_operator_progress_bars
)
# Retry transient S3 errors that sometimes show up due to
# throttling during read operations.
data_context.retried_io_errors.append("AWS Error ACCESS_DENIED")
data_context.retried_io_errors.append("AWS Error UNKNOWN (HTTP status 500)")
data_context.execution_options.locality_with_output = (
dataloader_config.locality_with_output
)
data_context.execution_options.actor_locality_enabled = (
dataloader_config.actor_locality_enabled
)
data_context.execution_options.preserve_order = dataloader_config.preserve_order
@abstractmethod
def get_ray_datasets(self) -> Dict[str, Dataset]:
"""Get Ray datasets."""
raise NotImplementedError
def _get_collate_fn(self) -> Optional[CollateFn]:
"""Return the collate function for the dataloader."""
return None
def get_ray_data_config(self) -> ray.train.DataConfig:
return ray.train.DataConfig(
enable_shard_locality=self.get_dataloader_config().enable_shard_locality,
)
def get_train_dataloader(self):
"""Get the training dataloader.
Returns:
Iterator of training batches
"""
# Get or create the shared spill monitor actor on first call.
if self._spill_monitor is None:
self._spill_monitor = get_or_create_spill_metrics_monitor()
ds_iterator = ray.train.get_dataset_shard(DatasetKey.TRAIN)
self._ray_ds_iterators[DatasetKey.TRAIN] = ds_iterator
dataloader_config = self.get_dataloader_config()
return iter(
ds_iterator.iter_torch_batches(
batch_size=dataloader_config.train_batch_size,
local_shuffle_buffer_size=(
dataloader_config.local_buffer_shuffle_size
if dataloader_config.local_buffer_shuffle_size > 0
else None
),
collate_fn=self._get_collate_fn(),
prefetch_batches=dataloader_config.ray_data_prefetch_batches,
drop_last=True,
pin_memory=dataloader_config.ray_data_pin_memory,
)
)
def get_val_dataloader(self):
"""Get the validation dataloader.
Returns:
Iterator of validation batches
"""
ds_iterator = ray.train.get_dataset_shard(DatasetKey.VALID)
self._ray_ds_iterators[DatasetKey.VALID] = ds_iterator
dataloader_config = self.get_dataloader_config()
return iter(
ds_iterator.iter_torch_batches(
batch_size=dataloader_config.validation_batch_size,
collate_fn=self._get_collate_fn(),
prefetch_batches=dataloader_config.ray_data_prefetch_batches,
drop_last=True,
)
)
def get_metrics(self) -> Dict[str, Any]:
metrics = {}
for ds_key, ds_iterator in self._ray_ds_iterators.items():
stats = ray.get(ds_iterator._coord_actor.stats.remote())
summary = stats.to_summary()
summary.iter_stats = ds_iterator._iter_stats.to_summary().iter_stats
summary.iter_stats.streaming_split_coord_time.add(
stats.streaming_split_coordinator_s.get()
)
if not summary.parents:
continue
# The split() operator has no metrics, so pull the stats
# from the final dataset stage.
ds_output_summary = summary.parents[0]
ds_throughput = (
ds_output_summary.operators_stats[-1].output_num_rows.sum
/ ds_output_summary.get_total_wall_time()
)
iter_stats = summary.iter_stats
metrics[f"dataloader/{ds_key}"] = {
"producer_throughput": ds_throughput,
"iter_stats": {
"prefetch_block-avg": iter_stats.wait_time.avg(),
"prefetch_block-min": iter_stats.wait_time.min(),
"prefetch_block-max": iter_stats.wait_time.max(),
"prefetch_block-total": iter_stats.wait_time.get(),
"get_ref_bundles-avg": iter_stats.get_ref_bundles_time.avg(),
"get_ref_bundles-min": iter_stats.get_ref_bundles_time.min(),
"get_ref_bundles-max": iter_stats.get_ref_bundles_time.max(),
"get_ref_bundles-total": iter_stats.get_ref_bundles_time.get(),
"fetch_block-avg": iter_stats.get_time.avg(),
"fetch_block-min": iter_stats.get_time.min(),
"fetch_block-max": iter_stats.get_time.max(),
"fetch_block-total": iter_stats.get_time.get(),
"block_to_batch-avg": iter_stats.next_time.avg(),
"block_to_batch-min": iter_stats.next_time.min(),
"block_to_batch-max": iter_stats.next_time.max(),
"block_to_batch-total": iter_stats.next_time.get(),
"format_batch-avg": iter_stats.format_time.avg(),
"format_batch-min": iter_stats.format_time.min(),
"format_batch-max": iter_stats.format_time.max(),
"format_batch-total": iter_stats.format_time.get(),
"collate-avg": iter_stats.collate_time.avg(),
"collate-min": iter_stats.collate_time.min(),
"collate-max": iter_stats.collate_time.max(),
"collate-total": iter_stats.collate_time.get(),
"finalize-avg": iter_stats.finalize_batch_time.avg(),
"finalize-min": iter_stats.finalize_batch_time.min(),
"finalize-max": iter_stats.finalize_batch_time.max(),
"finalize-total": iter_stats.finalize_batch_time.get(),
"time_spent_blocked-avg": iter_stats.block_time.avg(),
"time_spent_blocked-min": iter_stats.block_time.min(),
"time_spent_blocked-max": iter_stats.block_time.max(),
"time_spent_blocked-total": iter_stats.block_time.get(),
"time_spent_training-avg": iter_stats.user_time.avg(),
"time_spent_training-min": iter_stats.user_time.min(),
"time_spent_training-max": iter_stats.user_time.max(),
"time_spent_training-total": iter_stats.user_time.get(),
},
}
# Collect object store spill metrics.
try:
memory_info = get_memory_info_reply(
get_state_from_address(ray.get_runtime_context().gcs_address)
)
spilled_bytes_total = memory_info.store_stats.spilled_bytes_total
metrics["object_store_spilled_total_gb"] = round(
spilled_bytes_total / (1024**3), 4
)
except Exception as e:
logger.warning(
f"Failed to collect object_store_spilled_total_gb metric: {e}"
)
# Collect peak and average spilling rate from the background monitor.
if self._spill_monitor is not None:
try:
spill_metrics = ray.get(self._spill_monitor.get_metrics.remote())
metrics.update(spill_metrics)
except Exception as e:
logger.warning(f"Failed to collect spill rate metrics: {e}")
return metrics
@@ -0,0 +1,292 @@
import logging
import os
from typing import TYPE_CHECKING, Dict, List, Tuple
import boto3
import json
import numpy as np
import pyarrow.csv
import ray.data
from constants import DatasetKey
if TYPE_CHECKING:
from torchrec.datasets.utils import Batch
logger = logging.getLogger(__name__)
S3_BUCKET = "ray-benchmark-data-internal-us-west-2"
CRITEO_S3_URI = f"s3://{S3_BUCKET}/criteo/tsv.gz"
CAT_FEATURE_VALUE_COUNT_JSON_PATH_PATTERN = (
"criteo/tsv.gz/categorical_feature_value_counts/{}-value_counts.json"
)
INT_FEATURE_COUNT = 13
CAT_FEATURE_COUNT = 26
DAYS = 24
DEFAULT_LABEL_NAME = "label"
DEFAULT_INT_NAMES: List[str] = [f"int_{idx}" for idx in range(INT_FEATURE_COUNT)]
DEFAULT_CAT_NAMES: List[str] = [f"cat_{idx}" for idx in range(CAT_FEATURE_COUNT)]
DEFAULT_COLUMN_NAMES: List[str] = [
DEFAULT_LABEL_NAME,
*DEFAULT_INT_NAMES,
*DEFAULT_CAT_NAMES,
]
CRITEO_NUM_EMBEDDINGS_PER_FEATURE: List[int] = [
45833188,
36746,
17245,
7413,
20243,
3,
7114,
1441,
62,
29275261,
1572176,
345138,
10,
2209,
11267,
128,
4,
974,
14,
48937457,
11316796,
40094537,
452104,
12606,
104,
35,
]
DATASET_PATHS = {
DatasetKey.TRAIN: f"{CRITEO_S3_URI}/train",
DatasetKey.VALID: f"{CRITEO_S3_URI}/valid",
DatasetKey.TEST: f"{CRITEO_S3_URI}/test",
}
def fill_missing(batch: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
"""Fill in missing feature values with defaults.
Default to 0 for dense features, empty string "" for categorical features.
"""
for feature_name in DEFAULT_INT_NAMES:
batch[feature_name] = np.nan_to_num(batch[feature_name], nan=0)
for feature_name in DEFAULT_CAT_NAMES:
features = batch[feature_name]
features[np.equal(features, None)] = ""
return batch
def concat_and_normalize_dense_features(
batch: Dict[str, np.ndarray],
) -> Dict[str, np.ndarray]:
"""Concatenate dense and sparse features together.
Apply log transformation to dense features."""
out = {}
out["dense"] = np.column_stack(
[batch[feature_name] for feature_name in DEFAULT_INT_NAMES]
)
out["sparse"] = np.column_stack(
[batch[feature_name] for feature_name in DEFAULT_CAT_NAMES]
)
out["dense"] += 3 # Prevent log(0)
out["dense"] = np.log(out["dense"], dtype=np.float32)
out["label"] = batch["label"]
return out
def mock_dataloader(num_batches: int, batch_size: int):
"""Creates a dummy batch of size `batch_size` and yields it `num_batches` times."""
dense = np.random.randn(batch_size, INT_FEATURE_COUNT).astype(np.float32)
sparse = np.random.randint(
1,
np.array(CRITEO_NUM_EMBEDDINGS_PER_FEATURE),
(batch_size, CAT_FEATURE_COUNT),
).astype(np.int32)
labels = np.random.randint(0, 1, (batch_size,)).astype(np.int32)
batch = convert_to_torchrec_batch_format(
{"dense": dense, "sparse": sparse, "label": labels}
)
batch = batch.pin_memory()
for _ in range(num_batches):
yield batch
def convert_to_torchrec_batch_format(batch: Dict[str, np.ndarray]) -> "Batch":
"""Convert to a Batch, packaging sparse features as a KJT."""
import torch
from torchrec.datasets.utils import Batch
from torchrec.sparse.jagged_tensor import KeyedJaggedTensor
dense = batch["dense"]
sparse = batch["sparse"]
labels = batch["label"]
batch_size = len(dense)
lengths = torch.ones((batch_size * CAT_FEATURE_COUNT,), dtype=torch.int32)
offsets = torch.arange(0, batch_size * CAT_FEATURE_COUNT + 1, dtype=torch.int32)
length_per_key: List[int] = [batch_size] * CAT_FEATURE_COUNT
offset_per_key = [batch_size * i for i in range(CAT_FEATURE_COUNT + 1)]
index_per_key = {key: i for i, key in enumerate(DEFAULT_CAT_NAMES)}
# Handle partial batches (last batch).
# if batch_size == self.batch_size:
# length_per_key = self.length_per_key
# offset_per_key = self.offset_per_key
# else:
# # handle last batch in dataset when it's an incomplete batch.
# length_per_key = CAT_FEATURE_COUNT * [batch_size]
# offset_per_key = [batch_size * i for i in range(CAT_FEATURE_COUNT + 1)]
return Batch(
dense_features=torch.from_numpy(dense),
sparse_features=KeyedJaggedTensor(
keys=DEFAULT_CAT_NAMES,
# transpose().reshape(-1) introduces a copy
values=torch.from_numpy(sparse.transpose(1, 0).reshape(-1)),
lengths=lengths,
offsets=offsets,
stride=batch_size,
length_per_key=length_per_key,
offset_per_key=offset_per_key,
index_per_key=index_per_key,
),
labels=torch.from_numpy(labels.reshape(-1)),
)
def read_json_from_s3(bucket_name, key):
s3 = boto3.client("s3")
# Download object content
response = s3.get_object(Bucket=bucket_name, Key=key)
content = response["Body"].read().decode("utf-8")
# Parse JSON
data = json.loads(content)
return data
def _get_base_dataset(stage: DatasetKey = DatasetKey.TRAIN):
ds_path = DATASET_PATHS[stage]
ds = ray.data.read_csv(
ds_path,
read_options=pyarrow.csv.ReadOptions(column_names=DEFAULT_COLUMN_NAMES),
parse_options=pyarrow.csv.ParseOptions(delimiter="\t"),
shuffle=(
"files" if stage == DatasetKey.TRAIN else None
), # coarse file-level shuffle
)
return ds
def get_ray_dataset(stage: DatasetKey = DatasetKey.TRAIN):
ds = _get_base_dataset(stage)
# Convert categorical features to integers.
# Fetch cached value counts instead of "fitting" the preprocessor from scratch.
COMPUTE_VALUE_COUNTS_FROM_SCRATCH: bool = False
FREQUENCY_THRESHOLD = 3
LOW_FREQUENCY_INDEX = 1 # map low frequency values -> 1
categorical_value_to_index = {}
for cat_feature in DEFAULT_CAT_NAMES:
if COMPUTE_VALUE_COUNTS_FROM_SCRATCH:
value_counts = _compute_value_counts(ds, cat_feature)
else:
json_filepath = CAT_FEATURE_VALUE_COUNT_JSON_PATH_PATTERN.format(
cat_feature
)
logger.info(f"Downloading value counts file: {json_filepath}")
value_counts = read_json_from_s3(bucket_name=S3_BUCKET, key=json_filepath)
value_counts = filter(lambda x: x[1] >= FREQUENCY_THRESHOLD, value_counts)
categorical_value_to_index[cat_feature] = {
val: i for i, (val, _) in enumerate(value_counts, start=2)
}
# TODO: This will not scale well for the full dataset, since this dict might be 10s of GBs,
# which is expensive to copy to each map task.
# This mapping is large, so put a shared copy in the object store for all the map tasks to use.
categorical_value_to_index_ref = ray.put(categorical_value_to_index)
# Clean data.
ds = ds.map_batches(fill_missing)
def categorical_values_to_indices(
batch: Dict[str, np.ndarray], mapping_ref: ray.ObjectRef
):
mapping: Dict[str, int] = ray.get(mapping_ref)
for cat_feature in DEFAULT_CAT_NAMES:
batch[cat_feature] = np.vectorize(
lambda k: mapping.get(cat_feature, {}).get(k, LOW_FREQUENCY_INDEX)
)(batch[cat_feature])
return batch
ds = ds.map_batches(
categorical_values_to_indices, fn_args=(categorical_value_to_index_ref,)
)
# HACK: Dummy encoding for quicker testing.
# def dummy_categorical_encoder(batch):
# for feature_name in DEFAULT_CAT_NAMES:
# batch[feature_name] = np.random.randint(0, 3, size=(len(batch[feature_name]),))
# return batch
# ds = ds.map_batches(dummy_categorical_encoder)
ds = ds.map_batches(concat_and_normalize_dense_features)
# TODO: Need to shuffle the data.
return ds
def _compute_value_counts(ds, feature_name) -> List[Tuple]:
logger.info(f"Computing value counts for: {feature_name}")
# TODO: This needs to be optimized in order to run on the full dataset.
# Need to fill missing values with empty string.
value_counts = [
(
group[feature_name] if group[feature_name] is not None else "",
group["count()"],
)
for group in (
ds.select_columns(feature_name).groupby(key=feature_name).count().take_all()
)
]
return value_counts
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
ds = _get_base_dataset(stage="train")
# Create a directory for the value counts files.
save_dir = "/mnt/cluster_storage/criteo"
os.makedirs(save_dir, exist_ok=True)
for cat_feature in DEFAULT_CAT_NAMES:
value_counts = _compute_value_counts(ds, cat_feature)
json_filepath = os.path.join(save_dir, f"{cat_feature}-value_counts.json")
logger.info(f"Writing value counts to: {json_filepath}")
with open(json_filepath, "w") as f:
json.dump(value_counts, f)
@@ -0,0 +1,214 @@
from typing import Dict, List, Optional
import logging
import numpy as np
from pydantic import BaseModel
import torch
import torch.distributed as torch_dist
from ray.data.collate_fn import CollateFn, NumpyBatchCollateFn
import ray.train
import ray.train.torch
from constants import DatasetKey
from config import DataloaderType, BenchmarkConfig
from benchmark_factory import BenchmarkFactory
from dataloader_factory import (
BaseDataLoaderFactory,
)
from logger_utils import ContextLoggerAdapter
from ray_dataloader_factory import RayDataLoaderFactory
from recsys.criteo import (
CRITEO_NUM_EMBEDDINGS_PER_FEATURE,
convert_to_torchrec_batch_format,
get_ray_dataset,
mock_dataloader,
)
logger = ContextLoggerAdapter(logging.getLogger(__name__))
class RecsysMockDataLoaderFactory(BaseDataLoaderFactory):
def get_train_dataloader(self):
return mock_dataloader(
2048, self.benchmark_config.dataloader_config.train_batch_size
)
def get_val_dataloader(self):
return mock_dataloader(
256, self.benchmark_config.dataloader_config.validation_batch_size
)
class RecsysRayDataLoaderFactory(RayDataLoaderFactory):
def get_ray_datasets(self) -> Dict[str, ray.data.Dataset]:
# TODO: Use the train dataset for validation as well.
ds = get_ray_dataset(DatasetKey.VALID)
return {
DatasetKey.TRAIN: ds,
DatasetKey.VALID: ds,
}
def _get_collate_fn(self) -> Optional[CollateFn]:
from torchrec.datasets.utils import Batch
class TorchRecCollateFn(NumpyBatchCollateFn):
def __call__(self, batch: Dict[str, np.ndarray]) -> Batch:
return convert_to_torchrec_batch_format(batch)
return TorchRecCollateFn()
class TorchRecConfig(BaseModel):
embedding_dim: int = 128
num_embeddings_per_feature: List[int] = CRITEO_NUM_EMBEDDINGS_PER_FEATURE
over_arch_layer_sizes: List[int] = [1024, 1024, 512, 256, 1]
dense_arch_layer_sizes: List[int] = [512, 256, 128]
interaction_type: str = "dcn"
dcn_num_layers: int = 3
dcn_low_rank_dim: int = 512
class RecsysFactory(BenchmarkFactory):
def __init__(self, benchmark_config: BenchmarkConfig):
super().__init__(benchmark_config)
self.torchrec_config = TorchRecConfig()
def get_dataloader_factory(self) -> BaseDataLoaderFactory:
data_factory_cls = {
DataloaderType.MOCK: RecsysMockDataLoaderFactory,
DataloaderType.RAY_DATA: RecsysRayDataLoaderFactory,
}[self.benchmark_config.dataloader_type]
return data_factory_cls(self.benchmark_config)
def get_model(self) -> torch.nn.Module:
# NOTE: These imports error on a CPU-only driver node.
# Delay the import to happen on the GPU train workers instead.
from torchrec import EmbeddingBagCollection
from torchrec.datasets.criteo import DEFAULT_CAT_NAMES, DEFAULT_INT_NAMES
from torchrec.distributed.model_parallel import (
DistributedModelParallel,
get_default_sharders,
)
from torchrec.distributed.planner import EmbeddingShardingPlanner, Topology
from torchrec.distributed.planner.storage_reservations import (
HeuristicalStorageReservation,
)
from torchrec.models.dlrm import DLRM, DLRM_DCN, DLRM_Projection, DLRMTrain
from torchrec.modules.embedding_configs import EmbeddingBagConfig
from torchrec.optim.apply_optimizer_in_backward import (
apply_optimizer_in_backward,
)
args = self.torchrec_config
device = ray.train.torch.get_device()
local_world_size = ray.train.get_context().get_local_world_size()
global_world_size = ray.train.get_context().get_world_size()
eb_configs = [
EmbeddingBagConfig(
name=f"t_{feature_name}",
embedding_dim=args.embedding_dim,
num_embeddings=args.num_embeddings_per_feature[feature_idx],
feature_names=[feature_name],
)
for feature_idx, feature_name in enumerate(DEFAULT_CAT_NAMES)
]
sharded_module_kwargs = {}
if args.over_arch_layer_sizes is not None:
sharded_module_kwargs["over_arch_layer_sizes"] = args.over_arch_layer_sizes
if args.interaction_type == "original":
dlrm_model = DLRM(
embedding_bag_collection=EmbeddingBagCollection(
tables=eb_configs, device=torch.device("meta")
),
dense_in_features=len(DEFAULT_INT_NAMES),
dense_arch_layer_sizes=args.dense_arch_layer_sizes,
over_arch_layer_sizes=args.over_arch_layer_sizes,
dense_device=device,
)
elif args.interaction_type == "dcn":
dlrm_model = DLRM_DCN(
embedding_bag_collection=EmbeddingBagCollection(
tables=eb_configs, device=torch.device("meta")
),
dense_in_features=len(DEFAULT_INT_NAMES),
dense_arch_layer_sizes=args.dense_arch_layer_sizes,
over_arch_layer_sizes=args.over_arch_layer_sizes,
dcn_num_layers=args.dcn_num_layers,
dcn_low_rank_dim=args.dcn_low_rank_dim,
dense_device=device,
)
elif args.interaction_type == "projection":
raise NotImplementedError
dlrm_model = DLRM_Projection(
embedding_bag_collection=EmbeddingBagCollection(
tables=eb_configs, device=torch.device("meta")
),
dense_in_features=len(DEFAULT_INT_NAMES),
dense_arch_layer_sizes=args.dense_arch_layer_sizes,
over_arch_layer_sizes=args.over_arch_layer_sizes,
interaction_branch1_layer_sizes=args.interaction_branch1_layer_sizes,
interaction_branch2_layer_sizes=args.interaction_branch2_layer_sizes,
dense_device=device,
)
else:
raise ValueError(
"Unknown interaction option set. Should be original, dcn, or projection."
)
train_model = DLRMTrain(dlrm_model)
embedding_optimizer = torch.optim.Adagrad
# This will apply the Adagrad optimizer in the backward pass for the embeddings (sparse_arch). This means that
# the optimizer update will be applied in the backward pass, in this case through a fused op.
# TorchRec will use the FBGEMM implementation of EXACT_ADAGRAD. For GPU devices, a fused CUDA kernel is invoked. For CPU, FBGEMM_GPU invokes CPU kernels
# https://github.com/pytorch/FBGEMM/blob/2cb8b0dff3e67f9a009c4299defbd6b99cc12b8f/fbgemm_gpu/fbgemm_gpu/split_table_batched_embeddings_ops.py#L676-L678
# Note that lr_decay, weight_decay and initial_accumulator_value for Adagrad optimizer in FBGEMM v0.3.2
# cannot be specified below. This equivalently means that all these parameters are hardcoded to zero.
optimizer_kwargs = {"lr": 15.0, "eps": 1e-8}
apply_optimizer_in_backward(
embedding_optimizer,
train_model.model.sparse_arch.parameters(),
optimizer_kwargs,
)
planner = EmbeddingShardingPlanner(
topology=Topology(
local_world_size=local_world_size,
world_size=global_world_size,
compute_device=device.type,
),
batch_size=self.benchmark_config.dataloader_config.train_batch_size,
# If experience OOM, increase the percentage. see
# https://pytorch.org/torchrec/torchrec.distributed.planner.html#torchrec.distributed.planner.storage_reservations.HeuristicalStorageReservation
storage_reservation=HeuristicalStorageReservation(percentage=0.05),
)
plan = planner.collective_plan(
train_model, get_default_sharders(), torch_dist.GroupMember.WORLD
)
model = DistributedModelParallel(
module=train_model,
device=device,
plan=plan,
)
if ray.train.get_context().get_world_rank() == 0:
for collectionkey, plans in model._plan.plan.items():
logger.info(collectionkey)
for table_name, plan in plans.items():
logger.info(table_name)
logger.info(plan)
return model
def get_loss_fn(self) -> torch.nn.Module:
raise NotImplementedError(
"torchrec model should return the loss directly in forward. "
"See the `DLRMTrain` wrapper class."
)
@@ -0,0 +1,141 @@
import logging
import gc
import os
import torch
import torch.nn
from torchrec.distributed.train_pipeline import StagedTrainPipeline, SparseDataDistUtil
from torchrec.distributed.train_pipeline.utils import PipelineStage
from torchrec.optim.keyed import CombinedOptimizer, KeyedOptimizerWrapper
from torchrec.optim.optimizers import in_backward_optimizer_filter
import ray.train
import ray.train.torch
from runner import TrainLoopRunner
logger = logging.getLogger(__name__)
class TorchRecRunner(TrainLoopRunner):
def _setup(self):
if self.factory.benchmark_config.mock_gpu:
raise ValueError("Mock GPU is not supported for running TorchRec.")
self.model = self.factory.get_model()
# TODO: This code depends on the model having a fused_optimizer,
# which is hidden in the `get_model` method of the factory.
dense_optimizer = KeyedOptimizerWrapper(
dict(in_backward_optimizer_filter(self.model.named_parameters())),
lambda params: torch.optim.Adagrad(params, lr=15.0, eps=1e-8),
)
self.optimizer = CombinedOptimizer(
[self.model.fused_optimizer, dense_optimizer]
)
self._data_dist_stream = torch.cuda.Stream()
self._h2d_stream = torch.cuda.Stream()
def _wrap_dataloader(self, dataloader, train: bool = True):
dataloader_iter = iter(dataloader)
device = ray.train.torch.get_device()
sdd = SparseDataDistUtil(
model=self.model,
data_dist_stream=self._data_dist_stream,
# prefetch_stream=torch.cuda.Stream(),
)
pipeline = [
PipelineStage(
name="data_copy",
runnable=lambda batch: batch.to(device, non_blocking=True),
stream=self._h2d_stream,
),
PipelineStage(
name="start_sparse_data_dist",
runnable=sdd.start_sparse_data_dist,
stream=sdd.data_dist_stream,
fill_callback=sdd.wait_sparse_data_dist,
),
# PipelineStage(
# name="prefetch",
# runnable=sdd.prefetch,
# stream=sdd.prefetch_stream,
# fill_callback=sdd.load_prefetch,
# ),
]
pipeline = StagedTrainPipeline(pipeline_stages=pipeline)
def dataloader_with_torchrec_pipeline():
while batch := pipeline.progress(dataloader_iter):
yield batch
pipeline.flush_end()
return super()._wrap_dataloader(
dataloader_with_torchrec_pipeline(), train=train
)
def _train_step(self, batch):
self.model.train()
self.optimizer.zero_grad()
loss, out = self.model(batch)
loss.backward()
self.optimizer.step()
def _validate_step(self, batch):
self.model.eval()
with torch.no_grad():
loss, out = self.model(batch)
return loss
def _get_model_and_optim_filenames(self):
rank = ray.train.get_context().get_world_rank()
return f"model_shard_{rank=}.pt", f"optimizer_shard_{rank=}.pt"
def _save_training_state(self, local_dir: str):
# NOTE: Embedding table shards are on different GPUs,
# so we need to do distributed checkpointing.
# This checkpoint format must be loaded on the same number
# of workers and GPU types, since it was sharded with a compute-specific plan.
model_filename, optimizer_filename = self._get_model_and_optim_filenames()
torch.save(self.model.state_dict(), os.path.join(local_dir, model_filename))
torch.save(
self.optimizer.state_dict(), os.path.join(local_dir, optimizer_filename)
)
def _load_training_state(self, local_dir: str):
model_filename, optimizer_filename = self._get_model_and_optim_filenames()
self.model.load_state_dict(
torch.load(
os.path.join(local_dir, model_filename),
map_location=self.model.device,
)
)
self.optimizer.load_state_dict(
torch.load(
os.path.join(local_dir, optimizer_filename),
map_location=self.model.device,
)
)
def _cleanup(self):
# NOTE: This cleanup is needed to avoid zombie Train worker processes
# that hang on gc collect on python teardown.
del self.model
del self.optimizer
del self._data_dist_stream
del self._h2d_stream
torch.cuda.synchronize()
torch.cuda.empty_cache()
gc.collect()
+443
View File
@@ -0,0 +1,443 @@
import collections
import json
import logging
import os
import pprint
import time
import tempfile
from typing import Dict, Optional
import ray.train
from ray.data._internal.stats import Timer
import torch
from logger_utils import ContextLoggerAdapter
from benchmark_factory import BenchmarkFactory
logger = ContextLoggerAdapter(logging.getLogger(__name__))
class TrainLoopRunner:
"""Generic runner that sets up the training loop scaffolding.
Collects perf metrics and handles periodic checkpointing and validation.
"""
def __init__(self, factory: BenchmarkFactory):
self.factory = factory
self.benchmark_config = factory.benchmark_config
self._setup()
# Training progress state.
self._train_batch_idx: int = 0
self._train_epoch_idx: int = 0
self._global_rows_processed_this_epoch: int = 0
# Performance metrics
self._metrics = collections.defaultdict(lambda: Timer())
checkpoint = ray.train.get_checkpoint()
if checkpoint:
self._restore_from_checkpoint(checkpoint)
# Methods for subclasses to implement.
def _setup(self):
"""Subclasses should override this to setup the model, optimizer, etc.
The attributes initialized in this method should only be used in the
other overridden methods."""
pass
def _cleanup(self):
"""Subclasses can override this to cleanup any resources."""
pass
def _train_step(self, train_dataloader):
"""Subclasses should override this to implement the training step.
A training step represents a single forward and backward pass on a batch of data.
"""
raise NotImplementedError
def _validate_step(self, val_dataloader):
"""Subclasses should override this to implement the validation step.
A validation step represents a single forward pass on a batch of data."""
raise NotImplementedError
def _save_training_state(self, local_dir: str):
"""Subclasses should override this to save the training state.
This should reference the model and optimizer state initialized
in the `_setup` method."""
pass
def _load_training_state(self, local_dir: str):
"""Subclasses should override this to load the training state.
This should reference the model and optimizer state initialized
in the `_setup` method."""
pass
def _restore_from_checkpoint(self, checkpoint: ray.train.Checkpoint):
logger.info(
f"Restoring from checkpoint: {checkpoint} for worker "
f"{ray.train.get_context().get_world_rank()}"
)
with tempfile.TemporaryDirectory(
dir="/mnt/local_storage"
) as temp_checkpoint_dir:
download_start = time.perf_counter()
checkpoint.to_directory(temp_checkpoint_dir)
download_time = time.perf_counter() - download_start
load_start = time.perf_counter()
self._load_checkpoint(temp_checkpoint_dir)
load_time = time.perf_counter() - load_start
self._metrics["checkpoint/download"].add(download_time)
self._metrics["checkpoint/load"].add(load_time)
def _wrap_dataloader(self, dataloader, train: bool = True):
dataloader_iter = iter(dataloader)
prefix = "train" if train else "validation"
def dataloader_with_timers():
try:
with self._metrics[f"{prefix}/iter_first_batch"].timer():
batch = next(dataloader_iter)
if train:
self._train_batch_idx += 1
except StopIteration:
return
while True:
yield batch
try:
with self._metrics[f"{prefix}/iter_batch"].timer():
batch = next(dataloader_iter)
if train:
self._train_batch_idx += 1
except StopIteration:
return
return dataloader_with_timers()
@property
def _num_batches_to_skip(self) -> int:
"""Calculate the number of batches to skip based on the number of rows already processed in this epoch."""
global_batch_size = (
self.benchmark_config.dataloader_config.train_batch_size
* ray.train.get_context().get_world_size()
)
return self._global_rows_processed_this_epoch // global_batch_size
def _train_epoch(self):
"""Subclasses can override the entrire `_train_epoch` method for more training
logic customization."""
if ray.train.get_context().get_world_rank() == 0:
logger.info(f"Training starting @ epoch={self._train_epoch_idx}")
train_dataloader = self.factory.get_train_dataloader()
train_dataloader = self._wrap_dataloader(train_dataloader, train=True)
# Skip through batches if we restored to a middle of the epoch.
# TODO: Compare this baseline to the data checkpointing approach once we have it.
if self._num_batches_to_skip:
if ray.train.get_context().get_world_rank() == 0:
logger.info(f"Skipping {self._num_batches_to_skip} batches...")
# Zero before the skip loop drives the wrapper, which would
# otherwise double-count against the value restored from the
# checkpoint. After the skip, _train_batch_idx is rebuilt to
# _num_batches_to_skip — matching the restored value.
self._train_batch_idx = 0
for _ in range(self._num_batches_to_skip):
with self._metrics["train/iter_skip_batch"].timer():
next(train_dataloader)
for batch in train_dataloader:
with self._metrics["train/step"].timer():
if not self.benchmark_config.skip_train_step:
self._train_step(batch)
if self.benchmark_config.train_step_sleep_s > 0:
time.sleep(self.benchmark_config.train_step_sleep_s)
# TODO: This is slightly off if the last batch is a partial batch (if drop_last=False)
global_batch_size = (
self.benchmark_config.dataloader_config.train_batch_size
* ray.train.get_context().get_world_size()
)
self._metrics["train/rows_processed"].add(global_batch_size)
self._global_rows_processed_this_epoch += global_batch_size
if self._should_checkpoint_during_epoch():
self._checkpoint()
if self._should_validate_during_epoch():
validation_metrics = self._validate()
self._checkpoint(validation_metrics)
if self._should_log_metrics():
logger.info(pprint.pformat(self.get_metrics(), indent=2))
if (
self.benchmark_config.max_train_batches > 0
and self._train_batch_idx >= self.benchmark_config.max_train_batches
):
break
self._train_epoch_idx += 1
self._train_batch_idx = 0
self._global_rows_processed_this_epoch = 0
def _validate_epoch(self) -> Dict[str, float]:
if ray.train.get_context().get_world_rank() == 0:
logger.info(
f"Validation starting @ epoch={self._train_epoch_idx}, "
f"batch={self._train_batch_idx}"
)
val_dataloader = self.factory.get_val_dataloader()
val_dataloader = self._wrap_dataloader(val_dataloader, train=False)
total_loss = torch.tensor(0.0).to(ray.train.torch.get_device())
num_rows = 0
for batch in val_dataloader:
with self._metrics["validation/step"].timer():
if not self.benchmark_config.skip_validation_step:
total_loss += self._validate_step(batch)
num_rows += self.benchmark_config.dataloader_config.validation_batch_size
self._metrics["validation/rows_processed"].add(
self.benchmark_config.dataloader_config.validation_batch_size
)
assert num_rows > 0, "Validation dataset yielded no batches."
return {"validation/loss": total_loss.item() / num_rows}
def _should_checkpoint_during_epoch(self) -> bool:
"""Handles the checkpoint_every_n_steps logic."""
return (
self.benchmark_config.checkpoint_every_n_steps > 0
and self._train_batch_idx % self.benchmark_config.checkpoint_every_n_steps
== 0
)
def _should_validate_during_epoch(self) -> bool:
"""Handles the validate_every_n_steps logic."""
return (
self.benchmark_config.validate_every_n_steps > 0
and self._train_batch_idx % self.benchmark_config.validate_every_n_steps
== 0
)
def _should_log_metrics(self) -> bool:
"""Handles the log_metrics_every_n_steps logic."""
return (
self.benchmark_config.log_metrics_every_n_steps > 0
and self._train_batch_idx % self.benchmark_config.log_metrics_every_n_steps
== 0
)
def _validate(self) -> Dict[str, float]:
with self._metrics["validation/epoch"].timer():
validation_metrics = self._validate_epoch()
return validation_metrics
def _checkpoint(self, metrics: Optional[Dict[str, float]] = None):
with tempfile.TemporaryDirectory(
dir="/mnt/local_storage"
) as temp_checkpoint_dir:
with self._metrics["checkpoint/save"].timer():
self._save_checkpoint(temp_checkpoint_dir)
with self._metrics["checkpoint/report"].timer():
self._report_checkpoint(
metrics=metrics or {},
checkpoint=ray.train.Checkpoint.from_directory(temp_checkpoint_dir),
)
def _load_checkpoint(self, local_dir: str):
self._load_training_state(local_dir)
run_state = torch.load(os.path.join(local_dir, "run_state.pt"))
self._train_epoch_idx = run_state["epoch"]
self._train_batch_idx = run_state["batch_idx"]
self._global_rows_processed_this_epoch = run_state[
"global_rows_processed_this_epoch"
]
with open(os.path.join(local_dir, "metrics.json"), "r") as f:
metrics_json = json.load(f)
for k, v in metrics_json.items():
self._metrics[k].from_dict(v)
if ray.train.get_context().get_world_rank() == 0:
logger.info(
f"Restored to epoch={self._train_epoch_idx}, "
f"train_batch_idx={self._train_batch_idx} from checkpoint: "
f"{ray.train.get_checkpoint()}"
)
def _save_checkpoint(self, local_dir: str):
logger.info(
f"Saving checkpoint @ epoch={self._train_epoch_idx}, "
f"train_batch_idx={self._train_batch_idx}"
)
self._save_training_state(local_dir)
if ray.train.get_context().get_world_rank() == 0:
run_state = {
"epoch": self._train_epoch_idx,
"batch_idx": self._train_batch_idx,
"global_rows_processed_this_epoch": self._global_rows_processed_this_epoch,
}
torch.save(run_state, os.path.join(local_dir, "run_state.pt"))
metrics_json = {k: v.as_dict() for k, v in self._metrics.items()}
with open(os.path.join(local_dir, "metrics.json"), "w") as f:
json.dump(metrics_json, f)
def _report_checkpoint(self, metrics, checkpoint):
logger.info(
f"Uploading checkpoint @ epoch={self._train_epoch_idx}, "
f"train_batch_idx={self._train_batch_idx}"
)
checkpoint_dir_name = (
f"checkpoint_epoch={self._train_epoch_idx}_batch={self._train_batch_idx}"
)
ray.train.report(
metrics,
checkpoint=checkpoint,
checkpoint_dir_name=checkpoint_dir_name,
)
def run(self):
starting_epoch = self._train_epoch_idx
for _ in range(starting_epoch, self.benchmark_config.num_epochs):
with self._metrics["train/epoch"].timer():
self._train_epoch()
if not self.benchmark_config.skip_validation_at_epoch_end:
validation_metrics = self._validate()
self._checkpoint(validation_metrics)
if ray.train.get_context().get_world_rank() == 0:
logger.info(pprint.pformat(self.get_metrics(), indent=2))
self._cleanup()
def get_metrics(self, dataset_creation_time: float = 0.0) -> Dict[str, float]:
# TODO: These metrics should be aggregated across training workers.
metrics = {}
for key, metric in self._metrics.items():
metrics.update(
{
f"{key}-avg": metric.avg(),
f"{key}-min": metric.min(),
f"{key}-max": metric.max(),
f"{key}-total": metric.get(),
}
)
metrics["train/dataset_creation_time"] = dataset_creation_time
metrics["validation/dataset_creation_time"] = dataset_creation_time
# Throughput
# TODO: Ray Data can provide these throughput metrics automatically.
train_time = (
metrics["train/dataset_creation_time"]
+ self._metrics["train/step"].get()
# Include the time it takes to get the first batch.
+ self._metrics["train/iter_first_batch"].get()
+ self._metrics["train/iter_batch"].get()
)
if train_time > 0:
metrics["train/global_throughput"] = (
self._metrics["train/rows_processed"].get() / train_time
)
validation_time = (
metrics["validation/dataset_creation_time"]
+ self._metrics["validation/step"].get()
# Include the time it takes to get the first batch.
+ self._metrics["validation/iter_first_batch"].get()
+ self._metrics["validation/iter_batch"].get()
)
if validation_time > 0:
metrics["validation/global_throughput"] = (
self._metrics["validation/rows_processed"].get() / validation_time
)
# Extra time that each worker spends to restore from checkpoint,
# which includes downloading the checkpoint, loading the checkpoint,
# and skipping through batches that were already processed.
restoration_time = (
self._metrics["checkpoint/download"].get()
+ self._metrics["checkpoint/load"].get()
+ self._metrics["train/iter_skip_batch"].get()
)
if restoration_time > 0:
metrics["checkpoint/restoration_time"] = restoration_time
# Dataloader metrics (ex: Ray Data stats)
metrics.update(self.factory.get_dataloader_metrics())
return metrics
class VanillaTorchRunner(TrainLoopRunner):
"""A simple runner that uses a PyTorch model, optimizer, and loss function."""
def _setup(self):
model = self.factory.get_model()
self.model = ray.train.torch.prepare_model(model)
self.loss_fn = self.factory.get_loss_fn()
self.optimizer = torch.optim.Adam(self.model.parameters(), lr=1e-3)
def _train_step(self, batch):
self.model.train()
input_batch, labels = batch
self.model.train()
self.optimizer.zero_grad()
out = self.model(input_batch)
loss = self.loss_fn(out, labels)
loss.backward()
self.optimizer.step()
def _validate_step(self, batch):
self.model.eval()
input_batch, labels = batch
with torch.no_grad():
out = self.model(input_batch)
loss = self.loss_fn(out, labels)
return loss
def _save_training_state(self, local_dir: str):
# Standard DDP checkpointing.
if ray.train.get_context().get_world_rank() == 0:
torch.save(self.model.state_dict(), os.path.join(local_dir, "model.pt"))
torch.save(
self.optimizer.state_dict(), os.path.join(local_dir, "optimizer.pt")
)
def _load_training_state(self, local_dir: str):
self.model.load_state_dict(
torch.load(os.path.join(local_dir, "model.pt"), map_location="cpu")
)
self.optimizer.load_state_dict(
torch.load(os.path.join(local_dir, "optimizer.pt"), map_location="cpu")
)
@@ -0,0 +1,71 @@
# Standard library imports
from typing import List
import logging
# Third-party imports
from botocore.exceptions import NoCredentialsError
import ray
import ray.train
# Local imports
from s3_reader import S3Reader
from logger_utils import ContextLoggerAdapter
logger = ContextLoggerAdapter(logging.getLogger(__name__))
class S3JpegReader(S3Reader):
"""Extended S3Reader class for JPEG-specific functionality.
Provides specialized methods for:
1. Collecting JPEG file metadata (sizes) from S3
2. Distributing files among workers based on file sizes
3. Managing parallel S3 operations with Ray tasks
"""
def _get_file_urls(self, url: str) -> List[str]:
"""Get file URLs from S3 and distribute them among Ray workers.
Collects file metadata from S3 and distributes files among workers based on
file sizes to ensure balanced workload distribution.
Args:
url: S3 URL to list files from (e.g., "s3://bucket/path/to/directory")
Returns:
List of S3 URLs assigned to the current Ray worker
Raises:
S3CredentialsError: If AWS credentials are not found or invalid
S3FileError: If there's an error listing files from S3
"""
try:
# Get Ray worker configuration
worker_rank = ray.train.get_context().get_world_rank()
num_workers = ray.train.get_context().get_world_size()
# Parse S3 URL components
bucket, prefix = self._parse_s3_url(url)
# Collect file metadata for balanced distribution
logger.info(
f"Worker {worker_rank}: Collecting file metadata for balanced distribution"
)
file_urls, file_size_bytes = self._list_s3_files(bucket, prefix)
logger.info(f"Found {len(file_urls)} files in {url}")
# Distribute files based on size
return self._distribute_files(
file_urls=file_urls,
file_weights=file_size_bytes,
worker_rank=worker_rank,
num_workers=num_workers,
weight_unit="bytes",
)
except NoCredentialsError:
raise self.S3CredentialsError(
"AWS credentials not found. Ensure you have configured them."
)
except Exception as e:
raise self.S3FileError(f"Error listing files from {url}: {str(e)}")
@@ -0,0 +1,155 @@
# Standard library imports
from typing import List, Tuple
import logging
# Third-party imports
import boto3
from botocore.exceptions import NoCredentialsError
import ray
import ray.train
# Local imports
from s3_reader import S3Reader, AWS_REGION
from logger_utils import ContextLoggerAdapter
logger = ContextLoggerAdapter(logging.getLogger(__name__))
@ray.remote(num_cpus=0.25)
def _fetch_parquet_metadata(bucket: str, key: str, file_url: str) -> Tuple[str, int]:
"""Fetch Parquet row count using S3 Select in parallel.
Uses S3 Select to efficiently count rows in a Parquet file without reading
the entire file contents, enabling balanced distribution based on row counts.
Args:
bucket: S3 bucket name containing the Parquet file
key: S3 object key for the Parquet file
file_url: Full S3 URL for logging purposes
Returns:
Tuple containing:
- file_url: Full S3 URL of the processed file
- row_count: Number of rows in the Parquet file
"""
s3_client = boto3.client("s3", region_name=AWS_REGION)
# Execute S3 Select query to count rows
query = "SELECT COUNT(*) FROM s3object"
response = s3_client.select_object_content(
Bucket=bucket,
Key=key,
Expression=query,
ExpressionType="SQL",
InputSerialization={"Parquet": {}},
OutputSerialization={"CSV": {}},
)
# Extract row count from response
row_count = 0
for event in response["Payload"]:
if "Records" in event:
row_count = int(event["Records"]["Payload"].decode("utf-8").strip())
logger.info(f"File {file_url} has {row_count} rows")
return file_url, row_count
class S3ParquetReader(S3Reader):
"""Extended S3Reader class for Parquet-specific functionality.
Provides specialized methods for:
1. Collecting Parquet file metadata (row counts) from S3
2. Distributing files among workers based on row counts
3. Managing parallel S3 operations with Ray tasks
"""
def _collect_file_info(
self, bucket: str, prefix: str
) -> Tuple[List[str], List[int]]:
"""Collect file URLs and their row counts in parallel using Ray tasks.
Lists all Parquet files in the specified S3 prefix and launches parallel
tasks to count rows in each file using S3 Select for efficient metadata
collection.
Args:
bucket: S3 bucket name to list files from
prefix: S3 prefix to filter files
Returns:
Tuple containing:
- List of file URLs (e.g., "s3://bucket/path/to/file")
- List of row counts for each file
"""
file_urls, _ = self._list_s3_files(bucket, prefix)
# Launch parallel metadata collection tasks
tasks = []
for file_url in file_urls:
# Extract key from file_url
key = file_url.replace(f"s3://{bucket}/", "")
task = _fetch_parquet_metadata.remote(bucket, key, file_url)
tasks.append(task)
# Wait for all tasks to complete
worker_rank = ray.train.get_context().get_world_rank()
logger.info(
f"Worker {worker_rank}: Waiting for metadata from {len(tasks)} files..."
)
results = ray.get(tasks)
# Process results
file_urls, file_rows = zip(*results) if results else ([], [])
logger.info(
f"Worker {worker_rank}: Collected metadata for {len(file_urls)} files"
)
return list(file_urls), list(file_rows)
def _get_file_urls(self, url: str) -> List[str]:
"""Get file URLs from S3 and distribute them among Ray workers.
Collects file metadata from S3 and distributes files among workers based on
row counts to ensure balanced workload distribution.
Args:
url: S3 URL to list files from (e.g., "s3://bucket/path/to/directory")
Returns:
List of S3 URLs assigned to the current Ray worker
Raises:
S3CredentialsError: If AWS credentials are not found or invalid
S3FileError: If there's an error listing files from S3
"""
try:
# Get Ray worker configuration
worker_rank = ray.train.get_context().get_world_rank()
num_workers = ray.train.get_context().get_world_size()
# Parse S3 URL components
bucket, prefix = self._parse_s3_url(url)
# Collect file metadata for balanced distribution
logger.info(
f"Worker {worker_rank}: Collecting file metadata for balanced distribution"
)
file_urls, file_rows = self._collect_file_info(bucket, prefix)
logger.info(f"Found {len(file_urls)} files in {url}")
# Distribute files based on row counts
return self._distribute_files(
file_urls=file_urls,
file_weights=file_rows,
worker_rank=worker_rank,
num_workers=num_workers,
weight_unit="rows",
)
except NoCredentialsError:
raise self.S3CredentialsError(
"AWS credentials not found. Ensure you have configured them."
)
except Exception as e:
raise self.S3FileError(f"Error listing files from {url}: {str(e)}")
+255
View File
@@ -0,0 +1,255 @@
# Standard library imports
from typing import Tuple, List, Optional
import logging
# Third-party imports
import boto3
import ray
import ray.train
# Local imports
from logger_utils import ContextLoggerAdapter
# AWS configuration
AWS_REGION = "us-west-2"
logger = ContextLoggerAdapter(logging.getLogger(__name__))
@ray.remote(num_cpus=0.25)
def _list_s3_batch(
bucket: str,
prefix: str,
continuation_token: Optional[str] = None,
batch_size: int = 1000,
) -> Tuple[List[Tuple[str, int]], Optional[str]]:
"""List a batch of files from S3 in parallel.
Makes a paginated request to S3's list_objects_v2 API to efficiently list files
in batches. Each file is returned with its size in bytes.
Args:
bucket: S3 bucket name to list files from
prefix: S3 prefix to filter files (e.g., "path/to/directory/")
continuation_token: Token from previous request for pagination
batch_size: Maximum number of files to return in one request (default: 1000)
Returns:
Tuple containing:
- List of (file_url, size) tuples, where:
- file_url: Full S3 URL (e.g., "s3://bucket/path/to/file")
- size: File size in bytes
- Optional[str]: Token for the next batch (None if no more files)
"""
s3_client = boto3.client("s3")
# Prepare request parameters
list_params = {
"Bucket": bucket,
"Prefix": prefix,
"MaxKeys": batch_size,
}
if continuation_token:
list_params["ContinuationToken"] = continuation_token
# List objects from S3
response = s3_client.list_objects_v2(**list_params)
# Return empty results if no files found
if "Contents" not in response:
return [], None
# Extract file URLs and sizes
batch_files = response["Contents"]
results = [(f"s3://{bucket}/{f['Key']}", f["Size"]) for f in batch_files]
# Get token for next batch
next_token = (
response.get("NextContinuationToken") if response.get("IsTruncated") else None
)
return results, next_token
class S3Reader:
"""Base class for reading files from S3.
Provides common functionality for:
1. S3 client initialization and management
2. URL parsing and validation
3. File listing with pagination
4. Worker-based file distribution
5. Error handling for S3 operations
"""
class S3Error(Exception):
"""Base exception for S3-related errors."""
pass
class S3CredentialsError(S3Error):
"""Raised when AWS credentials are not found or invalid."""
pass
class S3FileError(S3Error):
"""Raised when there's an error accessing S3 files."""
pass
def __init__(self) -> None:
"""Initialize the S3Reader with lazy client initialization."""
self._s3_client = None
@property
def s3_client(self) -> "boto3.client":
"""Get or create the S3 client with AWS region configuration.
Uses lazy initialization to avoid serialization issues with Ray.
Returns:
boto3.client: Configured S3 client
"""
if self._s3_client is None:
self._s3_client = boto3.client("s3", region_name=AWS_REGION)
return self._s3_client
def _parse_s3_url(self, s3_url: str) -> Tuple[str, str]:
"""Parse an S3 URL into bucket and key components.
Args:
s3_url: S3 URL in format "s3://bucket/key"
Returns:
Tuple[str, str]: (bucket, key) components
Raises:
S3FileError: If URL is not a valid S3 URL
"""
if not s3_url.startswith("s3://"):
raise self.S3FileError(f"Invalid S3 URL format: {s3_url}")
s3_parts = s3_url.replace("s3://", "").split("/", 1)
return s3_parts[0], s3_parts[1]
def _list_s3_files(self, bucket: str, prefix: str) -> Tuple[List[str], List[int]]:
"""List files in an S3 bucket with the given prefix.
Uses Ray tasks to make parallel requests to S3's list_objects_v2 API,
handling pagination automatically. Returns file URLs and their sizes.
Args:
bucket: S3 bucket name
prefix: S3 prefix to filter files
Returns:
Tuple containing:
- List of file URLs (e.g., "s3://bucket/path/to/file")
- List of file sizes in bytes
"""
file_urls = []
file_sizes = []
continuation_token = None
batch_size = 1000 # Maximum allowed by S3 API
while True:
# Get next batch of files
batch_results, next_token = ray.get(
_list_s3_batch.remote(
bucket=bucket,
prefix=prefix,
continuation_token=continuation_token,
batch_size=batch_size,
)
)
# Handle empty results
if not batch_results:
if not file_urls: # Only warn on first request
logger.info(
f"No files found in s3://{bucket}/{prefix}", level="warning"
)
break
# Process batch results
batch_urls, batch_sizes = zip(*batch_results)
file_urls.extend(batch_urls)
file_sizes.extend(batch_sizes)
# Log progress
logger.info(f"Listed {len(file_urls)} files from s3://{bucket}/{prefix}")
# Continue if there are more files
if not next_token:
break
continuation_token = next_token
return file_urls, file_sizes
def _distribute_files(
self,
file_urls: List[str],
file_weights: List[int],
worker_rank: int,
num_workers: int,
weight_unit: str = "units",
) -> List[str]:
"""Distribute files among workers based on weights.
Uses a greedy algorithm to distribute files among workers while trying to
minimize the difference in total weight between workers. Files are sorted
by weight (descending) before distribution for better balance.
Args:
file_urls: List of file URLs to distribute
file_weights: List of weights for each file (e.g., size, row count)
worker_rank: Current worker's rank
num_workers: Total number of workers
weight_unit: Unit of measurement for weights (e.g., "bytes", "rows")
Returns:
List of file URLs assigned to this worker
"""
# Sort files by weight
files_with_weights = sorted(
zip(file_urls, file_weights), key=lambda x: x[1], reverse=True
)
file_urls = [f[0] for f in files_with_weights]
file_weights = [f[1] for f in files_with_weights]
# Handle single worker case
if num_workers <= 1 or not file_urls:
logger.info(
f"Worker {worker_rank}: Single worker or no files, "
f"returning all {len(file_urls)} files with total {sum(file_weights)} "
f"{weight_unit}"
)
return file_urls
# Calculate target weight per worker
total_weight = sum(file_weights)
target_weight_per_worker = total_weight / num_workers
logger.info(
f"Worker {worker_rank}: Total {weight_unit}: {total_weight}, "
f"Target per worker: {target_weight_per_worker:.0f} {weight_unit}"
)
# Initialize worker assignments
worker_files = [[] for _ in range(num_workers)]
worker_weights = [0] * num_workers
# Distribute files using greedy algorithm
for file_url, weight in zip(file_urls, file_weights):
min_weight_worker = min(range(num_workers), key=lambda w: worker_weights[w])
worker_files[min_weight_worker].append(file_url)
worker_weights[min_weight_worker] += weight
# Get this worker's assignment
my_files = worker_files[worker_rank]
my_weight = worker_weights[worker_rank]
logger.info(
f"Worker {worker_rank}: Assigned {len(my_files)}/{len(file_urls)} "
f"files with {my_weight}/{total_weight} {weight_unit} "
f"({my_weight/total_weight*100:.1f}%)"
)
return my_files
+1
View File
@@ -0,0 +1 @@
../../nightly_tests/setup_chaos.py
@@ -0,0 +1,186 @@
from typing import Dict, Iterator, Tuple
import logging
from abc import ABC, abstractmethod
import multiprocessing
import torch
from torch.utils.data import IterableDataset
import ray
import ray.train
from constants import DatasetKey
from config import BenchmarkConfig, TorchConfig
from dataloader_factory import BaseDataLoaderFactory
from logger_utils import ContextLoggerAdapter
logger = ContextLoggerAdapter(logging.getLogger(__name__))
class TorchDataLoaderFactory(BaseDataLoaderFactory, ABC):
"""Factory for creating PyTorch DataLoaders."""
@staticmethod
def worker_init_fn(worker_id: int):
"""Initialize each worker with proper CUDA settings and seed.
Args:
worker_id: The ID of the worker being initialized
"""
# Set worker-specific seed for reproducibility
worker_seed = torch.initial_seed() % 2**32
torch.manual_seed(worker_seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(worker_seed)
torch.cuda.manual_seed_all(worker_seed)
logger.info(f"Initialized worker {worker_id} with seed {worker_seed}")
def __init__(
self,
benchmark_config: BenchmarkConfig,
):
"""Initialize the factory.
Args:
benchmark_config: Configuration for the benchmark
"""
super().__init__(benchmark_config)
dataloader_config = self.get_dataloader_config()
assert isinstance(dataloader_config, TorchConfig), type(dataloader_config)
# Get worker configuration
num_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1
self.num_torch_workers = dataloader_config.num_torch_workers
self.num_ray_workers = benchmark_config.num_workers
# Log configuration without worker rank since context may not be initialized
logger.info(
f"Configuration: {self.num_ray_workers * self.num_torch_workers} total workers "
f"({self.num_ray_workers} Ray × {self.num_torch_workers} Torch) "
f"across {num_gpus} GPUs"
)
def _get_device(self) -> torch.device:
"""Get the device for the current worker using Ray Train's device management."""
try:
device = ray.train.torch.get_device()
except RuntimeError:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
worker_rank = ray.train.get_context().get_world_rank()
logger.info(f"Worker {worker_rank}: Using device: {device}")
return device
@abstractmethod
def create_batch_iterator(
self, dataloader: torch.utils.data.DataLoader, device: torch.device
) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Create a safe iterator that handles device transfer and error handling.
Args:
dataloader: The PyTorch DataLoader to iterate over
device: The device to move tensors to
Returns:
An iterator that yields batches moved to the specified device
"""
pass
@abstractmethod
def get_iterable_datasets(self) -> Dict[str, IterableDataset]:
"""Get the train and validation datasets.
Returns:
A dictionary containing the train and validation datasets.
"""
pass
def _create_multiprocessing_context(self):
# Importing libs in torch dataloader worker subprocesses is very slow.
# Preload some modules to speed up subprocess forking.
ctx = multiprocessing.get_context("forkserver")
modules = ["torch", "torchvision", "pandas", "numpy", "boto3", "fsspec"]
ctx.set_forkserver_preload(modules)
return ctx
def _create_dataloader(self, dataset_key: DatasetKey, batch_size: int):
worker_rank = ray.train.get_context().get_world_rank()
dataloader_config = self.get_dataloader_config()
# Create dataset and dataloader
ds = self.get_iterable_datasets()[dataset_key]
device = self._get_device()
# Adjust worker settings for 0 workers case
num_workers = max(0, self.num_torch_workers)
persistent_workers = num_workers > 0
pin_memory = dataloader_config.torch_pin_memory
if dataloader_config.torch_prefetch_factor >= 0:
prefetch_factor = dataloader_config.torch_prefetch_factor
else:
prefetch_factor = None
timeout = (
dataloader_config.torch_dataloader_timeout_seconds if num_workers > 0 else 0
)
logger.info(
f"Worker {worker_rank}: Creating train DataLoader with "
f"num_workers={num_workers}, pin_memory={pin_memory}, "
f"persistent_workers={persistent_workers}, prefetch_factor={prefetch_factor}, "
f"timeout={timeout}, batch_size={batch_size}"
)
multiprocessing_args = {}
if num_workers > 0:
multiprocessing_args = dict(
multiprocessing_context=self._create_multiprocessing_context(),
worker_init_fn=self.worker_init_fn,
persistent_workers=persistent_workers,
)
dataloader = torch.utils.data.DataLoader(
dataset=ds,
batch_size=batch_size,
num_workers=num_workers,
pin_memory=pin_memory,
prefetch_factor=prefetch_factor,
timeout=timeout,
drop_last=False,
**multiprocessing_args,
)
# Add a DistributedSampler to the dataloader if possible (map-style datasets)
dataloader = ray.train.torch.prepare_data_loader(
dataloader, move_to_device=False
)
return self.create_batch_iterator(dataloader, device)
def get_train_dataloader(self) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Create a DataLoader for training data.
Returns:
An iterator that yields (image, label) tensors for training
"""
worker_rank = ray.train.get_context().get_world_rank()
logger.info(f"Worker {worker_rank}: Creating train dataloader")
return self._create_dataloader(
DatasetKey.TRAIN, self.get_dataloader_config().train_batch_size
)
def get_val_dataloader(self) -> Iterator[Tuple[torch.Tensor, torch.Tensor]]:
"""Create a DataLoader for validation data.
Returns:
An iterator that yields (image, label) tensors for validation
"""
worker_rank = ray.train.get_context().get_world_rank()
logger.info(f"Worker {worker_rank}: Creating validation dataloader")
return self._create_dataloader(
DatasetKey.VALID, self.get_dataloader_config().validation_batch_size
)
@@ -0,0 +1,123 @@
import json
import logging
import os
import pprint
import time
import ray.train
from ray._private.test_utils import safe_write_to_results_json
from ray.train.torch import TorchTrainer
from ray.train.v2._internal.util import date_str
from config import BenchmarkConfig, cli_to_config
from benchmark_factory import BenchmarkFactory
from ray_dataloader_factory import RayDataLoaderFactory
logger = logging.getLogger(__name__)
METRICS_OUTPUT_PATH = "/mnt/cluster_storage/train_benchmark_metrics.json"
def train_fn_per_worker(config):
factory: BenchmarkFactory = config["factory"]
if factory.benchmark_config.task == "recsys":
from recsys.torchrec_runner import TorchRecRunner
runner = TorchRecRunner(factory)
else:
from runner import VanillaTorchRunner
runner = VanillaTorchRunner(factory)
runner.run()
metrics = runner.get_metrics(
dataset_creation_time=config.get("dataset_creation_time", 0)
)
if ray.train.get_context().get_world_rank() == 0:
with open(METRICS_OUTPUT_PATH, "w") as f:
json.dump(metrics, f)
def get_datasets_and_data_config(factory: BenchmarkFactory):
dataloader_factory = factory.get_dataloader_factory()
if isinstance(dataloader_factory, RayDataLoaderFactory):
datasets = dataloader_factory.get_ray_datasets()
data_config = dataloader_factory.get_ray_data_config()
else:
datasets = {}
data_config = None
return datasets, data_config
def main():
start_time = time.perf_counter()
logging.basicConfig(level=logging.INFO)
benchmark_config: BenchmarkConfig = cli_to_config()
logger.info(
"\nBenchmark config:\n" + pprint.pformat(benchmark_config.__dict__, indent=2)
)
if benchmark_config.task == "image_classification":
from image_classification.factory import ImageClassificationFactory
factory = ImageClassificationFactory(benchmark_config)
elif benchmark_config.task == "recsys":
from recsys.recsys_factory import RecsysFactory
factory = RecsysFactory(benchmark_config)
else:
raise ValueError(f"Unknown task: {benchmark_config.task}")
datasets, data_config = get_datasets_and_data_config(factory)
dataset_creation_time = time.perf_counter() - start_time
trainer = TorchTrainer(
train_loop_per_worker=train_fn_per_worker,
train_loop_config={
"factory": factory,
"dataset_creation_time": dataset_creation_time,
},
scaling_config=ray.train.ScalingConfig(
num_workers=benchmark_config.num_workers,
use_gpu=not benchmark_config.mock_gpu,
resources_per_worker={"MOCK_GPU": 1} if benchmark_config.mock_gpu else None,
),
run_config=ray.train.RunConfig(
storage_path=f"{os.environ['ANYSCALE_ARTIFACT_STORAGE']}/train_benchmark/",
name=f"{benchmark_config.task}-{date_str(include_ms=True)}",
failure_config=ray.train.FailureConfig(
max_failures=benchmark_config.max_failures
),
),
datasets=datasets,
dataset_config=data_config,
)
trainer.fit()
end_time = time.perf_counter()
with open(METRICS_OUTPUT_PATH, "r") as f:
metrics = json.load(f)
final_metrics_str = (
f"\nTotal training time: {end_time - start_time} seconds\n"
"Final metrics:\n" + "-" * 80 + "\n" + pprint.pformat(metrics) + "\n" + "-" * 80
)
logger.info(final_metrics_str)
# Write metrics as a release test result.
safe_write_to_results_json(metrics)
if __name__ == "__main__":
# Workers need to access the working directory module.
ray.init(runtime_env={"working_dir": os.path.dirname(__file__)})
main()