Modern frontier models such as the DeepSeek-V4 or GLM-5 series often contain hundreds of billions of parameters whose model weights alone require terabytes of memory and storage. Such models simply cannot fit within a single GPU or NPU regardless of its specifications. Training and serving LLMs at this scale requires distributing the workload among GPUs or NPUs often spanning multiple datacenter servers.
This article demonstrates how MindSpore and CANN provides native capabilities for distributed model training through a motivating example adapted from the official PyTorch YouTube tutorial.

Prerequisites
Introductory knowledge of deep learning and convolutional networks such as would be had by following through the first 7 chapters of the D2L textbook.
Hardware specifications
The motivating example in this article was developed and tested within my AI homelab based on the GMKtec NucBox K11 Mini PC paired with the OrangePi AI Studio Pro extension dock.
| Resource | Specifications |
|---|---|
| vCPU | 16 |
| Memory | 96G |
| Storage | 1T |
| NPU | Ascend 310P1 x2 |
| AI computing capability | 352 TOPS / 176 TFLOPS |
| Total device memory | 192G |
Learn all about my AI homelab setup from my previous blog posts.
Software environment and source code
The full training script can be found in my GitHub repository: DonaldKellett/my-ascend-jobs
This motivating example was validated within a containerized execution environment using a customized container image with MindSpore 2.10.0 + CANN 8.5.0 preinstalled and preconfigured. The CANN base container image is available on AscendHub.
- The Dockerfile for the CANN base image can be found in the upstream GitHub repository: Ascend/cann-container-image
- The layered Python packages used to build my customized MindSpore container image can be found in my GitHub repository: DonaldKellett/my-cann-container-image
The requirements.txt shown below for reference.
absl-py==2.5.0decorator==5.3.1matplotlib==3.11.1mindspore==2.10.0mlflow==3.15.1ml-dtypes==0.4.0numpy==1.26.4sympy==1.14.0tornado==6.5.8
Analyzing the source code
For simplicity, I used a monofile approach with everything defined in main.py. It’s sufficient for our use case since only a few helper classes and functions are involved.
Our main.py is roughly divided into the following sections.
- Package imports and dependencies
- Helper classes and functions
- Our distributed training pipeline defined in
main() - Invoking our
main()function at the end of the script
Without diving into the details, here’s the overall structure of our distributed training workflow.
"""Package imports and dependencies"""import ...from ... import ..."""Helper classes and functions"""def transform_ds(...): ...class MLflowLogging(...): ..."""Our distributed training pipeline"""def main(): ...if __name__ == '__main__': main()
Read on for the detailed breakdown and explanation of each component within our workflow.
Package imports and dependencies
The main Python packages and libraries involved in our workflow are listed below.
- MindSpore: Huawei’s deep learning framework optimized for their Ascend datacenter and edge NPUs
- MLflow: allows us to track deep learning experiments and visualize metrics such as model loss and accuracy
- Matplotlib: used to visualize samples within our training data and output the result in PNG format
import gzipimport matplotlib.pyplot as pltimport mindsporeimport mindspore.amp as ampimport mindspore.communication as communicationimport mindspore.context as contextimport mindspore.dataset as dsimport mindspore.dataset.transforms as transformsimport mindspore.dataset.vision as visionimport mindspore.nn as nnimport mindspore.ops as opsimport mlflowimport osimport urllib.requestfrom mindspore import dtype as mstypefrom mindspore.train import Callback, LossMonitor, Model
Transforming our images and labels for training
We’ll train a modern variation of LeNet on the Fashion MNIST dataset. The standard image-based data transformations apply.
- Each pixel per
28x28grayscale image is scaled by a factor of1/255to ensure all values lie within the range[0,1]. This helps to stabilize training and prevent exploding gradients - Reorder the image dimensions so the channel dimension comes first:
(channel,height,width). This is standard practice for feeding images into convolutional neural networks (CNN) - Image labels are one-hot encoded, a standard procedure for training simple classification models by minimizing the softmax cross-entropy loss
Our function transform_ds() receives a DataSet object and uses MindSpore’s built in data transformation methods to transform and split our dataset into batches of 64 samples per NPU. Since our workload is split across 2 NPUs, the global batch size is 64 * 2 = 128 samples per batch.
def transform_ds(dataset, batch_size): image_transforms = [ vision.Resize(size=(28, 28)), vision.Rescale(rescale=1/255, shift=0), vision.HWC2CHW(), transforms.TypeCast(data_type=mstype.float32) ] label_transforms = [ transforms.OneHot(num_classes=10), transforms.TypeCast(data_type=mstype.float32) ] dataset = dataset.map(operations=image_transforms, input_columns='image') dataset = dataset.map(operations=label_transforms, input_columns='label') dataset = dataset.batch(batch_size=batch_size, drop_remainder=False) return dataset
Tracking model hyperparameters and metrics with MLflow
Next, we define an MLflowLogging class based on mindspore.train.Callback. It creates an MLflow run to track model hyperparameters such as the learning rate and batch size as well as metrics such as:
- The training loss at the end of each step
- The validation loss at the end of each epoch
- The validation accuracy at the end of each epoch
class MLflowLogging(Callback): def __init__(self, run_name, network_type, loss_fn, learning_rate, batch_size, epochs, num_shards, shard_id, weight_decay=0.0, momentum=0.0, optimizer='sgd'): super().__init__() self.run_name = run_name self.network_type = network_type self.loss_fn = loss_fn self.learning_rate = learning_rate self.batch_size = batch_size self.epochs = epochs self.weight_decay = weight_decay self.momentum = momentum self.optimizer = optimizer self.num_shards = num_shards self.shard_id = shard_id self.run = mlflow.start_run(run_name=self.run_name) hyperparameters = { 'learning_rate': self.learning_rate, 'weight_decay': self.weight_decay, 'momentum': self.momentum, 'loss_fn': self.loss_fn, 'optimizer': self.optimizer, 'batch_size': self.batch_size, 'network_type': self.network_type, 'epochs': self.epochs, 'num_shards': num_shards, 'shard_id': shard_id } mlflow.log_params(hyperparameters) def on_train_step_end(self, run_context): cb_params = run_context.original_args() current_loss = cb_params.net_outputs.asnumpy().mean() mlflow.log_metric('train_loss', current_loss, step=cb_params.cur_step_num) def on_train_epoch_end(self, run_context): cb_params = run_context.original_args() if hasattr(cb_params, 'eval_results') and cb_params.eval_results: val_loss = cb_params.eval_results.get('loss', 0.0) val_accuracy = cb_params.eval_results.get('accuracy', 0.0) mlflow.log_metric('val_loss', val_loss, step=cb_params.cur_epoch_num) mlflow.log_metric('val_accuracy', val_accuracy, step=cb_params.cur_epoch_num) def on_train_end(self, run_context): mlflow.end_run()
With our helper classes and functions defined, let’s move on to the actual training pipeline defined in main().
Initializing HCCL for distributed training
Huawei Collective Communication Library (HCCL) is the communication library included within the CANN software which enables Ascend NPUs to communicate with each other via dedicated high-speed network links and protocols such as HCCS, RoCE and PCIe for distributed training and inference.
The distributed training job consists of a scheduler process and multiple worker subprocesses. Each worker corresponds to an available NPU device. The MS_ROLE environment variable distinguishes the scheduler from its workers. The scheduler is assigned the MS_SCHED role while workers carry the role MS_WORKER.
MS_ROLE = os.getenv('MS_ROLE')print(f'Running msrun with role: {MS_ROLE}')
Next, we invoke mindspore.communication.init to initialize HCCL as our communication backend. Additionally, we use set_context() and set_auto_parallel_context() under mindspore.context to set the following options.
mode=context.GRAPH_MODE: set the execution mode to Graph modeparallel_mode=context.ParallelMode.DATA_PARALLEL: use Data Parallel mode to distribute the training data across multiple NPUsgradients_mean=True: use the mean of computed gradients on each NPU for parameter update at the end of each training step
context.set_context(mode=context.GRAPH_MODE, device_target='Ascend')communication.init('hccl')context.set_auto_parallel_context( parallel_mode=context.ParallelMode.DATA_PARALLEL, gradients_mean=True)
With 2 NPUs on our node, each worker subprocess receives its own rank ID and the group size is 2. We obtain the rank ID and group size via the get_rank() and get_group_size() functions respectively. Furthermore, we define 2 additional variables.
MLFLOW_TRACKING_URI: the MLflow tracking server URL where we log our model hyperparameters and metrics toMPLBACKEND: specify the Matplotlib AGG backend to visualize and output samples from our training data in PNG format
rank_id = communication.get_rank()rank_size = communication.get_group_size()MLFLOW_TRACKING_URI = os.getenv('MLFLOW_TRACKING_URI')MPLBACKEND = os.getenv('MPLBACKEND')print(f'Running rank {rank_id} of {rank_size}')if rank_id == 0: print(f'Using MLflow tracking URI: {MLFLOW_TRACKING_URI}') print(f'Using Matplotlib backend: {MPLBACKEND}')
The rank_id == 0 guard ensures only the worker subprocess with rank ID 0 displays the MLflow tracking server URL and Matplotlib backend respectively so the message isn’t displayed twice.
Now we set the MLflow experiment name and use mlflow.set_experiment to set the active experiment.
experiment_name = '01-multi-npu-training'experiment = mlflow.set_experiment(experiment_name=experiment_name)
Downloading the Fashion MNIST dataset
Here we create a dedicated directory data/fashion/ to store our training data downloaded from a local mirror. The Python standard library is sufficient for this task.
Take note of the details below.
- We guard the entire data fetching pipeline with
rank_id == 0. This ensures we download the training data exactly once and prevents concurrent file I/O operations from corrupting our training data - Notice the
mindspore.ops.communication.barriercall right after our data fetching pipeline. This ensures all workers wait for the training data to be available before proceeding with the rest of the training pipeline, preventing individual workers from accessing incomplete or missing training data
dataset_dir = 'data/fashion/'if rank_id == 0: os.makedirs(dataset_dir, exist_ok=True) prefix_url = 'https://assets.donaldsebleung.com/datasets/fashion-mnist' X_train_url = f'{prefix_url}/train-images-idx3-ubyte.gz' y_train_url = f'{prefix_url}/train-labels-idx1-ubyte.gz' X_test_url = f'{prefix_url}/t10k-images-idx3-ubyte.gz' y_test_url = f'{prefix_url}/t10k-labels-idx1-ubyte.gz' X_train_path = os.path.join(dataset_dir, 'train-images-idx3-ubyte') y_train_path = os.path.join(dataset_dir, 'train-labels-idx1-ubyte') X_test_path = os.path.join(dataset_dir, 't10k-images-idx3-ubyte') y_test_path = os.path.join(dataset_dir, 't10k-labels-idx1-ubyte') with urllib.request.urlopen(X_train_url) as response: with open(X_train_path, 'wb') as out_file: data_gzip = response.read() data = gzip.decompress(data_gzip) out_file.write(data) with urllib.request.urlopen(y_train_url) as response: with open(y_train_path, 'wb') as out_file: data_gzip = response.read() data = gzip.decompress(data_gzip) out_file.write(data) with urllib.request.urlopen(X_test_url) as response: with open(X_test_path, 'wb') as out_file: data_gzip = response.read() data = gzip.decompress(data_gzip) out_file.write(data) with urllib.request.urlopen(y_test_url) as response: with open(y_test_path, 'wb') as out_file: data_gzip = response.read() data = gzip.decompress(data_gzip) out_file.write(data)if rank_size > 1: # All workers should wait for the full dataset to be downloaded before proceeding ops.communication.barrier()
Visualizing our dataset with Matplotlib
We use matplotlib.pyplot to display 10 random training samples as usual. 2 details worth noting:
- We guard against concurrent file I/O with
rank_id == 0again, ensuring the PNG visualization is rendered and written to disk exactly once - Instead of invoking
fig.show()to display the visualization directly within our Jupyter notebook, here we callplt.savefig()to save the visualization as a PNG file instead followed byplt.close().
if rank_id == 0: visualization_ds = ds.FashionMnistDataset(dataset_dir=dataset_dir, usage='train', shuffle=True) visualization_ds_samples = visualization_ds.batch(batch_size=10) X_samples, y_samples = next(visualization_ds_samples.create_tuple_iterator()) X_samples, y_samples = X_samples.asnumpy(), y_samples.asnumpy() LABELS = [ 'T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot' ] fig, axes = plt.subplots(2, 5) for axis_idx, axis in enumerate(axes.flatten()): axis.set_title(LABELS[y_samples[axis_idx]]) axis.imshow(X_samples[axis_idx]) axis.axis('off') fig.tight_layout() plt.savefig( '00-mindspore-hccl-lenet-fashion-mnist-samples.png', dpi=300, bbox_inches='tight', transparent=False, format='png' ) plt.close(fig)

