Scalable Parallel Execution in CUDA

Page 1 sur 28Lecteur de document UniversityLib

Scalable Parallel Execution in CUDA

Computer Science - Parallel Computing with CUDA · notes

CHAPTER

Scalable parallel execution 3

Mark Ebersole

CHAPTER OUTLINE

3.1 CUDA Thread Organization ....................................................................................43

3.2 Mapping Threads to Multidimensional Data ...........................................................47

3.3 Image Blur: A More Complex Kernel ......................................................................54

3.4 Synchronization and Transparent Scalability .........................................................58

3.5 Resource Assignment ...........................................................................................60

3.6 Querying Device Properties ...................................................................................61

3.7 Thread Scheduling and Latency Tolerance .............................................................64

3.8 Summary .............................................................................................................67

3.9 Exercises .............................................................................................................67

In Chapter 2, Data parallel computing, we learned to write a simple CUDA C pro-

gram that launches a kernel and a grid of threads to operate on elements in one-

dimensional arrays. The kernel specifies the C statements executed by each thread.

As we unleash such a massive execution activity, we need to control these activities

to achieve desired results, efficiency, and speed. In this chapter, we will study impor-

tant concepts involved in the control of parallel execution. We will start by learn-

ing how thread index and block index can facilitate processing multidimensional

arrays. Subsequently, we will explore the concept of flexible resource assignment

and the concept of occupancy. We will then advance into thread scheduling, latency

tolerance, and synchronization. A CUDA programmer who masters these concepts is

well-equipped to write and understand high-performance parallel applications.

3.1 CUDA THREAD ORGANIZATION

All CUDA threads in a grid execute the same kernel function; they rely on coordi-

nates to distinguish themselves from one another and identify the appropriate portion

of data to process. These threads are organized into a two-level hierarchy: a grid

consists of one or more blocks, and each block consists of one or more threads. All

43

Programming Massively Parallel Processors. DOI: Copyright © David B. Kirk/NVIDIA Corporation and Wen-mei W. Hwu. Published by Elsevier Inc. All rights reserved2017http://dx.doi.org/10.1016/B978-0-12-811986-0.00003-044

CHAPTER 3 Scalable parallel execution

threads in a block share the same block index, which is the value of the blockIdx

variable in a kernel. Each thread has a thread index, which can be accessed as the

value of the threadIdx variable in a kernel. When a thread executes a kernel func-

tion, references to the blockIdx and threadIdx variables return the coordinates of

the thread. The execution configuration parameters in a kernel launch statement

specify the dimensions of the grid and the dimensions of each block. These dimen-

sions are the values of the variables gridDim and blockDim in kernel functions.

HIERARCHICAL ORGANIZATIONS

Similar to CUDA threads, many real-world systems are organized hierarchi-

cally. The United States telephone system is a good example. At the top level,

the telephone system consists of “areas,” each of which corresponds to a geo-

graphical area. All telephone lines within the same area have the same 3-digit

“area code”. A telephone area can be larger than a city; e.g., many counties

and cities in Central Illinois are within the same telephone area and share the

same area code 217. Within an area, each phone line has a seven-digit local

phone number, which allows each area to have a maximum of about ten mil-

lion numbers.

Each phone line can be considered as a CUDA thread, the area code as the

value of blockIdx, and the seven-digit local number as the value of thread-

Idx. This hierarchical organization allows the system to accommodate a con-

siderably large number of phone lines while preserving “locality” for calling

the same area. When dialing a phone line in the same area, a caller only needs

to dial the local number. As long as we make most of our calls within the local

area, we seldom need to dial the area code. If we occasionally need to call

a phone line in another area, we dial “1” and the area code, followed by the

local number. (This is the reason why no local number in any area should start

with “1.”) The hierarchical organization of CUDA threads also offers a form

of locality, which will be examined here.

In general, a grid is a three-dimensional array of blocks1, and each block is a three-

dimensional array of threads. When launching a kernel, the program needs to specify

the size of the grid and blocks in each dimension. The programmer can use fewer

than three dimensions by setting the size of the unused dimensions to 1. The exact

