CuPy vs. NumPy,使用 GPU 速度提升 10 倍
如果你使用 Numpy 频率非常高,并且幸运地拥有一台配备 Nvidia GPU 的系统,那么你有一个相对简单的方法来提升你的计算运行时间。怎么做?很简单,使用 CuPy Python 库代替 Numpy。
什么是 Numpy?
我猜你已经知道 Numpy Python 库的全部含义了;否则,你可能不会读这篇文章。NumPy 是用 C 语言编写的,它支持快速数字运算,尤其适用于多维数组和矩阵,并且它还提供了一系列数学函数来高效地操作这些数组。
什么是 CuPy,为什么需要它?
CuPy 是由专注于深度学习技术的日本公司 Preferred Networks 开发的开源库,旨在提供与 NumPy 兼容的接口,以便使用 CUDA 在 NVIDIA GPU 上执行计算。
CUDA(统一计算设备架构)是由 NVIDIA 创建的并行计算平台和应用程序编程接口 (API) 模型。它允许软件开发人员和软件工程师使用支持 CUDA 的图形处理单元 (GPU) 进行通用处理。
CuPy 旨在成为 Numpy 的直接替代品,让你能够以最少的代码更改充分利用 GPU 的并行计算能力。CuPy 的 API 与 NumPy 高度兼容,这意味着在许多情况下,它可以直接替代 Numpy。
至于为什么需要 CuPy,答案很简单——速度。对于可并行化的操作,CuPy 可以利用 GPU 以比 CPU 更快的速度执行计算,尤其是在大规模数值计算方面。这对于科学计算、数据分析、机器学习、深度学习和图像处理任务尤其有利。
先决条件
Nvidia GPU
首先,你的系统上需要有一块 Nvidia GPU。在系统提示符下输入以下命令来检查你的 GPU。
>>
(base) PS C:\Users\yunduojun> nvidia-smi
Fri Mar 22 11:41:34 2024
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 551.61 Driver Version: 551.61 CUDA Version: 12.4 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A |
| 32% 24C P8 9W / 285W | 843MiB / 12282MiB | 1% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+
如果无法识别nvidia-smi命令(并且你使用的是Nvidia GPU) ,则可能需要安装驱动程序。相关说明请见页面下方。
此外,你拥有的任何 GPU 都需要具有 3.0 或更高的计算能力。你可以使用以下命令查看 GPU 的计算能力:
$ nvidia-smi --query-gpu=compute_cap --format=csv
# 在我的系统上输出以下内容
compute_cap
8.9
安装 WSL Ubuntu Linux
由于我使用的是 Windows 系统,而在该平台上安装 Cuda 比较复杂,因此我选择在 Linux 下进行安装。幸运的是,Windows 的 Linux 子系统 (WSL) 可以方便地实现这一点。
要在 Windows 上安装它,请打开 PowerShell 命令窗口,然后输入:-
(base) PS C:\Users\thoma> wsl --install
Installing: Windows Subsystem for Linux
Windows Subsystem for Linux has been installed.
Installing: Ubuntu
Ubuntu has been installed.
The requested operation is successful. Changes will not be effective until the system is rebooted.
接下来,重启你的电脑。WSL 应该会自动启动,并要求你设置用户名和密码。如果一切顺利,你的命令窗口应该如下所示:
要退出 WSL Linux,请在提示符下输入exit。在常规 PowerShell 命令窗口中,输入ubuntu即可返回 Linux Shell。
为你的 GPU 和系统安装最新的 Nvidia 驱动程序
转到 Nvidia 网站并安装与你的系统和 GPU 规格相关的最新驱动程序。www.nvidia.com
在 WSL 上安装 Miniconda
安装 WSL 并启动它后,输入以下命令来获取 Miniconda 并安装它。
$ wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
$ ./Miniconda3-latest-Linux-x86_64.sh
安装 CUDA 工具包
转到CUDA Toolkit 下载页面并选择符合你的系统要求和操作系统的版本。
CuPy 的安装
现在首先设置我们的 Python 开发环境。
#创建我们的测试环境
(base)$ conda create -n cupy_test python= 3.11 -y
# 现在激活它
(base)$ conda activate cupy_test
安装所需的库
(cupy_test) $ conda install -c conda - forge cupy jupyter numpy pandas matplotlib -y
现在在命令提示符中输入jupyter notebook 。你应该会看到浏览器中打开了一个 Jupyter Notebook。如果没有自动打开,你可能会在jupyter notebook 命令后看到一整屏的信息。在屏幕底部附近,会有一个 URL,你应该将其复制并粘贴到浏览器中以启动 Jupyter Notebook。
你的 URL 将与我的不同,但它应该看起来像这样:-
http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69da
注意:在下面的时间安排中,我连续多次运行了 Numpy 和 CuPy 进程,并分别获得了最佳时间。这确实在一定程度上有利于 CuPy 的运行,因为每次 CuPy 运行的首次调用都会产生少量开销,但总的来说,我认为这是一个更公平的比较。
示例 1
一个简单的数组数学运算。
在此示例中,我们设置了几个大型一维数组,然后对每个数组元素执行简单的加法运算。请注意,用于 CuPy 处理的数组是使用 CuPy 而非 NumPy 设置的。这一点很重要,因为这意味着数组数据将存储在 GPU 主内存中,而不是 CPU 主内存中。
import numpy as np
import cupy as cp
from timeit import default_timer as timer
# func1 和 func3 将在 CPU 上运行
deffunc1( a ):
for i in range ( len (a)):
a[i]+= 1
# func2 和 func4 将在 GPU 上运行
deffunc2( a ):
for i in range ( len (a)):
a[i]+= 2
deffunc3( a ):
a+= 3
deffunc4( a ):
a+= 4
if __name__=="__main__":
n1 = 300000000
a1 = np.ones(n1, dtype = np.float64)
# had to make this array much smaller than
# the others due to slow loop processing on the GPU
n2 = 300000
a2 = cp.ones(n2, dtype = cp.float64)
n3 = 300000000
a3 = np.ones(n1, dtype = np.float64)
n4 = 300000000
a4 = cp.ones(n2, dtype = cp.float64)
start = timer()
func1(a1)
print("without GPU/for loop:", timer()-start)
start = timer()
func2(a2)
# wait for all calcs to complete
cp.cuda.Stream.null.synchronize()
print("with GPU:/for loop", timer()-start)
start = timer()
func3(a3)
print("without GPU:vectorization", timer()-start)
start = timer()
func4(a4)
# wait for all calcs to complete
cp.cuda.Stream.null.synchronize()
print("with GPU:vectorization", timer()-start)
print()
print("a1 = ",a1)
print("a2 = ",a2)
print("a3 = ",a3)
print("a4 = ",a4)
输出如下
without GPU/for loop: 25.486853414004145
with GPU:/for loop 4.358431388995086
without GPU:vectorization 0.13804959499998404
with GPU:vectorization 0.07079174599994076
a1 = [2. 2. 2. ... 2. 2. 2.]
a2 = [3. 3. 3. ... 3. 3. 3.]
a3 = [4. 4. 4. ... 4. 4. 4.]
a4 = [5. 5. 5. ... 5. 5. 5.]
需要注意的是,使用 GPU 数据的循环非常慢!尽管 CuPy for 循环测试的数组大小是 Numpy 数组大小的 1/1000,但执行时间却只有 Numpy 的 1/7。
for 循环将在 CPU 上运行,并导致每次迭代时数据在 CPU 和 GPU 之间传输,从而加剧性能损失。
相比之下,看看我们为矢量化操作节省的时间。GPU 处理数据的速度是 CPU 的两倍。
如果你尝试处理不同的数据项数量,你会注意到,随着数据项数量的减少,GPU 的优势会逐渐减弱。这是可以预料的,你需要对数据进行一些测试,找到一个最佳平衡点,以证明将处理转移到 GPU 所需的额外努力是合理的。
在继续下一个示例之前,CuPy 还为类似情况提供了另一种选择——自定义 CUDA 内核。这些内核使用类似 C 语言的语法编写,可以根据你的需求调用各种 CuPy 函数。我们将使用的内核函数是ElementwiseKernel().
我们检查一下代码。
import numpy as np
import cupy as cp
from timeit import default_timer as timer
# ElementwiseKernel 函数
add_five_kernel = cp.ElementwiseKernel(
'float64 x' ,
'float64 y' ,
'y = x + 5' ,
'add_five'
)
deffunc5( a ):
add_five_kernel(a, a) # 就地修改
n5 = 300000000
a5 = cp.ones(n5, dtype=cp.float64)
start = timer()
func5(a5)
cp.cuda.Stream.null.synchronize()
print("with GPU/ElementwiseKernel:" , timer()-start)
在这种情况下,输出显示内核运行时间介于矢量化 CPU 和矢量化 GPU 操作所需的时间之间。
使用GPU/ElementwiseKernel:0.011662766003923025
a5 = [ 6. 6. 6. ... 6. 6. 6. ]
有关可用CuPy 核函数的完整列表,请查看你所使用版本的文档。
示例2
稍微复杂一点的数组操作。
在此示例中,我们将使用 CuPy 和 Numpy 库中内置的matmul运算进行多维矩阵乘法。每个数组的大小为 10000 x 10000,包含 1 到 100 之间的随机浮点数。
# 首先
import numpy as np
from timeit import default_timer as timer
# 设置种子以实现可重复性
np.random.seed( 0 )
# 生成两个 10000x10000 的 1 到 100 之间的随机浮点数数组
A = np.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 ))
B = np.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 ))
# 执行矩阵乘法
start = timer()
C = np.matmul(A, B)
# 由于矩阵很大,将它们全部打印出来不切实际。
# 相反,你可以打印结果的形状和一小部分以进行验证。
print("结果矩阵的一小部分:\n" , C[:5 , :5])
print("不使用 GPU:", timer()-start)
输出如下
结果矩阵的一小部分:
[[25461282.56020853 25168348.08695598 25212522.35402665 25303307.69696668 25277886.16204746]
[25114760.67252064 25197555.19361381 25340074.95867983 25341847.41707999 25373123.1113671 ]
[25381820.17590097 25326519.29503381 25438611.20780989 25596935.44312112 25538595.65174283]
[25317284.31091545 25223539.66363661 25272235.85780019 25551426.21236818 25467989.425944 ]
[25327294.06390036 25527840.32567072 25499601.14864586 25657214.623082 25527855.25691375]]
不使用:3.2115318500000285
现在来看看 CuPy。代码几乎一样;只需更改顶部的导入,并将 cp 替换为 np!
import cupy as cp
# 设置种子以实现可重复性
cp.random.seed(0)
# 生成两个 2000x2000 的随机浮点数数组,范围在 1 到 100 之间
A = cp.random.uniform(low=1.0, high=100.0, size=(10000 , 10000))
B = cp.random.uniform(low=1.0, high=100.0, size=(10000 , 10000))
# 执行矩阵乘法
start = timer()
C = cp.matmul(A, B)
# 等待所有计算完成
cp.cuda.Stream.null.synchronize()
# 由于矩阵很大,将它们全部打印出来并不实际。
# 相反,你可以打印结果的形状和一小部分以进行验证。
print("结果矩阵的一小部分:\n", C[:5 , :5])
print("使用 GPU:", timer()-start)
结果矩阵的一小部分:
[[25710603.94664048 25421109.27794836 25400571.17687622 25165215.05626292 25729646.95638799]
[25625453.16519611 25144442.91475235 25222187.53040171 25345612.79231448 25740855.19128766]
[25341043.05541366 25193877.59648657 25213287.79042915 25105198.56650982 25697665.56022939]
[25747476.4063573 25303358.1864255 25188271.28090249 25260575.16770762 25653182.98191385]
[25775006.9423866 25390991.70257155 25475701.8092414 25170055.16207211 25525589.5144844 ]]
使用 GPU:3.991562336000243
等一下!发生了什么?CuPy 代码比 Numpy 代码运行时间更长。这是怎么回事?
我承认我花了一些时间才弄清楚,但最终还是归结于内存。当我们将随机浮点值分配给数组时,它们默认设置为 64 位值。
A.dtype
dtype( 'float64' )
似乎大多数 GPU 都使用 32 位内存寄存器,因此当它们处理 64 位数字时,它们必须做额外的工作,因为每个数字都会分布在两个内存位置上。至少我是这么认为的。如果有人知道更详细的信息,请评论告诉我。
现在看看如果我们把数字的数据类型改为 float32 会发生什么。所以我把这两行代码改了
A = np.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 ))
B = np.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 ))
to
A = np.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 )).astype(np.float32)
B = np.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 )).astype(np.float32)
AND
A = cp.random.uniform(low= 1.0 , high= 100.0,size=( 10000 , 10000 ))
B = cp.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 ))
to
A = cp.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 )).astype(cp.float32)
B = cp.random.uniform(low= 1.0 , high= 100.0 , size=( 10000 , 10000 )).astype(cp.float32)
我重新运行了两组代码,以下是输出。先用 Numpy。
结果矩阵的一小部分:
[[25461280. 25168348. 25212528. 25303310. 25277886.]
[25114762. 25197556. 25340074. 25341846. 25373122.]
[25381818. 25326516. 25438612. 25596934. 25538596.]
[25317284. 25223536. 25272240. 25551426. 25467992.]
[25327292. 25527844. 25499604. 25657220. 25527856.]]
不使用 GPU:1.8297855099999651
因此,Numpy 的运行时间几乎减少了一半,这并不奇怪,因为内存需求也减少了一半。
现在看看 CuPy 运行会发生什么。
结果矩阵的一小部分:
[[25710616. 25421130. 25400568. 25165210. 25729658.]
[25625414. 25144374. 25222220. 25345620. 25740910.]
[25341046. 25193884. 25213314. 25105278. 25697658.]
[25747516. 25303340. 25188230. 25260598. 25653224.]
[25775088. 25390888. 25475664. 25170094. 25525540.]]
使用 GPU: 0.14140109800064238
这真是令人印象深刻。不到 0.15 秒的运行时间意味着比 Numpy 快了 10 倍以上。别忘了,NumPy 已经超级快了!
我想,归根结底,如果你真的需要处理 64 位数值数组,那么如果你的 GPU 只支持 32 位内存,那么使用 GPU 可能并没有什么优势。随着时间的推移,更新、更强大的 GPU 可能会转向 64 位内存寄存器。高端 GPU 可能已经这么做了。
例3
结合 CPU 和 GPU 代码。
有时,并非所有处理都能在 GPU 上完成。一个常见的用例是绘制数据图表。当然,你可以使用 GPU 处理数据,但通常,下一步是查看最终数据集的样子。如果数据驻留在 GPU 内存中,则无法绘制数据,因此在调用绘图函数之前,你需要将其移回 CPU 内存。将大量数据从 GPU 移动到 CPU 是否值得?
现在来一探究竟。
在此示例中,我有一个简单的 CSV 文件,其中包含日期列和 2016 年至 2018 年的用电量列。我想计算这些日期的用电量平均值、最小值和最大值,然后使用 matplotlib 绘制这些数据。这是前几行的样子。该文件总共有近 1200 万条记录。
DATE,USAGE
10/22/2016,0.01
10/22/2016,0.01
10/22/2016,0.01
10/22/2016,0.01
10/22/2016,0.01
10/22/2016,0.01
10/22/2016,0.01
10/22/2016,0.01
10/22/2016,0.02
10/22/2016,0.02
10/22/2016,0.02
10/22/2016,0.02
10/22/2016,0.01
...
...
以下是使用常规 CPU 代码执行此操作的一种方法。
from timeit import default_timer as timer
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import datetime
# 假设 df 是读取 CSV 后的 DataFrame
df = pd.read_csv( '/mnt/d/test/D202.csv' , sep= ',' )
start = timer()
df[ 'DATE' ] = pd.to_datetime(df[ 'DATE' ]).dt.date
# 将 'USAGE' 转换为 NumPy 数组
usage = df[ 'USAGE' ].values
# 将 'DATE' 转换为 NumPy 数组
dates = np.array([date.toordinal() for date in df[ 'DATE' ]])
# 查找唯一日期及其索引
unique_dates, indices = np.unique(dates, return_inverse= True )
# 初始化数组来保存最大值、最小值和平均值
max_usage = np.zeros( len (unique_dates))
min_usage = np.zeros( len (unique_dates))
mean_usage = np.zeros( len (unique_dates))
# 计算每个组的最大值、最小值和平均值
for i, date in enumerate (unique_dates):
max_usage[i] = np.max ( usage[indices == i])
min_usage[i] = np. min (usage[indices == i])
mean_usage[i] = np.mean(usage[indices == i])
# 将序数日期转换为日期时间进行绘图
plot_dates = [datetime.date.fromordinal(date) for date in unique_dates]
# Plotting
plt.figure(figsize=(10, 6))
plt.plot(plot_dates, mean_usage, label='Mean Usage', marker='o')
plt.plot(plot_dates, max_usage, label='Max Usage', marker='x')
plt.plot(plot_dates, min_usage, label='Min Usage', marker='+')
plt.xlabel('Date')
plt.ylabel('Usage (kWh)')
plt.title('Electric Usage Statistics by Date')
plt.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
print("Finished with CPU at ", timer()-start)
这是 GPU 等效代码。
from timeit import default_timer as timer
import pandas as pd
import numpy as np
import cupy as cp
import matplotlib.pyplot as plt
import datetime
# 假设 df 是读取 CSV 后的 DataFrame
df = pd.read_csv('/mnt/d/test/D202.csv' , sep=',' )
start = timer()
df['DATE'] = pd.to_datetime(df['DATE']).dt.date
# 将 'USAGE' 转换为 CuPy 数组
usage = cp.array(df['USAGE'].values)
# 将 'DATE' 转换为 NumPy 数组(因为日期处理不是 CuPy 功能)
dates = np.array([date.toordinal() for date in df['DATE']])
# 使用 NumPy 查找唯一日期及其索引
# (CuPy 不支持带有 return_inverse 的 np.unique)
unique_dates, indices = np.unique(dates, return_inverse=True)
# 初始化数组以保存 GPU 上的最大值、最小值和平均值
max_usage = cp.zeros(len(unique_dates), dtype=cp.float64)
min_usage = cp.zeros(len(unique_dates), dtype=cp.float64)
mean_usage = cp.zeros(len(unique_dates), dtype=cp.float64)
# 使用 CuPy 计算每个组的最大值、最小值和平均值
for i, date in enumerate(unique_dates):
mask = indices == i
max_usage[i] = cp.max(usage[mask])
min_usage[i] = cp.min(usage[mask])
mean_usage[i] = cp.mean(usage[mask])
# 等待计算完成
cp.cuda.Stream.null.synchronize()
# 由于 CuPy 不支持绘图和日期转换,
# 请将结果转换回 NumPy 以完成这些任务
max_usage = cp.asnumpy(max_usage)
min_usage = cp.asnumpy(min_usage)
mean_usage = cp.asnumpy(mean_usage)
plot_dates = [datetime.date.fromordinal(date) for date in unique_dates]
# 绘图
plt.figure(figsize=(10, 6))
plt.plot(plot_dates, mean_usage, label='Mean Usage', marker='o')
plt.plot(plot_dates, max_usage, label='Max Usage', marker='x')
plt.plot(plot_dates, min_usage, label='Min Usage', marker='+')
plt.xlabel('Date')
plt.ylabel('Usage (kWh)')
plt.title('Electric Usage Statistics by Date')
plt.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
print("Finished with GPU at ", timer()-start)
这两组代码集之间唯一真正的区别是这 3 行,我们在绘制 CuPy 数组数据之前将其从 GPU 复制到 CPU。
max_usage = cp.asnumpy(max_usage)
min_usage = cp.asnumpy(min_usage)
mean_usage = cp.asnumpy(mean_usage)
即便如此,CuPy 代码也比 NumPy 代码快 70%,因此在这种情况下,这些额外的数据复制步骤的开销是值得的。
无论如何,我希望本文能够激发你研究在工作负载中使用 CuPy 库和 GPU 的兴趣。
记住要测试、测试、再测试。并非所有工作负载都能从切换到基于 GPU 的代码中受益。有时,你的代码运行速度可能会变慢,或者任何好处都微不足道,甚至不值得付出努力去实现。尝试使用 CuPy 代替 NumPy 的一个好处是操作起来很容易。即使它没有用,你也不会浪费太多时间。