顯示具有 programming optimization 標籤的文章。 顯示所有文章
顯示具有 programming optimization 標籤的文章。 顯示所有文章

2020年12月24日 星期四

Improve CUDA Host-Device Memory Transmission


※ The code corresponding to this post is here.

MathJax LaTeX Example Page      CUDA (Compute Unified Device Architecture) is widely used in the Artificial Intelligence (AI) areas for its properties excellently meet deep learning computation framwork: large-scale parallel computing, localized memory accessing pattern, few collective decision and less CPU involving. 

    However, CUDA does not be only adopted in the AI areas, it is also appropriate to be used in the other large-scale parallel applications. In those cases, the main pain point may not be the computation speed, instead of the memory transmission efficiency. It is possible the bottleneck occuring at the host to/from device memory copy (consuming over 70% of time). If which cases, the benefit from optimizing the computation performance is paltry, we should pay attention on how to improve the transmission speed. But the most CUDA prgramming books emphasize on GPU shared-memory use, warp coalesce accessing, and cache hit-rate improvement, the authors do not spend too much space on the data transmission optimization, 

    This post will discuss how to optimize the CPU-GPU data transmission. Those tricks are simple, but very useful and beneficial.

    We use BLAS SAXPY \( y := A\cdot  X + y\) as the example for the demonstration.


零.  SAXPY CUDA code

    the code  is very straightforward. 

__global__ static void KernelSAXPY(float *p_input1, float *p_input2, 
	float value, int length)
{
	int i = blockIdx.x * blockDim.x + threadIdx.x;

	for (; i < length; i += gridDim.x * blockDim.x)
		p_input2[i] = p_input1[i] * value  + p_input2[i];
}


#define THREADS_IN_BLOCK					(1024)
#define NUM_BLOCKS						(512)

	cudaEventRecord(p_compute_handle->start_including_copy, stream);

	cudaMemcpy(p_dev_input1, p_host_input1, length * sizeof(float),
			cudaMemcpyHostToDevice);
		cudaMemcpy(p_dev_input2, p_host_input2, length * sizeof(float),
			cudaMemcpyHostToDevice);

	cudaEventRecord(p_compute_handle->start_excluding_copy, stream);


	KernelSAXPY << < THREADS_IN_BLOCK, block_num, 0, stream >> > (
			p_dev_input1, p_dev_input2, A, length);


	cudaEventRecord(p_compute_handle->stop_excluding_copy, stream);

	cudaMemcpy(p_host_input2, p_dev_input2, length * sizeof(float),
			cudaMemcpyDeviceToHost);

	cudaEventRecord(p_compute_handle->stop_including_copy, stream);

	cudaEventSynchronize(p_compute_handle->stop_including_copy);
※ If NUM_BLOCKS is THREADS_IN_BLOCK * ((length + (THREADS_IN_BLOCK - 1)) / THREADS_IN_BLOCK), we could get the best computational performance. But optimized NUM_BLOCKS will make the computational time too short to be measured precisely, thus we fix this value as 512 here.

The cudaEventRecord function purposes to measure the time-elasped excluding/including copy, more detail please refer to CUDA event APIs

SAXPX,
data length = 4MB
unit = milli-second
@GTX 1060 3G
excluding copy
elapsed-time
including copy
elasped-time
whole processing time,
measure from CPU
Un-optimized0.3211.5011.43

    It is extermely obvious the most time consuming in the memory copy. If we do not optimize the data transmission, In this case, it is not worthy to use CUDA. Using the CPU SIMD instructions definitely a better solution.
(If you inserested in SIMD, you could refer to my previous post related to this issue.)


一. Use the pinned memory

     As the 2 reference mentioned, piined memory trick improves the PCI-E data transmission : 
    Host (CPU) data allocations are pageable by default. The GPU cannot access data directly from pageable host memory, so when a data transfer from pageable host memory to device memory is invoked, the CUDA driver must first allocate a temporary page-locked, or “pinned”, host array, copy the host data to the pinned array  https://developer.nvidia.com/blog/how-optimize-data-transfers-cuda-cc/

     CUDA Driver checks, if the memory range is locked or not and then it will use a different codepath. Locked memory is stored in the physical memory (RAM), so device can fetch it w/o help from CPU (DMA, aka Async copy; device only need list of physical pages). Not-locked memory can generate a page fault on access, and it is stored not only in memory (e.g. it can be in swap), so driver need to access every page of non-locked memory, copy it into pinned buffer and pass it to DMA (Syncronious, page-by-page copy).

    So, we should use the pinned memory trick. In the implementation, we use cudaHostAlloc/cudaFreeHost to replace memcpy/free
