逐元素矩阵乘法可以通过共享内存获得性能提升吗?

Elementwise matrix multiplication may have performance gain by means of shared memory?

我最近几天才开始使用 Numba 进行 GPU 编程,我已经从博客中的零散信息中学到了一些技术,一些在 C programming guide 中,还有很多来自这里Stack 社区。

为简化起见,我正在尝试改进我在使用常规 Python 代码之前的模拟性能。使用 Numba,我已经提高了我的代码的性能,现在在我的 Geforce GTX 1660TI 上运行速度提高了 45 倍,但现在我正在努力提高一点点,如前所述 Here,我的内核没有'具有良好的内存访问模式。

最近我试图了解在某些内核中使用共享内存来提高性能 post,但我不知道这个示例是否对我有帮助,因为据我了解,它明确利用了共享内存,在我的常规内核中,我通常使用多个矩阵或向量进行元素乘法。

其实我不知道这是否是我应该在这里问的问题,所以如果这里不合适,请原谅我。

我的代码的主要内核之一及其测试实现在下面的代码中

from timeit import default_timer as timer
import numba
from numba import jit, guvectorize, int32, int64, float64, prange
from numba import cuda
import numpy as np
from numpy import *
import math

stream = cuda.stream()

D = 9
nx = 20000
ny = 1000
ly = ny-1
uLB = 0.04

cx = np.array([0, 1,-1, 0, 0, 1,-1, 1,-1],dtype=np.float64);
cy = np.array([0, 0, 0, 1,-1, 1,-1,-1, 1],dtype=np.float64);
c = np.array([cx,cy]);
w = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36],dtype=np.float64);


def inivel(d, x, y):
    return (1-d) * uLB * (1 + 1e-4*sin(y/ly*2*pi))

@cuda.jit
def equilibrium_gpu(rho,u,c,w,feq):
    
    nx2 = rho.shape[0]
    ny2 = rho.shape[1]
    
    cuda.syncthreads()

    j, k = cuda.grid(2)
    if (j < nx2) & (k < ny2):
        for i in range(9):
            feq[i, j, k] = rho[j,k]*w[i] * (1 + (3 * (c[0,i]*u[0,j,k] + c[1,i]*u[1,j,k])) + 0.5*(3 * (c[0,i]*u[0,j,k] + c[1,i]*u[1,j,k]))**2 - (3/2 * (u[0,j,k]**2 + u[1,j,k]**2)))
            
    cuda.syncthreads()

vel = fromfunction(inivel, (2,nx,ny))
rho = np.ones([nx, ny], dtype='float64')
res = np.zeros([D, nx, ny], dtype='float64')
feq = np.zeros((9,nx,ny))

rho_device = cuda.to_device(rho, stream=stream)
u_device = cuda.to_device(vel, stream=stream)
c_device = cuda.to_device(c, stream=stream)
w_device = cuda.to_device(w, stream=stream)
feq_device = cuda.device_array(shape=(D,nx,ny,), dtype=np.float64, stream=stream)

threadsperblock = (16, 16)
blockspergrid_x = math.ceil(nx / threadsperblock[0])
blockspergrid_y = math.ceil(ny / threadsperblock[1])
blockspergrid = (blockspergrid_x, blockspergrid_y)

s = timer()
cuda.synchronize()
equilibrium_gpu[blockspergrid, threadsperblock,stream](rho_device,u_device,c_device,w_device,feq_device)
cuda.synchronize()
gpu_time = timer() - s

print(gpu_time)

我想知道如何通过共享内存或其他方式提高这个内核的性能。

您可以将 w[i]c[1,i] 放在共享内存中,但我怀疑这会快得多,因为全局内存访问应该已经缓存在 L1 中。其他访问似乎没问题。

最大的性能问题来自于使用主流 GPU 计算双精度数,几乎无法有效地计算它们。事实上,Geforce GTX 1660TI 可以实现高达 4.6 TFLOPS 的单精度数字,而只有 0.144 TFLOPS 的双精度数字。这种简单精度计算在此 GPU 上快了大约 32 倍!

请注意,有些 GPU 能够进行非常高效的双精度计算,例如 Nvidia Volta GPU,它们也更昂贵(即使在这些 GPU 上,双精度仍然慢 2 倍)。

除此之外,最好自己展开循环,将一些变量放在寄存器中以免重新计算它们,并且关于生成的代码更喜欢 a*a 而不是 a**2。此外,syncthreads 调用似乎毫无用处(而且成本很高)。


