问题

DeepEP使用NVSHMEM在GPU之间传输数据,使用智谱的平湖集群测试DeepEP的过程中,我们发现,DeepEP的性能不符合预期(DeepEP测试对比分析-H100),大概只有单口RNIC的一半。观察交换机的流量,每张RNIC只使用了一个端口。

原因

NVSHMEM使用的缺省transportation机制IBRC不支持PE选择多个端口,建立QP连接,所以,在双端口RNIC的场景中,只使用了一个端口。

方案

有两个可选的方案解决这个问题:

使用NVSHMEM的IBGDA替代缺省的IBRC机制,但是,在双端口RNIC环境中,IBGDA初始化失败,原因是mlx5dv_devx_umem_reg注册GPU内存时,不成功。短时间内,这个问题,不一定有解。即使这个问题解决了,后续还存在很大的不确定性。

所以,我们只能选择第二种方案,即在IBRC中支持双端口RNIC,本质上,PE可以选择两个端口建立QP相互通信。

选网卡-已确认信息

按照距离最近的原则,每个PE/GPU选取最近的RNIC的两个端口,代码:

host/transport/transport.cc:nvshmemi_setup_connections(…)

module/topo/topo.cc:nvshmemi_get_devices_by_distance(…)

对于PIX的情况,PE可以正确地选到一个RNIC上的两个端口。

对于PXB的情况,PE可以正确地选到一个RNIC上的四个端口。

QP建链-已完成

module/transport/ibrc/ibrc.cc:nvshmemt_ibrc_connect_endpoints(…)中

将transport_ibrc_state_t中的ep(qp)数量double

根据实际的num_selected_devs数量,修改ep_handles与local_ep_handles的大小

在当前bootstap连接下,交换双端口ep的信息,并建立RDMA链接。

目前进展:成功在多端口上创建qp,并利用一次bootstrap通信交换双端口ep信息,已经验证

内存注册调整为多端口-已完成

为了适配一卡两口环境,对应的内存也需要重新进行注册,原先注册的内存在handles_结构中,具体注册的位置为ibrc.cpp:nvshmemt_ibrc_get_mem_handle(…),函数调用链为mem_transport.cpp:register_mem_handle(…) -> mem_heap.cpp:register_heap_chunk(…),我们期望将handles_从二维转成三维,进而能够储存多端口的内存注册信息。

class nvshmemi_symmetric_heap {
    //... ...
  - std::vector<std::vector<nvshmem_mem_handle>> handles_;
  + std::vector<std::vector<nvshmem_mem_handle>> handles_[2];
 

与handles_直接相关需要修改的函数接口为(部分需要修改接口参数,涉及上层调用):

mem_heap.cpp

  • void nvshmemi_symmetric_heap::update_idx_in_handle(...)

    未修改接口,仅修改函数体

  • nvshmemi_symmetric_heap::~nvshmemi_symmetric_heap(...)

    未修改接口,仅修改函数体

  • int nvshmemi_symmetric_heap_vidmem_dynamic_vmm::cleanup_symmetric_heap(...)

    未修改接口,仅修改函数体

  • int nvshmemi_symmetric_heap_static::cleanup_symmetric_heap(...)

    未修改接口,仅修改函数体

  • int nvshmemi_symmetric_heap_vidmem_static_pinned::map_heap_chunk(...)

    未修改接口,仅修改函数体

  • int nvshmemi_symmetric_heap_vidmem_dynamic_vmm::map_heap_chunk(...)

    未修改接口,仅修改函数体

  • int nvshmemi_symmetric_heap_vidmem_dynamic_vmm::exchange_heap_memory_handle(...)

    对P2P连接的pe对,调用该函数,在进程间交换handles信息,我们将接口修改为同时传入多端口的handles信息(目前两个),以期在不增加socket通信负载下,同时进行多端口的信息交换。

    接口修改后如下:

    int exchange_heap_memory_handle(nvshmem_mem_handle_t *local_handles, nvshmem_mem_handle_t *local_handles_2);

    在int nvshmemi_symmetric_heap_dynamic::register_heap_chunk(…)中,同时发送两个端口的handles信息给其余pe

