CUDA Kernel调试与优化--背景知识扫盲(LLM生成)

CUDA Kernel调试与优化–背景知识扫盲(LLM生成)

对于使用CUDA进行调试与性能优化,官方提供了丰富的参考资料和工具。以下是一些关键资源,可以帮助你更好地调试和优化CUDA代码:

官方文档和指南

  1. CUDA Toolkit Documentation
  2. CUDA C++ Programming Guide
    • URL: CUDA C++ Programming Guide
    • 内容: 深入介绍了CUDA编程模型、内存管理、优化策略和诸多其他方面的详细信息。
  3. CUDA Best Practices Guide
    • URL: CUDA Best Practices Guide
    • 内容: 介绍了优化CUDA应用程序性能的最佳实践,包括内存管理、线程管理等。

调试工具

  1. NVIDIA Nsight Debugger
    • URL: NVIDIA Nsight Visual Studio Edition
    • 内容: 提供用于调试CUDA应用程序的强大工具,集成在Visual Studio中,支持断点、变量监视、内存检查等功能。
  2. cuda-gdb
    • URL: cuda-gdb
    • 内容: 这是一个在命令行环境下使用的CUDA调试器,功能类似于GNU gdb,但专用于CUDA代码调试。

性能分析与优化工具

  1. NVIDIA Nsight Compute
    • URL: NVIDIA Nsight Compute
    • 内容: 专业的CUDA核函数性能分析工具,允许用户深入挖掘和分析每一个内核调用的性能细节和资源利用情况。
  2. NVIDIA Nsight Systems
    • URL: NVIDIA Nsight Systems
    • 内容: 提供系统级别的性能分析工具,可以分析CPU和GPU的性能、协调多个应用程序以及计算中不同部分的时序和资源使用。

在线资源和教程

  1. CUDA Zone on NVIDIA Developer
    • URL: CUDA Zone
    • 内容: 聚合了大量的CUDA相关资源,包括SDK、范例代码、博客文章、视频教程等。
  2. NVIDIA Developer Blog
    • URL: NVIDIA Developer Blog
    • 内容: 包含大量关于CUDA性能优化、最佳实践、工具使用的博文和教程。
  3. CUDA Training and Workshops
    • URL: CUDA Training
    • 内容: 提供不同级别的CUDA培训课程和工作坊,包括在线课程和现场培训。

社区与论坛

  1. NVIDIA Developer Forums
    • URL: NVIDIA Developer Forums
    • 内容: 提供一个与其他开发者和NVIDIA工程师交流的社区平台,可以在这里提出问题、分享经验和获得支持。

通过利用以上官方资源和工具,你可以在CUDA编程过程中有效地进行调试与性能优化,提升应用程序的执行效率。


NVIDIA Visual Profiler (nvvp) 已经逐步被 NVIDIA Nsight Systems 和 NVIDIA Nsight Compute 工具替代。这两个工具继承并扩展了 Visual Profiler 的功能,提供了更强大、更详细的性能分析和优化能力。

详细解释

  • NVIDIA Nsight Systems:
    • 功能:Nsight Systems 是一个系统级别的性能分析工具,能够提供跨CPU和GPU的全面分析。它帮助用户识别应用程序中的性能瓶颈,并且可以分析多线程、多进程和多GPU系统的性能。
    • 主要特点:
      • 时间线视图,可以查看CPU和GPU的执行情况和相互关系。
      • 分析系统端到端的性能,包括CPU、GPU、操作系统和存储等组件。
      • 支持捕获和分析多线程、MPI应用和网络通信。
  • NVIDIA Nsight Compute:
    • 功能:Nsight Compute 专门用于CUDA核函数性能分析。它提供了详细的内核级别性能指标,可以帮助用户深入理解CUDA内核的性能,并进行实际的优化。
    • 主要特点:
      • 详细的内核性能指标,包括指令吞吐量、内存带宽、缓存利用率等。
      • 灵活的报告生成和比较功能。
      • 支持内存对齐分析和共享内存银行冲突检查。

为什么转向 Nsight Systems 和 Nsight Compute

  1. 更强大的功能:Nsight工具提供了比Visual Profiler更详细、更全面的分析能力,从系统级别到内核级别都能覆盖。
  2. 更好的用户体验:Nsight工具的用户界面和报告生成更加友好和直观,可以更方便地定位性能瓶颈并进行优化。
  3. 持续更新:NVIDIA持续为Nsight工具提供更新和新功能,以适应最新的GPU架构和计算需求。

安装和使用

  1. 安装 Nsight Systems:
    • 在NVIDIA开发者页面上可以下载Nsight Systems安装包:Nsight Systems
  2. 安装 Nsight Compute:
    • 在NVIDIA开发者页面上可以下载Nsight Compute安装包:Nsight Compute
  3. 文档和教程

通过转向Nsight Systems和Nsight Compute,你可以获得更为强大和灵活的性能分析工具,帮助你更加高效地优化CUDA应用的性能。


整理和归纳CUDA软件的调试和调优工具对于提升开发效率和代码性能至关重要。以下将综合列出各工具的功能、它们之间的关系,以及如何配套使用这些工具,从而实现有效的CUDA程序调试和优化。

工具及其功能描述

1. NVIDIA Nsight Compute
  • 功能:详细的核函数(kernel)性能剖析工具,可以提供深入的性能指标和瓶颈分析。
  • 适用阶段:性能调优阶段,具体定位性能瓶颈。
2. NVIDIA Nsight Systems
  • 功能:系统级的性能分析工具,捕捉CPU和GPU之间的交互,帮助理解应用的整体性能表现。
  • 适用阶段:性能调优阶段,系统级别的性能评估和优化。
3. cuda-gdb
  • 功能:支持CUDA的GNU调试器,用于在源代码级别调试CUDA代码,设置断点、单步执行等。
  • 适用阶段:代码调试阶段,排查逻辑错误、数据竞争和同步问题。
4. nsight-visual-studio-code-edition
  • 功能:集成到Visual Studio Code的调试和性能分析插件,提供调试、性能剖析和代码优化建议。
  • 适用阶段:开发和调试阶段,集成开发和调试流程。
5. Profiler Counter Function、Assertion、Trap function、Breakpoint Function、Formatted Output
  • 功能
    • Profiler Counter Function:用于性能计数,获取核函数执行的低级硬件指标。
    • Assertion:用于在代码中插入断言,检查运行时条件是否满足。
    • Trap function:用于触发用户定义的错误或异常条件。
    • Breakpoint Function:设置断点,方便调试过程中暂停执行。
    • Formatted Output:输出格式化的调试信息,便于分析和理解。
  • 适用阶段:开发和调试阶段,插入代码检查点和性能监视。
6. CUPTI (CUDA Performance Tools Interface)
  • 功能:提供访问CUDA运行时和驱动层级的性能数据接口,用于构建自定义性能分析工具。
  • 适用阶段:性能调优和监控阶段,创建自定义性能分析解决方案。
7. CUDA Debugger API
  • 功能:提供接口用于创建自定义调试工具,支持线程控制、内存访问、断点设置等功能。
  • 适用阶段:高级调试阶段,用于构建特定的调试方案。

工具关系及配套使用

初期开发阶段
  • 工具使用:使用nsight-visual-studio-code-edition进行代码编写和初步调试。
  • 合理配套:集成的开发环境便于写代码、设置断点、进行初步的性能分析。
基础调试阶段
  • 工具使用:
    • cuda-gdb:进行深入的代码调试,解决逻辑错误和同步问题。
  • 合理配套:
    • Profiler Counter FunctionAssertionTrap functionBreakpoint FunctionFormatted Output结合,插入运行时检查点,捕捉和诊断错误。
性能剖析阶段
  • 工具使用:
    • NVIDIA Nsight Compute:定位核函数(kernel)性能瓶颈。
    • Profiler Counter Function:获取底层性能指标,结合Nsight Compute的分析结果进行优化。
  • 合理配套:
    • Nsight Compute和Profiler Counter Function结合,定位具体性能问题,进行相应的代码优化。
系统级性能分析阶段
  • 工具使用:
    • NVIDIA Nsight Systems:分析CPU与GPU之间的交互,评估应用程序整体性能。
  • 合理配套:
    • cuda-gdb结合,进行跨层级的性能问题定位,确保系统级性能瓶颈解决后,进行代码级优化。
高级调试和性能监测阶段
  • 工具使用:
    • CUPTI:构建自定义性能分析工具,监测CUDA应用的性能数据。
    • CUDA Debugger API:创建自定义调试工具,进行复杂调试任务。
  • 合理配套:
    • CUPTI与Nsight Systems和Nsight Compute结合,获取详尽的性能数据,进行深度分析。
    • CUDA Debugger API与cuda-gdb结合,创建特定的调试方案,针对复杂问题进行精细化调试。

配套使用示例

  1. 初期开发和基础调试
    • 编写CUDA代码并使用nsight-visual-studio-code-edition进行初步调试,使用cuda-gdb进行详细调试。
    • 使用Profiler Counter FunctionAssertion在代码中插入检查点,确保运行时条件正确。
  2. 性能剖析和系统性能评估
    • 使用NVIDIA Nsight Compute进行核函数(kernel)性能剖析,结合Profiler Counter Function获取具体性能指标。
    • 利用NVIDIA Nsight Systems进行系统级性能分析,确定CPU和GPU交互以及整体应用程序的性能瓶颈。
  3. 高级调试和自定义性能监测
    • 构建自定义性能分析工具,使用CUPTI接口监测丰富的性能数据,辅助调优。
    • 创建特定的调试工具,利用CUDA Debugger API进行复杂调试任务,解决高难度问题。