organization of a grid is determined by the execution configuration parameters

(within <<< >>>) of the kernel launch statement. The first execution configuration

parameter specifies the dimensions of the grid in the number of blocks. The second

specifies the dimensions of each block in the number of threads. Each such parameter

is of the dim3 type, which is a C struct with three unsigned integer fields: x, y, and z.

These three fields specify the sizes of the three dimensions.

1 Devices with compute capability less than 2.0 support grids with up to two-dimensional arrays of blocks.

3.1 Cuda thread organization

45

To illustrate, the following host code can be used to launch the vecAddkernel()

kernel function and generate a 1D grid that consists of 32 blocks, each of which

consists of 128 threads. The total number of threads in the grid is 128*32 = 4096.

dim3 dimGrid(32, 1, 1);

dim3 dimBlock(128, 1, 1);

vecAddKernel<<<dimGrid, dimBlock>>>(…);

Note that dimBlock and dimGrid are host code variables defined by the program-

mer. These variables can have any legal C variable names as long as they are of the

dim3 type and the kernel launch uses the appropriate names. For instance, the follow-

ing statements accomplish the same as the statements above:

dim3 dog(32, 1, 1);

dim3 cat(128, 1, 1);

vecAddKernel<<<dog, cat>>>(…);

The grid and block dimensions can also be calculated from other variables. The

kernel launch in Fig. 2.15 can be written as follows:

dim3 dimGrid(ceil(n/256.0), 1, 1);

dim3 dimBlock(256, 1, 1);

vecAddKernel<<<dimGrid, dimBlock>>>(…);

The number of blocks may vary with the size of the vectors for the grid to have

sufficient threads to cover all vector elements. In this example, the programmer chose

to fix the block size at 256. The value of variable n at kernel launch time will deter-

mine the dimension of the grid. If n is equal to 1000, the grid will consist of four

blocks. If n is equal to 4000, the grid will have 16 blocks. In each case, there will be

enough threads to cover all of the vector elements. Once vecAddKernel is launched,

the grid and block dimensions will remain the same until the entire grid finishes

execution.

For convenience, CUDA C provides a special shortcut for launching a kernel with

one-dimensional grids and blocks. Instead of dim3 variables, arithmetic expressions

can be used to specify the configuration of 1D grids and blocks. In this case, the

CUDA C compiler simply takes the arithmetic expression as the x dimensions and

assumes that the y and z dimensions are 1. Thus, the kernel launch statement is as

shown in Fig. 2.15:

vecAddKernel<<<ceil(n/256.0), 256>>>(…);

Readers familiar with the use of structures in C would realize that this “short-

hand” convention for 1D configurations takes advantage of the fact that the x field

is the first field of the dim3 structures gridDim(x, y, z) and blockDim{x, y, z).

This shortcut allows the compiler to conveniently initialize the x fields of gridDim

and blockDim with the values provided in the execution configuration parameters.

Within the kernel function, the x field of the variables gridDim and blockDim

are pre-initialized according to the values of the execution configuration parameters.

46

CHAPTER 3 Scalable parallel execution

If n is equal to 4000, references to gridDim.x and blockDim.x in the vectAddkernel

kernel will obtain 16 and 256, respectively. Unlike the dim3 variables in the host

code, the names of these variables within the kernel functions are part of the CUDA

C specification and cannot be changed—i.e., gridDim and blockDim in a kernel

always reflect the dimensions of the grid and the blocks.

In CUDA C, the allowed values of gridDim.x, gridDim.y and gridDim.z range

from 1 to 65,536. All threads in a block share the same blockIdx.x, blockIdx.y,

and blockIdx.z values. Among blocks, the blockIdx.x value ranges from 0 to

gridDim.x-1, the blockIdx.y value from 0 to gridDim.y-1, and the blockIdx.z

value from 0 to gridDim.z-1.

Advertisement

Regarding the configuration of blocks, each block is organized into a three-

dimensional array of threads. Two-dimensional blocks can be created by setting

blockDim.z to 1. One-dimensional blocks can be created by setting both blockDim.y

