NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

Click the blue text to follow us

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and ExecutionNCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

The NCCL (NVIDIA Collective Communication Library, pronounced “Nickel”) library is primarily used for communication in GPU clusters. This article provides some basic C++ API library calls for learning reference. The NCCL documentation has a rich API introduction, but the explanations are interspersed with various operational details, which can be a bit convoluted for beginners. For example, when introducing the communicator, it also discusses the split operation of comm.

The official examples (Examples – NCCL 2.22.3 documentation) may seem somewhat incomplete from a beginner’s perspective. Another library (GitHub – NVIDIA/nccl-tests: NCCL Tests) is not simple enough and requires reading the entire text. To help provide a preliminary understanding of the NCCL API, this article first introduces the basic steps for using the API, then explains several common scenario examples, and provides compilation and execution methods.

Necessary terminology explanations:

  • Node: A server device/machine;

  • Rank: A label for GPU devices/threads used for device management during communication.

  • Comm: An identifier for NCCL communication, considered a handle.

1. Basic Steps

Part.1

The process of using the NCCL API is illustrated in the following diagram, primarily initializing the communicator based on the scenario, then using it for cluster communication, and finally destroying and releasing it.

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

Step 1:

Create a thread group based on the scenario. Since GPUs are independent devices, CPU threads are needed to issue CUDA commands to control their operation. Specifically, different GPUs can be controlled by the same or different CPU threads. Before computation begins, a CPU thread group must be constructed, and the control relationship between threads and GPUs must be established. Thread creation/management can be done using openMPI or others (like pthread).

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution
One thread managing multiple devices (left), one thread managing one device (right)

Step 2:

Create a unique ID. This unique ID is created by a single CPU thread and needs to be passed to other threads. It serves as an identifier for initializing the communication group.

Step 3:

Initialize the communicator (abbreviated as comm). This primarily establishes a communication group, which can include all or part of the participating GPUs. Each participating GPU in the communication has a unique rank number, and each rank has its own comm instance. The following diagram illustrates the establishment of a communication group with a rank count of 8. Multiple communication groups can exist, so a GPU device can be controlled by different comms.

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

Step 4:

Prepare communication data. Here, the CUDA API is called to prepare the data.

Step 5:

Use the created comm for cluster communication.

Step 6:

Destroy the comm. Since the comm occupies a certain amount of resources, it can be destroyed.

2. Use Case Scenarios

Part.2

The official documentation examples (docs/examples.html) provide several basic examples, which may not be understood at first glance. Here, we explain a few with text and diagrams. A common scenario is Example 2.

Example 1

Single (main) thread managing all devices (Single Process, Single Thread, Multiple Devices)

After the main process starts, it manages all devices, meaning the main program does not create child processes and manages all GPU communications itself. As shown in the figure, there are 4 GPUs, and the main process creates a comms array, assigning a comm for communication to each GPU.

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

Code implementation reference (nccl/multi_devices_per_thread.cu), where it is important to note that the initialization function uses ncclCommInitAll, and the group function is called.

  //initializing NCCL  用了一个comm组  NCCLCHECK(ncclCommInitAll(comms, nDev, devs));   //calling NCCL communication API. Group API is required when using   //multiple devices per thread  NCCLCHECK(ncclGroupStart());   // Group 是保证ncclAllReduce指令在CPU端非阻塞运行。  for (int i = 0; i < nDev; ++i)    NCCLCHECK(ncclAllReduce((const void*)sendbuff[i], (void*)recvbuff[i], size, ncclFloat, ncclSum,        comms[i], s[i]));  NCCLCHECK(ncclGroupEnd());  // 此处是要让集合通信完成执行等待。示例二

Example 2

Single thread managing a single device (One Device per Process or Thread)

Managing a single device with a single thread is easier to understand and is the most commonly used scenario. This means creating multiple threads, with each thread managing one GPU device. As shown in the figure, two devices are managed by two different threads.

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

Example 3

Single thread managing multiple devices (Multiple Devices per Thread)

Managing multiple devices with a single thread differs from Example 1, where the main process/thread manages. Here, multiple threads are created to combine multiple devices. It is important to distinguish between NCCL rank and MPI rank. The MPI rank refers to the number of threads, while the NCCL rank refers to the identifier of the GPU in the comm. In some applications (like PyTorch), global rank and local rank are also used to identify the number of threads within and between machines. An example can be found at: https://zhuanlan.zhihu.com/p/358974461

As shown below, this is a dual-thread example, with each thread managing two GPUs:

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

The main function of the example code is shown below. Note that in this example, each thread occupies two GPUs, so when starting with mpirun, the relationshipn=GPU count/2 must be ensured. In the code, localRank refers to the MPI thread label on the local node, and nRanks refers to the total number of MPI global threads.

Assuming there are 8 GPUs, an example of the startup method is: mpirun -n 4 –allow-run-as-root ./nccl_demo

