CUDA的nvvp报告非理想的内存访问模式,但带宽几乎达到峰值

时间:2018-11-08 18:32:50

标签: cuda nvvp

编辑:一个新的最小工作示例,用以说明问题和对nvvp结果的更好解释(遵循评论中的建议)。

因此,我制作了一个“最小”的工作示例,如下:

#include <cuComplex.h>
#include <iostream>

int const n = 512 * 100;

typedef float real;

template < class T >
struct my_complex {
   T x;
   T y;
};

__global__ void set( my_complex< real > * a )
{
   my_complex< real > & d = a[ blockIdx.x * 1024 + threadIdx.x ];
   d = { 1.0f, 0.0f };
}

__global__ void duplicate_whole( my_complex< real > * a )
{
   my_complex< real > & d = a[ blockIdx.x * 1024 + threadIdx.x ];
   d = { 2.0f * d.x, 2.0f * d.y };
}

__global__ void duplicate_half( real * a )
{
   real & d = a[ blockIdx.x * 1024 + threadIdx.x ];
   d *= 2.0f;
}

int main()
{
   my_complex< real > * a;
   cudaMalloc( ( void * * ) & a, sizeof( my_complex< real > ) * n * 1024 );

   set<<< n, 1024 >>>( a );
   cudaDeviceSynchronize();
   duplicate_whole<<< n, 1024 >>>( a );
   cudaDeviceSynchronize();
   duplicate_half<<< 2 * n, 1024 >>>( reinterpret_cast< real * >( a ) );
   cudaDeviceSynchronize();

   my_complex< real > * a_h = new my_complex< real >[ n * 1024 ];
   cudaMemcpy( a_h, a, sizeof( my_complex< real > ) * n * 1024, cudaMemcpyDeviceToHost );

   std::cout << "( " << a_h[ 0 ].x << ", " << a_h[ 0 ].y << " )" << '\t' << "( " << a_h[ n * 1024 - 1 ].x << ", " << a_h[ n * 1024 - 1 ].y << " )"  << std::endl;

   return 0;
}

当我编译并运行上述代码时,内核duplicate_wholeduplicate_half几乎需要花费相同的时间来运行。

但是,当我使用nvvp分析内核时,我会在以下意义上针对每个内核获得不同的报告。对于内核duplicate_whole,nvvp警告我在第23行(d = { 2.0f * d.x, 2.0f * d.y };)内核正在执行

Global Load L2 Transaction/Access = 8, Ideal Transaction/Access = 4

我同意我正在加载8个字节的单词。我不明白的是为什么4个字节是理想的字长。特别是,内核之间没有性能差异。

我认为在某些情况下,这种全局存储访问模式可能会导致性能下降。这些是什么?

为什么我没有表现出色?

我希望此编辑澄清了一些不清楚的地方。

++++++++++++++++++++++++++++++++++++++++++++++++ ++++++++++++++++++++++++++++

我将使用一些内核代码来举例说明我的问题,下面将对此进行

template < class data_t >
__global__ void chirp_factors_multiply( std::complex< data_t > const * chirp_factors,
                                        std::complex< data_t > * data,
                                        int M,
                                        int row_length,
                                        int b,
                                        int i_0
                                        )
{
#ifndef CUGALE_MUL_SHUFFLE
    // Output array length:
    int plane_area = row_length * M;
    // Process element:
    int i = blockIdx.x * row_length + threadIdx.x + i_0;
    my_complex< data_t > const chirp_factor = ref_complex( chirp_factors[ i ] );
    my_complex< data_t > datum;
    my_complex< data_t > datum_new;

    for ( int i_b = 0; i_b < b; ++ i_b )
    {
        my_complex< data_t > & ref_datum = ref_complex( data[ i_b * plane_area + i ] );
        datum = ref_datum;
        datum_new.x = datum.x * chirp_factor.x - datum.y * chirp_factor.y;
        datum_new.y = datum.x * chirp_factor.y + datum.y * chirp_factor.x;
        ref_datum = datum_new;
    }
#else
    // Output array length:
    int plane_area = row_length * M;
    // Element to process:
    int i = blockIdx.x * row_length + ( threadIdx.x + i_0 ) / 2;
    my_complex< data_t > const chirp_factor = ref_complex( chirp_factors[ i ] );

    // Real and imaginary part of datum (not respectively for odd threads):
    data_t datum_a;
    data_t datum_b;

    // Even TIDs will read data in regular order, odd TIDs will read data in inverted order:
    int parity = ( threadIdx.x % 2 );
    int shuffle_dir = 1 - 2 * parity;
    int inwarp_tid = threadIdx.x % warpSize;

    for ( int i_b = 0; i_b < b; ++ i_b )
    {
        int data_idx = i_b * plane_area + i;
        datum_a = reinterpret_cast< data_t * >( data + data_idx )[ parity ];
        datum_b = __shfl_sync( 0xFFFFFFFF, datum_a, inwarp_tid + shuffle_dir, warpSize );

        // Even TIDs compute real part, odd TIDs compute imaginary part:
        reinterpret_cast< data_t * >( data + data_idx )[ parity ] = datum_a * chirp_factor.x - shuffle_dir * datum_b * chirp_factor.y;
    }
#endif // #ifndef CUGALE_MUL_SHUFFLE
}