通过以上工具的合理配套使用,可以在不同阶段提升CUDA代码的编写、调试和优化效率,最终实现高性能、稳定的GPU程序。


在编写和调试CUDA核函数(kernel)时,除了高层次的调试工具和方法外,有一些更为底层的调试技巧和工具可以帮助你解决特定的问题。这些包括使用CUDA C/C++、PTX指令、API调用以及一些环境变量来调试和优化CUDA代码。以下是详细的介绍:

CUDA C/C++调试

1. kernel中的打印输出
  • **

    printf()
    

    **:

    • 可以在核函数中使用printf打印调试信息,但要小心输出量,因为太多的打印输出会显著降低性能。

    • __global__ void kernel() {
          printf("Thread %d, Block %d\n", threadIdx.x, blockIdx.x);
      }
      
2. 核函数中的断言
  • **

    assert()
    

    **:

    • 在核函数中插入断言,用于检查运行时的条件是否满足。

    • __global__ void kernel() {
          assert(threadIdx.x < 10);  // 仅允许前10个线程继续执行
      }
      
3. 错误检查
  • CUDA API错误检查:

    • 在调用CUDA API后检查返回值,确保没有错误发生。

    • cudaError_t err = cudaMemcpy(dest, src, size, cudaMemcpyHostToDevice);
      if (err != cudaSuccess) {
          printf("CUDA error: %s\n", cudaGetErrorString(err));
      }
      

PTX指令调试

1. 内联PTX指令
  • 内联PTX:

    • 在CUDA C/C++代码中直接插入PTX(并行线程执行)汇编代码,可以进行低级别的调试和优化。

    • __global__ void kernel(float *a, float *b, float *c) {
          asm("add.f32 %0, %1, %2;" : "=f"(c[threadIdx.x]) : "f"(a[threadIdx.x]), "f"(b[threadIdx.x]));
      }
      

CUDA API

1. 调试辅助API
  • cudaDeviceSynchronize()

    • 在关键点调用cudaDeviceSynchronize(),确保前面所有的CUDA调用完成,方便捕捉错误。

    • kernel<<<blocks, threads>>>();
      cudaDeviceSynchronize();
      
  • cudaGetLastError()

    • 获取最后一个错误,并打印错误信息。

    • kernel<<<blocks, threads>>>();
      cudaError_t err = cudaGetLastError();
      if (err != cudaSuccess) {
          printf("Kernel launch error: %s\n", cudaGetErrorString(err));
      }
      cudaDeviceSynchronize();
      

环境变量

1. CUDA调试环境变量
  • CUDA_LAUNCH_BLOCKING

    • 设置为1,可以使所有CUDA内核函数调用和内存拷贝变成同步操作,有助于调试。

    • export CUDA_LAUNCH_BLOCKING=1
      
  • CUDA_DEVICE_WAITS_ON_EXCEPTION

    • 设置为1,使设备在发生异常时挂起,方便调试。

    • export CUDA_DEVICE_WAITS_ON_EXCEPTION=1
      
  • CUDA_DEBUGGER_SOFTWARE_PREEMPTION

    • 设置为1,允许调试器在正运行的CUDA线程中断的情况下重新调度其他线程,便于调试。

    • export CUDA_DEBUGGER_SOFTWARE_PREEMPTION=1
      
  • CUDA_MEMCHECK

    • 可以启用CUDA内存检查,检测内存越界、非法访问等问题。

    • cuda-memcheck ./your_cuda_application
      

结合和应用示例

以下是一个综合使用上述调试技巧的示例:

#include <stdio.h>
#include <cuda_runtime.h>

__global__ void kernel(int *a) {
    printf("Thread %d, Block %d\n", threadIdx.x, blockIdx.x);

    // 使用PTX指令进行操作
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    asm("mov.u32 %0, %1;" : "=r"(a[idx]) : "r"(idx));

    // 检查索引范围
    assert(idx < 100);

    // 其他操作...
}

int main() {
    const int size = 100;
    int *d_a;
    cudaMalloc((void**)&d_a, size * sizeof(int));

    // 启动核函数
    kernel<<<1, 1024>>>(d_a);

    // 同步并检查错误
    cudaError_t err = cudaDeviceSynchronize();
    if (err != cudaSuccess) {
        printf("CUDA Synchronization Error: %s\n", cudaGetErrorString(err));
    }

    err = cudaGetLastError();
    if (err != cudaSuccess) {
        printf("CUDA Kernel Launch Error: %s\n", cudaGetErrorString(err));
    }

    cudaFree(d_a);
    return 0;
}

以上示例展示了如何在核函数中使用printf调试输出、插入PTX指令、使用断言进行检查,并在主程序中利用CUDA API检查和同步错误。这种多层次的调试方法能够帮助开发者迅速定位和解决问题。

通过结合这些调试技巧和工具,你可以更高效地编写、调试和优化CUDA核函数,从而提升GPU应用的性能和可靠性。


CUDA架构提供了一些内建的时钟函数,可以在GPU内部进行精确的时间测量。以下是一些常用的方法:

方法一:使用clock()clock64()

1. clock()

clock()函数返回当前线程的时钟周期计数。因为它返回的是32位值,计数周期会在较大循环中溢出,因此适用于短时间的测量。

2. clock64()

clock64()函数与clock()类似,但它返回的是64位值,这样可以避免溢出问题,适用于较长时间的测量。

示例代码
#include <stdio.h>
#include <cuda_runtime.h>

__global__ void kernel() {
    // 获取起始时钟周期
    clock_t start = clock64();

    // 被测代码段
    for (int i = 0; i < 1000000; ++i) {
        // 模拟一些工作
    }

    // 获取结束时钟周期
    clock_t end = clock64();

    // 计算耗时
    clock_t clockCyclesTaken = end - start;
    printf("Thread %d in block %d: %ld clock cycles\n", threadIdx.x, blockIdx.x, clockCyclesTaken);
}

int main() {
    kernel<<<1, 1>>>();
    cudaDeviceSynchronize();

    return 0;
}

方法二:使用内置PTX指令

使用内联PTX(并行线程执行)汇编代码可以获取更低级别的时间测量,但这需要一些PTX指令的知识。

示例代码
#include <stdio.h>
#include <cuda_runtime.h>

__global__ void kernel() {
    unsigned long long start;
    unsigned long long end;

    // 内联PTX获取起始时间
    asm volatile ("mov.u64 %0, %%globaltimer;" : "=l"(start));

    // 被测代码段
    for (int i = 0; i < 1000000; ++i) {
        // 模拟一些工作
    }

    // 内联PTX获取结束时间
    asm volatile ("mov.u64 %0, %%globaltimer;" : "=l"(end));

    // 计算耗时
    unsigned long long clockCyclesTaken = end - start;
    printf("Thread %d in block %d: %llu clock cycles\n", threadIdx.x, blockIdx.x, clockCyclesTaken);
}

int main() {
    kernel<<<1, 1>>>();
    cudaDeviceSynchronize();

    return 0;
}

方法三:使用循环计时

如果你对单次测量时间不够精确,可以通过多次循环执行测量的代码段,并计算平均时间。

示例代码
#include <stdio.h>
#include <cuda_runtime.h>

__global__ void kernel() {
    // 测试循环的次数
    const int numTests = 100;
    unsigned long long totalCycles = 0;

    for (int i = 0; i < numTests; ++i) {
        // 获取起始时钟周期
        clock_t start = clock64();

        // 被测代码段
        for (int j = 0; j < 1000000; ++j) {
            // 模拟一些工作
        }

        // 获取结束时钟周期
        clock_t end = clock64();

        // 计算耗时
        totalCycles += (end - start);
    }

    // 计算平均耗时
    unsigned long long averageCycles = totalCycles / numTests;
    printf("Thread %d in block %d: %llu average clock cycles\n", threadIdx.x, blockIdx.x, averageCycles);
}

int main() {
    kernel<<<1, 1>>>();
    cudaDeviceSynchronize();

    return 0;
}

注意事项

  1. 多线程同步:如果在调用__syncthreads()之前使用这些计时函数,由于线程的执行次序不同,时钟周期计数可能会产生偏差。确保在同步点之后再测量时间。
  2. 测量开销:调试输出(如printf)和循环操作本身会引入开销,因此实际测量可能会略有误差。
  3. 最小化测量区域:尽可能缩小计时的代码段,只包含你真正需要测量的操作,避免其他代码的影响。

通过以上方法,你可以在CUDA核函数内部完成较为精确的时间测量,帮助识别和优化性能瓶颈。


CUDA编程涉及多个层次和格式的代码,从高层次的源代码到最终的可执行二进制文件。这些层次和格式之间的关系是至关重要的理解,以下是它们之间的关系和简要的描述:

1. CUDA C/C++

CUDA C/C++是用户编写的高层次源代码,旨在利用GPU进行并行计算。它扩展了标准的C/C++,增加了一些关键词和语法有助于在GPU上进行并行编程,比如__global____device____shared__

2. PTX(Parallel Thread Execution)

PTX是一个中间表示(IR),由CUDA编译器生成,用于描述并行线程的执行。PTX是一种面向GPU的汇编语言,独立于特定的硬件架构。这使得PTX代码可以在不同的GPU架构之间重用。