/**********************************************************************/

void* MallocPinned(size_t size)
{
	void *ptr;
	cudaHostAlloc(&ptr, size, cudaHostAllocMapped);
	return ptr;
}

/**********************************************************************/

void FreePinned(void *ptr)
{
	cudaFreeHost(&ptr);
}

Result :
GTX 1060 3G,
data length = 4MB
unit = milli-second
@GTX 1060 3G
excluding copy
elapsed-time
including copy
elasped-time
whole processing time,
measure from CPU
un-optimized0.3211.5011.43
Pinned memory, copy0.324.734.80

The data transmission gets a great improvement.


二. Use zero-copy trick.

  Zero-copy means the GPU operates the data on the CPU memory, it leaves out the memory copy, but it will slow the data operation.Thus, In the CUDA process, if there is only one procedure (or called subroutine) involved the CPU data (the input), it is the perfect situation to adopt this trick. SAXPY does be this situation.

   Practically, after applying the pinned memory trick, use cudaHostGetDevicePointer to map the CPU address pointers to the GPU pointers.

:
#if(1)
	cudaHostGetDevicePointer(&p_dev_input1, p_host_input1, 0);
	cudaHostGetDevicePointer(&p_dev_input2, p_host_input2, 0);
#else
	cudaMemcpy(p_dev_input1, p_host_input1, length * sizeof(float),
			cudaMemcpyHostToDevice);
	cudaMemcpy(p_dev_input2, p_host_input2, length * sizeof(float),
			cudaMemcpyHostToDevice);
#endif
:

	KernelSAXPY << < THREADS_IN_BLOCK, block_num, 0, stream >> > (
		p_dev_input1, p_dev_input2, A, length);

#if(0)
	cudaMemcpy(p_host_input2, p_dev_input2, length * sizeof(float),
		cudaMemcpyDeviceToHost);
#endf
※ The cost of calling cudaHostGetDevicePointer is paltry, It is not necessary to intentionally reduce the times of its calling.

Result :
GTX 1060 3G,
data length = 4MB
unit = milli-second
@GTX 1060 3G
excluding copy
elapsed-time
including copy
elasped-time
whole processing time,
measure from CPU
Un-optimized0.3211.5011.43
Pinned memory, copy0.324.734.80
Pinned Memory, zerocopy2.942.943.20

※ If NUM_BLOCKS is THREADS_IN_BLOCK * ((length + (THREADS_IN_BLOCK - 1)) / THREADS_IN_BLOCK), the zerocopy performance is tremendously better than the others. In this case, its whole precessing time is less than 0.2 ms.

    It shows the overall performance becomes better, but the computation speed slows, as we expected.


三.  Asynchronous procedure trick.
    For SAXPY,  though we have gotten the best performance from the zero-copy trick, we are still curious how much improvement we could get from the procedure synchronization.

   Use Nvidia Visual Profiler to view the pinned-memory case (二.), the profile shows as below : 
The procedures are purely sequentialized, for those are not overlapped at all.

     For SAXPY is perfect parallelable, the data is able to be diced into several sections and processed individually. It may be possible to overlap the memcpy with the computation, and shorten the overall elasped-time. 
      Below figure shows the ideal profile while the data sliced into 4 sections : 


    This behavior is called the asynchronous procedures
     To achieve this goal, we need to do :
1. Dice the data into mutiple sections.
2. use the function cudaMemcpyAsync to replace cudaMemcpy.
3. Use the stream APIs to manage (create/destroy) the streams.
4. Use CUDA event APIs to manage the events : create/destroy for initialization/close, cudaEventRecord to set the checkpoint, and use cudaEventQuery to check if the checkpoint reached or not.

    There are 2 ways function-call model we could use : depth-first and breadth-first. For depth-first, we call one stream's all procedures, then call the next stream's ; for breadth-first, we call each stream's zeroth procedure : once all streams's zeroth have been called, then call each stream's first procedures.

 甲. Depth-first call