// Main function: int main(int argc, char* argv[]){  int size = 32*1024*1024;  int myRank, nRanks, localRank = 0;  //initializing MPI  MPICHECK(MPI_Init(&argc, &argv));  MPICHECK(MPI_Comm_rank(MPI_COMM_WORLD, &myRank));  MPICHECK(MPI_Comm_size(MPI_COMM_WORLD, &nRanks));  //calculating localRank which is used in selecting a GPU  uint64_t hostHashs[nRanks];  char hostname[1024];  getHostName(hostname, 1024);  hostHashs[myRank] = getHostHash(hostname);  MPICHECK(MPI_Allgather(MPI_IN_PLACE, 0, MPI_DATATYPE_NULL, hostHashs, sizeof(uint64_t), MPI_BYTE, MPI_COMM_WORLD));  for (int p=0; p<nRanks; p++) {     if (p == myRank) break;     if (hostHashs[p] == hostHashs[myRank]) localRank++;  }  //each process is using two GPUs  int nDev = 2;  float** sendbuff = (float**)malloc(nDev * sizeof(float*));  float** recvbuff = (float**)malloc(nDev * sizeof(float*));  cudaStream_t* s = (cudaStream_t*)malloc(sizeof(cudaStream_t)*nDev);  //picking GPUs based on localRank  for (int i = 0; i < nDev; ++i) {    CUDACHECK(cudaSetDevice(localRank*nDev + i));    CUDACHECK(cudaMalloc(sendbuff + i, size * sizeof(float)));    CUDACHECK(cudaMalloc(recvbuff + i, size * sizeof(float)));    CUDACHECK(cudaMemset(sendbuff[i], 1, size * sizeof(float)));    CUDACHECK(cudaMemset(recvbuff[i], 0, size * sizeof(float)));    CUDACHECK(cudaStreamCreate(s+i));  }  ncclUniqueId id;  ncclComm_t comms[nDev];  //generating NCCL unique ID at one process and broadcasting it to all  if (myRank == 0) ncclGetUniqueId(&id);  MPICHECK(MPI_Bcast((void *)&id, sizeof(id), MPI_BYTE, 0, MPI_COMM_WORLD));  //initializing NCCL, group API is required around ncclCommInitRank as it is  //called across multiple GPUs in each thread/process  NCCLCHECK(ncclGroupStart());  for (int i=0; i<nDev; i++) {     CUDACHECK(cudaSetDevice(localRank*nDev + i));     NCCLCHECK(ncclCommInitRank(comms+i, nRanks*nDev, id, myRank*nDev + i)); // world size = nRanks*nDev  }  NCCLCHECK(ncclGroupEnd());  //calling NCCL communication API. Group API is required when using  //multiple devices per thread/process  NCCLCHECK(ncclGroupStart());  for (int i=0; i<nDev; i++)     NCCLCHECK(ncclAllReduce((const void*)sendbuff[i], (void*)recvbuff[i], size, ncclFloat, ncclSum,           comms[i], s[i]));  NCCLCHECK(ncclGroupEnd());  //synchronizing on CUDA stream to complete NCCL communication  for (int i=0; i<nDev; i++)      CUDACHECK(cudaStreamSynchronize(s[i]));  //freeing device memory  for (int i=0; i<nDev; i++) {     CUDACHECK(cudaFree(sendbuff[i]));     CUDACHECK(cudaFree(recvbuff[i]));  }  //finalizing NCCL  for (int i=0; i<nDev; i++) {     ncclCommDestroy(comms[i]);  }  //finalizing MPI  MPICHECK(MPI_Finalize());  printf("[MPI Rank %d] Success \n", myRank);  return 0;}

Example 4

Devices participating in different communication groups (Multiple communicators per device)

This example describes how a device is called by different communication groups. In some scenarios, it is necessary for some/all GPU devices within a communication group to participate in cluster communication. As shown below, this is a node machine with 4 cards, creating two communication groups. Communication group 0 contains two devices, while communication group 1 contains four devices. Of course, the rank index is re-labeled within each communication group. Communication group 0 (rank0, rank1), communication group 1 (rank0, rank1, rank2, rank3).

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

3. Compilation and Execution

Part.3

Example 1: Single thread operating multiple devices, code:nccl/multi_devices_per_thread.cu

Example 2: Single thread managing a single device, code: nccl/one_device_per_thread.cu

Example 3: Using MPI to create threads managing devices, code:nccl/nccl_with_mpi.cu

The official examples do not provide compilation examples; compilation requires installing some dependencies:

CUDA NVIDIA NCCL (optimized for NVLink) Open-MPI (optional)

Here, I used NVIDIA’s PyTorch image (PyTorch is not mandatory)ngc-pytorch. It contains the required dependencies; pull the image:

docker pull nvcr.io/nvidia/pytorch:24.07-py3

Run the container:

sudo docker run  --net=host --gpus=all -it -e UID=root --ipc host --shm-size="32g" 
-v /home/xky/:/home/xky 
-u 0 
--name=nccl2 nvcr.io/nvidia/pytorch:24.07-py3 bash

Example compilation command:

nvcc  -lnccl -ccbin g++ -std=c++11 -O3 -g multi_devices_per_thread.cu -o multi_devices_per_thread

Using a makefile can compile multiple files simultaneously, with different GPU architecture flags. Here is an example:nccl/Makefile

You can complete the compilation with the following command:

make # To support MPI, use the mpi parameter for separate compilation. make mpi

Execution:

./multi_devices_per_thread ./one_devices_per_thread

If using MPI execution command:

mpirun -n 6 --allow-run-as-root ./nccl_with_mpi

References:

This article’s code:

https://github.com/CalvinXKY/BasicCUDA/tree/master/nccl

NCCL examples: https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/examples.html

NCCL test: https://github.com/NVIDIA/nccl-tests

Scan the code to follow us and learn more about CPU fundamentals

NCCL Communication C++ Example (Part 1): Basic Use Case Interpretation and Execution

Leave a Comment