有限差分方法 - 拉普拉斯算子第二部分

news2024/10/8 13:00:21

Finite difference method - Laplacian part 2 — ROCm Blogs (amd.com)

2023年1月4日 作者:Justin Chang, Rajat Arora, Thomas Gibson, Sean Miller, Ossian O’Reilly

在之前的拉普拉斯算子文章中,我们开发了一种基于HIP实现的有限差分模板代码,专门用于拉普拉斯算子。初步实现发现该代码受限于内存带宽,也就是说其运行时间受限于我们移动数据到全局内存和从全局内存中提取数据的速率。此外,目前的内存访问模式需要多次访问全局内存来加载所有数据,因此如果我们缓存更多的数据,执行时间可以减少。我们将性能指标(FOM)定义为_有效内存带宽_,即理论上的内存传输量除以实际执行时间。我们HIP实现的FOM目前达到了单个MI250X GCD峰值的50%[1],但我们的分析表明,如果将实际内存传输量降低到理论值,我们的FOM可以达到峰值的至少71%[1]. 在这篇文章中,我们将介绍两种常见的优化技术,这些技术可以应用于内核,以帮助实现这一目标:

1. 循环分块以显著减少内存加载
2. 重新排序内存访问模式以改善缓存性能

回顾

在上一篇文章中,我们讨论了基于HIP实现拉普拉斯算子的中心有限差分模板。回顾一下,拉普拉斯算子的形式是标量场u(x,y,z)的梯度的散度:

∇⋅∇u=∇²u=∂²u/∂x² + ∂²u/∂y² + ∂²u/∂z²

最初的HIP实现如下所示:

 
template <typename T>
__global__ void laplacian_kernel(T * f, const T * u, int nx, int ny, int nz, T invhx2, T invhy2, T invhz2, T invhxyz2) {

    int i = threadIdx.x + blockIdx.x * blockDim.x;
    int j = threadIdx.y + blockIdx.y * blockDim.y;
    int k = threadIdx.z + blockIdx.z * blockDim.z;

    // Exit if this thread is on the boundary
    if (i == 0 || i >= nx - 1 ||
        j == 0 || j >= ny - 1 ||
        k == 0 || k >= nz - 1)
        return;

    const int slice = nx * ny;
    size_t pos = i + nx * j + slice * k;

    // Compute the result of the stencil operation
    f[pos] = u[pos] * invhxyz2
           + (u[pos - 1]     + u[pos + 1]) * invhx2
           + (u[pos - nx]    + u[pos + nx]) * invhy2
           + (u[pos - slice] + u[pos + slice]) * invhz2;
}

template <typename T>
void laplacian(T *d_f, T *d_u, int nx, int ny, int nz, int BLK_X, int BLK_Y, int BLK_Z, T hx, T hy, T hz) {

    dim3 block(BLK_X, BLK_Y, BLK_Z);
    dim3 grid((nx - 1) / block.x + 1, (ny - 1) / block.y + 1, (nz - 1) / block.z + 1);
    T invhx2 = (T)1./hx/hx;
    T invhy2 = (T)1./hy/hy;
    T invhz2 = (T)1./hz/hz;
    T invhxyz2 = -2. * (invhx2 + invhy2 + invhz2);

    laplacian_kernel<<<grid, block>>>(d_f, d_u, nx, ny, nz, invhx2, invhy2, invhz2, invhxyz2);
}

以及在单个MI250X GCD上的相应性能:

$ ./laplacian_dp_kernel1
Kernel: 1
Precision: double
nx,ny,nz = 512, 512, 512
block sizes = 256, 1, 1
Laplacian kernel took: 2.64172 ms, effective memory bandwidth: 808.148 GB/s

报告的808.148 GB/s 达到了我们在上一篇文章中设定目标性能的69.4%。我们将继续使用`rocprof`来帮助我们评估以下优化的有效性。

在上一篇文章中,我们可以推断出`f`设备数组是高效存储的,因为`WRITE_SIZE`指标与理论值一致。然而,报告的`FETCH_SIZE`几乎是理论值的两倍,因此如果该数据量可以减少,性能可能会有所提升。需要记住的是,从`u`数组加载的七个元素用于更新`f`数组中的每一个条目,但这些`u`元素最多可以被相邻的网格点重用六次。重要的是,从波阵面角度来看,每个`u`条目的加载是一个连续的64个条目块。我们当前的线程块配置(256 × 1 × 1)沿着`x`方向加载四个波的元素,为单个波中的线程提供了缓存和重用`x`方向元素用于`x - 1`和`x + 1`模板计算的机会。然而,沿着`y`和`z`方向加载波必须小心实施以最大化空间局部性并重用相邻波和线程块的值。因此,我们重点通过循环分块优化其中一个方向的加载。 

循环平铺

