【问题标题】:Why are CUDA GPU matrix multiplies slower than numpy? How is numpy so fast?为什么 CUDA GPU 矩阵乘法比 numpy 慢? numpy 怎么这么快?
【发布时间】:2021-08-12 08:58:33
【问题描述】:

我正在发现 numba 的 cuda 扩展,并查看了 CUDA 上矩阵乘法的示例实现。代码在numba's web site

然后我用我认为不太理想的实现对其进行了基准测试:numpy 的 dot 函数,将两个 1024x1024 矩阵相乘(使用 randn(1024,1024) 生成)

结果:

  • CUDA 每次乘法 40 毫秒,
  • numpy 每次乘法 5 毫秒。

如果numpy的算法是朴素矩阵乘法,那么它应该需要1024^3~1e9的乘法和加法。这是每 5ms/1e9 = 5 皮秒一次操作的平均吞吐量。我的 CPU 运行频率约为 3.4 GHz,因此每个周期需要 300 皮秒。

所以这是我的问题:numpy 的矩阵乘法如何比简单的矩阵乘法快 60 倍?

我听说过 Strassen 的算法,它的复杂度约为 N^2.8,因此每次乘法和加法需要 20 皮秒。仍然比 CPU 的速度快 30 倍。

编辑:

  1. cuda 方法的定义
from numba import cuda, float32

TPB = 16

@cuda.jit()
def fast_matmul(A, B, C):
    # Define an array in the shared memory
    # The size and type of the arrays must be known at compile time
    sA = cuda.shared.array(shape=(TPB, TPB), dtype=float32)
    sB = cuda.shared.array(shape=(TPB, TPB), dtype=float32)

    x, y = cuda.grid(2)

    tx = cuda.threadIdx.x
    ty = cuda.threadIdx.y
    bpg = cuda.gridDim.x    # blocks per grid

    if x >= C.shape[0] and y >= C.shape[1]:
        # Quit if (x, y) is outside of valid C boundary
        return

    # Each thread computes one element in the result matrix.
    # The dot product is chunked into dot products of TPB-long vectors.
    tmp = 0.
    for i in range(bpg):
        # Preload data into shared memory
        sA[tx, ty] = A[x, ty + i * TPB]
        sB[tx, ty] = B[tx + i * TPB, y]

        # Wait until all threads finish preloading
        cuda.syncthreads()

        # Computes partial product on the shared memory
        for j in range(TPB):
            tmp += sA[tx, j] * sB[j, ty]

        # Wait until all threads finish computing
        cuda.syncthreads()

    C[x, y] = tmp
  1. Cuda 调用
N=1024
a=randn(N,N).astype(np.float32)
b=a.T.copy()
c=zeros((N,N),dtype=np.float32)

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

fast_matmul[blockspergrid, threadsperblock](a,b,c) # takes 40ms

同时,使用 numpy:

c=a.dot(b) # takes 5ms

我认为吞吐量不是瓶颈,因为矩阵的大小为数百万,所需的周期为数十亿。

正如一位评论者所问,这些数组是 32 位浮点数。

编辑 2:

我了解 3GHz CPU 无法在 5ps 内执行单个任务,所以显然我的意思是平均吞吐量。由于吞吐量比假设每个周期 1 次乘法和加法的估计值好 30 倍,这意味着实现被严重优化,使用

  • 向量运算(我认为是 SSE 或 AVX)
  • 也可能在多个内核/CPU 上进行操作。

对于 CUDA,我测试的基本类型是单精度浮点数,事先假设它们的最佳位置是单精度或半精度浮点数。

  • CUDA 函数的编译时间没有实质性影响。基准测试宏 (%%timeit) 对此并不敏感。然而,
  • 我认为主内存和 GPU 内存之间的传输不会显着影响性能数据,因为传输的大小远小于计算数量(小几个数量级)。
  • 我尝试通过预先将阵列传输到 CUDA 设备来验证这一点,然后……哇哦,计算时间从 40 毫秒下降到 144 微秒。所以内存设备传输是大量昂贵的。感谢@talonmies 指出这一点。

