Ví dụ tính toán song song bằng CUDA

Một phần của tài liệu Xây dựng thuật toán song song tìm đường đi ngắn nhất với CUDA báo cáo nghiên cứu khoa học sinh viên (Trang 42)

5. Bố cục đề tài

2.1.7.Ví dụ tính toán song song bằng CUDA

Cộng hai số nguyên: Ví dụ này cho thấy cách thức viết một hàm chạy trên thiết bị

(device) và được triệu gọi ra sao:

Cộng hai số nguyên a và b kết quả được đưa vào số nguyên kết quả c. Chú ý là dùng kiểu con trỏ cho các biến.

Code tuần tự

void CongHaiSoNguyen(int *a,int *b, int *c)

{ *c=*a+*b; } void main() { int *a,*b,*c; CongHaiSoNguyen(a,b,c); } Code CUDA

__global__ void KernelCongHaiSoNguyen(int *a,int *b,int *c)

{ *c=*a+*b; } void main() { int *a,*b,*c; *a=1; *b=5; int *deva,*devb,*devc; cudaMalloc((void**)&deva, sizeof(int) );

cudaMalloc((void**)&devb, sizeof(int) );

cudaMalloc((void**)&devc, sizeof(int) );

cudaMemcpy(deva, a, sizeof(int), cudaMemcpyHostToDevice);

cudaMemcpy(devb, b, sizeof(int), cudaMemcpyHostToDevice);

KernelCongHaiSoNguyen<<<1,1>>>(deva, devb, devc);

cudaMemcpy(c, devc, sizeof(int), cudaMemcpyDeviceToHost);

}

Trên đây ta thấy gọi hàm KernelCongHaiSoNguyen khá đặc biệt, ta chỉ cấp 1 luồng để xử lý việc cộng 2 số a và b và kết quả lưu vào c. Ta chưa thấy được việc chạy song song trên thiết bị. Ví dụ này cho ta thấy cách viết một hàm thiết bị và gọi nó như thế nào. Ví dụ cộng hai mảng số nguyên phía sau đây sẽ thực hiện song song trên thiết bị.

Cộng hai mảng số nguyên: Ví dụ này cho thấy được việc song song hóa trên thiết

bị (device).Cộng hai mảng số nguyên a[n] và b[n], kết quả được lưu vào mảng c[n]. Làm cách nào để chúng ta chạy song song trên thiết bị?

- Cách giải quyết thứ nhất là thay cấu hình gọi hàm <<<1,1>>> bằng <<<n,1>>> có nghĩa là cấp n block (mỗi block chỉ có một thread) để thực hiện việc cộng từng phần tử của 2 mảng a và b lưu vào mảng c. Như vậy code song song của chúng ta sẽ là:

__global__ void KernelAdd(int *a, int *b, int *c) {

c[blockIdx.x]= a[blockIdx.x] + b[blockIdx.x]; }

Trên thiết bị, mỗi block sẽ thực hiện song song:

 Block 0 thực hiện: c[0]= a[0] + b[0];

 Block 1 thực hiện: c[1]= a[1] + b[1];

 Block 2 thực hiện: c[2]= a[2] + b[2];

 Block 3 thực hiện: c[3]= a[3] + b[3];

- Cách giải quyết thứ hai là thay vì ta dùng block để song song , ta có thể dùng luồng (threads) để song song, cấu hình gọi hàm sẽ là <<<1,n>>> (một block với nhiều luồng):

Code song song của chúng ta sẽ là :

__global__ void KernelAdd(int *a, int *b, int *c) {

c[threadIdx.x]= a[threadIdx.x] + b[threadIdx.x]; }

Như vậy chúng ta đã thấy việc song song dùng : (adsbygoogle = window.adsbygoogle || []).push({});

 Nhiều block với một thread cho mỗi block.

 Một block với nhiều luồng.

- Cách giải quyết thứ ba là kết hợp cả block và thread : Không còn đơn giản như việc dùng blockIdx.x và threadIdx.x nữa, ta hãy xem cách đánh chỉ số của một mảng với một phần tử của mảng cho mỗi thread (8thread/block) trong Hình 2.8.

Hình 2.8: Phương pháp đánh chỉ số luồng.

 Với M thread/block, một chỉ số duy nhất cho mỗi thread sẽ là:

int index = threadIdx.x + blockIdx.x * M;

 Dùng biến built-in blockDim.x (tương ứng với số lượng thread trong một block) thay cho M ta được :

