CuPy vs. NumPy,使用 GPU 速度提升 10 倍
如果你使用 Numpy 頻率非常高,并且幸運地擁有一臺配備 Nvidia GPU 的系統(tǒng),那么你有一個相對簡單的方法來提升你的計算運行時間。怎么做?很簡單,使用 CuPy Python 庫代替 Numpy。
什么是 Numpy?
我猜你已經(jīng)知道 Numpy Python 庫的全部含義了;否則,你可能不會讀這篇文章。NumPy 是用 C 語言編寫的,它支持快速數(shù)字運算,尤其適用于多維數(shù)組和矩陣,并且它還提供了一系列數(shù)學(xué)函數(shù)來高效地操作這些數(shù)組。
什么是 CuPy,為什么需要它?
CuPy 是由專注于深度學(xué)習(xí)技術(shù)的日本公司 Preferred Networks 開發(fā)的開源庫,旨在提供與 NumPy 兼容的接口,以便使用 CUDA 在 NVIDIA GPU 上執(zhí)行計算。
CUDA(統(tǒng)一計算設(shè)備架構(gòu))是由 NVIDIA 創(chuàng)建的并行計算平臺和應(yīng)用程序編程接口 (API) 模型。它允許軟件開發(fā)人員和軟件工程師使用支持 CUDA 的圖形處理單元 (GPU) 進行通用處理。
CuPy 旨在成為 Numpy 的直接替代品,讓你能夠以最少的代碼更改充分利用 GPU 的并行計算能力。CuPy 的 API 與 NumPy 高度兼容,這意味著在許多情況下,它可以直接替代 Numpy。
至于為什么需要 CuPy,答案很簡單——速度。對于可并行化的操作,CuPy 可以利用 GPU 以比 CPU 更快的速度執(zhí)行計算,尤其是在大規(guī)模數(shù)值計算方面。這對于科學(xué)計算、數(shù)據(jù)分析、機器學(xué)習(xí)、深度學(xué)習(xí)和圖像處理任務(wù)尤其有利。
先決條件
- Nvidia GPU
首先,你的系統(tǒng)上需要有一塊 Nvidia GPU。在系統(tǒng)提示符下輸入以下命令來檢查你的 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) ,則可能需要安裝驅(qū)動程序。相關(guān)說明請見頁面下方。
此外,你擁有的任何 GPU 都需要具有 3.0 或更高的計算能力。你可以使用以下命令查看 GPU 的計算能力:
$ nvidia-smi --query-gpu=compute_cap --format=csv
# 在我的系統(tǒng)上輸出以下內(nèi)容
compute_cap
8.9- 安裝 WSL Ubuntu Linux
由于我使用的是 Windows 系統(tǒng),而在該平臺上安裝 Cuda 比較復(fù)雜,因此我選擇在 Linux 下進行安裝。幸運的是,Windows 的 Linux 子系統(tǒng) (WSL) 可以方便地實現(xiàn)這一點。
要在 Windows 上安裝它,請打開 PowerShell 命令窗口,然后輸入:-
(base) PS C:\Users\thoma> wsl --installInstalling: 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 應(yīng)該會自動啟動,并要求你設(shè)置用戶名和密碼。如果一切順利,你的命令窗口應(yīng)該如下所示:
圖片
要退出 WSL Linux,請在提示符下輸入exit。在常規(guī) PowerShell 命令窗口中,輸入ubuntu即可返回 Linux Shell。
- 為你的 GPU 和系統(tǒng)安裝最新的 Nvidia 驅(qū)動程序
轉(zhuǎn)到 Nvidia 網(wǎng)站并安裝與你的系統(tǒng)和 GPU 規(guī)格相關(guān)的最新驅(qū)動程序。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 工具包
轉(zhuǎn)到CUDA Toolkit 下載頁面并選擇符合你的系統(tǒng)要求和操作系統(tǒng)的版本。
CuPy 的安裝
現(xiàn)在首先設(shè)置我們的 Python 開發(fā)環(huán)境。
#創(chuàng)建我們的測試環(huán)境
(base)$ conda create -n cupy_test pythnotallow= 3.11 -y
# 現(xiàn)在激活它
(base)$ conda activate cupy_test安裝所需的庫。
(cupy_test) $ conda install -c conda - forge cupy jupyter numpy pandas matplotlib -y現(xiàn)在在命令提示符中輸入jupyter notebook 。你應(yīng)該會看到瀏覽器中打開了一個 Jupyter Notebook。如果沒有自動打開,你可能會在jupyter notebook 命令后看到一整屏的信息。在屏幕底部附近,會有一個 URL,你應(yīng)該將其復(fù)制并粘貼到瀏覽器中以啟動 Jupyter Notebook。
你的 URL 將與我的不同,但它應(yīng)該看起來像這樣:-
http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69da
注意:在下面的時間安排中,我連續(xù)多次運行了 Numpy 和 CuPy 進程,并分別獲得了最佳時間。這確實在一定程度上有利于 CuPy 的運行,因為每次 CuPy 運行的首次調(diào)用都會產(chǎn)生少量開銷,但總的來說,我認為這是一個更公平的比較。
示例 1
一個簡單的數(shù)組數(shù)學(xué)運算。
在此示例中,我們設(shè)置了幾個大型一維數(shù)組,然后對每個數(shù)組元素執(zhí)行簡單的加法運算。請注意,用于 CuPy 處理的數(shù)組是使用 CuPy 而非 NumPy 設(shè)置的。這一點很重要,因為這意味著數(shù)組數(shù)據(jù)將存儲在 GPU 主內(nèi)存中,而不是 CPU 主內(nèi)存中。
import numpy as np
import cupy as cp
from timeit import default_timer as timer
# func1 和 func3 將在 CPU 上運行
def func1 ( a ):
for i in range ( len (a)):
a[i]+= 1
# func2 和 func4 將在 GPU 上運行
def func2 ( a ):
for i in range ( len (a)):
a[i]+= 2
def func3 ( a ):
a+= 3
def func4 ( 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 數(shù)據(jù)的循環(huán)非常慢!盡管 CuPy for 循環(huán)測試的數(shù)組大小是 Numpy 數(shù)組大小的 1/1000,但執(zhí)行時間卻只有 Numpy 的 1/7。
for 循環(huán)將在 CPU 上運行,并導(dǎo)致每次迭代時數(shù)據(jù)在 CPU 和 GPU 之間傳輸,從而加劇性能損失。
相比之下,看看我們?yōu)槭噶炕僮鞴?jié)省的時間。GPU 處理數(shù)據(jù)的速度是 CPU 的兩倍。
如果你嘗試處理不同的數(shù)據(jù)項數(shù)量,你會注意到,隨著數(shù)據(jù)項數(shù)量的減少,GPU 的優(yōu)勢會逐漸減弱。這是可以預(yù)料的,你需要對數(shù)據(jù)進行一些測試,找到一個最佳平衡點,以證明將處理轉(zhuǎn)移到 GPU 所需的額外努力是合理的。
在繼續(xù)下一個示例之前,CuPy 還為類似情況提供了另一種選擇——自定義 CUDA 內(nèi)核。這些內(nèi)核使用類似 C 語言的語法編寫,可以根據(jù)你的需求調(diào)用各種 CuPy 函數(shù)。我們將使用的內(nèi)核函數(shù)是ElementwiseKernel().
我們檢查一下代碼。
import numpy as np
import cupy as cp
from timeit import default_timer as timer
# ElementwiseKernel 函數(shù)
add_five_kernel = cp.ElementwiseKernel(
'float64 x' ,
'float64 y' ,
'y = x + 5' ,
'add_five'
)
def func5 ( 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)在這種情況下,輸出顯示內(nèi)核運行時間介于矢量化 CPU 和矢量化 GPU 操作所需的時間之間。
使用GPU/ElementwiseKernel:0.011662766003923025
a5 = [ 6. 6. 6. ... 6. 6. 6. ]有關(guān)可用CuPy 核函數(shù)的完整列表,請查看你所使用版本的文檔。
示例2
稍微復(fù)雜一點的數(shù)組操作。
在此示例中,我們將使用 CuPy 和 Numpy 庫中內(nèi)置的matmul運算進行多維矩陣乘法。每個數(shù)組的大小為 10000 x 10000,包含 1 到 100 之間的隨機浮點數(shù)。
# 首先
import numpy as np
from timeit import default_timer as timer
# 設(shè)置種子以實現(xiàn)可重復(fù)性
np.random.seed( 0 )
# 生成兩個 10000x10000 的 1 到 100 之間的隨機浮點數(shù)數(shù)組
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 ))
# 執(zhí)行矩陣乘法
start = timer()
C = np.matmul(A, B)
# 由于矩陣很大,將它們?nèi)看蛴〕鰜聿磺袑嶋H。
# 相反,你可以打印結(jié)果的形狀和一小部分以進行驗證。
print("結(jié)果矩陣的一小部分:\n" , C[:5 , :5])
print("不使用 GPU:", timer()-start)輸出如下
結(jié)果矩陣的一小部分:
[[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現(xiàn)在來看看 CuPy。代碼幾乎一樣;只需更改頂部的導(dǎo)入,并將 cp 替換為 np!
import cupy as cp
# 設(shè)置種子以實現(xiàn)可重復(fù)性
cp.random.seed(0)
# 生成兩個 2000x2000 的隨機浮點數(shù)數(shù)組,范圍在 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))
# 執(zhí)行矩陣乘法
start = timer()
C = cp.matmul(A, B)
# 等待所有計算完成
cp.cuda.Stream.null.synchronize()
# 由于矩陣很大,將它們?nèi)看蛴〕鰜聿⒉粚嶋H。
# 相反,你可以打印結(jié)果的形狀和一小部分以進行驗證。
print("結(jié)果矩陣的一小部分:\n", C[:5 , :5])
print("使用 GPU:", timer()-start)結(jié)果矩陣的一小部分:
[[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等一下!發(fā)生了什么?CuPy 代碼比 Numpy 代碼運行時間更長。這是怎么回事?
我承認我花了一些時間才弄清楚,但最終還是歸結(jié)于內(nèi)存。當(dāng)我們將隨機浮點值分配給數(shù)組時,它們默認設(shè)置為 64 位值。
A.dtype
dtype( 'float64' )似乎大多數(shù) GPU 都使用 32 位內(nèi)存寄存器,因此當(dāng)它們處理 64 位數(shù)字時,它們必須做額外的工作,因為每個數(shù)字都會分布在兩個內(nèi)存位置上。至少我是這么認為的。如果有人知道更詳細的信息,請評論告訴我。
現(xiàn)在看看如果我們把數(shù)字的數(shù)據(jù)類型改為 float32 會發(fā)生什么。所以我把這兩行代碼改了
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。
結(jié)果矩陣的一小部分:
[[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 的運行時間幾乎減少了一半,這并不奇怪,因為內(nèi)存需求也減少了一半。
現(xiàn)在看看 CuPy 運行會發(fā)生什么。
結(jié)果矩陣的一小部分:
[[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 已經(jīng)超級快了!
我想,歸根結(jié)底,如果你真的需要處理 64 位數(shù)值數(shù)組,那么如果你的 GPU 只支持 32 位內(nèi)存,那么使用 GPU 可能并沒有什么優(yōu)勢。隨著時間的推移,更新、更強大的 GPU 可能會轉(zhuǎn)向 64 位內(nèi)存寄存器。高端 GPU 可能已經(jīng)這么做了。
例3
結(jié)合 CPU 和 GPU 代碼。
有時,并非所有處理都能在 GPU 上完成。一個常見的用例是繪制數(shù)據(jù)圖表。當(dāng)然,你可以使用 GPU 處理數(shù)據(jù),但通常,下一步是查看最終數(shù)據(jù)集的樣子。如果數(shù)據(jù)駐留在 GPU 內(nèi)存中,則無法繪制數(shù)據(jù),因此在調(diào)用繪圖函數(shù)之前,你需要將其移回 CPU 內(nèi)存。將大量數(shù)據(jù)從 GPU 移動到 CPU 是否值得?
現(xiàn)在來一探究竟。
在此示例中,我有一個簡單的 CSV 文件,其中包含日期列和 2016 年至 2018 年的用電量列。我想計算這些日期的用電量平均值、最小值和最大值,然后使用 matplotlib 繪制這些數(shù)據(jù)。這是前幾行的樣子。該文件總共有近 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
...
...以下是使用常規(guī) CPU 代碼執(zhí)行此操作的一種方法。
from timeit import default_timer as timer
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import datetime
# 假設(shè) 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' 轉(zhuǎn)換為 NumPy 數(shù)組
usage = df[ 'USAGE' ].values
# 將 'DATE' 轉(zhuǎn)換為 NumPy 數(shù)組
dates = np.array([date.toordinal() for date in df[ 'DATE' ]])
# 查找唯一日期及其索引
unique_dates, indices = np.unique(dates, return_inverse= True )
# 初始化數(shù)組來保存最大值、最小值和平均值
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])
# 將序數(shù)日期轉(zhuǎn)換為日期時間進行繪圖
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(rotatinotallow=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
# 假設(shè) 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' 轉(zhuǎn)換為 CuPy 數(shù)組
usage = cp.array(df['USAGE'].values)
# 將 'DATE' 轉(zhuǎn)換為 NumPy 數(shù)組(因為日期處理不是 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)
# 初始化數(shù)組以保存 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 不支持繪圖和日期轉(zhuǎn)換,
# 請將結(jié)果轉(zhuǎn)換回 NumPy 以完成這些任務(wù)
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(rotatinotallow=45)
plt.tight_layout()
plt.show()
print("Finished with GPU at ", timer()-start)
這兩組代碼集之間唯一真正的區(qū)別是這 3 行,我們在繪制 CuPy 數(shù)組數(shù)據(jù)之前將其從 GPU 復(fù)制到 CPU。
max_usage = cp.asnumpy(max_usage)
min_usage = cp.asnumpy(min_usage)
mean_usage = cp.asnumpy(mean_usage)即便如此,CuPy 代碼也比 NumPy 代碼快 70%,因此在這種情況下,這些額外的數(shù)據(jù)復(fù)制步驟的開銷是值得的。
無論如何,我希望本文能夠激發(fā)你研究在工作負載中使用 CuPy 庫和 GPU 的興趣。
記住要測試、測試、再測試。并非所有工作負載都能從切換到基于 GPU 的代碼中受益。有時,你的代碼運行速度可能會變慢,或者任何好處都微不足道,甚至不值得付出努力去實現(xiàn)。嘗試使用 CuPy 代替 NumPy 的一個好處是操作起來很容易。即使它沒有用,你也不會浪費太多時間。



