当CUDA源代码通过nvcc编译器编译时,首先被编译成PTX。这是一个中间步骤,便于进一步优化和转换为GPU特定的指令集。

PTX示例:
.version 6.0
.target sm_30
.address_size 64

.visible .entry _Z6kernelPi (
    .param .u64 _Z6kernelPi_param_0
)
{
    .reg .pred 	%p<2>;
    .reg .b32 	%r<2>;
    .reg .b64 	%rd<3>;

    ld.param.u64 	%rd1, [_Z6kernelPi_param_0];
    cvta.to.global.u64 	%rd2, %rd1;
    mov.u32 	%r1, %tid.x;
    shl.b64 	%rd2, %rd2, 2;
    add.u64 	%rd2, %rd2, %r1;
    st.global.u32 	[%rd2], %r1;
    ret;
}

3. CUBIN (CUDA Binary)

CUBIN文件是由PTX代码进一步编译生成的二进制文件,特定于GPU的架构(比如NVIDIA的架构)。CUBIN包含了低级的机器指令,可以直接在GPU上执行。

CUBIN文件是通过nvcc编译器选项(如-arch)指定目标架构之后所生成的。它们与特定的GPU架构如sm_35sm_50等紧密相关。

4. ELF (Executable and Linkable Format)

ELF文件是一种通用的文件格式,用于可执行文件、共享库和转储文件。CUDA应用程序的可执行文件通常会包含ELF格式的二进制代码。

在CUDA生态系统中,包含GPU代码的ELF文件被称为完整的应用程序,它会带有嵌入的CUBIN段。这些CUBIN段在运行时由CUDA驱动加载到GPU中执行。

5. DWARF (Debugging With Attributed Record Formats)

DWARF是一种标准的调试数据格式,广泛用于存储源级调试信息。它包含变量的类型信息、行号信息以及用于调试器所需的其他数据。在CUDA程序中,DWARF调试信息可以嵌入到ELF文件中,使得调试器(如cuda-gdb)能够利用这些信息进行源级调试。

关系简述:

  1. CUDA C/C++:高层的源代码,由开发者编写。

    |
    V
    
  2. PTX:中间表示,CUDA源代码编译成PTX代码。可以对PTX进行进一步的优化。

    |
    V
    
  3. CUBIN:特定于目标GPU架构的二进制文件,由PTX进一步编译生成。

    |
    V
    
  4. ELF:通用的可执行文件格式,包含CUBIN段,可以直接在GPU上运行。

    |
    +---- 新式调试信息(DWARF)
    
总结起来,CUDA程序的编译过程可以从高层的CUDA C/C++逐步转换为中间的PTX表示,再到特定架构的二进制CUBIN文件,最终整合到通用的ELF文件中供执行。同时,DWARF调试信息可以嵌入到这些文件中,提供调试支持。这一系列的转换和整合步骤帮助实现了高效的GPU执行,同时保留了对不同GPU架构的灵活性。

CUDA程序的执行流程包括多个步骤,从主机启动到在GPU核心上实际运行内核代码。以下是详细的流程说明,从创建CUDA上下文到内核在CUDA核心上执行的整个过程。最终目标是让CUDA二进制(CUBIN)在CUDA内核(CUDA core)上执行。

一、准备阶段:主机端的准备工作

1. 初始化CUDA Driver API

使用CUDA Driver API初始化库。

#include <cuda.h>

int main() {
    // 初始化CUDA驱动API
    cuInit(0);
    CUdevice device;
    CUcontext context;

    // 获取并选择CUDA设备
    cuDeviceGet(&device, 0);

    // 创建CUDA上下文
    cuCtxCreate(&context, 0, device);
    
    // 后续步骤...
    
    return 0;
}
2. 加载CUBIN文件

读取并加载CUDA CUBIN文件。

CUmodule module;
CUfunction kernel;

// 加载CUBIN文件
cuModuleLoad(&module, "my_kernel.cubin");

// 获取内核函数句柄
cuModuleGetFunction(&kernel, module, "my_kernel_function");

二、配置和启动内核

3. 内存分配和数据传输

在主机和设备之间分配内存,并进行数据传输。

int *d_data;
int size = 1000;
cuMemAlloc((CUdeviceptr*)&d_data, size * sizeof(int));

// 将数据从主机传输到设备
int *h_data = new int[size];
cuMemcpyHtoD((CUdeviceptr)d_data, h_data, size * sizeof(int));

// 内核参数设置
void *args[] = { &d_data };
4. 配置内核执行参数

设置内核的执行配置,包括网格和块的尺寸。

int threadsPerBlock = 256;
int blocksPerGrid = (size + threadsPerBlock - 1) / threadsPerBlock;

// 启动内核
cuLaunchKernel(kernel, 
               blocksPerGrid, 1, 1,    // 网格维度
               threadsPerBlock, 1, 1,  // 块维度
               0,                      // 共享内存大小
               0,                      // 流
               args,                   // 参数列表
               0);                     // 额外参数

三、GPU端执行:从调度到执行

5. CUDA驱动层处理
  • 内核启动:主机调用驱动API启动内核运行。
  • 参数传递:驱动API将内核函数及其参数发送到设备侧。
  • 编译与链接:若需要(如使用JIT编译),驱动API会编译PTX或其他中间代码并链接成可执行的二进制代码。
// 实际执行链条在CUDA驱动内部进行
// cuLaunchKernel调用会经历上述步骤,实际用户无需特别处理
6. CUDA运行时层处理
  • 上下文切换:CUDA上下文被调度,这个上下文内存储了当前运行的CUDA程序状态及其资源。
  • 指令调度:CUDA驱动将内核函数分解为多个块(block)和线程(thread),再由GPU硬件进行实际调度。

四、硬件执行阶段:在CUDA Core上执行

7. 网格和块调度器
  • GigaThread引擎:负责全局调度,管理网格级别的内核并调度其内的块到不同的SM(Streaming Multiprocessor)。
  • 轨迹器(Warp Scheduler):负责每个SM内的线程束(warp)的调度。每个线程束包含固定数量的32个线程。
8. SM和CUDA Core
  • 分派单元(Dispatch Unit):SM内部有多个分派单元,会将warp调度给可用的CUDA核心。
  • 执行阶段(Execution Phase):每个warp内的32个线程会在CUDA核心上并行执行。具体的指令在CUDA核心中被译码和执行。

五、执行后处理:从GPU回到主机

9. 同步与结果处理
// 内核执行完毕后,同步主机与设备
cuCtxSynchronize();

// 将结果从设备传回主机
cuMemcpyDtoH(h_data, (CUdeviceptr)d_data, size * sizeof(int));

// 释放资源
cuMemFree((CUdeviceptr)d_data);
delete[] h_data;

// 销毁模块和上下文
cuModuleUnload(module);
cuCtxDestroy(context);

总结

以下是一个总结的流程图:

  1. 主机初始化CUDA驱动API。
  2. 加载并解析CUBIN文件。
  3. 分配设备内存及数据传输。
  4. 配置内核启动参数。
  5. 启动内核通过CUDA API(如cuLaunchKernel)。
  6. CUDA驱动API管理上下文、编译、链接等任务。
  7. CUDA硬件调度网格、块、线程束。
  8. CUDA核心上执行每个线程束的指令。
  9. 同步结果并传回主机。

这样的执行流程涉及主机API调用、驱动层处理,以及GPU硬件层面的执行调度,确保用户编写的CUDA代码能够高效地在GPU上运行。


在CUDA编程模型中,一个warp是调度的基本单位。当一个warp中的某个线程被stalled(暂停)时,通常会发生以下情况:

  1. 如果只有一些线程被stalled,整个warp还是会被认为是stalled,因此整个warp会被暂停执行。
  2. CUDA硬件会寻找其他可以运行的warp来执行,以最大化资源利用率和减少延迟。

细节解释:

  1. Warp是调度单位:在CUDA硬件层面,warp是调度的基本单位,不是单个线程。当一个warp中的任意一个或多个线程被stalled,而无法立即继续执行时,整个warp会被认为是stalled。
  2. 零开销硬件调度:SM(Streaming Multiprocessor)内部的warp调度器会选择其他准备好的warp来执行,而不需要额外的调度开销。这种零开销调度机制可以快速切换到其他warp来隐藏内存延迟或其他高延迟的操作。
  3. 复合指令掩码:CUDA硬件可以通过执行掩码来识别哪些线程应该执行哪些指令,哪些线程被stalled。在同一个warp内,如果某些线程因内存访问或其他原因stall,其他线程可以继续,但一般情况下,warp调度器会选择完全准备好执行的warp来替换,而不是等待当前warp部分空闲。

示例:

假设我们有一个大小为1024的线程数组启动了一个内核,这意味着有32个warp。(每个warp有32个线程)

  1. 初始化执行

    warp 0: threads 0-31
    warp 1: threads 32-63
    ...
    warp 31: threads 992-1023
    
  2. 部分线程stall

    • 假设warp 0中的线程8需要访问一个全局内存,并产生延迟。
    • 这时,warp 0被标记为stalled,warp调度器会选择其他准备好的warp。
  3. Warp调度

    • Warp调度器选择warp 1(假设其所有线程都准备好执行)来执行。
    • while warp 1执行,等待warp 0的数据访问完成。

关键点总结:

  • Warp级调度:一个线程stall会导致整个warp被标记为stalled,warp调度器会暂停这个warp,直到它准备好执行。
  • 高效利用:调度器会选择其他准备好的warp来执行,从而最大化资源利用率。
  • 并行机制:在不同warp继续执行的情况下,线程在其各自的warp内被动地等待,直到所有线程都准备好继续执行。