int index = threadIdx.x + blockIdx.x * blockDim.x;

 Vậy code song song của chúng ta sẽ là:

{

int index= threadIdx.x + blockIdx.x * blockDim.x; c[index]= a[index] + b[index];

}

void main() {

……;

KernelAdd<<<n/ThreadperBlock, ThreadperBlock >>>(deva,devb,devc); ……;

}

Như vậy ta đã biết song song hóa trên thiết bị dùng block hoặc thread hoặc kết hợp cả hai. Nhưng ở đây ta mới song song hóa được một chiều x (threadIdx.x hay blockIdx.x ) còn chiều y (threadIdx.y hay blockIdx.y hay blockDim.y) ta chưa tận dụng được cơ chế song song hai chiều, ví dụ cộng hai ma trận vuông dưới đây sẽ cho ta thấy được điều đó.

Cộng hai ma trận vuông: Cộng hai ma trận A[n,n] và B[n,n], kết quả trả về ma trận C[n][n].

Hình 2.9 : Cộng hai ma trận

- Lấy ma trân A[n,n] làm ví dụ. Giả sử ta chia ma trận A[n,n] thành các ma trận con, mỗi ma trận con chứa phần tử của ma trận A[n,n] , tương ứng như vậy với một lưới (grid) ta chia thành từng khối (block) mỗi khối sẽ chứa luồng (thread) như Hình 2.10 sau:

Hình 2.10 : Cơ chế hai chiều của block và thread - Code song song sẽ như sau:

__global__ void KernelAddMaTran(int *A, int *B, int *C, int n) {

int tidX = blockIdx.x * blockDim.x + threadIdx.x; int tidY = blockIdx.y * blockDim.y + threadIdx.y; if (tidX < n && tidY < n) {

int index = tidY * n + tidX;

A[index] = B[index] + C[index]; } }

void main() { …..;

dim3 grid(XBLOCKS,YBLOCKS); // 2 chiều

KernelAddMaTran<<<grid,block>>>(devA, devB, devC, n); …..;

}

- Giải thích : (adsbygoogle = window.adsbygoogle || []).push({});

 Yêu cầu CUDA tạo cho ta số thread (luồng) bằng số phần tử trên mỗi ma trận.

 Thread lại được gom thành từng nhóm nhỏ, mỗi nhóm ta gọi là block, trong trường hợp này ta yêu cầu 1 block là (XTHREADS, YTHREADS) có nghĩa là mỗi block sẽ chứa XTHREADS * YTHREADS, giả sử XTHREADS= 16 và YTHREADS= 16 vậy 1 block sẽ chứa 16*16= 256 thread , tương ứng như vậy một ma trận con sẽ có 256 phần tử( vì mỗi block sẽ xử lý một ma trận vuông nhỏ) trong ma trận lớn.

 Các block này được gom lại thành một grid( hay là ma trận lớn gồm các ma trận con đã được phân ra), vậy grid là tổ hợp các block còn block là tổ hợp các thread (xem Hình 2.10).

 Vậy làm sao ta xác định chính xác thread nào sẽ xử lý phần tử nào, đó là: Trong hàm kernel, thread trong mỗi block được xác định thông qua chỉ mục threadIdx.x theo phương x và threadIdx.y theo phương y, tương tự block trong một grid được xác định thông qua chỉ mục blockIdx.x và blockIdx.y, số lượng thread trong mỗi block được xác định thông qua (XTHREADS, YTHREADS) mà ta đã khai báo hoặc ta có thể truy cập theo cách khác dùng blockDim.x và blockDim.y.

 Như vậy ta xác định chỉ số index của thread trong grid thông qua: int tidX = blockIdx.x * blockDim.x + threadIdx.x;

int tidY = blockIdx.y * blockDim.y + threadIdx.y; int index = tidY * n + tidX;

 Giả xử trong mỗi block có 256 thread, nên số lượng thread trong một grid sẽ là bội số của 256 (ví dụ có 2 block thì 255*2 ), điều gì xảy ra khi cần tính một

ma trận vuông 30x30, khi đó CUDA driver sẽ tạo cho ta bốn block mỗi block là 256 thread. Ta thử làm một phép trừ 256*4 – 30*30= 124 , tức là sẽ có 124 thread sẽ không thực hiện do ma trận của ta chỉ có 30*30= 900 phần tử. Vì vậy thread nào có chỉ số tidX < 30 và tidY <30 thì được hoạt động.

