MPI 分布式并行计算通讯库技术

MPI分布式并行通信技术解析

目录

MPI 的诞生

20 世纪 90 年代,由于传统的 CPU 单机计算已经无法满足大规模科学模拟、大数据处理等需求,所以以 CPU 超算系统为代表的 SPMD(Single Program Multiple Data,单程序-多数据)分布式并行计算模式的得到了飞快的发展。这些超算系统部署了海量的 CPU 服务器和 CPU 计算单元,并通过跨节点的多进程通信技术来实现控制和协同。

最初,这些多进程通信技术是由超算解决方案厂商们(如 IBM、Cray)各自提供的私有方案。但私有方案显然存在着代码兼容性差、移植成本高等问题。于是在 1994 年提出了 MPI(Message Passing Interface,消息传递接口)标准协议,旨在统一分布式计算场景中的跨节点的多进程通信标准。

可见,自诞生起 MPI 就是面向 CPU 而设计的,是以 CPU 为中心的超算系统中最常用的通信标准之一。

OpenMPI 的诞生

MPI 是一个面向 CPU 的分布式并行计算场景中的跨节点的多进程间通信协议,基于 Linux Socket 实现,包括通信协议、通信范式和通信机制等方面。

OpenMPI 软件库就是 MPI 协议的具体实现,是一个开源的并行计算框架,用于编写 CPU 并行程序,提供了多种跨节点多进程间通信的方式和函数库。例如:点对点(Point-to-Point)通信和集合通信(Collective Communication)。

  • 点对点通信:支持阻塞式点对点和非阻塞式点对点等。
  • 集合通信:支持 all-reduce、all-to-all、all-gather 等。

为什么说我们在正式了解 NCCL 之前需要先了解 MPI?这是因为 NCCL 中的很多概念继承自 MPI,并且至今也经常使用 OpenMPI 来启动一个 NCCL 应用程序的运行。

基本概念

进程

MPI 进程(Process):在 MPI 的语境中,进程特指独立使用 MPI 进行通信的一个个体。一个 MPI 并行计算应用程序则由一组运行在相同或不同计算节点上的进程组成。在 Linux OS 上,一个进程就是一个 Unix 进程,它使用 MPI 进行通信。同一个 MPI 进程只能运行在一个节点上,一个节点上可以运行若干个 MPI 进程,并且这些进程运行着相同的代码逻辑和处理不同的数据(Single Program Multiple Data)。

进程组

MPI 进程组(Process Group):如果说 MPI 进程是一个计算概念,那么 MPI 进程组就是一个通信概念,表示若干个能够进行跨节点间 MPI 通信的一组 MPI 进程。通常的,一个 MPI 并行程序只定义一个组,但也可以划分为多个不同的组来进行各自的通信。

通信器

MPI 通信器(Communicator):用于描述 MPI 进程之间的通信范围,也称之为通信域,同时还记录了 MPI 进程组内或组外的 MPI 进程之间的通信拓扑和通信信息。通信器是 MPI 最重要的概念之一,其决定了 MPI 进程之间的通信范围和通信方式。

MPI 通信器可以细分为 2 种类型:

  1. 组内通信器(intra-communicator):用于同 1 个 MPI 组内的进程之间进行通信。
  2. 组间通信器 (inter-communicator):用于 2 个 MPI 组之间的进程之间进行通信。

另外,一个 MPI 并行程序运行时会自动地创建 2 个特别的通信器。

  1. 全局通信器(MPI_COMM_WORLD):其包含了该 MPI 程序中的所有进程。
  2. 个体通信器(MPI_COMM_SELF):其只包含了进程本身。

在程序中,我们可以使用这两个 build-in 的通信器变量来获得 mpirun 启动的 MPI 并发程序所配置的 MPI 进程数量,这些数量只有在 mpirun 启动后才会确定下来。

消息

MPI 消息(Message):MPI 进程间通信时候传递的数据单元称为消息,一个消息由通信器、源地址、目的地址、消息标签和实际数据构成。

Rank(序号)

Rank(序号):一个 MPI 进程可以被包含在不同的 MPI 进程组中,与多个不同的通信器进行关联。所以,每个 MPI 进程在不同的 MPI 进程组中都会有一个唯一标号,称为 Rank ID,用于确定其身份。在一个 MPI 并行程序中,使用 “MPI 进程组 + Rank ID” 或 “通信器 + Rank ID” 来确定一个 MPI 进程。