换句话说,当一个warp中的某些线程被stalled时,整个warp都会被暂停,并由warp调度器调度其他warp进行执行。这种机制是为了隐藏内存延迟和其他高延迟操作,从而保持CUDA核心的高效利用。


当一个warp被分配给一个特定的SM(Streaming Multiprocessor)后,该warp及其所有的线程将在这个SM内部执行,并不会被调度到其他的SM上。这一特性有助于减少上下文切换的开销和复杂性,从而提高执行效率。

详细解释:

  1. 固定SM分配
    • 当CUDA内核启动时,线程块(thread block)会被分配给可用的SM进行执行。
    • 每个线程块中的所有warp(每个warp含有32个线程)也被固定分配到该SM。
    • 在内核执行期间,线程块不会被重新分配到其他SM。
  2. SM内部执行
    • 一个warp内的线程会在分配的SM内部的各个CUDA核心之间执行。
    • 尽管warp的线程在具体的CUDA核心间可以进行调度,但整个warp不会迁移到其他SM。
  3. 线程的执行调度
    • 每个SM通常有多个warp调度器,可以调度多个warp并行执行,以尽量利用全部的CUDA核心。
    • 如果一个warp中的某些线程需要等待(例如因为内存访问),warp调度器会调度其他准备好的warp来执行。

内核执行示例:

考虑一个包含64个线程的线程块(共2个warp),在执行过程中发生的调度:

  1. 分配到SM
    • 线程块被分配到SM0。
    • Warp 0(线程0-31)和Warp 1(线程32-63)被分配到SM0。
  2. SM内部调度
    • Warp 0的线程被分配到SM0的CUDA核心。
    • 如果Warp 0由于某些线程stalled,SM0的warp调度器会调度Warp 1进行执行。
  3. 不跨SM调度
    • Warp 0和Warp 1始终在SM0内执行,不会被调度到其他SM(例如SM1)上。

关键点总结:

  • SM固定性:一旦一个warp被分配到一个SM,它将在该SM内部执行,不会跨SM迁移。
  • Warp调度:SM内部的warp调度器会在SM的CUDA核心间调度warp的执行,确保最大化硬件利用率。
  • 效率和开销:这种调度策略有助于减少重分配的开销,提高执行效率。

这意味着每个global ID的线程在执行过程中,不会跨SM或CUDA核心迁移,而是始终在最初分配的SM和其内部的CUDA核心上执行。


确切执行哪个CUDA core是由硬件调度器动态决定的,并不像在CPU上那样在软件层面明确指定。以下是更多关于这个主题的详细信息:

1. Warp和SM的映射

  • 当一个线程块(thread block)被分配到一个SM时,这个SM负责执行该线程块中的所有warp。
  • Warp中的每个线程可以被分配到SM内部的不同CUDA核心上执行。

2. CUDA核心分配

  • 一个warp包含32个线程,通常会被分配到SM内部的多个CUDA核心(具体数量取决于SM的架构,例如,如果SM有128个CUDA核心,可同时处理多个warp)。
  • 具体warp中的哪些线程被分配到哪些CUDA核心是由SM的硬件调度器动态决定的。

3. 调度器的作用

  • SM内部的warp调度器负责管理和分配线程到CUDA核心。
  • 调度器根据当前的可用资源和负载情况,动态分配warp和线程到不同的CUDA核心。

4. 执行的不确定性

  • 虽然一个warp中的线程被确定分配到一定数量的CUDA核心上,但是具体分配到哪些核心,以及每次执行时线程被分配到的核心可能是动态和不确定的。
  • 这种不确定性是为了确保高效利用硬件资源,实现高度并行和负载平衡。

5. 示例说明

假设有一个SM,包含128个CUDA核心,我们在内核调用中启动了一个线程块,有64个线程(这组成了2个warp)。以下是调度过程:

  1. 分配Warp 0和Warp 1到SM:
    • 这些warp中的线程需要由SM内部的CUDA核心执行。
  2. 线程执行:
    • 在一个执行周期内,SM的调度器可能让Warp 0的线程0-31在CUDA核心0-31上执行,Warp 1的线程32-63在CUDA核心32-63上执行。
    • 在下一个执行周期内,调度器可能会改变这些线程在CUDA核心上的分配,希望最大化资源利用率。

6. 调度灵活性

  • 调度器运用了复杂的调度算法来最大化吞吐量和GPU利用率。
  • 当某些资源空闲时,比如某个CUDA核心由于warp中的某些线程被stalled而空闲,调度器会迅速利用这些空闲资源执行其他准备好的warp。

总结:

  • 线程块到SM的分配是固定的:一旦线程块被分配到一个SM,它将在该SM上执行整个生命周期。
  • Warp中的线程到CUDA核心的分配是动态的:SM内部的warp调度器根据当前资源和负载情况,动态分配线程到不同的CUDA核心。这种不确定性确保了资源的高效利用和负载平衡。

因此,虽然一个warp固定在某个SM上执行,但其内部线程具体使用的CUDA核心是动态决定的,且不一定每次都相同。这是为了最优化硬件资源,确保并行计算的高效性。


当我们提到GPU具有多少个CUDA核心时,这是一个简化后的描述,方便理解GPU的计算能力。然而实际上,一个SM(Streaming Multiprocessor)中包含了一组不同类型的核心,每种核心执行特定类型的计算任务。为了进一步理解这一点,下面详细说明CUDA核心的职责和PTX指令与核心的关系:

CUDA Cores:

  1. 职责
    • CUDA核心是GPU中的基础计算单元,主要负责执行32位的浮点运算(FP32)和一些整型运算。虽然称之为"CUDA核心",但在现代硬件中,它们的职责并不仅限于FP32计算。
  2. 执行的运算类型
    • 浮点运算(FP32):CUDA核心最主要的职责是执行32位浮点运算。
    • 整型运算(INT):在某些架构中,CUDA核心也会执行低延迟的整型运算。
    • 随着架构的发展,一些整型运算可能会由专门的整数核心来处理,从而使FP32核心更加专注于浮点计算。

专用核心(例如,Tensor Cores):

  1. 职责
    • 专门用于执行高效的矩阵乘法和累加运算,极大地加速深度学习任务。
  2. 执行的运算类型
    • 混合精度运算:如FP16到FP32的转换和运算,适用于深度学习中的矩阵乘法。
    • 张量操作:Tensor Cores设计用于加速常见的深度学习算子,例如矩阵乘法。

PTX指令与核心匹配:

PTX(Parallel Thread Execution)是CUDA编程模型中的中间表示语言,编写的PTX指令经过编译,会分配到不同的硬件单元执行。如何确定PTX指令运行在哪个核心上,可以通过以下方式:

  1. 基础指令执行
    • 大多数基础指令,包括算术运算指令(如add.f32),会被分配到CUDA核心执行。
    • 专门的差异性操作指令,有时会通过命名或者指令后缀来标识它们适用于特定的硬件单元(如FP64核心或INT核心)。
  2. 高级指令和Tensor Core
    • PTX中还是会有高级指令,例如适用于Tensor Core的矩阵乘法指令(如wgmma.mma_async.sync.aligned.m16n8k16.row.col.f16等),这些指令会被编译到特定的硬件指令集,匹配到Tensor Core执行。
  3. 指令映射和优化
    • PTX到SASS(machine-specific instruction set)是硬件相关编译器的责任,这个过程中,指令会映射到具体的硬件指令,确保高效执行。
    • NVIDIA提供的工具(如nvprof或者Nsight Compute)可以详细分析PTX指令的执行情况和映射关系,帮助开发者优化代码。

如何判定PTX指令运行在哪个核心:

要了解PTX指令在具体核心上的执行情况,可以使用以下工具和方法:

  1. Nsight Compute
    • Nsight Compute是一款强大的分析工具,可以展示内核执行的详细情况,帮助识别哪些类型的核心在执行特定PTX指令。
  2. SASS解释(机器指令集)
    • 编译后查看PTX代码生成的SASS代码,可以通过反汇编检查具体的指令分配。
    • SASS代码里会更明确地指出具体硬件单元上的指令执行情况。

总结:

  • CUDA核心及其他核心的协作:GPU包含不同类型的核心,CUDA核心主要负责FP32和一些整型运算,而先进的架构还包含专门的整型核心、浮点核心(FP64),以及为深度学习设计的Tensor Cores。
  • 指令分配和调度:特定的PTX指令经过编译器优化,动态地分配到适合的硬件核心上执行,确保最大化性能。
  • 分析工具和优化:使用Nsight Compute等分析工具,可以深入了解PTX指令的执行路径和效率,以便进行深度优化。

总的来说,对于开发者来说,准确理解不同核心的职责和如何高效利用它们是优化CUDA内核性能的关键。


指令issue瓶颈(instruction issue bottleneck)是指当GPU的SM(Streaming Multiprocessor)无法以足够高的速率发出(issue)指令,从而导致性能受限的情况。要判断一个内核(kernel)是否遇到指令issue瓶颈,可以使用性能分析工具来分析GPU的资源利用率和指令发出的情况。