and blockDim.z to 1, as was the case in the vectorAddkernel example. As pre-

viously mentioned, all blocks in a grid have the same dimensions and sizes. The

number of threads in each dimension of a block is specified by the second execution

configuration parameter at the kernel launch. Within the kernel, this configuration

parameter can be accessed as the x, y, and z fields of blockDim.

The total size of a block is limited to 1024 threads, with flexibility in distributing

these elements into the three dimensions as long as the total number of threads does

not exceed 1024. For instance, blockDim(512, 1, 1), blockDim(8, 16, 4), and

blockDim(32, 16, 2) are allowable blockDim values, but blockDim(32, 32, 2) is

not allowable because the total number of threads would exceed 1024.2

The grid can have higher dimensionality than its blocks and vice versa. For

instance, Fig. 3.1 shows a small toy grid example of gridDim(2, 2, 1) with block-

Dim(4, 2, 2). The grid can be generated with the following host code:

dim3 dimGrid(2, 2, 1);

dim3 dimBlock(4, 2, 2);

KernelFunction<<<dimGrid, dimBlock>>>(…);

The grid consists of four blocks organized into a 2 × 2 array. Each block in

Fig. 3.1 is labeled with (blockIdx.y, blockIdx.x), e.g., Block(1,0) has

blockIdx.y=1 and blockIdx.x=0. The labels are ordered such that the highest

dimension comes first. Note that this block labeling notation is the reversed order-

ing of that used in the C statements for setting configuration parameters where the

lowest dimension comes first. This reversed ordering for labeling blocks works more

effectively when we illustrate the mapping of thread coordinates into data indexes in

accessing multidimensional data.

Each threadIdx also consists of three fields: the x coordinate threadId.x, the y

coordinate threadIdx.y, and the z coordinate threadIdx.z. Fig. 3.1 illustrates the

organization of threads within a block. In this example, each block is organized into

4 × 2 × 2 arrays of threads. All blocks within a grid have the same dimensions; thus, we

2 Devices with capability less than 2.0 allow blocks with up to 512 threads.

3.2 Mapping threads to multidimensional data

47

host

device

Grid 1

Block

(0, 0)

Block

(1, 0)

Grid 2

Block

(0, 1)

Block

(1, 1)

(1,0,0) (1,0,1)

(1,0,2)

(1,0,3)

Block (1,1)

Kernel 1

Kernel 2

Thread

(0,0,0)

Thread

(0,0,1)

Thread

(0,0,2)

Thread

(0,1,0)

Thread

(0,1,1)

Thread

(0,1,2)

FIGURE 3.1

A multidimensional example of CUDA grid organization.

Thread

(0,0,3)

Thread

(0,0,0)

Thread

(0,1,3)

only need to show one of them. Fig. 3.1 expands Block(1,1) to show its 16 threads. For

instance, Thread(1,0,2) has threadIdx.z=1, threadIdx.y=0, and threadIdx.x=2.

This example shows 4 blocks of 16 threads each, with a total of 64 threads in

the grid. We use these small numbers to keep the illustration simple. Typical CUDA

grids contain thousands to millions of threads.

3.2 MAPPING THREADS TO MULTIDIMENSIONAL DATA

The choice of 1D, 2D, or 3D thread organizations is usually based on the nature of

the data. Pictures are 2D array of pixels. Using a 2D grid that consists of 2D blocks is

often convenient for processing the pixels in a picture. Fig. 3.2 shows such an arrange-

ment for processing a 76 × 62 picture P (76 pixels in the horizontal or x direction

and 62 pixels in the vertical or y direction). Assume that we decided to use a 16 × 16

block, with 16 threads in the x direction and 16 threads in the y direction. We will

need 5 blocks in the x direction and 4 blocks in the y direction, resulting in 5 × 4 = 20

blocks, as shown in Fig. 3.2. The heavy lines mark the block boundaries. The shaded

area depicts the threads that cover pixels. It is easy to verify that one can identify the

Pin element processed by thread(0,0) of block(1,0) with the formula:

PblockIdx.yblockDim.y threadIdx.y,blockIdx.xblockDim.x thhreadIdx.x

