chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
advanced_instance_config:
|
||||
TagSpecifications:
|
||||
- ResourceType: "instance"
|
||||
Tags:
|
||||
- Key: ttl-hours
|
||||
Value: '24'
|
||||
|
||||
head_node:
|
||||
instance_type: m5.4xlarge
|
||||
|
||||
worker_nodes:
|
||||
- name: train-worker-node
|
||||
instance_type: g4dn.xlarge
|
||||
min_nodes: 2
|
||||
max_nodes: 2
|
||||
market_type: ON_DEMAND
|
||||
labels:
|
||||
ray-subcluster: train
|
||||
- name: validation-worker-node
|
||||
instance_type: g4dn.xlarge
|
||||
min_nodes: 0
|
||||
max_nodes: 2
|
||||
market_type: ON_DEMAND
|
||||
labels:
|
||||
ray-subcluster: validation
|
||||
+723
@@ -0,0 +1,723 @@
|
||||
from enum import Enum
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import torch
|
||||
import torch.distributed.checkpoint as dist_cp
|
||||
import torchmetrics
|
||||
from torch.distributed.checkpoint.state_dict import get_state_dict
|
||||
from torch.distributed.checkpoint.state_dict_saver import async_save
|
||||
from torch.nn import CrossEntropyLoss
|
||||
from torch.optim import Adam
|
||||
from torchvision import transforms
|
||||
from torchvision.models import VisionTransformer
|
||||
from torchvision.transforms import ToTensor, Normalize
|
||||
import ray
|
||||
import ray.train
|
||||
import ray.train.torch
|
||||
from ray.data import ExecutionOptions
|
||||
from ray.train import CheckpointUploadMode, ValidationConfig, ValidationTaskConfig
|
||||
from ray._private.test_utils import safe_write_to_results_json
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class ValidationType(Enum):
|
||||
# run synchronously with the training loop
|
||||
INLINE = "inline"
|
||||
# run asynchronously with a torch trainer
|
||||
TORCH_TRAINER = "torch_trainer"
|
||||
# run asynchronously with a map batches function
|
||||
MAP_BATCHES = "map_batches"
|
||||
|
||||
|
||||
class CheckpointSaveMode(Enum):
|
||||
# save to disk with torch.save
|
||||
TORCH_SAVE = "torch_save"
|
||||
# synchronous save via Torch DCP
|
||||
TORCH_DCP_SYNC = "torch_dcp_sync"
|
||||
# asynchronous save, Ray Train's background thread waits for completion.
|
||||
TORCH_DCP_ASYNC = "torch_dcp_async"
|
||||
|
||||
|
||||
MAXIMUM_ALLOWED_ACCURACY_DIFF = 0.2
|
||||
MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER = 1.1
|
||||
|
||||
# ==== Start dataset and model creation ======
|
||||
|
||||
STORAGE_PATH_PREFIX = os.environ.get("ANYSCALE_ARTIFACT_STORAGE", "artifact_storage")
|
||||
STORAGE_PATH = f"{STORAGE_PATH_PREFIX}/ray_summit_24_train_demo"
|
||||
|
||||
|
||||
def transform_cifar(row: dict):
|
||||
transform = transforms.Compose(
|
||||
[ToTensor(), Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]
|
||||
)
|
||||
row["image"] = transform(row["image"])
|
||||
return row
|
||||
|
||||
|
||||
validation_dataset = ray.data.read_parquet(f"{STORAGE_PATH}/cifar10-parquet/test").map(
|
||||
transform_cifar
|
||||
)
|
||||
|
||||
|
||||
def create_model():
|
||||
return VisionTransformer(
|
||||
image_size=32, # CIFAR-10 image size is 32x32
|
||||
patch_size=4, # Patch size is 4x4
|
||||
num_layers=24, # Number of transformer layers
|
||||
num_heads=8, # Number of attention heads
|
||||
hidden_dim=384, # Hidden size (can be adjusted)
|
||||
mlp_dim=768, # MLP dimension (can be adjusted)
|
||||
num_classes=10, # CIFAR-10 has 10 classes
|
||||
)
|
||||
|
||||
|
||||
# ==== End dataset and model creation ======
|
||||
|
||||
# ==== Start map_batches approach ======
|
||||
|
||||
|
||||
class Predictor:
|
||||
def __init__(self, checkpoint):
|
||||
self.model = create_model()
|
||||
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
model_pt = os.path.join(checkpoint_dir, "model.pt")
|
||||
if os.path.exists(model_pt):
|
||||
self.model.load_state_dict(torch.load(model_pt))
|
||||
else:
|
||||
state_dict = {"model": self.model.state_dict()}
|
||||
dist_cp.load(
|
||||
state_dict,
|
||||
storage_reader=dist_cp.FileSystemReader(checkpoint_dir),
|
||||
)
|
||||
self.model.load_state_dict(state_dict["model"])
|
||||
|
||||
self.model.cuda().eval()
|
||||
|
||||
def __call__(self, batch):
|
||||
image = torch.as_tensor(batch["image"], dtype=torch.float32, device="cuda")
|
||||
label = torch.as_tensor(batch["label"], dtype=torch.int8, device="cuda")
|
||||
pred = self.model(image)
|
||||
return {"res": (pred.argmax(1) == label).cpu().numpy()}
|
||||
|
||||
|
||||
def validate_with_map_batches(checkpoint):
|
||||
validation_dataset.set_name("async_val_map_batches")
|
||||
validation_dataset.context.execution_options.label_selector = {
|
||||
"ray-subcluster": "validation"
|
||||
}
|
||||
start_time = time.time()
|
||||
eval_res = validation_dataset.map_batches(
|
||||
Predictor,
|
||||
batch_size=128,
|
||||
num_gpus=1,
|
||||
fn_constructor_kwargs={"checkpoint": checkpoint},
|
||||
concurrency=2,
|
||||
)
|
||||
mean = eval_res.mean(["res"])
|
||||
return {
|
||||
"score": mean,
|
||||
"validation_time": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
# ==== End map_batches approach ======
|
||||
|
||||
# ==== Start TorchTrainer approach ======
|
||||
|
||||
|
||||
def eval_only_train_func(config_dict):
|
||||
# Load the checkpoint
|
||||
model = create_model()
|
||||
|
||||
checkpoint = config_dict["checkpoint"]
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
model_pt = os.path.join(checkpoint_dir, "model.pt")
|
||||
if os.path.exists(model_pt):
|
||||
model.load_state_dict(torch.load(model_pt))
|
||||
else:
|
||||
state_dict = {"model": model.state_dict()}
|
||||
dist_cp.load(
|
||||
state_dict,
|
||||
storage_reader=dist_cp.FileSystemReader(checkpoint_dir),
|
||||
)
|
||||
model.load_state_dict(state_dict["model"])
|
||||
|
||||
model.cuda().eval()
|
||||
|
||||
# Get the data
|
||||
test_data_shard = ray.train.get_dataset_shard("async_val_torch_trainer")
|
||||
test_dataloader = test_data_shard.iter_torch_batches(batch_size=128)
|
||||
|
||||
# Report metrics
|
||||
mean_acc = torchmetrics.Accuracy(task="multiclass", num_classes=10, top_k=1).cuda()
|
||||
with torch.no_grad():
|
||||
for batch in test_dataloader:
|
||||
images, labels = batch["image"], batch["label"]
|
||||
outputs = model(images)
|
||||
mean_acc(outputs.argmax(1), labels)
|
||||
return {"score": mean_acc.compute().item()}
|
||||
|
||||
|
||||
def validate_with_torch_trainer(checkpoint, parent_run_name, epoch, batch_idx):
|
||||
start_time = time.time()
|
||||
trainer = ray.train.torch.TorchTrainer(
|
||||
eval_only_train_func,
|
||||
train_loop_config={"checkpoint": checkpoint},
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=2, use_gpu=True),
|
||||
datasets={"async_val_torch_trainer": validation_dataset},
|
||||
run_config=ray.train.RunConfig(
|
||||
name=f"{parent_run_name}-validation_epoch={epoch}_batch_idx={batch_idx}"
|
||||
),
|
||||
dataset_config=ray.train.DataConfig(
|
||||
execution_options={
|
||||
"async_val_torch_trainer": ExecutionOptions(
|
||||
label_selector={"ray-subcluster": "validation"}
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
result = trainer.fit()
|
||||
return {
|
||||
"score": result.return_value["score"],
|
||||
"validation_time": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
# ==== End TorchTrainer approach ======
|
||||
|
||||
|
||||
def validate_and_report(
|
||||
model,
|
||||
epoch,
|
||||
batch_idx,
|
||||
blocked_times,
|
||||
config,
|
||||
loss,
|
||||
):
|
||||
validate_within_trainer = config["validate_within_trainer"]
|
||||
num_epochs = config["num_epochs"]
|
||||
checkpoint_upload_mode = config["checkpoint_upload_mode"]
|
||||
validation_type = config["validation_type"]
|
||||
checkpoint_save_mode = config["checkpoint_save_mode"]
|
||||
|
||||
if validate_within_trainer:
|
||||
test_dataloader = ray.train.get_dataset_shard("inline_val").iter_torch_batches(
|
||||
batch_size=128
|
||||
)
|
||||
|
||||
# Validate model within training loop
|
||||
val_elapsed_time = None
|
||||
if validate_within_trainer:
|
||||
val_start_time = time.time()
|
||||
mean_acc = torchmetrics.Accuracy(
|
||||
task="multiclass", num_classes=10, top_k=1
|
||||
).cuda()
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
for batch in test_dataloader:
|
||||
X, y = batch["image"], batch["label"]
|
||||
outputs = model(X)
|
||||
mean_acc(outputs.argmax(1), y)
|
||||
val_elapsed_time = time.time() - val_start_time
|
||||
|
||||
# Report metrics + checkpoint + validate
|
||||
metrics = {"loss": loss.item(), "epoch": epoch}
|
||||
if validate_within_trainer and epoch == num_epochs - 1:
|
||||
metrics["score"] = mean_acc.compute().item()
|
||||
|
||||
# Record how long the upload process takes
|
||||
start_time = time.time()
|
||||
|
||||
# DCP save is a distributed collective so all ranks must call it together.
|
||||
ckpt_ref = None # Only used by TORCH_DCP_ASYNC
|
||||
iteration_checkpoint_dir = None # Not used by TORCH_SAVE
|
||||
if checkpoint_save_mode in (
|
||||
CheckpointSaveMode.TORCH_DCP_SYNC,
|
||||
CheckpointSaveMode.TORCH_DCP_ASYNC,
|
||||
):
|
||||
# For DCP, all workers write shards to the same shared storage path so that
|
||||
# the full checkpoint is available without any upload step.
|
||||
iteration_checkpoint_dir = (
|
||||
ray.train.get_context()
|
||||
.get_storage()
|
||||
.build_checkpoint_path_from_name(f"dcp_epoch_{epoch}_batch_{batch_idx}")
|
||||
)
|
||||
|
||||
storage_writer = dist_cp.FileSystemWriter(iteration_checkpoint_dir)
|
||||
model_dict, _ = get_state_dict(model=model, optimizers=())
|
||||
|
||||
if checkpoint_save_mode == CheckpointSaveMode.TORCH_DCP_SYNC:
|
||||
# Save via Torch DCP
|
||||
dist_cp.save({"model": model_dict}, storage_writer=storage_writer)
|
||||
elif checkpoint_save_mode == CheckpointSaveMode.TORCH_DCP_ASYNC:
|
||||
# Initiate async save; rank 0 will wait via checkpoint_upload_fn
|
||||
ckpt_ref = async_save({"model": model_dict}, storage_writer=storage_writer)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
if val_elapsed_time:
|
||||
metrics["validation_time"] = val_elapsed_time
|
||||
|
||||
if validation_type == ValidationType.TORCH_TRAINER:
|
||||
validation = ValidationTaskConfig(
|
||||
fn_kwargs={
|
||||
"parent_run_name": ray.train.get_context().get_experiment_name(),
|
||||
"epoch": epoch,
|
||||
"batch_idx": batch_idx,
|
||||
}
|
||||
)
|
||||
elif validation_type == ValidationType.MAP_BATCHES:
|
||||
validation = True
|
||||
else:
|
||||
validation = False
|
||||
|
||||
if checkpoint_save_mode == CheckpointSaveMode.TORCH_SAVE:
|
||||
# We can't use `tempfile.TemporaryDirectory()` due to CheckpointUploadMode.ASYNC
|
||||
iteration_checkpoint_dir = tempfile.mkdtemp()
|
||||
torch.save(
|
||||
model.module.state_dict(),
|
||||
os.path.join(iteration_checkpoint_dir, "model.pt"),
|
||||
)
|
||||
|
||||
ray.train.report(
|
||||
metrics,
|
||||
checkpoint=ray.train.Checkpoint.from_directory(
|
||||
iteration_checkpoint_dir
|
||||
),
|
||||
checkpoint_upload_mode=checkpoint_upload_mode,
|
||||
delete_local_checkpoint_after_upload=True,
|
||||
validation=validation,
|
||||
)
|
||||
elif checkpoint_save_mode == CheckpointSaveMode.TORCH_DCP_SYNC:
|
||||
# Shards are already in shared storage; no upload needed.
|
||||
ray.train.report(
|
||||
metrics,
|
||||
checkpoint=ray.train.Checkpoint.from_directory(
|
||||
iteration_checkpoint_dir
|
||||
),
|
||||
checkpoint_upload_mode=CheckpointUploadMode.NO_UPLOAD,
|
||||
validation=validation,
|
||||
)
|
||||
elif checkpoint_save_mode == CheckpointSaveMode.TORCH_DCP_ASYNC:
|
||||
# Shards are written directly to shared storage. The `async_save`
|
||||
# returns a future that will wait until all workers are complete.
|
||||
# Internally it has a barrier before `future.result()` is returned.
|
||||
def wait_async_save(
|
||||
checkpoint, checkpoint_dir_name, upload_complete_ref=ckpt_ref
|
||||
):
|
||||
upload_complete_ref.result()
|
||||
return checkpoint
|
||||
|
||||
ray.train.report(
|
||||
metrics,
|
||||
checkpoint=ray.train.Checkpoint.from_directory(
|
||||
iteration_checkpoint_dir
|
||||
),
|
||||
checkpoint_upload_fn=wait_async_save,
|
||||
checkpoint_dir_name=f"dcp_epoch_{epoch}_batch_{batch_idx}",
|
||||
checkpoint_upload_mode=CheckpointUploadMode.ASYNC,
|
||||
# iteration_checkpoint_dir is already in shared storage so don't delete it.
|
||||
delete_local_checkpoint_after_upload=False,
|
||||
validation=validation,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
blocked_times.append(time.time() - start_time)
|
||||
else:
|
||||
ray.train.report({}, None)
|
||||
|
||||
|
||||
def train_func(config):
|
||||
batch_size = 256
|
||||
num_epochs = config["num_epochs"]
|
||||
midpoint_batch = int(config["rows_per_worker"] / batch_size / 2)
|
||||
|
||||
# Prepare model, dataloader, and possibly metrics
|
||||
model = create_model()
|
||||
model = ray.train.torch.prepare_model(model)
|
||||
criterion = CrossEntropyLoss()
|
||||
optimizer = Adam(model.parameters(), lr=0.001)
|
||||
train_data_shard = ray.train.get_dataset_shard("train")
|
||||
train_dataloader = train_data_shard.iter_torch_batches(batch_size=batch_size)
|
||||
|
||||
# Train / eval / report loop
|
||||
blocked_times = []
|
||||
for epoch in range(num_epochs):
|
||||
|
||||
# Train model, then validate/report at midpoint and end of epoch
|
||||
model.train()
|
||||
i = 0
|
||||
for i, batch in enumerate(train_dataloader):
|
||||
images, labels = batch["image"], batch["label"]
|
||||
outputs = model(images)
|
||||
loss = criterion(outputs, labels)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if i == midpoint_batch:
|
||||
validate_and_report(model, epoch, i, blocked_times, config, loss)
|
||||
validate_and_report(model, epoch, i, blocked_times, config, loss)
|
||||
|
||||
# Return train_func metrics
|
||||
return {
|
||||
"report_blocked_times": blocked_times,
|
||||
"train_func_return_time": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def run_training_with_validation(
|
||||
checkpoint_upload_mode: CheckpointUploadMode,
|
||||
validation_type: ValidationType,
|
||||
validate_within_trainer: bool,
|
||||
num_epochs: int,
|
||||
train_dataset: ray.data.Dataset,
|
||||
training_rows: int,
|
||||
checkpoint_save_mode: CheckpointSaveMode,
|
||||
):
|
||||
# Launch distributed training job.
|
||||
start_time = time.time()
|
||||
scaling_config = ray.train.ScalingConfig(num_workers=2, use_gpu=True)
|
||||
|
||||
if validation_type == ValidationType.INLINE:
|
||||
validation_config = None
|
||||
elif validation_type == ValidationType.TORCH_TRAINER:
|
||||
validation_config = ValidationConfig(validate_with_torch_trainer)
|
||||
elif validation_type == ValidationType.MAP_BATCHES:
|
||||
validation_config = ValidationConfig(validate_with_map_batches)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
datasets = {"train": train_dataset}
|
||||
train_loop_config = {
|
||||
"validate_within_trainer": validate_within_trainer,
|
||||
"num_epochs": num_epochs,
|
||||
"checkpoint_upload_mode": checkpoint_upload_mode,
|
||||
"rows_per_worker": training_rows / 2,
|
||||
"validation_type": validation_type,
|
||||
"checkpoint_save_mode": checkpoint_save_mode,
|
||||
}
|
||||
if validate_within_trainer:
|
||||
datasets["inline_val"] = validation_dataset
|
||||
# Sync validation: train workers iterate both datasets, so split each
|
||||
# across the train subcluster and the validation subcluster respectively.
|
||||
dataset_config = ray.train.DataConfig(
|
||||
datasets_to_split=["train", "inline_val"],
|
||||
execution_options={
|
||||
"train": ExecutionOptions(label_selector={"ray-subcluster": "train"}),
|
||||
"inline_val": ExecutionOptions(
|
||||
label_selector={"ray-subcluster": "validation"}
|
||||
),
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Async validation: the validation dataset is consumed by a separate
|
||||
# driver (validate_with_torch_trainer / validate_with_map_batches),
|
||||
# which sets its own subcluster label.
|
||||
dataset_config = ray.train.DataConfig(
|
||||
datasets_to_split=["train"],
|
||||
execution_options={
|
||||
"train": ExecutionOptions(label_selector={"ray-subcluster": "train"}),
|
||||
},
|
||||
)
|
||||
|
||||
# async_save additionally requires a CPU process group alongside the GPU one
|
||||
# because it runs collectives in a background thread.
|
||||
if checkpoint_save_mode == CheckpointSaveMode.TORCH_DCP_ASYNC:
|
||||
torch_config = ray.train.torch.TorchConfig(backend="cpu:gloo,cuda:nccl")
|
||||
else:
|
||||
torch_config = None
|
||||
|
||||
trainer = ray.train.torch.TorchTrainer(
|
||||
train_func,
|
||||
validation_config=validation_config,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=scaling_config,
|
||||
datasets=datasets,
|
||||
torch_config=torch_config,
|
||||
run_config=ray.train.RunConfig(storage_path="/mnt/cluster_storage"),
|
||||
dataset_config=dataset_config,
|
||||
)
|
||||
result = trainer.fit()
|
||||
end_time = time.time()
|
||||
|
||||
# Return metrics
|
||||
# TODO: consider measuring how long it takes to kick off validation,
|
||||
# how long checkpoint upload takes, distribution of times
|
||||
train_func_metrics = result.return_value
|
||||
return {
|
||||
"e2e_time": end_time - start_time,
|
||||
"final_validation_waiting_time": (
|
||||
end_time - train_func_metrics["train_func_return_time"]
|
||||
),
|
||||
"total_report_blocked_time": sum(train_func_metrics["report_blocked_times"]),
|
||||
"total_validation_time": sum(
|
||||
m["validation_time"] for c, m in result.best_checkpoints[:-1]
|
||||
),
|
||||
"final_score": result.best_checkpoints[-2][1]["score"],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
train_dataset = ray.data.read_parquet(f"{STORAGE_PATH}/cifar10-parquet/train").map(
|
||||
transform_cifar
|
||||
)
|
||||
training_rows = train_dataset.count()
|
||||
consolidated_metrics = {}
|
||||
num_epochs = 10
|
||||
consolidated_metrics["sync_cp_inline_val_metrics"] = run_training_with_validation(
|
||||
CheckpointUploadMode.SYNC,
|
||||
ValidationType.INLINE,
|
||||
True,
|
||||
num_epochs,
|
||||
train_dataset,
|
||||
training_rows,
|
||||
CheckpointSaveMode.TORCH_SAVE,
|
||||
)
|
||||
consolidated_metrics[
|
||||
"async_cp_torch_trainer_val_metrics"
|
||||
] = run_training_with_validation(
|
||||
CheckpointUploadMode.ASYNC,
|
||||
ValidationType.TORCH_TRAINER,
|
||||
False,
|
||||
num_epochs,
|
||||
train_dataset,
|
||||
training_rows,
|
||||
CheckpointSaveMode.TORCH_SAVE,
|
||||
)
|
||||
consolidated_metrics[
|
||||
"async_cp_map_batches_val_metrics"
|
||||
] = run_training_with_validation(
|
||||
CheckpointUploadMode.ASYNC,
|
||||
ValidationType.MAP_BATCHES,
|
||||
False,
|
||||
num_epochs,
|
||||
train_dataset,
|
||||
training_rows,
|
||||
CheckpointSaveMode.TORCH_SAVE,
|
||||
)
|
||||
consolidated_metrics[
|
||||
"sync_dcp_map_batches_val_metrics"
|
||||
] = run_training_with_validation(
|
||||
CheckpointUploadMode.NO_UPLOAD,
|
||||
ValidationType.MAP_BATCHES,
|
||||
False,
|
||||
num_epochs,
|
||||
train_dataset,
|
||||
training_rows,
|
||||
CheckpointSaveMode.TORCH_DCP_SYNC,
|
||||
)
|
||||
consolidated_metrics[
|
||||
"async_dcp_map_batches_val_metrics"
|
||||
] = run_training_with_validation(
|
||||
CheckpointUploadMode.ASYNC,
|
||||
ValidationType.MAP_BATCHES,
|
||||
False,
|
||||
num_epochs,
|
||||
train_dataset,
|
||||
training_rows,
|
||||
CheckpointSaveMode.TORCH_DCP_ASYNC,
|
||||
)
|
||||
safe_write_to_results_json(consolidated_metrics)
|
||||
|
||||
# Assert final scores aren't too far off, which would imply an inaccurate comparison
|
||||
# Example: {'async_dcp_map_batches': 0.56, 'sync_cp_inline': 0.57, 'async_cp_map_batches': 0.57, 'async_cp_torch_trainer': 0.58, 'sync_dcp_map_batches': 0.59}
|
||||
sync_final_score = consolidated_metrics["sync_cp_inline_val_metrics"]["final_score"]
|
||||
async_torchtrainer_final_score = consolidated_metrics[
|
||||
"async_cp_torch_trainer_val_metrics"
|
||||
]["final_score"]
|
||||
async_map_batches_final_score = consolidated_metrics[
|
||||
"async_cp_map_batches_val_metrics"
|
||||
]["final_score"]
|
||||
sync_dcp_final_score = consolidated_metrics["sync_dcp_map_batches_val_metrics"][
|
||||
"final_score"
|
||||
]
|
||||
async_dcp_final_score = consolidated_metrics["async_dcp_map_batches_val_metrics"][
|
||||
"final_score"
|
||||
]
|
||||
logger.info(
|
||||
"Validation metrics order=%s",
|
||||
dict(
|
||||
sorted(
|
||||
(
|
||||
(k, round(v["final_score"], 2))
|
||||
for k, v in consolidated_metrics.items()
|
||||
),
|
||||
key=lambda a: a[1],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert (
|
||||
abs(sync_final_score - async_torchtrainer_final_score)
|
||||
< MAXIMUM_ALLOWED_ACCURACY_DIFF
|
||||
)
|
||||
assert (
|
||||
abs(sync_final_score - async_map_batches_final_score)
|
||||
< MAXIMUM_ALLOWED_ACCURACY_DIFF
|
||||
)
|
||||
assert abs(sync_final_score - sync_dcp_final_score) < MAXIMUM_ALLOWED_ACCURACY_DIFF
|
||||
assert abs(sync_final_score - async_dcp_final_score) < MAXIMUM_ALLOWED_ACCURACY_DIFF
|
||||
|
||||
# Assert async checkpointing/validation e2e time is faster; add multipler to account for training time variance
|
||||
# Example: {'async_cp_map_batches': 1346.26, 'sync_dcp_map_batches': 1350.58, 'async_dcp_map_batches': 1367.41, 'async_cp_torch_trainer': 1390.7, 'sync_cp_inline': 1571.73}
|
||||
sync_e2e_time = consolidated_metrics["sync_cp_inline_val_metrics"]["e2e_time"]
|
||||
async_torchtrainer_e2e_time = consolidated_metrics[
|
||||
"async_cp_torch_trainer_val_metrics"
|
||||
]["e2e_time"]
|
||||
async_map_batches_e2e_time = consolidated_metrics[
|
||||
"async_cp_map_batches_val_metrics"
|
||||
]["e2e_time"]
|
||||
sync_dcp_e2e_time = consolidated_metrics["sync_dcp_map_batches_val_metrics"][
|
||||
"e2e_time"
|
||||
]
|
||||
async_dcp_e2e_time = consolidated_metrics["async_dcp_map_batches_val_metrics"][
|
||||
"e2e_time"
|
||||
]
|
||||
logger.info(
|
||||
"Total end-to-end time order=%s",
|
||||
dict(
|
||||
sorted(
|
||||
((k, round(v["e2e_time"], 2)) for k, v in consolidated_metrics.items()),
|
||||
key=lambda a: a[1],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert (
|
||||
async_torchtrainer_e2e_time
|
||||
< sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER
|
||||
), f"{async_torchtrainer_e2e_time=}, {sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER=} ({sync_e2e_time=})"
|
||||
assert (
|
||||
async_map_batches_e2e_time < sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER
|
||||
), f"{async_map_batches_e2e_time=}, {sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER=} ({sync_e2e_time=})"
|
||||
assert (
|
||||
sync_dcp_e2e_time < sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER
|
||||
), f"{sync_dcp_e2e_time=}, {sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER=} ({sync_e2e_time=})"
|
||||
assert (
|
||||
async_dcp_e2e_time < sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER
|
||||
), f"{async_dcp_e2e_time=}, {sync_e2e_time * MAXIMUM_ALLOWED_E2E_TIME_MULTIPLIER=} ({sync_e2e_time=})"
|
||||
|
||||
# map_batches is faster than TorchTrainer. Note that inline is the fastest but is blocking
|
||||
# Examples: {'async_dcp_map_batches': 1.39, 'async_cp_torch_trainer': 3.19, 'async_cp_map_batches': 3.27, 'sync_dcp_map_batches': 9.02, 'sync_cp_inline': 11.75}
|
||||
sync_validation_time = consolidated_metrics["sync_cp_inline_val_metrics"][
|
||||
"total_validation_time"
|
||||
]
|
||||
|
||||
sync_report_blocked_time = consolidated_metrics["sync_cp_inline_val_metrics"][
|
||||
"total_report_blocked_time"
|
||||
]
|
||||
async_torchtrainer_report_blocked_time = consolidated_metrics[
|
||||
"async_cp_torch_trainer_val_metrics"
|
||||
]["total_report_blocked_time"]
|
||||
async_map_batches_report_blocked_time = consolidated_metrics[
|
||||
"async_cp_map_batches_val_metrics"
|
||||
]["total_report_blocked_time"]
|
||||
sync_dcp_report_blocked_time = consolidated_metrics[
|
||||
"sync_dcp_map_batches_val_metrics"
|
||||
]["total_report_blocked_time"]
|
||||
async_dcp_report_blocked_time = consolidated_metrics[
|
||||
"async_dcp_map_batches_val_metrics"
|
||||
]["total_report_blocked_time"]
|
||||
logger.info(
|
||||
"Total report blocked time order=%s",
|
||||
dict(
|
||||
sorted(
|
||||
(
|
||||
(k, round(v["total_report_blocked_time"], 2))
|
||||
for k, v in consolidated_metrics.items()
|
||||
),
|
||||
key=lambda a: a[1],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Assert report blocking time is less than with async checkpointing.
|
||||
# Example values: 3.66s vs 0.033s vs 0.028s
|
||||
assert async_torchtrainer_report_blocked_time < sync_report_blocked_time
|
||||
assert async_map_batches_report_blocked_time < sync_report_blocked_time
|
||||
assert sync_dcp_report_blocked_time < sync_report_blocked_time
|
||||
assert async_dcp_report_blocked_time < sync_dcp_report_blocked_time
|
||||
|
||||
# Assert sync blocking time (report + validation + final validation) is less than async blocking time (report + final validation)
|
||||
# Example: {'async_dcp_map_batches': 25.52, 'async_cp_map_batches': 26.01, 'sync_cp_inline': 29.76, 'sync_dcp_map_batches': 31.52, 'async_cp_torch_trainer': 37.75}
|
||||
sync_final_validation_blocking_time = consolidated_metrics[
|
||||
"sync_cp_inline_val_metrics"
|
||||
]["final_validation_waiting_time"]
|
||||
async_torchtrainer_final_validation_blocking_time = consolidated_metrics[
|
||||
"async_cp_torch_trainer_val_metrics"
|
||||
]["final_validation_waiting_time"]
|
||||
async_map_batches_final_validation_blocking_time = consolidated_metrics[
|
||||
"async_cp_map_batches_val_metrics"
|
||||
]["final_validation_waiting_time"]
|
||||
sync_dcp_final_validation_blocking_time = consolidated_metrics[
|
||||
"sync_dcp_map_batches_val_metrics"
|
||||
]["final_validation_waiting_time"]
|
||||
async_dcp_final_validation_blocking_time = consolidated_metrics[
|
||||
"async_dcp_map_batches_val_metrics"
|
||||
]["final_validation_waiting_time"]
|
||||
sync_blocking_time = (
|
||||
sync_report_blocked_time
|
||||
+ sync_validation_time
|
||||
+ sync_final_validation_blocking_time
|
||||
)
|
||||
async_torchtrainer_blocking_time = (
|
||||
async_torchtrainer_report_blocked_time
|
||||
+ async_torchtrainer_final_validation_blocking_time
|
||||
)
|
||||
async_map_batches_blocking_time = (
|
||||
async_map_batches_report_blocked_time
|
||||
+ async_map_batches_final_validation_blocking_time
|
||||
)
|
||||
sync_dcp_blocking_time = (
|
||||
sync_dcp_report_blocked_time + sync_dcp_final_validation_blocking_time
|
||||
)
|
||||
async_dcp_blocking_time = (
|
||||
async_dcp_report_blocked_time + async_dcp_final_validation_blocking_time
|
||||
)
|
||||
logger.info(
|
||||
"Total validation blocking time order=%s",
|
||||
dict(
|
||||
sorted(
|
||||
(
|
||||
(
|
||||
k,
|
||||
round(
|
||||
(
|
||||
v["total_validation_time"]
|
||||
if k == "sync_cp_inline_val_metrics"
|
||||
else 0
|
||||
)
|
||||
+ v["total_report_blocked_time"]
|
||||
+ v["final_validation_waiting_time"],
|
||||
2,
|
||||
),
|
||||
)
|
||||
for k, v in consolidated_metrics.items()
|
||||
),
|
||||
key=lambda a: a[1],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert sync_blocking_time > async_torchtrainer_blocking_time
|
||||
assert sync_blocking_time > async_map_batches_blocking_time
|
||||
assert sync_blocking_time > sync_dcp_blocking_time
|
||||
assert sync_blocking_time > async_dcp_blocking_time
|
||||
|
||||
# TODO: consider correctness checks like validating that local checkpoints get deleted
|
||||
# TODO: track validation startup metrics: schedule validation task, autoscale nodes,
|
||||
# start TorchTrainer/map_batches, load checkpoint.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
+16
@@ -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
|
||||
+16
@@ -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
|
||||
@@ -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
+53
@@ -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
|
||||
+273
@@ -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()
|
||||
@@ -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)}")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,22 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
advanced_instance_config:
|
||||
TagSpecifications:
|
||||
- ResourceType: "instance"
|
||||
Tags:
|
||||
- Key: ttl-hours
|
||||
Value: '24'
|
||||
|
||||
head_node:
|
||||
instance_type: g4dn.4xlarge
|
||||
# GPU head — expose natural resources (16 vCPU + 1 T4 GPU) so the test
|
||||
# (which colocates the trainer driver on the head) can use GPU.
|
||||
resources:
|
||||
CPU: 16
|
||||
GPU: 1
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: g4dn.xlarge
|
||||
min_nodes: 3
|
||||
max_nodes: 3
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Ray Train release test: Colocate Trainer and Rank 0 worker
|
||||
|
||||
Setup:
|
||||
- 1 x g4dn.4xlarge (16 CPU, 1 GPU, 64 GB Memory)
|
||||
- 3 x g4dn.xlarge (4 CPU, 1 GPU, 16 GB memory)
|
||||
|
||||
Test owner: woshiyyya
|
||||
"""
|
||||
|
||||
import ray
|
||||
import ray.train
|
||||
import pytest
|
||||
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train import ScalingConfig
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trainer_resources", [None, {"memory": 40 * 1024**3}, {"CPU": 10}]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"resources_per_worker_and_use_gpu",
|
||||
[
|
||||
(None, True),
|
||||
({"CPU": 1}, False),
|
||||
({"GPU": 1}, True),
|
||||
],
|
||||
)
|
||||
def test_colocate_trainer_and_rank0_worker(
|
||||
trainer_resources,
|
||||
resources_per_worker_and_use_gpu,
|
||||
):
|
||||
ray.init(ignore_reinit_error=True)
|
||||
|
||||
resources_per_worker, use_gpu = resources_per_worker_and_use_gpu
|
||||
|
||||
def train_func():
|
||||
pass
|
||||
|
||||
class CustomBackend(Backend):
|
||||
def on_training_start(self, worker_group, backend_config):
|
||||
trainer_node_ip = ray.util.get_node_ip_address()
|
||||
|
||||
def check_node_ip():
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
assert trainer_node_ip == ray.util.get_node_ip_address()
|
||||
|
||||
worker_group.execute(check_node_ip)
|
||||
|
||||
class CustomBackendConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return CustomBackend
|
||||
|
||||
for num_workers in [1, 2, 4]:
|
||||
scale_config = ScalingConfig(
|
||||
num_workers=num_workers,
|
||||
use_gpu=use_gpu,
|
||||
trainer_resources=trainer_resources,
|
||||
resources_per_worker=resources_per_worker,
|
||||
)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
scaling_config=scale_config,
|
||||
backend_config=CustomBackendConfig(),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,30 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
advanced_instance_config:
|
||||
BlockDeviceMappings:
|
||||
- DeviceName: /dev/sda1
|
||||
Ebs:
|
||||
DeleteOnTermination: true
|
||||
VolumeSize: 800
|
||||
Iops: 5000
|
||||
Throughput: 1000
|
||||
VolumeType: gp3
|
||||
TagSpecifications:
|
||||
- ResourceType: "instance"
|
||||
Tags:
|
||||
- Key: chaos-test-name
|
||||
Value: "train-chaos-test"
|
||||
|
||||
head_node:
|
||||
instance_type: g4dn.12xlarge
|
||||
# GPU head — expose natural resources (48 vCPU + 4 T4 GPU) so elastic
|
||||
# training can use the head as one of its GPU nodes.
|
||||
resources:
|
||||
CPU: 48
|
||||
GPU: 4
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: g4dn.12xlarge
|
||||
min_nodes: 0
|
||||
max_nodes: 3
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,68 @@
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
from torch import nn
|
||||
|
||||
import ray
|
||||
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
|
||||
|
||||
|
||||
class NeuralNetwork(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.flatten = nn.Flatten()
|
||||
self.linear_relu_stack = nn.Sequential(
|
||||
nn.Linear(28 * 28, 512),
|
||||
nn.ReLU(),
|
||||
nn.Linear(512, 512),
|
||||
nn.ReLU(),
|
||||
nn.Linear(512, 10),
|
||||
nn.ReLU(),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.flatten(x)
|
||||
logits = self.linear_relu_stack(x)
|
||||
return logits
|
||||
|
||||
|
||||
def terminate_current_instance():
|
||||
"""Use AWS CLI to terminate current instance."""
|
||||
|
||||
token = requests.put(
|
||||
"http://169.254.169.254/latest/api/token",
|
||||
headers={"X-aws-ec2-metadata-token-ttl-seconds": "300"},
|
||||
timeout=10,
|
||||
).text
|
||||
instance_id = requests.get(
|
||||
"http://169.254.169.254/latest/meta-data/instance-id",
|
||||
headers={"X-aws-ec2-metadata-token": token},
|
||||
timeout=10,
|
||||
).text
|
||||
region = requests.get(
|
||||
"http://169.254.169.254/latest/meta-data/placement/region",
|
||||
headers={"X-aws-ec2-metadata-token": token},
|
||||
timeout=10,
|
||||
).text
|
||||
return subprocess.run(
|
||||
[
|
||||
"aws",
|
||||
"ec2",
|
||||
"terminate-instances",
|
||||
"--instance-ids",
|
||||
instance_id,
|
||||
"--region",
|
||||
region,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
def terminate_node(node_id: str):
|
||||
killer_task = ray.remote(terminate_current_instance).options(
|
||||
num_cpus=0,
|
||||
scheduling_strategy=NodeAffinitySchedulingStrategy(node_id, soft=False),
|
||||
)
|
||||
ray.get(killer_task.remote())
|
||||
@@ -0,0 +1,373 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import click
|
||||
from elastic_util import NeuralNetwork, terminate_node
|
||||
from filelock import FileLock
|
||||
import ray
|
||||
import ray.train as train
|
||||
from ray.tune.utils import date_str
|
||||
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
|
||||
import torch
|
||||
from torch import nn
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision import datasets
|
||||
from torchvision.transforms import ToTensor
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CONFIG = {"lr": 1e-3, "batch_size": 64}
|
||||
LOG_FILE = "/tmp/driver.log"
|
||||
DATA_DIR = "/tmp/fashion_mnist"
|
||||
|
||||
|
||||
def get_default_storage_path():
|
||||
remote_default_artifact_storage_prefix = os.environ.get(
|
||||
"ANYSCALE_ARTIFACT_STORAGE", "artifact_storage"
|
||||
)
|
||||
return f"{remote_default_artifact_storage_prefix}/train_release_tests/elastic_e2e"
|
||||
|
||||
|
||||
STORAGE_PATH = get_default_storage_path()
|
||||
|
||||
|
||||
def load_data(data_dir):
|
||||
with FileLock(f"{DATA_DIR}.data.lock"):
|
||||
trainset = datasets.FashionMNIST(
|
||||
root=data_dir, train=True, download=True, transform=ToTensor()
|
||||
)
|
||||
testset = datasets.FashionMNIST(
|
||||
root=data_dir, train=False, download=True, transform=ToTensor()
|
||||
)
|
||||
return trainset, testset
|
||||
|
||||
|
||||
def train_epoch(
|
||||
dataloader, model, loss_fn, optimizer, world_size: int, world_rank: int
|
||||
):
|
||||
size = len(dataloader.dataset) // world_size
|
||||
model.train()
|
||||
for batch_index, (inputs, labels) in enumerate(dataloader):
|
||||
predictions = model(inputs)
|
||||
loss = loss_fn(predictions, labels)
|
||||
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
if batch_index % 100 == 0:
|
||||
current = batch_index * len(inputs)
|
||||
print(
|
||||
f"[rank={world_rank}] loss: {loss.item():>7f} [{current:>5d}/{size:>5d}]"
|
||||
)
|
||||
|
||||
|
||||
def validate_epoch(dataloader, model, loss_fn, world_size: int, world_rank: int):
|
||||
size = len(dataloader.dataset) // world_size
|
||||
num_batches = len(dataloader)
|
||||
model.eval()
|
||||
test_loss, correct = 0, 0
|
||||
with torch.no_grad():
|
||||
for inputs, labels in dataloader:
|
||||
predictions = model(inputs)
|
||||
test_loss += loss_fn(predictions, labels).item()
|
||||
correct += (predictions.argmax(1) == labels).type(torch.float).sum().item()
|
||||
test_loss /= num_batches
|
||||
correct /= size
|
||||
print(
|
||||
f"[rank={world_rank}] Test Error: \n "
|
||||
f"Accuracy: {(100 * correct):>0.1f}%, "
|
||||
f"Avg loss: {test_loss:>8f} \n"
|
||||
)
|
||||
return test_loss
|
||||
|
||||
|
||||
def save_checkpoint(local_dir, model, optimizer, epoch):
|
||||
checkpoint = {
|
||||
"model": model.state_dict(),
|
||||
"optimizer": optimizer.state_dict(),
|
||||
"epoch": epoch,
|
||||
}
|
||||
torch.save(checkpoint, os.path.join(local_dir, "checkpoint.pt"))
|
||||
|
||||
|
||||
def load_checkpoint(local_ckpt_path, model, optimizer) -> int:
|
||||
checkpoint = torch.load(os.path.join(local_ckpt_path, "checkpoint.pt"))
|
||||
model.load_state_dict(checkpoint["model"])
|
||||
optimizer.load_state_dict(checkpoint["optimizer"])
|
||||
return checkpoint["epoch"] + 1
|
||||
|
||||
|
||||
def train_func(config: Dict):
|
||||
local_start_time = time.monotonic()
|
||||
|
||||
batch_size = config["batch_size"]
|
||||
lr = config["lr"]
|
||||
epochs = config["epochs"]
|
||||
shuffle = config.get("shuffle", False)
|
||||
|
||||
world_size = train.get_context().get_world_size()
|
||||
world_rank = train.get_context().get_world_rank()
|
||||
|
||||
worker_batch_size = batch_size // world_size
|
||||
if world_rank == 0:
|
||||
print(f"global batch size is {worker_batch_size * world_size}")
|
||||
|
||||
training_data, test_data = load_data(DATA_DIR)
|
||||
|
||||
train_dataloader = DataLoader(
|
||||
training_data, shuffle=shuffle, batch_size=worker_batch_size
|
||||
)
|
||||
test_dataloader = DataLoader(
|
||||
test_data, shuffle=shuffle, batch_size=worker_batch_size
|
||||
)
|
||||
|
||||
train_dataloader = train.torch.prepare_data_loader(train_dataloader)
|
||||
test_dataloader = train.torch.prepare_data_loader(test_dataloader)
|
||||
|
||||
model = train.torch.prepare_model(NeuralNetwork())
|
||||
loss_fn = nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=lr)
|
||||
|
||||
start_epoch = 1
|
||||
checkpoint = ray.train.get_checkpoint()
|
||||
if checkpoint:
|
||||
with checkpoint.as_directory() as temp_ckpt_dir:
|
||||
print("Found checkpoint: ", checkpoint)
|
||||
start_epoch = load_checkpoint(temp_ckpt_dir, model, optimizer)
|
||||
print(f"Restoration done! Resuming training from {start_epoch=}")
|
||||
|
||||
for epoch in range(start_epoch, epochs + 1):
|
||||
if world_size > 1:
|
||||
train_dataloader.sampler.set_epoch(epoch)
|
||||
|
||||
train_epoch(
|
||||
train_dataloader,
|
||||
model,
|
||||
loss_fn,
|
||||
optimizer,
|
||||
world_size=world_size,
|
||||
world_rank=world_rank,
|
||||
)
|
||||
loss = validate_epoch(
|
||||
test_dataloader,
|
||||
model,
|
||||
loss_fn,
|
||||
world_size=world_size,
|
||||
world_rank=world_rank,
|
||||
)
|
||||
|
||||
local_time_taken = time.monotonic() - local_start_time
|
||||
with tempfile.TemporaryDirectory() as temp_checkpoint_dir:
|
||||
checkpoint = None
|
||||
if world_rank == 0:
|
||||
print("Saving checkpoint...")
|
||||
save_checkpoint(temp_checkpoint_dir, model, optimizer, epoch)
|
||||
checkpoint = train.Checkpoint.from_directory(temp_checkpoint_dir)
|
||||
|
||||
train.report(
|
||||
metrics={"loss": loss, "local_time_taken": local_time_taken},
|
||||
checkpoint=checkpoint,
|
||||
checkpoint_dir_name=f"checkpoint-epoch={epoch}",
|
||||
)
|
||||
|
||||
|
||||
def train_torch_ray_train(
|
||||
config: dict,
|
||||
num_workers: Tuple[int, int] = (4, 12),
|
||||
use_gpu: bool = True,
|
||||
) -> train.Result:
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=lambda c: train_func(config=c),
|
||||
train_loop_config=config,
|
||||
scaling_config=ray.train.ScalingConfig(
|
||||
num_workers=num_workers, use_gpu=use_gpu
|
||||
),
|
||||
run_config=ray.train.RunConfig(
|
||||
name=f"elastic_train_experiment-{date_str()}",
|
||||
storage_path=STORAGE_PATH,
|
||||
checkpoint_config=ray.train.CheckpointConfig(num_to_keep=2),
|
||||
failure_config=ray.train.FailureConfig(max_failures=3),
|
||||
),
|
||||
)
|
||||
return trainer.fit()
|
||||
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
def run_cluster_node_killing_events(target_gpu_count: int):
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
terminator_logger = logging.getLogger(__name__)
|
||||
terminator_logger.addHandler(get_file_handler())
|
||||
|
||||
start = time.time()
|
||||
head_node_id = ray.get_runtime_context().get_node_id()
|
||||
|
||||
def get_cluster_resources() -> Dict[str, float]:
|
||||
return {
|
||||
resource: value
|
||||
for resource, value in ray.cluster_resources().items()
|
||||
if resource in ("CPU", "GPU")
|
||||
}
|
||||
|
||||
def get_worker_nodes() -> List[Dict]:
|
||||
return [
|
||||
node
|
||||
for node in ray.nodes()
|
||||
if node["Alive"] and node["NodeID"] != head_node_id
|
||||
]
|
||||
|
||||
def kill_nodes(nodes_to_kill):
|
||||
terminator_logger.info(
|
||||
"Nodes to kill: %s", [n["NodeID"] for n in nodes_to_kill]
|
||||
)
|
||||
for node in nodes_to_kill:
|
||||
terminator_logger.info(
|
||||
"Killing node: %s (alive=%s)", node["NodeID"], node["Alive"]
|
||||
)
|
||||
terminate_node(node["NodeID"])
|
||||
|
||||
def all_nodes_dead(dying_nodes) -> bool:
|
||||
dying_node_ids = [n["NodeID"] for n in dying_nodes]
|
||||
return all(
|
||||
not node["Alive"]
|
||||
for node in ray.nodes()
|
||||
if node["NodeID"] in dying_node_ids
|
||||
)
|
||||
|
||||
def log_status(message):
|
||||
elapsed = time.time() - start
|
||||
status_str = "\n"
|
||||
status_str += "-" * 80 + "\n"
|
||||
status_str += (
|
||||
f"[elapsed={elapsed:.1f}s] cluster_resources={get_cluster_resources()}\n"
|
||||
)
|
||||
status_str += message + "\n"
|
||||
status_str += "-" * 80 + "\n\n"
|
||||
terminator_logger.info(status_str)
|
||||
|
||||
log_status(f"Waiting to upscale back to {target_gpu_count} GPUs...")
|
||||
while get_cluster_resources().get("GPU", 0) < target_gpu_count:
|
||||
time.sleep(1)
|
||||
|
||||
log_status("Waiting for 30s before modifying cluster resources...")
|
||||
time.sleep(30)
|
||||
|
||||
log_status("Killing all nodes in the current cluster...")
|
||||
nodes_to_kill = get_worker_nodes()
|
||||
kill_nodes(nodes_to_kill)
|
||||
while not all_nodes_dead(nodes_to_kill):
|
||||
time.sleep(1)
|
||||
|
||||
log_status(f"Waiting to upscale back to {target_gpu_count} GPUs...")
|
||||
while get_cluster_resources().get("GPU", 0) < target_gpu_count:
|
||||
time.sleep(1)
|
||||
|
||||
log_status("Waiting for 30s before modifying cluster resources...")
|
||||
time.sleep(30)
|
||||
|
||||
log_status("Killing two worker nodes...")
|
||||
nodes_to_kill = get_worker_nodes()[-2:]
|
||||
kill_nodes(nodes_to_kill)
|
||||
while not all_nodes_dead(nodes_to_kill):
|
||||
time.sleep(1)
|
||||
|
||||
log_status(f"Waiting to upscale back to {target_gpu_count} GPUs...")
|
||||
while get_cluster_resources().get("GPU", 0) < target_gpu_count:
|
||||
time.sleep(1)
|
||||
|
||||
log_status("Waiting for 30s before modifying cluster resources...")
|
||||
time.sleep(30)
|
||||
|
||||
log_status("Killing 1 worker node...")
|
||||
nodes_to_kill = [get_worker_nodes()[-1]]
|
||||
kill_nodes(nodes_to_kill)
|
||||
while not all_nodes_dead(nodes_to_kill):
|
||||
time.sleep(1)
|
||||
|
||||
log_status("All node killing events generated, waiting for training finish...")
|
||||
|
||||
|
||||
@click.group(help="Run Torch benchmarks")
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@cli.command(help="Kick off Ray Train elastic benchmark")
|
||||
@click.option("--num-epochs", type=int, default=50)
|
||||
@click.option("--num-workers", type=tuple, default=(4, 12))
|
||||
@click.option("--use-gpu", is_flag=True, default=True)
|
||||
@click.option("--batch-size", type=int, default=64)
|
||||
def run(
|
||||
num_epochs: int = 50,
|
||||
num_workers: Tuple[int, int] = (4, 12),
|
||||
use_gpu: bool = True,
|
||||
batch_size: int = 64,
|
||||
):
|
||||
config = CONFIG.copy()
|
||||
config["epochs"] = num_epochs
|
||||
config["batch_size"] = batch_size
|
||||
|
||||
ray.init(log_to_driver=True, runtime_env={"working_dir": os.path.dirname(__file__)})
|
||||
|
||||
head_node_id = ray.get_runtime_context().get_node_id()
|
||||
event_future = run_cluster_node_killing_events.options(
|
||||
scheduling_strategy=NodeAffinitySchedulingStrategy(
|
||||
node_id=head_node_id, soft=False
|
||||
),
|
||||
runtime_env={"env_vars": {"RAY_TRAIN_V2_ENABLED": "1"}},
|
||||
).remote(target_gpu_count=num_workers[1])
|
||||
|
||||
result = train_torch_ray_train(
|
||||
config=config,
|
||||
num_workers=num_workers,
|
||||
use_gpu=use_gpu,
|
||||
)
|
||||
ray.get(event_future)
|
||||
|
||||
logger.info(
|
||||
"`trainer.fit` finished with (error, checkpoint):\nerror = %s\ncheckpoint = %s",
|
||||
result.error,
|
||||
result.checkpoint,
|
||||
)
|
||||
assert not result.error, result.error
|
||||
assert result.checkpoint
|
||||
|
||||
checkpoint_dir_name = Path(result.checkpoint.path).name
|
||||
expected_checkpoint_dir_name = f"checkpoint-epoch={num_epochs}"
|
||||
assert (
|
||||
checkpoint_dir_name == expected_checkpoint_dir_name
|
||||
), f"{checkpoint_dir_name=} != {expected_checkpoint_dir_name=}"
|
||||
|
||||
with open(LOG_FILE, "r") as f:
|
||||
print(f.read())
|
||||
|
||||
|
||||
def get_file_handler() -> logging.FileHandler:
|
||||
handler = logging.FileHandler(LOG_FILE)
|
||||
handler.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter("%(asctime)s [%(levelname)s] :: %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
return handler
|
||||
|
||||
|
||||
def setup_logging():
|
||||
file_handler = get_file_handler()
|
||||
logger.addHandler(file_handler)
|
||||
logging.getLogger("ray.train").addHandler(file_handler)
|
||||
|
||||
|
||||
def main():
|
||||
setup_logging()
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
cloud_id: {{env["ANYSCALE_CLOUD_ID"]}}
|
||||
region: us-west-2
|
||||
|
||||
max_workers: 1
|
||||
|
||||
head_node_type:
|
||||
name: head_node
|
||||
# 4 cpus, 16G mem, $0.224/hr on demand
|
||||
instance_type: m5.xlarge
|
||||
|
||||
worker_node_types:
|
||||
- name: worker_node
|
||||
instance_type: m5.xlarge
|
||||
max_workers: 1
|
||||
min_workers: 1
|
||||
use_spot: false
|
||||
|
||||
advanced_configurations_json:
|
||||
TagSpecifications:
|
||||
- ResourceType: "instance"
|
||||
Tags:
|
||||
- Key: ttl-hours
|
||||
Value: '24'
|
||||
@@ -0,0 +1,25 @@
|
||||
cloud_id: {{env["ANYSCALE_CLOUD_ID"]}}
|
||||
region: us-west1
|
||||
allowed_azs:
|
||||
- us-west1-b
|
||||
|
||||
max_workers: 1
|
||||
|
||||
head_node_type:
|
||||
name: head_node
|
||||
# 4 cpus, 16G mem, $0.224/hr on demand
|
||||
instance_type: n1-standard-4
|
||||
|
||||
worker_node_types:
|
||||
- name: worker_node
|
||||
instance_type: n1-standard-4
|
||||
max_workers: 1
|
||||
min_workers: 1
|
||||
use_spot: false
|
||||
|
||||
#advanced_configurations_json:
|
||||
# TagSpecifications:
|
||||
# - ResourceType: "instance"
|
||||
# Tags:
|
||||
# - Key: ttl-hours
|
||||
# Value: '24'
|
||||
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.air.constants import TRAINING_ITERATION
|
||||
from ray.train.examples.horovod.horovod_example import (
|
||||
train_func as horovod_torch_train_func,
|
||||
)
|
||||
from ray.train.horovod.horovod_trainer import HorovodTrainer
|
||||
|
||||
if __name__ == "__main__":
|
||||
ray.init(address=os.environ.get("RAY_ADDRESS", "auto"))
|
||||
start_time = time.time()
|
||||
|
||||
num_workers = 8
|
||||
num_epochs = 10
|
||||
trainer = HorovodTrainer(
|
||||
horovod_torch_train_func,
|
||||
train_loop_config={"num_epochs": num_epochs, "lr": 1e-3},
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=num_workers,
|
||||
trainer_resources={"CPU": 0},
|
||||
),
|
||||
)
|
||||
results = trainer.fit()
|
||||
result = results.metrics
|
||||
assert result[TRAINING_ITERATION] == num_epochs
|
||||
|
||||
loss = list(results.metrics_dataframe["loss"])
|
||||
assert len(loss) == num_epochs
|
||||
assert loss[-1] < loss[0]
|
||||
|
||||
delta = time.time() - start_time
|
||||
with open(os.environ["TEST_OUTPUT_JSON"], "w") as f:
|
||||
f.write(json.dumps({"train_time": delta, "success": True}))
|
||||
@@ -0,0 +1,10 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
head_node:
|
||||
instance_type: m5.2xlarge
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: g4dn.12xlarge
|
||||
min_nodes: 1
|
||||
max_nodes: 1
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,170 @@
|
||||
import tempfile
|
||||
|
||||
import torch
|
||||
import evaluate
|
||||
from datasets import load_dataset
|
||||
from transformers import (
|
||||
AutoTokenizer,
|
||||
AutoModelForSequenceClassification,
|
||||
AdamW,
|
||||
get_linear_schedule_with_warmup,
|
||||
)
|
||||
from accelerate import Accelerator
|
||||
|
||||
import ray
|
||||
import ray.train
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
def train_func():
|
||||
# Instantiate the accelerator
|
||||
accelerator = Accelerator()
|
||||
|
||||
# Datasets
|
||||
dataset = load_dataset("yelp_review_full")
|
||||
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
|
||||
|
||||
def tokenize_function(examples):
|
||||
outputs = tokenizer(examples["text"], padding="max_length", truncation=True)
|
||||
outputs["labels"] = examples["label"]
|
||||
return outputs
|
||||
|
||||
small_train_dataset = (
|
||||
dataset["train"].select(range(100)).map(tokenize_function, batched=True)
|
||||
)
|
||||
small_eval_dataset = (
|
||||
dataset["test"].select(range(100)).map(tokenize_function, batched=True)
|
||||
)
|
||||
|
||||
# Remove unwanted columns and convert datasets to PyTorch format
|
||||
columns_to_remove = [
|
||||
"text",
|
||||
"label",
|
||||
] # Remove original columns, keep tokenized ones
|
||||
small_train_dataset = small_train_dataset.remove_columns(columns_to_remove)
|
||||
small_eval_dataset = small_eval_dataset.remove_columns(columns_to_remove)
|
||||
|
||||
small_train_dataset.set_format("torch")
|
||||
small_eval_dataset.set_format("torch")
|
||||
|
||||
# Create data loaders
|
||||
train_dataloader = torch.utils.data.DataLoader(
|
||||
small_train_dataset, batch_size=16, shuffle=True
|
||||
)
|
||||
eval_dataloader = torch.utils.data.DataLoader(
|
||||
small_eval_dataset, batch_size=16, shuffle=False
|
||||
)
|
||||
|
||||
# Model
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
"bert-base-cased", num_labels=5
|
||||
)
|
||||
|
||||
# Optimizer and scheduler
|
||||
optimizer = AdamW(model.parameters(), lr=2e-5)
|
||||
|
||||
num_training_steps = len(train_dataloader) * 3 # 3 epochs
|
||||
lr_scheduler = get_linear_schedule_with_warmup(
|
||||
optimizer=optimizer,
|
||||
num_warmup_steps=0,
|
||||
num_training_steps=num_training_steps,
|
||||
)
|
||||
|
||||
# Prepare everything for distributed training
|
||||
(
|
||||
model,
|
||||
optimizer,
|
||||
train_dataloader,
|
||||
eval_dataloader,
|
||||
lr_scheduler,
|
||||
) = accelerator.prepare(
|
||||
model, optimizer, train_dataloader, eval_dataloader, lr_scheduler
|
||||
)
|
||||
|
||||
# Evaluation metric
|
||||
metric = evaluate.load("accuracy")
|
||||
|
||||
# Start training
|
||||
num_epochs = 3
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
# Training
|
||||
model.train()
|
||||
total_loss = 0
|
||||
|
||||
for batch in train_dataloader:
|
||||
outputs = model(**batch)
|
||||
loss = outputs.loss
|
||||
accelerator.backward(loss)
|
||||
|
||||
optimizer.step()
|
||||
lr_scheduler.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
total_loss += loss.item()
|
||||
|
||||
# Evaluation
|
||||
model.eval()
|
||||
for batch in eval_dataloader:
|
||||
with torch.no_grad():
|
||||
outputs = model(**batch)
|
||||
|
||||
predictions = outputs.logits.argmax(dim=-1)
|
||||
predictions, references = accelerator.gather_for_metrics(
|
||||
(predictions, batch["labels"])
|
||||
)
|
||||
metric.add_batch(predictions=predictions, references=references)
|
||||
|
||||
eval_results = metric.compute()
|
||||
accelerator.print(f"Epoch {epoch + 1}: {eval_results}")
|
||||
|
||||
# Report metrics and checkpoint to Ray Train
|
||||
metrics = {
|
||||
"epoch": epoch + 1,
|
||||
"train_loss": total_loss / len(train_dataloader),
|
||||
"eval_accuracy": eval_results["accuracy"],
|
||||
}
|
||||
|
||||
# Create checkpoint
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if accelerator.is_main_process:
|
||||
unwrapped_model = accelerator.unwrap_model(model)
|
||||
unwrapped_model.save_pretrained(tmpdir)
|
||||
tokenizer.save_pretrained(tmpdir)
|
||||
checkpoint = Checkpoint.from_directory(tmpdir)
|
||||
else:
|
||||
checkpoint = None
|
||||
|
||||
ray.train.report(metrics=metrics, checkpoint=checkpoint)
|
||||
|
||||
|
||||
def test_huggingface_accelerate():
|
||||
# Define a Ray TorchTrainer to launch `train_func` on all workers
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
|
||||
# If running in a multi-node cluster, this is where you
|
||||
# should configure the run's persistent storage that is accessible
|
||||
# across all worker nodes.
|
||||
run_config=ray.train.RunConfig(
|
||||
storage_path="/mnt/cluster_storage/huggingface_accelerate_run"
|
||||
),
|
||||
)
|
||||
result: ray.train.Result = trainer.fit()
|
||||
|
||||
# Verify training completed successfully
|
||||
assert result.metrics is not None
|
||||
assert "eval_accuracy" in result.metrics
|
||||
assert result.checkpoint is not None
|
||||
|
||||
# Load the trained model from checkpoint
|
||||
with result.checkpoint.as_directory() as checkpoint_dir:
|
||||
model = AutoModelForSequenceClassification.from_pretrained( # noqa: F841
|
||||
checkpoint_dir
|
||||
)
|
||||
tokenizer = AutoTokenizer.from_pretrained(checkpoint_dir) # noqa: F841
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_huggingface_accelerate()
|
||||
@@ -0,0 +1,10 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
head_node:
|
||||
instance_type: m5.2xlarge
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: g4dn.12xlarge
|
||||
min_nodes: 1
|
||||
max_nodes: 1
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,104 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import evaluate
|
||||
from datasets import load_dataset
|
||||
from transformers import (
|
||||
Trainer,
|
||||
TrainingArguments,
|
||||
AutoTokenizer,
|
||||
AutoModelForSequenceClassification,
|
||||
)
|
||||
|
||||
import ray.train.huggingface.transformers
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
# [1] Encapsulate data preprocessing, training, and evaluation
|
||||
# logic in a training function
|
||||
# ============================================================
|
||||
def train_func():
|
||||
# Datasets
|
||||
dataset = load_dataset("yelp_review_full")
|
||||
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
|
||||
|
||||
def tokenize_function(examples):
|
||||
return tokenizer(examples["text"], padding="max_length", truncation=True)
|
||||
|
||||
small_train_dataset = (
|
||||
dataset["train"].select(range(100)).map(tokenize_function, batched=True)
|
||||
)
|
||||
small_eval_dataset = (
|
||||
dataset["test"].select(range(100)).map(tokenize_function, batched=True)
|
||||
)
|
||||
|
||||
# Model
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
"bert-base-cased", num_labels=5
|
||||
)
|
||||
|
||||
# Evaluation Metrics
|
||||
metric = evaluate.load("accuracy")
|
||||
|
||||
def compute_metrics(eval_pred):
|
||||
logits, labels = eval_pred
|
||||
predictions = np.argmax(logits, axis=-1)
|
||||
return metric.compute(predictions=predictions, references=labels)
|
||||
|
||||
# Hugging Face Trainer
|
||||
training_args = TrainingArguments(
|
||||
output_dir="test_trainer",
|
||||
evaluation_strategy="epoch",
|
||||
save_strategy="epoch",
|
||||
report_to="none",
|
||||
)
|
||||
|
||||
trainer = Trainer(
|
||||
model=model,
|
||||
args=training_args,
|
||||
train_dataset=small_train_dataset,
|
||||
eval_dataset=small_eval_dataset,
|
||||
compute_metrics=compute_metrics,
|
||||
)
|
||||
|
||||
# [2] Report Metrics and Checkpoints to Ray Train
|
||||
# ===============================================
|
||||
callback = ray.train.huggingface.transformers.RayTrainReportCallback()
|
||||
trainer.add_callback(callback)
|
||||
|
||||
# [3] Prepare Transformers Trainer
|
||||
# ================================
|
||||
trainer = ray.train.huggingface.transformers.prepare_trainer(trainer)
|
||||
|
||||
# Start Training
|
||||
trainer.train()
|
||||
|
||||
|
||||
def test_huggingface_transformers():
|
||||
# [4] Define a Ray TorchTrainer to launch `train_func` on all workers
|
||||
# ===================================================================
|
||||
ray_trainer = TorchTrainer(
|
||||
train_func,
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
|
||||
# [4a] For multi-node clusters, configure persistent storage that is
|
||||
# accessible across all worker nodes
|
||||
run_config=ray.train.RunConfig(
|
||||
storage_path="/mnt/cluster_storage/huggingface_run"
|
||||
),
|
||||
)
|
||||
result: ray.train.Result = ray_trainer.fit()
|
||||
|
||||
# [5] Load the trained model
|
||||
with result.checkpoint.as_directory() as checkpoint_dir:
|
||||
checkpoint_path = os.path.join( # noqa: F841
|
||||
checkpoint_dir,
|
||||
ray.train.huggingface.transformers.RayTrainReportCallback.CHECKPOINT_NAME,
|
||||
)
|
||||
model = AutoModelForSequenceClassification.from_pretrained( # noqa: F841
|
||||
checkpoint_path
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_huggingface_transformers()
|
||||
@@ -0,0 +1,10 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
head_node:
|
||||
instance_type: m5.4xlarge
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: g4dn.12xlarge
|
||||
min_nodes: 2
|
||||
max_nodes: 2
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Ray Train release test: local mode launched by torchrun.
|
||||
|
||||
Setup:
|
||||
- 2 x g4dn.12xlarge (4 GPU)
|
||||
|
||||
Test owner: xinyuangui2
|
||||
|
||||
The test launches a ray cluster with 2 nodes, and launches a torchrun job on each node.
|
||||
"""
|
||||
import os
|
||||
import ray
|
||||
import subprocess
|
||||
import logging
|
||||
from ray.air.util.node import _force_on_node
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ray.remote
|
||||
def _write(stream: bytes, path: str):
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "wb") as f:
|
||||
f.write(stream)
|
||||
|
||||
|
||||
@ray.remote
|
||||
def _torch_run_launch(
|
||||
master_address: str,
|
||||
node_rank: int,
|
||||
absolute_path: str,
|
||||
n_nodes: int,
|
||||
n_processes_per_node: int,
|
||||
master_port: int,
|
||||
):
|
||||
cmd = [
|
||||
"torchrun",
|
||||
f"--nnodes={n_nodes}",
|
||||
f"--nproc-per-node={n_processes_per_node}",
|
||||
f"--node_rank={node_rank}",
|
||||
"--rdzv_backend=c10d",
|
||||
f"--rdzv_endpoint={master_address}:{master_port}",
|
||||
"--rdzv_id=local_mode_job",
|
||||
absolute_path,
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
env["RAY_TRAIN_V2_ENABLED"] = "1"
|
||||
|
||||
subprocess.check_call(cmd, env=env)
|
||||
|
||||
|
||||
def torch_run_launch_on_nodes():
|
||||
head_ip = ray.util.get_node_ip_address()
|
||||
node_id_ips = []
|
||||
for node in ray.nodes():
|
||||
if not node["Alive"]:
|
||||
continue
|
||||
|
||||
node_ip = node["NodeManagerAddress"]
|
||||
|
||||
if node_ip == head_ip:
|
||||
continue
|
||||
|
||||
node_id = node["NodeID"]
|
||||
node_id_ips.append((node_id, node_ip))
|
||||
|
||||
assert len(node_id_ips) == 2, f"Expected 2 nodes, got {len(node_id_ips)}"
|
||||
master_address = node_id_ips[0][1]
|
||||
futures = []
|
||||
absolute_path = os.path.abspath("torch_local_mode_test.py")
|
||||
with open(absolute_path, "rb") as f:
|
||||
stream = f.read()
|
||||
logger.info(f"Uploading file to all nodes: {absolute_path}")
|
||||
for i in range(len(node_id_ips)):
|
||||
futures.append(
|
||||
_force_on_node(node_id_ips[i][0], _write).remote(stream, absolute_path)
|
||||
)
|
||||
ray.get(futures)
|
||||
logger.info("Uploaded file to all nodes, starting torch run launch")
|
||||
futures = []
|
||||
for i in range(len(node_id_ips)):
|
||||
futures.append(
|
||||
_force_on_node(node_id_ips[i][0], _torch_run_launch).remote(
|
||||
master_address, i, absolute_path, len(node_id_ips), 4, 29500
|
||||
)
|
||||
)
|
||||
ray.get(futures)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# https://docs.ray.io/en/latest/ray-core/scheduling/accelerators.html#using-accelerators-in-tasks-and-actors
|
||||
# we don't want actors to override CUDA_VISIBLE_DEVICES
|
||||
ray.init(
|
||||
"auto",
|
||||
runtime_env={"env_vars": {"RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES": "1"}},
|
||||
)
|
||||
torch_run_launch_on_nodes()
|
||||
@@ -0,0 +1,162 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import logging
|
||||
|
||||
import torch
|
||||
from torch.nn import CrossEntropyLoss
|
||||
from torch.optim import Adam
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision.models import resnet18
|
||||
from torchvision.datasets import FashionMNIST
|
||||
from torchvision.transforms import ToTensor, Normalize, Compose
|
||||
from filelock import FileLock
|
||||
import torch.distributed as dist
|
||||
|
||||
import ray
|
||||
from ray.train import (
|
||||
Checkpoint,
|
||||
CheckpointConfig,
|
||||
RunConfig,
|
||||
ScalingConfig,
|
||||
get_context,
|
||||
)
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DATA_ROOT = "/tmp/test_data"
|
||||
|
||||
|
||||
def train_func(config):
|
||||
# Model, Loss, Optimizer
|
||||
model = resnet18(num_classes=10)
|
||||
model.conv1 = torch.nn.Conv2d(
|
||||
1, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False
|
||||
)
|
||||
lock = FileLock(os.path.join(DATA_ROOT, "fashionmnist.lock"))
|
||||
# [1] Prepare model.
|
||||
model = ray.train.torch.prepare_model(model)
|
||||
|
||||
# model.to("cuda") # This is done by `prepare_model`
|
||||
criterion = CrossEntropyLoss()
|
||||
optimizer = Adam(model.parameters(), lr=config["lr"])
|
||||
|
||||
# Data
|
||||
transform = Compose([ToTensor(), Normalize((0.28604,), (0.32025,))])
|
||||
local_rank = get_context().get_local_rank()
|
||||
if local_rank == 0:
|
||||
logger.info(f"Downloading FashionMNIST data to {DATA_ROOT}")
|
||||
with lock:
|
||||
_ = FashionMNIST(
|
||||
root=DATA_ROOT, train=True, download=True, transform=transform
|
||||
)
|
||||
dist.barrier()
|
||||
logger.info(f"Loading FashionMNIST data from {DATA_ROOT}")
|
||||
train_data = FashionMNIST(
|
||||
root=DATA_ROOT, train=True, download=False, transform=transform
|
||||
)
|
||||
|
||||
train_loader = DataLoader(train_data, batch_size=config["batch_size"], shuffle=True)
|
||||
# [2] Prepare dataloader.
|
||||
train_loader = ray.train.torch.prepare_data_loader(train_loader)
|
||||
|
||||
# Training
|
||||
epoch_losses = []
|
||||
for epoch in range(config["num_epochs"]):
|
||||
if ray.train.get_context().get_world_size() > 1:
|
||||
train_loader.sampler.set_epoch(epoch)
|
||||
|
||||
epoch_loss = 0.0
|
||||
num_batches = 0
|
||||
for images, labels in train_loader:
|
||||
# This is done by `prepare_data_loader`!
|
||||
# images, labels = images.to("cuda"), labels.to("cuda")
|
||||
outputs = model(images)
|
||||
loss = criterion(outputs, labels)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
epoch_loss += loss.item()
|
||||
num_batches += 1
|
||||
|
||||
# Calculate average loss for the epoch
|
||||
avg_epoch_loss = epoch_loss / num_batches if num_batches > 0 else float("inf")
|
||||
epoch_losses.append(avg_epoch_loss)
|
||||
|
||||
# [3] Report metrics and checkpoint.
|
||||
metrics = {
|
||||
"loss": avg_epoch_loss,
|
||||
"epoch": epoch,
|
||||
"epoch_losses": epoch_losses.copy(), # Track all losses for validation
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp_checkpoint_dir:
|
||||
torch.save(
|
||||
model.state_dict(), os.path.join(temp_checkpoint_dir, "model.pt")
|
||||
)
|
||||
ray.train.report(
|
||||
metrics,
|
||||
checkpoint=Checkpoint.from_directory(temp_checkpoint_dir),
|
||||
)
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
logger.info(f"metrics: {metrics}")
|
||||
|
||||
|
||||
def fit_func():
|
||||
# Define configurations.
|
||||
train_loop_config = {"num_epochs": 20, "lr": 0.01, "batch_size": 32}
|
||||
scaling_config = ScalingConfig(num_workers=0, use_gpu=True)
|
||||
run_config = RunConfig(checkpoint_config=CheckpointConfig(num_to_keep=1))
|
||||
|
||||
# Initialize the Trainer.
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=scaling_config,
|
||||
run_config=run_config,
|
||||
)
|
||||
|
||||
# Train the model.
|
||||
result = trainer.fit()
|
||||
|
||||
# Inspect the results and validate loss makes sense
|
||||
final_loss = result.metrics["loss"]
|
||||
epoch_losses = result.metrics.get("epoch_losses", [])
|
||||
|
||||
logger.info(f"final_loss: {final_loss}")
|
||||
logger.info(f"all epoch losses: {epoch_losses}")
|
||||
|
||||
# Validation 1: Check loss is finite and not NaN
|
||||
assert not torch.isnan(torch.tensor(final_loss)), f"Final loss is NaN: {final_loss}"
|
||||
assert torch.isfinite(
|
||||
torch.tensor(final_loss)
|
||||
), f"Final loss is not finite: {final_loss}"
|
||||
|
||||
# Validation 2: Check loss convergence - final loss should be lower than initial loss
|
||||
if len(epoch_losses) >= 2:
|
||||
initial_loss = epoch_losses[0]
|
||||
assert (
|
||||
final_loss < initial_loss
|
||||
), f"Loss didn't decrease: initial={initial_loss}, final={final_loss}"
|
||||
logger.info(
|
||||
f"Loss successfully decreased from {initial_loss:.4f} to {final_loss:.4f}"
|
||||
)
|
||||
|
||||
# Additional check: loss should show general decreasing trend
|
||||
# Allow for some fluctuation but overall trend should be downward
|
||||
mid_point = len(epoch_losses) // 2
|
||||
early_avg = sum(epoch_losses[:mid_point]) / mid_point
|
||||
late_avg = sum(epoch_losses[mid_point:]) / (len(epoch_losses) - mid_point)
|
||||
assert (
|
||||
late_avg < early_avg
|
||||
), f"Loss trend not decreasing: early_avg={early_avg:.4f}, late_avg={late_avg:.4f}"
|
||||
logger.info(
|
||||
f"Loss trend validation passed: early_avg={early_avg:.4f}, late_avg={late_avg:.4f}"
|
||||
)
|
||||
|
||||
logger.info("All loss validation checks passed!")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fit_func()
|
||||
@@ -0,0 +1,20 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
advanced_instance_config:
|
||||
TagSpecifications:
|
||||
- ResourceType: "instance"
|
||||
Tags:
|
||||
- Key: ttl-hours
|
||||
Value: '24'
|
||||
|
||||
head_node:
|
||||
instance_type: m5.2xlarge
|
||||
# Test's wait_for_nodes: 4 > 3 workers — head must count as a usable node.
|
||||
resources:
|
||||
CPU: 8
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: m5.2xlarge
|
||||
min_nodes: 3
|
||||
max_nodes: 3
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,14 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
zones:
|
||||
- us-west1-b
|
||||
|
||||
head_node:
|
||||
instance_type: n2-standard-8
|
||||
resources:
|
||||
CPU: 8
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: n2-standard-8
|
||||
min_nodes: 3
|
||||
max_nodes: 3
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Train multi-node persistence/checkpoint release test.
|
||||
|
||||
This test is a multi-node version of `test_new_persistence.py`/`test_persistence.py`
|
||||
and is meant to be run on a cluster with NFS or S3 storage configured.
|
||||
|
||||
This test also records timing metrics on checkpoint save (to disk), save (to storage),
|
||||
and load (from storage) operations and outputs them as release test metrics.
|
||||
|
||||
Setup:
|
||||
- 4x 8 CPU instances
|
||||
- 8 workers, each allocated 4 CPUs
|
||||
|
||||
Test owner: justinvyu
|
||||
"""
|
||||
|
||||
import collections
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pickle
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
import uuid
|
||||
|
||||
import fsspec
|
||||
import numpy as np
|
||||
import pyarrow.fs
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray._private.dict import flatten_dict
|
||||
from ray.air.constants import TRAINING_ITERATION
|
||||
from ray.air._internal.uri_utils import URI
|
||||
from ray.train import Checkpoint
|
||||
from ray.train.torch import TorchTrainer
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
|
||||
if is_v2_enabled():
|
||||
from test_v2_persistence import (
|
||||
train_fn,
|
||||
_assert_storage_contents,
|
||||
)
|
||||
from ray.train.v2.api.exceptions import WorkerGroupError
|
||||
else:
|
||||
from test_v1_persistence import (
|
||||
train_fn,
|
||||
_assert_storage_contents,
|
||||
_resume_from_checkpoint,
|
||||
)
|
||||
from ray.train.base_trainer import TrainingFailedError
|
||||
|
||||
|
||||
# Add a unique ID to the storage path to avoid collisions between release test runs.
|
||||
TEST_ID = uuid.uuid4().hex[:4] + "_" + datetime.today().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
CLOUD_TEST_DIR = (
|
||||
os.environ["ANYSCALE_ARTIFACT_STORAGE"] + f"/test-persistence-{TEST_ID}/"
|
||||
)
|
||||
NFS_TEST_DIR = f"/mnt/cluster_storage/test-persistence-{TEST_ID}/"
|
||||
|
||||
|
||||
class TestConstants:
|
||||
NUM_ITERATIONS = 10 # == num_checkpoints == num_artifacts
|
||||
NUM_TRIALS = 2
|
||||
|
||||
# 4 * 8 = 32 CPUs total
|
||||
NUM_WORKERS = 8
|
||||
NUM_CPUS_PER_WORKER = 4
|
||||
|
||||
SCORE_KEY = "score"
|
||||
|
||||
NUM_GB = 2
|
||||
NUM_MB = 10
|
||||
NUM_KB = 10
|
||||
|
||||
|
||||
def update_output_json(metrics: Dict[str, Any]):
|
||||
test_output_json = os.environ.get("TEST_OUTPUT_JSON", "/tmp/release_test_out.json")
|
||||
data = {}
|
||||
if os.path.exists(test_output_json):
|
||||
with open(test_output_json, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
data.update(metrics)
|
||||
with open(test_output_json, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
def create_checkpoint(checkpoint_dir: str) -> float:
|
||||
"""Create a somewhat realistic checkpoint of a given size.
|
||||
|
||||
Returns the time it takes to dump this checkpoint to disk."""
|
||||
start = time.perf_counter()
|
||||
# Small (1kb) files
|
||||
for i in range(TestConstants.NUM_KB):
|
||||
with open(os.path.join(checkpoint_dir, f"1kb-{i}.txt"), "w") as f:
|
||||
f.write("a" * 1024)
|
||||
|
||||
# Medium files (1 mb)
|
||||
for i in range(TestConstants.NUM_MB):
|
||||
with open(os.path.join(checkpoint_dir, f"1mb-{i}.txt"), "w") as f:
|
||||
f.write("a" * 1024 * 1024)
|
||||
|
||||
# Large files (1 gb)
|
||||
for i in range(TestConstants.NUM_GB):
|
||||
with open(os.path.join(checkpoint_dir, f"1gb-{i}.txt"), "w") as f:
|
||||
f.write("a" * 1024 * 1024 * 1024)
|
||||
return time.perf_counter() - start
|
||||
|
||||
|
||||
def custom_restore_fn(checkpoint: Checkpoint):
|
||||
start = time.perf_counter()
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
time_to_load = time.perf_counter() - start
|
||||
|
||||
dist.barrier()
|
||||
time_tensor = torch.tensor([time_to_load, 1.0])
|
||||
dist.reduce(time_tensor, dst=0, op=dist.ReduceOp.SUM)
|
||||
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
aggregated_metrics = {"load": time_tensor[0].item() / time_tensor[1].item()}
|
||||
checkpoint.update_metadata(aggregated_metrics)
|
||||
print("[checkpoint] Restore metrics:\n", aggregated_metrics)
|
||||
|
||||
# This is a file populated by the default saving logic in `train_fn`.
|
||||
with open(os.path.join(checkpoint_dir, "checkpoint.pkl"), "rb") as f:
|
||||
state = pickle.load(f)
|
||||
return state
|
||||
|
||||
|
||||
@contextmanager
|
||||
def custom_save_fn(temp_checkpoint_dir: str):
|
||||
time_to_save = create_checkpoint(temp_checkpoint_dir)
|
||||
|
||||
start = time.perf_counter()
|
||||
yield # train.report happens here
|
||||
time_to_report = time.perf_counter() - start
|
||||
|
||||
# Do an all-gather and have rank 0 write the aggregated timing metrics
|
||||
dist.barrier()
|
||||
timing_metrics = torch.tensor([time_to_save, time_to_report, 1.0])
|
||||
dist.reduce(timing_metrics, dst=0, op=dist.ReduceOp.SUM)
|
||||
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
persisted_checkpoint = train.get_checkpoint()
|
||||
aggregated_metrics = {
|
||||
"save_to_disk": timing_metrics[0].item() / timing_metrics[2].item(),
|
||||
"report": timing_metrics[1].item() / timing_metrics[2].item(),
|
||||
}
|
||||
persisted_checkpoint.update_metadata(aggregated_metrics)
|
||||
print("[checkpoint] Save metrics:\n", aggregated_metrics)
|
||||
|
||||
|
||||
def get_custom_cloud_fs() -> pyarrow.fs.FileSystem:
|
||||
fsspec_fs, _ = fsspec.core.url_to_fs(os.environ["ANYSCALE_ARTIFACT_STORAGE"])
|
||||
return pyarrow.fs.PyFileSystem(pyarrow.fs.FSSpecHandler(fsspec_fs))
|
||||
|
||||
|
||||
def strip_prefix(path: str) -> str:
|
||||
return path.replace("s3://", "").replace("gs://", "")
|
||||
|
||||
|
||||
def delete_at_uri(uri: str):
|
||||
if uri.startswith("s3://"):
|
||||
subprocess.check_output(["aws", "s3", "rm", "--recursive", uri])
|
||||
elif uri.startswith("gs://"):
|
||||
subprocess.check_output(["gsutil", "-m", "rm", "-r", uri])
|
||||
else:
|
||||
raise NotImplementedError(f"Invalid URI: {uri}")
|
||||
|
||||
|
||||
def download_from_uri(uri: str, local_path: str):
|
||||
if uri.startswith("s3://"):
|
||||
subprocess.check_output(["aws", "s3", "cp", "--recursive", uri, local_path])
|
||||
elif uri.startswith("gs://"):
|
||||
subprocess.check_output(
|
||||
["gsutil", "-m", "cp", "-r", uri.rstrip("/") + "/*", local_path]
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Invalid URI: {uri}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"root_path_storage_filesystem_label",
|
||||
[
|
||||
(CLOUD_TEST_DIR, None, "cloud"),
|
||||
(NFS_TEST_DIR, None, "nfs"),
|
||||
(strip_prefix(CLOUD_TEST_DIR), get_custom_cloud_fs(), "cloud+custom_fs"),
|
||||
],
|
||||
)
|
||||
def test_trainer(root_path_storage_filesystem_label, tmp_path, monkeypatch):
|
||||
"""Tests that a data parallel trainer can save and restore checkpoints to
|
||||
various storage types properly. Also records checkpoint save/restore timing.
|
||||
|
||||
Here's the rundown of what this test does:
|
||||
1. Passes in a `custom_save_fn` and `custom_restore_fn` to the trainer to
|
||||
record how long the operations take, as well as save a large checkpoint.
|
||||
See `create_checkpoint` for details on the checkpoint contents.
|
||||
2. Configures the training loop to fail 3 times.
|
||||
3. Runs the trainer, which will fail 2 times and recover via FailureConfig.
|
||||
This first run will exit on the 3rd failure.
|
||||
4. Manually restores the trainer, which will restore from the 3rd failure and
|
||||
run to completion.
|
||||
5. Downloads the results from the storage path and asserts that the contents
|
||||
are all correct. See `ray.train.test_new_persistence` for the expected filetree.
|
||||
6. Tests a new run with `resume_from_checkpoint`.
|
||||
"""
|
||||
ray.init(runtime_env={"working_dir": "."}, ignore_reinit_error=True)
|
||||
|
||||
root_path, storage_filesystem, label = root_path_storage_filesystem_label
|
||||
storage_path = root_path + label
|
||||
num_to_keep = TestConstants.NUM_ITERATIONS // 2
|
||||
checkpoint_config = train.CheckpointConfig(num_to_keep=num_to_keep)
|
||||
exp_name = "test_trainer"
|
||||
|
||||
print(
|
||||
"\nSaving results under (storage_path, exp_name) = "
|
||||
f"({storage_path}, {exp_name})\n"
|
||||
)
|
||||
train_loop_config = {
|
||||
"fail_iters": [3, 6, 8],
|
||||
"time_per_iter": 1.0,
|
||||
"num_iterations": TestConstants.NUM_ITERATIONS,
|
||||
"custom_save_fn": custom_save_fn,
|
||||
"custom_restore_fn": custom_restore_fn,
|
||||
"num_to_keep": num_to_keep,
|
||||
}
|
||||
scaling_config = train.ScalingConfig(
|
||||
num_workers=TestConstants.NUM_WORKERS,
|
||||
resources_per_worker={"CPU": TestConstants.NUM_CPUS_PER_WORKER},
|
||||
)
|
||||
run_config = train.RunConfig(
|
||||
failure_config=train.FailureConfig(max_failures=2),
|
||||
name=exp_name,
|
||||
storage_path=storage_path,
|
||||
storage_filesystem=storage_filesystem,
|
||||
checkpoint_config=checkpoint_config,
|
||||
)
|
||||
if not is_v2_enabled():
|
||||
train_loop_config["in_trainer"] = True
|
||||
scaling_config.trainer_resources = {"CPU": 0}
|
||||
run_config.sync_config = train.SyncConfig(sync_artifacts=True)
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=scaling_config,
|
||||
run_config=run_config,
|
||||
)
|
||||
print("\nStarting initial run.\n")
|
||||
if is_v2_enabled():
|
||||
with pytest.raises(WorkerGroupError):
|
||||
trainer.fit()
|
||||
else:
|
||||
with pytest.raises(TrainingFailedError):
|
||||
result = trainer.fit()
|
||||
|
||||
print("\nStarting manually restored run.\n")
|
||||
if is_v2_enabled():
|
||||
restored_trainer = TorchTrainer(
|
||||
train_fn,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=scaling_config,
|
||||
run_config=run_config,
|
||||
)
|
||||
else:
|
||||
restored_trainer = TorchTrainer.restore(
|
||||
path=str(URI(storage_path) / exp_name),
|
||||
storage_filesystem=storage_filesystem,
|
||||
)
|
||||
result = restored_trainer.fit()
|
||||
print(result)
|
||||
|
||||
print("\nAsserting contents of uploaded results.\n")
|
||||
local_inspect_dir = tmp_path / "inspect_dir"
|
||||
local_inspect_dir.mkdir()
|
||||
# Download the results from storage
|
||||
if "cloud" in label:
|
||||
# NOTE: Use the CLI to download, since the python libraries
|
||||
# (pyarrow, fsspec) aren't consistent across cloud platforms (s3, gs).
|
||||
cloud_uri = CLOUD_TEST_DIR + label
|
||||
print("\nDownloading from cloud URI:", cloud_uri, "\n")
|
||||
download_from_uri(cloud_uri, str(local_inspect_dir))
|
||||
elif label == "nfs":
|
||||
local_inspect_dir = Path(storage_path)
|
||||
else:
|
||||
raise NotImplementedError(f"Invalid storage type: {label}")
|
||||
|
||||
if is_v2_enabled():
|
||||
_assert_storage_contents(
|
||||
local_inspect_dir,
|
||||
exp_name,
|
||||
checkpoint_config,
|
||||
constants=TestConstants,
|
||||
)
|
||||
else:
|
||||
_assert_storage_contents(
|
||||
local_inspect_dir,
|
||||
exp_name,
|
||||
checkpoint_config,
|
||||
"TorchTrainer",
|
||||
test_trainer=True,
|
||||
constants=TestConstants,
|
||||
)
|
||||
|
||||
# Test `resume_from_checkpoint`
|
||||
if not is_v2_enabled():
|
||||
_resume_from_checkpoint(
|
||||
result.checkpoint,
|
||||
expected_state={"iter": TestConstants.NUM_ITERATIONS - 1},
|
||||
storage_path=storage_path,
|
||||
storage_filesystem=storage_filesystem,
|
||||
)
|
||||
|
||||
# Upload checkpoint save and restore timing release test metrics
|
||||
all_checkpoint_timing_metrics = collections.defaultdict(list)
|
||||
for checkpoint, _ in result.best_checkpoints:
|
||||
metadata = checkpoint.get_metadata()
|
||||
for metric, value in metadata.items():
|
||||
all_checkpoint_timing_metrics[metric].append(value)
|
||||
|
||||
aggregated_metrics = {
|
||||
key: np.mean(values) for key, values in all_checkpoint_timing_metrics.items()
|
||||
}
|
||||
checkpoint_size_mb = (
|
||||
TestConstants.NUM_GB * 1000 + TestConstants.NUM_MB + TestConstants.NUM_KB / 1000
|
||||
)
|
||||
speeds = {
|
||||
key + "_speed_mbps": checkpoint_size_mb / time_s
|
||||
for key, time_s in aggregated_metrics.items()
|
||||
}
|
||||
# Add units as the suffix
|
||||
aggregated_metrics = {
|
||||
key + "_avg_s": time_s for key, time_s in aggregated_metrics.items()
|
||||
}
|
||||
aggregated_metrics.update(speeds)
|
||||
aggregated_metrics["checkpoint_size_mb"] = checkpoint_size_mb
|
||||
|
||||
print(aggregated_metrics)
|
||||
update_output_json(flatten_dict({label: aggregated_metrics}))
|
||||
|
||||
print("Deleting files from the run...")
|
||||
if "cloud" in label:
|
||||
# NOTE: Use the CLI to delete files on cloud, since the python libraries
|
||||
# (pyarrow, fsspec) aren't consistent across cloud platforms (s3, gs).
|
||||
delete_at_uri(CLOUD_TEST_DIR)
|
||||
elif label == "nfs":
|
||||
shutil.rmtree(NFS_TEST_DIR, ignore_errors=True)
|
||||
else:
|
||||
raise NotImplementedError(f"Invalid storage type: {label}")
|
||||
|
||||
|
||||
def test_no_storage_error(tmp_path, monkeypatch):
|
||||
"""Tests that an error is raised if you do multi-node checkpointing
|
||||
w/ no persistent storage configured."""
|
||||
ray.init(runtime_env={"working_dir": "."}, ignore_reinit_error=True)
|
||||
|
||||
train_loop_config = {
|
||||
"time_per_iter": 1.0,
|
||||
"num_iterations": TestConstants.NUM_ITERATIONS,
|
||||
}
|
||||
scaling_config = train.ScalingConfig(
|
||||
num_workers=TestConstants.NUM_WORKERS,
|
||||
resources_per_worker={"CPU": TestConstants.NUM_CPUS_PER_WORKER},
|
||||
)
|
||||
if not is_v2_enabled():
|
||||
train_loop_config["in_trainer"] = True
|
||||
scaling_config.trainer_resources = {"CPU": 0}
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=scaling_config,
|
||||
run_config=train.RunConfig(name="test_trainer", storage_path=None),
|
||||
)
|
||||
if is_v2_enabled():
|
||||
with pytest.raises(WorkerGroupError):
|
||||
trainer.fit()
|
||||
else:
|
||||
with pytest.raises(TrainingFailedError):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_no_storage_no_checkpoints(tmp_path, monkeypatch):
|
||||
"""Tests that it's ok to run multi-node with no persistent storage
|
||||
if you never report checkpoints."""
|
||||
ray.init(runtime_env={"working_dir": "."}, ignore_reinit_error=True)
|
||||
|
||||
train_loop_config = {
|
||||
"time_per_iter": 1.0,
|
||||
"num_iterations": TestConstants.NUM_ITERATIONS,
|
||||
# Don't report any checkpoints
|
||||
"no_checkpoint_ranks": list(range(TestConstants.NUM_WORKERS)),
|
||||
}
|
||||
scaling_config = train.ScalingConfig(
|
||||
num_workers=TestConstants.NUM_WORKERS,
|
||||
resources_per_worker={"CPU": TestConstants.NUM_CPUS_PER_WORKER},
|
||||
)
|
||||
run_config = train.RunConfig(
|
||||
failure_config=train.FailureConfig(max_failures=2),
|
||||
name="test_trainer",
|
||||
storage_path=None,
|
||||
)
|
||||
if not is_v2_enabled():
|
||||
train_loop_config["in_trainer"] = True
|
||||
scaling_config.trainer_resources = {"CPU": 0}
|
||||
run_config.sync_config = train.SyncConfig(sync_artifacts=True)
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=scaling_config,
|
||||
run_config=run_config,
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
# v2 does not support free floating metrics
|
||||
if not is_v2_enabled():
|
||||
assert result.metrics[TRAINING_ITERATION] == TestConstants.NUM_ITERATIONS
|
||||
assert len(result.metrics_dataframe) == TestConstants.NUM_ITERATIONS
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1 @@
|
||||
../../../python/ray/train/tests/test_new_persistence.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../python/ray/train/v2/tests/test_persistence.py
|
||||
@@ -0,0 +1,10 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
head_node:
|
||||
instance_type: m5.2xlarge
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: g4dn.12xlarge
|
||||
min_nodes: 1
|
||||
max_nodes: 1
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import torch
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision.models import resnet18
|
||||
from torchvision.datasets import FashionMNIST
|
||||
from torchvision.transforms import ToTensor, Normalize, Compose
|
||||
import lightning.pytorch as pl
|
||||
|
||||
import ray.train.lightning
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
# Model, Loss, Optimizer
|
||||
class ImageClassifier(pl.LightningModule):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = resnet18(num_classes=10)
|
||||
self.model.conv1 = torch.nn.Conv2d(
|
||||
1, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False
|
||||
)
|
||||
self.criterion = torch.nn.CrossEntropyLoss()
|
||||
|
||||
def forward(self, x):
|
||||
return self.model(x)
|
||||
|
||||
def training_step(self, batch, batch_idx):
|
||||
x, y = batch
|
||||
outputs = self.forward(x)
|
||||
loss = self.criterion(outputs, y)
|
||||
self.log("loss", loss, on_step=True, prog_bar=True)
|
||||
return loss
|
||||
|
||||
def configure_optimizers(self):
|
||||
return torch.optim.Adam(self.model.parameters(), lr=0.001)
|
||||
|
||||
|
||||
def train_func():
|
||||
# Data
|
||||
transform = Compose([ToTensor(), Normalize((0.28604,), (0.32025,))])
|
||||
data_dir = os.path.join(tempfile.gettempdir(), "data")
|
||||
train_data = FashionMNIST(
|
||||
root=data_dir, train=True, download=True, transform=transform
|
||||
)
|
||||
train_dataloader = DataLoader(train_data, batch_size=128, shuffle=True)
|
||||
|
||||
# Training
|
||||
model = ImageClassifier()
|
||||
# [1] Configure PyTorch Lightning Trainer.
|
||||
trainer = pl.Trainer(
|
||||
max_epochs=10,
|
||||
devices="auto",
|
||||
accelerator="auto",
|
||||
strategy=ray.train.lightning.RayDDPStrategy(),
|
||||
plugins=[ray.train.lightning.RayLightningEnvironment()],
|
||||
callbacks=[ray.train.lightning.RayTrainReportCallback()],
|
||||
# [1a] Optionally, disable the default checkpointing behavior
|
||||
# in favor of the `RayTrainReportCallback` above.
|
||||
enable_checkpointing=False,
|
||||
)
|
||||
trainer = ray.train.lightning.prepare_trainer(trainer)
|
||||
trainer.fit(model, train_dataloaders=train_dataloader)
|
||||
|
||||
|
||||
def test_lightning_train_run():
|
||||
# [2] Configure scaling and resource requirements.
|
||||
scaling_config = ray.train.ScalingConfig(num_workers=4, use_gpu=True)
|
||||
|
||||
# [3] Launch distributed training job.
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
scaling_config=scaling_config,
|
||||
# [3a] If running in a multi-node cluster, this is where you
|
||||
# should configure the run's persistent storage that is accessible
|
||||
# across all worker nodes.
|
||||
run_config=ray.train.RunConfig(
|
||||
storage_path="/mnt/cluster_storage/lightning_run"
|
||||
),
|
||||
)
|
||||
result: ray.train.Result = trainer.fit()
|
||||
|
||||
# [4] Load the trained model.
|
||||
with result.checkpoint.as_directory() as checkpoint_dir:
|
||||
model = ImageClassifier.load_from_checkpoint( # noqa: F841
|
||||
os.path.join(
|
||||
checkpoint_dir,
|
||||
ray.train.lightning.RayTrainReportCallback.CHECKPOINT_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_lightning_train_run()
|
||||
@@ -0,0 +1,8 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
# Single GPU node. The torchft linear example trains on CPU (gloo) but the
|
||||
# image ships GPU torch (torch==2.9.0+cu128), so a CUDA node is representative.
|
||||
head_node:
|
||||
instance_type: g4dn.4xlarge
|
||||
|
||||
worker_nodes: []
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Hello-world release test for the torchft Ray ML image variant.
|
||||
|
||||
This is a reference test showing how to run a release test on the custom
|
||||
torchft BYOD image. The image is the core Ray CUDA image (py3.13) with the
|
||||
torchft ML dependency lock installed on top:
|
||||
|
||||
- lock: release/ray_release/byod/ml_torchft_py3.13.lock
|
||||
(generated by ci/raydepsets/configs/release_ml_torchft_tests.depsets.yaml)
|
||||
|
||||
The lock is referenced from the release test via `byod.python_depset`; the BYOD
|
||||
build installs it automatically (`uv pip install --system --no-deps -r
|
||||
python_depset.lock`), so no post_build_script is required.
|
||||
|
||||
See the matching `torchft_hello_world` entry in release/release_tests.yaml for
|
||||
the cluster wiring (byod.type / byod.python_depset).
|
||||
|
||||
The test simply proves the image works end to end: torch 2.9.0 + torchft import
|
||||
cleanly and a tiny Ray Train v2 + torchft training loop runs to completion.
|
||||
"""
|
||||
|
||||
import ray
|
||||
import torch
|
||||
import torchft # noqa: F401 -- provided by torchft-nightly in the image
|
||||
|
||||
from ray.train.v2.examples.pytorch.torchft_linear_example import train_torchft
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"ray=={ray.__version__} torch=={torch.__version__} torchft OK")
|
||||
|
||||
# torchft_linear_example trains a tiny linear model on CPU (gloo backend)
|
||||
# under Ray Train v2 with a torchft TorchftConfig. A short run is enough to
|
||||
# validate that the image's torch + torchft + Ray Train stack works.
|
||||
metrics = train_torchft(num_workers=2, num_steps=20)
|
||||
print(f"torchft hello world succeeded: {metrics}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,20 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
advanced_instance_config:
|
||||
BlockDeviceMappings:
|
||||
- DeviceName: /dev/sda1
|
||||
Ebs:
|
||||
DeleteOnTermination: true
|
||||
Iops: 5000
|
||||
Throughput: 1000
|
||||
VolumeSize: 200
|
||||
VolumeType: gp3
|
||||
|
||||
head_node:
|
||||
instance_type: m5.2xlarge
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: m5.4xlarge
|
||||
min_nodes: 10
|
||||
max_nodes: 10
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,20 @@
|
||||
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}
|
||||
|
||||
advanced_instance_config:
|
||||
BlockDeviceMappings:
|
||||
- DeviceName: /dev/sda1
|
||||
Ebs:
|
||||
DeleteOnTermination: true
|
||||
Iops: 5000
|
||||
Throughput: 1000
|
||||
VolumeSize: 200
|
||||
VolumeType: gp3
|
||||
|
||||
head_node:
|
||||
instance_type: m5.2xlarge
|
||||
|
||||
worker_nodes:
|
||||
- instance_type: m5.4xlarge
|
||||
min_nodes: 1
|
||||
max_nodes: 1
|
||||
market_type: ON_DEMAND
|
||||
@@ -0,0 +1,246 @@
|
||||
import json
|
||||
import numpy as np
|
||||
import os
|
||||
import pandas as pd
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import xgboost as xgb
|
||||
import lightgbm as lgb
|
||||
|
||||
import ray
|
||||
from ray import data
|
||||
from ray.train.lightgbm import (
|
||||
LightGBMTrainer,
|
||||
RayTrainReportCallback as LightGBMReportCallback,
|
||||
normalize_pandas_for_lightgbm,
|
||||
)
|
||||
from ray.train.xgboost import (
|
||||
RayTrainReportCallback as XGBoostReportCallback,
|
||||
XGBoostTrainer,
|
||||
)
|
||||
from ray.train import RunConfig, ScalingConfig
|
||||
|
||||
_TRAINING_TIME_THRESHOLD = 600
|
||||
_PREDICTION_TIME_THRESHOLD = 450
|
||||
|
||||
_EXPERIMENT_PARAMS = {
|
||||
"smoke_test": {
|
||||
"data": (
|
||||
"https://air-example-data-2.s3.us-west-2.amazonaws.com/"
|
||||
"10G-xgboost-data.parquet/8034b2644a1d426d9be3bbfa78673dfa_000000.parquet"
|
||||
),
|
||||
"num_workers": 1,
|
||||
"cpus_per_worker": 1,
|
||||
},
|
||||
"10G": {
|
||||
"data": "s3://air-example-data-2/10G-xgboost-data.parquet/",
|
||||
"num_workers": 1,
|
||||
"cpus_per_worker": 12,
|
||||
},
|
||||
"100G": {
|
||||
"data": "s3://air-example-data-2/100G-xgboost-data.parquet/",
|
||||
"num_workers": 10,
|
||||
"cpus_per_worker": 12,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class BasePredictor:
|
||||
def __init__(self, report_callback_cls, result: ray.train.Result):
|
||||
self.model = report_callback_cls.get_model(result.checkpoint)
|
||||
|
||||
def __call__(self, data):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class XGBoostPredictor(BasePredictor):
|
||||
def __call__(self, data: pd.DataFrame) -> Dict[str, np.ndarray]:
|
||||
dmatrix = xgb.DMatrix(data)
|
||||
return {"predictions": self.model.predict(dmatrix)}
|
||||
|
||||
|
||||
class LightGBMPredictor(BasePredictor):
|
||||
def __call__(self, data: pd.DataFrame) -> Dict[str, np.ndarray]:
|
||||
return {"predictions": self.model.predict(normalize_pandas_for_lightgbm(data))}
|
||||
|
||||
|
||||
def xgboost_train_loop_function(config: Dict):
|
||||
train_ds_iter = ray.train.get_dataset_shard("train")
|
||||
train_df = train_ds_iter.materialize().to_pandas()
|
||||
|
||||
label_column, params = config["label_column"], config["params"]
|
||||
train_X, train_y = train_df.drop(label_column, axis=1), train_df[label_column]
|
||||
|
||||
dtrain = xgb.DMatrix(train_X, label=train_y)
|
||||
|
||||
report_callback = config["report_callback_cls"]
|
||||
xgb.train(
|
||||
params,
|
||||
dtrain=dtrain,
|
||||
num_boost_round=10,
|
||||
callbacks=[report_callback()],
|
||||
)
|
||||
|
||||
|
||||
def lightgbm_train_loop_function(config: Dict):
|
||||
train_ds_iter = ray.train.get_dataset_shard("train")
|
||||
train_df = normalize_pandas_for_lightgbm(train_ds_iter.materialize().to_pandas())
|
||||
|
||||
label_column, params = config["label_column"], config["params"]
|
||||
train_X, train_y = train_df.drop(label_column, axis=1), train_df[label_column]
|
||||
train_set = lgb.Dataset(train_X, label=train_y)
|
||||
|
||||
report_callback = config["report_callback_cls"]
|
||||
network_params = ray.train.lightgbm.get_network_params()
|
||||
params.update(network_params)
|
||||
|
||||
lgb.train(
|
||||
params,
|
||||
train_set=train_set,
|
||||
num_boost_round=10,
|
||||
callbacks=[report_callback()],
|
||||
)
|
||||
|
||||
|
||||
_FRAMEWORK_PARAMS = {
|
||||
"xgboost": {
|
||||
"trainer_cls": XGBoostTrainer,
|
||||
"predictor_cls": XGBoostPredictor,
|
||||
"train_loop_function": xgboost_train_loop_function,
|
||||
"train_loop_config": {
|
||||
"params": {
|
||||
"objective": "binary:logistic",
|
||||
"eval_metric": ["logloss", "error"],
|
||||
},
|
||||
"label_column": "labels",
|
||||
"report_callback_cls": XGBoostReportCallback,
|
||||
},
|
||||
},
|
||||
"lightgbm": {
|
||||
"trainer_cls": LightGBMTrainer,
|
||||
"predictor_cls": LightGBMPredictor,
|
||||
"train_loop_function": lightgbm_train_loop_function,
|
||||
"train_loop_config": {
|
||||
"params": {
|
||||
"objective": "binary",
|
||||
"metric": ["binary_logloss", "binary_error"],
|
||||
},
|
||||
"label_column": "labels",
|
||||
"report_callback_cls": LightGBMReportCallback,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def train(
|
||||
framework: str, data_path: str, num_workers: int, cpus_per_worker: int
|
||||
) -> ray.train.Result:
|
||||
ds = data.read_parquet(data_path)
|
||||
framework_params = _FRAMEWORK_PARAMS[framework]
|
||||
|
||||
trainer_cls = framework_params["trainer_cls"]
|
||||
framework_train_loop_fn = framework_params["train_loop_function"]
|
||||
|
||||
trainer = trainer_cls(
|
||||
train_loop_per_worker=framework_train_loop_fn,
|
||||
train_loop_config=framework_params["train_loop_config"],
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=num_workers,
|
||||
resources_per_worker={"CPU": cpus_per_worker},
|
||||
),
|
||||
datasets={"train": ds},
|
||||
run_config=RunConfig(
|
||||
storage_path="/mnt/cluster_storage", name=f"{framework}_benchmark"
|
||||
),
|
||||
)
|
||||
result = trainer.fit()
|
||||
return result
|
||||
|
||||
|
||||
def predict(framework: str, result: ray.train.Result, data_path: str):
|
||||
framework_params = _FRAMEWORK_PARAMS[framework]
|
||||
|
||||
predictor_cls = framework_params["predictor_cls"]
|
||||
|
||||
ds = data.read_parquet(data_path)
|
||||
ds = ds.drop_columns(["labels"])
|
||||
|
||||
concurrency = int(ray.cluster_resources()["CPU"] // 2)
|
||||
ds.map_batches(
|
||||
predictor_cls,
|
||||
# Improve prediction throughput with larger batch size than default 4096
|
||||
batch_size=8192,
|
||||
concurrency=concurrency,
|
||||
fn_constructor_kwargs={
|
||||
"report_callback_cls": framework_params["train_loop_config"][
|
||||
"report_callback_cls"
|
||||
],
|
||||
"result": result,
|
||||
},
|
||||
batch_format="pandas",
|
||||
).write_parquet("/mnt/cluster_storage/predictions")
|
||||
|
||||
|
||||
def main(args):
|
||||
framework = args.framework
|
||||
|
||||
experiment = args.size if not args.smoke_test else "smoke_test"
|
||||
experiment_params = _EXPERIMENT_PARAMS[experiment]
|
||||
|
||||
data_path, num_workers, cpus_per_worker = (
|
||||
experiment_params["data"],
|
||||
experiment_params["num_workers"],
|
||||
experiment_params["cpus_per_worker"],
|
||||
)
|
||||
|
||||
print(f"Running {framework} training benchmark...")
|
||||
training_start = time.perf_counter()
|
||||
result = train(framework, data_path, num_workers, cpus_per_worker)
|
||||
training_time = time.perf_counter() - training_start
|
||||
|
||||
print(f"Running {framework} prediction benchmark...")
|
||||
prediction_start = time.perf_counter()
|
||||
predict(framework, result, data_path)
|
||||
prediction_time = time.perf_counter() - prediction_start
|
||||
|
||||
times = {"training_time": training_time, "prediction_time": prediction_time}
|
||||
print("Training result:\n", result)
|
||||
print("Training/prediction times:", times)
|
||||
test_output_json = os.environ.get("TEST_OUTPUT_JSON", "/tmp/result.json")
|
||||
with open(test_output_json, "wt") as f:
|
||||
json.dump(times, f)
|
||||
|
||||
if not args.disable_check:
|
||||
if training_time > _TRAINING_TIME_THRESHOLD:
|
||||
raise RuntimeError(
|
||||
f"Training is taking {training_time} seconds, "
|
||||
f"which is longer than expected ({_TRAINING_TIME_THRESHOLD} seconds)."
|
||||
)
|
||||
|
||||
if prediction_time > _PREDICTION_TIME_THRESHOLD:
|
||||
raise RuntimeError(
|
||||
f"Batch prediction is taking {prediction_time} seconds, "
|
||||
f"which is longer than expected ({_PREDICTION_TIME_THRESHOLD} seconds)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"framework", type=str, choices=["xgboost", "lightgbm"], default="xgboost"
|
||||
)
|
||||
parser.add_argument("--size", type=str, choices=["10G", "100G"], default="100G")
|
||||
# Add a flag for disabling the timeout error.
|
||||
# Use case: running the benchmark as a documented example, in infra settings
|
||||
# different from the formal benchmark's EC2 setup.
|
||||
parser.add_argument(
|
||||
"--disable-check",
|
||||
action="store_true",
|
||||
help="disable runtime error on benchmark timeout",
|
||||
)
|
||||
parser.add_argument("--smoke-test", action="store_true")
|
||||
args = parser.parse_args()
|
||||
main(args)
|
||||
Reference in New Issue
Block a user