如何判断一个内核是否遇到指令issue瓶颈

  1. 使用Nsight Compute
    • Nsight Compute是NVIDIA提供的强大性能分析工具,可以帮助开发者深入了解CUDA内核的执行情况。
    • 通过运行Nsight Compute,观察各个性能计数器和瓶颈分析报告。
  2. 查看指令发出率
    • 指令发出率(Instruction Issue Rate):可以用Nsight Compute分析GPU的每个SM的指令发出情况,确定其最大化发出指令的能力。
    • 关注指令发出速率与指令执行时间的相关关系,判断是否SM在发出指令上被限制。
  3. 分析关键性能指标
    • Warp Execution Efficiency:衡量warp中线程的执行效率。如果Warp Execution Efficiency很高,而指令发出率较低,可能表明存在指令issue瓶颈。
    • Issue Slot Utilization:分析SM的指令发出插槽(issue slot)是否被充分利用。如果利用率较低而其他资源利用率正常,可能暗示指令issue瓶颈。

为什么会出现指令issue瓶颈?

指令issue瓶颈通常是由于以下几个原因引起的:

  1. 指令依赖性(Instruction Dependency)
    • 当指令序列中存在大量的依赖性时,后续指令必须等待前面的指令执行完成才能开始执行。这个等待可能导致指令发出速率下降。
  2. 分支指令(Branch Instructions)
    • 分支指令会导致warp的分裂,影响指令流水线的连续性。warp分裂会降低核心的指令发出效率。
  3. 指令调度限制(Instruction Scheduling Limits)
    • CUDA硬件调度器有其处理指令的上限。如果指令调度超出这些上限,指令发出速率就会受到限制。
  4. Warp Divergence
    • 当同一个warp中的不同线程采取不同执行路径时,会导致warp分裂并降低指令发出效率。
  5. 资源冲突(Resource Contention)
    • 共享资源(如寄存器、共享内存等)的竞争会导致指令发出被延迟。例如,如果寄存器使用过多,可能导致指令的调度瓶颈。

如何缓解指令issue瓶颈?

  1. 优化指令序列
    • 尽量减少指令间的依赖性,重新安排指令顺序以增加指令的并行性。
  2. 简化分支逻辑
    • 减少if-else语句的使用,尽量使用GPU友好的算法,如通过数据的转置或重排来最小化warp divergence。
  3. 使用更多寄存器
    • 合理使用寄存器和共享内存,避免过多地使用全局内存,提高计算效率。
  4. 增加并行度
    • 尽量增加活跃的warp数量,以提高指令发出率。可以通过调整线程块的大小和布局来实现。
  5. 性能分析和调整
    • 经常使用工具如Nsight Compute进行性能分析,发现和调整瓶颈。

总之,理解指令issue瓶颈的成因和表现,使用适当的分析工具进行诊断,并通过优化代码结构和内存使用,可以有效提高CUDA内核的计算效率。


在NVIDIA GPU架构中,每个Streaming Multiprocessor (SM) 是一个高度并行的计算单元,内部包含多个子单元和硬件调度机制。指令发出插槽、warp调度器和分派单元是理解SM内部如何调度和执行指令的关键组件。

1. 指令发出插槽(Issue Slot)

指令发出插槽是在SM内部用于每个时钟周期可以发出的指令位置。指令发出插槽的数量决定了SM在每个时钟周期内能够发出的指令数量,这对性能有重要的影响。

2. Warp调度器(Warp Scheduler)

Warp调度器是管理和调度warp(32个线程组成的执行单元)的硬件单元。每个warp调度器负责从准备好的warp中选择一个,并将其指令发送到执行单元。

3. 分派单元(Dispatch Unit)

分派单元负责将调度器发出的指令实际发送到具体的执行单元(例如各种类型的计算核心,如CUDA核心、Tensor核心等)上去执行。

SM中这些单元的关系

在现代的NVIDIA GPU架构中(如Volta、Turing和Ampere),每个SM通常具有多个warp调度器和多个指令发出插槽。其关系可以概括如下:

多个warp调度器和多个发出插槽
  • 一个SM内包含多个warp调度器(如Turing架构中,一个SM有4个warp调度器)。
  • 每个warp调度器可以同时处理多个warp,并在每个时钟周期选择其中一个warp进行调度。
  • 一个SM具有多个指令发出插槽,每个warp调度器可以利用一个或多个发出插槽,具体数量取决于架构。
  • 每个发出插槽可以关联到不同类型的执行单元,例如整型执行单元、浮点执行单元、特殊功能单元等。
Warp调度器到分派单元的路径
  1. 选择warp:Warp调度器从其管理的warp池中选择一个warp准备发出指令。
  2. 核对资源:确保选择的warp的指令所需的执行单元和资源是空闲的。
  3. 发出指令:一旦资源空闲,warp调度器会将指令发出到指令发出插槽。
  4. 分配并执行:分派单元将发出的指令分配到具体的执行单元上,如CUDA核心、载波变换核心或Tensor核心等。
Dispatch和Issue阶段
  • 调度(Schedule):Warp调度器决定哪个warp要执行。
  • 发出(Issue):指令被放到指令发出插槽中。
  • 分派(Dispatch):分派单元将指令从发出插槽送到相应的执行单元。

指令发出插槽不是单个的

在现代架构中,一个SM具有多个指令发出插槽:

  • 例如,在V100 GPU(Volta架构)中,每个SM有4个warp调度器,每个调度器有2个发出插槽,总共8个插槽。这意味着每个SM可以在每个时钟周期内最多发出8条指令。

提高性能利用率

  • 并行调度:多个warp调度器可以并行工作,提高调度和发出指令的并行度。
  • 资源利用率:优化代码确保warp在执行时不会等待所需的资源,以提高发出插槽和执行单元的利用率。

总结:

  • Warp调度器:负责选择和调度warp,确保其所需资源空闲。
  • 指令发出插槽(Issue Slot):每个SM有多个发出插槽,决定能同时执行的指令数量。
  • 分派单元(Dispatch Unit):将发出的指令分派到相应的执行单元。

理解这些组件在SM内部的相互关系和工作机制,有助于优化CUDA内核的性能,提高GPU计算资源的利用率。在实际开发中,通过性能分析工具(如Nsight Compute)可以详细分析这些调度和发出机制的使用情况,找到和解决潜在的瓶颈。


在CUDA编程中,虽然我们可以将取指(Fetch)、译码(Decode)、发射(Issue)、执行(Execute)看作是一个指令流水线(pipeline)的阶段,但GPU的架构和运行方式与传统的CPU流水线有一些不同,尤其在处理并行线程和warp时。我们可以把取指、译码、发射和执行看作是一个流水线处理的过程,但其实际运行方式是高度并行的。

是否是一个队列还是一个管道

  1. 指令流水线(Pipeline)
    • 在传统的CPU中,指令流水线是为了提高指令吞吐量,把指令的执行分解为多个阶段,每个阶段并行运行以提高效率。
    • GPU中的每个CUDA线程确实经历类似的阶段,包括取指、译码、发射和执行,但不同的是,GPU的设计是为了最大化并行处理而不是单纯的流水线吞吐量。
  2. 队列与管道
    • 管道(Pipeline):可以把各阶段看作一个流线型的处理过程,前一阶段的输出直接成为下一阶段的输入。
    • 队列(Queue):指令在等待各个阶段时有可能排队,但这种情况在GPU中不常见,因为GPU设计上是为了避免这种瓶颈,最大化并行度。

问题:某个线程处于Issue状态时,另一个线程可以Fetch吗?

在GPU架构中,单个SM内管理多个warp和大量的线程,每个warp内有32个线程(on NVIDIA architecture)。 warp是GPU并行处理的基本单位。SM内有多个硬件调度器可以并行处理多个warp,这样可以保证高效的指令吞吐和资源利用。

  1. 并行处理
    • GPU的SM拥有多个warp调度器,这些调度器可以并行工作。一个warp的一个线程在Issue阶段时,另一个warp的线程可以进行Fetch。这种并行调度大大提高了资源利用率。
  2. 异步操作
    • GPU内部的各个流水线阶段可以异步操作。即使一个线程在一个阶段等待中,其他线程可以继续在其他阶段运行。例如,一个warp的线程在Fetch指令时,另一个warp的线程可以在Execute阶段执行指令。

高效利用流水线和保持管道繁忙的策略

为了保持所有阶段(Fetch、Decode、Issue、Execute)忙碌,并最大化资源利用,GPU架构和CUDA编程模型有以下特点:

  1. Warp-level并行调度
    • 多warp并行调度:多个warp同时在同一个SM中调度和运行,warp调度器能够在不同warp之间快速切换。
    • 避免等待:一个warp在等待内存访问或流水线中的某个阶段时,调度器可以将其他ready的warp投递到执行单元中。
  2. 优化线程分配和资源使用
    • 合理的资源分配:通过合理划分线程块和调度策略(如选取最佳线程块大小),保证每个SM中有足够多的活跃warp,以减少等待时间,提高并行执行的效率。
    • 避免溢出到全局内存:尽可能利用寄存器和共享内存,减少对全局内存的访问,因为全局内存访问延迟较高。
  3. 内存访问模式优化
    • 内存协调访问(Coalesced Access):使得内存访问得益于一致性,减少内存访问等待时间,把更多资源用在计算上。
  4. 指令重排和优化
    • 通过指令重排减少指令间依赖,增加指令的并行度,使不同warp的指令可以并行取指、译码、发射和执行。

结论

