5. Bố cục đề tài
2.3.3. Một số công trình tiêu biểu
“Accelerating large graph algorithms on the GPU using CUDA” của nhóm tác giả
Pawan Harish và P. J. Narayanan:
Bài báo này đề cập đến các thuật toán đồ thị (Graph Algorithms) và thực thi CUDA (CUDA Implementation). Cách biểu diễn đồ thị ở đây là dùng danh sách kề
(Adjacency list).
Breadth First Search (BFS)
Giải quyết BFS dùng level synchronization. BFS đi qua đồ thị theo nhiều cấp
(level). Khi một cấp được truy cập thì không được truy cập lại lần nữa. Biên BFS (The BFS frontier) tướng ứng với tất cả các nút đang xử lý ở cấp hiện tại (current level). Không duy trì hàng đợi cho mỗi đỉnh trong quá trình BFS thực thi bởi vì sẽ gánh chịu thêm các chi phí của việc duy trì các chỉ mục của mảng mới (new array indices) và thay đổi cấu hình của lưới (grid) ở mọi cấp độ của việc thực hiện nhân (kernel). Điều này làm chậm tốc độ thực hiện của mô hình CUDA.
Cung cấp luồng (threads) ánh xạ tới đỉnh (vertexs). Hai mảng kiểu logic (kiểu boolen) là frontier và visited tương ứng là Fa và Xa có kích thước là |V| được tạo để chứa
biên BFS (BFS frontier) và các đỉnh được truy cập (visited vertices). Và một mảng số
nguyên lưu chi phí (cost) là Ca chứa số lượng tối thiểu các cạnh của mỗi đỉnh từ đỉnh nguồn S. Trong mỗi lần lặp mỗi đỉnh sẽ kiểm tra mảng Fa. Nếu là đúng thì cập nhật lại Ca và cập nhật tất cả các chi phí của cạnh lân cận nếu lớn hơn chi phí của nó cộng thêm 1
dùng danh sách cạnh Ea. Đỉnh sẽ loại bỏ giá tri của nó từ mảng Fa và thêm vào mảng Xa. Đỉnh này cũng thêm các đỉnh lân cận với nó vào mảng Fa nếu đỉnh lân cận là chưa được truy cập. Quá trình này lặp cho tới khi Fa là rỗng.
Algorithm 1: CUDA BFS (Graph G(V;E), Source Vertex S)
1: Create vertex array Va from all vertices and edge Array Ea from all edges in G(V;E),
2: Create frontier array Fa, visited array Xa and cost array Ca of size V. 3: Initialize Fa , Xa to false and Ca to
4: Fa[S]= true, Ca[S]= 0 5: while Fa not Empty do
6: for each vertex V in parallel do
7: Invoke CUDA BFS KERNEL(Va; Ea; Fa; Xa; Ca ) on the grid. 8: end for
9: end while
Algorithm 2: CUDA BFS (Va; Ea; Fa; Xa; Ca ) 1: tid = getThreadID
2: if Fa[tid] then
3: Fa[tid]= false, Xa[tid]= true 4: for all neighbors nid of tid do 5: if NOT Xa[nid] then
6: Ca[nid] = Ca[tid] + 1 7: Fa[nid]= true
8: end if 9: end for 10: end if
Algorithm 1 chạy trên CPU còn Algorithm 2 chạy trên GPU. Vòng lặp while ở
Single Source Shortest Path (SSSP)
SSSP không đi qua đồ thị theo cấp như BFS. Chi phí của mỗi đỉnh đã truy cập có thể thay đổi do một con đường có chi phí thấp được tìm thấy ở mỗi lần lặp.
Thực hiện, dùng mảng Va lưu các đỉnh và Ea lưu các cạnh, một mảng đánh dấu Ma kiểu boolean kích thước |V| và một mảng trọng số Wa kích thước |E|. Trong mỗi lần lặp, kiểm tra mảng Ma, nếu đúng thì lấy chi phí hiện tại từ mảng Ca cộng với trọng số của các đỉnh lân cận từ mảng Wa. Chi phí của mỗi đỉnh lân cận được cập nhật nếu lớn hơn chi phí của đỉnh hiện tại cộng với trọng số cạnh. Chi phi mới không phản ánh trong Ca nhưng được cập nhật trong một mảng thay thế là Ua. Tại vị trí cuối việc thực thi của nhân (kernel), kernel 2 so sánh chi phí Ca với Ua nếu Ca lớn hơn Ua thì cập nhật Ca và đánh dấu Ma.
Algorithm 3 CUDA SSSP (Graph G(V;E;W), Source Vertex S)
1: Create vertex array Va , edge array Ea and weight array Wa from G(V;E;W) 2: Create mask array Ma , cost array Ca and Updating cost array Ua of size V 3: Initialize mask Ma to false, cost array Ca and Updating cost array Ua to
4: Ma[S] = true, Ca[S] =0, Ua[S] =0 5: while Ma not Empty do
6: for each vertex V in parallel do
7: Invoke CUDA SSSP KERNEL1(Va;Ea;Wa;Ma;Ca;Ua) on the grid 8: Invoke CUDA SSSP KERNEL2(Va;Ea;Wa;Ma;Ca;Ua) on the grid 9: end for
10: end while
Algorithm 4 CUDA SSSP KERNEL1(Va; Ea; Wa; Ma; Ca; Ua) 1: tid = getThreadID
2: if Ma[tid] then 3: Ma [tid]= false
4: for all neighbors nid of tid do
6: Ua[nid]= Ca[tid] + Wa[nid] 7: end if
8: end for 9: end if
Algorithm 5 CUDA SSSP KERNEL2(Va; Ea; Wa; Ma; Ca; Ua) 1: tid = getThreadID
2: if Ca[tid] > Ua[tid] then 3: Ca[tid]= Ua[tid] 4: Ma[tid]= true 5: end if
6: Ua[tid]= Ca[tid]
All Pairs Shortest Path
Đề cập đến thuật toán Floyd, dùng ma trận kề để biểu diễn đồ thị, dùng V2 luồng (threads) chạy song song. Thuật toán Floyd cũng có thể dùng O(V) luồng, mỗi luồng sẽ chạy một vòng lặp bên trong nó. Việc tiếp cận này chậm hơn nhiều so với dùng V2 luồng vì truy cập tuần tự của toàn bộ mảng bởi một luồng. Ví dụ trên đồ thị một nghìn đỉnh nó mất khoảng 9 giây so với 1 giây được thực hiện bởi Algorithm 6.
Algorithm 6 Parallel-Floyd-Warshall (G(V;E;W))
1: Create adjacency Matrix A from G(V;E;W) 2: for k from 1 to V do
3: for all Elements in the Adjacency Matrix A, where i ≥ 1; V ≥ j in parallel do 4: A[i; j]=min(A[i; j], A[i;k]+A[k; j])
5: end for 6: end for
Đoạn mã CUDA kernel sẽ được thực hiện ở dòng số bốn của Algorithm 6. Phần
“Parallel Graph Searching Algorithms using CUDA” của tác giả J. M. Kemp.
Bài báo này đề cập đến các thuật toán đồ thị như Breadth First Search (BFS),
thuật toán Dijkstra.
Breadth First Search (BFS)
Đối với BFS, ánh xạ luồng (thread) tới đỉnh thể hiện ở hình 2.11. Sử dụng một block có kích thước 512, số lượng của block và do đó threads là được tính toán thông qua số lượng đỉnh trong đồ thị đó để tìm kiếm.
Hình 2.11: Ánh xạ threads đến vertices
Cũng như mảng đồ thị, Va là mảng chứa các đỉnh và Ea là mảng chứa các cạnh, fa là mảng biên (frontier array). Mỗi đỉnh sẽ có thread riêng của mình. Thread ID (tid) là một số nguyên mà có thể được ánh xạ tới một đỉnh, đỉnh được định danh là một số nguyên (được thể hiện trong hình 2.11). Trong mỗi lần lặp của kernel, mỗi đỉnh sẽ kiểm tra trong mục fa của mình. Nếu là đúng thì đỉnh đang xét đánh dấu lại các đỉnh lân cận của nó là đúng trong fa. Kernel sẽ kiểm tra xem nếu đỉnh mục tiêu là đúng trong fa thì kernel sẽ dừng và việc tìm kiếm là hoàn tất. Nếu không quá trình này cứ tiếp tục lặp đi lặp lại cho đến khi đỉnh mục tiêu là được tìm thấy hoặc tất cả nội dung trong fa là đúng.
Ban đầu, lời gọi kernel được đặt bên trong một vòng lặp cũa đoạn mã trên host. Sau mỗi lần thực hiện kernel, fa được sao chép trở lại host để kiểm tra đỉnh mục tiêu đã
được tìm thấy hay không, hoặc nội dung trong fa là đúng. Thời gian dành cho việc sao chép dữ liệu từ device sau mỗi lần thực hiện kernel là tốn kém, do đó việc thiết kế các thuật toán đã thay đổi để giữ cho việc kiểm tra này thực hiện bên trong kernel, loại bỏ việc chuyển giao dữ liệu cải thiện đáng kể hiệu suất của thuật toán.
Thuật toán Dijkstra
Việc thực hiện thuật toán Dijkstra cũng tương tự như BFS. Tuy nhiên, cần thêm một mảng Ca lưu chi phí đường đi từ một đỉnh đến đích và một mảng cờ fla để theo dõi các phần tử trong Ca hiện đang được cập nhật. Giống như BFS, threads là được ánh xạ đến vertex với kích thước block là 512 là số lượng được tính toán thông qua số đỉnh của đồ thị. Trong quá trình thực hiện kernel, mỗi đỉnh kiểm tra trong mục fa của mình dùng thread ID (tid). Nếu giá trị là đúng thì nó đánh dấu tất cả các đỉnh lân cận trong fa là đúng. Từ đây nó đánh dấu các đỉnh có mục tương ứng trong fla, đỉnh sau đó tìm kiếm các đỉnh lân cận trong Ca và cập nhật nếu chi phí nhận được từ đỉnh hiện tại tới một đỉnh lân cận là nhỏ hơn. Tương ứng như vậy các mục tong fla là được đánh dấu là sai (false). Mục đích của fla là ngăn chặn các thread khác truy cập vào cùng một mục trong Ca. Nếu không có sự ngăn chặn này thì có thể xảy ra mâu thuẫn với hai thread cùng đọc một giá trị và cập nhật cùng một lúc, kết quả là không chính xác.
CHƢƠNG 3: XÂY DỰNG THUẬT TOÁN SONG SONG TÌM ĐƢỜNG ĐI NGẮN NHẤT VỚI CUDA
3.1. Song song hóa một số thuật toán tuần tự tìm đƣờng đi ngắn nhất 3.1.1. Song song hóa thuật toán Dijkstra
Trước tiên ta hãy đi phân tích thuật toán Dijkstra tuần tự có thể song song hóa được những phần nào (trình bày ở mục 1.1.4 và mục 1.1.5). Từ đó ta áp dụng phương pháp trình bày ở mục 1.1.6 để thực hiện việc song song.
Thuật toán Dijkstra tuần tự (xem ở mục 2.2.3.1) Phần khởi tạo
for v V do
Begin
d[v]=+; // không có đướng đi từ S đến v
if a[S,v]>0 // nếu có đường đi từ S đến v thì Begin
d[v]= a[S,v]; End
Mark[v]=0; // đánh dấu đỉnh tự do (tức chưa được chọn để đi) pre[v]=S;// trước v là S
End
Phần lặp (phần tối ưu nhãn d[v] và lưu vết pre)
Lặp n-1 lần // vì lần bước khởi tạo đã làm 1 bước rồi cho nên làm n-1 bước
còn lại Begin
Tìm đỉnh u thuộc V mà u là đỉnh tự do thỏa mãn sao cho d[u] có giá trị nhỏ nhất
Mark[u]=1; // đánh dấu u là nhãn cố định. for v V do
d[v] = d[u] + a[u,v]; // cập nhận lại nhãn d[v] pre[v]= u; // cập nhật lại trước v là u }
End
Để song song hóa thuật toán Dijkstra trước tiên ta chia thuật toán Dijkstra thành hai phần để dễ dàng song song hóa:
Phần khởi tạo :
Quan sát ở phần phía trên ta thấy một vòng lặp for duyệt qua tất cả các đỉnh (v V). Và gán từng phần tử của mảng d và pre và Mark tương ứng với mỗi đỉnh bằng một giá trị nhất định.
pre S S S S S
Mark 0 0 0 0 0
d + + A[S,v] + A[S,v]
Ở mảng d ta thấy có điều kiện rẽ nhánh là nếu có cạnh nối trực tiếp từ S đến v thì d[v] sẽ bằng a[S,v].
Từ trên suy ra ta không thấy có một sự phụ thuộc dữ liệu nào giữa các phần tử trong một mảng, vậy nên ta song song hóa được phần khởi tạo này. Mỗi phần tử trong mảng độc lập với nhau và được gán bởi một giá trị. Từ đây ta sẽ phân n tiến trình sẽ thực hiện việc gán giá trị cho các mảng d, pre, Mark.
Code Dijkstra CUDA song song cho phần khởi tạo:
__global__ void KernelInit(int *a, int *d,int *Mark, int *pre, size_t S, size_t n)
{
int tidX = blockIdx.x * blockDim.x + threadIdx.x; // tính chỉ số thread trong //một grid tương ứng với số đỉnh.
index = tidX + S*n; // tính chỉ số index của một phần tử trong ma trận a d[tidX] =vocuc;
pre[tidX] = start; if(a[index])
d[tidX] = a[index]; }
Phần Lặp:
Phần này được chia làm hai phần: Thứ nhất tìm đỉnh u tự do có nhãn d[u] nhỏ nhất, và thứ hai là dùng đỉnh u đó để tối ưu nhãn d[v] các đỉnh tự do v ∈
V còn lại.
Phần thứ nhất tìm đỉnh u có nhãn tự do d[u] nhỏ nhất phần này nhóm tác giả chỉ nêu thuật toán giải quyết chứ chưa thể song song hóa bằng CUDA được do hạn chế về kiến thức CUDA: Ta quan sát code tìm đỉnh u bé nhất trong nhãn d[u].
for(int u= 0; u < n; u++)
if(d[u]< minn && Mark[u]== 0) minn = d[u];
Ta thấy nó có vẻ như dữ liệu phụ thuộc với nhau, do phải duyệt tuần tự so sánh từng giá trị trong mảng d với giá trị minn để cập nhật lại giá trị minn có giá trị nhỏ nhất trong mảng d (trường hợp với các đỉnh tự do Mark[u]==0). Thật ra ta có cách phân chia bài toán này thành các phần nhỏ hơn tương đối đập lập với nhau (áp dụng ở mục 1.1.4) như mô tả sau:
2 1 3 9 8 5 4 7
1 3 5 4
1 4
Giả sử ta có mảng d tương ứng như trên, ta chia mảng này thành những phần nhỏ, mỗi phần nhỏ sẽ là gồm 2 phần tử gần kề nhau trong mảng. Ta lần lượt so sánh 2 phần tử kề này với nhau nếu phần tử nào bé hơn sẽ được đưa về phía phần tử bên tay phải (nếu phần tử phía bên tay phải bé hơn thì giữ nguyên vị trí không đổi). Cứ lần lượt như vậy ta sẽ có giá trị bé nhất nằm ở phía cuối của mảng.
Phần này nhóm tác giả vẫn chưa song song hóa được do chưa biết cách phân các thread cho các lần so sánh tiếp theo.
Phần thứ hai dùng đỉnh u để sửa nhãn d[v] (tối ưu). Ta hãy nhìn đoạn code sau :
for (int v=0; v < n; v++)
{
if ((Mark[v] == 0) && (d[u] + a[u*n+v] < d[v]))
{
d[v] = d[u] + a[u*n+v]
pre[v] = u;
}
Ta thấy vòng lặp for duyệt qua tất cả các đỉnh, kiểm tra xem đỉnh nào tự do và thỏa điều kiện d[u] + a[u*n+v] < d[v] thì thực hiện việc tối ưu nhãn d[v] và lưu vết đường đi. Mỗi khi v trong vòng lặp for tăng thì tương ứng nhãn chỉ số v trong nhãn d[v] và pre[v] cũng tăng, tương ứng vậy ma trận a cũng vậy chỉ số v ở cột cũng tăng, do đó không có sự phụ thuộc dữ liệu. Ví dụ: giả sử như thỏa điều kiện: if ((Mark[v] == 0) && (d[u] + a[u*n+v] < d[v]))
v=0: d[0]= d[u] + a[u*n+0] ; pre[0] = u ; v=1: d[1]= d[u] + a[u*n+1] ; pre[1] = u v=2: d[2]= d[u] + a[u*n+2] ; pre[2] = u v=3: d[3]= d[u] + a[u*n+3] ; pre[3] = u v=4: d[4]= d[u] + a[u*n+4] ; pre[4] = u
v=n-1: d[n-1]= d[u] + a[u*n+n-1] ; pre[n-1] = u
Như vậy ta sẽ song song hóa được đoạn chương trình này, vì dữ liệu của chúng không phụ thuộc vào nhau, khi tính d[1], ta không dùng lại d[0] để
tính d[1]; và tính d[2] ta không dùng lại các nhãn d phía trước đã tính(d[0], d[1]). Đó là giữa các nhãn d[v] với nhau, còn việc d[u] và a[u*n+v] thì do trong hàm này không làm thay đổi 2 giá trị này, mà chỉ dùng nó để cập nhật lại nhãn d[v]. Tương tự pre cũng vậy việc tính toán là độc lập nhau.
Code CUDA song song cho phần tối ưu nhãn d[v]:
__global__ void kernelToiUu (int *a,int *d ,int *Mark, int *pre,size_t u , size_t n)
{
int tidX = blockIdx.x * blockDim.x + threadIdx.x; if (tidX < n)
{ int index = u*n + tidX; if((d[u] + a[index])<d[tidX])
if(Mark[tidX]==0) if(a[index]>0) {
d[tidX]= d[u] + a[index]; pre[tidX]= u;
} }
}
o Giải thích :
tidX = blockIdx.x * blockDim.x + threadIdx.x tức là tính chỉ số thread trong 1 grid ( ở đây mỗi thread tương ứng với mỗi đỉnh).
index = u*n + tidX tức là tính vị trí trong ma trận a ứng với dòng cột là u và tidX, để lấy giá trị a[u, tidX].
3.1.2. Song song hóa thuật toán Ford Bellman
Trước tiên ta hãy đi phân tích thuật toán Ford Bellman tuần tự có thể song song hóa được những phần nào (trình bày ở mục 1.1.4 và mục 1.1.5). Từ đó ta áp dụng phương pháp trình bày ở mục 1.1.6 để thực hiện việc song song.
Thuật toán Ford Bellman tuần tự (xem ở mục 2.2.3.2) Phần khởi tạo chia làm hai phần :
o Khởi tạo ma trận trọng số của đồ thị: Nếu u,v là hai đỉnh bất kỳ của đồ thì mà không có cạnh nối trực tiếp thì gán (u,v) bằng vô cực.
for v V do
for u V do
Nếu a[v,u]== 0 thì a[v,u] =+
o Khởi tạo nhãn d[v] và pre[v] ( với mọi v V). Gọi S là đỉnh xuất phát:
for v V do Begin d[v]= a[S,v]; Pre[v]= S; End Phần tối ưu :
Trong khi còn có thể tối ưu Với mỗi đỉnh u
Với mỗi đỉnh v
Nếu d[v] > d[u] + a[u,v] Begin
d[v] = d[u] + a[u,v]; pre[v]=u ;
Để song song hóa thuật toán Ford Bellman trước tiên ta chia thuật toán thành hai phần để dễ dàng song song hóa:
o Song Song hóa phần khởi tạo Khởi tạo ma trận trọng số
- Quan sát phần khởi tạo ma trận trọng số phía trên ta thấy gán giá trị vô cực cho các cạnh nối giữa hai đỉnh u, v (với mọi u,v thuộc V) của đồ thì nếu hai đỉnh đó không nối trực tiếp với nhau (hay nói cách khác cạnh (u,v) không thuộc E). Giả sử như các cặp đỉnh dưới đây không có cạnh nối trực tiếp: u=0, v=0: a[0,0]= + u=0, v=1: a[0,1]= + u=1, v=0: a[1,0]= + u=1, v=1: a[1,1]= + u=n-1, v=n-1: a[n-1, n-1]= +
- Ta thấy không có sự phụ thuộc dữ liệu giữa các a[u,v] (với mọi u,v thuộc V) với nhau, do khi tính a[0,1] không dùng a[0,0] để tính a[0,1], cứ tương tự như vậy, a[1,0] cũng không dùng a[0,0] hay a[0,1] để tính a[1,0],… - Code CUDA song song cho phần khởi tạo ma trận trọng số
__global__ void KernelKhoiTaoMatran(int *a, size_t 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; if(a[index]==0)
a[index]=VOCUC; }
- Giải thích: Ta thấy không còn hai vòng lặp for nữa (hai vòng lặp for dùng để duyệt ta các đỉnh, để xét tất cả các cạnh nối giữa các đỉnh), còn ở code CUDA này tidX đóng vai trò như đỉnh v (với mọi v thuộc V), tidY đóng vai trò như đỉnh u (với mọi u thuộc V). Còn index là tính chỉ số vị trí