    NVSHMEMI_IPC_CHECK(
                    ipcSendFd(myIpcHandle, *(int *)local_handles, pid, receiving_process));
    NVSHMEMI_IPC_CHECK(
                    ipcSendFd(myIpcHandle, *(int *)local_handles_2, pid, receiving_process));
     
    // ... ...
     
    NVSHMEMI_IPC_CHECK(ipcRecvFd(
                    recvIpcHandles[sending_process],
                    (int *)&handles_[0].back()[it1->second * state->num_initialized_transports]));
    NVSHMEMI_IPC_CHECK(ipcRecvFd(
                    recvIpcHandles[sending_process],
                    (int *)&handles_[1].back()[it1->second * state->num_initialized_transports]));
     
  • int nvshmemi_symmetric_heap_sysmem_static_shm::register_heap_memory_handle(...)

    未注册两口内存,因而增加函数参数,修改后的函数接口如下:

    int nvshmemi_symmetric_heap_sysmem_static_shm::register_heap_memory_handle(…, int index)

    在函数中,利用index调用register_mem_handle(…)选择具体的端口进行内存注册

  • int nvshmemi_symmetric_heap_vidmem_static_pinned::register_heap_memory_handle(...)

    同上

  • int nvshmemi_symmetric_heap_static::register_heap_chunk(...)

    同下,唯一区别在于,相比nvshmemi_symmetric_heap_dynamic,该类函数中,存在cache的概念,因而当访问cache存在时,会直接从cache中读取handle而不需要再次注册

    if (NVSHMEMI_TRANSPORT_IS_CAP(current, state->mype, NVSHMEM_TRANSPORT_CAP_MAP)) {
        map_handles = handles_[0].front().data();
        local_handles[0][i] = map_handles[i];
      + map_handles = handles_[1].front().data();
      + local_handles[1][i] = map_handles[i];
    }
     
  • int nvshmemi_symmetric_heap_dynamic::register_heap_chunk(...)