综上所述,在CUDA编程和NVIDIA GPU架构中,取指(Fetch)、译码(Decode)、发射(Issue)和执行(Execute)这些阶段确实是一种类流水线(pipeline)处理的过程,但由于GPU高度并行的特性,这些阶段并不是单纯的顺序执行。一个线程可以在不同的阶段时刻处理,且多个warp可以并行调度,使得资源利用率达到最大化。因此,一个线程处于Issue状态时,另一个线程完全可以进行Fetch操作。高效利用这些特性,可以通过合理设计和优化代码,使得管道尽量保持繁忙状态,从而提高整体性能。


比较CUDA线程块与英特尔SPSC(Single Program Multiple Data)架构中的任务

翻译:

CUDA 抽象的实现
  • 线程块的调度
    • 系统可以以任意顺序调度线程块。
    • 系统假设线程块之间没有依赖关系。
    • 逻辑上是并发的。
    • 非常类似于ISPC(英特尔SPSC)的任务,对吧?
  • 同一线程块中的CUDA线程
    • 同一线程块中的CUDA线程是并发运行的(同时存在)。
    • 当线程块开始执行时,所有线程都存在并分配了寄存器状态(这些语义对系统调度施加了一定的约束)。
    • 一个CUDA线程块本身就是一个SPMD(Single Program Multiple Data)程序(类似于ISPC的一组程序实例)。
    • 线程块中的线程是并发的,作为协作的“工人”一起工作。
  • CUDA的实现
    • NVIDIA GPU中的warp具有性能特征,类似于一组ISPC实例(但与ISPC的一组实例不同,warp的概念在编程模型中不存在)。
    • 线程块中的所有warp都被调度到同一个SM(Streaming Multiprocessor)上,允许通过共享内存变量进行高带宽、低延迟的通信。
    • 当线程块中的所有线程完成时,线程块的资源(共享内存分配,warp执行上下文)将可用于下一个线程块。

详细解释:

1. 线程块的调度
  • 线程块的任意调度
    CUDA中的线程块(thread blocks)可以由系统以任意顺序调度。这意味着硬件或CUDA运行时系统可以自由地选择在何时以及在哪个流多处理器(SM)上执行哪个线程块。
  • 无依赖假设
    系统假设线程块之间没有交叉依赖性(dependencies)。这允许线程块独立执行,简化了硬件调度算法。
  • 逻辑上并发
    尽管线程块的执行可以在时间上错开,但编程模型使它们看起来是逻辑上并发的。
  • 类似于ISPC任务
    ISPC(Intel SPMD Program Compiler)中的任务也是类似的并发执行单元,因此这种调度方式与ISPC任务的调度方式相似。
2. 同一线程块中的CUDA线程
  • 并发执行
    在一个线程块开始执行时,该线程块中的所有线程都同时存在,并且具有已分配的寄存器状态。这使得线程块中的线程彼此之间可以协作,而不需要等待其他线程块。
  • 约束系统调度
    由于同一线程块中的线程需要并发存在,这对系统施加了一定的调度约束,即系统需要确保在同一时间段内分配足够的资源来执行整个线程块中的所有线程。
  • SPMD 程序
    一个CUDA线程块被视为一个SPMD程序(所有线程运行相同的程序代码,但操作的数据不同),这类似于ISPC中的一组并发执行的程序实例。
  • 协作“工人”
    线程块中的线程视为并发的、协作的工作单元,它们可以通过共享内存和同步原语(如__syncthreads())相互通信和协作。
3. CUDA 实现细节
  • warp的性能特征
    在NVIDIA GPU中,warp(32个线程的集合)具有类似于一组ISPC实例的性能特征。虽然warp这一概念在CUDA编程模型中并不明显暴露,但它是硬件执行的基本单元。
  • 同一SM中的warp调度
    线程块中的所有warps都会被调度到同一个SM上,这样可以利用共享内存进行高带宽、低延迟的通信。这使得线程之间的合作更加高效。
  • 资源回收
    当线程块中的所有线程完成执行时,该线程块的资源(如共享内存和warp的执行上下文)将被释放,并可以用于下一个即将执行的线程块。

总结

这段文字提供了对CUDA并行计算模型的一种高层次的理解。它解释了线程块的调度无顺序依赖、同一块中线程的并发执行、warp的硬件调度特性并与ISPC中的任务作对比。通过这些解释,我们可以更好地理解CUDA编程模型的设计目的以及其在硬件上的具体实现。


对于NVIDIA GPU架构,尤其是在CUDA编程模型下,warp调度器(Warp Scheduler)确实起着关键作用。它需要管理warp的调度、分析指令依赖关系、确定可用计算单元等复杂任务。warp调度器的运作频率和效率对于整个GPU的性能有重要影响。通常情况下,warp调度器的运行频率比单个CUDA核心的执行频率更高,但确切的倍数取决于具体的GPU架构。

Warp调度器与CUDA核心的运行频率对比

1. 高效调度的需求
  • 隐藏指令延迟:warp调度器通过频繁调度warp来隐藏指令执行中的延迟,确保计算单元尽量保持繁忙状态。如果某个warp正在等待内存访问或复杂指令的执行,调度器会调度其他warp以继续执行。
  • 资源分配:warp调度器需要动态评估当前所有warp的状态,分配合适的计算资源(如CUDA核心、特殊功能单元)。
2. 调度频率
  • warp调度器频率:现代GPU中的warp调度器通常在每个时钟周期内执行调度决策。这意味着它可以每个时钟周期评估多个warp的状态,并在需要时进行调度。
  • CUDA核心执行频率:CUDA核心的执行频率取决于指令类型。简单指令(如整数、浮点运算)通常可以在一个时钟周期内完成,而复杂操作(如内存访问、特殊功能单元操作)可能需要多个时钟周期。

在典型的现代NVIDIA GPU架构中,warp调度器的调度操作频率一般比单个CUDA核心执行单条指令的频率要高。

CUDA核心与warp调度器的频率关系

1. 指令执行时间
  • 简单指令:这些通常需要1个时钟周期;常见于基本的整数和浮点运算。
  • 复杂指令:如内存加载/存储、特殊功能单元操作,可能需要2个或更多时钟周期。
2. 调度频率

在一个时钟周期内,warp调度器可以调度多个warp,以便使得每个可用的计算单元都尽可能在工作。因此,warp调度器的频率通常可以认为是“每个时钟周期”进行一次调度。

具体的频率比

现代的NVIDIA GPU架构没有公开具体的warp调度频率与CUDA核心执行频率的具体倍数,但是根据上述介绍,我们可以理解为:

  • Warp调度器在每个时钟周期内评估和调度warp,以隐藏延迟和最大化资源利用率。
  • CUDA核心在执行单条指令时,简单指令通常需要一个时钟周期,而复杂指令则需要多个时钟周期

从这种架构设计上可以推断出:warp调度器的频率至少是CUDA核心频率的若干倍,因为它需要频繁调度以确保每时每刻都有合适的warp在等待执行。此外,warp调度器的频率与单个CUDA核心执行频率并不是一个固定的倍数关系,而是动态的、高效调度的机制设计。

总结

  • 高频调度机制:warp调度器每个时钟周期都进行调度,以确保warp能够高效利用GPU资源,隐藏延迟。
  • CUDA核心频率依赖指令:CUDA核心的执行频率取决于指令类型,简单指令为一个时钟周期,复杂指令需要多个时钟周期。
  • 调度频率优势:由于warp调度器的频率比单个CUDA核心执行指令的频率高,所以warp调度器能够高频次地调度warp,保证GPU资源尽可能高效地利用。

总的来说,warp调度器的运行频率相较于单个CUDA核心执行指令的频率要高很多,以确保极高的并行度和资源利用效率。具体的倍数关系取决于实际的GPU架构和指令类型,可以理解为warp调度器的效率和频率设计是为了最大化并行性能和吞吐量。


当一个warp在GPU上执行代码时,可能会遇到各种原因导致的stall(阻塞),例如等待内存访问、数据依赖、硬件资源冲突等。关于warp stall后的寄存器数据处理,GPU架构设计了高效的机制来处理这种情况,以最大限度地提高并行性和资源利用率。

寄存器数据的处理机制

1. 寄存器文件管理

每个Streaming Multiprocessor(SM)都有一个大型的寄存器文件,用于存储所有正在执行的warp的寄存器数据。在一个warp遇到stall时,其寄存器数据仍然保留在这个寄存器文件中。

  1. 寄存器文件(Register File):这个寄存器文件很大,可以存储多个warp的寄存器数据。每个warp执行不同的指令时,对应的寄存器数据不会丢失,也不会被重新加载或存取,除非在需要上下文切换的情况下。
  2. 寄存器分配:编译器结合硬件资源进行寄存器分配,每个warp的寄存器需求量在编译时已经确定。一旦一个warp被分配到了寄存器,这些寄存器就一直保持分配状态,直到warp执行完毕。
2. warp调度与切换

当一个warp遇到stall时,warp调度器会将其标记为不可调度状态并去寻找其他可以调度的warp来执行,而不会立刻释放或者操作stall的warp的寄存器内容。

  1. warp调度器切换:warp调度器可以迅速切换到其他warp,并让其他warp的指令执行单元保持繁忙,以最大化SM的利用率。这种切换是轻量级的,因为寄存器数据不需要重新加载或存储。
  2. 上下文切换速度:由于寄存器文件对多个warp的数据进行管理,切换warp的代价很低,高效的上下文切换促进GPU的高并行度运行。
3. 寄存器内容的一致性