但对我来说,最重要的信息是 CPU 的编译现在可以执行极其激进的优化(向量操作、多线程),在某些情况下,即使对于计算过程,它也很难用 GPU 击败它有一个非常规则的模式。即使您希望每个输入样本执行 1000 次左右的计算,与设备之间的传输也可能非常昂贵。

最后但同样重要的是,我要感谢@user2640045 他/她的基准和对数插值,这似乎表明计算在 O(N^3) 中,因此 numpy 似乎使用了最简单的矩阵乘积实施。

【问题讨论】:

  • 您的时间安排无疑是错误的。在没有看到代码的情况下,您的 Numba 执行时间几乎肯定包含编译和主机设备数据传输。而且您对数据的有限描述表明您使用的是双精度,这可能会使 GPU 处于巨大的吞吐量劣势,具体取决于您使用的硬件
  • 我们不将 GPU 用于一切的原因是因为在某些情况下它并不是最成功的。 talonmies 提到主机设备数据传输,这在许多 GPU 调用中往往是一个相当大的瓶颈,因为对于小数据,启动 GPU,放入数据,取出数据等可能比数学慢数百倍.
  • numpy 最肯定不使用幼稚的 matmul 实现,并且可能比 Strassen 更快(我前段时间读过这个,但不记得详细使用了哪种算法,但源代码应该可用),此外它将使用 SIMD 指令集,并且可能同时使用多个线程
  • @geebert:当前的答案表明它可以按 N^3 进行缩放,因此它可能没有使用 Strassen。所以可能算法上很简单,但在实现方面显然在 SIMD 和缓存阻塞方面进行了高度优化,并且可能还可以并行化到多个内核。 (这将使某些“5 皮秒”计算无效,这已经很奇怪,因为它们没有序列化,而是在 2x 256 位 SIMD 流水线 FMA 单元中进行的多个操作,假设是现代 x86)。同意“天真”不是一个很好的描述。也许是“小心使用蛮力”。
  • 好的,现在我们看到了你的代码,我的假设是正确的。顾名思义,即时编译意味着函数调用包括编译以及与在设备上获取代码、为 GPU 分配内存以及将数据复制到 GPU 以及从 GPU 复制数据相关的所有 API 开销。使用预分配的 GPU 缓冲区运行代码并为 第二次调用 计时。您将观察到显着差异。

标签: python numpy cuda benchmarking numba


【解决方案1】:

为什么 CUDA GPU 矩阵乘法比 numpy 慢?

真正简短的回答是他们可能不是。

numpy 怎么这么快?

因为它通常在底层使用最先进的平台优化(通常是多线程)BLAS 实现来进行 GEMM 操作。

据我所知,您对为什么要测量所测量的内容的困惑源于对 Numba 的工作原理、numpy 的工作原理以及 GPU 和 CPU 的工作原理的一些误解。让我们对您的代码进行轻微修改,用于测量时间并针对您正在研究的 gemm 问题转换为 GFLOP/s。它运行四个案例,每个案例 10 次:

  1. 您的原始 Numba 内核案例在主机上具有主机源和目标数组(因此在执行时间中会产生设备内存分配、设备到主机和主机到设备内存分配惩罚)。
  2. Numba 内核的原始案例在设备上具有主机源和目标数组(因此无需分配或复制)
  3. 问题在使用 numpy 的主机上运行(在后台使用最先进的主机 BLAS SGEMM 实现)
  4. 问题在设备上使用具有源和目标的 cupy 运行(在后台使用最先进的 GPU BLAS SGEMM 实现)

看起来像这样:

from numba import cuda, float32
import numpy as np
import time
import math
import cupy

TPB = 16