    1)为注册两口内存,需要调用两次remotetran.register_mem_handle(…),对相同的buff用不同的端口进行内存注册,因此,需要进一步修改函数接口

    • int nvshmemi_mem_remote_transport::register_mem_handle(…, int index)
      • nvshmemt_ibdevx_get_mem_handle(…, int index)
      • nvshmemt_ibgda_get_mem_handle(…, int index)
      • nvshmemt_libfabric_get_mem_handle(…, int index)
      • nvshmemt_ucx_get_mem_handle(…, int index)
    • buffer_register(…):未被调用,未来修改

    :调用register_mem_handle(…)函数位置有多处,但由于我们实际执行时,并未涉及所有函数,因此只在register_heap_chunk(…)中调用时区分index,其余时候均设置Index = 0

    2)在register_heap_chunk(…)中,将local_handles数据结构从一维变为二维以存放多端口内存句柄,随后注册内存,并利用两次all_gather进行交换信息(为什么不一次?如果强行改为一次通信,则存放handles的数据结构会变得不连续,接口改动过于大

    // Export memory for both ports
    status =
        export_memory((nvshmem_mem_handle_t *)(local_handles[0] + idx), mem_handle_in);
    NVSHMEMI_NZ_ERROR_JMP(status, NVSHMEMX_ERROR_INTERNAL, out,
                          "export_memory failed for p2p on heap dynamic \\n");
    status =
        export_memory((nvshmem_mem_handle_t *)(local_handles[1] + idx), mem_handle_in);
    NVSHMEMI_NZ_ERROR_JMP(status, NVSHMEMX_ERROR_INTERNAL, out,
                          "export_memory failed for p2p on heap dynamic \\n");
     
    // ... ...
     
    // register memory on both ports
    status = remotetran.register_mem_handle(&local_handles[0][0], idx, mem_handle_in, buf,
                                            size, current, 0);
    status = remotetran.register_mem_handle(&local_handles[1][0], idx, mem_handle_in, buf,
                                            size, current, 1);
     
    // ... ...
    // Allgather memory handle for remote connected PEs
    handles_[0].push_back(
        vector<nvshmem_mem_handle_t>(state->num_initialized_transports * state->npes));
    handles_[1].push_back(
        vector<nvshmem_mem_handle_t>(state->num_initialized_transports * state->npes));
     
    status = nvshmemi_boot_handle.allgather(
        (void *)local_handles[0], (void *)(handles_[0].back().data()),
        sizeof(nvshmem_mem_handle_t) * state->num_initialized_transports, &nvshmemi_boot_handle);
    NVSHMEMI_NZ_ERROR_JMP(status, NVSHMEMX_ERROR_INTERNAL, out,
                          "allgather of mem handles failed \\n");
    status = nvshmemi_boot_handle.allgather(
        (void *)local_handles[1], (void *)(handles_[1].back().data()),
        sizeof(nvshmem_mem_handle_t) * state->num_initialized_transports, &nvshmemi_boot_handle);
     
    status = remotetran.gather_mem_handles(*(dynamic_cast<nvshmemi_symmetric_heap *>(this)),
                                           physical_heap_size_, size);
    NVSHMEMI_NZ_ERROR_JMP(status, NVSHMEMX_ERROR_INTERNAL, out,
                          "allgather of mem handles failed for remotetransport\\n");
     
    // Exchange send/recv memory handles for p2p connected PEs
    exchange_heap_memory_handle(&local_handles[0][0], &local_handles[1][0]);
    heap_mspace_->add_new_chunk((char *)heap_base_ + physical_heap_size_, size);
     

:gather_mem_handles(…)只与ibgda相关,因此暂未做修改

mem_transport.cpp

  • void nvshmemi_mem_p2p_transport::print_mem_handle(...)

    仅作打印,暂未修改

  • int nvshmemi_mem_remote_transport::gather_mem_handles(...)

    只与ibgda相关,暂未修改

nvshmemi_symmetric_heap.hpp

  • inline nvshmem_mem_handle *nvshmemi_symmetric_heap::get_transport_mem_handle(...)

    修改函数接口为:

    inline nvshmem_mem_handle *nvshmemi_symmetric_heap::get_transport_mem_handle(…,

    int index)

    并同步修改上层调用接口nvshmemi_get_remote_mem_handle(…)与nvshmemi_get_local_mem_handle(…)如下

    • static inline void nvshmemi_get_local_mem_handle(…, int index)
    • static inline void nvshmemi_get_remote_mem_handle(…, int index)

收发 - 已完成

数据收发时,流量平均分布在两个端口的QP上。

在ibrc中,收发主要依赖int nvshmemt_ibrc_rma(…)与int nvshmemt_ibrc_amo(…)两个函数实现,rma用来处理PUT和GET操作,实现RDMA WRITE和READ,amo则主要实现infiniband的原子操作,在其中,传入函数的参数有在上一节内存注册中提到的handle,为了能够同时利用多端口进行收发,我们期望同时传入两个端口的handle信息,因此,我们进一步修改了相关数据结构amo_memdesc_t与rma_memdesc_t,修改后的数据结构如下:

typedef struct rma_memdesc {
    void *ptr;
    uint64_t offset;
    nvshmem_mem_handle_t *handle[2];
} rma_memdesc_t;
 
typedef struct amo_memdesc {
    rma_memdesc_t remote_memdesc;
    uint64_t retflag;
    void *retptr;
    void *valptr;
    void *cmpptr;
    uint64_t val;
    uint64_t cmp;
    nvshmem_mem_handle_t *ret_handle[2];
} amo_memdesc_t;
 

随后我们在所有调用nvshmemi_get_local_mem_handle(…)与nvshmemi_get_remote_mem_handle(…)获取handle的位置,改为获取双端口的handle信息,将他们存放入同一个修改后的结构体中,在ibrc的int nvshmemt_ibrc_rma(…)与int nvshmemt_ibrc_amo(…)里,可以选择该块数据具体利用哪个端口进行发送,实现流量的平均分布。

    size_t remain_bytes = bytesdesc.nelems * bytesdesc.elembytes;
    size_t send_bytes = 0;
 
    for (int index = 0; index < ibrc_state->ndevs; index++) {
        if (is_proxy) {
            ep = ibrc_state->ep[index][(ibrc_state->ep_count * pe + ibrc_state->proxy_ep_idx)];
            ep2 = ibrc_state->ep[index ^ 1][(ibrc_state->ep_count * pe + ibrc_state->proxy_ep_idx)];
        }
        else {
            ep = ibrc_state->ep[index][(ibrc_state->ep_count * pe)];
            ep2 = ibrc_state->ep[index ^ 1][(ibrc_state->ep_count * pe)];
        }
 
        status = check_poll_avail(ep, WAIT_ANY);
        NVSHMEMI_NZ_ERROR_JMP(status, NVSHMEMX_ERROR_INTERNAL, out, "check_poll failed \\n");
 
        op_id = ep->head_op_id & IBRC_REQUEST_QUEUE_MASK; // ep->head_op_id % ibrc_qp_depth
 
        sr = &(ep->req + op_id)->sr;
        bad_sr = &(ep->req + op_id)->bad_sr;
        sge = &(ep->req + op_id)->sge;
 
        memset(sr, 0, sizeof(ibv_send_wr));
 
        sr->next = NULL;
        sr->send_flags = IBV_SEND_SIGNALED;
        sr->wr_id = NVSHMEMI_OP_PUT;
        sr->num_sge = 1;
        sr->sg_list = sge;
 
        sr->wr.rdma.remote_addr = (uint64_t)remote->ptr + send_bytes;
        assert(remote->handle[index]);
        sr->wr.rdma.rkey = ((struct nvshmemt_ib_common_mem_handle *)remote->handle[index])->rkey;
        if (index != ibrc_state->ndevs - 1)
            sge->length = (bytesdesc.nelems * bytesdesc.elembytes) / ibrc_state->ndevs;
        else
            sge->length = remain_bytes;
        sge->addr = (uintptr_t)local->ptr + send_bytes;
        remain_bytes -= sge->length;
        send_bytes += sge->length;
 

目前已经测试了,分别单独利用两个端口收发时的能力,剩余工作为,同时利用两个端口进行收发。

目前存在问题,观察到,当rma利用端口1发送时,amo必须也利用端口1发送,否则会出现hang的情况,反之亦然,目前在尝试定位两个操作之间的依赖关系。

hang的问题目前已解决,可能的原因为,当原先在单端口发送时,rma和amo下发的wr存在单个qp中,执行顺序保序,而当wr分布到两个端口上后,可能存在,先于AMO的RMA操作反而晚于AMO操作执行的情况,因此,在AMO中,增加wait逻辑,等待另一侧端口上已经下发的wr全部完成后,才下发该amo操作wr,此时,hang解决(暂时不理解为什么amo操作需要保证,在其之前的rma需要先于他完成,看deepEP代码没看出关联性)

status = check_poll_avail(ep, WAIT_ANY);
NVSHMEMI_NZ_ERROR_JMP(status, NVSHMEMX_ERROR_INTERNAL, out, "check_poll failed \\n");
if (ibrc_state->ndevs >= 2) status = check_poll_avail(ep2, WAIT_ALL);
NVSHMEMI_NZ_ERROR_JMP(status, NVSHMEMX_ERROR_INTERNAL, out, "check_poll failed \\n");
 

PXB支持16个端口收发 - 已完成

为了使得PXB也能够同时利用所有端口进行收发,我们在选择网卡时,对于奇数号的GPU,我们将其的前四个选择的select_dev_id转置,因此,他们会选择共享的四张网卡的后两张,而偶数GPU则继续使用原先的两个端口,此时,流量会均匀的分布在四个端口上。

if ((gpu_device_id % 2 == 1) && (max_dev_per_pe >= 16)) {
        int left = 0;
        int right = 3;
        while (left < right) {
            int temp = device_arr[left];
            device_arr[left] = device_arr[right];
            device_arr[right] = temp;
            left++;
            right--;
        }
    }
 

实现

nic select debug打印:

PXB的情况:

/workspace/infra/nvshmem_3.1.7/src/modules/transport/ibrc/ibrc.cpp 1457 Select RNIC 13: n_pes: 2, my_pe:1, index:1
/workspace/infra/nvshmem_3.1.7/src/modules/transport/ibrc/ibrc.cpp 1457 Select RNIC 14: n_pes: 2, my_pe:1, index:1
/workspace/infra/nvshmem_3.1.7/src/modules/transport/ibrc/ibrc.cpp 1457 Select RNIC 15: n_pes: 2, my_pe:1, index:1
/workspace/infra/nvshmem_3.1.7/src/modules/transport/ibrc/ibrc.cpp 1457 Select RNIC 16: n_pes: 2, my_pe:1, index:1
 

PIX的情况:

/workspace/infra/nvshmem_3.1.7/src/modules/transport/ibrc/ibrc.cpp 1457 Select RNIC 14: n_pes: 2, my_pe:0, index:1
/workspace/infra/nvshmem_3.1.7/src/modules/transport/ibrc/ibrc.cpp 1457 Select RNIC 15: n_pes: 2, my_pe:0, index:1
 

功能测试

两机PIX

原生:

RDMA: 44.01 NVL: 143.95

双端口收发:

RDMA: 51.33 NVL: 167.92

两机PXB

原生:

RDMA: 23.31 NVL: 76.25

双端口收发:

RDMA:52.25 NVL:170.91

对比测试

两机 node501(PXB)node510(PXB)

[tuning] Best dispatch (FP8): SMs 24, NVL chunk 24, RDMA chunk 32: 39.28 GB/s (RDMA), 128.21 GB/s (NVL)

[tuning] Best dispatch (BF16): SMs 24, NVL chunk 20, RDMA chunk 32: 52.26 GB/s (RDMA), 170.59 GB/s (NVL)

[tuning] Best combine: SMs 24, NVL chunk 3, RDMA chunk 32: 44.13 GB/s (RDMA), 144.05 GB/s (NVL)

两机 node506(PIX) node507(PIX)

[tuning] Best dispatch (FP8): SMs 24, NVL chunk 32, RDMA chunk 32: 34.16 GB/s (RDMA), 111.76 GB/s (NVL)

[tuning] Best dispatch (BF16): SMs 24, NVL chunk 12, RDMA chunk 32: 47.97 GB/s (RDMA), 156.94 GB/s (NVL)

[tuning] Best combine: SMs 24, NVL chunk 2, RDMA chunk 32: 45.48 GB/s (RDMA), 148.77 GB/s (NVL)

两机 node501(PXB) node507(PIX)

[tuning] Best dispatch (FP8): SMs 24, NVL chunk 20, RDMA chunk 32: 35.48 GB/s (RDMA), 115.81 GB/s (NVL)

[tuning] Best dispatch (BF16): SMs 24, NVL chunk 24, RDMA chunk 32: 49.27 GB/s (RDMA), 160.81 GB/s (NVL)

[tuning] Best combine: SMs 24, NVL chunk 2, RDMA chunk 32: 43.67 GB/s (RDMA), 142.54 GB/s (NVL)

两机 node501(PXB) node506(PIX)

[tuning] Best dispatch (FP8): SMs 24, NVL chunk 20, RDMA chunk 32: 35.25 GB/s (RDMA), 115.07 GB/s (NVL)

[tuning] Best dispatch (BF16): SMs 24, NVL chunk 20, RDMA chunk 32: 49.10 GB/s (RDMA), 160.25 GB/s (NVL)

[tuning] Best combine: SMs 24, NVL chunk 3, RDMA chunk 32: 43.63 GB/s (RDMA), 142.40 GB/s (NVL)

四机

[tuning] Best dispatch (FP8): SMs 24, NVL chunk 16, RDMA chunk 20: 21.01 GB/s (RDMA), 42.19 GB/s (NVL)

[tuning] Best dispatch (BF16): SMs 24, NVL chunk 12, RDMA chunk 12: 20.47 GB/s (RDMA), 41.11 GB/s (NVL)

[tuning] Best combine: SMs 24, NVL chunk 2, RDMA chunk 12: 20.02 GB/s (RDMA), 40.20 GB/s (NVL)

参考资料

启用双端口发送后交换机流量均匀分布端口