if (tidX < n && tidY < n)

2.2. Thuật toán tìm đƣờng đi ngắn nhất 2.2.1. Mở đầu

Trong đời sống, chúng ta thường gặp những tình huống như sau: để đi từ địa điểm A đến địa điểm B trong thành phố, có nhiều đường đi, nhiều cách đi, có lúc ta chọn đường đi ngắn nhất (theo nghĩa cự ly), có lúc lại cần chọn đường đi nhanh nhất (theo nghĩa thời gian) và có lúc phải cân nhắc để chọn đường đi rẻ tiền nhất (theo nghĩa chi phí), v.v...

Có thể coi sơ đồ của đường đi từ A đến B trong thành phố là một đồ thị, với đỉnh là các giao lộ (A và B coi như giao lộ), cạnh là đoạn đường nối hai giao lộ. Trên mỗi cạnh của đồ thị này, ta gán một số dương, ứng với chiều dài của đoạn đường, thời gian đi đoạn đường hoặc cước phí vận chuyển trên đoạn đường đó,…

Hiện nay có rất nhiều thuật toán tìm đường đi ngắn nhất trên đồ thị, phải kể đến ba thuật toán kinh điển đó là Dijktra, Ford Bellman, Floyd sẽ được nghiên cứu trong bài nghiên cứu này.

2.2.2. Các khái niệm mở đầu

Xét đồ thị có hướng G = (V,E) với V là tập các đỉnh, E là tập các cạnh, với các cung được gán trọng số, nghĩa là mỗi cung (u,v)E của nó được đặt tương ứng với một số thực a(u,v) gọi là trọng số của nó. Chúng ta sẽ đặt a(u,v)= 0, nếu (u,v)E . Nếu dãy v0, v1 , ... , vp là một đường đi trên G, thì độ dài của nó được định nghĩa là tổng sau:

p

∑a(vi-1, vi)

i=1

tức là , độ dài của đường đi chính là tổng các trọng số trên các cung của nó (chú ý rằng nếu chúng ta gán trọng số cho tất cả các cung đều bằng 1, thì ta thu được định nghĩa độ dài đường đi như là số cung của đường đi).

Bài toán tìm đường đi ngắn nhất trên đồ thị dưới dạng tổng quát có thể được phát biểu dưới dạng tổng quát như sau: Tìm đường đi có độ dài nhỏ nhất từ một đỉnh xuất phát SV đến đỉnh cuối (đích) tV. Đường đi như vậy sẽ gọi là đường đi ngắn nhất từ S đến t còn độ dài của nó sẽ kí hiệu là d(S,t) và còn gọi là khoảng cách từ S đến t (khoảng cách định nghĩa như vậy có thể là số âm ). Nếu như không tồn tại đường đi từ S đến t thì ta đặt d(S,t)= + từ đó ta thấy chu trình trong đồ thị có độ dài dương, thì trong đường đi ngắn nhất không có đỉnh nào lặp lại (đường đi như thế gọi là đường đi cơ bản).

Mặt khác, nếu trong đồ thị có chu trình với độ dài âm (gọi là chu trình âm) thì khoảng cách giữa 1 số cặp đỉnh nào đó của đồ thị có thể là không xác định, bởi vì, bằng cách đi vòng theo chu trình này một số đủ lớn lần, ta có thể chỉ ra đường đi giữa các đỉnh này có độ dài nhỏ hơn bất kì số thực cho trước nào. Trong trường hợp như vậy, có thể đặt vấn đề tìm đường đi cơ bản ngắn nhất, tuy nhiên bài toán đặt ra sẽ trở nên phức tạp hơn rất nhiều, bởi vì nó chứa bài toán xét sự tồn tại đường đi Hamintơn trong đồ thị như là một trường hợp riêng.

Trước hết cần chú ý rằng nếu biết khoảng cách từ S đến t, thì đường đi ngắn nhất từ S đến t, trong trường hợp trọng số không âm, có thể tìm một cách dễ dàng. Để tìm đường đi, chỉ cần chú ý là đối với cặp đỉnh S, tV tuỳ ý (St) luôn tìm được đỉnh v sao cho:

d(S,t)= d(S,v) + a(v,t)