@cuda.jit()
def fast_matmul(A, B, C):
    # Define an array in the shared memory
    # The size and type of the arrays must be known at compile time
    sA = cuda.shared.array(shape=(TPB, TPB), dtype=float32)
    sB = cuda.shared.array(shape=(TPB, TPB), dtype=float32)

    x, y = cuda.grid(2)

    tx = cuda.threadIdx.x
    ty = cuda.threadIdx.y
    bpg = cuda.gridDim.x    # blocks per grid

    if x >= C.shape[0] and y >= C.shape[1]:
        # Quit if (x, y) is outside of valid C boundary
        return

    # Each thread computes one element in the result matrix.
    # The dot product is chunked into dot products of TPB-long vectors.
    tmp = 0.
    for i in range(bpg):
        # Preload data into shared memory
        sA[tx, ty] = A[x, ty + i * TPB]
        sB[tx, ty] = B[tx + i * TPB, y]

        # Wait until all threads finish preloading
        cuda.syncthreads()

        # Computes partial product on the shared memory
        for j in range(TPB):
            tmp += sA[tx, j] * sB[j, ty]

        # Wait until all threads finish computing
        cuda.syncthreads()

    C[x, y] = tmp

N=1024
a=np.random.randn(N,N).astype(np.float32)
b=a.T.copy()
c=np.zeros((N,N),dtype=np.float32)

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

a_d=cuda.to_device(a)
b_d=cuda.to_device(b)
c_d=cuda.to_device(c)

gfcalc = lambda t0,t1: 1e-9 * (N * N * (2*N)) / (t1-t0)

for i in range(0,10):
  t0 = time.time()
  fast_matmul[blockspergrid, threadsperblock](a, b, c)
  cuda.synchronize()
  t1 = time.time()
  print("Numba, host source & dest", gfcalc(t0,t1))

for i in range(0,10):
  t0 = time.time()
  fast_matmul[blockspergrid, threadsperblock](a_d, b_d, c_d)
  cuda.synchronize()
  t1 = time.time()
  print("Numba, device source & dest", gfcalc(t0,t1))

for i in range(0,10):
  t0 = time.time()
  c_h = a.dot(b)
  t1 = time.time()
  print("Numpy", gfcalc(t0,t1))

with cupy.cuda.Device() as dev:
  a_c = cupy.asarray(a)
  b_c = cupy.asarray(b)
  c_c = cupy.asarray(c)
  for i in range(0,10):
    t0 = time.time()
    a_c.dot(b_c, out=c_c)
    dev.synchronize()
    t1 = time.time()
    print("cupy device source & dest", gfcalc(t0,t1))

每次运行的结果分别以 GFLOP/s 打印,因此我们可以看到热身效果。在未知天意的 Google Colab 会话上运行:

Numba, host source & dest 7.157921582274139
Numba, host source & dest 35.41623777048565
Numba, host source & dest 41.53596793561073
Numba, host source & dest 40.067434107237034
Numba, host source & dest 41.853653713591996
Numba, host source & dest 49.797096688049365
Numba, host source & dest 73.13115945878288
Numba, host source & dest 70.30009174431994
Numba, host source & dest 74.86845532463607
Numba, host source & dest 76.87160119090733
Numba, device source & dest 105.97453061087833
Numba, device source & dest 105.56095086831826
Numba, device source & dest 105.78037879907214
Numba, device source & dest 106.06063296721804
Numba, device source & dest 106.87105343720403
Numba, device source & dest 104.89221337519061
Numba, device source & dest 106.34112058583715
Numba, device source & dest 103.33148924767107
Numba, device source & dest 106.94211047481143
Numba, device source & dest 104.29586223965393
Numpy 81.5965580615561
Numpy 70.34621140682276
Numpy 58.2601841797442
Numpy 79.16676998234227
Numpy 81.37246257365994
Numpy 83.9362524903643
Numpy 76.91820953485447
Numpy 68.72629315606706
Numpy 56.53278637482029
Numpy 76.50855577892254
cupy device source & dest 3298.132279290001
cupy device source & dest 2730.281677702635
cupy device source & dest 4824.423810787891
cupy device source & dest 4698.591160532599
cupy device source & dest 3971.4282428311253
cupy device source & dest 4943.578076147636
cupy device source & dest 3046.0599441126114
cupy device source & dest 2642.1822395837467
cupy device source & dest 3298.132279290001
cupy device source & dest 5144.0315561056495