#define ASYNC_SECTION_NUM					(4)

	unsigned int done_flag = 0;

	for (int i = 0; i < ASYNC_SECTION_NUM; i++) {

		GPUSAXPYAsynchronousCopyHostToDevice(cuda_handle_array[i], 
			DATA_LENGTH / ASYNC_SECTION_NUM, A, 
			&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM], 
			&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);

		GPUSAXPYAsynchronousCompute(cuda_handle_array[i],
			DATA_LENGTH / ASYNC_SECTION_NUM, A,
			&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM], 
			&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);

		GPUSAXPYAsynchronousCopyDeviceToHost(cuda_handle_array[i], 
			DATA_LENGTH / ASYNC_SECTION_NUM, A,
			&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM], 
			&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);
	}

	while (1)
	{
		for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
			if (0x00 == (0x01 & (done_flag >> i)))
			{
				if (true == IsComputeDone(cuda_handle_array[i]))
				{
					done_flag |= (0x1 << i);

					if (ASYNC_SECTION_NUM/2 == i)
					{
						float inlcuding_time, excluding_time;
						GetElaspedTime(cuda_handle_array[i],
							&inlcuding_time, &excluding_time);

						elasped_time_including_copy_in_ms += inlcuding_time;
						elasped_time_excluding_copy_in_ms += excluding_time;
					}

				}
				else
				{
					Sleep(1);
				}
			}
		}

		if (all_done_flag == done_flag)
			break;
	}

GTX 1060 3G,
data length = 4MB
unit = milli-second
@GTX 1060 3G
excluding copy
elapsed-time
including copy
elasped-time
whole processing time,
measure from CPU
Un-optimized0.3211.5011.43
Pinned memory, copy0.324.734.80
Pinned Memory, zerocopy2.942.943.20
Asynchronized,
depth-first, pinned
0.091.375.00
 The depth-first performance is worse than the sequentialized copy version.

※ The benchmark should be based on the whole processing time  : the "excluding/including copy elasped-time" does NOT include the scheduling time. The asynchronized one has the shortest including copy elasped-time, but it is not the best : in the profile, there are the pauses (no data transmission/compuation) while the streams start. As the below figure shows.