P

, *

1 16 0 0 16 0

*

P

16,0

.

48

CHAPTER 3 Scalable parallel execution

FIGURE 3.2

Using a 2D thread grid to process a 76 × 62 picture P.

Note that we have 4 extra threads in the x direction and 2 extra threads in the y

direction—i.e., we will generate 80 × 64 threads to process 76 × 62 pixels. This case

is similar to the situation in which a 1000-element vector is processed by the 1D

kernel vecAddKernel in Fig. 2.11 by using four 256-thread blocks. Recall that an if

statement is needed to prevent the extra 24 threads from taking effect. Analogously,

we should expect that the picture processing kernel function will have if statements

to test whether the thread indexes threadIdx.x and threadIdx.y fall within the

valid range of pixels.

Assume that the host code uses an integer variable m to track the number of pix-

els in the x direction and another integer variable n to track the number of pixels

in the y direction. We further assume that the input picture data have been copied to

the device memory and can be accessed through a pointer variable d_Pin. The out-

put picture has been allocated in the device memory and can be accessed through a

pointer variable d_Pout. The following host code can be used to launch a 2D kernel

colorToGreyscaleConversion to process the picture, as follows:

dim3 dimGrid(ceil(m/16.0), ceil(n/16.0), 1);

dim3 dimBlock(16, 16, 1);

colorToGreyscaleConversion<<<dimGrid,dimBlock>>>(d_Pin,d_Pout,m,n);

In this example, we assume, for simplicity, that the dimensions of the blocks are

fixed at 16 × 16. Meanwhile, the dimensions of the grid depend on the dimensions

of the picture. To process a 2000 × 1500 (3-million-pixel) picture, we will generate

11,750 blocks—125 in the x direction and 94 in the y direction. Within the kernel

function, references to gridDim.x, gridDim.y, blockDim.x, and blockDim.y will

result in 125, 94, 16, and 16, respectively.

3.2 Mapping threads to multidimensional data

Advertisement

49

MEMORY SPACE

Memory space is a simplified view of how a processor accesses its memory

in modern computers. It is usually associated with each running application.

The data to be processed by an application and instructions executed for the

application are stored in locations in its memory space. Typically, each loca-

tion can accommodate a byte and has an address. Variables that require multi-

ple bytes—4 bytes for float and 8 bytes for double—are stored in consecutive

byte locations. The processor generates the starting address (address of the

starting byte location) and the number of bytes needed when accessing a data

value from the memory space.

The locations in a memory space are similar to phones in a telephone

system where everyone has a unique phone number. Most modern computers

have at least 4G byte-sized locations, where each G is 1,073,741,824 (230).

All locations are labeled with an address ranging from 0 to the largest number.

Every location has only one address; thus, we say that the memory space has

a “flat” organization. As a result, all multidimensional arrays are ultimately

“flattened” into equivalent one-dimensional arrays. Whereas a C programmer

can use a multidimensional syntax to access an element of a multidimensional

array, the compiler translates these accesses into a base pointer that points

to the initial element of the array, along with an offset calculated from these

multidimensional indexes.

Before we show the kernel code, we need to first understand how C statements access

elements of dynamically allocated multidimensional arrays. Ideally, we would like to

access d_Pin as a two-dimensional array where an element at row j and column i can

be accessed as d_Pin[j][i]. However, the ANSI C standard on which the develop-

ment of CUDA C was based requires that the number of columns in d_Pin be known

at compile time for d_Pin to be accessed as a 2D array. Unfortunately, this informa-

tion is not known at compiler time for dynamically allocated arrays. In fact, part of

the reason dynamically allocated arrays are used is to allow the sizes and dimensions

of these arrays to vary according to data size at run time. Thus, the information on the

number of columns in a dynamically allocated two-dimensional array is unknown

at compile time by design. Consequently, programmers need to explicitly linearize

or “flatten” a dynamically allocated two-dimensional array into an equivalent one-

dimensional array in the current CUDA C. The newer C99 standard allows multidi-