目前,每个线程仅计算单个网格点的模版。如果让每个线程计算多个网格点的模版会发生什么?这会使每个线程需要更多的加载指令,但由于线程分配的连续性,这些加载更有可能重用缓存的 u 值。这种优化称为循环平铺,将减少启动的线程块数量并增加每个线程计算的模版数量。

在深入优化的拉普拉斯核之前,让我们先通过一个小例子来进一步解释循环平铺的概念和好处:假设我们想执行步幅计算 f[pos] = u[pos - 1] + u[pos] + u[pos + 1]。在不进行平铺的情况下,每个线程需要执行三个加载和一个存储指令。如果我们能让每个线程只加载一个 u 元素并重用已经加载并存储在寄存器中的其他两个 u 元素,这将很理想。换句话说,我们应该尽量减少每个线程的每次存储指令的加载数量。如果我们按某个因子平铺,比如选两个,那么每个线程将执行两次存储,但考虑加载次数:

f[pos]     = u[pos - 1]  + u[pos]     + u[pos + 1];
f[pos + 1] = u[pos]      + u[pos + 1] + u[pos + 2];

注意到 u[pos] 和 u[pos + 1] 出现了两次,这意味着我们只需要加载它们一次。这个观察结果使我们可以重用之前加载的值。为了更清楚地说明这一点,我们可以引入两个变量:

double u0 = u[pos];
double u1 = u[pos + 1];
f[pos]     = u[pos - 1] + u0  + u1;
f[pos + 1] = u0         + u1  + u[pos + 2];

结果,我们显式地将加载指令从 6 次减少到了 4 次(即减少了 33%)。我们现在大约有每次存储 2 次加载。请参阅下表,了解循环平铺因子与加载存储比率的关系。

平铺因子加载次数存储次数 比率

1

3

1

3.00

2

4

2

2.00

4

6

4

1.50

8

10

8

1.25

16

18

16

1.13

请记住,增加平铺因子会增加内核中的寄存器使用,这会减少其占用率。如果编译器耗尽寄存器,则寄存器溢出到全局内存,这可能会对性能产生负面影响。

回到 3D 拉普拉斯核,有三个方向可以进行循环平铺。让我们演示 y 方向的平铺。如下面的图示所示,考虑重用模式:

../../../_images/reuse.png

图 1: 每个线程在 xy 平面上循环平铺的说明。加载和重用的网格点数量取决于平铺宽度。

让我们用 m 表示平铺因子,它是编译时确定的用户定义的宏变量。由于此内核的代码修改比较复杂,因此我们将其分为两个阶段:设置和计算。首先,我们应用以下更改:

内核 1 设置(之前)内核 2 设置(之后)
int j = threadIdx.y + blockIdx.y * blockDim.y;

...

// Exit if this thread is on the boundary
if (i == 0 || i >= nx - 1 ||
    j == 0 || j >= ny - 1 ||
    k == 0 || k >= nz - 1)
    return;

...

dim3 grid((nx - 1) / block.x + 1,
          (ny - 1) / block.y + 1,
          (nz - 1) / block.z + 1);
#define m 1

...

int j = m*(threadIdx.y + blockIdx.y * blockDim.y);

...

// Exit if this thread is on the xz boundary
if (i == 0 || i >= nx - 1 ||
    k == 0 || k >= nz - 1)
    return;

...


dim3 grid((nx - 1) / block.x + 1,
          (ny - 1) / (block.y * m) + 1,
          (nz - 1) / block.z + 1);

内核 2 引入了宏变量 m,用于在 y 方向上划分网格维度。当前设定 m=1 没有平铺效果 - 试验其他值需要重新编译代码。我们去掉了需要在线程退出时与边界重叠的情况。

在我们的下一组代码修改中,我们重点重写平铺因子 m 的 f[pos] = ... 计算。因为我们在 y 方向上平铺,每个线程按 nx 步幅前进。这些修改非常复杂,因此我们将其分为四个步骤:

1. 在主要计算内核中添加一个 for 循环
2. 引入大小为 m 的数组以积累模版点的运行和

3. 将 u 元素加载和 f 元素存储分成单独的循环。
4. 引入一个变量来保存 u 元素以便重用。

下面是应用四个步骤之前和之后的代码片段。我们首先通过在一个for循环中进行我们的模板评估:

Kernel 1计算 - 步骤0(之前)Step 1(之后)
f[pos] = u[pos] * invhxyz2
       + (u[pos - 1]
       +  u[pos + 1]) * invhx2
       + (u[pos - nx]
       +  u[pos + nx]) * invhy2
       + (u[pos - slice]
       +  u[pos + slice]) * invhz2;
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1)
    f[pos + n*nx] = u[pos + n*nx] * invhxyz2
       + (u[pos - 1 + n*nx]
       +  u[pos + 1 + n*nx]) * invhx2
       + (u[pos - nx + n*nx]
       +  u[pos + nx + n*nx]) * invhy2
       + (u[pos - slice + n*nx]
       +  u[pos + slice + n*nx]) * invhz2;