寄存器内容在warp stall期间保持不变,确保当warp重新调度时,其上下文依然保持一致。

  1. 寄存器的一致性:寄存器文件中保存的warp寄存器内容保持不变,直到warp重新被调度恢复执行。
  2. 恢复执行:一旦需要的条件满足(例如内存数据到达、资源可用等),warp调度器将重新调度这个warp,利用其存储在寄存器文件中的数据继续执行。

GPU中Warp Stall后的行为概述

  1. 等待内存:当warp因为等待内存而程序stall,warp调度器会切换到其他可运行的warp。寄存器内容不会改变,仍然保留在寄存器文件中。
  2. 数据依赖和资源冲突:类似地,如果因为数据依赖或者资源冲突导致stall,这个warp的寄存器状态仍然保持不变,等到可运行条件满足后再恢复执行。

具体处理流程

  1. Warp Stall检测:检测到warp因为内存访问、数据依赖等原因进入stall状态。
  2. 保存寄存器状态:寄存器文件保持当前warp的寄存器内容不变。
  3. 调度其他warp:warp调度器切换到其他可以执行的warp。
  4. 状态恢复:当stall条件解除(如数据可用),重新调度原warp并继续执行之前暂停的操作。

性能优化建议

  1. 减少warp stall:通过优化内存访问模式(如优化内存访问合并、减少内存访问冲突)、使用共享内存和寄存器减少对全局内存的依赖等方式,减少warp sstall的机会。
  2. 提升并行效率:适当增加并行的warp数量,确保warp调度器总有可调度的活跃warp,最大化利用计算资源。

总结

在GPU中,当一个warp遇到stall时,其寄存器状态保持在寄存器文件中,等待条件解除后重新调度执行。warp调度器在等待期间切换到其他warp,以确保高效的资源利用和并行性。这种机制保证了寄存器数据的一致性和warp上下文的快速切换,从而实现高效的GPU并行计算。


在GPU编程和架构设计中,寄存器分配是确保高效计算的重要因素之一。寄存器分配涉及编译阶段产生的虚拟寄存器编号和实际运行时硬件寄存器的映射。以下是关于编译阶段分配的寄存器编号与warp调度器实际分配到的硬件寄存器编号之间关系的详细解释:

编译阶段的寄存器分配

1. 虚拟寄存器编号
  • 虚拟寄存器:在编译阶段,编译器为每个局部变量和临时值分配虚拟寄存器。虚拟寄存器编号是一个编译器内部的逻辑编号,不直接对应硬件中的物理寄存器位置。
  • 优化分配:编译器通过寄存器分配算法(如图着色算法)优化虚拟寄存器的数量,减少寄存器需求。
2. 寄存器压力
  • 寄存器需求量:根据所需要执行的核函数,编译器确定每个warp(或线程块)的寄存器需求量。这将决定每个SM可以容纳多少并发的warp。

运行时的硬件寄存器分配

1. 硬件寄存器文件
  • 物理寄存器:每个SM包含一个大容量的寄存器文件(Register File),由多个物理寄存器构成。这些物理寄存器用于存储所有同时在这个SM上执行的warp的寄存器数据。
2. 寄存器映射
  • 虚拟到物理映射:在内核执行时,warp调度器将编译器分配的虚拟寄存器编号映射到物理寄存器。这个映射过程是由硬件管理的,透明于编程者。
  • 寄存器分片:每个warp被分配一组连续的物理寄存器,但物理寄存器的具体编号根据运行时的寄存器管理机制动态决定。

编译阶段与运行时之间的关系

  1. 寄存器需求计算
    • 编译阶段:编译器根据代码复杂度和变量使用情况计算出最小的虚拟寄存器需求量。例如,如果内核函数需要32个虚拟寄存器,编译器会确保生成的PTX代码只需要这些寄存器。
    • 运行时:执行环境根据warp的寄存器需求和SM的寄存器容量进行调度计算。假设每个SM有65536个寄存器,而每个warp需要32个寄存器,那么将决定每个SM最多能并发支持多少个warp。
  2. 寄存器绑定与分配
    • 编译器输出:编译器生成的PTX代码包含虚拟寄存器编号。在进一步的编译阶段(如SASS生成阶段),这些虚拟寄存器编号会转化为具体的硬件指令格式,但仍与具体物理寄存器无直接关联。
    • 硬件调度:在内核启动后,warp调度器和SM硬件将这些虚拟寄存器映射到具体的物理寄存器上。这种映射是动态的,由硬件管理,避免不同warp之间的寄存器冲突和重叠。
  3. 动态调度与执行
    • 调度机制:warp调度器根据当前SM的资源状况、warp的状态及其寄存器需求,动态地分配和绑定物理寄存器。尽管每个warp开始时可能需要特定数量的寄存器,但具体的分配是由硬件在调度时决定。
    • 并发与阻塞管理:当某个warp被阻塞时(例如内存等待),其寄存器状态保持在物理寄存器中。warp调度器可以随时调度其他可运行的warp,充分利用寄存器资源。

总结

  • 编译阶段:编译器分配虚拟寄存器编号,并在内部代码中使用这些编号来管理计算过程。优化算法确保寄存器需求量尽可能低。
  • 运行时映射:warp调度器在执行内核函数时,将虚拟寄存器编号动态映射到物理寄存器。这使得寄存器文件得以高效利用,最大化SM的并发能力。
  • 物理寄存器文件:寄存器文件管理所有在SM上有效工作的warp的寄存器状态,确保在warp被阻塞与恢复时维护其状态的一致性。

这种分离机制不仅保证了编译器优化代码的灵活性,同时也保证了硬件的高效调度能力,实现高性能并行计算。


在CUDA编程模型中,寄存器、L1缓存、L2缓存、全局内存(Global Memory)、局部内存(Local Memory)之间的关系是优化GPU性能的关键因素。了解它们之间的关系及其工作机制,对于编写高效的CUDA代码至关重要。让我们详细探讨这些内存层次以及数据在它们之间的传输和管理机制。

寄存器(Registers)

  • 专用性:寄存器是每个SM中速度最快的存储单元,专用于存储线程的局部变量和临时计算结果。
  • 分配:每个线程被分配一组寄存器,寄存器数目是有限的,过多的寄存器使用会导致寄存器溢出,进而使用局部内存。

L1缓存(L1 Cache)

  • 位置:L1缓存位于每个SM中,用于加速对全局内存和常量内存的访问。
  • 功能:L1缓存提高了内存访问的效率,通过存储最近访问的数据,同时还可以配置为共享内存的一部分。

L2缓存(L2 Cache)

  • 位置:L2缓存是所有SM共享的,用于缓存从全局内存加载的数据。
  • 功能:作为全局内存访问的第二级缓存,L2缓存进一步减少了访问全局内存的延迟。

全局内存(Global Memory)

  • 位置:全局内存位于设备内存中,具有高容量和高延迟。
  • 功能:存储所有线程块(blocks)可访问的数据,是设备之间数据交换的重要位置。

局部内存(Local Memory)

  • 用途:用于存储单个线程的私有数据,实质上是全局内存的一部分。
  • 访问:使用频繁的局部内存变量会导致性能下降。

数据加载与合并

简单线程加载

一个线程执行数据加载时,实际的内存访问由硬件管理来完成数据的加载、合并和传输:

  1. 合并访问:当warp中多个线程访问连续的全局内存地址时,内存访问会进行合并(coalescing),即多个小的内存请求合并为一个大的请求,减少总的访问次数。
  2. 加载机制:加载指令由warp发起,硬件负责合并和传输。
L1与L2之间的数据传输
  1. L1缓存控制:每个SM都有自己的L1缓存,数据从全局内存或者L2缓存加载到L1缓存。
  2. L2缓存控制:所有SM共享一个L2缓存。当L1缓存缺失时,数据会从L2缓存请求。如果L2缓存也缺失,则从全局内存读取。
  3. 事务管理:当数据从L2缓存加载到L1缓存时,硬件按照缓存一致性协议和事务管理机制,确保数据正确性和同步性。

数据传输机制

  1. 读写操作流程
    • 寄存器 -> L1缓存:线程从寄存器读取数据,或者将数据写入寄存器。如果读取的数据不在寄存器,则在L1缓存中查找。
    • L1缓存 -> L2缓存:如果L1缓存丢失,会从共享的L2缓存中读取数据到L1缓存。
    • L2缓存 -> 全局内存:如果L2缓存也丢失,则从全局内存读取数据到L2缓存。
  2. 数据一致性与刷新
    • 写回策略:L1缓存通常采用写回策略,当一个数据被修改时,修改结果先更新到L1缓存,随后再更新到L2缓存及全局内存。
    • 缓存一致性:硬件管理缓存的一致性,确保所有线程对同一数据的访问是同步一致的。

总结与优化建议

  1. 寄存器使用优化:尽量高效使用寄存器,避免寄存器溢出导致访问缓慢的局部内存。
  2. 内存合并访问:编写代码时尽量让warp中的线程按顺序访问连续内存,提升内存合并访问效率。
  3. 缓存优化:利用L1和L2缓存减少全局内存访问延迟,必要时可以显式地使用共享内存。
  4. 减少局部内存使用:减少对局部内存的依赖,提高代码执行效率。

通过理解这些内存层次及其关系,可以更加高效地优化CUDA代码,提升GPU的计算性能,实现高效的并行处理。


在CUDA编程中,trap()函数用于生成一个设备端断点(即触发一个调试断点),通常用来进行调试目的。对应的PTX指令是TRAP

PTX指令:TRAP

功能