mensional syntax for dynamically allocated arrays. Future CUDA C versions may

support multidimensional syntax for dynamically allocated arrays.

In reality, all multidimensional arrays in C are linearized because of the use of a

“flat” memory space in modern computers (see “Memory Space” sidebar). In stati-

cally allocated arrays, the compilers allow the programmers to use higher-dimensional

indexing syntax such as d_Pin[j][i] to access their elements. Under the hood, the

50

CHAPTER 3 Scalable parallel execution

M0,0

M0,1

M0,2

M0,3

M1,0

M1,1

M1,2 M1,3

M2,0

M2,1

M2,2 M2,3

M3,0

M3,1

M3,2 M3,3

M

M

M0,0

M0,1

M0,2

M0,3

M1,0

M1,1

M1,2 M1,3

M2,0

M2,1

M2,2 M2,3

M3,0

M3,1

M3,2 M3,3

RowWidth+Col = 24+1 = 9

M0

M1

M2

M3

M4

M5

M6 M7

M8

M9

M10 M11

M12

M13

M14 M15

FIGURE 3.3

Row-major layout for a 2D C array. The result is an equivalent 1D array accessed by an

index expression j*Width+ i for an element that is in the j th row and i th column of an

array of Width elements in each row.

compiler linearizes them into an equivalent one-dimensional array and translates the

multidimensional indexing syntax into a one-dimensional offset. In dynamically allo-

cated arrays, the current CUDA C compiler leaves the work of such translation to the

programmers because of the lack of dimensional information at compile time.

A two-dimensional array can be linearized in at least two ways. One way is to

place all elements of the same row into consecutive locations. The rows are then

placed one after another into the memory space. This arrangement, called row-major

layout, is depicted in Fig. 3.3. To improve readability, we will use Mj,i to denote the M

element at the jth row and the ith column. Pj,i is equivalent to the C expression M[j][i]

but is slightly more readable. Fig. 3.3 illustrates how a 4 × 4 matrix M is linearized

into a 16-element one-dimensional array, with all elements of row 0 first, followed

by the four elements of row 1, and so on. Therefore, the one-dimensional equivalent

index for M in row j and column i is j4 + i. The j4 term skips all elements of the rows

before row j. The i term then selects the right element within the section for row j.

The one-dimensional index for M2,1 is 2*4 + 1 = 9, as shown in Fig. 3.3, where M9

is the one-dimensional equivalent to M2,1. This process shows the way C compilers

linearize two-dimensional arrays.

Another method to linearize a two-dimensional array is to place all elements of

the same column into consecutive locations. The columns are then placed one after

another into the memory space. This arrangement, called the column-major layout

is used by FORTRAN compilers. The column-major layout of a two-dimensional

3.2 Mapping threads to multidimensional data

51

array is equivalent to the row-major layout of its transposed form. Readers whose

primary previous programming experience were with FORTRAN should be aware

that CUDA C uses the row-major layout rather than the column-major layout. In

addition, numerous C libraries that are designed for FORTRAN programs use the

column-major layout to match the FORTRAN compiler layout. Consequently, the

manual pages for these libraries, such as Basic Linear Algebra Subprograms (BLAS)

(see “Linear Algebra Functions” sidebar), usually instruct the users to transpose the

input arrays if they call these libraries from C programs.

LINEAR ALGEBRA FUNCTIONS

Linear algebra operations are widely used in science and engineering applica-

tions. BLAS, a de facto standard for publishing libraries that perform basic

algebraic operations, includes three levels of linear algebra functions. As the

level increases, the number of operations performed by the function increases

Advertisement

as well. Level-1 functions perform vector operations of the form y = αx +

y, where x and y are vectors and α is a scalar. Our vector addition example

is a special case of a level-1 function with α=1. Level-2 functions perform

matrix–vector operations of the form y = αAx + βy, where A is a matrix, x

and y are vectors, and α, β are scalars. We will be examining a form of level-2

function in sparse linear algebra. Level-3 functions perform matrix–matrix

operations of the form C = αAB + βC, where A, B, C are matrices and α,

β are scalars. Our matrix–matrix multiplication example is a special case of

