数据STUDIO

CuPy vs. NumPy,使用 GPU 速度提升 10 倍

如果你使用 Numpy 频率非常高,并且幸运地拥有一台配备 Nvidia GPU 的系统,那么你有一个相对简单的方法来提升你的计算运行时间。怎么做?很简单,使用 CuPy Python 库代替 Numpy。

Image

什么是 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 应该会自动启动,并要求你设置用户名和密码。如果一切顺利,你的命令窗口应该如下所示:

Image

要退出 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)
Image

这是 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)
Image

这两组代码集之间唯一真正的区别是这 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 的一个好处是操作起来很容易。即使它没有用,你也不会浪费太多时间。