使用cuda C完成矩陣相乘算法詳解

寫在前面:

使用cuda C完成矩陣相乘算法詳解


矩陣相乘大家應該都不陌生。

設有兩個矩陣M和N,假設M和N都是方陣,維度均爲width × width

在這裏插入圖片描述
如果M和N均爲1000 × 1000的矩陣,總共要進行1000000次點乘。其中,每次點乘有1000次乘法和1000次加法。

Matirx Multiply:CPU實現


先來看看使用普通的c代碼在CPU端如何實現

void MatrixMulOnHost(float* M,float* N,float* P,int width)
{
    for(int i=0;i<width;++i)
        for(int j=0;i<width;j++)
        {
            //sum對應每一次點乘(M的某一行×N的某一列)的結果
            float sum = 0;
            for(int k=0;k<width;k++)
            {
                float a = M[i*width+k];
                float b = N[k*width+j];
                sum+=a*b;
            }
            P[i*width+j]=sum;//乘累加的結果放到對應位置上
        }
}

可以看到循環計算結果P矩陣裏的每一個元素。計算過程非常清晰。

從這裏可以看到,這個計算存在非常大的並行性,即結果矩陣P裏的每一個元素結果的計算與P中其他元素是不相關的,沒有依賴性。

所以我們可以在GPU端上實現矩陣相乘。

Matirx Multiply:GPU實現


可以看到總共有3步:

  1. 管理內存(在GPU上分配空間,將CPU端數據拷貝到GPU端)
  2. GPU上並行處理(啓動kernel函數)
  3. 將結果拷貝回到CPU端

第1步:在算法框架中添加 CUDA memory transfers

第2步:CUDA C編程實現kernel

可以看到這裏有2個問題

  1. 使用線程的索引代替了雙重循環,並行去做就可以
  2. 不需要鎖或同步,如果數據之間有依賴,存在同步問題,但這裏每一個結果矩陣的元素是獨立的,與別的元素無關,所以不需要鎖存。

第3步 CUDA C編程調用kernel

源代碼

#include <stdio.h>
#include <cuda_runtime.h>
#include <device_launch_parameters.h>
#include <stdlib.h>

#define WIDTH 16

__global__ void MatrixMulKernel(float* Md, float* Nd, float* Pd, int width)
{
	int tx = threadIdx.x;
	int ty = threadIdx.y;

	float Pvalue = 0;

	for (int k = 0; k<width; k++)
	{
		float Mdelement = Md[ty*width + k];
		float Ndelement = Nd[k*width + tx];
		Pvalue += Mdelement * Ndelement;
	}
	Pd[ty*width + tx] = Pvalue;
}

int main(void)
{
	float M[16][16], N[16][16], P[16][16];
	int Width = 16;
	int NUM = 192;
	//初始化示例數據
	for (int i = 0; i<16; i++)
	{
		for (int j = 0; j<16; j++)
		{
			M[i][j] = 2.0;
			N[i][j] = 3.0;
		}
	}
	int size = Width*Width*sizeof(float);

	float *Md, *Nd, *Pd;
	cudaMalloc((void**)&Md, size);
	cudaMemcpy(Md, M, size, cudaMemcpyHostToDevice);
	cudaMalloc((void**)&Nd, size);
	cudaMemcpy(Nd, N, size, cudaMemcpyHostToDevice);
	cudaMalloc((void**)&Pd, size);

	dim3 dimBlock(WIDTH, WIDTH);
	dim3 dimGrid(1, 1);
	MatrixMulKernel <<<dimGrid, dimBlock >> >(Md, Nd, Pd, Width);

	cudaMemcpy(P, Pd, size, cudaMemcpyDeviceToHost);

	//打印結果矩陣
	for (int i = 0; i<16; i++)
	{
		for (int j = 0; j<16; j++)
		{
			printf("%.2f  ", P[i][j]);
		}
		printf("\n");
	}

	cudaFree(Md);
	cudaFree(Nd);
	cudaFree(Pd);

	return 0;
}

回顧一下並行實現這個樣例的原理:

在這個矩陣相乘的案例中:

  1. 在算法實現中最主要的性能問題是什麼?
    • 矩陣長度限制
      • 僅有一個block
      • 以G80和GT200爲例 —— 最多512個線程/block
  2. 主要的限制是什麼?
    • 很多global memory讀寫訪問(讀Md,Nd,兩次讀後才寫一次,這兩次讀耗時巨大,開銷較大)

優化矩陣相乘(一) —— 去除長度限制

解決第一個問題:去除長度限制

  • 將Pd矩陣拆成tile小塊
  • 把一個tile佈置到一個block
  • 通過threadIdx和blockIdx索引

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-InMb90kF-1585747183513)(images-distributed-computing/image-20200331194345393.png)]
在這裏插入圖片描述