点对点通信方式

  • MPI_Send:发送消息。
int MPI_Send(void* buf, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm)
// buf :消息内容的起始指针。
// count :消息中数据的数量。
// datatype :数据类型,MPI中预定义了多种数据类型。
// dest :消息的目的进程ID。
// tag :消息标签,用于区分同一对进程之间的不同消息。
// comm :通信域,即通信的范围,通常是一个MPI进程组。
  • MPI_Recv:接收消息。
int MPI_Recv(void* buf, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status* status)
// buf :用于存放接收到的消息的起始指针。
// count :消息缓冲区的最大长度。
// datatype :预定义的数据类型,与发送时一致。
// source :发送消息的源进程ID。
// tag :消息标签,用于过滤消息。
// comm :通信域。
// status :输出参数,包含有关接收到的消息的信息。
  • MPI_Isend:非阻塞发送。
  • MPI_Irecv:非阻塞接收。
  • MPI_Ssend:同步发送(接收端准备好时才发送)。
  • MPI_Bsend:缓冲发送(用户提供缓冲区)。
  • MPI_Rsend:就绪发送(接收端必须先调用 MPI_Recv)。
  • MPI_Sendrecv:同时发送和接收。
  • MPI_Sendrecv_replace:发送和接收使用同一缓冲区。

集合通信方式

MPI_Bcast:广播

MPI_Bcast:广播,1 发 N。将一个进程的数据发送到所有其他进程中。

/*
 * MPI_Bcast:广播操作,一对多通信
 * 将一个进程(根进程)的数据发送到通信域中的所有其他进程中
 * 所有进程调用此函数后,recvbuf中将包含根进程发送的数据
 */
int MPI_Bcast(
    void* buffer,           // [in/out] 缓冲区指针。在根进程中为发送缓冲区,在其他进程中为接收缓冲区
    int count,             // [in] 缓冲区中元素的个数
    MPI_Datatype datatype,  // [in] 缓冲区中元素的数据类型
    int root,              // [in] 根进程的rank,即数据来源进程
    MPI_Comm comm          // [in] 通信域,指定参与通信的进程范围
);

MPI_Scatter:分散

MPI_Scatter:分散,1 发 N(每人得到总量的其中一份)。将一个进程的数据分散到多个进程中。

/*
 * MPI_Scatter:分散操作,一对多通信
 * 将根进程的发送缓冲区中的数据缓冲区中的数据均匀分散到所有进程(包括根进程自己)
 */
int MPI_Scatter(
    void* sendbuf,         // [in] 发送缓冲区指针(仅在根进程中有效)
    int sendcount,          // [in] 发送到每个进程的元素个数(注意:不是总数)
    MPI_Datatype sendtype,  // [in] 发送数据类型
    void* recvbuf,         // [out] 接收缓冲区指针
    int recvcount,          // [in] 每个进程接收的元素个数
    MPI_Datatype recvtype,  // [in] 接收数据类型
    int root,              // [in] 根进程的rank
    MPI_Comm comm          // [in] 通信域
);

MPI_Gather:收集

MPI_Gather:收集,N 发 1。将多个进程的数据收集到一个进程中。

/*
 * MPI_Gather:收集操作,多对一通信
 * 将所有进程(包括根进程)的发送缓冲区中的数据收集到根进程的接收缓冲区中
 */
int MPI_Gather(
    void* sendbuf,         // [in] 发送缓冲区指针
    int sendcount,          // [in] 每个进程发送的元素个数
    MPI_Datatype sendtype,  // [in] 发送数据类型
    void* recvbuf,         // [out] 接收缓冲区指针(仅在根进程中有效)
    int recvcount,          // [in] 从每个进程接收的元素个数
    MPI_Datatype recvtype,  // [in] 接收数据类型
    int root,              // [in] 根进程的rank
    MPI_Comm comm          // [in] 通信域
);

MPI_Allgather:全收集

MPI_Allgather:全收集,N 发 N。

/*
 * MPI_Allgather:全收集操作,多对多通信
 * 相当于在每个进程上都执行了一次Gather操作
 */