a level-3 function, where α=1 and β=0. These BLAS functions are used as

basic building blocks of higher-level algebraic functions such as linear system

solvers and eigenvalue analysis. As we will discuss later, the performance of

different implementations of BLAS functions can vary by orders of magni-

tude in both sequential and parallel computers.

We are now ready to study the source code of colorToGreyscaleConversion

shown in Fig. 3.4. The kernel code uses the formula

L

r

  • .

0 21

g

  • .

0 72

b

  • .

0 07

to convert each color pixel to its greyscale counterpart.

A total of blockDim.x*gridDim.x threads can be found in the horizontal direc-

tion. As in the vecAddKernel example, the expression

Col=blockIdx.x*blockDim.x+threadIdx.x generates every integer value from 0

to blockDim.xgridDim.x–1. We know that gridDim.xblockDim.x is greater than

or equal to width (m value passed in from the host code). We have at least as many

threads as the number of pixels in the horizontal direction. Similarly, we know that

52

CHAPTER 3 Scalable parallel execution

// we have 3 channels corresponding to RGB

// The input image is encoded as unsigned characters [0, 255]

__global__

void colorToGreyscaleConversion(unsigned char * Pout, unsigned

char * Pin, int width, int height) {,

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

int Row = threadIdx.y + blockIdx.y * blockDim.y;

if (Col < width && Row < height) {

// get 1D coordinate for the grayscale image

int greyOffset = Row*width + Col;

// one can think of the RGB image having

// CHANNEL times columns than the grayscale image

int rgbOffset = greyOffset*CHANNELS;

unsigned char r = Pin[rgbOffset ]; // red value for pixel

unsigned char g = Pin[rgbOffset + 2]; // green value for pixel

unsigned char b = Pin[rgbOffset + 3]; // blue value for pixel

// perform the rescaling and store it

// We multiply by floating point constants

Pout[grayOffset] = 0.21fr + 0.71fg + 0.07f*b;

}

}

FIGURE 3.4

Source code of colorToGreyscaleConversion showing 2D thread mapping to data.

at least as many threads as the number of pixels in the vertical direction are present.

Therefore, as long as we test and make sure only the threads with both Row and Col

values are within range—i.e., (Col<width) && (Row<height)—we can cover every

pixel in the picture.

Given that each row has width pixels, we can thus generate the one-dimensional

index for the pixel at row Row and column Col as Row*width+Col. This one-dimen-

sional index greyOffset is the pixel index for Pout as each pixel in the output grey-

scale image is one byte (unsigned char). By using our 76 × 62 image example, the

linearized one-dimensional index of the Pout pixel is calculated by thread(0,0) of

block(1,0) with the formula:

Pout blockIdx.yblockDim.y threadIdx.y,blockIdx.xblockDim.xx threadIdx.x

P

out

, *

1 16 0 0 16 0

*

P

out

,

16 0

P

[

out

16

*

76

0

]

P

uut[

o

1216

]

As for Pin, we multiply the gray pixel index by 3 because each pixel is stored as

(r, g, b), with each equal to one byte. The resulting rgbOffset gives the starting loca-

tion of the color pixel in the Pin array. We read the r, g, and b values from the three

consecutive byte locations of the Pin array, perform the calculation of the greyscale

pixel value, and write that value into the Pout array by using greyOffset. With our

76 × 62 image example, the linearized one-dimensional index of the Pin pixel is

calculated by thread(0,0) of block(1,0) with the following formula:

3.2 Mapping threads to multidimensional data

53

FIGURE 3.5

Covering a 76 × 62 picture with 16 × 16 blocks.

PinblockIdx.yblockDim.y threadIdx.y,blockIdx.xblockDim.x threadIdx.x

3

[33648]

[

in

16

76

in

in

P

P

P

*

0

*

]

,

16 0

P

in

, *

1 16 0 0 16 0

*

The data being accessed are the three bytes, starting at byte index 3648.

Advertisement

Fig. 3.5 illustrates the execution of colorToGreyscaleConversion when process-