在编译时已知的值`m`的for循环引入具有与循环展开相似的效果,其中编译器将最小化循环的开销。请记住,`m`不能太大,否则编译器会将寄存器溢出到全局内存。

此时,内核在技术上已被平铺,然而,为了最小化负载-存储比,还需要进行一些代码修改。接下来我们创建一个累加数组:

Step 1(之前)Step 2(之后)
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1)
    f[pos + n*nx] = u[pos + n*nx] * invhxyz2
       + (u[pos - 1 + n*nx]
       +  u[pos + 1 + n*nx]) * invhx2
       + (u[pos - nx + n*nx]
       +  u[pos + nx + n*nx]) * invhy2
       + (u[pos - slice + n*nx]
       +  u[pos + slice + n*nx]) * invhz2;
T Lu[m] = {0};
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1) {
    Lu[n] = u[pos + n*nx] * invhxyz2;
    Lu[n] += (u[pos - 1 + n*nx]
          +   u[pos + 1 + n*nx]) * invhx2;
    Lu[n] += (u[pos - nx + n*nx]
          +   u[pos + nx + n*nx]) * invhy2;
    Lu[n] += (u[pos - slice + n*nx]
          +   u[pos + slice + n*nx]) * invhz2;
    f[pos + n*nx] = Lu[n];
  }

累加数组`Lu`暂时保存计算的总和。需要注意的是,我们保持了模板计算的原始顺序——首先加载x方向的模板,然后是y方向的模板,最后是z方向的模板。我们最终将重新审视这个顺序,但接下来的步骤是将加载和存储步骤分开:

Step 2(之前)Step 3(之后)
T Lu[m] = {0};
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1) {
    Lu[n] = u[pos + n*nx] * invhxyz2;
    Lu[n] += (u[pos - 1 + n*nx]
          +   u[pos + 1 + n*nx]) * invhx2;
    Lu[n] += (u[pos - nx + n*nx]
          +   u[pos + nx + n*nx]) * invhy2;
    Lu[n] += (u[pos - slice + n*nx]
          +  u[pos + slice + n*nx]) * invhz2;
    f[pos + n*nx] = Lu[n];
  }
T Lu[m] = {0};
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1) {
    Lu[n] = u[pos + n*nx] * invhxyz2;
    Lu[n] += (u[pos - 1 + n*nx]
          +   u[pos + 1 + n*nx]) * invhx2;
    Lu[n] += (u[pos - nx + n*nx]
          +   u[pos + nx + n*nx]) * invhy2;
    Lu[n] += (u[pos - slice + n*nx]
          +   u[pos + slice + n*nx]) * invhz2;
  }
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1)
    f[pos + n*nx] = Lu[n];

将加载和存储分成独立的for循环使所有`Lu`元素能够在写入`f`之前同时累积模板计算。第四个也是最后一个改变则是通过重用加载的`u`元素跨不同模板来显式地删除加载指令。每次迭代的`n`仍然会加载`x`方向和`z`方向的模板以计算`Lu[n]`,但现在可以潜在地重用`u`元素来计算属于`Lu[n-1]`和/或`Lu[n+1]`的`y`方向模板:

Step 3(之前)Kernel 2计算 - 步骤4(之后)
T Lu[m] = {0};
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1) {
    Lu[n] = u[pos + n*nx] * invhxyz2;
    Lu[n] += (u[pos - 1 + n*nx]
          +   u[pos + 1 + n*nx]) * invhx2;
    Lu[n] += (u[pos - nx + n*nx]
          +   u[pos + nx + n*nx]) * invhy2;
    Lu[n] += (u[pos - slice + n*nx]
          +   u[pos + slice + n*nx]) * invhz2;
  }
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1)
    f[pos + n*nx] = Lu[n];
T center;
T Lu[m] = {0};
for (int n = 0; n < m; n++) {
  center = u[pos + n*nx];
  Lu[n] = center * invhxyz2
        + (u[pos - 1 + n*nx]
        +  u[pos + 1 + n*nx]) * invhx2;
  if (n == 0)
    Lu[n] += u[pos - nx + n*nx] * invhy2;
  if (n > 0)
    Lu[n-1] += center * invhy2;
  if (n < m - 1)
    Lu[n+1] += center * invhy2;
  if (n == m - 1)
    Lu[n] += u[pos + nx + n*nx] * invhy2;
  Lu[n] += (u[pos - slice + n*nx]
        +   u[pos + slice + n*nx]) * invhz2;
}
for (int n = 0; n < m; n++)
  if (j + n > 0 && j + n < ny - 1)
    f[pos + n*nx] = Lu[n];

下面是捕获所有上述代码修改的完整kernel 2实施:

// Tiling factor
#define m 1
template <typename T>
__global__ void laplacian_kernel(T * f, const T * u, int nx, int ny, int nz, T invhx2, T invhy2, T invhz2, T invhxyz2) {

    int i = threadIdx.x + blockIdx.x * blockDim.x;
    int j = m*(threadIdx.y + blockIdx.y * blockDim.y);
    int k = threadIdx.z + blockIdx.z * blockDim.z;

    // Exit if this thread is on the xz boundary
    if (i == 0 || i >= nx - 1 ||
        k == 0 || k >= nz - 1)
        return;

    const int slice = nx * ny;
    size_t pos = i + nx * j + slice * k;

    // Each thread accumulates m stencils in the y direction
    T Lu[m] = {0};

    // Scalar for reusable data
    T center;

    // Loop tiling
    for (int n = 0; n < m; n++) {
        center = u[pos + n*nx]; // store for reuse

        // x direction
        Lu[n] += center *invhxyz2
              + (u[pos - 1 + n*nx] + u[pos + 1 + n*nx]) * invhx2;

        // y - 1, first n
        if (n == 0) Lu[n] += u[pos - nx + n*nx] * invhy2;

        // reuse: y + 1 for prev n
        if (n > 0) Lu[n-1] += center * invhy2;

        // reuse: y - 1 for next n
        if (n < m - 1) Lu[n+1] += center * invhy2;

        // y + 1, last n
        if (n == m - 1) Lu[n] += u[pos + nx + n*nx] * invhy2;

        // z - 1 and z + 1
        Lu[n] += (u[pos - slice + n*nx] + u[pos + slice + n*nx]) * invhz2;
    }

    // Store only if thread is inside y boundary
    for (int n = 0; n < m; n++)
      if (j + n > 0 && j + n < ny - 1)
        f[pos + n*nx] = Lu[n];
 }

template <typename T>
void laplacian(T *d_f, T *d_u, int nx, int ny, int nz, int BLK_X, int BLK_Y, int BLK_Z, T hx, T hy, T hz) {

    dim3 block(BLK_X, BLK_Y, BLK_Z);
    dim3 grid((nx - 1) / block.x + 1, (ny - 1) / (block.y * m) + 1, (nz - 1) / block.z + 1);
    T invhx2 = (T)1./hx/hx;
    T invhy2 = (T)1./hy/hy;
    T invhz2 = (T)1./hz/hz;
    T invhxyz2 = -2. * (invhx2 + invhy2 + invhz2);

    laplacian_kernel<<<grid, block>>>(d_f, d_u, nx, ny, nz, invhx2, invhy2, invhz2, invhxyz2);
}

需要注意的是,这个内核目前写成了`ny`必须为`block.y * m`的整数倍。让我们实验一些与我们选择的问题大小兼容的`m`值,看看循环平铺是否有任何好处。

Speedup

% of target

Kernel 1 - Baseline

1.00

69.4%

Kernel 2 - Loop tiling m=1

1.00

69.4%

Kernel 2 - Loop tiling m=2

0.98

68.3%

Kernel 2 - Loop tiling m=4

0.94

65.5%

Kernel 2 - Loop tiling m=8

0.92

64.0%

Kernel 2 - Loop tiling m=16

0.29

20.1%

令人奇怪的是,所测试的 m 都没有带来显著的加速。事实上,增加平铺因子反而加剧了性能问题。让我们检查一下 FETCH_SIZE 和 L2CacheHit 指标以获得进一步的见解:

FETCH_SIZE (GB)

Fetch efficiency (%)

L2CacheHit (%)

Theoretical

1.074

-

-

Kernel 1 - Baseline

2.014

53.3

65.0

Kernel 2 - Loop tiling m=1

2.014

53.3

65.0

Kernel 2 - Loop tiling m=2

1.848

58.1

60.5

Kernel 2 - Loop tiling m=4

1.880

57.1

57.0

Kernel 2 - Loop tiling m=8

1.820

59.0

56.0

Kernel 2 - Loop tiling m=16

5.637

19.1

40.9

从表中可以看出,取回效率只是略有提高,而 L2 缓存命中率显著下降。我们怀疑原因是累加步骤遵循了与初始内核相同的访问模式。也就是说,我们首先计算 x 方向的模板,然后计算可能可重用的 y - 1 和 y + 1 模板,最后是 z - 1 和 z + 1 模板。从内存地址的角度来看,读访问模式前后跳跃,这可能产生了一些问题,如下所述并解决。

重新排序读取访问模式

优化后,没有显著的加速效果。虽然增加块因子减少了加载与存储的比率,但这不一定会直接减少全局数据的传输量。要减少`FETCH_SIZE`,需减少L2缓存和全局内存之间的数据传输。随着加载与存储比率的增加,发送到L1缓存的读取请求数量应减少,同样适用于L2(假设L1缓存命中率不变)。由于我们观察到`FETCH_SIZE`保持不变并且`L2CacheHit`降低,说明优化减少了对L2缓存的压力(发送给它的请求减少),但未能改善从全局内存加载到L2缓存的数据重用。为了理解之前的内核为何未能实现L2数据重用的最优效果,让我们在`m=2`的情况下可视化3D模板及其读取访问模式:

../../../_images/5x5-stencil_pattern.png

图2:块因子为`m=2`时的三维有限差分模板。黑色数字代表Kernel 2中每个线程访问`u`元素的顺序。

图3:Kernel 2中单个线程对数组`u`的内存访问模式,块因子为`m=2`。数字和黑色箭头对应线程访问`u`元素的顺序。`n=0`和`n=1`行表示计算模板时所需的`u`元素。第一个访问的元素(`u[pos]`)在`n=0`迭代中加载,并在`n=1`迭代中重用作为`y-1`元素。同样,第七次访问的元素(`u[pos+nx]`)在`n=1`迭代中加载,并在`n=0`迭代中重用作为`y+1`元素。

我们马上看到一个问题。线程经常需要在`u`数组的内存空间内“向后”跳转。在访问每个网格点的`z+1`元素后,线程需要“向后”跳转以访问下一个`n`迭代的`x`方向元素。在内存地址中频繁地来回访问`u`元素可能会提前将可重用数据从缓存中逐出。我们更希望重新排列内核中的指令,只使用一个方向,即按升序访问内存地址:

../../../_images/5x5-stencil_pattern2.png

图4:块因子为`m=2`的三维有限差分模板。黑色数字代表提议的内核中每个线程访问`u`元素的顺序。

图5:提议中的单个线程对数组`u`的内存访问模式,块因子为`m=2`。数字和黑色箭头对应线程访问`u`元素的顺序。`n=0`和`n=1`行表示计算模板时所需的`u`元素。第5次访问的元素(`u[pos]`)在`n=0`迭代中加载,并在`n=1`迭代中重用作为`y-1`元素。同样,第8次访问的元素(`u[pos + nx]`)在`n=1`迭代中加载,并在`n=0`迭代中重用作为`y+1`元素。

在这种新方法下,我们首先访问所有`z-1`元素,然后是`n=0`迭代的一个`y-1`元素,接着是所有`x`方向的元素,`n=m-1`迭代的一个`y+1`元素,最后访问所有`z+1`元素。现在,每个线程按升序内存地址访问所有所需的`u`元素。这需要内核的大幅重写,因此我们首先呈现完整实现:

// 块因子
#define m 1
template <typename T>
__global__ void laplacian_kernel(T * f, const T * u, int nx, int ny, int nz, T invhx2, T invhy2, T invhz2, T invhxyz2) {

    int i = threadIdx.x + blockIdx.x * blockDim.x;
    int j = m*(threadIdx.y + blockIdx.y * blockDim.y);
    int k = threadIdx.z + blockIdx.z * blockDim.z;

    // 如果线程位于xz边界则退出
    if (i == 0 || i >= nx - 1 ||
        k == 0 || k >= nz - 1)
        return;

    const int slice = nx * ny;
    size_t pos = i + nx * j + slice * k;

    // 每个线程在y方向累积m个模板
    T Lu[m] = {0};

    // 用于可重用数据的标量
    T center;

    // z-1,循环分块
    for (int n = 0; n < m; n++)
        Lu[n] += u[pos - slice + n*nx] * invhz2;

    // y - 1
    Lu[0]   += j > 0 ? u[pos - 1*nx] * invhy2 : 0; // 边界检查

    //  x方向,循环分块
    for (int n = 0; n < m; n++) {
        // x - 1
        Lu[n] += u[pos - 1 + n*nx] * invhx2;

        // x
        center = u[pos + n*nx]; // 存储以供重用
        Lu[n] += center * invhxyz2;

        // x + 1
        Lu[n] += u[pos + 1 + n*nx] * invhx2;

        // 重用: 前一个n的y+1
        if (n > 0) Lu[n-1] += center * invhy2;

        // 重用: 下一个n的y-1
        if (n < m - 1) Lu[n+1] += center * invhy2;
    }

    // y + 1
    Lu[m-1]  += j < ny - m ? u[pos + m*nx] * invhy2 : 0; // bound check

    // z+1,循环分块
    for (int n = 0; n < m; n++)
      Lu[n] += u[pos + slice + n*nx] * invhz2;

    // 只有在线程位于y边界内时才存储结果
    for (int n = 0; n < m; n++)
      if (n + j > 0 && n + j < ny - 1)
        f[pos + n*nx] = Lu[n];
}

template <typename T>
void laplacian(T *d_f, T *d_u, int nx, int ny, int nz, int BLK_X, int BLK_Y, int BLK_Z, T hx, T hy, T hz) {

    dim3 block(BLK_X, BLK_Y, BLK_Z);
    dim3 grid((nx - 1) / block.x + 1, (ny - 1) / (block.y * m) + 1, (nz - 1) / block.z + 1);
    T invhx2 = (T)1./hx/hx;
    T invhy2 = (T)1./hy/hy;
    T invhz2 = (T)1./hz/hz;
    T invhxyz2 = -2. * (invhx2 + invhy2 + invhz2);

    laplacian_kernel<<<grid, block>>>(d_f, d_u, nx, ny, nz, invhx2, invhy2, invhz2, invhxyz2);
} 