我们看到了什么:

  1. 第一个 Numba 内核调用非常慢,因为它包括 JIT 编译和 GPU API 开销,以便在 GPU 上获取代码。之后,我们看到稳定的 40 奇数 GFLOP/s,经过几次迭代后增加到大约 75 GFLOP/s。当驱动程序检测到 GPU 处于负载状态时,这可能会导致 GPU 时钟频率加速
  2. 当内存分配、主机到设备传输和设备到主机传输从内核执行时序中移除时,您的相同 Numba 内核的速度大约快 50%,达到约 105 GFLOP/s。
  3. numpy dot 调用(使用高度优化的主机 BLAS GEMM)平均可以达到约 75 GFLOP/s,这比 GPU 上的 Numba 内核性能要慢,而无需包括所有内存分配和传输开销
  4. cupy dot 调用(使用高度优化的 GPU BLAS GEMM)平均达到约 4000 GFLOP/s,即比在主机上运行的 numpy 快约 50 倍。这是计算 GPU 和现代 x86-64 CPU 的峰值浮点吞吐量的真实反映

因此,总而言之,您对正在发生的事情的假设是不正确的,这会导致您得出一些误导性的结论。 Numpy 的 dot 是一种高性能和优化的实现,但它比运行最先进的 BLAS 实现的专用计算 GPU 慢得多。正确的基准测试本身就是一门科学,除非您完全了解自己在做什么,否则很容易得出错误的结论。

【讨论】:

  • 感谢您的洞察力。正如我所说,我的基准测试中 cuda 代码的编译时间并没有影响我的基准测试,因为我运行了几次。为了便于阅读,我给出的代码已经过简化。我的意思不是说 CUDA 的坏话并冒犯任何人(我觉得我确实这样做了),而是强调 numpy 的乘法的 CPU 版本已经过大量优化。
【解决方案2】:

我不相信你用大 O 表示法的计算是有效的。请记住,这只会给出渐近复杂性,这意味着最终所需时间不会比例如n^2.8。一开始它并没有说明它有多快或多慢。

考虑到这一点,我对大小为 1x1 到 2^12x2^12 的方阵的增长速度很感兴趣。所以我运行它并根据结果时间拟合曲线。请注意,我使用 jupyter line magic 来计算时间,所以这不是纯 python 代码。

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit

results = []
n = 12
x = np.linspace(0,2**n,10)

for i in x:
    print(np.log2(i))
    size = np.round(i).astype('int')
    A = np.random.random((size, size))
    B = np.random.random((size, size))
    if i < 10:
        timeit = %timeit -n 100 -o A@B
    else:
        timeit = %timeit -n 1 -o A@B
    results.append(timeit.best)

results = np.array(results)
x2 = np.linspace(x.min(),x.max(), 10**4)

def func(x, a, b, n):
    return a*x**n+b

popt, pcov = curve_fit(func, x, results, p0=[results[0], 0, 3])

plt.figure()
plt.plot(x2, func(x2, *popt), 'r-')
plt.scatter(x,results)
plt.xlabel('nxn matrices')
plt.ylabel('seconds')
popt

array([ 1.55232100e-11, -8.11662366e-04,  3.00821421e+00])

意思是我们的近似值是 1.55232100e-11*x^3.00821421e+00-8.11662366e-04。虽然正如我之前所说,原则上这使得所有大 O 类都处于开放状态,我们只查看了有限的运行时集,但我认为可以说这表明它在 O(n^3) 中。

【讨论】:

    猜你喜欢
    • 2017-06-07
    • 2018-11-29
    • 2018-12-22
    • 2011-03-14
    • 2014-05-28
    • 2016-03-17
    • 2012-05-13
    • 2012-07-14
    • 2012-06-22
    相关资源
    最近更新 更多