ing our 76 × 62 example. Assuming that we use 16 × 16 blocks, launching color-

ToGreyscaleConvertion generates 80 × 64 threads. The grid will have 20 blocks—5

in the horizontal direction and 4 in the vertical direction. The execution behavior

of blocks will fall into one of four different cases, depicted as four shaded areas in

Fig. 3.5.

The first area, marked as “1” in Fig. 3.5, consists of threads that belong to the 12

blocks covering the majority of pixels in the picture. Both the Col and Row values

of these threads are within range; all these threads will pass the if-statement test

and process pixels in the heavily shaded area of the picture—i.e., all 16 × 16 = 256

threads in each block will process pixels. The second area, marked as “2” in Fig. 3.5,

contains the threads that belong to the three blocks in the medium-shaded area cover-

ing the upper right pixels of the picture. Although the Row values of these threads are

always within range, some Col values exceed the m value (76). The reason is that the

number of threads in the horizontal direction is always a multiple of the blockDim.x

value chosen by the programmer (16 in this case). The smallest multiple of 16 needed

to cover 76 pixels is 80. Thus, 12 threads in each row will have Col values that are

within range and will process pixels. Meanwhile, 4 threads in each row will have Col

values that are out of range and thus fail the if-statement condition. These threads

will not process any pixels. Overall, 12 × 16 = 192 of the 16 × 16 = 256 threads in

each of these blocks will process pixels.

54

CHAPTER 3 Scalable parallel execution

The third area, marked “3” in Fig. 3.5, accounts for the 3 lower left blocks cover-

ing the medium-shaded area in the picture. Although the Col values of these threads

are always within range, some Row values exceed the m value (62). The reason is

that the number of threads in the vertical direction is always a multiple of the

blockDim.y value chosen by the programmer (16 in this case). The smallest multiple

of 16 to cover 62 is 64. Thus, 14 threads in each column will have Row values that are

within range and will process pixels. Meanwhile, 2 threads in each column will

fail the if-statement of area 2 and will not process any pixels. Of the 256 threads,

16 × 14 = 224 will process pixels. The fourth area, marked “4” in Fig. 3.5, contains

threads that cover the lower right, lightly shaded area of the picture. In each of the

top 14 rows, 4 threads will have Col values that are out of range, similar to Area 2.

The entire bottom two rows of this block will have Row values that are out of range,

similar to area “3”. Thus, only 14 × 12 = 168 of the 16 × 16 = 256 threads will

process pixels.

We can easily extend our discussion of 2D arrays to 3D arrays by including

another dimension when we linearize arrays. This is accomplished by placing each

“plane” of the array one after another into the address space. The assumption is that

the programmer uses variables m and n to track the number of columns and rows

in a 3D array. The programmer also needs to determine the values of blockDim.z

and gridDim.z when launching a kernel. In the kernel, the array index will involve

another global index:

int Plane = blockIdx.z*blockDim.z + threadIdx.z

The linearized access to a three-dimensional array P will be of the form

P[Planemn+Row*m+Col]. A kernel processing the 3D P array needs to check

whether all the three global indexes—Plane, Row, and Col—fall within the valid

range of the array.

3.3 IMAGE BLUR: A MORE COMPLEX KERNEL

We have studied vecAddkernel and colorToGreyscaleConversion in which each

thread performs only a small number of arithmetic operations on one array element.

These kernels serve their purposes well: to illustrate the basic CUDA C program struc-

ture and data parallel execution concepts. At this point, the reader should ask the obvious

question—do all CUDA threads perform only such simple, trivial amount of operation

independently of each other? The answer is no. In real CUDA C programs, threads often

perform complex algorithms on their data and need to cooperate with one another. For

the next few chapters, we are going to work on increasingly more complex examples

that exhibit these characteristics. We will start with an image blurring function.

Image blurring smooths out the abrupt variation of pixel values while preserving

the edges that are essential for recognizing the key features of the image. Fig. 3.6

illustrates the effect of image blurring. Simply stated, we make the image appear

blurry. To the human eye, a blurred image tends to obscure the fine details and present

3.3 Image blur: a more complex kernel

55

