编辑:一个新的最小工作示例,用以说明问题和对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_whole
和duplicate_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 }}。两个内核都运行良好(如果您对此有疑问,可以编辑问题以向您显示对两个版本的调用)。
答案 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缓冲区”行为允许它在该行被写出或逐出之前“积累”对已驻留行的多次写入。这大约等效于“写回”缓存行为。