接下来让我们深入探讨内核中的计算步骤。首先,我们访问所有`z - 1`网格点,然后是一个`y - 1`:

    // z-1,循环分块
    for (int n = 0; n < m; n++)
        Lu[n] += u[pos - slice + n*nx] * invhz2;

    // y - 1
    Lu[0]   += j > 0 ? u[pos - 1*nx] * invhy2 : 0; // bound check

请注意,引入了条件运算符,以确保只有在`n = 0`网格点不限于`y`边界时才计算`y - 1`模板。`z - 1`和`y - 1`元素都不会在线程级别重用。

接下来,线程计算`x`方向上的模板:

    // x方向,循环分块
    for (int n = 0; n < m; n++) {
        // x - 1
        Lu[n] += u[pos - 1 + n*nx] * invhx2;

        // x
        center = u[pos + n*nx]; // 存储以供重用
        Lu[n] += center * invhxyz2;

        // x + 1
        Lu[n] += u[pos + 1 + n*nx] * invhx2;

        // 重用: 前一个n的y + 1
        if (n > 0) Lu[n-1] += center * invhy2;

        // 重用: 下一个n的y - 1
        if (n < m - 1) Lu[n+1] += center * invhy2;
    }

同样,`x - 1`和`x + 1`点不会在线程级别重用,但中心元素`u[pos + n*nx]`最多可以重用两次,就像之前的内核一样。

然后,我们加载最终的`y + 1`点和所有`z + 1`点:

    // y + 1
    Lu[m-1]  += j < ny - m ? u[pos + m*nx] * invhy2 : 0; // bound check

    // z + 1,循环分块
    for (int n = 0; n < m; n++)
      Lu[n] += u[pos + slice + n*nx] * invhz2;

再次使用了条件运算符,以确保仅在`n = m - 1`网格点不位于`y`边界时才计算`y + 1`模板。

最后,仅在线程位于`y`边界内时才将结果写回内存:

    // 只有线程在y边界内时才存储结果
    for (int n = 0; n < m; n++)
      if (n + j > 0 && n + j < ny - 1)
        f[pos + n*nx] = Lu[n];

让我们现在使用相同的块因子进行实验,看看这种重新排序是否产生影响:

速度提升目标百分比
Kernel 1 - 基线

1.00

69.4%

Kernel 2 - 循环分块 m=1

1.00

69.4%

Kernel 2 - 循环分块 m=2

0.98

68.3%

Kernel 2 - 循环分块 m=4

0.94

65.5%

Kernel 2 - 循环分块 m=8

0.92

64.0%

Kernel 2 - 循环分块 m=16

0.29

20.1%

Kernel 3 - 重新排序加载 m=1

1.20

82.9%

Kernel 3 - 重新排序加载 m=2

1.28

88.9%

Kernel 3 - 重新排序加载 m=4

1.34

93.1%

Kernel 3 - 重新排序加载 m=8

1.37

94.8%

Kernel 3 - 重新排序加载 m=16

0.42

29.4%

即使在`m=1`的情况下,重新排序`u`元素的访问模式已经显著提升了性能。每个`m`的增量提升符合预期。让我们查看新内核的`rocprof`指标:

FETCH_SIZE (GB)

提取效率 (%)

L2CacheHit (%)

理论值

1.074

-

-

Kernel 1 - 基线

2.014

53.3

65.0

Kernel 2 - 循环分块 m=1

2.014

53.3

65.0

Kernel 2 - 循环分块 m=2

1.848

58.1

60.5

Kernel 2 -循环分块 m=4

1.880

57.1

57.0

Kernel 2 - 循环分块 m=8

1.820

59.0

56.0

Kernel 2 - 循环分块 m=16

5.637

19.1

40.9

Kernel 3 - 重新排序加载 m=1

1.347

79.7

72.0

Kernel 3 - 重新排序加载 m=2

1.166

92.1

70.6

Kernel 3 - 重新排序加载 m=4

1.107

97.0

68.8

Kernel 3 - 重新排序加载 m=8

1.080

99.4

67.7

Kernel 3 - 重新排序加载 m=16

3.915

27.4

44.5

FETCH_SIZE这一指标显著减少,使我们接近理论极限。`L2CacheHit`命中率不仅提高了,而且超出了我们从基线内核原本得到的值。不过,当`m=16`时,我们观察到了缓存命中率的大幅下降以及取数大小的显著增加。对于所选问题,`m=8`的内核3是我们目前最好的内核,达到了目标有效内存带宽的近95%和超过99%的取数效率。