FIGURE 3.6

An original image and a blurred version.

the “big picture” impression or the major thematic objects in the picture. In computer

image processing algorithms, a common use case of image blurring is to reduce the

impact of noise and granular rendering effects in an image by correcting problematic

pixel values with the clean surrounding pixel values. In computer vision, image blur-

ring can be used to allow edge detection and object recognition algorithms to focus

on thematic objects rather than being impeded by a massive quantity of fine-grained

objects. In displays, image blurring is sometimes used to highlight a particular part

of the image by blurring the rest of the image.

Mathematically, an image blurring function calculates the value of an output

image pixel as a weighted sum of a patch of pixels encompassing the pixel in the input

image. As we will learn in Chapter 7, Parallel pattern: convolution, the computation of

such weighted sums belongs to the convolution pattern. We will be using a simplified

approach in this chapter by taking a simple average value of the N×N patch of pixels

surrounding, and including, our target pixel. To keep the algorithm simple, we will not

place a weight on the value of any pixels based on its distance from the target pixel,

which is common in a convolution blurring approach such as Gaussian blur.

Fig. 3.7 shows an example using a 3 × 3 patch. When calculating an output pixel

value at the (Row, Col) position, we see that the patch is centered at the input pixel

located at the (Row, Col) position. The 3 × 3 patch spans three rows (Row-1, Row,

Row+1) and three columns (Col-1, Col, Col+1). To illustrate, the coordinates of the

nine pixels for calculating the output pixel at (25, 50) are (24, 49), (24, 50), (24, 51),

(25, 49), (25, 50), (25, 51), (26, 49), (26, 50), and (26, 51).

Fig. 3.8 shows an image blur kernel. Similar to that in colorToGreyscaleCon-

version, we use each thread to calculate an output pixel. That is, the thread to output

data mapping remains the same. Thus, at the beginning of the kernel, we see the

familiar calculation of the Col and Row indexes. We also see the familiar if-statement

that verifies whether both Col and Row are within the valid range according to the

height and width of the image. Only the threads whose Col and Row indexes are

within the value ranges will be allowed to participate in the execution.

56

CHAPTER 3 Scalable parallel execution

FIGURE 3.7

Each output pixel is the average of a patch of pixels in the input image.

FIGURE 3.8

An image blur kernel.

3.3 Image blur: a more complex kernel

57

As shown in Fig. 3.7, the Col and Row values also generate the central pixel

location of the patch used to calculate the output pixel for the thread. The nested

for-loop Lines 3 and 4 of Fig. 3.8 iterate through all pixels in the patch. We assume

that the program has a defined constant, BLUR_SIZE. The value of BLUR_SIZE is set

such that 2*BLUR_SIZE gives the number of pixels on each side of the patch. For a 3

× 3 patch, BLUR_SIZE is set to 1, whereas for a 7 × 7 patch, BLUR_SIZE is set to 3. The

outer loop iterates through the rows of the patch. For each row, the inner loop iterates

through the columns of the patch.

In our 3 × 3 patch example, the BLUR_SIZE is 1. For the thread that calculates the

output pixel (25, 50), during the first iteration of the outer loop, the curRow variable

is Row-BLUR_SIZE = (25 − 1) = 24. Thus, during the first iteration of the outer loop,

the inner loop iterates through the patch pixels in row 24. The inner loop iterates from

the column Col-BLUR_SIZE = 50 − 1 = 49 to Col+BLUR_SIZE = 51 by using the

curCol variable. Therefore, the pixels processed in the first iteration of the outer loop

are (24, 49), (24, 50), and (24, 51). The reader should verify that in the second itera-

tion of the outer loop, the inner loop iterates through pixels (25, 49), (25, 50), and

(25, 51). Finally, in the third iteration of the outer loop, the inner loop iterates through

pixels (26, 49), (26, 50), and (26, 51).

Line 8 uses the linearized index of curRow and curCol to access the value of the

input pixel visited in the current iteration. It accumulates the pixel value into a run-

ning sum variable pixVal. Line 9 records the addition of one more pixel value into

Advertisement

the...