让我们考虑data_t是float的情况,这是受内存带宽限制的。从上面可以看出,内核有两个版本,一个版本的每个线程读/写8个字节(整个复数),另一个版本的每个线程读/写4个字节,然后对结果进行混洗,因此复杂乘积为计算正确。

之所以使用shuffle编写版本,是因为nvvp坚持认为每个线程读取8个字节并不是最好的主意,因为这种内存访问模式效率很低。即使在两个测试的系统(GTX 1050和GTX Titan Xp)中,内存带宽都非常接近理论最大值。

当然,我知道不可能进行任何改进,的确如此:两个内核几乎都在同一时间运行。所以,我的问题是:

为什么nvvp报告读取8个字节的效率不如读取每个线程4个字节的效率?在什么情况下会如此?

请注意,单精度对我来说更重要,但在某些情况下双精度也很有用。有趣的是,在data_t为double的情况下,两个内核版本之间也没有执行时间的差异,即使在这种情况下,内核是受计算限制的,并且shuffle版本比原始版本执行更多的翻转。

注意:内核被应用于row_length * M * b数据集(b个图像with row_length列和M行),chirp_factor数组为{{1 }}。两个内核都运行良好(如果您对此有疑问,可以编辑问题以向您显示对两个版本的调用)。

1 个答案:

答案 0 :(得分:2)

这里的问题与编译器如何处理代码有关。 nvvp只是尽职尽责地报告运行代码时发生的情况。

如果在可执行文件上使用cuobjdump -sass工具,则会发现duplicate_whole例程正在执行两个4字节加载和两个4字节存储。这不是最佳选择,部分原因是每次加载和存储都需要大步前进(每个加载和存储触摸会在内存中交替出现)。

这样做的原因是编译器不知道您的my_complex结构的对齐方式。您的结构在防止编译器生成(合法)8字节负载的情况下使用是合法的。如here所讨论的,我们可以通过通知编译器我们仅打算在CUDA 8字节加载合法(即“自然对齐”)的对齐方案中使用该结构来解决此问题。对结构的修改如下:

template < class T >
struct  __align__(8) my_complex {
   T x;
   T y;
};

对代码进行的更改之后,编译器将为duplicate_whole内核生成8字节的加载,并且您应该会看到与探查器不同的报告。仅在了解含义并愿意与编译器签订合同以确保确实如此时,才应使用这种修饰。如果您执行不寻常的操作(例如异常的指针转换),则可能违反讨价还价的条件并产生机器故障。

您几乎看不到性能差异的原因几乎肯定与CUDA加载/存储行为以及GPU 缓存

有关

进行跨级加载时,GPU仍然会加载整个缓存行,即使(在这种情况下)您只需要为特定的加载操作使用一半的元素(实际元素)。但是,无论如何,您都需要元素的另一半(虚构的元素)。它们将被加载到下一条指令上,由于先前的加载,该指令很可能会命中高速缓存。

在这种情况下,在分步存储中,在一个指令中写入分步元素,而在下一条指令中写入替代元素,最终将使用其中一个缓存作为“合并缓冲区”。在CUDA术语中,这并不是典型意义上的合并。这种合并仅适用于单个指令。但是,高速缓存的“ coalescing缓冲区”行为允许它在该行被写出或逐出之前“积累”对已驻留行的多次写入。这大约等效于“写回”缓存行为。