int MPI_Allgather(
    const void *sendbuf,   // [in] 发送缓冲区指针
    int sendcount,         // [in] 每个进程发送的元素个数
    MPI_Datatype sendtype, // [in] 发送数据类型
    void *recvbuf,         // [out] 接收缓冲区指针
    int recvcount,         // [in] 从每个进程接收的元素个数
    MPI_Datatype recvtype, // [in] 接收数据类型
    MPI_Comm comm          // [in] 通信域
);

MPI_Alltoall:全互换

MPI_Alltoall:全互换,N 对 N(每人给每人)。

/*
 * MPI_Alltoall:全互换操作,多对多通信
 * 每个进程都向所有其他进程发送数据(Scatter),同时也从所有其他进程接收数据(Gather)。
 */
int MPI_Alltoall(
    void* sendbuf,         // [in] 发送缓冲区指针
    int sendcount,          // [in] 发送到每个进程的元素个数
    MPI_Datatype sendtype,  // [in] 发送数据类型
    void* recvbuf,         // [out] 接收缓冲区指针
    int recvcount,          // [in] 从每个进程接收的元素个数
    MPI_Datatype recvtype,  // [in] 接收数据类型
    MPI_Comm comm          // [in] 通信域
);

MPI_Reduce:归约

MPI_Reduce:归约,N 发 1。对所有进程中的数据进行归约操作(例如累加和,或先累加和再求平均),并将结果发送到一个进程中。

/*
 * MPI_Reduce:归约操作,多对一通信
 * 对所有进程的发送缓冲区中的数据执行指定的归约操作
 * 并将结果存储在根进程的接收缓冲区中
 */
int MPI_Reduce(
    void* sendbuf,         // [in] 发送缓冲区指针
    void* recvbuf,         // [out] 接收缓冲区指针(仅在根进程中有效)
    int count,             // [in] 缓冲区中元素的个数
    MPI_Datatype datatype,  // [in] 缓冲区中元素的数据类型
    MPI_Op op,             // [in] 归约操作类型(如MPI_SUM, MPI_MAX等)
    int root,              // [in] 根进程的rank
    MPI_Comm comm          // [in] 通信域
);

MPI_Allreduce:全归约

MPI_Allreduce:全归约,最终结果会再广播给所有人。

/*
 * MPI_Allreduce:全归约操作,多对多通信
 * 对所有进程的数据执行指定的归约操作
 * 然后将结果分发到所有进程的接收缓冲区中
 */
int MPI_Allreduce(
    void* sendbuf,         // [in] 发送缓冲区指针
    void* recvbuf,         // [out] 接收缓冲区指针
    int count,             // [in] 缓冲区中元素的个数
    MPI_Datatype datatype,  // [in] 缓冲区中元素的数据类型
    MPI_Op op,             // [in] 归约操作类型
    MPI_Comm comm          // [in] 通信域
);

MPI_Reduce_scatter:归约后再分散

MPI_Reduce_scatter:归约后再分散。

/*
 * MPI_Reduce_scatter:归约后分散操作
 * 先对来自所有进程的数据执行归约操作
 * 然后将结果按照scounts数组分散到各个进程中
 */
int MPI_Reduce_scatter(
    void* sendbuf,         // [in] 发送缓冲区指针
    void* recvbuf,         // [out] 接收缓冲区指针
    int* scounts,          // [in] 分散计数的数组
    MPI_Datatype datatype,  // [in] 缓冲区中元素的数据类型
    MPI_Op op,             // [in] 归约操作类型
    MPI_Comm comm          // [in] 通信域
);

MPI_Scan:前缀归约

MPI_Scan:前缀归约(串行式)。

/*
 * MPI_Scan:前缀归约操作
 * 对进程0到当前进程的所有进程的数据执行指定的归约操作
 * 每个进程都会得到一个累积的结果
 */
int MPI_Scan(
    void* sendbuf,         // [in] 发送缓冲区指针
    void* recvbuf,         // [out] 接收缓冲区指针
    int count,             // [in] 缓冲区中元素的个数
    MPI_Datatype datatype,  // [in] 缓冲区中元素的数据类型
    MPI_Op op,             // [in] 归约操作类型
    MPI_Comm comm          // [in] 通信域
);

