Sự xuất hiện của các CPU đa lõi và GPU nhiều lõi cho thấy rằng xu thế chủ đạo của
các chip xử lý bây giờ là các hệ thống song song. Hơn nữa, đặc tính song song của
chúng tiếp tục gia tăng theo luật của Moore. Sự thách thức là để phát triển phần mềm
ứng dụng mở rộng (scale) trong suốt về tính song song để tận dụng được sự gia tăng về
số lượng của các lõi xử lý, càng có nhiều các ứng dụng 3D mở rộng (scale) một cách
trong suốt tính song song của nó tới các GPU nhiều lõi với các số lượng thay đổi lớn
của các lõi.
Mô hình lập trình CUDA được thiết kế để vượt qua thách thức này trong khi vẫn duy
trì một độ khó về học tập thấp cho các người lập trình quen thuộc với ngôn ngữ lập
Tại nhân của CUDA là ba khái niệm trừu tượng chính – một cơ chế phân cấp của các
nhóm thread, các bộ nhớ chia sẻ, và rào chắn đồng bộ - đơn giản được bộc lộ tới người
lập trình như là một tập nhỏ các mở rộng về ngôn ngữ.
Các khái niệm trừu tượng này cung cấp cơ chế song song dữ liệu và song song tiến
trình nhỏ được lồng bên trong song song dữ liệu và song song tác vụ lớn. Chúng hướng người lập trình phân chia bài toán thành các bài toán con thô có thể được giải quyết độc
lập một cách song song bởi các block gồm các tiến trình, và mỗi bài toán con thành các
phần mịn hơn mà có thể được giải quyết cùng nhau theo song song bởi tất cả các tiến
trình bên trong block đó. Sự phân chia này biểu hiện khả năng của ngôn ngữ đó là cho
phép các tiến trình phối hợp với nhau để xử lý mỗi bài toán con, và tại cùng một thời
điểm cho phép mở rộng một cách tự động. Quả thực, mỗi block tiến trình có thể được
lập lịch trên bất kỳ số lượng lõi xử lý nào đang có, theo bất cứ thứ tự nào, đồng thời
hay là tuần tự, do đó một chương trình CUDA đã biên dịch có thể chạy trên mọi số
lượng lõi xử lý như được minh họa ở Hình 6, và chỉ vào lúc chạy hệ thống mới cần biết
số lượng lõi xử lý vật lý.
Mô hình lập trình có thể mở rộng này cho phép kiến trúc CUDA mở rộng một phạm vi thương mại rộng lớn băng cách là đơn giản mở rộng số lượng lõi xử lý và các phân vùng bộ nhớ: từ các GPU dành cho tính toán hiệu năng cao GeForce và các sản phẩm
cao cấp Quadro và tính toán Tesla tới một số đa dạng các loại rẻ hơn, dòng chính là các GPU GeForce.
Một chương trình đa luồng được phân chia thành các block tiến trình và chúng được
thực thi độc lập với nhau, do đó một GPU nhiều lõi sẽ tự động thực hiện chương trình
Hình 7: Khả năng tự mở rộng
3.2. Các khái niệm chính
Phần này giới thiệu các khái niệm chính của mô hình lập trình CUDA bằng cách tóm
a. Kernel
CUDA C mở rộng ngôn ngữ C bằng cách cho phép người lập trình định nghĩa các hàm
của C, được gọi là các kernel, mà khi được gọi, sẽ được thực hiện N lần theo song song
bởi N thread CUDA, ngược với chỉ một lần như các hàm thông thường của C.
Một kernel được định nghĩa sử dụng đặc tả khai báo __global__ và số tiến trình CUDA thực hiện kernel đó trong một lần gọi kernel được chỉ ra bằng cách dùng một cú
pháp cấu hình sự thực thi mới <<<...>>> Mỗi tiến trình thực thi kernel được gán một
ID tiến trình duy nhất mà có thể được truy cập bên trong kernel bằng biến có sẵn
threadIdx.
Để minh họa, đoạn mã ví dụ mẫu sau đây thực hiện cộng hai vector A và B có kích
thước N và lưu kết quả vào vector C:
// Kernel definition
__global__ void VecAdd(float* A, float* B, float* C) {
int i = threadIdx.x; C[i] = A[i] + B[i]; }
int main() {
...
// Kernel invocation with N threads VecAdd<<<1, N>>>(A, B, C); }
Ở đây, mỗi tiến trình trong N tiến trình thực thi hàm VecAdd() thực hiện phép cộng
cho một cặp.
b. Phân cấp thread
Theo quy ước, biến threadIdx là một vector có 3 thành phần, do đó các thread có thể
được định danh sử dụng một chỉ số thread một chiều, hai chiều, hoặc ba chiều, để tạo
thành block một chiều, hai chiều, hoặc ba chiều. Cơ chế này cung cấp một cách tự
nhiên để thực hiện tính toán trên các phần tử trong một trường như một vector, ma trận,
hoặc khối.
Chỉ số của một thread và ID của nó liên hệ với nhau rất rõ ràng: Với một block một
chiều, chúng là như nhau; với một block hai chiều có kích thước (Dx ,Dy), ID của
một thread có chỉ số (x,y) là (x + yDx); với một block ba chiều có kích thước (Dx ,Dy
,Dz), ID của một thread có chỉ số (x,y,z) là (x + yDx + z Dy Dz).
Ví dụ, đoạn mã sau đây cộng hai ma trận A và B có kích thước NxN và lưu kết quả vào
ma trận C :
// Kernel definition
__global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N])
{
int i = threadIdx.x; int j = threadIdx.y;
C[i][j] = A[i][j] + B[i][j]; }
int main() {
...
// Kernel invocation with one block of N * N * 1 threads int numBlocks = 1;
dim3 threadsPerBlock(N, N);
MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C); }
Có sự giới hạn về số thread trong một block, bởi vì tất cả thread của một block được dự
kiến ở trên cùng lõi xử lý và phải chia sẻ các tài nguyên bộ nhớ giới hạn cho lõi đó.
Trên các GPU hiện nay, một block có thể chứa được tới 512 thread.
Tuy nhiên, một kernel có thể được thực hiện bởi nhiều block giống nhau, nên tổng số
thread sẽ được tính bằng số thread trong một block nhân với số block.
Các block được tổ chức thành một grid (lưới) một chiều hoặc hai chiều của các block
như minh họa ở Hình 7. Số block trong một grid thường dựa vào kích thước của dư
liệu cần xử lý hoặc số lõi trong hệ thống (mà có thể tăng rất nhanh).
Số thread trong một block và số block trong một grid được chỉ ra trong cú pháp
<<<...>>> và có thể nhận kiểu int hoặc dim3. Các block hai chiều hoặc các grid có thể được chỉ ra như ở ví dụ trên.
Mỗi block bên trong grid có thể được chỉ ra bằng một chỉ số một chiều hoặc hai chiều
và có thể truy cập từ bên trong kernel thông qua biến có sẵn blockIdx. Chiều của block
Hình 8: Lưới của các block
// Kernel definition
float C[N][N]) {
int i = blockIdx.x * blockDim.x + threadIdx.x; int j = blockIdx.y * blockDim.y + threadIdx.y; if (i < N && j < N)
C[i][j] = A[i][j] + B[i][j]; } int main() { ... // Kernel invocation dim3 threadsPerBlock(16, 16);
dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y); MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
}
Một block có kích thước 16x16 (256 tiến trình), (mặc dù là tùy ý trong trường hợp
này), là một lựa chọn thông thường. Grid được xây dựng với số block đủ để có một
tiến trình cho một phần tử của ma trận. Để đơn giản, ví dụ này giả sử số tiến trình trên một chiều của grid có thể chia hết cho số tiến trình trên block theo chiều đó, mặc dù có thể khác như vậy.
Các block được thực hiện độc lập: Và có thể thực hiện các block theo bất cứ thứ tự
nào, song song hoặc tuần tự. Yêu cầu độc lập này cho phép các block được lập lịch
theo mọi thứ tự trên mọi số lượng lõi như minh họa ở Hình 8, và cho phép người lập
Các thread bên trong một block có thể phối hợp bằng cách chia sẻ dữ liệu thông qua
một vài bộ nhớ chia sẻ và bằng cách đồng bộ hóa việc thực thi của chúng để phối hợp
truy cập bộ nhớ. Đặc biệt hơn, có thể chỉ ra các điểm đồng bộ hóa trong kernel bằng việc gọi hàm giới hạn__syncthreads() ; __syncthreads() hoạt động như một rào chắn
mà tại đó tất cả các tiến trình bên trong block phải đợi trước khi một tiến trình nào đó được phép tiếp tục.
Để phối hợp có hiệu quả, bộ nhớ chia sẻ được thiết kế là một bộ nhớ có độ trễ truy cập
thấpvà nằm gần lõi xử lý (phần nhiều giống như một bộ đệm mức 1) và hàm __syncthreads() là một hàm gọn nhẹ (lightweight).
c. Phân cấp bộ nhớ
Các tiến trình CUDA có thể truy cập dữ liệu từ nhiều không gian bộ nhớ trong suốt quá trình thực hiện của chúng. Mỗi tiến trình có bộ nhớ cục bộ riêng. Mỗi block có bộ nhớ
chia sẻ được tất cả các tiến trình của block đó nhìn thấy trong cùng thời gian sống với
block. Tất cả các thread có thể truy cập tới bộ nhớ toàn cục chung (global memory).
Có hai không gian bộ nhớ chỉ đọc (read-only) được bổ sung và có thể truy cập bởi tất
cả các tiến trình: đó là bộ nhớ constant và bộ nhớ texture. Các không gian bộ nhớ
global, constant, và texture được tối ưu cho các mục đích sử dụng bộ nhớ khác nhau.
Bộ nhớ texture cũng đưa ra các chế độ địa chỉ khác nhau, cũng như là việc lọc dư liệu,
cho một vài định dạng dữ liệu đặc biệt
Các bộ nhớ global, constant, và texture được ràng buộc với các khởi chạy (launch) của
kernel bởi cùng ứng dụng.
d. Hệ thống nhiều device
Một hệ thống host có thể có nhiều device. Có thể liệt kê các device, truy vấn các đặc
Một vài các tiến trình host có thể thực thi phần device code trên cùng thiết bị, nhưng
theo thiết kế, một tiến trình host có thể thực thi device code chỉ trên một thiết bị tại một
thời điểm. Do đó, để thực hiện device code trên nhiều thiết bị cần phải có nhiều tiến
trình host. Hơn nữa, mọi tài nguyên CUDA được tạo ra thông qua runtime trong một
tiến trình host không thể được dùng bởi runtime từ tiến trình host khác.
Đoạn mã sau liệt kê tất cả các device trong hệ thống và truy lục các đặc tính của nó.
Nó cũng xác định số thiết bị hỗ trợ CUDA
int deviceCount;
cudaGetDeviceCount(&deviceCount); int device;
for (device = 0; device < deviceCount; ++device) { cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, device); if (dev == 0) {
if (deviceProp.major == 9999 && deviceProp.minor == 9999) printf("There is no device supporting CUDA.\n");
else if (deviceCount == 1)
printf("There is 1 device supporting CUDA\n"); else
printf("There are %d devices supporting CUDA\n", deviceCount);
} }
Theo mặc định, thiết bị được liên kết với tiến trình host được ngầm chọn là device 0 ngay khi một hàm runtime quản lý được gọi. Mọi thiết bị khác có thể được chọn bằng cách gọi hàm cudaSetDevice() trước. Sau khi một device đã được chọn, hoặc
là ngầm hoặc rõ, mọi lời gọi rõ ràng tiếp theo tới cudaSetDevice() sẽ không hiệu
lực cho đến khi hàm cudaThreadExit() được gọi.
cudaThreadExit() xóa bỏ tất cả các tài nguyên liên quan đến runtime đã liên kết với
tiến trình host. Mọi lời gọi API tiếp theo khởi tạo lại runtime.
3.2. Lập trình không đồng nhất
Như minh họa ở Hình 10, mô hình lập trình CUDA cho phép các tiến trình CUDA thực
hiện trên một thiết bị (device) vật lý tách rời và có thể hoạt động như một bộ đồng xử
lý với tiến trình chủ (host) chạy chương trình C. Trong trường hợp này, ví dụ, khi các
kernel thực hiện trên một GPU và phần chương trình C còn lại thực hiện trên một CPU.
Mô hình lập trình CUDA cũng cho phép cả host và device duy trì các vùng bộ nhớ
DRAM tách rời của chúng, được gọi là host memory và device memory tương ứng.
Hình 10: Lập trình không đồng nhất
Do đó, một chương trình quản lý các vùng nhớ global, constant, và texture có thể nhìn
phát bộ nhớ và giải phóng bộ nhớ cũng như truyền dữ liệu giữa bộ nhớ host và bộ nhớ
device.
3.3. Khả năng tính toán.
Khả năng tính toán của một thiết bị được xác định bằng một số revision chính và một
số revision phụ.
Các thiết bị với cùng con số chính là có cùng kiến trúc lõi. Con số chính của các thiết
bị dựa trên kiến trúc Fermi là 2. Các thiết bị đầu tiên đều có khả năng tính toán 1.x
(con số revision chính là 1).
Con số revison phụ tương ứng với một sự cải tiến cho kiến trúc lõi đó, có thể bao gồm các đặc tính mới.
3.4. Giao diện lập trình
Hai giao diện lập trình hiện tại được hỗ trợ để viết các chương trình CUDA là: CUDA C và CUDA driver API. Một ứng dụng thông thường sử dụng một trong hai, nhưng
cũng có thể dử dụng cả hai..
CUDA C thể hiện mô hình lập trình CUDA như là một tập nhỏ các mở rộng của ngôn
ngữ C. Mọi tệp nguồn chứa một số mở rộng này phải được biên dịch bằng nvcc như
tóm tắt trong phần 3.5. Các giới hạn này cho phép người lập trình định nghĩa một
kernel như một hàm của C và sử dụng một số cú pháp mới để chỉ ra chiều của grid và
block mỗi lần hàm kernel được gọi.
CUDA driver API là một API C ở mức thấp hơn cung cấp các hàm để tải các kernel như là các module của mã CUDA dạng nhị phân hoặc các mã assembly, để kiểm tra
các tham số của chúng, và để thực hiện chúng. Các mã nhị phân và mã assembly
thường có được sau khi biên dịch các kernel được viết bằng C.
CUDA C đi cùng với một API runtime và cả API runtime và API driver đều cung cấp
các hàm để cấp phát, giải phóng bộ nhớ thiết bị, truyền dữ liệu dư bộ nhớ host và bộ
API runtime được xây dựng trên đỉnh của CUDA driver API. Việc quản lý khởi tạo,
quản lý ngữ cảnh (context), quản lý module hoàn toàn được ẩn đi và do đó các đoạn mã
rất ngắn gọn. CUDA C cũng hỗ trợ chế độ mô phỏng thiết bị, thường có ích trong việc
gỡ rối
Ngược lại, CUDA driver API yêu cầu nhiều code hơn, khó lập trình và gỡ rối hơn,
nhưng nó đưa ra một sự điều khiển ở mức tốt hơn và độc lập với ngôn ngữ do đó có thể
quản lý mã nhị phân hoặc assembly.
3.5. Biên dịch với NVCC
Các kernel có thể được viết sử dụng kiến trúc tập lệnh CUDA, được gọi là PTX (được
mô tả trong tài liệu PTX reference manual). Tuy nhiên thường thì sử dụng một ngôn
ngữ lập trình cấp cao như là C sẽ hiệu quả hơn. Trong cả hai trường hợp,
các kernel phải được biên dịch thành mã nhị phân bởi nvcc để có thể thực hiện được
trên thiết bị (device).
nvcc là một trình biên dịch đơn giản hóa việc biên dịch mã nguồn C hoặc mã PTX: Nó cung cấp các tùy chọn dòng lệnh đơn giản và quen thuộc và thực thi chúng bằng cách gọi tập các công cụ để thực hiện các pha biên dịch khác nhau. Phần này trình bày
tổng quan về luồng công việc và các tùy chọn dòng lệnh của nvcc. Một mô tả đầy đủ
có thể tìm trong tìm trong tài liệu nvcc user manual.
3.6. CUDA C
CUDA C cung cấp một cách thức đơn giản cho các người sử dụng quen thuộc với ngôn ngữ lập trình C để dễ dàng viết các chương trình để thực thi trên thiết bị.
Nó bao gồm một tập nhỏ các mở rộng của ngôn ngữ C và một thư viện thời gian chạy
thư viện runtime. Một mô tả hoàn chỉnh cho runtime có thể tìm thấy ở trong tài liệu
CUDA reference manual.
runtime được thực hiện trong thư viện động cudart và tất cả các mục của nó đều có tiền
tố cuda.
Không có hàm khởi tạo rõ ràng cho runtime; nó khởi tạo khi một hàm runtime được
gọi lần đầu tiên (chính xác hơn là mọi hàm trừ các hàm từ các phần quản lý phiên bản
và thiết bị trong tài liệu reference manual). Cần chú ý đến điều này khi lựa chọn đúng
lúc (timing) các lời gọi hàm runtime và khi thông dịch mã lỗi từ lời gọi đầu tiên trong runtime.
Một khi runtime đã được khởi tạo trong một tiến trình của host, mọi tài nguyên (bộ