Sharding and transforming our dataset
Here we instantiate our mindspore.dataset.FashionMnistDataset instances using the available training and validation data. For the training set, we further specify the following parameters to split the data between both NPUs.
num_shards: the group size of our distributed training jobshard_id: the rank ID assigned to this worker
train_ds = ds.FashionMnistDataset( dataset_dir=dataset_dir, usage='train', shuffle=True, num_shards=rank_size, shard_id=rank_id)test_ds = ds.FashionMnistDataset( dataset_dir=dataset_dir, usage='test', shuffle=True)
Our per-NPU batch size is 64 and the global batch size is 64 * 2 = 128. Use the transform_ds() function we defined earlier to apply the necessary data transformations for model training and evaluation.
batch_size = 64train_ds = transform_ds( dataset=train_ds, batch_size=batch_size)test_ds = transform_ds( dataset=test_ds, batch_size=batch_size)
Defining our neural network, loss function and optimizer
We’ll use a modernized variation of LeNet for our neural network with the following improvements over the original implementation.
- Replace sigmoid with ReLU activation to prevent vanishing gradients
- Max-pooling often performs better in CNNs compared to average pooling
net = nn.SequentialCell([ nn.Conv2d(1, 6, kernel_size=5, pad_mode='valid'), nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(6, 16, kernel_size=5, pad_mode='valid'), nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2), nn.Flatten(), nn.Dense(256, 120, activation='relu'), nn.Dense(120, 84, activation='relu'), nn.Dense(84, 10)])
As usual for simple classification algorithms, we’ll use softmax cross-entropy for our loss function and minibatch SGD for our optimizer. Furthermore, let’s wrap our neural network with mindspore.amp.auto_mixed_precision to handle type casting between FP16 and FP32 automatically.
learning_rate = 0.1net_amp = amp.auto_mixed_precision(network=net, amp_level='O2')loss_fn = nn.SoftmaxCrossEntropyWithLogits(reduction='mean')optimizer = nn.SGD(params=net_amp.trainable_params(), learning_rate=learning_rate)
Defining our training loop with the Model API
MindSpore’s Model API gives us a clean, high-level abstraction to define our training loop. We’ve seen it before so let’s focus on the differences compared to previous runs.
- Each worker subprocess receives its own set of training data and computes its gradients in parallel. This means each callback defined in
callbacksis invoked once per worker across each step and epoch - As a result, an MLflow run is created and updated independently per worker. We customize the
run_namebased on the worker’srank_idto distinguish both runs in the MLflow UI and log thenum_shards,shard_idas additional hyperparameters
run_name = '00-mindspore-hccl-lenet'network_type = 'lenet'loss_fn_str = 'softmax_cross_entropy'epochs = 10model = Model( network=net_amp, loss_fn=loss_fn, optimizer=optimizer, metrics={'accuracy', 'loss'})callbacks = [ LossMonitor(per_print_times=10), MLflowLogging( run_name=f'{run_name}-rank-{rank_id}', network_type=network_type, loss_fn=loss_fn_str, learning_rate=learning_rate, batch_size=batch_size, epochs=epochs, num_shards=rank_size, shard_id=rank_id )]model.fit( epoch=epochs, train_dataset=train_ds, valid_dataset=test_ds, callbacks=callbacks, dataset_sink_mode=False)
Running the distributed training job with msrun
Unlike single-NPU training jobs which are executed directly with Python, we need to leverage the msrun command provided by MindSpore to run our distributed training job in parallel across multiple subprocesses.
- The scheduler process orchestrates the entire workflow and distributes the workload across multiple worker subprocesses, 1 worker per available NPU
- Each worker subprocess runs the entire workflow with its allocated share of training data and model parameters. Workers coordinate via the network layout provided by the scheduler to synchronize the training progress, model weights and computed gradients among each other
With our distributed training job spread across 2 Ascend 310P NPUs, the following options were specified.
--worker_num=2: create a total of 2 worker subprocesses across all NPUs and nodes--local_worker_num=2: since both NPUs are on the same node, create 2 workers on this node--join=True: inform the scheduler to wait for all workers to complete. Otherwise, the scheduler process ends early which terminates our Kubernetes job prematurely
msrun \ --worker_num=2 \ --local_worker_num=2 \ --join=True \ python \ /app/main.py
Demo and screenshots
Here’s a GIF animation of running the distributed training job on Kubernetes. View it on Asciinema or download the Asciicast recording.

The distributed training script logged separate MLflow runs and metrics per subprocess as well, shown below.



I’d like to take this opportunity to thank my teacher DeepSeek-V4-Pro for helping me adapt the original training script for multi-NPU scenario 😉

Concluding remarks and going further
We saw in this article how MindSpore and CANN provide native capabilities for distributing model training workloads across multiple Ascend NPUs on a single node with msrun and HCCL. In production environments with frontier models involving hundreds of billions of parameters, we can extend this distributed training workflow across multiple server nodes as well.
I hope you enjoyed reading this article as much as I did authoring it and stay tuned for updates! 😉
Leave a Reply