Phác họa bài post:
Ⓐ. Đề dẫn.
Ⓑ. GPU xử lý
song song bằng cách nào?
Ⓒ. Làm thế nào
để chạy chương trình trên GPU?
Ⓓ. Trải nghiệm
so sánh CPU và GPU.
Ⓔ. Suy ngẫm chậm.
Ⓕ. Phụ lục: Nội
dung file “CPU, GPU comparison.ipynb”
~
Để giúp anh/chị
quyết định có đọc tiếp hay không, tôi xin phép cung cấp các thông tin liên quan
đến bài post này như sau:
·
Chủ
đề: Parallel
computing (điện toán song song)
·
Tính
thời sự: Tháng 3/2023.
·
Thời
gian đọc: 7 phút, lồng
vào thời gian uống cà phê (uống cà phê xong là đọc xong).
-
Ⓐ. Đề dẫn.
Chủ đề lần này
là GPU (Graphics Processing Unit).
Thoạt nghe chúng ta dễ có cảm giác như đang nói về vấn đề thiết bị điện tử (electronic
device). Lúc đầu đúng thế thật. Về sau thiết bị phần cứng này được “mềm
hóa” thành bộ xử lý (Processing Unit).
Tôi tin rằng
trên diễn đàn này có nhiều anh/chị đã từng mày mò với các chip điện tử dòng thời
xưa 8 bit như Intel 8085, Zilog
Z80, … Rồi sau đó chuyển
sang dòng 16 bit như Intel 8086, Intel
80286, rồi 32 bit như i386, … rồi 64 bit như x86-64. Tuy các bộ xử lý trung tâm CPU (Central Processing Unit) về sau càng ngày càng
nhanh nhưng chúng xử lý một cách tuần tự. Điều này thì tôi tin anh/chị chẳng có
gì ngạc nhiên. Để xử lý đa luồng (multi-thread), đa nhiệm (multi-task)
thì chúng ta cho các chương trình (program) “nằm” trong bộ nhớ RAM rồi
CPU chạy vòng tròn xử lý từng “khúc” một của từng luồng (thread), từng
nhiệm (task). Người dùng có cảm giác như CPU xử lý đồng thời, nhưng bản
chất không phải thế. Chỉ là CPU xử lý rất nhanh, quay vòng liên tục, nhanh đến
mức con người không “thấy” được.
Lại nói về việc
hiển thị lên màn hình. Hồi xưa chúng ta xem việc hiển thị lên màn hình là điền
vào một ma trận W x H điểm (pixel), trong đó W là chiều rộng, H là chiều
cao. Việc hiển thị thực sự là việc của card màn hình (graphics card):
căn cứ vào ma trận điểm này, card màn hình “bắn” các điểm tương ứng lên màn hiển
thị. Ví dụ, máy IBM PC hồi xưa có card VGA với độ phân giải 640 x 480 hay card SVGA có độ phân giải 800 x 600. Thực chất card màn hình là một
tấm mạch tích hợp (integrated circuit), cắm vào khe PCI nối với bo mạch chính. Chính PCI này là bus kết nối CPU với
card màn hình. Chúng ta tưởng tượng PCI chính là chiếc cầu để chuyển dữ liệu từ
bộ nhớ RAM vào bộ nhớ của card màn hình (gọi là Video RAM hay VRAM).
Bây giờ chúng
ta lại nói đến việc xử lý từng điểm của ma trận màn hình. Các điểm trên màn
hình hoàn toàn độc lập với nhau. Việc xử lý điểm này hoàn toàn không làm ảnh hưởng
đến xử lý các điểm khác. Nếu có cách nào đó xử lý đồng thời tất cả các điểm
cùng lúc thì màn hình sẽ hiển thị nhanh hơn. Nó cũng không phụ thuộc vào độ
phân giải của màn hình. Độ phân giải cao hoặc độ phân giải thấp đều có thời
gian xử lý như nhau vì card màn hình bắn các điểm VRAM lên màn hình bằng mạch
tích hợp.
Nói thêm, trong
đồ họa máy tính, người ta có một loại phần mềm có tên gọi là shader (tô bóng). Phần mềm này tạo đồ họa 3
chiều: tính độ sáng (light), độ tối (darkness) và màu (color) của từng điểm. Đối với các điểm thuộc
dạng này, người ta cần xử lý sơ bộ trước khi “bắn” lên màn hình. Từ đặc điểm
này, người ta nghĩ đến việc lập trình phần tô bóng ngay trên card đồ họa. Muốn
lập trình được, card đồ họa cần bộ xử lý (processor), thanh ghi (register),
bộ nhớ (RAM), bộ tính toán (floating point),
gần giống với CPU nhưng chỉ để xử lý đồ họa. Từ đó, ra đời GPU (Graphics
Processing Unit) – bộ xử lý đồ họa.
-
Người ta nhận
thấy có một vài điểm đặc biệt của GPU:
·
Dữ
liệu mà GPU xử lý là ma trận – nói một cách tổng quát là mảng. Các phần tử của
mảng giống nhau về cấu trúc và độc lập lẫn nhau;
·
Có
thể áp phép toán (như tô bóng) lên tất cả các phần tử của mảng một cách đồng thời;
Với đặc điểm
trên thì GPU có thể xử lý song song toàn bộ các phần tử của mảng. Người ta gọi
phương thức xử lý này là SIMD (Single Instruction, Multiple Data): lệnh
đơn, nhiều dữ liệu (xem hình vẽ).
Người ta gọi mảng
các bản ghi dữ liệu như trên là Stream, phép toán là Kernel.
Chúng ta có thể coi Kernel là một hàm.
Chú ý rằng nếu
so về tốc độ tính toán thì GPU chậm hơn CPU. Tốc độ của GPU dao động từ 500 đến
800 MHz. Nghĩa là GPU có thể xử lý 500 triệu đến 800 triệu lệnh/giây. Tốc độ của
CPU có thể đạt từ 3.5 đến 4 GHz. Nghĩa là CPU có thể xử lý đến 4 tỷ lệnh/giây.
Như vậy tốc độ CPU nhanh hơn GPU (4 tỷ / 500 triệu) ~ 8 lần. GPU chỉ có ưu điểm
là xử lý song song: áp hàm Kernel đồng thời lên toàn bộ mảng Stream.
-
Ⓑ. GPU xử lý song song bằng cách nào?
Cốt lõi vấn đề:
GPU có hàng ngàn core (lõi) trong lúc CPU thường chỉ có 1 core hoặc
cùng lắm là hàng chục core. Mỗi một core của GPU lại có thể chạy
hàng ngàn thread (luồng). Tổng thể là GPU cùng lúc xử lý hàng triệu thread
và mỗi một thread chỉ xử lý một đoạn trong mảng dữ liệu Stream.
Xin lấy một ví dụ.
Giả thiết chúng
ta có một ảnh 1920x1080 pixel (= 2,073,600 pixel) và chúng ta cần tô bóng cho tất
cả các pixel.
Chương trình chạy
trên CPU xử lý bằng cách nào? Chúng ta sẽ lập 2 vòng lặp lồng nhau và tô bóng từng
điểm một và số lượng phép toán cần xử lý là 2,073,600. Chú ý rằng thuật toán tô
bóng giống nhau, chỉ có tham số tô bóng (pixel) khác nhau mà thôi. Đương
nhiên cách xử lý này chậm.
Thế còn GPU xử
lý như thế nào? Vì GPU sẽ phát sinh ra 2,073,600 thread, mỗi một thread
gọi hàm tô bóng với pixel được chỉ định cho chính thread đó. Có thể nói
GPU xử lý đồng thời 2,073,600 hàm tô bóng cùng một lúc. Thú vị nhất là thời
gian xử lý của GPU không phụ thuộc vào kích thước ảnh vì tất cả các điểm cần tô
bóng được xử lý đồng thời.
Chú ý: Tốc
độ xử lý của GPU = [clock speed] x [số lượng core] (chứ không phải [clock
speed] x [số lượng thread]). Số thread sinh ra từ 1 core thực ra
là xử lý tuần tự từ core đấy.
-
Ⓒ. Làm thế nào để chạy chương trình trên GPU?
Trả lời cô đọng:
Thông qua giao
diện lập trình (API) CUDA (Compute Unified Device Architecture).
-
Trả lời chi tiết:
Bây giờ chúng
ta xét đến mối quan hệ giữa CPU và GPU. Trong mối quan hệ này, vai của CPU là
chủ (host) và vai của GPU là thiết bị (device) – xem hình vẽ.
Trong hình vẽ trên, chương trình chính chạy trên CPU. CPU phối hợp với GPU như sau:
1. Chương trình chính chép dữ liệu từ bộ nhớ của CPU sang bộ nhớ của GPU
2. Chương trình chính kích hoạt GPU
3. GPU xử lý song song bằng cách áp Kernel lên toàn bộ mảng dữ liệu vừa chép vào
4. GPU chép kết quả từ bộ nhớ của GPU sang bộ nhớ của CPU.
CUDA được tích
hợp vào các ngôn ngữ lập trình như C, C++, Fortran, Python và các framework như
TensorFlow, PyTorch, … Dưới góc độ của người lập trình thì chúng ta chỉ cần hiểu
nguyên lý như trên. Việc còn lại chỉ là gọi các hàm từ các thư viện có sẵn.
-
Ⓓ. Trải nghiệm so sánh CPU và GPU.
Anh/chị có thể
chất vấn: lý thuyết ai chả biết, nhưng làm thế nào để “kiểm tra” xem CPU và GPU
chạy nhanh chậm thế nào. Phần này tôi xin giới thiệu với anh/chị cách thử và so
sánh thời gian xử lý của CPU và GPU ngay trên chính PC của anh/chị.
💡 Ý tưởng là trên cùng phép toán, chúng ta thử tính thời gian
xử lý phép toán đó của CPU và thời gian xử lý cũng cùng phép toán đó của GPU.
Tôi chọn 2 phép toán để so sánh:
·
Phép
toán 1: nhân 2 số vô hướng với nhau
·
Phép
toán 2: nhân 2 ma trận với nhau – mỗi ma trận có 10,000 dòng, 10,000 cột (là ma
trận có 100 triệu số thực). Ma trận này được khởi tạo bởi một hàm ngẫu nhiên.
Công cụ mà tôi
sử dụng là google colab notebook (đặt tên file là “CPU, GPU comparison.ipynb”).
Trong file này tôi sử dụng ngôn ngữ lập trình Python trên nền framework PyTorch.
Anh/chị nào có nhã ý tìm hiểu chi tiết xin mời anh/chị tham khảo phần Phụ lục.
▼ Giải thích ý tưởng
Tôi
chia phần chương trình thành 2 đoạn: đoạn đầu do CPU xử lý và đoạn sau do GPU xử
lý. Tôi đánh số các Cell để anh/chị tiện theo dõi. Trong mỗi Cell, ngay dòng lệnh
đầu tiên tôi sử dụng hàm %%timeit để đo thời gian xử lý của cả Cell.
Anh/chị nào tò mò về lập trình của %%timeit xin tham khảo ở đây.
·
Cell[1],
Cell[2], Cell[3] do CPU xử lý.
·
Cell[4],
Cell[5], Cell[6], Cell[7] do GPU xử lý
-
CPU
Cell [1]: import framework PyTorch
Cell [2]: Đo thời gian xử lý nhân 2 số với nhau (thực chất là nhân 2 ma trận
(1,1) với nhau). Thời gian CPU xử lý mất khoảng 8.38 micro giây.
Cell [3]: Đo thời gian xử lý nhân 2 ma trận (10000, 10000) với nhau. Thời
gian CPU xử lý mất khoảng 32.7 giây (hơn nửa phút).
-
GPU
Trước
khi xử lý, chuyển sang chế độ chạy bằng GPU. Cách chuyển như sau:
·
Click
menu Runtime
·
Chọn
Change Runtime Type
·
Chọn
GPU
Cell[4]: import framework PyTorch
Cell[5]: chọn device là CUDA
Cell[6]: Đo thời gian xử lý nhân 2 số với nhau (thực chất là nhân 2 ma
trận [1,1] với nhau). Thời gian GPU xử lý mất khoảng 73.6 micro giây (so với CPU chỉ mất 8.38
micro giây).
Cell[7]: Đo thời gian xử lý nhân 2 ma trận (10000, 10000) với nhau. Thời
gian GPU xử lý mất khoảng 988 mili giây
(chưa đầy 1 giây) so với CPU xử lý mất 32.7 giây.
▲ Giải thích
-
Ⓔ. Suy ngẫm chậm.
Đôi lúc, công
nghệ mới ra đời một cách ngẫu nhiên thú vị. Có lẽ sự ra đời của GPU nằm trong số
đó. Xuất phát từ một ý tưởng ban đầu là tăng tốc xử lý các điểm ảnh trên màn
hình (dùng trong các phần mềm games, video), dần dần các nhà công nghệ đã phát
triển các bộ xử lý GPU đa nhân (multi-core), mỗi một core chịu
trác nhiệm xử lý một vùng điểm trên màn hình. Vì vậy việc tăng tốc xử lý màn
hình đồng nghĩa với thiết kế chip GPU tăng số core. Người ta nhận thấy số
core trong một chip GPU tăng theo cấp số nhân hàng năm. Khác với quy luật
Moore (Moore’s Law: số lượng bóng bán dẫn trên một chip liên tục tăng gấp đôi
trong chu kỳ từ 18 đến 24 tháng) đang tiến dần đến giới hạn thì việc tăng số core
trong GPU có cảm giác như chỉ mới bắt đầu và chưa thấy giới hạn trong tương lai
gần.
Tất nhiên chúng
ta hiểu rằng GPU đóng góp vào dòng chảy chung của điện toán song song (parallel
computing) như điện toán đa nhân (multi-core computing), điện toán
phân tán (distributed computing), điện toán cụm (cluster computing),
điện toán lưới (grid computing), …
Tuy nhiên, GPU
có một điểm rất thú vị: phần cứng của GPU phát triển gắn liền với phần mềm, đặc
biệt gắn liền với Machine Learning. Vì sao vậy? Nếu chúng ta soi các
phép toán của mạng nơ-ron (Neural Network) thì việc tính toán tại các
nút đơn thuần là nhân ma trận (xem hình vẽ). Việc tính toán cả ở chiều xuôi (feedforward)
và chiều ngược (backpropagation) đều là các phép nhân ma trận. Hiển
nhiên, phép nhân ma trận chẳng có gì là bí hiểm cả (tổ hợp phép nhân và phép cộng
của các số vô hướng). Chỉ có một nhược điểm là mất nhiều thời gian để tính
toán. Cái mà người ta có ý tưởng tăng tốc phép nhân ma trận là họ “cứng hóa”
phép toán này: sản xuất chip GPU với nhiều Tensor core. (Tensor
là một cách gọi mỹ miều của mảng dữ liệu N chiều.) Với Tensor core thì
phép nhân ma trận được thực hiện chỉ trong vài nhịp đồng hồ máy thay vì phải
hàng trăm, hàng ngàn nhịp. Nvidia là công ty sản xuất chip GPU với nhiều Tensor
core. (Nhân tiện thông tin thêm: tính đến tháng 3/2023, Nvidia chiếm 84% thị
phần GPU, AMD 12%, trong lúc Intel chỉ chiếm 4%.)
Cùng ý tưởng
như Tensor core, Google sản xuất chip TPU (TensorFlow Processing Unit).
Chỉ có điều là TPU chỉ dùng trong các ứng dụng của chính Google, họ không bán
chip này ra thị trường. Google cung cấp dịch vụ điện toán đám mây có TPU. Thuê
bao có thể huấn luyện các mô hình chạy trên đám mây của Google mà không cần phải
mua sắm thiết bị.
Ví dụ về một mạng
nơ-ron (Neural Network).
-
Có thể nói xu
thế phát triển phần cứng đang dần gắn liền với phát triển phần mềm, phần cứng
có chỗ được “mềm hóa” và phần mềm có một số phép toán được “cứng hóa”.
-
Trước khi kết
thúc bài post, tôi trân trọng mời anh/chị một tách cà phê với họa tiết nền GPU.
Credit: Bing Image Creator, powered by DALL·E.
-
Ⓕ. Phụ lục: Nội dung file “CPU, GPU comparison.ipynb”
CPU
[1] import
torch
---
[2] %%timeit
z
= torch.randn(1,1)
result
= torch.matmul(z,z)
del
z, result
-
8.38 µs ± 1.63 µs per loop (mean ± std. dev. of 7 runs,
100000 loops each)
---
[3] %%timeit
z
= torch.randn(10000,10000)
result
= torch.matmul(z,z)
del
z, result
-
32.7 s ± 320 ms per loop (mean ± std. dev. of 7 runs, 1 loop
each)
GPU
Trước khi xử
lý, chuyển sang chế độ chạy bằng GPU. Cách chuyển như sau:
·
Click
menu Runtime
·
Chọn
Change Runtime Type
·
Chọn
GPU
[4] import
torch
---
[5] device
= torch.device('cuda' if torch.cuda.is_available() else 'cpu')
---
[6] %%timeit
z
= torch.randn(1,1).to(device)
result
= torch.matmul(z,z)
del
z, result
-
The slowest run took 5.38 times longer than the fastest.
This could mean that an intermediate result is being cached.
73.6 µs ± 63.2 µs per loop (mean ± std. dev. of 7 runs, 1
loop each)
---
[7] %%timeit
z
= torch.randn(10000,10000).to(device)
result
= torch.matmul(z,z)
del
z, result
-
988 ms ± 193 ms per loop (mean ± std. dev. of 7 runs, 1 loop
each)