总结

结合两种优化后,`FETCH_SIZE`减少了近2倍。这表明我们的HIP内核可以为特定网格大小有效地加载数据。要实现这一点,我们首先通过循环分块显式计算多个模板,减少了每个存储指令的加载次数。然而,最初的实现并未提高性能。为了解决这一问题,我们重新排序了内存访问模式,以提高L2缓存命中率。现在的问题是我们是否“完成”了针对Laplacian的有限差分方法的初始HIP实现的优化。我们必须先解决以下几个悬而未决的问题:

1. 是否还有进一步提高性能的空间?我们已经优化了L2缓存与全局内存之间的数据移动,因此我们必须在其他领域寻找性能提升的机会,例如隐藏延迟。
2. 为什么`m=16`的性能显著下降?无论是否重新排序内存访问都会发生这种情况。或许解决潜在问题可以帮助我们更接近目标?
3. 其他架构和问题规模如何影响块因子的选择?到目前为止,我们的所有优化都针对单个MI250X GCD和问题规模为`nx,ny,nz = 512, 512, 512`。

本系列的下一篇文章将回答这些悬而未决的问题。

附带代码示例

如果您有任何问题或意见,请在GitHub上联系我们 讨论区


[1](1,2)

测试使用ROCm版本5.3.0-63进行。基准测试结果并非验证的性能数据,仅用来展示代码修改的相对性能改进。实际性能结果取决于多个因素,包括系统配置和环境设置,结果的可重复性不能得到保证。 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2196399.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

springboot车位预约小程序-计算机毕业设计源码42655

摘要 随着社会发展和人口增加&#xff0c;城市交通压力越来越大&#xff0c;停车位资源的分配和管理成为一个重要问题。传统的停车位和车位预约管理方式存在信息不对称、效率低下等问题&#xff0c;给用户带来不便。而基于微信小程序的车位预约系统可以通过智能化、数字化、便…

布尔莎公式推导

问题的提出 原始的围绕x轴、y轴、z轴进行旋转矩阵的公式为 但是最近需要将船体坐标系转换到相应的世界坐标系之中&#xff0c;在查看相关论文时&#xff0c;看到一个三维点公式转换模型 这里的旋转矩阵为&#xff0c;和我之前见到的旋转矩阵是不一样的。我一开始先是看到的202…

计算机网络-------重传、TCP流量控制、拥塞控制

重传、滑动窗口、流量控制、拥塞避免 重传机制 超时重传 发送方在发送数据时会启动一个定时器&#xff0c;当超过指定的时间之后&#xff0c;还没接收到接收方的ACK确认应答报文&#xff0c;就会重传该数据 快重传 当发送方收到接收方三个连续的ack之后说明发送方发送的报…

蓝牙模块(BT04/HC05)

目录 一、介绍 二、模块原理 1.原理图与外形尺寸 2.引脚描述 3.蓝牙模块基础AT指令介绍 三、程序设计 usart3.h文件 usart3.c文件 四、实验效果 五、资料获取 项目分享 一、介绍 BT04A是一款蓝牙低功耗&#xff08;Bluetooth Low Energy, BLE&#xff09;模块&…

华为OD机试 - 奖牌榜排名(Python/JS/C/C++ 2024 E卷 100分)

华为OD机试 2024E卷题库疯狂收录中&#xff0c;刷题点这里 专栏导读 本专栏收录于《华为OD机试真题&#xff08;Python/JS/C/C&#xff09;》。 刷的越多&#xff0c;抽中的概率越大&#xff0c;私信哪吒&#xff0c;备注华为OD&#xff0c;加入华为OD刷题交流群&#xff0c;…

Python面向对象编程:属性和方法②

文章目录 一、什么是属性和方法1.1 属性1.2 方法 二、定义和使用属性2.1 定义实例属性2.2 访问和修改实例属性2.3 定义类属性2.4 访问和修改类属性 三、定义和使用方法3.1 定义实例方法3.2 调用实例方法3.3 定义类方法3.4 调用类方法3.5 定义静态方法3.6 调用静态方法 四、综合…

ChatGPT背景下,高职人工智能技术应用专业的人才培养

一、引言 ChatGPT&#xff0c;即聊天生成预训练变换器&#xff0c;由美国OpenAI公司开发&#xff0c;自2022年11月首次亮相以来&#xff0c;已成为人工智能领域的一个标志性成就。这款聊天机器人利用先进的人工智能技术&#xff0c;处理自然语言&#xff0c;能够精准把握用户的…

【实战教程】SpringBoot全面指南:快速上手到项目实战(SpringBoot)

文章目录 【实战教程】SpringBoot全面指南&#xff1a;快速上手到项目实战(SpringBoot)1. SpringBoot介绍1.1 SpringBoot简介1.2系统要求1.3 SpringBoot和SpringMVC区别1.4 SpringBoot和SpringCloud区别 2.快速入门3. Web开发3.1 静态资源访问3.2 渲染Web页面3.3 YML与Properti…