Thực vậy đỉnh v như vậy chính là đỉnh đi trước đỉnh t trong đường đi ngắn nhất từ S đến t. Tiếp theo ta có thể tìm được u sao cho d(S,v)= d(S,u) + a(u,v),... Từ giả thiết về tính không âm của các trọng số dễ dàng suy ra rằng dãy t, v, u... không chứa đỉnh lặp lại và kết thúc ở đỉnh S. Rõ ràng dãy thu được xác định đường đi ngắn nhất từ S đến t.

2.2.3. Đƣờng đi ngắn nhất xuất phát từ một đỉnh

Phần lớn các thuật toán tìm khoảng cách giữa hai đỉnh S và t được xây dựng nhờ kỹ thuật tính toán mà ta có thể mô tả đại thể như sau: từ ma trận trọng số a[u,v], u, vV, ta tính cận trên d[v] của khoảng cách từ s đến tất cả các đỉnh vV. Mỗi khi phát hiện d[u]+a[u, v] < d[v] cận trên d[v] sẽ được tốt lên: d[v] = d[u] + a[u,v].

Quá trình đó sẽ kết thúc khi nào chúng ta không làm tốt thêm được bất cứ cận trên nào. Khi đó, rõ ràng giá trị của mỗi d[v] sẽ cho ta khoảng cách từ mỗi đỉnh S đến v. Khi thể hiện kỹ thuật tính toán này trên máy tính, cận trên d[v] sẽ được gọi là nhãn của đỉnh v, còn việc tính lại các cận trên này sẽ gọi là phép gán nhãn cho đồ thị và toàn bộ thủ tục thường gọi là thủ tục gán nhãn. Nhận thấy rằng để tính khoảng cách từ S đến tất cả các đỉnh còn lại của đồ thị. Hiện nay vẫn chưa biết thuật toán nào cho phép tìm đường đi ngắn nhất giữa hai đỉnh làm việc thực sự hiệu quả hơn những thuật toán tìm đường đi ngắn nhất từ một đỉnh đến tất cả các đỉnh còn lại.

Sơ đồ tính toán mà ta vừa mô tả còn chưa xác định, bởi vì còn phải chỉ ra thứ tự chọn các đỉnh u và v để kiểm tra điều kiện d[u] + a[u,v] < d[v] . Thứ tự chọn này có ảnh hưởng rất lớn đến hiệu quả thuật toán.

2.2.3.1. Thuật toán Dijkstra

Trong trường hợp trọng số trên các cung là không âm thuật toán do Dijkstra đề nghị để giải quyết bài toán tìm đường đi ngắn nhất từ đỉnh s đến các đỉnh còn lại của đồ thị làm việc hữu hiệu hơn rất nhiều so với thuật toán khác. Thuật toán được xây dựng trên cơ sở gán cho các đỉnh các nhãn tạm thời. Nhãn của mỗi đỉnh cho biết cận trên của độ dài đường đi ngắn nhất từ S đến nó. Các nhãn này sẽ được biến đổi theo thủ tục lặp, mà ở mỗi một bước lặp có một nhãn tạm thời trở thành nhãn cố định . Nếu nhãn của một đỉnh nào đó trở thành cố định thì nó sẽ cho ta không phải là cận trên mà là độ dài đường đi ngắn nhất từ đỉnh s đến nó. Thuật toán được mô tả như sau: (adsbygoogle = window.adsbygoogle || []).push({});

 Bước 1: Khởi tao

 Với đỉnh v  V, gọi nhãn d[v] là độ dài đường đi ngắn nhất từ S (đỉnh bắt đầu) đến v. Ta sẽ tính các nhãn d[v].

 Ban đầu d[v] được khởi gán: d[S] = 0 và d[v] = +nếu không có đường đi trực tiếp từ S đến v ngược lại d[v] = a[S,v] nếu có đường đi trực tiếp từ S đến v.

 Nhãn của mỗi đỉnh có hai trạng thái tự do hay cố đinh, nhãn tự do là nhãn mà có thể tối ưu thêm được nữa, nhãn cố định là nhãn mà không thể tối ưu thêm tức là d[v] đã bằng độ dài đường đi ngắn nhất từ S đến v nên không

thể tối ưu thêm. Để làm điều này ta có thể sử dụng kỹ tuật đánh dấu Mark[v]= true hay Mark[v]= false tùy theo d[v] tự do hay cố định. Ban đầu đặt các nhãn đều tự do.

 Bước 2: Lặp

