Best Practices for Multi-Node Training on ABCI with PyTorch
This article summarizes a simple method for conducting distributed training with ABCI, the GPU cloud computing service operated by AIST. The following repository provides a minimal working example of training code on ABCI with support for multi-node training:
https://github.com/yukara-ikemiya/abci-code-sample
Using ABCI for Training Large-Scale Models
AI Bridging Cloud Infrastructure (ABCI) is the world’s first large-scale Open AI Computing Infrastructure, constructed and operated by National Institute of Advanced Industrial Science and Technology (AIST). (https://abci.ai/en/about_abci/)
Creating the Python Environment
ABCI supports using Singularity containers for building training environments. There are several ways to build a Singularity container (SIF), but in my repository, scripts are provided to build a Docker image first and then convert it into a SIF file.
Once a SIF file is created, move it to any directory on ABCI and mount it when running training jobs.
Preparing the Training Code
When conducting distributed training with PyTorch, it is common to write training loops using DistributedDataParallel (DDP). Here, utilizing HuggingFace Accelerate is highly recommended. It wraps DDP into a clean, intuitive API, keeping code maintainable and preventing subtle synchronization bugs.
Model (Minimal Auto-Encoder)
import torch
from torch import nn
class SimpleModule(nn.Module):
def __init__(self, dim_in, dim_hidden):
super().__init__()
self.dim_in = dim_in
self.dim_hidden = dim_hidden
self.net = nn.Sequential(
nn.Linear(self.dim_in, self.dim_hidden),
nn.Linear(self.dim_hidden, self.dim_in)
)
def forward(self, x):
return self.net(x)
Toy Dataset
import numpy as np
import torch
from torch.utils.data import Dataset
class DummyDataset(Dataset):
def __init__(self, dim:int, num_data:int=10000):
super().__init__()
self.dim = dim
self.num_data = num_data
def get_item(self, idx):
data = np.linspace(0, idx, self.dim)
return torch.from_numpy(data.astype(np.float32))
def __len__(self):
return self.num_data
def __getitem__(self, idx):
return self.get_item(idx)
Training Code
import argparse
import torch
from torch.utils.data import DataLoader
from accelerate import Accelerator
from simple_module import SimpleModule
from dummy_dataset import DummyDataset
def get_args():
parser = argparse.ArgumentParser()
parser.add_argument("--bs", type=int, default=50, help="batch size")
parser.add_argument("--lr", type=float, default=0.0001, help="learning rate")
parser.add_argument("--amp", type=str, default='fp16', help="autmatic mixed precision")
return parser.parse_args()
def main():
args = get_args()
# Initialize accelerator
accelerator = Accelerator(mixed_precision=args.amp, split_batches=True)
# Model
model = SimpleModule(dim_in=1000, dim_hidden=100)
# Dataset
num_data = 10000
dataset = DummyDataset(model.dim_in, num_data)
dataloader = DataLoader(dataset, batch_size=args.bs, num_workers=4,
pin_memory=True, persistent_workers=True, shuffle=True)
# Optimizer
optimizer = torch.optim.Adam(model.parameters(), lr=args.lr, betas=[0.0, 0.99])
# Prepare for distributed training
model, dataloader, optimizer = accelerator.prepare(model, dataloader, optimizer)
for idx_e in range(200):
loss_epoch = 0.
for idx_d, x in enumerate(dataloader):
# forward
optimizer.zero_grad(set_to_none=True)
y = model(x)
# RMSE loss
loss = ((x - y) ** 2).mean().sqrt()
# backward
accelerator.backward(loss)
optimizer.step()
loss_epoch += loss.detach()
if accelerator.is_main_process:
loss_epoch /= idx_d + 1
print(f'Epoch {idx_e+1} : {loss_epoch}')
if __name__ == '__main__':
main()
Preparing Execution Scripts
In multi-node environments, the internal environment variables expected by HuggingFace Accelerate might not always be propagated automatically. To handle this cleanly, wrap the Python invocation with a bash wrapper copying OpenMPI environment variables:
python.bash
#!/bin/bash
# Define (copy) environment variables for HuggingFace Accelerate
export WORLD_SIZE=$OMPI_COMM_WORLD_SIZE
export RANK=$OMPI_COMM_WORLD_RANK
export LOCAL_RANK=$OMPI_COMM_WORLD_LOCAL_RANK
export MASTER_PORT=11111
exec python "$@"
Cluster parameters (such as GPUs per node) can be detected dynamically:
train.bash
#!/bin/bash
#$-cwd
# Load modules (adjust based on ABCI environment version)
source /etc/profile.d/modules.sh
module load hpcx/2.12
module load singularitypro/3.11
module load cuda/11.6/11.6.2
module load nccl/2.11/2.11.4-1
# Singularity container path
CONTAINER_PATH="/path/to/your/container.sif"
# Job ID
JOB_NAME=$JOB_ID
# Detect GPU type (V100 or A100)
GPU_INFO=$(nvidia-smi --query-gpu=gpu_name --format=csv)
if [[ $GPU_INFO =~ "V100" ]]; then
NUM_GPUS_PER_NODE=4
elif [[ $GPU_INFO =~ "A100" ]]; then
NUM_GPUS_PER_NODE=8
else
readonly PROC_ID=$!
kill ${PROC_ID}
fi
# Get number of GPUs
GPUS_IN_ONE_NODE=$(nvidia-smi --list-gpus | wc -l)
NUM_GPU=$(expr ${NHOSTS} \* ${GPUS_IN_ONE_NODE})
echo "NUM_GPU = ${NUM_GPU}"
# MPI options
MPIOPTS="-np $NUM_GPU -N ${NUM_GPUS_PER_NODE} -x MASTER_ADDR=${HOSTNAME} -hostfile $SGE_JOB_HOSTLIST"
# Source code directory to mount
ROOT_SRC="/path/to/your/source/codes/"
# Hyperparameters
batch_size=256 # multiple of 16 (for 2 nodes)
learning_rate=0.0001
amp=fp16
# Execute job
mpirun $MPIOPTS \
singularity exec --nv --pwd ${ROOT_SRC}/src/ -B ${ROOT_SRC} \
${CONTAINER_PATH} \
${ROOT_SRC}/job/python.bash ${ROOT_SRC}/src/train.py \
--bs ${batch_size} \
--lr ${learning_rate} \
--amp ${amp}
Finally, submit the multi-node job to ABCI:
_run_job_on_abci_nodes.bash
# Submit training job using 2 nodes
qsub -j y -g gce12345 -l rt_AF=2 -l h_rt=0:30:00 ./job/train.bash