ctf.bugku - 本地管理员

题目来源&#xff1a;本地管理员 - Bugku CTF 访问页面 页面的最后返回一个字符串&#xff1b; 结尾 应该是base64 编码&#xff1b; 解码得到 test123 同时&#xff0c;提示信息还有 IP禁止访问&#xff0c;本地管理员登陆&#xff1b; 所以&#xff0c;请求头添加&#x…

“欢迎”相关英语表达柯桥成人商务英语口语学习到蓝天广场

1.某地的欢迎标语 说到欢迎&#xff0c;小编想起了江苏的欢迎标语。 这则标语把“江苏欢迎您”&#xff0c;翻译成了“Jiangsu welcomes you”。 不少小伙伴都觉得这样翻译不对&#xff0c;“欢迎您来某某地方”&#xff0c;应该翻译成“Welcome to XX”。 但其实&#xff0c;一…

超声波气象监测站的工作原理

TH-CQX5超声波气象监测站&#xff0c;顾名思义&#xff0c;是一种通过超声波技术实现气象数据监测的设备。这种监测站的设计理念充分利用了超声波在空气中传播的特性&#xff0c;能够高效、准确地测量风速、风向、温度、湿度等气象要素。超声波气象监测站的构造简洁而高效&…

华为OD机试 - 银行插队 - 队列(Python/JS/C/C++ 2024 E卷 100分)

华为OD机试 2024E卷题库疯狂收录中&#xff0c;刷题点这里 专栏导读 本专栏收录于《华为OD机试真题&#xff08;Python/JS/C/C&#xff09;》。 刷的越多&#xff0c;抽中的概率越大&#xff0c;私信哪吒&#xff0c;备注华为OD&#xff0c;加入华为OD刷题交流群&#xff0c;…

前端vue-安装pinia,它和vuex的区别

创建一个store的目录&#xff0c;任意一个js文件&#xff0c;再导入pinia&#xff0c;再定义

想走?可以!先买票——迭代器模式

文章目录 想走&#xff1f;可以&#xff01;先买票——迭代器模式乘车买票&#xff0c;不管你是谁&#xff01;迭代器模式迭代器实现Java的迭代器实现迭代高手 想走&#xff1f;可以&#xff01;先买票——迭代器模式 乘车买票&#xff0c;不管你是谁&#xff01; 时间&#…

【2024版】最新kali linux入门及常用简单工具介绍(非常详细)零基础入门到精通,收藏这一篇就够了_kalilinux

一、介绍 kali Linux Kali Linux 是一个基于 Debian 的 Linux 发行版&#xff0c;主要用于数字取证和渗透测试。它预装了大量的安全审计和渗透测试工具&#xff0c;被广泛应用于网络安全领域。 &#xff08;一&#xff09;特点 工具丰富&#xff1a;集成了数百种用于渗透测试…

越差越好?为什么简单反而赢了,这背后究竟有什么秘诀?

你有没有发现,软件界里那些最成功的产品,往往并不是最复杂、最强大的?我们用的很多东西,看起来功能普通,甚至有些粗糙,但就是这样简陋的设计,反而成了市场上的赢家。 也许你玩过Flappy Bird这个游戏:它的设计非常简单,玩家只需要点击屏幕让小鸟飞行,避开管道障碍。游…

知名开发工具RubyMine全新发布v2024.2——增加浏览器保护的代码洞察

RubyMine 是一个为Ruby 和 Rails开发者准备的 IDE&#xff0c;其带有所有开发者必须的功能&#xff0c;并将之紧密集成于便捷的开发环境中。 立即获取RubyMine v2024.2正式版 具体更新详情如下&#xff1a; Rails 对Kamal配置文件的补全 RubyMine现在为 Kamal 配置文件提供…

代码随想录算法训练营Day28 | 39. 组合总和、40.组合总和Ⅱ、131.分割回文串

目录 39. 组合总和 40.组合总和Ⅱ 131.分割回文串 39. 组合总和 题目 39. 组合总和 - 力扣&#xff08;LeetCode&#xff09; 给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target &#xff0c;找出 candidates 中可以使数字和为目标数 target 的 所有 不…

Pytorch实现CNN实验

一、实验要求 用 python 的 Pytorch模块实现卷积神经网络。网络结构为一个输入层、两个卷积层、一个全连接层、一个输出层。 二、实验目的 实现一个包含卷积层、池化层和全连接层的卷积神经网了解如何在训练数据集上使用反向传播算法和Adam优化算法训练神经网络。加深对卷积…

国外电商系统开发-运维系统文件上传-高级上传

如果您要上传文件到10台服务器中&#xff0c;有3台服务器的路径不是一样的&#xff0c;那么在这种情况下您就可以使用本功能&#xff0c;单独执行不一样的路径 点击【高级】上传