由於計算結果依然是彼此獨立的,所以每個block可以自己做自己的事情。

在這裏插入圖片描述

優化後的kernel函數如下:

(注意:每個線程計算的是塊內子矩陣的一個元素)

__global__ void MatrixMulKernel_01(float* Md, float* Nd, float* Pd, int width)
{
	//計算矩陣Pd和M的行索引
	int Row = blockIdx.y*blockDim.y + threadIdx.y;
	//計算矩陣Pd和N的列索引
	int Col = blockIdx.x*blockDim.x + threadIdx.x;

	float Pvalue = 0;

	//每個線程計算塊內子矩陣的一個元素
	for (int k = 0; k<width; k++)
	{
		float Mdelement = Md[Row*width + k];
		float Ndelement = Nd[k*width + Col];
		Pvalue += Mdelement * Ndelement;
	}
	Pd[Row*width + Col] = Pvalue;
}

這種方式可以適用於大規模的問題

調用kernel代碼如下:

dim3 dimGrid(Width / TILE_WIDTH, Height / TILE_WIDTH);
dim3 dimBlock(TILE_WIDTH, TILE_WIDTH);
MatrixMulKernel_01 <<<dimGrid, dimBlock >> >(Md, Nd, Pd, TILE_WIDTH);

注:如果輸入的數組不是TILE_WIDTH的整數倍怎麼辦?擴充元素到分塊的整數倍後將元素填0

優化矩陣相乘(二) —— 使用共享內存

上述代碼訪存受限與global memory帶寬

  • G80 峯值GFLOPS:346.4
  • 需要1386 GB/s(每個浮點數4個bit,346*4)的帶寬未達到
  • G80 存儲器實際帶寬:86.4GB/s
    • 限制代碼 21.6 GFLOPS
    • 實際上,代碼運行速度是15 GLOPS
  • 必須大幅減少對global memory的訪問

回顧之前的程序,實際上,每個輸入元素被Width個線程讀取(對於每一列都讀取了Width次),所以使用shared memory來減少global memory帶寬需求。

在這裏插入圖片描述

把kernel拆分成多個階段

  • 每個階段用Md和Nd的子集累加Pd
  • 每個階段有很好的數據局部性

在這裏插入圖片描述

每個線程

  • 讀入瓦片內Md和Nd的一個元素存入shared memory

代碼如下:

__global__ void MatrixMulKernel_01(float* Md, float* Nd, float* Pd, int width)
{
	//塊內定義shared memory存儲Md和Nd的子集
	__shared__ float Mds[TILE_WIDTH][TILE_WIDTH];
	__shared__ float Nds[TILE_WIDTH][TILE_WIDTH];

	int bx = blockIdx.x; int by = blockIdx.y;
	int tx = threadIdx.x; int ty = threadIdx.y;

	int Row = by*TILE_WIDTH + ty;
	int Col = bx*TILE_WIDTH + tx;

	float Pvalue = 0;
	//注:多個塊的計算的結果相加後纔得到pd對應元素的值
	//width/TILE_WIDTH:階段數目
	//m:當前階段的索引
	for (int m = 0; m<width/TILE_WIDTH; m++)
	{
		//從Md和Nd各取一個元素存入shared memory
		Mds[ty][tx] = Md[Row*width + (m*TILE_WIDTH + tx)];
		Nds[ty][tx] = Nd[Col + (m*TILE_WIDTH + ty)*width];
		//等待block內所有線程,即等到整個瓦片存入shared memory
		__syncthreads();

		//累加點乘的子集
		for (int k = 0; k < TILE_WIDTH; k++)
			Pvalue += Mds[ty][k] * Nds[k][tx];
		//注:如果沒有同步,可以上一次的乘累加沒完成,下一次的數已經過來把乘累加結果沖掉了
		__synthreads();
	}
	//把最終結果寫入global memory
	Pd[Row*width + Col] = Pvalue;
}

如何選取TITLE_WIDTH的數值?

  • 如果太大的話會怎樣?

    • 超出一個塊允許的最大線程數

    ​ Fermi — 1024;Kerpler - 1024;具體根據不同的計算能力查表得到。

    • 超出shared memory極限

    ​ 以G80爲例:16KM/SM 並且 B blocks/SM;

    ​ 2KB/block

    ​ 1KB給Nds,1KB給Mds(16 * 16 *4)

    ​ TITLE_WIDTH = 16

    ​ 更大的TITLE_WIDTH將導致更少的塊數