- Bước lặp gồm hai thao tác:

 Cố định nhãn: Chọn trong các đỉnh có nhãn tự do, lấy ra đỉnh u là đỉnh có d[u] nhỏ nhất, và cố định nhãn đỉnh d[u].

 Sửa nhãn (tối ưu): Dùng đỉnh u, xét tất cả các đỉnh v và sửa lại các d[v] theo công thức: d[v] = min(d[v], d[u] + a[u,v]).

- Bước lặp sẽ kết thúc khi mà không còn đỉnh nào để cố định nữa (tức là tìm được đường đi ngắn nhất từ S đến tất cả các đỉnh còn lại), hoặc tại thao tác cố định nhãn, tất cả các đỉnh tự do đều có nhãn bằng +.

 Bước 3: Kết hợp với việc lưu vết đường đi trên từng bước sửa nhãn, thông báo đường đi ngắn nhất tìm được, hoặc thông báo không tìm thấy đường đi xuất phát từ đỉnh S.

Có thể đặt câu hỏi ở bước cố định nhãn, tại sao đỉnh u như vậy được cố định nhãn, giả sử d[u] còn có thể tối ưu thêm được nữa thì tất phải có một đỉnh t mang nhãn tự do sao cho d[u] > d[t] + d[t,u]. Do trọng số a[t,u] không âm nên d[u] > d[t] , trái với cách chọn d[u] là nhỏ nhất. Tất nhiên trong lần đầu tiên thì S là đỉnh được cố định nhãn do d[S]= 0.

2.2.3.2. Thuật toán Ford Bellman

Thuật toán Ford Bellman có thể được phát biểu đơn giản như sau:

Với đỉnh xuất phát S. Gọi d[v] là khoảng cách từ S đến v với các giá trị khởi tạo là: d[S]= 0, d[v]= a[S,v] nếu có đường đi trực tiếp từ S đến v ngược lại d[v]=+ nếu không có đường đi trực tiếp từ S đến v.

Sau đó ta tối ưu hóa dần các nhãn d[v] như sau :

Xét mọi cặp đỉnh u,v của đồ thị, nếu có một cặp đỉnh u,v mà d[v] > d[u] + a[u,v], tức là nếu độ dài đường đi từ S đến v lớn hơn tổng độ dài đường đi từ S đến v cộng với

chi phí đi từ u đến v. Thì ta sẽ hủy bỏ đường đi từ S đến v đang có và coi đường đi từ S đến v chính là đường đi từ S đến u sau đó đi tiếp từ u đến v. Chú ý rằng đặt a[u,v] = + nếu (u,v) không là cung. Thuật toán sẽ kết thúc nếu như không thể tối ưu thêm bất kỳ một nhãn d[v] nào nữa.

Tính đúng của thuật toán:

- Tại bước khởi tạo thì mỗi d[v] chính là độ dài ngắn nhất của đường đi từ S tới v không quá 0 cạnh.

- Giả sử khi bắt đầu bước lặp thứ i (i ≥ 1), d[v] đã bằng độ dài đường đi ngắn nhất từ S tới v qua không quá i-1 cạnh. Do tính chất đường đi từ S tới v không quá i cạnh sẽ phải thành lập bằng cách: Lấy một đường đi từ S tới một đỉnh u nào đó qua không quá i-1 cạnh, rồi đi tiếp tới v bằng cung (u,v), nên độ dài đường đi ngắn nhất từ S tới v qua không quá i cạnh sẽ được tính bằng giá trị nhỏ nhất trong các giá trị (nguyên lý tối ưu Bellman):

 Độ dài đường đi ngắn nhất từ S tới v không quá i-1 cạnh

 Độ dài đường đi ngắn nhất từ S tới u không quá i-1 cạnh cộng với số cạnh (u,v), (∀u). Vì vậy sau bước lặp tối ưu các d[v] bằng công thức:

d[v]bước i = min(d[v]bước i-1, d[u]bước i-1 + c[u, v]), (∀u)

thì các d[v] sẽ bằng độ dài đường đi ngắn nhất từ S tới v qua không quá i cạnh. - Sau bước lặp tối ưu thứ n-1, ta có d[v] = độ dài đường đi ngắn nhất từ S tới v qua

không quá n-1 cạnh. Vì đồ thị không có chu trình âm nên sẽ có một đường đi ngắn

Một phần của tài liệu Xây dựng thuật toán song song tìm đường đi ngắn nhất với CUDA báo cáo nghiên cứu khoa học sinh viên (Trang 42)