TRAP指令用于触发一个断点或捕获特定的异常。在执行过程中,一旦触发,GPU的执行流会中断,并进入调试模式。这通常用于调试和开发阶段,以便开发人员检查和调试代码行为。

语法

PTX指令TRAP通常没有操作数,其基本语法为:

trap;
原理和执行单元
  1. 触发断点
    当CUDA程序执行trap()对应的PTX TRAP指令时,当前warp的执行将在执行单元处中断。
  2. 中断执行流
    触发TRAP后,GPU执行流会停止,并进入调试模式,使得开发人员可以使用调试工具在此断点上进行检查。
  3. 硬件执行单元
    TRAP指令由流处理器(Streaming Processor,SP)执行。在GPU内,具体的调度与异常处理由每个SM中的控制逻辑负责管理。

使用示例

在CUDA C/C++代码中可以通过内置函数trap()用于生成相应的PTX指令TRAP

__global__ void kernel() {
    // Some computation
    int idx = threadIdx.x;
    
    if (idx == 10) {
        // Trigger a trap for debugging
        asm("trap;");
    }

    // More computation
}

在这个示例中,如果线程索引为10的线程执行到这行代码,trap()调用将明确地插入PTX指令TRAP,从而触发一个断点。

GPU硬件处理

  • Warp上下文切换
    TRAP指令触发时,GPU的控制逻辑会捕获这个异常信号,并暂停当前warp的执行。
  • 调试端口
    GPU硬件通常带有与调试工具通信的接口,这些接口允许开发者通过调试端口捕捉和处理断点。

调试工具支持

常见的CUDA调试工具如cuda-gdbNsight Computing支持在代码中设置trap()断点,开发者可在这些断点处进行检查、变量查看、堆栈跟踪等调试操作。

总结

  • trap()函数:生成一个PTX指令TRAP
  • PTX指令TRAP:触发调试断点。
  • 执行单元:由GPU的流处理器和SM中的控制逻辑管理。
  • 调试用途:用于调试和开发阶段,便于检查、分析代码行为。

通过TRAP指令,开发者可以更好地调试并分析CUDA内核代码的执行,找出潜在的问题和瓶颈。

cuda-gdb 中设置条件断点时,调试器需要不断检查设置的条件是否满足,以决定是否触发断点。了解这一原理,可以帮助我们更好地使用条件断点,并理解其性能影响。

条件断点的原理

条件断点的检查机制
  1. 断点设置
    当你在 cuda-gdb 中设置一个断点时,调试器会记录断点的位置和条件。如果是条件断点,条件表达式也会被记录下来。
  2. 执行检查
    当程序运行到断点所在的行时,调试器会暂停代码执行,并评估断点条件。具体步骤如下:
    • 环境捕获:调试器捕获当前的执行环境,包括所有内存变量和寄存器状态。
    • 条件评估:调试器计算并评估条件表达式。如果条件为真,则触发断点,否则继续执行。
  3. 调试器中断
    • 如果条件满足,调试器中断程序执行,进入调试状态。开发者可以检查变量、执行调试命令和查看堆栈信息。
    • 如果条件不满足,调试器允许程序继续执行。

谁负责检查变量

cuda-gdb中,条件断点的检查主要由调试器(即 cuda-gdb itself)负责。具体过程如下:

  1. 调试器捕获环境
    每当遇到一个断点,调试器暂停线程执行,捕获当前线程的执行环境。这包括寄存器内容、当前线程栈、局部变量状态和CUDA线程上下文等。
  2. 评估条件
    调试器解释和评估条件表达式,检查捕获的变量和寄存器值。这类似于在一个交互式解释器中执行条件表达式。
  3. 继续执行或暂停
    • 如果条件为真,调试器中断程序执行,进入交互调试模式。
    • 如果条件为假,调试器恢复线程执行,继续执行下一条指令。

性能影响

条件断点的检查机制会引入一些性能开销,具体体现在:

  1. 上下文切换
    每次遇到断点,调试器都需要捕获当前执行环境,这会引入一定的性能开销。
  2. 条件评估
    调试器需要在软件上评估条件表达式,这也会带来一些开销,特别是复杂表达式或需要多次访问内存的情况。
  3. 频繁断点
    如果断点设置的位置处于热点代码(即高频执行的地方),频繁的断点检查会对程序性能产生显著影响。

优化建议

为了减少条件断点带来的性能影响,可以考虑以下优化建议:

  1. 尽量减少不必要的条件断点
    只在关键代码路径设置条件断点,避免在循环或频繁调用的代码段设置断点。
  2. 简化条件表达式
    使用尽量简洁的条件表达式,以减少评估条件的开销。
  3. 结合日志调试
    在某些情况下,可以结合日志调试与断点调试,通过记录关键信息,减少断点调试的依赖。

总结

cuda-gdb中,条件断点的检查和评估由调试器 (cuda-gdb) 负责。当程序运行到断点位置时,调试器会暂停执行,捕获当前执行环境,并评估条件表达式。如果条件满足,调试器中断程序进入调试模式,否则继续执行。这一机制会引入一定的性能开销,特别是在热点代码段设置条件断点时。通过合理设置断点和简化条件表达式,可以在一定程度上优化调试性能。

充分理解这些机制有助于高效使用条件断点,提高调试效率,减少调试过程中不必要的性能开销


在 GPU 中,统计各种性能指标(metrics)是关键的任务,有助于调试和优化 GPU 程序的性能。通常,这些性能指标是通过专门的硬件单元(如性能监视单元,Performance Monitoring Units,PMUs)实现的,并且这些硬件单元可能与 GPU 的 Streaming Multiprocessors (SMs) 和其他子系统集成在一起。

GPU 性能监视的机制

1. 硬件性能监视单元 (PMU)
  • 专用硬件单元:
    • PMUs 是专门设计用于统计和报告各种性能指标的硬件单元。它们可以嵌入在 GPU 的不同硬件组件中,例如 SM、内存控制器、缓存层级等。
    • 这些硬件单元可以监视和记录特定事件的发生情况,例如指令执行、内存访问、缓存命中与未命中等。
  • 事件计数器:
    • PMUs 包含多个计数器,每个计数器用于统计某个特定类型的事件。例如,一个计数器可以统计在特定时段内执行了多少条指令,另一个计数器可以统计缓存命中率。
2. 集成在 SM 或 GPU 子系统中的监视功能
  • SM 中的性能监视:
    • 现代 GPU 中的 SM 通常集成了专门的计数器和监视电路。这些计数器可以用于监视诸如执行延迟(stall)、存储器效率、指令吞吐量等。
    • 这些计数器被硬件直接访问,并在GPU操作过程中实时更新。
  • 其他子系统的性能监视:
    • 内存控制器、片上缓存(L1、L2缓存)和其他子系统同样会集成性能计数器,用于监控读写吞吐量、缓存命中率等。
3. 采样与聚合
  • 采样机制:
    • 性能计数器可以按周期性或事件触发进行采样。按照设定的采样周期,GPU 可以定时读取计数器值并记录。
    • 事件触发的采样机制可以在特定事件发生时(例如数据缓存未命中时)记录当前计数器状态。
  • 数据聚合:
    • 采样数据通常会被存储在 GPU 的专用内存区域,并在之后通过驱动程序或管理工具(如 NVIDIA Nsight、CUDA 运行时库)传递给主机系统。
    • 主机系统可以进一步处理、分析和展示这些数据,用于性能调试和优化。

性能指标的具体示例

以下是一些常见性能指标,以及它们的实现方式:

  1. Stall_exec_dependency:
    • 实现方式: 统计执行单元由于操作数未准备好而等待的时钟周期数。专用的计数器监视每个时钟周期内的依赖性延迟,并累加到计数器中。
  2. Shared_efficiency:
    • 实现方式: 统计共享内存操作的利用效率。计数器监视共享内存请求和服务情况,计算有效带宽与实际带宽的比例。
  3. L2_L1_write_throughput:
    • 实现方式: 监视从 L2 缓存到 L1 缓存的写操作,以字节为单位统计数据传输量。计数器记录每次数据包的大小和频率。

面积和复杂性

实现性能监视功能需要额外的硬件资源,包括逻辑电路和存储单元:

  • 面积与开销:
    • 现代GPU设计在考虑这种功能时通常会尽量优化资源使用,以便在不影响核心功能和性能的前提下引入这些监视器。
    • 性能监视器的逻辑电路和存储单元相对于整个GPU的面积来讲占比较小,但精确的实现细节和具体面积开销依赖于各个GPU架构和厂商的设计。
  • 优化设计:
    • 厂商倾向于在芯片设计上做出优化,以确保这些监视器不影响总体性能。例如,通过在低功耗状态下激活监视器、仅在需要时进行详细采样等方法,减少额外开销。

使用示例

借助 CUDA 提供的工具,例如 nvprofNsight Compute ,开发者可以访问和分析这些性能指标。以下是一个获取性能指标的示例:

nvprof --metrics stall_exec_dependency,shared_efficiency,l2_l1_write_throughput ./your_cuda_program

总结

GPU 中的性能统计是由专门的性能监视器硬件单元(PMUs)实现的,这些单元分布在 GPU 的各种组件中,包括 SM、内存控制器和缓存层级。虽然这些监视器占用了一定的硬件资源,但其设计目标是尽量减少面积和性能开销,从而在不显著影响GPU性能的前提下,提供详尽的性能数据供调试和优化使用。通过这些指标,开发人员可以更深入地了解和优化 CUDA 程序的性能。

  • 35
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Hi20240217

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值