乙. Breadth-first call:
		unsigned int done_flag = 0;

		for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
			GPUSAXPYAsynchronousCopyHostToDevice(cuda_handle_array[i],
				DATA_LENGTH / ASYNC_SECTION_NUM, 2.0,
				&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM],
				&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);
		}

		for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
			GPUSAXPYAsynchronousCompute(cuda_handle_array[i],
				DATA_LENGTH / ASYNC_SECTION_NUM, 2.0,
				&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM],
				&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);
		}

		for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
			GPUSAXPYAsynchronousCopyDeviceToHost(cuda_handle_array[i],
				DATA_LENGTH / ASYNC_SECTION_NUM, 2.0,
				&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM],
				&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);
		}
		
		while (1)
		{
			for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
				if (0x00 == (0x01 & (done_flag >> i)))
				:

GTX 1060 3G,
data length = 4MB
unit = milli-second
@GTX 1060 3G
excluding copy
elapsed-time
including copy
elasped-time
whole processing time,
measure from CPU
un-optimized0.3211.5011.43
Pinned memory, copy0.324.734.80
Pinned Memory, zerocopy2.942.943.20
Asynchronized,
depth first, pinned
0.091.375.00
Asynchronized,
breadth-first, pinned
0.093.885.00
    The breadth-first performance is indentical to the depth-first, nothing improved.

    Why does it lead the result ? After scrutinizing the code, we could find that: the GPU cores have nothing to do before all the host to device memcpy has been done. It is, the data-hungry brings the low performance.    
    To solve it, we modify the code as "memcpy breadth-first". That is, through it is the breadth-first strcture, the computation calls just follow the memcpy:
		unsigned int done_flag = 0;

		for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
			GPUSAXPYAsynchronousCopyHostToDevice(cuda_handle_array[i],
				DATA_LENGTH / ASYNC_SECTION_NUM, 2.0,
				&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM],
				&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);

			GPUSAXPYAsynchronousCompute(cuda_handle_array[i],
				DATA_LENGTH / ASYNC_SECTION_NUM, 2.0,
				&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM],
				&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);
		}

		for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
			GPUSAXPYAsynchronousCopyDeviceToHost(cuda_handle_array[i],
				DATA_LENGTH / ASYNC_SECTION_NUM, 2.0,
				&data_ptr_array[0][i * DATA_LENGTH / ASYNC_SECTION_NUM],
				&data_ptr_array[1][i * DATA_LENGTH / ASYNC_SECTION_NUM]);
		}

		while (1)
		{
			for (int i = 0; i < ASYNC_SECTION_NUM; i++) {
				if (0x00 == (0x01 & (done_flag >> i)))


GTX 1060 3G,
data length = 4MB
unit = milli-second
@GTX 1060 3G
excluding copy
elapsed-time
including copy
elasped-time
whole processing time,
measure from CPU
un-optimized0.3211.5011.43
Pinned memory, copy0.324.734.80
Pinned Memory, zerocopy2.942.943.20
Asynchronized,
depth-first, pinned
0.091.375.00
Asynchronized,
breadth-first, pinned
0.093.885.00
Asynchronized,
memcpy breadth-first, pinned
0.092.914.50
The result gets better, as we presumed.


     Why the depth-first is worse than memcpy bread-first? It is related to the CUDA stream management strategy : The scheduler will not start the other streams while current stream's following procedure depending on current's work. In our case, because the computation procedures depend on the memcpy (of course),  the scaleduler would not start the other streams until the copy has been done. It eventually evolves to be sequentialized.
     On the other hand, in the memcpy breadth-first, each memcpy does not depends on each other, so the scaleduler does start the other stream while current work has been done, it brings the procedures asynchronized.

    The visual profilers shows the evidence.

The depth-first :
    There is no asynchronization at the depth-first.
  
The memcpy breadth-first :
    The subsequent streams start while a copy has been done.
    
Nvidia names the depth-first's behavior as "pseudo-dependency".
     
※ In my example code, I also provide the example to show how to use 2 CPU-threads to feed/acquire the data to/from the GPU asynchronously.


四.  Summary.

For CUDA host-device data transmission :
     甲. Pinned memory is able to get the obvious improvement.
     乙. Zerocopy get the profound improvement, it requires the pinned memory.
     丙. While applying the asynchronization trick, the breadth-first call is necessary to achieve the asynchronous procedures.
     丁. Avoid data-hungry in the breadth-first asynchronization : make the GPU cores have work to do while the other stream starts. Practically, each computation call should just follow each memcpy.

2020年6月19日 星期五

Tiling Separable Convolution in CUDA

※ The code of this post is available in here.

    When we are doing image processing on a GPU, one of the key idea is 'Tiling'. Tiling cuts the image into small rectangles, and tackle those rectangles one by one. It helps to localize the data and improve the cache hit rate. This article discuss how to use it to improve the performance of separable convolution in CUDA.
  tiling.png

Concept of Tiling

    For CPU, tiling is also called loop-nest optimization. In some problems -e,g the image rotation, tiling is able to limit the memory accessing bound in a region, it avoids the cache being flushed frequently. For GPU, it is much natural : because GPU is matrix machine essentially : A NVIDIA's  streaming multiprocessors (SMP) contains dozen to hundred of cores, and a GPU has several to dozen of SMPs. Therefore, tiling the data is actually to allocate the work to each SMP.


Tiling on GPU

    In most GPU cases, tiling strategy affects the performance mightily. As CPU, a good tiling could increase the cache hit rate. Cache missing, means the data need to be loaded from memory instead of from cache, and the memory accessing, called memory latency, is much slower the arithmetic operations, bring the low performance;  The tile size not only affects cache hit rate,  also influences the SMP usage rate (NVIDIA uses the term "achieved occupancy"). Only in the very simple cases, for example, the vector arithmetic, are little affected by the selection of tile size.
     In NVIDIA Pascal architecture, a SMP is able to run 2048 active threads at one time. Those threads will be allocated to each warp, which comprises 32 lines. CUDA launches SMP in unit of warp,  if the thread number in x-dimension is not an integer multiple of 32, it still need an entire warp to run the remainder - even there is only one thread. Based on this reason, the block size should be as a multiple of 32 as possible. 
     The tiling strategy depends the procedure. If the procedure is pure row-accessing (horizontal), it may be an excellent choice for the block size being (1024, 1); But if the procedure involves column-accessing (vertical), the horizontal 1D block size may not be a good choice, since the block shape would make some lines in the warps idle. We will see how block shape affects the performance.


Separable Convolution

    Separable convolution is a very important concept in image processing or machine learning. If the kernel matrix is product of the 2 vectors, the convolution could be reduced as two of 1D convolution, which reduce an O(N^2) operation to 2*O(N).  More detail about separable convolution please refer to here.


Separable Convolution on CUDA

    Below is the separable convolution CUDA code of row. ( The column code is pretty similar with this one, I leave it out) The outer 2 loops, for height and width, are mapped to the y-dimensional and x-dimensional threads, respectively - i.e. we launch a 2D kernel. Since the length of kernel data is small and the accessing pattern is identical across a warp, we place the kernel data in the constant memory.      The constant memory supports broadcasting, the loaded kernel weightwould be broadcasted to all threads in a single step.  Next, I use the LDG macro (it is the wrapper of __ldg intrinsic) to force loading the input data into the read-only (L2) cache. the code is :
__constant__ float kernel_const_mem[1024];
:
j = blockDim.y*blockIdx.y + threadIdx.y;
for (; j < height; j += blockDim.y * gridDim.y) {

 i = blockDim.x*blockIdx.x + threadIdx.x;
 for (; i < width; i += blockDim.x * gridDim.x) {

  int x, y_mul_input_width;
  int ii;
  float sum;

  sum = 0;
  y_mul_input_width = j*extended_width;
  x = i;

  for (ii = 0; ii < kernel_length; ii++) {
   sum +=LDG(kernel_const_mem[ii])
    * LDG(p_column_done_extended_input_dev[y_mul_input_width + x]);

   x += 1;
  }/*for kernel_length*/

  p_output_dev[j*width + i] = sum;
 }/*for width*/

}/*for j*/
SeparateConvolutionRowGPU << <num_blocks, num_threads >> >
 (width, height, p_column_done_extended_input_dev,
  kernel_length, NULL, p_output_dev);

     The more block we requested, the more warps would be mapped into the SMPs.  If the block number is over the necessity, the SMP recourse would be wasted. Based on this perspective, the choice of the number of blocks (gridDim) is intuitive :  to fill the data as much as possible, and do not waste any block:
gridDim.x = ((width + (blockDim.x -1))/blockDim.x;
gridDim.y = ((height + (blockDim.y -1))/blockDim.y;
     Hence, the question is, how to selection an appropriate block size.


The Selection of Tile Size

    There is a wise phrase in Confucianism :【論語‧子罕第九 】 子曰:「吾有知乎哉?無知也。有鄙夫問於我,空空如也,我叩其兩端而竭焉。」This is to suggest people analyzing the issues from the two extreme cases.  In the matter of the block size, the extreme cases are (1024, 1) and (1, 1024). The runtime result is (including the data transmission time to/from GPU):
input = 1500x1500 kernel = 21x21 100 Rounds no_ldg ldg
1024 x 1 268 ms 265 ms
1 x 1024 730 ms 733 ms
 
    This shows CUDA threads is x-dimensional-major  - this is, the L2 cache is designed for the consecutive accessing of thread.x. The (1, 1024) case violates this accessing pattern and the warp size, leads its low performance. Following the Confucius' guide, analyzing the extreme cases is able to clarify the facts: nvprof  proves why the (1, 1024), no_ldg case is the worst : the cache hit rate is zero, and its low occupancy.

 
no_ldg Column/Row Unified Cache Hit Rate(%) C/R Achieved Occupancy C/R
1024 x 1 50.1/48.3 0.706/0.68
1 x 1024 0/0 0.588/0.606
     Denote that the occupancy are not high in the both 2 cases, it implies there is still room to improve the performance. It is make sense : there is 2 procedures in the separate convolution, column and row . The block (1024, 1) does not fitting the procedure pattern of column i.e. some lanes in the warps would be idle, so it must not be the best. Below is the result for all block sizes of power 2. block_size_runtime.png
     The performance of (64, 16), (128, 8), (256, 4) and (512, 2) are almost the same. The best one is (64, 16) both for no_ldg and ldg.  nvprof shows the performance is highly related to the cache hit rate and achieved occupancy:
no_ldg Column/Row Unified Cache Hit Rate(%), C/R Achieved Occupancy, C/R
16 x 64 38.3/38.7 0.818/0.777
64 x 16 50.1/48.3 0.829/0.793
512 x 2 50.1/48.3 0.831/0.784

     Using __ldg intrinsics would force keeping the data in L2 cache, again, nvprof confirms  __ldg makes the cache hit rate high. Beware that if the block size is too "tubby" (e.g. 1024 x 1 ) or too "slender" (e.g. 2 x 512), the cache hit rate and occupancy are still low, in spite of  __ldg has been adopted.
ldg Column/Row Unified Cache Hit Rate(%), C/R Achieved Occupancy, C/R
2 x 512 68.8/60.1 0.753/0.72
16 x 64 88.5/92.2 0.744/0.828
64 x 16 88.8/94.8 0.742/0.812
1024 x 1 56.6/95.1 0.66/0.695

      If block size is tubby, it brings the L2 cache needs to be flushed frequently in the column procedure; whereas the slender case, the performance deserves being low for its shape violating the rule of consecutive accessing and the warp width. Therefore, the block size affects the performance immensely.     
     A good selection is able to achieve good cache hit rate and high occupancy. Especially the cache hit rate, it dominates the speed. However, the best runtime amount these cases is still little worse than the full optimized code using shared-memory which I covered in another blog. But selecting a good block size plus using '__ldg' intrinsic is much easier than rewriting to use shared-memory, it is worthwhile.


Summary

一. Select the block size prudently, it heavily affects the performance. The two metrics in nvprof, unified cache hit rate and achieved occupancy, are able to help you to evaluate whether the selected size is appropriate or not. 

二. Using __ldg intrinsics is the icing on the cake, it brings the good selection of block size more performance.