MPI_Exscan:扫描

MPI_Exscan:扫描(不含自身)。

/*
 * MPI_Exscan:排除自身的扫描操作
 * 类似于MPI_Scan,但不包含当前进程自己的数据
 * 即从进程0到前一个进程的前缀归约值
 */
int MPI_Exscan(
    void* sendbuf,         // [in] 发送缓冲区指针
    void* recvbuf,         // [out] 接收缓冲区指针
    int count,             // [in] 缓冲区中元素的个数
    MPI_Datatype datatype,  // [in] 缓冲区中元素的数据类型
    MPI_Op op,             // [in] 归约操作类型
    MPI_Comm comm          // [in] 通信域
);

同步通信

Barrier 栏杆

Barrier(分界线;关卡),在并行计算中,需要在最后将所有并行计算的子结果进行汇总,而快的进程就会在 Barrier 分界线上等待慢的进程完成计算,直到所有进程都完成了计算之后在进行下一步操作,所以也称之为同步等待。

  • MPI_Barrier:所有进程同步等待。

mpirun 指令

在编写好一个 MPI 并行程序之后,我们需要使用 mpirun 来启动这个程序,而并非是直接运行程序的二进制文件。因为 MPI 程序只有在使用 mpirun 启动时,才能够知道具体在哪一些节点上并行运行。

mpirun 指令的关键参数选项:

  • –allow-run-as-root:在 root 环境执行 MPI 程序。
  • –hostfile 和 --host:指定计算节点集群,使用 file 或 list 格式。
  • –map-by:进程映射到硬件上的方式,控制 MPI 进程如何在硬件上分布,支持 hwthread、core、slot、numa、socket (default)、board、node 等由小到大的粒度。
  • –np:启动的 MPI 进程总数。假设 hostfile 配置 4 个主机,配置 --map-by node,则:
    • –np 2:host1 和 host2 每个主机一个进程。
    • –np 4:host1 ~ host4 每个主机一个进程。
    • –np 8:host1 ~ host4 每个主机二个进程。

编程示例

#include "mpi.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/**
 * 这个程序演示了MPI中的基本概念:
 * - MPI进程 (MPI Process)
 * - MPI进程组 (Process Group) 
 * - MPI通信器 (Communicator)
 * - MPI消息 (Message)
 * - Rank (序号)
 */