Shared memory瓦片化的好處

  • global memory 訪問次數減少TILE_WIDTH倍
    • 16*16瓦片 減少16倍
  • 以G80爲例
    • 現在global memory 支持345.6 GFLOPS
    • 接近峯值 346.5 GFLOPS

G80線程尺寸的考慮

  • 每個thread block有許多個線程
    • TILE_WIDTH爲16時:16*16=256個線程
  • 需要許多個thread blocks
    • 一個1024*1024 Pd 需要:64 * 64 = 4K thread blocks
  • 每個thread block執行 2*256 =512次global memory的float讀入,爲了供應256 *(2 *16)= 8K mul/add操作
    • 存儲帶寬不再是限制因素

Atomic Functions 原子操作

  • 許多原子操作
//算數運算
atomicAdd();
atomicSub();
atomicExch();
atomicMin();
atomicMax();
atomicDec();
atomicCAS();

//位運算
atomicAnd();
atomicOr();
atomicXor();
  • 不同塊裏面的線程如何協作?
    • CUDA中的線程協作主要是通過共享內存實現的。使用關鍵字**“share”**聲明共享變量
    • 共享內存是用於同一個線程塊內的線程之間交流的,不同線程塊之間是無法通過共享內存進行交流的
    • 還有更多內容待補充。
  • 儘量少用原子操作,爲什麼?
    • 原子操作比較耗時,需要在整個系統裏進行排隊

優化的矩陣乘法源代碼

#include <stdio.h>
#include <cuda_runtime.h>
#include <device_launch_parameters.h>
#include <stdlib.h>

/*
 * 去除長度限制 & 使用共享內存 的矩陣相乘優化
*/

#define TILE_WIDTH 8

__global__ void MatrixMulKernel_01(float* Md, float* Nd, float* Pd, int width)
{
	//塊內定義shared memory存儲Md和Nd的子集
	__shared__ float Mds[TILE_WIDTH][TILE_WIDTH];
	__shared__ float Nds[TILE_WIDTH][TILE_WIDTH];

	int bx = blockIdx.x; int by = blockIdx.y;
	int tx = threadIdx.x; int ty = threadIdx.y;

	int Row = by*TILE_WIDTH + ty;
	int Col = bx*TILE_WIDTH + tx;

	float Pvalue = 0;
	//注:多個塊的計算的結果相加後纔得到pd對應元素的值
	//width/TILE_WIDTH:階段數目
	//m:當前階段的索引
	for (int m = 0; m<width/TILE_WIDTH; m++)
	{
		//從Md和Nd各取一個元素存入shared memory
		Mds[ty][tx] = Md[Row*width + (m*TILE_WIDTH + tx)];
		Nds[ty][tx] = Nd[Col + (m*TILE_WIDTH + ty)*width];
		//等待block內所有線程,即等到整個瓦片存入shared memory
		__syncthreads();

		//累加點乘的子集
		for (int k = 0; k < TILE_WIDTH; k++)
			Pvalue += Mds[ty][k] * Nds[k][tx];
		//注:如果沒有同步,可以上一次的乘累加沒完成,下一次的數已經過來把乘累加結果沖掉了
		__syncthreads();
	}
	//把最終結果寫入global memory
	Pd[Row*width + Col] = Pvalue;
}

int main(void)
{
	float M[16][16], N[16][16], P[16][16];
	int Width = 16;
	int Height = 16;
	//初始化示例數據
	for (int i = 0; i<16; i++)
	{
		for (int j = 0; j<16; j++)
		{
			M[i][j] = 2.0;
			N[i][j] = 3.0;
		}
	}
	int size = Width*Height*sizeof(float);

	float *Md, *Nd, *Pd;
	cudaMalloc((void**)&Md, size);
	cudaMemcpy(Md, M, size, cudaMemcpyHostToDevice);
	cudaMalloc((void**)&Nd, size);
	cudaMemcpy(Nd, N, size, cudaMemcpyHostToDevice);
	cudaMalloc((void**)&Pd, size);

	dim3 dimGrid(Width / TILE_WIDTH, Height / TILE_WIDTH);
	dim3 dimBlock(TILE_WIDTH, TILE_WIDTH);
	MatrixMulKernel_01 <<<dimGrid, dimBlock >> >(Md, Nd, Pd, Width);

	cudaMemcpy(P, Pd, size, cudaMemcpyDeviceToHost);

	//打印結果矩陣
	for (int i = 0; i<16; i++)
	{
		for (int j = 0; j<16; j++)
		{
			printf("%.2f  ", P[i][j]);
		}
		printf("\n");
	}

	cudaFree(Md);
	cudaFree(Nd);
	cudaFree(Pd);

	return 0;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章