Vào tháng 11/2006, hãng NVIDIA phát hành CUDATM, một kiến trúc tính toán song song đa dụng - với một mô hình lập trình song song và kiến trúc tập lệnh mới - có thể thúc đẩy tính toán song song trong các GPU của NVIDIA để giải quyết nhiều bài toán tính toán phức tạp theo một hướng hiệu năng hơn nhiều so với CPU.
CUDA đi cùng với một môi trường phần mềm cho phép người phát triển sử dụng C như ngôn ngữ lập trình cấp cao. Như minh họa ở hình 2.2, các ngôn ngữ hoặc các giao diện lập trình ứng dụng khác được hỗ trợ, như CUDA FORTRAN, OpenCL, và DirectCompute.
Hình 2. 2. CUDA được thiết kế để hỗ trợ nhiều ngôn ngữ hoặc các API khác nhau
2.2.1. Khả năng mở rộng của CUDA
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 xử lý.
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 trình chuẩn như ngôn ngữ C.
Trong 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 2.3, 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 đó nhanh hơn một GPU có ít lõi hơn.
Hình 2. 3. Khả năng tự mở rộng
2.2.2. Các khái niệm
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 tắt cách chúng được mở rộng từ ngôn ngữ C.
2.2.2.1. 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.
2.2.2.2. 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 2.4. 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 có thể truy cập bên trong kernel thông qua biến có sẵn blockDim.
Đoạn mã sau mở rộng ví dụ MatAdd() trước để xử lý nhiều block. // Kernel definition
__global__ void MatAdd(float A[N][N], float B[N][N], 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ù có thể 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, trong 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 2.4, và cho phép người lập trình viết code có thể mở rộng với số lượng lõi.
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ấp và 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).
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.
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 tính của chúng, và có thể chọn một trong các device để thực hiện kernel.
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.
Hình 2. 5. Phân cấp bộ nhớ
2.2.3. Lập trình không đồng nhất
Như minh họa ở Hình 2.5, 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. Do đó, một chương trình quản lý các vùng nhớ global, constant, và texture có thể nhìn thấy từ kernel thông qua các lời gọi tới các hàm runtime của CUDA. Bao gồm cả cấp 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.
Hình 2. 6. Lập trình không đồng nhất
2.2.4. 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ố 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.