int main(int argc, char *argv[])
{
    /**
     * ========================================================
     * MPI进程 (MPI Process) 部分
     * ========================================================
     * MPI进程是指在分布式系统中参与计算的单个实体。
     * 每个MPI进程都有自己的内存空间和执行流程,
     * 它们通过MPI消息传递机制进行通信。
     */

    // 声明变量
    int total_processes;                    // 总进程数
    int global_rank;                        // 在全局通信域中的序号
    int group_color;                        // 分组颜色标识
    int local_rank_in_intra_comm;           // 在组内通信域中的序号
    int local_rank_in_parent_comm;          // 在父通信域中的序号
    
    // 通信器声明
    MPI_Comm global_world_comm;             // 全局通信域 (预定义的MPI_COMM_WORLD)
    MPI_Comm intra_comm_by_color;          // 按颜色划分的组内通信域
    MPI_Comm intercomm_between_groups;     // 组间通信域
    
    // 初始化MPI环境,创建所有的MPI进程
    MPI_Init(&argc, &argv);
    
    // 获取全局通信域
    global_world_comm = MPI_COMM_WORLD;
    
    // 获取当前进程的信息
    MPI_Comm_size(global_world_comm, &total_processes);
    MPI_Comm_rank(global_world_comm, &global_rank);
    
    printf("[进程 %d]: 我是MPI并行程序中的一个进程,总共有 %d 个进程\n", 
           global_rank, total_processes);

    /**
     * ========================================================
     * MPI进程组 (Process Group) 部分
     * ========================================================
     * 进程组是一组可以互相通信的MPI进程的集合。
     * 我们可以根据需要将进程划分为不同的组来进行通信。
     */

    // 将所有进程分成两组:偶数组和奇数组
    group_color = global_rank % 2;  // 0代表偶数组,1代表奇数组
    
    /**
     * ========================================================
     * MPI通信器 (Communicator) 创建部分
     * ========================================================
     * 通信器定义了通信的范围和上下文。
     * 我们将创建两种类型的通信器:组内通信器和组间通信器。
     */
    
    // 方法1: 使用MPI_Comm_split创建组内通信器
    MPI_Comm_split(global_world_comm, group_color, global_rank, &intra_comm_by_color);
    
    // 在新的组内通信域中获取当前进程的序号
    MPI_Comm_rank(intra_comm_by_color, &local_rank_in_intra_comm);
    
    printf("[进程 %d]: 我被分配到%s组(color=%d), 在该组内我的序号是 %d\n", 
           global_rank,
           (group_color == 0) ? "偶数" : "奇数",
           group_color,
           local_rank_in_intra_comm);

    /**
     * ========================================================
     * Rank (序号) 概念的演示
     * ========================================================
     * 同一个MPI进程在不同的通信域中可以有不同的序号。
     * 这就是为什么我们需要为什么我们需要记录在不同通信域中的序号。
     */
    
    // 演示同一个进程在不同通信域中有不同的序号
    if (total_processes >= 2) {
        printf("[进程 %d]: 在全局通信域中我的序号是 %d,在组内通信域中我的序号是 %d\n",
               global_rank, global_rank, local_rank_in_intra_comm);
    }

    /**
     * ========================================================
     * 组内通信演示
     * ========================================================
     * 在同一组内的进程可以进行集体通信操作。
     */
    
    // 准备要在组内广播的数据
    int broadcast_root_data = -1;
    int received_broadcast_data = -1;
    
    // 每个组内的0号进程作为广播根节点
    if (local_rank_in_intra_comm == 0) {
        broadcast_root_data = group_color * 100 + global_rank;
    }
    
    // 在组内进行广播操作
    // 这个过程只在组内通信域中进行,不会影响到其他组的进程
    MPI_Bcast(&broadcast_root_data, 1, MPI_INT, 0, intra_comm_by_color);
    
    printf("[进程 %d]: 我所在的%s组完成了广播,接收到的数据是 %d\n",
               global_rank,
               (group_color == 0) ? "偶数" : "奇数",
               broadcast_root_data);

    /**
     * ========================================================
     * 组间通信器创建和使用
     * ========================================================
     * 组间通信器允许不同组之间的进程进行通信。
     */
    
    // 创建偶数组和奇数组之间的组间通信器
    if (group_color == 0) {
        // 偶数组的视角:连接到奇数组
        MPI_Intercomm_create(
            intra_comm_by_color,                    // 本地组内通信域
            0,                                      // 本地组领导进程的序号
            global_world_comm,                      // 用于解析远程组的父通信域
            1,                                      // 远程组的颜色/标识
            123,                                    // 通信标签,两端必须匹配
            &intercomm_between_groups              // 输出的组间通信域
        );
    } else if (group_color == 1) {
        // 奇数组的视角:连接到偶数组
        MPI_Intercomm_create(
            intra_comm_by_color,                    // 本地组内通信域
            0,                                      // 本地组领导进程的序号
            global_world_comm,                      // 父通信域
            0,                                      // 远程组的颜色/标识
            123,                                    // 通信标签,必须与另一端匹配
            &intercomm_between_groups               // 输出的组间通信域
        );
    }

    /**
     * ========================================================
     * MPI消息 (Message) 传递演示
     * ========================================================
     * MPI消息是在进程间传递的数据单元,包含数据和元数据。
     */
    
    // 定义消息内容
    char message_buffer[100];
    int message_length;
    MPI_Status receive_status;
    
    // 偶数组的第一个进程向奇数组的第一个进程发送消息
    if (group_color == 0 && local_rank_in_intra_comm == 0) {
        // 构造消息内容
        sprintf(message_buffer, 
                "你好,奇数组! 这条消息来自偶数组的进程 %d", 
                global_rank);
        
        message_length = strlen(message_buffer) + 1;  // +1是为了包括字符串结束符
        
        printf("\n--- MPI消息传递开始 ---\n");
        printf("[进程 %d (偶数组)]: 正准备发送消息到奇数组\n", global_rank);
        
        // 发送MPI消息
        MPI_Send(
            message_buffer,                    // 消息数据缓冲区
            message_length,                   // 消息长度
            MPI_CHAR,                         // 数据类型
            0,                                // 目标进程在远程组中的序号
                99,                                // 消息标签,用于识别不同类型的消息
            intercomm_between_groups          // 使用的通信域(这里是组间通信域)
        );
        
        printf("[进程 %d (偶数组)]: 消息已发送\n", global_rank);
    }
    
    // 奇数组的第一个进程接收来自偶数组的消息
    if (group_color == 1 && local_rank_in_intra_comm == 0) {
        // 接收MPI消息
        MPI_Recv(
            message_buffer,                    // 接收缓冲区
            sizeof(message_buffer),           // 缓冲区最大容量
            MPI_CHAR,                         // 数据类型
            0,                                // 源进程在远程组中的序号
                99,                                // 期望的消息标签
            intercomm_between_groups,         // 使用的通信域
            &receive_status                    // 接收状态信息
        );
        
        printf("[进程 %d (奇数组)]: 收到消息: \"%s\"\n", 
               global_rank, message_buffer);
        
        // 可以从状态中获取更多信息
        int actual_message_length;
        MPI_Get_count(&receive_status, MPI_CHAR, &actual_message_length);
        
        printf("[进程 %d (奇数组)]: 消息长度为 %d 字符\n", 
               global_rank, actual_message_length);
    }
    
    /**
     * ========================================================
     * 资源清理和程序结束
     * ========================================================
     * 记得释放所有创建的通信域资源。
     */
    
    // 等待所有进程完成通信
    MPI_Barrier(global_world_comm);
    
    // 释放通信域
    if (intercomm_between_groups != MPI_COMM_NULL) {
        MPI_Comm_free(&intercomm_between_groups);
    }
    
    MPI_Comm_free(&intra_comm_by_color);
    
    printf("[进程 %d]: 通信已完成,准备退出\n", global_rank);
    
    // 终结MPI环境,关闭所有MPI进程
    MPI_Finalize();
    
    return 0;
}