编辑: 我错过了非常低效的 feq[i, j, k] 访问以及 j 应该更好地用于连续维度的事实,因为 grid 似乎 return 值 x, y 而不是 y, x (其中 x 应该是“连续”维度,尽管文档对此确实不清楚)。使用 feq[i, k, j] 连续写入 f(并读取 u 等其他数组)然后转置数组(可能在 GPU 上)可能会好得多。我认为优化的数组转置比使用共享内存在本地转置块更快。或者,您可以重新设计代码,这样根本就不需要换位了。

此示例包含 中建议的大部分修改,除了切换到单精度(这很容易做到)。在我的特定机器 (GTX 960) 上,它似乎 运行 比你的内核快 3 倍。您可能希望更改 nxny 以匹配您正在 运行 的任何测试用例。在我看来,我还调整了围绕内核启动的代码,以便更好地进行基准测试。据我所知,并非所有更改似乎都有意义。例如转换 x**2 -> x*x 似乎并不重要。

from timeit import default_timer as timer
import numba
from numba import jit, guvectorize, int32, int64, float64, prange
from numba import cuda
import numpy as np
from numpy import *
import math

stream = cuda.stream()

D = 9
nx = 4000
ny = 1000
ly = ny-1
uLB = 0.04

cx = np.array([0, 1,-1, 0, 0, 1,-1, 1,-1],dtype=np.float64);
cy = np.array([0, 0, 0, 1,-1, 1,-1,-1, 1],dtype=np.float64);
c = np.array([cx,cy]);
w = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36],dtype=np.float64);


def inivel(d, x, y):
    return (1-d) * uLB * (1 + 1e-4*sin(y/ly*2*pi))

@numba.jit('void(float64[:,:], float64[:,:], float64[:,:], float64[:], float64[:], float64[:], float64[:,:,:])',parallel=True)
def equilibrium_cpu(rho, ux, uy, cx, cy, w, feq ):              # Equilibrium distribution function.
    nx2 = rho.shape[0]
    ny2 = rho.shape[1]

    for i in prange(9):
        for j in prange(0,nx2):
            for k in prange(0,ny2):
                feq[i,j,k] = rho[j,k]*w[i] * (1 + (3 * (cx[i]*ux[j,k] + cy[i]*uy[j,k])) + 0.5*(3 * (cx[i]*ux[j,k] + cy[i]*uy[j,k]))**2 - (3/2 * (ux[j,k]**2 + uy[j,k]**2)))



@cuda.jit
def equilibrium_gpu(rho,u,c,w,feq):

    nx2 = rho.shape[0]
    ny2 = rho.shape[1]

    k,j = cuda.grid(2)
    if (j < nx2) & (k < ny2):
        r = rho[j,k]
        ux = u[0,j,k]
        uy = u[1,j,k]
        for i in range(9):
             cv = (3 * (c[0,i]*ux + c[1,i]*uy))
             feq[i, j, k] = r*w[i] * (1 + cv + 0.5*(cv*cv) - (3/2 * (ux*ux + uy*uy)))

vel = fromfunction(inivel, (2,nx,ny))
rho = np.ones([nx, ny], dtype='float64')
res = np.zeros([D, nx, ny], dtype='float64')
feq = np.zeros((9,nx,ny))

rho_device = cuda.to_device(rho, stream=stream)
u_device = cuda.to_device(vel, stream=stream)
c_device = cuda.to_device(c, stream=stream)
w_device = cuda.to_device(w, stream=stream)
feq_device = cuda.device_array(shape=(D,nx,ny,), dtype=np.float64, stream=stream)

threadsperblock = (32, 32)
blockspergrid_x = math.ceil(ny / threadsperblock[0])
blockspergrid_y = math.ceil(nx / threadsperblock[1])
blockspergrid = (blockspergrid_x, blockspergrid_y)

s = timer()
equilibrium_cpu(rho, vel[0], vel[1], cx, cy, w, feq)

cpu_time = timer() - s

print(cpu_time)
equilibrium_gpu[blockspergrid, threadsperblock,stream](rho_device,u_device,c_device,w_device,feq_device)


cuda.synchronize()
s = timer()
equilibrium_gpu[blockspergrid, threadsperblock,stream](rho_device,u_device,c_device,w_device,feq_device)
cuda.synchronize()
gpu_time = timer() - s

print(gpu_time)

s = timer()
feq_host2 = feq_device.copy_to_host()
print(timer()-s)

gain = cpu_time/gpu_time
print(gain)
print(np.allclose(feq_host2, feq))