编译:

mpicc -o mpi_concepts_example mpi_concepts_example.c

运行:

# 使用4个进程运行
mpirun -np 4 ./mpi_concepts_example

输出:

[进程 0]: 我是MPI并行程序中的一个进程,总共有 4 个进程
[进程 1]: 我是MPI并行程序中的一个进程,总共有 4 个进程
[进程 2]: 我是MPI并行程序中的一个进程,总共有 4 个进程
[进程 3]: 我是MPI并行程序中的一个进程,总共有 4 个进程

[进程 0]: 我被分配到偶数组(color=0), 在该组内我的序号是 0
[进程 2]: 我被分配到偶数组(color=0), 在该组内我的序号是 1
[进程 1]: 我被分配到奇数组(color=1), 在该组内我的序号是 0
[进程 3]: 我被分配到奇数组(color=1), 在该组内我的序号是 1

[进程 0]: 在全局通信域中我的序号是 0,在组内通信域中我的序号是 0
[进程 1]: 在全局通信域中我的序号是 1,在组内通信域中我的序号是 0
[进程 2]: 在全局通信域中我的序号是 2,在组内通信域中我的序号是 1
[进程 3]: 在全局通信域中我的序号是 3,在组内通信域中我的序号是 1

[进程 0]: 我所在的偶数组完成了广播,接收到的数据是 0
[进程 2]: 我所在的偶数组完成了广播,接收到的数据是 0
[进程 1]: 我所在的奇数组完成了广播,接收到的数据是 101
[进程 3]: 我所在的奇数组完成了广播,接收到的数据是 101

--- MPI消息传递开始 ---
[进程 0 (偶数组)]: 正准备发送消息到奇数组
[进程 0 (偶数组)]: 消息已发送
[进程 1 (奇数组)]: 收到消息: "你好,奇数组! 这条消息来自偶数组的进程 0"
[进程 1 (奇数组)]: 消息长度为 54 字符

[进程 0]: 通信已完成,准备退出
[进程 1]: 通信已完成,准备退出
[进程 2]: 通信已完成,准备退出
[进程 3]: 通信已完成,准备退出
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

范桂飓

文章对您有帮助就请一键三连:)

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值