00 Dissertation
00 Dissertation
submitted to the
of the
put forward by
born in
Görlitz, Sachsen
Heidelberg, 2020
Exploiting BSP Abstractions for
Compiler Based Optimizations of
GPU Applications on multi-GPU
Systems
1 Introduction 1
2 Background 7
2.1 GPU Architecture and Programming Models . . . . . . . . . . . . . . . 7
2.2 The LLVM Compiler Infrastructure Project and Clang . . . . . . . . . 11
2.3 Memory Trace Collection on GPUs . . . . . . . . . . . . . . . . . . . . 17
2.4 Polyhedral Compilation . . . . . . . . . . . . . . . . . . . . . . . . . . 19
7 Conclusion 125
Bibliography 139
Chapter
1
Introduction
Computers as the defining invention of the information age have enabled breakthroughs
in a variety of fields. They execute computations that are so massive in scale that
even large groups of humans simply can not perform the same computations within
a reasonable time-frame. Equations that describe real-world phenomena, such as
Maxwell’s equations behavior of electro-magnetic fields or Euler equations for the
motion of non-viscous fluids, are computationally so expensive that their manual
application typically was limited to more easily solvable corner cases. The invention of
powerful computers suddenly provided the opportunity to perform these calculations
kick-started the field of computational science that focuses on computers primarily as
tools for scientific research. The wild success of computers has made them pervasive in
virtually every industry, from the characterization and prediction of financial markets, 3-
dimensional x-ray imaging and gene sequencing in medical sciences, to the simulation of
galaxies and analysis of the cosmos’ background radiation in astronomy. In all of these
examples, having access to more computational power provides more accurate results
and as a result. As a result, since the invention of the first computer, the manufacturers
of their components have been in a steady result to provide ever better-performing
products.
A common way to improve the performance of a computer system is the addition
of specialized chips that have a limited set of functions but outperform the Central
Processing Unit (CPU) at these functions. These chips are called accelerators, and a
very successful example of them is the Graphics Processing Units (GPUs). They were
originally invented to speed up the synthesis of (three-dimensional) computer images and
1
Introduction
are designed to compute a very large number of simple linear algebra problems, being
both faster than and more energy-efficient than CPUs for this purpose. They quickly
stirred the interest of the scientific community, which began using them for scientific
purposes other than image synthesis, creating the General Purpose Programming on
Graphics Processing Units (GPGPU) community. Their focus on computer graphics
has led to different design decisions than those made for CPUs. Instead of aggressively
optimizing the execution of an individual computation to be as fast as possible, they are
optimized to execute a large batch of similar computations in a short time, sacrificing
the execution time of any individual computation. They require special software to be
programmed and the two most important GPGPU frameworks, OpenCL and CUDA,
rely on the so-called Bulk-Synchronous Parallel (BSP) programming model as their
primary abstraction [1]. In the BSP programming model, an application is broken down
into sections, so-called “super steps”, which contain a high number of small programs
(called threads) that can be executed independently of each other and synchronization
is only possible at the boundaries of these super steps. Both OpenCL and CUDA
provide a relaxed implementation of this that allows limited communication between
parallel threads in small groups. By providing many more threads than can be executed
on the hardware, while also limiting communication between any two threads, memory
latencies can be hidden and performance is portable between different generations and
designs GPUs. This is a very important feature of data-parallel languages such as
OpenCL and CUDA, as it hides architectural aspects from the user while providing
consistent performance, independent of actual hardware configurations.
For applications that require large amounts of parallel computations, GPUs often
provide the majority of the computation power and turn into the bottleneck of the
system. While installing additional GPUs into the system increases the available raw
processing power, applications need to be aware of this, which introduces additional
complexity. Efficiently utilizing an individual GPU already requires high skill and
adding the need to synchronize GPUs and manage data movements between them is a
significant burden. Although more recent CUDA versions are tackling this complexity
by allowing different GPUs to directly access each other’s main memory, creating a
Virtual Shared Memory (VSM), accessing memory located on another GPU is orders
of magnitudes slower. The additional complexity required to efficiently utilize multiple
GPUs in a single node is comparable to utilizing multiple nodes in traditional cluster
systems. While the distribution of work across multiple GPUs benefits from the
BSP programming model and its relaxed guarantees between multiple threads, data
distribution and collection requires the insertion of communications code around all
2
BSP super steps. This transformation requires careful changes throughout the whole
application, a process that is tedious and error-prone.
This work analyzes the challenges of utilizing multiple GPUs and derives a way to
simplify the transition from one to multiple GPUs. It relies on the key observation
that both the BSP programming GPU hardware favor programs that contain limited
control flow and access memory in a predictable way. By creating a model of the
memory accesses, data dependencies can be identified and the data transfers required
by a partitioned application can be computed automatically. Using this information,
the application can then be safely partitioned over multiple GPUs, each computing a
subset of the solution and communication where necessary. This work can be performed
automatically by a compiler that translates a given GPU program from source code
to an executable file. Such an automatic partitioning provides a massive increase in
productivity as it transparently allows the utilization of multiple GPUs while keeping
the relative simplicity of the single-GPU programming model.
An analysis of the memory accesses performed by applications taken from popular
GPU-benchmarks suites suggests that GPU applications are expected to be amenable to
such an automated partitioning. The analysis uses the memory accesses to determine
the minimum amount of communication that the given application would require
to compute correct results when being split into multiple partitions. The primary
takeaway of this analysis is that most applications can be split in such a way that
communication volume grows sublinear with the number of partitions, suggesting that
the aggregated bandwidth of additional GPUs can more than balance the additional
volume.
This work presents a concept and prototype implementation for an automatically
partitioning CUDA compiler that relies on a hybrid optimization scheme utilizing
static code analysis and a runtime support system. The static code analysis extracts
a mathematical model of the application’s memory accesses in GPU memory and
a transformation step distributes work across multiple GPUs and inserts code for
synchronization and communication. The communications code is generated using
optimizing libraries to efficiently describe data dependencies to minimize runtime
overheads. A runtime system determines the target GPU of data dependencies and
minimizes data transfers by implementing a simple coherence scheme. A fall-back
solution provides support for non-partitionable GPU kernels. The prototype compiler
is explained in detail and evaluated using a set of custom benchmarks. It is based
on the gpucc CUDA compiler that is integrated into the LLVM compiler project [2].
Although CUDA has been chosen as the sole input language for pragmatic reasons,
3
Introduction
there are no conceptual differences when replacing CUDA with OpenCL. While this
work focuses on the distribution of work across the GPUs of a single node, the approach
can be transparently applied to GPU-clusters or cloud environments using existing
software [3].
Contributions
This work makes the following contributions:
4
of the implementation are grouped into functional units and explained in detail.
The work implements an improved concept of the prototype presented at the 9th
International Workshop on Programmability and Architectures for Heterogeneous
Multicores (MULTIPROG-2016). 1 .
5
Introduction
support system. In Chapter 6.1, the work is put into context in its field by discussing
and differentiating work related to the individual aspects of this work or the general
approach in its entirety. The work concludes in Chapter 7, which critically discusses
the result of this work, provides an outlook, and parts with final words.
6
Chapter
2
Background
This chapter provides background on the major topics that have been relevant as part
of this work. The sections are written to familiarize the reader enough with each topic
to be able to follow this work. Each topic represents a field of research in its own right
and is too vast to provide an in-depth introduction within the scope of this thesis.
Interested readers are encouraged to use the provided references as starting points for
further reading.
7
Background
computing [8, 9, 10]. This high demand led to the creation of tools for explicit GPGPU
programming, such as CUDA and OpenCL [11, 12].
Statements made in this section about NVIDIA hardware and the CUDA program-
ming model are taken primarily from official white papers and online documentation
released by NVIDIA unless cited otherwise [13, 14, 12]. While this work focuses on
NVIDIA GPUs and the programming model implemented by CUDA, the general
architecture and programming models of GPUs from other vendors are similar and the
general statements apply to them as well.
The differences in the design between GPUs and CPUs can be summarized like
this: GPUs contain thousands of fairly simple cores instead of a few very powerful ones
found in CPUs. Many of the more detailed design decisions follow as a consequence
to facilitate this overarching premise. GPU cores are simplified to such a degree
that they don’t even contain an individual scheduler but share a single one within
a group (of size 32 in current NVIDIA hardware), called a warp. As a consequence,
the cores can not be programmed individually but instead present a single instruction
multiple thread system, where all cores of the system execute the same instruction with
different operands in lockstep [15, 13]. The main memory follows a relaxed consistency
model with severely limited guarantees about the visibility of modifications, reducing
synchronization overhead and increasing parallel throughput. Caches are not implicit
but explicit, simplifying hardware at the cost of requiring manual management. Host-
system synchronization and I/O-facilities are severely limited to further increase parallel
throughput. While some of these missing features are beginning to be implemented in
more recent versions of the CUDA hardware and software (CUDA 9.0 and onwards),
they are still severely limited compared to CPUs.
CUDA is a proprietary GPGPU programming language based on C++, developed
and endorsed by NVIDIA to program their hardware. The GPGPU programming
model used by CUDA is an implementation of the Bulk Synchronous Parallel (BSP)
programming model [14]. In this programming model, a parallel program is divided into
super-steps that execute a large number of threads in parallel without synchronization,
followed by a synchronization point for all threads in-between super-steps [1]. In
CUDA terminology, the super-steps are called a kernel and execute in a two-level
hierarchy of threads. The first level of the hierarchy is called the (thread) grid, a large,
3-dimensional grid containing a thread block on each node of the grid. Each of these
thread blocks itself consists of a large number of individual threads that are organized
into another 3-dimensional cuboid, forming the second level of the hierarchy.
Within a thread block, threads share an explicit cache, can synchronize execution
8
2.1 GPU Architecture and Programming Models
Figure 2.1: A simplified example code in CUDA showing code intended to run on the
device and the host-code syntax for the configuration and launch of a kernel.
with each other, and provide guarantees about progress. Thread blocks are the atomic
scheduling unit in CUDA: threads in a thread-block can only be scheduled for execution
as a whole, not individually. Communication or synchronization between threads of
different thread blocks is generally not possible 1 . The weak/non-existent guarantees
about the visibility of memory accesses and lack of synchronization effectively mean
that thread blocks execute fully independently of each other.
Applications written in CUDA are targeting two different architectures: the host
CPU and the NVIDIA GPU executing the kernels. Although the code for both
architectures can, and often is, written in a single source code file, the functions
targeting each architecture can be clearly distinguished from each other and provide
slightly different semantics. Definitions and functions intended for the GPU are called
device code and are prefixed by the macros __global__ or __device__. The code in a
CUDA kernel describes the code for an individual thread, which is then executed by a
potentially very large number of actual threads. In order to differentiate both control
flow and data for each thread, a 3-dimensional index for each thread is provided that
1
The exception is a memory fence across the whole system, which is rarely used due to dramatic
performance implications and a weak implementation [16].
9
Background
uniquely identifies the thread in the thread hierarchy. This unique global index is
calculated in figure 2.1 in line 2 and then first used to avoid an out-of-bounds error
in line 3 and to index a unique element in the arrays a and b in line 4. This usage
pattern for thread indices can be found in almost every CUDA application. OpenCL
provides a built-in command that returns the global unique index directly, skipping
the equivalent formula threadIdx.x + blockIdx.x * blockDim.x used in CUDA.
The threads of each thread block in a GPU can be configured to have accesses to a
portion of the manually managed cache, called shared memory in CUDA terminology.
While a consistent view of this cache is also not enforced between threads by default,
CUDA provides memory fences that enforce consistency. Shared memory is located
on-chip and therefore much faster than the GPU’s main memory, called global memory.
The lower latency is particularly beneficial for repeatedly accessed values shared by
multiple threads, empirical proof of which is the high popularity of tiling optimizations
on the granularity of thread blocks [17, 18].
Device code is compiled down into an intermediate Instruction Set Architecture
(ISA) that is portable between different iterations of NVIDIA GPUs, called Parallel
Thread eXecution (PTX). It is then translated into machine code for the actual GPU
found in the system by the driver before kernel execution.
Analogously to device code, definitions and functions intended for the host archi-
tecture are referred to as host code and are implemented in regular C++, except for a
special notation that configures and launches a CUDA kernel on a GPU. In addition
to launching kernels, the host code also takes care of the auxiliary code required for
the kernels to run, in particular allocation and management of memory buffers on the
GPU. The code in figure 2.1 from line 10 to line 20 shows a simplified version of the
code required to launch a kernel. First, a buffer is allocated on the device using the
cudaMalloc API call, which is then filled in from a regular host buffer by the cudaMemcpy
API call. Then, the thread block and the thread grid sizes are computed in such a
way that the total number of threads is guaranteed to be at least as large the number
of array elements, a pattern commonly found in CUDA code. Finally, the kernel is
launched using the special kernel launch syntax, followed by another cudaMemcpy call to
copy the result from GPU memory back to host memory. While the kernel launch is
an asynchronous operation and might return before the kernel has finished execution,
operations on a given GPU are sequential, so the synchronous cudaMemcpy call waits for
the kernel to finish before it starts copying. The application finishes by releasing the
GPU buffers.
The host and device code found in a CUDA source files are compiled as a unit and
10
2.2 The LLVM Compiler Infrastructure Project and Clang
produce a single binary that executes on any system with a compatible driver and
CUDA enabled hardware. The GPU architectures are backward-compatible, i.e. newer
GPUs can always execute code compiled for older architectures and any translation
steps are handled transparently by the driver.
The CUDA API has supported the parallel utilization of multiple GPUs in parallel
since its release. Multi-GPU support is implemented by providing a function that
allows setting any available GPU as the “active” GPU that will be the target of all
subsequent CUDA API calls. If only a single GPU is to be utilized, this function
can be simply ignored as the API is initialized to use the “first available” GPU in the
system by default.
11
Background
that the majority of all instructions are side-effect free. Exceptions to this rule
exist, the most prominent ones being load and store instructions.
12
2.2 The LLVM Compiler Infrastructure Project and Clang
1 entry:
2 %2 = icmp eq i32 %c, 0
3 br i1 %2, label %else, \
entry:
4 label %then
%2 = icmp eq i32 %c, 0 5 then:
br i1 %2, label %else, label %then 6 %4 = tail call i32 (...) @a()
7 br label %cont
then: else: 8 else:
%4 = tail call i32 (.) @a() #2 %6 = tail call i32 (.) @b() #2 9 %6 = tail call i32 (...) @b()
br label %cont br label %cont
10 br label %cont
11 cont:
cont: 12 %res = phi i32 [ %4, %then ], \
%8 = phi i32 [ %4, %then ], [ %6, %else ] 13 [ %6, %then ]
ret i32 %8
14 ret i32 %res
15 }
(a) The control flow graph of the LLVM IR
code. (b) The LLVM IR generated for code that
chooses between two values based on a vari-
able “%c”.
values that evolve over the runtime of the program, such as loop induction variables.
SSA solves this problem by introducing the phi-instruction (also ϕ-instruction/node),
a pseudo-instruction that “magically” selects between different values depending on
previous control flow. Figure 2.2 illustrates the Control Flow Graph (CFG) of a
function that selects between two values, for example from a statement such as return
c ? a() : b();. The code first diverges at the condition in the “entry” block and then
converges in the “cont” block, returning the result of either the “then” or the “else”
block. Since it is illegal for programs in SSA form to assign two different values to the
same name, a new value is created in each block: the value %4 in the then-block and %6
in the else-block. They are merged into a single value by the phi-instruction in the
cont-block by assigning either the %4 or %6 value to %res, depending from where the
cont-block was entered.
The phi-instruction is not intended to be executed on real hardware and only used
to simplify data flow analysis during optimization stages by providing a side-effect free
way of representing changing values in SSA. During code generation, the phi-instruction
is translated into one or more instructions on the target hardware that together provide
identical semantics. As an example, when generating code for x86-based architectures,
the code shown in 2.2 can be translated into mov instructions that store the value of
a() and b() directly into the e.g. EAX register in the respective branch. Depending on
13
Background
Figure 2.3: Simplified architecture of the Clang compiler. Examples for related
toolchains that reuse parts are depicted in dashed outlines.
which path is taken, the correct value can be found in the EAX register and returned
as the result. This implements the logic represented by the phi-instruction at the
cost of introducing state, since the EAX register is not freely available for computations
anymore.
By providing this well-defined intermediate representation as the primary interface
between the components of the LLVM toolchain, the individual components can
be very loosely coupled and designed in a highly reusable way. Since the majority
of the core LLVM project is implemented as a set of mostly independent analysis
and transformation passes, the core project is rarely used on its own and instead
typically integrated into a fully-featured compiler toolchain. One such toolchain that is
maintained as part of the LLVM project is the Clang project, a compiler for languages
of the C-family of languages [24].
Clang implements the generic design of an optimizing compiler by being split into
the following three basic components: front-end (parser), middle-end (optimizer), and
back-end (code generator). This design simplifies porting a compiler to new input
languages or target architectures by decoupling input, processing, and output. A
simplified view of the LLVM architecture is shown in figure 2.3 and includes toolchains
other than Clang that are based on LLVM [28, 29, 30].
First, the front-end reads a program in the input programming language and
generates an easier processable intermediate representation of the program. This is
done by first parsing the source code into an Abstract Syntax Tree (AST), which is a
structured high-level representation of the input. Parsing is often done in a two-step
process: A lexer splits the input data stream into individual tokens such as identifies,
numbers, parentheses, and others, while ignoring insignificant parts such as white-space
or comments. The tokens are then fed into a parser that uses rules (based on a grammar
14
2.2 The LLVM Compiler Infrastructure Project and Clang
ReturnStmt
1 define i32 @add(i32, i32) {
2 %3 = alloca i32, align 4
3 %4 = alloca i32, align 4 BinaryOperator
4 store i32 %0, i32* %3, align 4 'int' '+'
Figure 2.4: A function adding two numbers in its different forms as it processed by the
Clang front-end.
describing the structure of the programming language) to assemble the tokens into an
AST that is returned as the result. If it encounters an unexpected token, it simply
aborts with an error message. The AST is then translated into unoptimized LLVM
IR. Since AST is considered a high-level representation containing complex program
structures and IR is much simpler and can not model all of the high-level information,
this step is called “lowering”. Figure 2.4 illustrates the process for a function that adds
two numbers and returns the result. Each translation of the input into a different form
performs a lowering from a representation with complex semantics into an equivalent
representation using simpler semantics.
Second, the middle-end takes unoptimized LLVM IR from the front-end and passes
it through a series of optimizations. This optimizer stage represents the heart of the
LLVM project in the context of the Clang compiler. Optimizations range from fairly
simple such as common subexpression elimination to very complex such as aggressive
restructuring and parallelization of loop nests [31, 32]. The output of the middle-end is
again LLVM IR representing a semantically identical, but optimized, form of the input.
Although certain optimizations rely on others as a precondition (in particular, most
optimizations rely on the code being in proper SSA form), the optimizations and the
15
Background
Figure 2.5: The modified Clang pipeline to support CUDA via the gpucc project.
code generator are largely independent of each other. This allows reusing the majority
of the optimizations for almost arbitrary input languages or target architectures.
Lastly, the back-end consumes the (optimized) LLVM from the middle-end and
produces code for a specific target architecture from it. Similar to the front-end, this
step again lowers the program representation, from the LLVM IR into either assembly or
machine code for a specific architecture, e.g. x86 or ARM. Target-specific optimizations
are also applied at this stage, such as instruction combining (e.g. fused multiply-add)
or removal of redundant instruction sequences [33, 34, 35]. The output of the back-end
is an object-file containing machine-code specific to the target architecture.
In addition to providing all the components required by a modern compiler, the
Clang project also provides an interface that connects these components and provides
a convenient interface for the end-user, called the compiler driver. While the compiler
driver primarily benefits the user experience, the complexity of a modern optimizing
compiler is often overwhelming and necessitates such a driver for anyone but expert
users.
Although Clang primarily targets the languages C, C++, and Objective-C, it also
supports the proprietary GPGPU language CUDA in the form of the gpucc project [2].
It initially started as a separate effort by Google, but was soon fully integrated into
the project and is now maintained as an official part of the LLVM and Clang projects.
The combination of both host and device code in a single source code file requires
16
2.3 Memory Trace Collection on GPUs
drastic changes to the regular Clang pipeline used for C or C++. Host and device
code are split and then compiled separately, with the device code being compiled down
to NVIDIDA PTX code first, which is then embedded into the host code. Figure 2.5
depicts an overview of the pipeline implemented by gpucc.
In the context of this work, both the front-end and the back-end of Clang are left
unchanged. Some functionality is implemented as pre-processing before the front-end
is executed, but the majority of the components developed in this are implemented to
purely work on LLVM IR.
17
Background
information at the ISA level. The Barra simulator is based on the simulator framework
UNISIM and provides a functional simulator based on the CUDA PTX ISA without
targeting a specific hardware target [36, 37]. A more detailed simulator is Multi2Sim,
which simulates a system containing both a generic x86 CPU as well as an Advanced
Micro Devices (AMD) Evergreen GPU on the instruction level [38, 39]. The highest
level of detail can be extracted from simulators modeling the full architecture of a
specific hardware. One such project is a reincarnation of the Multi2Sim project that
simulates an NVIDIA GPU based on the Kepler architecture to a high degree of
accuracy [40]. Another project is the GPGPU-Sim, which models large aspects of
an NVIDIA GPU, including individual cores, memory controllers, and the central
interconnection network [41]. As a consequence of this high degree of detail, executing
an application on this simulator is slowed down by a factor of 170, 000x to 2, 000, 000x
[42]. For the collection of memory traces that tie a CUDA thread block to the addresses
of its memory accesses, an ISA-level simulation is accurate enough since it does model
the execution grid and the instructions contain the addresses in question.
The third option for the extraction of memory accesses is instrumentation, the
compiler-based insertion of specific instructions that emit information about the
application. Instrumentation is often used to collect statistics about the execution
profile of an application, e.g. to identify time-consuming code regions, compute test
coverage, or locate memory leaks [43, 44]. The changes required for instrumentation
are usually not complex and easily automated. Two different types exist: source
instrumentation and binary instrumentation. Source instrumentation refers to the
insertion of instrumentation code during the normal compilation of the program
[23, 45]. Depending on where in the compilation pipeline the instrumentation code
is inserted, it can interfere with the optimizations performed by the compiler, so
instrumentation code should be added late in the pipeline for accurate results. While
there are approaches that insert the corresponding code extremely early during the
preprocessor stage [46], compilers often already include some instrumentation facilities
[47]. An obvious requirement for source instrumentation is access to the source code of
the application that is to be instrumented. The alternative, binary instrumentation,
targets compiled applications and inserts either fixed or user-defined code into an
application by carefully transforming the binary file, providing very accurate results
but offering limited portability. Some projects go a step further and provide facilities
for the modification of application during execution time [48]. Adding instrumentation
to an application always increases its runtime, whether the code is inserted during
compilation or as part of a binary translation [49, 44, 50, 51]. Although the amount of
18
2.4 Polyhedral Compilation
overhead depends on the specific type of instrumentation added, the slowdown observed
for popular instrumentation suites is between one and two orders of magnitude [52, 44].
Memory traces collected using instrumentation are accurate on the level of individual
instructions. Although the lack of access to hardware internals might prevent the user
from seeing the effects of techniques such as read-combining [53], the traces reproduce
the exact sequence of memory accesses as seen by the application. Instrumentation on
GPUs is not as common as on CPUs, but frameworks exist. GPU Lynx is a dynamic
instrumentation tool and hooks into the CUDA runtime system to install user-specified
code written in a C-like language [54]. Unfortunately, the project does not support
any CUDA releases after version 5.0 and is limited to producing results of a fixed size,
while memory traces are unbound in the general case. SASSI is a framework for static
instrumentation of CUDA kernels and fully supports CUDA to specify instrumentation
hooks [55]. However, it is a closed source solution that does not seem to be actively
maintained. BARRACUDA is a custom binary instrumentation tool for CUDA that is
used to develop instruction traces for race detection [56]. This project comes closest to
fulfilling the requirements for the analysis in this work and a patched version of this
framework could have been used instead.
19
Background
existing sets. The conditions, sets, and relations used in the polyhedral model are
considered to be quasi-affine [65]. An example of a possible description for the set
{ S[i] : 0 ≤ i ≤ 5 ∨ 8 ≤ i ≤ 10 }. (2.2)
The list [i] after the name of the set S is used to both state the dimensionality of the
set and assign names to the dimensions. All quasi-affine conditions that define the
tuples in the set are listed after the colon. The dimensions of a set are identified by
their index and their names can be chosen arbitrarily, so the following is true:
While naming polyhedral sets is convenient, the name can also be omitted, creating an
unnamed, or anonymous, set.
Figure 2.6 shows a simple loop in C, containing two statements, S1 and S2 . To a
human programmer, it is obvious that statement S1 is executed ten times, one time each
for i ∈ {0, 1, 2, 3, 4, 5, 6, 7, 9}, and statement S2 is executed five times, one time each
for i ∈ {0, 2, 4, 6, 8}. The execution of such a statement during run-time is considered
its “dynamic instance”. The polyhedral model represents these dynamic instances of
a statement as the statement’s domain. Using polyhedral sets, the domains of the
20
2.4 Polyhedral Compilation
Figure 2.6: A simple loop containing two statements with different domains.
This example show cases one of the main benefits of a polyhedral program representation:
high-level semantics are retained and expressed in a mathematical sound way. Notice
how loop bounds and conditional branches can be translated into simple conditions
that concisely and accurately describe the loop nest without relying on any of the
control flow constructs of the original programming language. An equivalent while
loop would result in the same polyhedral representation.
Memory accesses in the polyhedral model are represented as linear functions that
map one polyhedral set to another. The domain of such an access is the domain of the
statement it belongs and the image, the output, of this polyhedral map describes a set
containing the array elements that are accessed. The notation for this is an extension
from the notation for polyhedral sets:
describes an access from a 2-dimensional loop nest to a one dimensional array (both
anonymous). The quasi-affine function that describes the array index is i + j. Analo-
gously to the dimensions in a set, the dimensions of a map’s domain and image are
identified by their position, and can be arbitrarily named, resulting in the following
sets all being equal
21
Background
1 int a[2*n];
2 int b[n];
3 for (int i = 0; i < n; ++i) {
4 S: b[i] = b[2*i] + b[2*i + 1];
5 }
Notice how the domains of these accesses are unconstrained. If these accesses were part
of a program they would be executed for all points in an infinite 2-dimensional plane.
Figure 2.7 shows an example code in C that memory accesses on two different
arrays. The statement S has the domain [n] → { S[i] : 0 ≤ i < n } and performs the
following memory accesses:
Since polyhedral sets and maps can be manipulated using operators from set theory, the
read accesses can be combined into a single map. Unions are represented by describing
all sets in the union separated by semicolons, resulting in the set:
This union can be simplified by coalescing the accesses, resulting the equivalent set:
Polyhedral maps can also be used to describe dependencies between statements. For
this, the domain of the map represents the dependent loop nest and the map’s image
represents the loop nest it depends on, both of which can be identical for dependencies
within a loop nest. The edges in the map then reveal dependencies between iterations.
As an example, the map
can be used describes dependencies in a loop, where the first ten iterations must
22
2.4 Polyhedral Compilation
occur in ascending order. Although this information is critical for the typical kind of
optimizations performed using polyhedral frameworks [66, 67], a polyhedral model for
dependencies between loop nests is not required for this work.
This representation then allows extensive analyses on the program including, but
not limited to, queries such as the following [62]:
• The minimum and maximum indices of a memory access in each of its dimensions,
which loosely corresponds to the bounds of an array. Both the lexicographic
extremes as well as bounds for an individual dimension can be computed.
• Different kinds of hull volumes, e.g. a convex hull that potentially over-
approximates the result. Such a hull volume can be used to estimate the upper
bounds for the memory area affected by a memory access.
23
Chapter
3
GPU Memory Access Patterns
This chapter covers the collection of traces containing the memory accesses of GPU
applications, inference of the resulting communication if the application was partitioned
among multiple GPUs, and an analysis of this communication. 1 Multi-GPU systems
are typically used for workloads with very large requirements on computational power
and memory bandwidth. Examples include high-performance computing (HPC) [72]
and training of deep neural networks [73, 74]. Multi-GPU solutions have also been
proposed to overcome the scalability limitations of monolithic GPUs [75, 76]. However,
compared to single GPUs, multi-GPU systems show strong Non-Uniform Memory
Access (NUMA) effects, as on-card vs. off-card throughput differs by more than one
order of magnitude [77, 72]. This drastic difference in throughput requires careful data
placement and memory management, complicating the programming of multi-GPU
systems.
The purpose of the analysis is to estimate certain characteristics of automatically
split single-GPU applications, in particular, two properties are of interest:
25
GPU Memory Access Patterns
26
3.1 GPU Memory Trace Collection
Static Analysis
Performance
Instrumentation
Simulation
Accuracy
27
GPU Memory Access Patterns
Figure 3.2: CUDA Compilation pipeline of Clang (gpucc) with tracing. Components
required for memory tracing are highlighted in gray.
limiting their accuracy for general expression. Although the code of GPU applications
often represents the inner loop nest and uses the thread grid as an implicit outer loop
nest, this assumption can not be made in general. The accuracy of static analysis is
therefore deemed too low. This leaves instrumentation as the most reasonable choice
for the collection of memory access patterns.
28
3.1 GPU Memory Trace Collection
components (highlighted in gray) are integrated into it. The framework 2 is designed as
a plugin for Clang/LLVM and a static library that is linked into the final application.
This architecture provides a simple usage model that requires only a few compiler flags
to be set to enable instrumentation. When the instrumented application is executed, a
trace file is produced that, for each kernel, contains a list of records that describe each
memory access of the application. Each record includes among other things the base
address, kind, and origin of each memory access (the full data layout is described in
figure 3.7). The instrumentation is split into three parts that roughly correspond to
the components shown in figure 3.2. Device instrumentation enables trace collection
and writes them into a queue that is setup using the host instrumentation; the queue
implementation itself is part of the support library.
Device Code Instrumentation
The main part of the instrumentation framework is the device code instrumentation,
which is broken up into three steps. The first step is preparation and force-inlines all
inline-able function calls. If any un-inlined function calls remain, the instrumentation
can not proceed and compilation aborts. The next step is the set-up of the context
required to emit memory traces, which mainly consists of a set of queues set up by
the host. Pointers to these queues are located using global device variables following
a naming convention. Lastly, all load or store instructions targeting global memory
are collected and instrumented. The instrumentation consists of the collection of all
interesting information (CTA index, address, operand size) and pushing this information
into the queue to host memory. Once the information is passed to the host, all work
required by the device side is done.
Figure 3.3 illustrates a simplified version of this process. The original code simply
contains to write accesses, one to some address in shared memory and one to some
other address in global memory. Line 1 of the instrumented code contains the device
variable declaration that will contain the pointers to the queue at runtime (notice how
the name contains the kernel name, not mangled here for clarity, as part of the naming
scheme). The next line shows the injected helper function __trace that provides access
to the queue. Inside the kernel, existing instructions are left untouched but right before
the store to global memory a call to the __trace function has been inserted (note that
no such call was inserted before the write access to shared memory). The arguments
to this helper function have been collected from the kernel and the store itself: the
pointer to the queue, the memory access’ base address, the size of the operand and
2
Liberally licensed under MIT and hosted on Github at [Link]
cuda-memtrace
29
GPU Memory Access Patterns
Figure 3.3: Simplified illustration of the device code transformation for memory access
tracing on a simple CUDA code snippet.
the kind of access (read or write). Information about the CTA is gathered inside the
__trace function.
Host Code Instrumentation
The task of the host code instrumentation is to set up the environment for the
device code to produce traces and to process these traces. Most of the more complex
functionality is implemented externally in the support library, reducing host code
instrumentation to a two-step process:
2. Install setup and tear down code for the support library.
Locating kernels in the host code requires a surprising amount of analysis on the IR
level. LLVM IR is a low-level program representation that lacks the higher semantics
of most programming languages, including CUDA kernel launches. In IR, CUDA
kernel launches are lowered into a sequence of function calls into the (stateful) CUDA
Runtime API. Figure 3.4 shows control flow graphs for the two possible structures of IR
generated for a kernel launch. The graph on the left illustrates the inlined version of a
kernel launch and the right graph illustrates the extracted version using a wrapper. The
process is best illustrated using the version with inlined code. The first IR generated
for the kernel launch (in the entry basic block) is a call to the cudaConfigureCall API
function, configuring the execution grid and shared memory of the next kernel launch.
If this call has a non-zero value, indicating an error, the code branches to a landing pad
basic block (called [Link]). Next, one basic block for each of the arguments of the
30
3.1 GPU Memory Trace Collection
Figure 3.4: Kernel calls after lowering into LLVM IR by Clang. The left shows the
inlined version, the right shows the wrapped version.
kernel is created. A call to cudaSetupArgument instructs the CUDA API to prepare the
corresponding argument with the given value, a non-zero return value again results in
aborting the kernel launch by branching to the landing pad. After the execution grid,
shared memory, and arguments are set up, the kernel is launched in a basic block with
the prefix [Link] using the (non-blocking) cudaLaunch API function. The kernel
launch finishes by branching to the landing pad, regardless of the call’s return value. If
the code is not inlined, the argument preparation and kernel launch are extracted into
a wrapper function, but the overall control flow is very similar. The pair of basic blocks
(entry, [Link]) describes a single-entry single-exit (SESE) region that encapsulates
the kernel launch. This fact is exploited for kernel launch location by first location
calls to cudaConfigureCall and then following the CFG to a basic block where the name
is prefixed with [Link]. The last call in this basic block must be either a call
to cudaLaunch or a call to the wrapper function.
Installing the setup and tear-down code then is simply a matter of inserting some
function calls into a kernel launch region that has been identified. Figure 3.5 shows a
simplified version of the transformation in CUDA source code. First, the CUDA stream
31
GPU Memory Access Patterns
1 1 __queue_prepare(stream, "K");
2 2 cudaStreamAddCallback(stream,
3 3 __queue_start, "K");
4 K <<<... , stream>>> (...); 4 K <<<... , stream>>> (...);
5 5 cudaStreamAddCallback(stream,
6 6 __queue_stop, NULL);
Figure 3.5: Simplified illustration of the host code transformation on a simple kernel
launch.
Figure 3.6: Schematic overview of the interaction of the Runtime Support System with
the other components. Host Code indirectly manages the queues that the device code
writes into using CUDA stream IDs as handles.
ID and the name of the kernel are extracted. The stream ID can be extracted from the
call to cudaConfigureCall and the kernel name from either the argument cudaLaunch or
the kernel wrapper function directly. This information is then used to insert calls into
the support library (__queue_*) and register callbacks on the CUDA stream to start
and stop a host-side queue consumer. Registering these functions as callbacks into the
CUDA stream automatically handles the asynchronous behavior of CUDA streams
correctly.
Runtime Support Library
The Runtime Support Library is the last component and provides the link between
device code and host code instrumentation. Its primary purpose is the implementation
of the shared device-host queue and a multi-threaded consumer that reads data from
the queue and flushes it to disk. Figure 3.6 provides a high-level overview of the
interaction between host code, device code and the Runtime Support library. The host
code manages queues on a per-stream basis, using CUDA stream IDs as handles for
32
3.1 GPU Memory Trace Collection
0 16 32 48 60
Bit 16 16 16 12 4
0 SM ID Size Type
64 Address
128 CTA X CTA Y CTA Z
192 Count (disk only)
Figure 3.7: Record format for memory accesses. The record format in the queue does
not includes bits 192 .. 207, they are only used for run-length encoding on the disk.
2 alloc
commit
3
watermark
Queue clear (Host)
3
alloc 1
commit
2
Figure 3.8: Two-phase operation of the queue: first the GPU inserts until allocations
cross a watermark, then, once commit also crosses the watermark, the host clears and
resets the queue.
the runtime system. It initiates the queue set up, as well as starting and stopping the
consumer threads. On the device code, queue access is implemented using a convenience
function that implements the queue protocol described below.
The queue is a lock-free linear buffer that contains records with a size of 24 bytes
each. The binary encoding of a record is illustrated in figure 3.7 and contains the
following information:
5. the CTA ID (encoded in 32 bits for x and 16 bits each for y and z),
33
GPU Memory Access Patterns
6. and a 16 bits field used for run-length encoding for the disk-version of this format.
Instead of implementing the queue using a circular buffer, a simpler, linear imple-
mentation operating in two phases is used, as shown in figure 3.8. Access to the loop
is coordinated using a pair of pointers: an alloc pointer and a commit pointer. The
alloc pointer is updated atomically ( 1 ) to allocate slots in the queue for new records
and to communicate the number of records that have been inserted into the queue
(pending and finished insertions). Then the allocated number of records is written
into the corresponding slots ( 2 ) and finally, the commit pointer is incremented ( 3 ).
Therefore, the difference alloc − commit is exactly equal to the number of outstanding
insertions. Unfortunately, the order in which insert operations finish is not necessarily
the same order in which they are started. As a consequence, there is no guarantee that
data in slots before the commit pointer has been safely written. On the device, write
access is coordinated in warps to reduce synchronization by electing a leader thread
within the warp, which then performs the atomic operations on behalf of all active
threads. The maximum number of active threads in a warp is 32, which makes this
number the largest number of slots that can be atomically allocated. A watermark is
34
3.1 GPU Memory Trace Collection
1 while (true) {
2 // wait for queue to be ready
3 while(*commit < watermark) ((void)0);
4
5 uint32_t nrecords = *commit;
6 record_t record = records[0]; // we know there is at least 1 record
7 uint16_t counter = 1; // initialize counter
8 for (int i = 1; i < nrecords; ++i) { // iterate over all records
9 record_t ref = records[i];
10 // run-length encoding for similar accesses
11 if ([Link] == [Link] && [Link] == [Link] &&
12 [Link] == [Link] && [Link] == [Link] &&
13 [Link] == [Link] + counter * [Link]) {
14 counter += 1;
15 } else {
16 // if this is a new record, flush record + counter to disk and
reinitialize
17 write_record(record, counter);
18 record = ref;
19 counter = 1;
20 }
21 }
22 write_record(record, counter); // flush last record
23 *commit = 0; // reset commit first
24 *alloc = 0; // then alloc, reopening queue for device.
25 }
Figure 3.10: Consumer thread code running on the host including run-length encod-
ing(simplified).
declared at the position N − 32, with N being the total number of slots, and write
access by the device is prohibited once the alloc pointer passes the watermark. This
secures the queue against overflows and guarantees that all outstanding operations
have finished once the commit pointer has passed the watermark. Figure 3.9 shows a
simplified version of the device helper function that orchestrates access to the queue,
acting as the producer of this producer-consumer relationship.
On the host side, the consumer thread starts by waiting for the commit pointer to
cross the watermark, since the queue is linear instead of circular. Flushing the queue
earlier may yield incorrect results, since increments to the commit pointer do not
necessarily follow the order of increments to the alloc pointer, resulting in potentially
unfinished insertions into the queue in slots before the commit pointer. Once the
queue is full, a running record and counter are initialized using the first record found
in the queue. Then each record in the queue is compared to the running record: if it
is an access of the same size and type on a contiguous memory address, a counter is
35
GPU Memory Access Patterns
increased, otherwise, the running record and counter are flushed to disk and reset to
the record under inspection. After all records have been read from the queue ( 1 ),
the remaining running record is flushed to disk and the alloc and commit pointers are
reset. Resetting the alloc pointer immediately switches control back to the GPU so
the commit pointer needs to be reset first to remain a consistent state ( 2 and 3 ).
This process is then repeated until the consumer thread is killed. Run-length encoding
for the applications analyzed in this work provides compression ratios of up to 25 : 1
depending on the regularity of the application, with the worst case being an increase in
file size of about 8 %. Due to its simplicity, virtually non-existent run-time overhead,
and small potential disk space overhead it is applied to all traces regardless of the
regularity of the application.
Definition 1. Read/Write Set. The read set R(B) of a thread block B is the set of
all addresses in global memory read at by B at least once. Analogously, the write set
W (B) is the set of all addresses in global memory written to by B at least once.
36
3.2 Analysis Model
This transformation is performed by keeping a list of the regions each CTA has
read, written or accessed atomically, iterating through the trace, and incrementally
updating the byte intervals. Atomic accesses are a combination of a read and a write
access with special semantics. Since these semantics do not matter for the analysis, no
list of atomic accesses is built but they are simply treated as a read-write access. The
list of intervals for read and write accesses describe sets of bytes accessed by a given
CTA and are respectively called that CTAs read set or write set (see definition 1. A
memory access from the trace is then inserted into the sorted list at the corresponding
position, or merged with either one or two existing intervals if the access overlaps or
adjoins them. For example, consider some CTA with a read set [0, 8) ∪ [16, 24). A read
access to the interval [8, 8) would result in a new read set [0, 24), while a read access
to [4, 8) would instead result in the new read set [0, 12) ∪ [16, 24).
The output of the post-processing step is a map
that provides the read set R and the write set W for a given CTA with the 3-dimensional
index (x, y, z), part of the thread grid ⃗n ∈ N3 . Such a map is created for all kernels in
the application trace in the order they were launched. This summary is in-line with
the CUDA consistency model, which for this analysis allows ignoring the order of loads
and stores during a single kernel execution.
The map is stored in a relational database of about 5.3 GB for the summaries of
the benchmarks presented in 3.3, compared to the about 29 GB of the compressed
traces. A relational database is used due to its proven high maturity compared to a
custom solution as well as the possibility to issue flexible and sophisticated queries.
Having the data collected, post-processed, and stored, enables extensive analysis
on this data to gather insights on the implicit communication of CUDA applications.
37
GPU Memory Access Patterns
CUDA
Kernel 1 Kernel 2
Stream In Out In Out
(a) Buffer usage across multiple kernels in a single-GPU setting. Buffer B is used to communi-
cate results from kernel 1 to kernel 2, it therefore acts as a communication channel.
“communication” and is used to detail the transformation steps that are required to
reveal internal communication in GPU applications.
Then, the partitioning model that is used to virtually split the kernels of GPU
applications is explained. The model contains a placeholder that allows limited
customization of how thread blocks are divided into partitions. Several functions to
fill in the placeholder are presented and their properties regarding the distribution of
thread blocks are discussed.
38
3.2 Analysis Model
any actual work on a GPU, they have limited autonomy and operate in lock-step with
other threads in their thread block. For this reason, GPU code is typically in a way
that minimizes control-flow divergence within thread blocks, making thread blocks the
base unit that executes a given task. Therefore, the end points of communication, as
understood by this analysis, are thread-blocks as a whole.
Next is the mechanism of communication used by the thread blocks. Similar
to regular programs that are written to run on Operating Systems with a memory
management system, memory on GPUs is organized in units of buffers, which are
allocated before being used. In contrast to regular programs, however, these buffers
are typically not allocated by the program running on the GPU, but by the supporting
host program. These buffers are then used as communication channels to provide the
results of a kernel launch to either subsequent kernel launches or to the host. Figure
3.11a depicts the communication of two kernel launches. Buffer A is a read-only buffer
(regarding kernels) and buffer C is a write-only buffer, only buffer B is both written
to and then read from. In this case, all of buffer B acts as a single communication
channel between kernel 1 and 2.
If the thread grid of such a kernel were to be split and executed in the same shared
memory system, the resulting communication would have a finer granularity, as shown
in figure 3.11b. All kernels are split into two partitions across the Y-axis and in this
hypothetical case, the memory access patterns of each kernel’s partitions match this
split (i.e. a partition only accesses half of each buffer, also split across the Y-axis).
This splits the buffer into two communication channels, one between the kernel 1.1
and 2.1 and another channel between kernel 1.2 and 2.2. Kernels 1.1 and 2.2 do not
communicate, likewise for kernels 1.2 and 2.1. While this type of memory access was
chosen for the example, there is no guarantee that splitting a kernel results in such a
clear split of the communication channels (what happens in real world applications is
precisely the topic to be investigated by the analysis).
Definition 2. A kernel launch is largest scheduling unit in CUDA and corresponds
to a super step in the BSP programming model [1]. Kernel launches are scheduled as
part of a CUDA stream and exhibit a total ordered within their respective stream. The
statement K1 < K2 holds if K1 and K2 are part of the same stream and K1 is scheduled
before K2 . There is no defined order between kernel launches of different streams.
Definition 3. A thread block is a user-defined group of CUDA threads, executing
concurrently on a GPU, with limited means of communication among themselves. It
is CUDA’s atomic scheduling unit and part of a specific kernel launch’s thread grid.
A thread block’s index refers to its position within the 3-dimensional thread grid of
39
GPU Memory Access Patterns
a particular kernel launch. Thread blocks with identical index but executed as part
of different kernel launches are considered different from one another. Thread blocks
within the same kernel are ordered lexicographically by their indices. There is no order
between thread blocks from different kernel launches.
Using these two concepts, a formal model for the internal communication of GPU
applications can be designed that is in-line with the major design choices in GPU
programming models. The model defines communication on the atomic unit of execution
in CUDA, thread blocks, and interprets the term as described in definition 3. This
definition differs from the one used by CUDA in that it answers the question of the
identity of thread blocks over multiple kernels: thread blocks from different kernel
launches are always considered different from one another, even if the executed kernel
code and all kernel parameters are identical [14]. While for these cases it seems easily
possible to assign an identity to a thread block that is persistent over multiple kernel
launches, it can not be generalized to kernels executing different code or with different
thread grid configurations. Furthermore, communication between thread blocks can
only if they are part of different kernel launches and is directed from launches and
is directed from earlier launches to later ones, using kernel launches from definition
2. This is in-line with the CUDA consistency model lacking guarantees about the
visibility of write accesses to main memory.
Given
the thread block bs is sending a message to bd using the memory locations in M if all
of the following statements hold:
K1 < K2 (3.5)
M ̸= ∅ (3.6)
∀b ∈ Ki (M ∩ W (b) = ∅), s < i < d (3.7)
∀b ∈ Ks (b ≤ bs ∨ M ∩ W (b) = ∅) (3.8)
In other words, the kernel of bs must be different from and precede that of bd (3.5),
they need at least one shared memory location between the write set of bs and the read
40
3.2 Analysis Model
Mem. host
READ
K1 1,0 2,0 3,0
WRITE
READ
K2 1,0 2,0 3,0
WRITE
Figure 3.12: Example of the evolution of the tracker state (denoted as “Mem.”) over
an application trace containing two kernel launches with three thread blocks each.
set of bd , and bs is the only writer to these locations in all kernel launches between Ks
and Kd (3.7). Within kernel launch Ks , other thread blocks either do not write to the
shared memory location or have a higher index than bs (3.8). Another interpretation
of the statement 3.8 is that the highest thread block index wins if multiple thread
blocks of the same kernel launch perform competing writes to the same location. As
a result of the lack of coupling between CUDA streams, each stream is regarded as
independent from different streams and no communication is possible between them.
This formal definition is used as the basis for an efficient algorithm that computes
all communication within a GPU kernel in a single pass over all kernels. This is done
by using a map that keeps track of all write accesses throughout the kernels of an
application and can be used to lookup the last writer of each address. Because of the
size of the GPU address space and the fact that memory accesses very rarely interleave
on individual bytes, an interval tree can be used to compress the data [78]. The interval
tree used for this algorithm has two extensions modified: a) intervals are tagged with
a value, and b) intervals can not overlap. If an interval is inserted that overlaps with
an existing interval, two cases are considered: if the existing interval has a different
tag, it is split, shrunk, or removed to create room for the new interval and if it has the
same tag as the new interval, they are merged into a single interval. In this work, such
a segment is called a “memory tracker” when used for this kind of analysis.
The algorithm 1 illustrates how communication is efficiently extracted from an
application trace using a memory tracker. Note that this algorithm is designed for the
41
GPU Memory Access Patterns
read and write sets produced by the post-processing step of the trace collection. The
first step is the initialization of a new interval tree (called a “tracker”) such that all
memory locations are tagged with the special value (HOST, ∅) that precedes all kernel
launches. What follows is an iterative process that is repeated for each kernel in the
order they are launched: All read sets from the kernel trace are tested for intersections
with existing intervals in the tracker. Each intersection corresponds to communication,
either from a previous kernel launch or from the host and is emitted as such. After
all read sets have been processed, the write sets are used to update the tracker. Each
interval in the tracker is tagged with a pair containing the thread block index of the
access and the thread blocks kernel launch index. Since the tracker does not permit
overlapping intervals, inserting an interval that would overlap an existing one results
in one of the following scenarios:
1. The insertion is ignored if: the new kernel launch index is lower than the old one,
or the kernel launch indices are equal and the new thread block id is lower than
the old one.
2. Both intervals are merged if: both the kernel launch indices and the thread block
indices are equal.
42
3.2 Analysis Model
Kernel 1 0 1 2 3 Kernel 1 0 1 2 3
Mem. Mem.
Kernel 2 0 1 Kernel 2 0 1
(a) Kernel 1 and 2 have identical working sets (b) Kernel 1 and 2 have identical working sets
sizes per thread block. sizes per kernel.
Figure 3.13: Two common combinations of kernel types of arbitrarily many ones. No
general rule exists that reliably matches thread blocks that communicate with each
other using only their thread indices.
3. The old interval is shrunk, split or removed in all other cases to make room for
the new interval.
Figure 3.12 illustrate the evolution of the tracker state throughout an application trace.
Memory is initially tagged and then updated by write accesses from kernel launches.
Note the conflict resolution after K2, where the two thread blocks (1, 0) and (2, 0) both
write to the same address and the higher thread block index (2, 0) wins.
43
GPU Memory Access Patterns
However, this knowledge about the algorithm is unavailable for the analysis performed
with this model. The only input to the partitioning is the thread grid configuration of
each kernel launch.
Providing a good partition assignment for thread blocks using only this information
provides challenges. Figure 3.13 illustrates the primary issue using two common cases
of memory access patterns in CUDA kernels. The first case, shown on the left, has
two kernel launches of different sizes but with memory accesses of the same size for
each thread block. A explanation is that both launches execute the same kernel code,
just on different grid sizes. In this case, a partition assignment that results in a high
spatial locality between kernels should have a fixed mapping of indices to partitions
that does not depend on the overall kernel size, for example, a round-robin approach.
In the second case, shown on the right, the amount of data accessed per thread block
varies between kernels, but that of the full thread grid is the same size. This can be
the result of kernel launches executing different code, or executing the same code, but
with different thread block sizes. Here, a partition assignment with high spatial locality
should be based on the thread blocks index relative to the total thread grid size. Both
these cases are very common and there is no obvious solution.
The problem is side-stepped by only defining an interface for the partition assignment
and then performing the analysis with a range of different partitioning schemes. This
interface is defined as a mapping
that maps a three-dimensional thread ID to the half-open real interval from (inclusive)
zero to (exclusive) one. The 3-dimensional vector ⃗n describes the total thread grid size
in x, y, and z. If this function is applied to all thread blocks in a particular kernel
launch, the resulting set of thread blocks within the [0, 1) range is called the called its
thread block range.
This approach allows splitting assigning a thread block to a partition based on its
position in the 1-dimensional thread block range using the function
This function distributes thread blocks into one of p partitions by cutting the range
into equally sized segments and assigning all thread blocks in one segment to the same
partition. Although the two functions could be combined into one, splitting them up
44
1/3 1/3 1/3
0.0 1.0
1/4 1/4 3.2 Analysis
1/4 1/4 Model
Figure 3.14: Two examples of thread block ranges. The left example has perfect
uniformity
1/3 while the
1/3 example
1/3on the right is heavily clustered, resulting in unevenly
sized partitions.
0.0 1.0
1/4 1/4 1/4 1/4
has benefits. First, function 3.10 does not change for any mapping, so it can be kept
separate. Second, the distribution of thread blocks on the range [0, 1) can be used to
judge the quality of a mapping.
Cutting the range into equally sized segments for the partition assignment is
susceptible can result in unequally sized partitions of the partition mapping produces
an uneven distribution in the [0, 1) range. Two examples are the existence of multiple
groups of points in proximity to each other within a group (clustering) and not utilizing
the full [0, 1) range (bounding). Both of these irregularities in the result of the mapping
function can result in unequally sized partitions and lead to inaccurate results for the
communication analysis. Figure 3.14 illustrates the importance of this property. Both
examples contain a total of 12 thread blocks mapped to a thread block range. The left
example with perfect uniformity has partitions of sizes (4, 4, 4) for p = 3 and partitions
of sizes (3, 3, 3, 3) for p = 4. For numbers of partitions that are factors of the number
of thread blocks, this distribution always produces perfectly evenly sized partitions
and only produces errors that are off by one for other values of p. The right example,
on the other hand, is highly clustered with two large clusters the 1/4 and 3/4. For
p = 4 the partitions are again evenly sized with (3, 3, 3, 3) but for p = 3, the clustering
produces heavily imbalanced partitions with the sizes (6, 0, 6).
A reliable way to identify both irregularities in a mapping function is by comparing
it to an ideal linear mapping that produces perfectly equally sized partitions. Given
a kernel Kn containing n thread blocks, this approach starts the sequence r of the
outputs of MK for all thread block indices in Kn . Then, a permutation k is required
where the elements are in their natural order, possibly destroying the order created by
MK :
45
GPU Memory Access Patterns
As an example, let n = 3, Kn = ((2, 0, 0), (1, 0, 0), (0, 0, 0)) and MK (id) = id0 /3. This
results in k = (2/3, 1/3, 0) and its permutation in natural order is ks = (0, 1/3, 2/3).
This approach explicitly allows MK to map multiple thread blocks to the same real
number. The ideal linear mapping is represented by a sequence computed as
(︃ )︃
0 1 n−1
ln = , , ..., . (3.13)
n n n
Finally, the sorted sequence ks is compared to ln by computing the average (of the
magnitudes) of their differences in each element:
n
1 ∑︂
εM = |ksi − li |.
n i=1
Taking up the example above, ln = (0, 1/3, 2/3) and is identical to ks , resulting in
an average difference of εM = 0, indicating a perfectly uniform matching. Since this
metric represents the error between an ideal distribution and a real one, it should be
interpreted as lower is better. Using an ideal linear mapping as reference exposes
both clustering as well as bounding.
This can be illustrated with examples for each of the extremes. The first example
is a mapping with extreme clustering, that maps all thread blocks to a single real
number:
The average difference is quite high at εM ≈ 0.28, correctly indicating a very bad
thread block mapping. The second example is one of very strong bounding, where all
46
3.2 Analysis Model
This example results in a fairly bad average error of εM ≈ 1.7. While this is still
indicating a fairly bad thread block mapping, it is better than the previous one,
matching the intuitive result when comparing both. The highest possible average error
would be achieved by mapping all thread blocks to one end of the scale and tends
towards εM = 1/2 for large values of n.
Notice that this metrics treats the mapping as a black box and judges the uniformity
for a single combination of a mapping and a thread grid. As a result, a single result
typically cannot accurately describe the overall uniformity of a mapping, especially
in the presence of potentially skewed thread grids with vastly different dimension
sizes. In order to provide a fair comparison, all selected mappings are judged using
thread blocks with 2048 thread blocks in total and the following ratios of X and Y :
1
, 1 , 1 , 1 , 1 , 1 , 1 , 1 , 2 , 4 , 8 , 16 , 32 , 64 , 128
256 128 64 32 8 4 2 1 1 1 1 1 1 1 1
, 256
1
. The Z-dimension is always of size 1,
since all relevant effects are already visible in 2 dimensions. The different results for
the uniformity of a mapping are then summarily presented by their quintiles.
The other important metric is the preservation of locality. The locality is preserved
when thread blocks that are close to each other in the thread grid are mapped to real
numbers that are also close to each other. Intuitively, a mapping that scores high on
this metric should lead to more intra-partition communication and less inter-partition
communication, lessening the demands on the interconnect between devices. Whether
that is the case is one of the research questions of this analysis.
Two approaches are taken to judge the preservation of the locality of a given
mapping. The first one is a qualitative analysis of the general behavior of the mapping
algorithm. Although this approach does not provide a quantitative measure, it provides
a valuable intuition about the expected communication.
The second approach is a stencil that assumes communication between adjacent
thread blocks and computes the mean value of the worst-case distances for all thread
blocks. As such, the way to interpret this metric is that lower is better. Let ⃗n ∈ Z3
47
GPU Memory Access Patterns
d̂ : (Z3 , Z3 ) → R
⎧
⎨|M (x ⃗ + ⃗s)|, if ⃗0 ≤ x +
⃗ ) − M (x ⃗ s < ⃗n
d̂(x
⃗ , ⃗s) = (3.14)
⎩0, otherwise
d̂max : Z3 → R
d̂max (x
⃗ ) =max(d̂(x
⃗ , (−1, 0, 0)), d̂(x
⃗ , (1, 0, 0)), (3.15)
⃗ , (0, −1, 0)), d̂(x
d̂(x ⃗ , (0, 1, 0)),
⃗ , (0, 0, −1)), d̂(x
d̂(x ⃗ , (0, 0, 1)))
dM : Z3 → R
n1 ∑︂
n2 ∑︂
n3
1 ∑︂
dM (n
⃗) = d̂max (i, k, l) (3.16)
n1 · n2 · n3 i=0 k=0 l=0
The function d̂ returns the distance between the mapped values of ⃗x and ⃗x shifted by
⃗s, provided that the shifted vector is within the bounds of the thread grid, otherwise
it returns the distance 0. d̂max is the stencil operator, determining the maximum
distance between the vector ⃗x and its six adjacent neighbors. The max operator
assigns high weight to communication over longer distances in the resulting mean value.
This produces a pessimistic estimate of the possible communication, which intuitively
mirrors real-world scenarios where the total runtime of a bulk synchronous operation
is the maximum runtime of each of its threads. Finally, the average communication
distance dM is defined as the mean value of the stencil-operator being applied to the
full thread grid. Similar to the linear error metric for uniformity, this metric depends
on a specific thread grid and might produce inconclusive results for a single evaluation.
To combat this effect, it is evaluated with the same number of thread blocks and ratios
as the linear error metric.
Intuitively, preserving locality should lead to less communication between partitions
and is, therefore, assumed to be a positive trait of a mapping. Instead of an equation
that defines locality preservation, this property is discussed qualitatively for each of
the selected mappings.
48
3.2 Analysis Model
0 … n 0 … n
0 …
0 …
n n
Figure 3.15: Visualization of the lexico- Figure 3.16: The colexicographic mapping,
graphic mapping on an 8x8 grid. Thread the counterpart to the lexicographic one.
blocks are lexicographically ordered by All characteristics are the reverse of its
comparing their position in in dimension sister mapping.
individually, starting in X, then Y and
lastly Z.
Lexicographic
The first mapping is based on the lexicographic order of the thread blocks. Here,
thread blocks are ordered by comparing each dimension individually, starting with
the X-, then Y-, and lastly Z-dimensions. This order is identical to the row-major
order used in most C-programs to store multi-dimensional arrays in linear memory
and shares a virtually identical definition. Given a thread block index veci ∈ N03 in a
thread grid of size ⃗n ∈ N 3 , the lexicographic mapping is defined as
i1 + i2 · n1 + i3 · n1 · n2
M⃗n (i⃗) = . (3.17)
n1 · n2 · n3
This mapping produces perfectly uniform thread block ranges for thread grids of
arbitrary sizes. The average difference to the ideal linear mapping on the thread grid
49
GPU Memory Access Patterns
sizes specified in 3.2.2 is zero in all cases. The linear error for this mapping is εM = 0
and the number of thread blocks in a partition differs at most by one for arbitrary
numbers of partitions and thread grid shapes.
Locality in this mapping is not preserved uniformly for each dimension. The distance
between thread blocks and its influence on distances in the thread block range can
be considered individually and their ratios can be taken directly from the mapping’s
definition. These ratios are X : Y : Z = 1 : n1 : n1 · n2 , meaning that distances in
X have the smallest impact (locality is preserved best), distances in Y produce a
distance that is n1 times larger, and distances in Z produce distances that are larger
by a factor of n1 · n2 (locality is preserved worst). Calculating the communication
distance estimate dM for this mapping results in the following five-number summary of
distances: (0.0014, 0.0078, 0.031, 0.12, 0.5).
Colexicographic
The colexicographic mapping is closely related to the lexicographic one and can be
seen as its inverse mapping. It is defined as
i1 · n2 · n3 + i2 · n3 + i3
M⃗n (i⃗) =
n1 · n2 · n3
The general properties are identical to that of the lexicographic mapping, however,
locality is preserved in the opposite order of dimensions. It has perfect uniformity as well
dimension-dependent locality preservation, with the ratios X : Y : Z = n2 · n3 : n3 : 1.
Z-Order curve
While the first two mappings favor one dimension over the other regarding locality,
the Z-order curve is a mapping from N-dimensional points to a one-dimensional
range that roughly preserves locality across all dimensions. It is used in many areas
in computer science, examples are storing multi-dimensional GPU textures in Z-order
to improve cache hit rates in [79] and efficient indices for multi-dimensional data for
use in databases and similar [80]. It belongs to the class of space-filling curves and
is popular due to its simple calculation. Calculating the one-dimensional index of an
N-dimensional one in Z-order simply requires interleaving the bits of each dimension
in lexicographic order. For a three-dimensional point in Bb × Bb × Bb , B := {0, 1},
described by three binary numbers of length b, the Z-order index can be calculated by
50
3.2 Analysis Model
0 … n
n
Figure 3.17: A Z-order curve over a thread grid of size 8 × 8 × 1. Notice the recursive
partitioning.
Z : Bb × Bb × Bb → B3b
⃗ , ⃗y , ⃗z ) ↦→ zb , yb , xb . . . z2 , y2 , x2 , z1 , y1 , x1
(x
describes a walk through Bb × Bb × Bb in the shape of multiple Zs, giving the curve its
name. The Z-order curve is defined for an arbitrary number of dimensions and always
calculated by interleaving the bits of its components in lexicographic order. Figure 3.17
illustrates the Z-order curve in two dimensions, displaying the distinctive Z-pattern.
Dividing the Z-order index of a point by the maximum number of points in the cube
(2 ) results in a value in the [0, 1) range. Due to the recursive nature of this approach,
3b
it only works for thread grids that are cubes with a side length that is a power of two
(Z-cube). For any other thread grid shape, no perfectly fitting cuboid with points in
Z-order can be created directly from this definition. As an example, a thread grid of
the dimensions 8 × 4 × 4 can only be ordered assigned an order by mapping its points
into that of a 83 cube, for which a Z-order is defined. Using an identity function to
map the thread grid to the cube would result in significant bounding (and clustering
to some degree), as the whole last quadrant would not be populated by the thread
grid. The more skewed the dimensions of a thread grid are, the more pronounced this
effect. These issues are partially solved by scaling the thread grid up to the dimensions
of the cube, in the case of this example by multiplying every point with the factor
(1, 2, 2). Degenerate dimensions (dimensions of size 1) still produce bounding since all
points have the same value in this dimension. They are handled by falling back on
51
GPU Memory Access Patterns
Z(j⃗ )
M⃗n (i⃗) = ,
23b
b b b
⃗j = (⌊i1 2 ⌋, ⌊i2 2 ⌋, ⌊i3 2 ⌋)
n1 n2 n3
which maps thread block IDs of any given thread grid to the range [0, 1). A three-
dimensional ID of non-negative integers ⃗j is interpreted as a three-tuple of binary num-
bers when applied to Z and the resulting Z-order index is interpreted as a non-negative
integer for the division. Overall, uniformity is preserved fairly well, with calculated
average linear error εM having the five-number summary (0, 0.0024, 0.01, 0.034, 0.083).
Qualitatively speaking, this mapping preserves locality roughly equally well in all
dimensions. The compromise between an easily computable solution and the reasonable
locality between consecutive points in the ordering is the primary reason it is used in
computer science [80]. However, the recursive definition creates comparatively large
jumps at the higher levels, the largest ones being 12 in each dimension highest level
as illustrated in figure 3.17. The average communication distance dM has the same
maximum as that of the lexicographic and colexicographic values but worse results
overall, with a five-number summary of (0.036, 0.046, 0.1, 0.2, 0.5).
An important thing to note about all three of these mappings is that they are
identical for one-dimensional thread grids, where they collapse into the linear mapping:
Such a linear mapping preserves locality very well and provides a uniform distribution
of points in the image. Using these mapping functions, the resulting simulated
communication between these partitions can be analyzed.
Hashed
The fourth mapping is a deliberately chaotic one that does not optimize locality in
any or all dimensions. The hashed mapping acts as a deterministic, quasi-random
mapping. The use of a hash function instead of a true or pseudo-random assignment
has primarily two benefits:
1. the mapping produces repeatable results that eliminate transient effects on the
communication analysis, and
52
3.2 Analysis Model
0 … n
Figure 3.18: The thread block order of the hash-based mapping in a thread grid of size
8 × 8 × 1.
2. the hash function is, in contrast to random number generators, state-less and
therefore a true function of only its inputs.
This particular implementation is based on the SHA-256 hashing algorithm from the
SHA-2 family of cryptographic hashing functions [81]. Algorithm 2 illustrates the
approach. The assignment of a thread block index is computed by first packing the
X, Y, and Z coordinates of a thread block index into a 12 byte buffer in that order
as unsigned 4 byte integers each. The choice of 4 bytes per integer is based on the
maximum number of thread blocks in any dimension of the thread grid and can be
extended to 8 or more bytes should this value change in the future [14, section H.1.
Features and Technical Specifications]. Then the SHA-256 hash with a size of 32 bytes
is computed, the first 8 bytes are interpreted as an unsigned integer and divided as
by 264 to scale the value down into the range [0, 1). In contrast to the previous three
mappings, this one does not depend on the thread grid size.
Considering the quasi-random behavior of the hashed mapping, systematic clustering
or bounding effects are unlikely to occur. The likelihood of a thread index to be mapped
into a specific sub-range of [0, 1) is directly proportional to the size of that sub-range
53
GPU Memory Access Patterns
and independent of its position. The five-number summary of the linear error εM is
(0.0048, 0.0057, 0.0074, 0.0093, 0.012).
The quasi-random behavior also suggests a fairly high distance for the virtual
stencil. The average communication distance dM for the thread grid sizes specified
before results in the following five-number summary: (0.51, 0.55, 0.56, 0.56, 0.57). The
minimum value is already larger than the worst case for any of the other mappings,
suggesting that it would map thread blocks highly unfavorable in virtually all settings
and confirming the intuition that a random or quasi-random mapping should not
produce an efficient thread block distribution.
The list of mappings presented in this section is by no means exhaustive. There
is a theoretically unlimited number of different mappings and more research into this
area could provide helpful insights into automatic distribution schemes for multi-GPU
applications.
54
3.3 Workload Selection
such as memory bandwidth and peak FLOP/s. Level two measures the performance of
basic parallel algorithms and is the most direct counterpart to other benchmark suits.
Lastly, level three contains complex applications with different computation kernels
and complex system-accelerator-interactions designed as stress tests to identify flaws
in the hardware itself, the system setup, and cooling.
The third benchmark, Parboil, was released as another improvement on Rodinia in
2012 and aims to provide benchmarks that can easily be adapted to the quickly changing
landscape of accelerators [84]. The name stems from the process of pre-cooking rice
to create a product that is already partially cooked, but requires to be finished by
the consumer, known as parboiling. The name was chosen because each benchmark
in the suite is provided in multiple stages of “done-ness”. It generally provides three
levels of implementations: fully optimized CUDA and OpenCL implementations, less
hand-optimized versions using OpenMP, and lastly naive CPU implementations. When
new hardware is released, the benchmarks can be quickly adapting by choosing the most
appropriate starting point and then porting to and optimizing for the new hardware.
In the context of this analysis, only the CUDA implementations are relevant.
Finally, the custom benchmarks are included as a baseline. They only contain
the most basic optimizations and are written in a way that is friendly towards static
analysis and transformations. This means they are implemented in a single translation
unit with little program logic and no dynamic behavior changes (e.g. swapping kernels
based on program arguments). The custom benchmarks are comprised of the following
applications:
55
GPU Memory Access Patterns
5. An in-place vector sum using two phases in a polymorphic kernel. The first phase
reduces the vector size by a factor of two by computing partial sums until a
threshold for the vector size is reached. Each reduction step is implemented as a
kernel launch. The second phase then reduces the partial result into a final sum
in another kernel launch.
Unfortunately, not every benchmark from the three official benchmark suites could
be included in the analysis. There are several reasons for exclusion, ranging from
implementation details that prohibit trace generation to conceptual issues that render
any analysis of the traces useless. Each problem encountered as part of the analysis
was sorted into an error category based on the underlying root cause. The following
types of root causes have been identified in the analysis:
• Feature. The benchmark uses CUDA features that are not supported by gpucc.
This means primarily the use of texture memory, a common optimization for
read-only data.
• Kernels. The benchmark contains only a single CUDA kernel launch. For an
analysis of the communication between kernels, a benchmark must contain at
least two CUDA kernels. Communication through the GPU’s global memory is
unreliable within a single kernel and therefore illegal in the context of this work.
This category also catches applications where kernels do not read from global
memory.
• Bugs. LLVM IR is unaware of any CUDA concepts, requiring the tracing plugin
to reverse engineer gpucc to include all functionality. This category catches all
bugs that could not be fixed during the development of the instrumentation
framework and manifests primarily in crashes during compile time or run time.
The benchmarks that had to be excluded due to one of these reasons, as well as the
specific reason, are listed in figure 3.19. An interesting case is the md5hash benchmark
of the SHOC benchmark. The traces of this benchmark contain kernels that have
written to main memory, but not read anything from it. The kernel input in this
56
3.4 Trace Evaluation
57
GPU Memory Access Patterns
Figure 3.20: Benchmarks included in the analysis and three letter codes used in figures.
other. First, kernels are analyzed as a whole, without splitting them into partitions,
using the communication model from section 3.2.2. Then, the second group focuses on
the interactions between different partitions and how the communication is influenced
by the partitioning mapping used.
The analysis does not process raw traces due to their large size and corresponding
long processing time, but relies on the summaries that are computed as explained
section 3.1.3. To summarize, the summary of a kernel contains a list of all threads
blocks executed as part of the kernel. Each thread block, in turn, contains two big sets,
one with all addresses that have been read by it (called the read set) and one with all
addresses that have been written by it (called the write set). Although this way of
summarizing memory accesses loses some of the information available in the traces,
name in-warp order of memory accesses and the operand size of memory accesses, they
are sufficient for this analysis.
58
3.4 Trace Evaluation
Fraction of all addresses read
1.00
0.75
Dataset
0.50 rfgpu
rfhost
0.25
0.00
RDW
RNW
PMG
RMY
PMQ
RGA
C2M
CNB
RBA
CHS
CRE
RHS
PCC
RBT
RLU
RSR
RBF
SRE
SSO
RPF
CFF
PLB
SSC
PST
SBF
PHI
PSP
Application
Figure 3.21: Addresses read from the GPU (rfgpu ) and addresses read from the Host
(rfhost ), both normalized to the number of all addresses read by the application’s
kernels. Many addresses fall in both categories.
behavior of any individual kernel launch, which is reduced to just the contiguous
region of memory it accesses and their size. To extract useful information, multiple
kernel launches throughout the application are analyzed e.g. to identify the amount of
data that is communicated between different launches. The different metrics are then
normalized so that they can be fairly compared between different applications. This
generality of the results if the main benefit of analyzing kernels as one big partition.
i=n
⋃︂
Rapp := R(ki ) (3.18)
i=1
k=i−1
⋃︂
i
Rgpu := R(ki ) ∩ W (kk ) (3.19)
k=1
⃓ ⃓
⃓i=n
⋃︁ i ⃓
⃓
⃓
⃓ Rgpu ⃓
i=1
rf gpu := (3.20)
|Rapp |
59
GPU Memory Access Patterns
k=i−1
⋃︂
i
Rhost := R(ki ) \ W (kk ) (3.21)
k=1
⃓i=n ⃓
⃓ ⋃︁ i
⃓
⃓
⃓ Rhost ⃓
⃓
i=1
rf host := (3.22)
|Rapp |
The first metric computes the sources of all read accesses. In this context, the
“source” of a read access is the source of the last write access to the same address. It
can be either a kernel executed on the GPU or the host system and often changes
for any given address over multiple kernel launches. Formally, given a kernel Ki , the
source of an address in its read set a ∈ R(Ki ) is considered to be a kernel (counted
towards Rgpu from 3.19), if any of the previous kernels Kk , k ∈ {1, ..., i − 1} contains
the address a in its write set W (Kk ). If the address a is not in the write set of any
previous kernels (i.e. has not been written to by a kernel), it is considered to be read
from the host (counted towards Rhost from 3.21). As an example, the value at address
a can be read from the host in kernel K0 , followed by a kernel K1 that updates the
value, and finally read from GPU by a third kernel K2 . This analysis is performed for
the full read set of each kernel. These read sets of kernels differ wildly in their sizes
between different applications. Therefore, the final metrics rf gpu and rf host are the sizes
of these sets normalized to the size of all addresses read throughout the application.
Figure 3.21 shows the computed metrics for all applications, note that since an
address can belong to both rf host and rf gpu , the bars do not necessarily add up to one.
Three major observations are made on this illustration:
1. All but two applications read 50%+ of their data from the host at least once.
2. Of the 27 applications, 13 read 50%+ of their data from the GPU at least once.
3. Three applications (PCC, PSP, and SRE) do not read any data from the GPU
and two applications (RBT, SSC) only very small amounts.
This data emphasizes the importance of the host-device interconnection. The large
amount of data that is read from the host at least once implies large transfers from
host to device for virtually all applications, making these transfers a lucrative target
for optimization. One such optimization could come automatically when splitting the
application. If a partitioning in e.g. 2 partitions results in each partition only reading
half of the original data from the host, the larger aggregated bandwidth is expected to
speed up this transfer.
60
3.4 Trace Evaluation
1.00 ● ● ● ●
●
●
●
Critical Fraction
0.75
●
0.50
0.25
0.00 ●
RDW
RNW
PMG
RMY
PMQ
RGA
C2M
CNB
RBA
CHS
CRE
RHS
PCC
RBT
RLU
RSR
RBF
SRE
SSO
RPF
CFF
PLB
SSC
PST
SBF
PHI
PSP
Application
implies the majority of a kernels input data has been generated in the previous kernel
launch.
i
Rcrit := R(ki ) ∩ W (ki−1 ) (3.23)
|Ri |
rf icrit := ⃓⃓ crit
i ⃓
⃓ (3.24)
Rgpu
61
GPU Memory Access Patterns
applications with large critical fractions: 19 out of the 27 analyzed applications have a
critical fraction of over 75%. Although this high number suggests very large communi-
cation overhead for partitioned application, not all data on the critical path necessarily
requires communication. Considering the total amount of data of an application is
fixed, communication grows linearly with the number of partitions (GPUs) in the worst
case. This can likely be compensated by the aggregated bandwidth of multiple-GPUs.
Another important insight is the uniformity of kernels within each application: in more
than half (15 out of 27) virtually all kernels behave identically with at most 1 outlier.
62
3.4 Trace Evaluation
1.00
Remote Fraction
Mapping
0.75
Colex.
0.50 Hashed
Lexic.
0.25
Z−order
0.00
PMG
PMQ
C2M
CNB
RBA
CHS
CRE
PCC
RBF
CFF
PLB
PST
PHI
PSP
Application
1.00
Remote Fraction
Mapping
0.75
Colex.
0.50 Hashed
Lexic.
0.25
Z−order
0.00
RDW
RNW
RMY
RGA
RHS
RBT
RLU
RSR
SRE
SSO
RPF
SSC
SBF
Application
Figure 3.23: Sum of all data read from remote device of all partitions, normalized to
the sum of data read from any non-host device.
block of a kernel is assigned to one of the 16 partitions and all addresses in its read set
are assigned to one of the following categories:
Host. The data at this address has not been updated by any previous GPU kernel.
Local. The data at this address has been updated most recently by a thread block of
the same partition.
Remote. The data at this address has been updated most recently by a thread block
of a different partition. This data needs to be communicated between partitions
on different devices.
The number of addresses in each category is accumulated over all kernels and finally,
the sum of all remote data is normalized to the sum of local and data, host data is
ignored for this metric. This analysis complements the one about the source of data
presented in figure 3.21 as it provides more information about the fraction of data that
results in communication.
Figure 3.23 reports the results for this analysis for all combinations of applications
and mappings. The first observation is the variance between different applications:
63
GPU Memory Access Patterns
most applications read either almost all their data from a different partition or very
little, only four applications fall into the range of 25% - 75% for three or more mappings.
For the majority of mappings, 16 applications require less than 25% of their GPU-data
to be communicated between partitions and only 7 applications require 75% or more
to be communicated. This is an indicator of the high spatial locality of the algorithms
implemented in GPU applications.
Another insight is the irregular behavior of the hashed mapping. It was intended as
a random alternative that does not optimize locality in any dimension and, as intended,
produces chaotic results. It scores the worst, best, and average in roughly the same
number of applications. Interestingly, it seems to perform worst for applications with
low overall communication. This can be attributed to these applications exhibiting
high spatial locality, which is not exploited by the hashed mapping.
Many of the applications behave identical with the lexicographic, the colexicographic,
and the Z-order mapping. This is caused by those applications only containing kernels
with 1-dimensional thread-grids. In these cases, all three mappings fall back to a
linear mapping. For the other applications, the Z-order mapping generally performs
best, with three exceptions where either the lexicographic or colexicographic mapping
win. Why some applications have a clear preference for one of the mappings can be
illustrated using the chained matrix multiply (C2M). If a colexicographic mapping is
used, the first matrix multiply splits its result into horizontal chunks among the 16
partitions. The second matrix multiply then reads the result in rows to compute the
final matrix. The distribution of the read accesses matches that of the colexicographic
partitioning and no data needs to be transferred between partitions. If instead the
lexicographic mapping is used, the read pattern is directly opposed to the distribution
of the intermediate matrix (vertical chunks), leading to the worst performance. The
Z-order mapping and the hashed mapping, not distributing in horizontal or vertical
chunks, fall in-between as expected.
The second analysis removes the constraint of using a fixed number of partitions and
focuses instead on the behavior of the applications with varying numbers of partitions.
It only uses the Z-order mapping to partition thread blocks due it providing the overall
best behavior of all mappings. Some applications contain only kernels without any
communication and are excluded: PCC, PMQ, PSP, RBA, RGA, RMY, SBF, SRE.
The metric used to analyze the scaling behavior is a modification of that used in
the previous analysis. First, the total amount of data read from remote partitions is
computed normally, but it is not normalized to the total amount of data read from
any partition. Instead, it is normalized to the maximum amount of data read from
64
3.4 Trace Evaluation
1.00 1.00
Norm. Communication
Norm. Communication
0.75 Application 0.75
CFF Application
0.50 CRE 0.50 RNW
RDW RSR
0.25 RPF 0.25
0.00 0.00
2 4 6 8 10 12 14 16 2 4 6 8 10 12 14 16
Number of partitions Number of partitions
Norm. Communication
Application
CNB
0.75 0.75 C2M
PHI
CHS
PMG
0.50 0.50 PLB
RBF
PST
0.25 RBT 0.25 RHS
SSC
RLU
0.00 SSO 0.00
2 4 6 8 10 12 14 16 2 4 6 8 10 12 14 16
Number of partitions Number of partitions
remote partitions over different numbers of partitions. The result is the change in
the total amount of remote data read when varying the number of partitions. As an
example, consider a scaling analysis for some application that returns the total number
of remote bytes read for different numbers of partitions and produces the following
pairs of (npartition , nbytes ): (2, 4), (4, 8), (6, 12), (8, 16). In this sequence, 16 is the
maximum amount of remote data and the reference to normalize against, resulting in
the normalized sequence (2, 0.25), (4, 0.5), (6, 0.75), (8, 1.0).
The figures 3.24 present the result of this scaling analysis for all applications with
communication between their kernels. Four basic classes of scaling behavior can be
identified:
2. The second class contains applications where communication grows linearly with
the number of partitions used. The most likely reason for this behavior is a
constant amount of communication per partition, for example, if every thread
65
GPU Memory Access Patterns
3. The third class of scaling behavior is an offset asymptotic growth, that can
be approximated for example using some configuration of the logistic function
1
1+e−k(x−x0
, with k describing the growth rate and x0 the midpoint of the sigmoid
function (in these cases always on the negative side of the x-axis). Applications
falling into this category have more complex memory access patterns as the first
two, but are still highly regular, resulting in the predictable behavior.
4. The last class is a catch-all for applications that behave differently. They primarily
distinguish themselves from the linear and logistic growth classes due to their
irregular jumps between certain numbers of partitions. While the communication
volume scales irregularly, the memory access patterns might not necessarily also
be irregular. One cause for the irregularities might be thread grid sizes of the
kernels that are not amenable to the Z-order mapping and cause bounding or
clustering issues as described in 3.2.3.
The key observation from this scaling analysis is that the communication volume
grows at most linear with the number of partitions. For most applications, the
communication volume even grows less than linear. In contrast, the total aggregated
bandwidth in a multi-GPU system grows linear with the number of GPUs (assuming
identical GPUs and a non-blocking interconnection network). This, again, is an
encouraging result for automatic partitioning, as it suggests that the communication
requirements arising from the partitioning can be covered by multiple GPUs.
3.5 Summary
This chapter presented an instrumentation framework that allows extracting the
memory access patterns of GPGPU applications, a methodology for the analysis of
these patterns with regards to the NUMA behavior of a multi-device execution of the
applications, as well as a fully performed in-depth analysis of 27 applications of three
benchmark suites, using four partitioning strategies.
The extraction of memory accesses is performed using an instrumentation system
that is embedded into the application during its compilation and does not require
any modifications to its source code 4 . Application instrumentation is implemented
using a set of simple transformations on both the host and the device side. Memory
4
The plugin can be used completely independently of the rest of the trace analysis and is available
for download at [Link]
66
3.5 Summary
accesses are first written into a queue in host memory and then flushed to disk, where
they can be processed further. The transformation is packaged into a plugin for the
Clang/LLVM compilation framework and a static library. This allows a simple usage
model that only requires one additional flag each for the compilation step and the
linking step.
The analysis relies on summaries of the memory accesses performed by thread blocks.
The memory accesses of all threads in a thread block are combined to form one read
set and one write set. These sets are then used to identify NUMA effects in virtually
partitioned single-GPU applications, by splitting the thread grid into partitions using
a selection of four simple mappings. Each of the mappings has different properties
regarding the locality and distribution of the partitions, which are explained in-depth.
The memory accesses of each partition are then combined and interactions between
partitions interpreted as communication. Due to the relaxed consistency guarantees of
the GPU memory model, communication between partitions can only occur between
different kernel launches.
This analysis methodology is then used to compute different metrics, some of which
ignore partitions and focus on communication between kernels as a whole, while others
focus on inter-partition communication. A total of 27 applications have been analyzed
and classified with regards to their locality. The findings are then put into context by
discussing their implications for an automatic partitioning that is executed on real
hardware. The analysis results in three key findings:
1. GPGPU applications differ dramatically between each other with regards to the
amount of data read from the GPU but all read large amounts of data from the
host.
2. More than half of all benchmarks require less than 25% of their data to exchanged
between devices to synchronize between iterations. The exception is the semi-
random hash mapping, which performs significantly worse.
3. The Z-order mapping creates partitions in such a way that the total commu-
nication volume grows slower with the number of GPUs than the aggregated
bandwidth of these GPUs.
67
GPU Memory Access Patterns
68
Chapter
4
Automatically Partitioning CUDA
4.1 Concept
This section provides a high-level overview of the challenges that arise when automati-
cally partitioning GPU applications and their solution. It builds on the results and
insights of previous work of the author [90]. Potential implementation details are not
discussed at this point except where these details are critical for the feasibility of an
implementation.
The basic premise used to automatically partition CUDA applications is the fact that
thread blocks in a CUDA are independent of each other and cannot communicate with
each other [14]. Due to communication being possible in a reliable way between threads
of the same thread block, threads in a thread-block typically cooperate with each other
(also giving them the name CTA), making it impossible to split thread blocks while
guaranteeing correct results. In this regard, they share some similarities with tasks in
task-based programming models such as X10 or HPX, albeit far simplified [91, 92]. The
main similarity between the two is that tasks can be executed independently of each
69
Automatically Partitioning CUDA
Figure 4.1: Subdivision of a 2-dimensional thread grid using straight cuts across its
axes.
other as pure functions, taking some input and producing some output without side-
effects. There are significant differences, however, as task-based models often contain
sophisticated scheduling algorithms with complex dependency graphs and support
nesting, which is not the case for CUDA thread blocks. In the CUDA execution model,
all thread blocks of a kernel are considered ready for execution when the kernel is
scheduled.
Distributing the execution of a CUDA kernel across multiple devices requires three
steps:
2. identifying the data dependencies of all thread blocks in each partition, and
All illustrations in this section use a 2-dimensional stand-in for the thread grid for
clarity, without loss of generality. The illustrated concepts apply directly to the
3-dimensional thread grid of CUDA.
70
4.1 Concept
One way of limiting the thread blocks that are executed on a GPU is called an
“execution guard” and implemented by putting a conditional return in front of the
kernel code [93]. The execution guard tests whether the current thread index is part of
active partition and aborts if the test fails. This requires scheduling all thread blocks,
even if only a small portion of them pass the execution guard. These thread blocks
that do not pass the execution guard still consume resources on the GPU, preventing
new thread blocks from being scheduled. Alternatively, the execution guard can be
eliminated by instead shrinking the thread grid for each kernel launch down to the
size of its partition and adjusting the calculation of the thread block index to reflect
its position in the original thread grid. However, this way of splitting a thread grid
is only possible in this case because the thread grid is split by cutting along its axes,
resulting again in an axis-aligned cuboid. Sub-sections of a thread grid in any other
shape require an execution guard.
The best dimension to partition along depends on the optimization goal and the
application in question. Since thread blocks are assumed to be executed independently
of each other, the most interesting factor to optimize appears to be the volume of
communication. Extensive work has been done in this area to optimize the partitioning
as a function of both input and output memory access patterns [94]. This work uses a
significantly simpler approach that aims to reduce fragmentation in the input data by
cutting across the dimension that causes the largest stride in the access patterns of the
input data. As an example, consider a 2-dimensional kernel accessing a 2-dimensional
array stored in row-major order using the formula float value = array[y * n + x];,
with x and y being the current thread’s respective global X and Y index and n the
size of the array in the X-dimension. Here, consecutive elements in the Y-dimensions
exhibit a stride of n, while consecutive elements in the X-dimension only have a stride
of 1. The thread grid should be horizontally split across the Y-axis, resulting in the
accesses of each partition forming two large consecutive chunks in memory.
1. Fully replicate all arrays on all devices, automatically satisfying all data depen-
71
Automatically Partitioning CUDA
dencies at the cost of large upfront data transfers. Partial results need to be
merged, which can be successfully done at runtime.
2. Place all buffers in memory that is shared between the host and all GPUs.
Data dependencies are then solved by directly accessing (remote) memory. This
approach is available for devices that support CUDA 6 and above.
Although both solutions work and do not require any data flow analysis, they have
suboptimal performance. In the first case, always copying the full buffer although often
only a small portion is required wastes several Gigabytes of data transfers for larger
problems. In the second case, the high latencies between different PCIe devices (tens of
microseconds [95]) destroy any potential speedup from distributing the computational
work.
An approach that only uses minimal static analysis is an application of the “merge
kernels” of the Single Kernel Multiple Devices (SKMD) project [93]. In this approach,
a kernel is stripped from all non-index related computation and only writes a bitmap
of all read accesses performed by the kernel. This bitmap of the data dependencies
can then be used to copy the corresponding data to the GPU. The VAST project uses
a page-based version of this approach with a granularity of 4 KB [96]. However, the
required round trip to the GPU and before data distribution as well as the required
fine-tuning of the page size render the approach unsatisfactory for this work.
Many approaches for dependency analysis use static analysis to build a mathematical
model of the data dependencies. The details of such a model are designed to match
the given problem and represent a compromise between usefulness and simplification
on one hand and expressive power and complexity on the other hand.
The SKMD project uses a custom model that represents memory accesses as a
linear function of the global thread index in any of the three dimensions (represented as
a · (Wi · wi + li ), with a as a constant or loop induction variable, and Wi , wi , and li the
thread block size, thread block index, and thread index respectively) [93]. Although
simple and robust, this model is severely limited in the type of access patterns it
supports and even a constant offset can prohibit using this approach.
A more sophisticated approach is the polyhedral model, where loop iterations are
modeled as sets of multi-dimensional points and memory accesses as maps from between
such sets. This model can represent complex applications as long as loop boundaries
and indices memory of accesses can be described using presburger relations [97]. It’s An
in-depth description that contains all of its aspects required for this work can be found
in 2.4. Since extracting the maximum performance from GPUs requires very regular
72
4.1 Concept
(a) Memory access pattern of a stencil, map- (b) The polyhedral memory access pattern ap-
ping a single 2-dimensional point to 5 2- plied to a partition of a 2-dimensional thread
dimensional points. grid. Notice the correctly handled out-of-
boundary condition at the top of the grid.
Figure 4.2: Illustration of polyhedral memory access patterns as used in this work.
control-flow and memory accesses, this model is expected to be a good fit for GPU
applications. Although it also primarily relies on memory accesses being described
by linear functions, the model can be parametrized with arbitrarily many parameters
and supports arbitrarily many constraints on each memory access, allowing to model
complex memory accesses. Being based on a rigorous mathematical foundation makes
it easy to use and its generality should make it easy to adapt to GPU applications.
From the available options, polyhedral compilation seems to fit the requirements of
this project well and is chosen as its foundation. The model describing a CUDA kernel
relies primarily on two abstractions:
• The CUDA thread grid and the partitions are represented by 3-dimensional
polyhedral sets. Due to the constraint that the thread grid can only be partitioned
by straight cuts along the three axes, the resulting sets will always by axis-aligned
cuboids.
Figure 4.2 illustrates the use of polyhedral memory access patterns. Subfigure 4.2a
on the left illustrates the actual memory access pattern as a mapping from a single
2-dimensional point to a union of 5 different 2-dimensional points. The subfigure
4.2b on the right is the application of this memory access pattern to a partition of a
2-dimensional thread grid. The partition is described by the constraints x ≥ 1 ∧ x ≤
6 ∧ y ≤ 3 (starting to count from zero). The resulting access in a 2-dimensional array
of the same size is the union of the memory access pattern applied to each individual
73
Automatically Partitioning CUDA
of the partition and handles boundary conditions, such as array sizes, correctly. Such
a memory access pattern is created for the read and write accesses for each of the
arrays used in a CUDA kernel and allows the reliable identification of input data that
potentially requires communication between GPUs.
74
4.2 Toolchain Overview
GPU
GPU
GPU111
GPU
GPU
GPU111
HOST
HOST
HOST
GPU
GPU
GPU222 GPU
GPU
GPU222
(a) Buffer state before any (b) Buffer state after a ker- (c) Buffer state after another
kernel launches. All data was nel with two partitions was kernel with a different mem-
last updated (initialized) by executed. The top half was ory access pattern. Regions
the host. updated by GPU 1, the bot- in the buffer do not need to
tom by GPU 2. be polyhedra.
Figure 4.3: Possible evolution of buffer state over the runtime of an application with
two launches of different kernels.
the data or only the lower-left quarter cannot be statically determined, but is resolved
at runtime using the tracker.
The read pattern of a kernel on a particular buffer can then be used to query for
the location of all required data and issue the corresponding data transfers. Polyhedral
compilation provides facilities to generate very optimized code to query the shape
of polyhedra, which can be used to both update the tracker and query it, reducing
runtime overheads [71].
75
Automatically Partitioning CUDA
CUDA
gpucc
host device
Polyhedral
Analysis
Rewriter
gpucc
host device
Polyhedral Kernel
Codegen Partition
Runtime linking
Library
Binary
x86/ARM for the host and PTX for GPU related code. In gpucc, this is achieved
by implementing two separate pipelines. GPU code is compiled first, ignoring all
CPU code and resulting in an artifact that contains the generated code. Then, all
host-related code is compiled, GPU-related code is parsed to collect information about
the kernels that are present, and the artifact created from the first pipeline is embedded
into the resulting compiled object.
The automatically partitioning prototype compiler keeps this model mostly intact.
However, during GPU compilation, memory access patterns are extracted and the
kernels are modified to be relocatable within the thread grid. Information about
the memory access patterns or transformations can not be reliably passed from the
76
4.3 Kernel Transformations for Work Splitting
3. Code generation for efficient queries of memory access patterns, used to identify
data dependencies and redistribution of partial results (section 4.5.
The following sections explaining the individual components have been ordered in a
way that the author feels requires the least jumping between sections, which is not the
order in which they are called used in the compiler. For example, the transformation
of GPU kernels for work splitting is performed late in the pipeline (in the second gpucc
invocation) but does not depend on any of the other steps; therefore, it is explained first.
Host code transformations depend on essentially all previous steps and are therefore
explained last.
77
Automatically Partitioning CUDA
β
Figure 4.5: Illustration of the work splitting as implemented in this work. Partitions
are created by shrinking and shifting the thread grid to the size specified by α and β.
guards. The resulting partitions therefore are axis-aligned bounding boxes (AABBs)
and sufficiently described by a pair (α ⃗ , ⃗β ), with ⃗α ∈ Z3 describing the minimum point
and ⃗β ∈ Z3 the maximum point in X, Y, and Z respectively. The 3-dimensional interval
describe by these points is half-open (i.e. ⃗α is the first point outside of the interval), so
the size of the partition can be calculated with the formula βi − αi with i ∈ {x, y, z}
for any of the three dimensions.
Executing the thread blocks of such a partition requires two steps that are mostly
independent of each other. First, schedule the kernel with a thread grid of the same size
as the partition and then forward information about the partition to the kernel. Second,
manipulate all index and thread grid size computations in such a way that boundary
conditions and array index calculations are the same as if the kernel was executed with
its original thread grid. In other words, the thread grid needs to be shrunk to the size
of the partition and then shifted to its position, as illustrated in figure 4.5. Shrinking
the thread grid is the responsibility of the host code when launching the kernel, leaving
only the transformation of thread-grid-related computations inside the kernel.
First, a copy of the kernel code is made and modified to accept additional arguments
that describe the partition as the pair (a ⃗ , ⃗b). The kernel copy is named using a
convention that allows it to be found by other components of the compiler.
Then, calculations based on the thread grid can be updated according to the
following to substitution rules, which are applied for each of the three dimensions:
The first rule (equation 4.1) updates all occurrences of the thread block index to their
new position inside the partition by adding the minimum point of the partition. The
second rule (equation 4.2) updates computations using the thread grid size to instead
78
4.4 Polyhedral Analysis
use the maximum point of the partition. These substitutions only keep the semantics of
the original CUDA code when the thread grid size the kernel is launched with matches
the size of the partition.
Since work is always split along the boundaries of thread blocks, leaving them intact
as the atomic scheduling unit, the size of thread blocks remains unchanged. The same
is true for all calculations performed with a thread block’s size inside the kernel. This
is desirable because CUDA applications often rely on thread blocks having a specific
size for optimizations and changing this result can lead to incorrect results, an example
being tiling optimizations [98].
79
Automatically Partitioning CUDA
for the dimension i ∈ {x, y, z}. Both blockIdx and blockDim are unknown at compile-
time, resulting in the non-linear term that can not be represented in the polyhedral
model. However, since the block size is known at the launch of a kernel, the product can
be replaced by a virtual “block offset” that encapsulates the non-affine multiplication
[102]. The result of this substitution is the affine expression
80
4.4 Polyhedral Analysis
on , ..., o1 representing the array indices in each dimension. This approach is not specific
to the low-level polyhedral analysis and used in some form in most polyhedral projects
using a C-based language as input [105, 106, 107].
By extracting these maps with a CUDA-friendly domain and recovering multi-
dimensional arrays, arbitrary shapes of partitions can be supported. Given some
read access to an array represented by a polyhedral map and a thread grid partition
represented by a polyhedral set, the memory read by that particular partition can
be computed by intersecting the memory access’ domain with that of the partition.
81
Automatically Partitioning CUDA
Kernel
Kernel
1 1 Partition
Partition Input
Input
bufIn
bufIn
bufIn type
typetype
:: float
float
: float
[n][n]
[n][n]
[n][n] bufIn
bufIn
read
readread
:: : bufIn
bufIn
write
write :: : ∅
write ∅ ∅ bufOut
bufOut
bufOut ∅ ∅
∅
bufOut type
bufOut
bufOut typetype
:: float
float
: float
[n][n]
[n][n]
[n][n]
Output
Output
read
readread
:: : ∅
∅ ∅
bufIn
bufIn ∅
∅
write
write
write
:: : bufIn ∅
nn n type
typetype
:: int32
int32
: int32 bufOut
bufOut
bufOut
(a) The application model (b) The partition the model (c) The input and output
created for the stencil ker- is applied to. data sets for the involved
nel. The model contains data buffers for the required parti-
types, array sizes and scalar tion.
arguments of the kernel.
For axis-aligned cuboids as used in this work, the intersection can be performed by
constraining each dimension of the memory access’ domain with an upper and lower
limit. While less expressive than arbitrary polyhedral sets, partitions can now be
described using a fixed set of numbers. The corresponding parameters are introduced
as additional constraints, following the recipe
for each of the X, Y, and Z dimensions of the thread grid. The parameters boffmin i
and boffmax i are introduced to avoid non-affine expressions and must be calculated
using the provided formula.
As a final preparation before constructing the application model, the maps of write
accesses are tested for injectivity. In this context, an injective write map corresponds
82
4.5 Polyhedral Code Generation
to memory being accessed in a way so that no two thread blocks try to write to the
same memory location. This proves the absence of write-after-write (WAW) hazards
contained in the application, breaking the CUDA consistency model. While single-
CUDA applications might tolerate this behavior when executing on a single-GPU by
relying on eventual consistency, updates from different thread blocks will never be
visible to kernels executing on other GPUs before synchronization.
Figure 4.6a illustrates the model extracted a CUDA application containing a 2-
dimensional Jacobi stencil. The kernel has three arguments float *bufIn, float *bufOut,
and int n. For scalar arguments, only the data type is stored. For array arguments,
the data type, the number, and sizes of dimensions, as well as the read and write
maps are stored. In this case, both bufIn and bufOut contain a square grid of floats,
with one array acting as input and the other as output respectively. Arrays are not
required to be either pure input or pure output as shown in the illustration, they can
be both input and output at the same time. Figure 4.6b presents a partition with the
specification α = (1, 0, 0), β = (7, 4, 1), using the notation from 4.3 and starting to
count from zero. The input and output data sets of this partition, based on the Jacobi
kernel’s model, are illustrated in figure 4.6c. The input array bufIn is exclusively read
and contains a halo area. Out-of-bounds memory accesses are avoided by conditional
code in the kernel, which is preserved in the model that only adds the halo area where
appropriate. The output array bufOut has a 1 : 1 access map and does not create WAW
hazards; the resulting application model is therefore valid and can be used to partition
the application.
83
Automatically Partitioning CUDA
[n] → { 2n + 1 }
1 %1 = mul 2 %n
2 %result = add 1 %1
Figure 4.7: Code generation example for a polyhedral expression. Expressions always
produce a single scalar value and do not contain control flow.
and evaluated as is, providing a correct, albeit slow, result. Similarly, enumerating
the points of a set (e.g. resulting from the intersection of two other sets) can also be
performed by interpreting the set description, computing the polyhedrons corners, and
interpolating from there. However, a polyhedral compilation library provides a way to
create a representation of the set that, when evaluated, enumerates the points directly,
e.g. in the form of a 2-dimensional loop nest. This generation of highly optimized
parameterized solutions is, in fact, one of the core features of polyhedral libraries and
research to find further optimizations is ongoing [68, 71, 110, 69].
84
4.5 Polyhedral Code Generation
Figure 4.8: Code generation example for polyhedral set. The generated code iterates
over all elements in the set by creating loops.
operation. The operands of each operator are either leaves (constant values or variable
names) or nodes representing the result of preceding operations, which are already
emitted due to the post-order traversal. AST expressions always generate straight-line
code without any control flow and execute in constant time. Figure 4.7 illustrates this
approach using a simple example with two operations.
The second kind of object is an AST node, created from polyhedral sets. They
represent a loop nest that iterates over all elements in a set and call a user-defined
payload function for each element. Since the isl library does not assume an order
between dimensions and requires the user to provide a schedule that defines the order
of traversal over the set. Once a schedule has been used to create an AST node for
a polyhedral set, each of its nodes represents one of five constructs, implementing
different control flow: blocks, for-loops, if-else-branches, callbacks, and marks. Together
they again form a tree that can be traversed recursively to generate the corresponding
code. In order to set up place holders for the contents of each of the nodes, this
AST is traversed in pre-order, creating a basic block for each node. The following
segments describe each of the available types of control flow and how to generate the
corresponding code.
Mark nodes are used to attach arbitrary information to a schedule and retrieve it
85
Automatically Partitioning CUDA
during code generation, they are not required for this work and can be ignored.
User nodes represent the payload callback that is called for each element of the
set. The n-dimensional index of the specific element is provided by several polyhedral
expressions, which can be passed as arguments to the callback. Code generation for
user nodes requires first generating the index expressions and then calling a function.
Block nodes represent a sequence of other nodes. They directly correspond to a
basic block in LLVM IR.
If nodes correspond to an if-else-branch in structured programming languages.
An if-node contains three members: the condition and the bodies of the “then” and
“else” branches. It can be implemented by first generating the AST expression for
the condition, and creating three basic blocks, the “then” block, the “else” block, and
the continuation. The “then” and “else” blocks are used as insertion points for the
corresponding body AST node and both terminate by an unconditional branch to the
continuation. The continuation is used as the insertion point for AST nodes in the
same block as the if-node.
A For node is the most complex type of node and represents a for-loop. For-nodes
have five members: the name of the iterator, its starting value, a condition, the
increment, and the loop body. They can be generated by creating three empty basic
blocks, the header, body, and continuation, and an unconditional branch to the loop
header. Two structures are inserted into the loop header: a phi-node that selects
between either the initial value of the following values of the iterator, and code for the
expression of the loop condition. The loop terminates by evaluating the loop condition
and branching either to the loop body or the continuation. The start of the loop body
is used as the insertion point for the body AST node and terminates by adding the
increment to the iterator and unconditionally branching back to the loop header. The
continuation is used as the insertion point for AST nodes following the for-node.
The code generation process for AST nodes is illustrated in figure 4.8 using a simple
for loop. Instead of producing a scalar value, the result of the code generated for a
polyhedral set is the side effect produced by calling the function “S” with the index of
the set element passed as the argument.
86
4.5 Polyhedral Code Generation
set of multi-dimensional array indices. Interpreted literally, the generated code for a
memory access map should implement a function
with d being number of dimensions of the array. However, the thread block offset was
only introduced for compatibility with the polyhedral model and contains redundant
information (it is the thread block index multiplied by the thread block index). It is
more convenient to specify the partition as its upper and lower bounds for the thread
block index and the corresponding thread block size. Enumerating each buffer element
of a read or write set individually results in very inefficient code, so the generated code
is optimized to avoid this:
1. GPU applications often contain very regular memory access patterns and it is
unlikely that a partition of the thread grid produces access maps that contain
holes. Therefore, memory access maps are simplified by replacing them with
their convex hull, resulting in simpler shapes and simpler loop nests.
2. In the memory access maps produced by the low-level polyhedral analysis, the
inner-most dimension of multi-dimensional arrays is stored in memory consec-
utively. This can be exploited by not enumerating individual elements of the
inner-most dimension but only computing the lower and upper bound of these
ranges.
expecting a partition’s lower and upper bound as thread block indices, the thread
block size, and returning a set of pairs with each pair describing the start and end of
an interval of array elements. Figure 4.9 illustrates the different levels of optimizations
of the memory access map, from its original state in the application model on the left
to the simplified intervals on the right.
87
Automatically Partitioning CUDA
(a) Unoptimized access map. (b) Simplified as convex hull. (c) Rows replaced with inter-
vals.
Figure 4.9: The different optimization levels (excluding linearization of accesses) for
the memory access map before code generation.
Since only polyhedral sets and expressions can be used to generate ASTs with
isl, the memory access maps have to be broken down into sets before generation.
Fortunately, the information that is required for data dependency analysis can be
extracted from the memory access map’s image. When isolating the image of the
map, it represents the memory access of all elements (thread blocks) in its domain.
Therefore, the domain of the map should be constrained in such a way that it contains
exactly a (parameterized) partition. The domain can then be dropped, translating all
its constraints into equivalent constraints on the image, resulting in a set representing
only the map’s image.
Taking the optimized approach described above, memory access maps should be
translated into code that performs the following steps:
1. Iterate through all but the inner-most dimension of the image of the access map,
effectively enumerating all rows of the memory access.
(a) compute the minimum value min and maximum value max at position
(xn , ..., x2 ) as closed-form expressions.
(b) Linearize the two indices min lin = (xn , ..., x2 , min) and max lin =
(xn , ..., x2 , max ) as closed-form expressions.
(c) Return the pair (min lin , max lin ).
Except for the array index linearization, the isl library provides the tools to create
polyhedral expressions and sets required to implement the algorithm. Overall, the
88
4.5 Polyhedral Code Generation
algorithm suggests the following structure for the generated code: A loop nest with
n − 1 loops for the outer dimensions of the memory access, the innermost of which
calls another function that computes the bounds of the memory access and, in turn,
calls a user-defined callback.
Step 1 mainly involves setting up a loop nest that embeds the payload of step 2.
The order in which the image of the memory access map is traversed is specified by the
schedule that is passed to isl at code generation. Although selecting the best schedule
for a particular set is generally non-trivial when optimizing loop nests, the choice
easily falls on the lexicographic order for the identification of data dependency. This
way, outer loops represent outer dimensions of the array (larger distances in memory)
and inner loops represent the inner dimensions (smaller distances in memory). The
isl -version of this lexicographic schedule is called an identity schedule. The loop nest
can then be created using the following steps:
4. Generate code for this schedule, invoking the callback for each element (i.e. each
row).
The callback function implementing step 2 contains a bit more logic. Its input
are the indices of the n − 1 out dimensions and as output it produces the (linearized)
upper and lower bounds of the memory access. It is implemented using the following
approach:
4. Generate code for the closed-form expressions of the arrays dimension sizes
sn , ..., s1 .
89
Automatically Partitioning CUDA
5. From the multi-dimensional index (in , ..., i2 , min) compute the linearized mini-
mum index min lin by generating code for the recursive formula:
o1 = min
ok = (ik + ok−1 ) ∗ sk+1
on = ik + ok−1 = min lin
6. Repeat the approach to compute the linearized maximum value max lin .
7. Emit the result by invoking the function of form (i64, i64) with the values
(min lin , max lin ).
The function containing this code is automatically inserted as the call-target of the
surrounding loops. It is factored out as a separate function to avoid accidental
interference between the two code segments and to avoid code duplication. The code
generation relies on the LLVM infrastructure to inline this code if its heuristics indicate
it as the more efficient option.
Since this code will be used as the interface to the rest of the system, it needs a
flexible way to receive the data it needs and pass on its result. The resulting function
that enumerates a memory access’ elements is called an iterator and has the following
prototype:
1 typedef void(*__me_itfn_t)(int64_t grid[], int64_t param[], __me_cbfn_t
callback, void* user);
The partition information is passed using the grid argument as a fixed sized array
containing lower and upper bounds of the partition and the thread block size. Kernel
arguments are passed using the param argument, containing all integer arguments to
the kernel that might be used by memory access maps. Although this list can have
arbitrary sizes for each kernel, its size is known at compile time, avoiding out-of-bounds
issues. The third argument is a pointer to a callback function with the prototype:
1 typedef void(*__me_cbfn_t)(int64_t lower, int64_t upper, void *user);
As explained above, it receives the access’ index in all outer dimensions and the bounds
of the access in the inner dimension. Additionally, it receives a pointer to user-data,
that is simply passed through from the original call. These functions can then be used
by the runtime system to identify data dependencies of the individual partitions of a
kernel.
90
4.6 Runtime Library
• An interface layer that enables the automatic integration of the runtime library
into multi-GPU applications.
91
Automatically Partitioning CUDA
G0 G0 G1 G1 G0 G0 G1 G1
0 10 21 32 43 54 5 0 10 21 32 43 54 5
ery:ery:
[0, 4) →
[0,{ 4)
([0,→2){ -([0,
G0 ),2)([2,
- G4)
0 ), -([2,
G1)4)} - G1) } Update:Update:
([1, 3), G
([1,
2) →
3), GOK
2 ) → OK
G0 G0G2 G2 G1 G1
0 10 21 32 43 54 5
(a) Range query on a tracker covering two (b) An update on a tracker containing two
intervals. Two new intervals fitting the query intervals. The existing ones are updated to
are returned providing their exclusive owners. provide space for the new one.
Figure 4.10: Two example use cases of the tracker component tracking the exclusive
owners of all array elements. All ranges are specified as half-closed intervals.
buffer management system is tasked with allocating and freeing the corresponding
buffers, as well as setting up and maintaining auxiliary data structures.
Where the original application allocates a single buffer, the partitioned application
allocates one buffer per GPU and the auxiliary data structures and returns a “virtual
buffer” that refers to the compound object.
Buffer allocation is simple: for each GPU available to the application, one buffer of
the requested size is allocated on the corresponding GPU. As the number of GPUs
does not vary throughout the runtime of the application, they can be kept in an array
that is addressed using the GPU index, e.g. position 0 in the array contains a reference
to the buffer of GPU 0.
The type of memory system created by GPUs in this work can best be described as
a “cache-only memory architecture” (COMA) and coherence is enforced using a relaxed
and simplified version of the DDM [111]. The first simplification is that the model is
reduced to only the invalid and exclusive states. The second simplification is that the
“caches” (GPU memory) do not communicate directly via coherence broadcasts and
probes, but all cache state is managed centrally using a tracker component. For each
element in the buffer, the tracker keeps track of the owner of the exclusive copy of the
element. Given n GPUs, each element is in the exclusive state on exactly one GPU
and in the invalid state on all others. The lack of a shared state immediately indicates
to potential redundant transfers for data that has not been updated between.
Figure 4.10 illustrates the tracker usage with two examples, a query of the exclusive
owners over a range and an update on a particular range, declaring a new exclusive
92
4.6 Runtime Library
owner for this range. These two operations are the only ones required for the coherence
scheme used in the partitioning compiler. Usage is not systematically biased towards
either queries or updates, requiring both to exhibit acceptable performance.
A naive implementation of the tracker could be a bitmap that stores the index of
the GPU that exclusively owns the element. However, this approach has prohibitive
space requirements and poor performance for queries about large regions in the buffer.
Instead, the tracker employs two optimizations.
The first optimization is to store all elements as non-overlapping intervals that share
the same exclusive owner. For example, instead of storing the list (G0 , G0 , G0 , G0 ) to
indicate that the first four elements are owned by GPU 0, it stores ([0, 4), G0 ), the
half-closed interval [0, 4) containing the numbers (0, 1, 2, 3) and the exclusive owner G0 .
To avoid fragmentation and keep storage requirements stable, neighboring intervals
with the same exclusive owner must be merged into a single one representing the
union of both. This optimization exploits the domain decomposition using the thread
grid that is often employed for GPU applications. Threads that are neighbors in the
thread grid often write to neighboring elements in the output buffer. In the worst-case
scenario, each element must be represented by an interval of length one and has a
storage requirement of 3 numbers (start, end, owner) instead of only one (owner). On
the other hand, if the average size of intervals with the same owner is larger than
three, this approach is space-efficient. For the common 1 : 1 write pattern, where all
threads write to consecutive addresses (after linearization), the space requirements for
the tracker using intervals are 3 numbers per GPU.
The second optimization is storing the intervals in a sorted, self-balancing tree
using. The start of an interval is used as the key and they are small and quick to
compare, so a B-Tree has been chosen to improve performance over e.g. a red-black
tree [112]. For a tracker containing n intervals and comparing against a sorted list of
intervals, this keeps the time complexity of a lookup of O(n) but reduces the time
complexity for inserts from O(n) (caused by shifting the following elements) down to
O(n).
93
Automatically Partitioning CUDA
that each require a different approach for the translation from a single-GPU to a
multi-GPU context.
Host-to-Device memcopies turn into a 1 : n data movement. The single source
buffer is distributed among multiple GPUs. Static analysis can not be guaranteed to
accurately predict the required data distribution for the next kernel launch involving
the corresponding buffer. An alternative approach to relying on memory access patterns
for the data distribution is to distribute the data in some predefined way. In the best
case, the distribution pattern matches the next kernels memory access patterns and no
data needs to be transferred between kernels. Otherwise, the buffers are synchronized
to resolve the data dependencies on all kernels. In addition to the data transfers, the
tracker needs to be updated to reflect the new distribution, where each GPU now
exclusively owns the part that it received. This is the approach chosen for this work,
which currently simply uses a linear distribution pattern. 1 2
Device-to-Host memcopies are a n : 1 data movement in the translated application.
Many source buffers are gathered into a single host target buffer. Performing a range
query on the tracker of the virtual source buffer over the full size of the buffer returns
the current data distribution. Using this information, the buffer can be assembled in
the host memory by a series of memcopies that collect the most up to date copies of
all elements from their exclusive owners.
Device-to-Device copies turn into n : n data movements. It can be implemented
as a combination of the Host-to-Device and Device-to-Host strategies. First, the full
data distribution is queried from the tracker. Then, the data is linearly distributed
over all GPUs with this information. Single-GPU applications typically avoid these
copies since it simply creates a copy of data that is already existent on the GPU. For
this reason, this type of CUDA memcopy has not been implemented yet.
Host-to-Host data movements are left unmodified as 1 : 1 data movements.
94
4.6 Runtime Library
states. The relaxation is the requirement to enforce coherence only at specific points in
the program, immediately after a kernel has finished execution on all partitions. This
matches the coherence guarantees made by the CUDA execution model [14] and is
completely transparent for applications adhering to these guarantees and not relying
on architecture-specific exceptions to it. In other words, enforcing coherence only after
kernel launches is indistinguishable from enforcing it at every point in the application.
Algorithm 3 Algorithm for the synchronization of a single virtual buffer for a single
partition.
Input: kernel - a CUDA kernel
buffer - a buffer of kernel
GPU - the current GPU
Output: none
However, for a kernel to execute successfully and compute the correct result, the
buffers on each GPU need to contain valid (up-to-date) copies of all elements in the read
set of the GPU’s partition. The process of identifying this data and communicating it
between the different GPUs is referred to as buffer synchronization. Since the tracker
of a virtual buffer does not support a shared state for any elements, all data that is
not exclusively owned by a specific GPU is considered out-of-date. Using the code
generated for the read accesses of the buffers of a kernel, this results in the algorithm
depicted in 3 for the synchronization of a single virtual buffer for a single GPU. The
execution of this algorithm is repeated for each partition for each virtual buffer used in
a kernel. Since no exclusively owned data is overwritten, none of the memory transfers
have any dependencies between them and they can all be executed concurrently.
Updating the tracker after kernel execution is similar to buffer synchronization,
but simpler. Tracker state is not queried at all but simply overwritten using the write
accesses computed using the generated code. The write accesses to a buffer from a
particular GPU are simply traversed and the tracker is updated to set the owner for
95
Automatically Partitioning CUDA
each interval to the GPU in question. Algorithm 4 shows this algorithm. The buffer
updates are also repeated for each buffer for each partition.
1. Substitute all calls to the regular CUDA Runtime API with their wrappers from
the runtime system.
2. Replace all CUDA kernel launches with code for partitioning, buffer synchroniza-
tion, and kernel orchestration.
Due to their different levels of complexity, they are implemented in different stages of
the compilation using different abstractions.
The first transformation, the replacement of calls to the CUDA Runtime API with
the wrappers provided by the static runtime library, is implemented on the IR level.
All wrappers in the runtime library have been designed to have prototypes that are
compatible with that of their CUDA Runtime API counterparts. The replacement
logic in LLVM can then be implemented with a map from CUDA API calls to their
substitute and following this recipe:
1. For each function in the CUDA Runtime API, perform a look-up in the translation
unit’s symbol table.
2. If the symbol is not found, continue with the next API function.
96
4.7 Host Code Transformations
(a) a substitute does exist: iterate over all its uses and if the use is a function
call, replace the called function (the CUDA Runtime API function) with its
substitute from the partitioning runtime library.
(b) a substitute does not exist: check if any of the uses is a function call, abort
compilation with an error message about the unsupported CUDA Runtime
API call.
Aborting the compilation when unsupported CUDA API calls are encountered is
necessary to guarantee correct semantics of the resulting partitioned application. Any
CUDA API calls that can not be translated keep their original semantics and expect
device buffer reference to directly point into device memory instead of the compound
virtual buffer object. This can lead to undefined behavior and data corruption.
The second transformation, implementing the partitioning, buffer synchronization,
and kernel orchestration, is complex enough to warrant its implementation in a high-level
language. While this transformation seems to be a good fit for some form of template
instantiation in LLVM IR, there is an impedance mismatch because LLVM IR does not
have a semantically equivalent representation of a CUDA kernel launch. It is lowered
into a set of setup instructions and function calls that requires a complicated matching
heuristic to identify, as described in section 3.1.2. Ideally, the transformation would
be implemented as a source-to-source translation or as an AST-level transformation.
However, the LLVM framework in general and Clang (as the C-family front-end) are
not intended for source-to-source translation. Although frameworks for these purposes
exist, the learning curve and engineering efforts involved with the integration of a
new technology into the toolchain were deemed too high [113, 114, 115]. Instead, all
transformations are implemented using regular expressions substitutions before the
application is passed to the compiler [116]. This implements a poor man’s source-to-
source transformation that does not require a CUDA capable compiler frontend but is
unaware of the semantic structure of the program.
Lua has been chosen as the implementation language due to its availability, ex-
pressiveness, and string processing facilities (including built-in support for a flavor of
regular expressions) [117]. The translator written in lua expects the application model
and a CUDA file as input and then performs the substitutions in two steps.
First, type definitions and prototypes are inserted at the top of the translation
unit. This prepares the translation unit so that external functions from the runtime
97
Automatically Partitioning CUDA
system and generated code are recognized by the compiler. Specifically, the following
information is inserted:
• Definitions for function pointers that represent the code generated for memory
access patterns as described in section 4.5 and the callbacks used to emit intervals
from the memory access.
• Prototypes for the functions generated for all memory access patterns from section
4.5.
The second step is the transformation of CUDA kernels. For this, all CUDA kernel
launches are located by using the regular expression defined by launch:
launch = id ws "<<<" ws config ws ">>>" ws "(" ws params ws ")" ws ";"
id = [_a-zA-Z][_a-zA-Z0-9]*
ws = [ \t\f\n]*
config = .* / ws ">>>"
params = @balanced{"(", ")"}
where "s" matches the string literal s, [a-xyz] matches a single letter contained in the
character class a...x, y, z, R* matches zero or more occurrences of R, R / S matches
R followed by S without consuming S, and @balanced{"(", ")"} matches anything
between a balanced number of occurrences of ( and ). This expression allows matching
CUDA kernel launches with reasonable accuracy. However, using regular expressions
instead of an actual parser is vulnerable to malicious input since it ignores comments
and some other structures available in the language. Each match for this pattern is then
replaced with code that generated by instantiating the template shown in algorithm
4.11.
The first line extracts all scalar arguments used in the kernel launch. It identifies
them by comparing them to the arguments stored in the application model by position.
The resulting array params contains all kernel arguments that influence the kernel’s
read or write accesses. Then, the thread grid of this kernel is split into partitions,
using the kernel name (which contains the predicted best partitioning based on its
read accesses), the grid configuration, the scalar kernel arguments, and the number of
GPUs as input.
98
4.7 Host Code Transformations
Figure 4.11: Pseudo code of the kernel launch replacement that is inserted by the
source-to-source rewriter.
Next is the buffer synchronization that is performed by the loop in line 4. For
each GPU, it retrieves a list of arrays that are read by at least some threads in the
kernel. It then retrieves the code generated for the read access map of the array by
this particular kernel. A reference to the virtual buffer, the memory access map, the
GPUs partition, as well as the kernel scalar arguments are then passed to the runtime
function implementing the buffer synchronization. It uses this information to distribute
all elements among the GPUs that are required by them as input. After the loop, a
device synchronization call waits for all GPUs to finish their pending operations to
ensure all memory is up to date before launching any kernels.
After synchronization, the partitioned kernel can be launched on each GPU in the
loop starting at line 12. The first step is to compute an updated thread grid size, to
limit the kernel’s execution to only the threads of the current GPU’s partition. Next,
an updated list of arguments is created using the application model. Scalar arguments
99
Automatically Partitioning CUDA
are copied unmodified. Array arguments are replaced with virtual buffers. The buffer
instance located on the current GPU is retrieved and its pointer copied to the argument
list. Last, the partitioned kernel is launched using the new grid configuration, new list
of arguments, and the partition information that is required by the relocatable kernel’s
index calculations as described in section 4.3. No device synchronization is required
after launching the kernels to match the semantics of the regular CUDA kernel launch,
which is an asynchronous call as well.
The last major operation in the replacement for kernel launches is the update
of coherence info, which is performed in the loop starting at line 23. The program
semantics are unaffected by whether the update is performed directly before the kernel
launch or directly after it. But since kernel launches are launched asynchronous and
most GPU applications simply wait on the kernel to finish, this is an opportunity
to hide the latency from the update in the execution time of CUDA kernel launches.
The coherence information in the tracker is updated using the write access maps in
the application model for the corresponding kernel. It follows the same recipe as the
buffer synchronization but retrieves write maps instead of read maps and calls the
runtime library function that updates the tracker. The injectivity of write maps (by
the polyhedral analysis in section 4.4) ensures that the order in which the write maps
are traversed does not influence the resulting distribution of exclusive owners.
The loop selecting and processing kernel arrays and arguments are unrolled during
the template instantiation. This produces simpler code that is easier to debug and
reduces the depth of the loop nests created by this code. The pseudo-code in algorithm
4.11 contains the corresponding regions as loops for clarity.
The host code transformation has now combined the individual pieces of the
partitioning compiler. By first analyzing the code to produce the application model,
then translating both host and device code, and generating the code for memory access
maps, a partitioned binary is been created that utilizes all GPUs in the system to
jointly compute the solution of the program.
100
Chapter
5
Prototype Evaluation
This chapter evaluates the concept and prototype implementation for the automatically
partitioning compiler presented in this work and discusses the results. It focuses on two
aspects of the performance of the produced application binaries: measured performance
of the generated binaries, and overhead introduced as a side-effect of the transformation.
The evaluation will be based on three proxy applications with memory access patterns
that can be successfully extracted by static analysis, thus enabling the generation of
and multi-GPU binaries.
This chapter first details the evaluation methodology, which includes detailed
descriptions of the proxy applications, the test system used, and how execution times
are measured. Then, the speedup achieved with multiple GPUs is calculated and
explanations for the behavior of the individual proxy applications are discussed. Next,
the overheads introduced by the individual parts of the runtime system are presented
and discussed. These overheads can be assigned to both essential and accidental
complexity, and their individual contributions will be examined. Lastly, the chapter
concludes with a summary of the findings and their consequences for the prototype
implementation and the underlying concept.
5.1 Functionality
This section evaluates the functionality of the partitioning compiler. Functionality
includes both the range of applications covered by the compiler as well as the correctness
of applications that can be optimized by it.
101
Prototype Evaluation
• Control flow dependent on the state of the GPU memory, if the content of the
memory cannot be represented in the polyhedral model [121]. This is the case
for complex convergence criteria, as used for example in different string matching
algorithms [122] or simply loops with non-affine bounds.
Issues with these and other constructs are typically side-stepped in polyhedral com-
pilation by focusing on sub-regions that can be fully represented in the polyhedral
model. However, for the automatic partitioning approach in this work, the control
flow of the full kernel must be modeled. There are efforts to relax these constraints
by over-approximating the problematic regions, in the worst case with loops from
zero to infinity [123]. This simplification severely limits the usefulness of the memory
access patterns used in this work and cannot be adopted. Many algorithms of GPU
applications rely on either indirect memory accesses and loops with non-affine bounds,
or introduce them with various optimizations.
The analysis used in this work has been repurposed from another research project
and is designed as a general purpose analysis instead of bespoke for GPU code. It
requires external hints about the semantics of the CUDA programming model to
successfully create a model of the application. An example of this is a common
formula used in CUDA that computes the global index of a given thread by calculating
blockIdx.x * blockDim.x + threadIdx.x, which results in non-affine accesses that can
not be handled by polyhedral analysis without help. The issue is resolved by creating
a parameter for this expression that can later be located and replaced with actual
102
5.2 Evaluation Methodology
values when a memory access pattern is resolved. Although the solution works for
this very common special case, it requires the global thread index to be computed in
this exact way, mixing dimensions or any other non-trivial modification breaks the
analysis. Additionally, the analysis itself is experimental and does not cover all possible
edge cases, resulting in crashes due to violations of invariants [99]. Overall, only a
fraction of applications that might generally be amenable to polyhedral analysis can
be represented by the application model used in this work.
For this reason, the evaluation is limited to a comparatively small set of applica-
tions. They are minimal implementations of the workloads they represent and have
been written in such a way as to avoid any of the issues mentioned above. Naive
implementations of the algorithms themselves do not contain indirect memory accesses
or non-affine loops and only optimizations that are possible within these constraints
have been implemented. They cover both iterative and non-iterative applications,
host-side buffer swapping and a variety of different kinds of memory access patterns.
The compiler can analyze these applications and generate partitioned binaries for them
that keep the original semantics intact, producing the correct results for all of them in
all tested configurations.
103
Prototype Evaluation
5.2.1 2mm
The 2mm benchmark computes two chained matrix multiplications that reuse the
result of the first computation as input for the second: C = A · B , E = C · D . Both
multiplications are executed in individual kernel launches of the same kernel code. The
problem size n of this application to the side length of the quadratic matrices of size
n × n. The matrix multiplication code used here is a fairly simple implementation
with the only optimization being tiling in shared memory with a tile-size of 32. The
sequence of actions performed by the application is the following:
4. Execute kernel E = C · D .
5.2.2 Hotspot
The hotspot benchmark is a heat relaxation, implemented as a 5-point stencil iteration
on a static grid with a fixed time-step. The grid has size n × n, with n being the
problem size. Each iteration is performed in a separate kernel launch and executes
the same kernel code, which is a naive 2D-Jacobi iteration. In particular, no adaptive
mesh refinement approach is used, which necessarily introduces dynamic buffers and
prohibits a program analysis using the polyhedral model [124].
2. Compute the result of the Jacobi iteration from the input A to a buffer B .
5. Copy result from buffer A (after switch in step 3) back to host memory.
104
5.2 Evaluation Methodology
5.2.3 N-body
The n-body benchmark is a gravitational simulation of point masses with a fixed
time-step. The problem size n refers to the number of bodies in the simulation of
this benchmark. Each body is initialized with a random position, mass, and velocity,
and the simulation then updates velocities and positions using a semi-implicit euler
integrator. First, velocities are updated in one kernel by calculating the forces of each
body onto every other body. Then, another kernel updates the positions of all bodies by
integrating the velocities over a fixed time-step. Most optimizations typically applied to
n-body simulations introduce dynamic memory accesses via indirections, making them
inappropriate for polyhedral analysis. In particular, no clustering optimizations are
applied, where bodies in close proximity are grouped into a cluster. Within each cluster,
the contributions of each body are computed accurately, but the contributions of bodies
in other clusters are simplified as a single point-mass per cluster. The simulation
performs the following steps:
3. Launch kernel B to update positions for all bodies using the update velocities
(semi-implicit euler integration).
105
Prototype Evaluation
All applications have been compiled twice, once with an unmodified CUDA compiler
for single-GPU reference and once with the partitioning compiler to measure execution
times and overhead. Both variants have been compiled with the same LLVM/Clang
compiler, a development build of version 7.0.0 of January 8, 2018 and the CUDA SDK
in version 8.0. The single-GPU binaries have been compiled with this combination
as is, for GPU architectures sm_30 and with the optimization flag -O3. The multi-
GPU binaries have been compiled with the same compiler and settings, but with the
automatic partitioning passes added to the toolchain via an external LLVM module.
Each benchmark was executed at least 16 times in each configuration and all
execution times are provided as mean values with the corresponding standard deviation.
Application runtime can be measured in several ways. This analysis exclusively
uses wall-clock time instead of CPU time. The applications used here simply go to
sleep when calculations are performed on the GPU, which is not captured by CPU
time. Additionally, since this work focuses on time spent with GPU-related activities,
only these activities are captured. This primarily includes memory copies between
the host and the device and the execution of CUDA kernels. Driver initialization,
host-buffer initialization, and result verification are not counted towards the runtime.
Driver initialization time can be up to several seconds and is a function of the CUDA
driver, number of GPUs and amount of system memory. The benchmark applications
are written in such a way that all activities that are counted towards the runtime are
executed back to back without any time-consuming operations in-between (in particular
the activities mentioned before).
Time is measured using the gettimeofday call, which has a mean resolution of less
than 1 µs and a worst-case resolution of about 700 µs on the test system, measured
using the approach from Finney [125]. The time of each runtime phase has been
measured separately and added up to the total runtime, which introduces an average
error of less than a microsecond.
106
5.3 Speedup of Partitioned Applications
the ratio between the runtime of the unpartitioned binary Ts and the runtime of of
partitioned binary Tp [126]. Both runtimes are measured based on the time spent in
GPU activities as detailed in section 5.2. As an additional indicator of the scaling
behavior, its efficiency η is calculated from the speedup S and the number of GPUs
(partitions) P for that speedup using the formula
S
η= . (5.2)
P
The efficiency describes the contribution to the speedup per additional GPU. As
Amdahl’s Law demonstrates with the inevitable sequential parts of any application,
the marginal contribution per GPU should shrink with each additional GPU.
In addition to the speedup and efficiency, inherent properties are discussed to
explain the results observed experimentally. Two properties regarding the scaling
behavior of the amount of both work and space required for an algorithm are discussed
[127]:
• The space complexity is a related measure that describes the relative amounts
of memory required to solve the algorithm for different problem sizes.
Both types of complexities are defined as a function of the problem size n as defined in
section 5.2 and the number of iterations I if appropriate.
Both the computational and space complexity are properties of the underlying
algorithm of the unpartitioned application. This does not always directly translate
to the corresponding requirements for the partitioned application, in particular con-
sidering that additional communication to synchronize data between GPUs might be
necessary. Therefore, for the partitioned application, the computational complexity
per partition and the communication complexity per partition are calculated
analogous to the two properties above, as a function of the problem size n, the number
of GPUs (partitions) P and the number of iterations I where appropriate. Note
that the communication complexity is directly dependent on the partitioning used to
distribute the application over multiple GPUs and must be mentioned when discussing
the communication complexity. Communication complexity also does not capture
107
Prototype Evaluation
8
Problem Size
7
6 8192
Speedup
5 12288
4 16384
3 20480
2 28672
1
1 2 4 6 8 10 12 14 16
GPUs
additional latencies that can arise from data being laid out sub-optimally, prohibiting
batch transfers and instead requiring many small transfers.
Figure 5.1 presents the speedups measured for the 2mm workload. The highest
speedup achieved is S = 8.31 (with a standard deviation σ = 0.155) with 14 GPUs,
which corresponds to an efficiency η = 59.4 %. The highest scaling efficiency achieved
is η = 95.2% (σ = 0.86%) with two GPUs, resulting in a speedup of S = 1.90. Both
maximum values are observed for the largest problem size of n = 28672.
While the computational complexity of the chained square matrix multiplication is
O(n3 ), the space complexity is only O(n2 ). Computation can be perfectly distributed (︂ 3 )︂
between multiple GPUs independent of the partitioning and results simply in O nP .
The partitioning heuristic in the compiler suggests a linear distribution across the Y-axis
to allow batch transfers or contiguous chunks in memory. As a result, the left-hand
matrix in the first multiplication A is laid out correctly across the GPUs using the
linear distribution for host-to-device memcopies. Similarly, the intermediate matrix
used on the left-hand side of the second multiplication C and each partition computes
the partial result it later reuses. On the other hand, the linear data distribution does
not correctly layout the right-hand matrices B and D which are required in full by
each GPU, resulting in some synchronization between the devices. The communication
complexity per partition, therefore, results in O(n2 ). This large communication
complexity independent of the number of partitions limits the speedups that can be
achieved despite the massive amounts of computation.
The speedup achieved on the test system for the hotspot benchmark is shown in
figure 5.2. Similar to the 2mm benchmark, the highest speedup is achieved with 14
GPUs with S = 7.59 (σ = 0.35), with an efficiency of η = 54.2% that is slightly
108
5.3 Speedup of Partitioned Applications
8
7 Problem Size
6 8192
Speedup
5 16384
4 23200
3 28384
2 36864
1
1 2 4 6 8 10 12 14 16
GPUs
lower than that of the highest speedup of 2mm. The highest efficiency of η = 96.6%
(σ = 1.2%) is also measured when using two GPUs with a speedup of S = 1.93 and is
slightly higher than the highest efficiency of the 2mm benchmark.
The computational complexity of the hotspot benchmark is O(n2 I), while the space
complexity is O(n2 ), indicating the 2-dimensional grid and a constant amount of work
per element in the grid per iteration. When partitioned across multiple GPUs, work
can again be perfectly distributed(︂ 2 )︂independent of the partitioning and computational
complexity per partition is O nP I . Similarly to the 2mm benchmark, the partitioning
heuristic suggests a linear distribution across the Y-axis to allow few large transfers
of chunks that are contiguous in memory. The initial data distribution as well as the
write-pattern in every iteration match the read pattern almost exactly and only require
to synchronize a small
(︂ 2 static)︂ amount before each partition, resulting in a communication
complexity of O nP + nI . Although the small synchronization transfers between
iterations introduce a sequential lag that limits parallelization, increasing the number
of iterations causes the computation to grow faster than the communication, creating
a potential for a speedup, as illustrated by figure 5.2.
Figure 5.3 shows the speedup measured on the test system with the n-body bench-
mark for up to 16 GPUs. The highest speedup of S = 12.07 (σ = 0.02) was measured
with 16 GPUs and results in an unusually high efficiency of η = 75.4%. The highest
efficiency of η = 99.1% (σ = 0.1%) was observed with when using two GPUs and has a
speedup of S = 1.98.
The computational complexity of the unpartitioned n-body benchmark is O(n2 I)
and its space complexity is O(n). As with the previous two benchmarks, work can be
shared perfectly between GPUs, resulting in a computational complexity per partition
109
Prototype Evaluation
12
11 Problem Size
10
9 65536
Speedup
8
7 131072
6 196608
5
4 262144
3 327680
2
1
1 2 4 6 8 10 12 14 16
GPUs
2
of O( nP I ). As the main data structures in this simulation are flat arrays of particles,
the partitioning heuristic suggests a linear distribution across the X-axis. During any
iteration, each partition needs the full list of positions and masses and a fraction of
the list of velocities. However, the tracker is implemented in a way that does not
allow shared unmodified copies of data on multiple GPUs, requiring the positions
and masses to be fully synchronized in each iteration. The array containing velocity
values, however, requires only a single transfer during the first host-to-device memcopy
and then stays local to its GPU. This results in a communication complexity per
partition of O(nI). Although the n-body benchmark has the same computational and
communication complexities per partition as the hotspot benchmark, the very small
space complexity allows scaling to much larger problem sizes. Large problem sizes
exploit the higher order of the computational complexity per partition and so enable
higher speedups, as is visible from figure 5.3.
110
5.4 Partitioning Overhead Analysis
1. In regular mode, the default mode, all access patterns are enumerated and
memory is transferred normally. The partitioned application behaves as expected.
None of the proxy applications contain data-dependent control flow. While disabling
transfers does change the result that is computed, the algorithm itself is unaffected
by it. Identical to the speedup analysis, the benchmarks have been executed in each
configuration and each mode for at least 16 times and all results are presented by their
average and standard deviation. For clarity, only the smallest and the largest problem
sizes are examined, with one plot for each. The fraction of the runtime that is taken
up by communication c is calculated as
and the fraction of the runtime spent enumerating memory access patterns e is calculated
as
Tnotransf ers − Tnopatterns
e= . (5.4)
Tregular
111
Prototype Evaluation
200
4
100
2
0 0
1 2 4 6 8 10 12 14 16 1 2 4 6 8 10 12 14 16
GPUs GPUs
Regular Runtime Without Transfers Without Patterns
Figure 5.4: Composition of the application runtime by type of activity for the 2mm-
workload.
optimizations are avoided, producing a realistic insight into the performance of the
runtime system.
The 2mm benchmark has a fairly high communicational complexity per partition
of O(n2 ), as explained in section 5.3. The large overheads of these transfers were
expected to severely limit the application’s scalability, which is confirmed by the results
shown in figure 5.4. For both problem sizes, the runtime of modes no-transfers and
no-patterns continues to decrease for larger growing numbers of GPUs, while the gap
between them and the runtime in regular mode increases. This suggests the growing
portion of the runtime is taken up by data transfers, which is clearly visible for both
problem sizes. The highest largest portion of time spent transferring data is c = 67.6%
(σ = 7.6%), completely negating any time savings from distributed computation. The
largest fraction of time spend in pattern enumeration is e = 7.3% (σ = 5.1%) and
is fairly high, but has a standard deviation in the same order of magnitude. The
corresponding values for the large problem size are c = 39% (σ = 1.8%) and e < 1%.
In general, the hotspot benchmark behaves similarly to the 2mm benchmark: for
small problem sizes, communication grows large enough to destroy any reduction in
runtime by work distribution and communication is still a significant factor even for
the large problem size. However, for the small problem size n = 8192, the fraction
spent transferring data is even more exaggerated than with 2mm. The maximum value
for the communication fraction is c = 78.1% (σ = 14.1%), more than 10% higher than
2mm. The maximum time spent enumerating patterns is e = 6.8% (σ = 2.6%), which
is comparable to 2mm. For large problem sizes, these values are c = 41.2% (σ = 3.9%)
and e = 3.6% (σ = 0.2%).
The n-body benchmark behaves significantly differently than the other two bench-
112
5.4 Partitioning Overhead Analysis
n = 8192 n = 36864
Execution Time (s)
5 100
0 0
1 2 4 6 8 10 12 14 16 1 2 4 6 8 10 12 14 16
GPUs GPUs
Regular Runtime Without Transfers Without Patterns
Figure 5.5: Composition of the application runtime by type of activity for the hotspot-
workload.
7.5
150
5.0
100
2.5 50
0.0 0
1 2 4 6 8 10 12 14 16 1 2 4 6 8 10 12 14 16
GPUs GPUs
Regular Runtime Without Transfers Without Patterns
Figure 5.6: Composition of the application runtime by type of activity for the n-body
workload.
marks, although transfers still ruin scalability for small problem sizes. However,
communication takes up at most c = 49.6% (σ = 2.5%) of the total runtime, far less
than for 2mm or hotspot. Pattern enumeration is also far less pronounced with a
maximum value of e = 5.7% (σ = 3.2%). For the large problem size, the highest
fraction of time spent transferring data is c = 16% (σ = 0.2%), and patterns a negligible
taking up less than 1% in the worst case. This behavior matches the predictions made
in section 5.3 about the communication behavior of the application.
The necessity to communicate is an inherent problem when distributing computation
and while optimized algorithms can limit the amount of communication required, it
can rarely be avoided altogether. Overheads like the enumeration of data dependencies,
however, are accidental complexity introduced only by the particular approach chosen
to partition applications. This kind of overhead should be avoided or kept insignificant
113
Prototype Evaluation
wherever possible. The analysis performed in this section shows that the accidental
overhead introduced by the runtime system is always less than 10% of the total runtime
and in many cases negligible for larger problem sizes. This is an acceptable result and
validates this aspect of the automatic partitioning solution.
A different, but not insignificant type of overhead is the compile-time overhead.
The automatic partitioning requires certain repeated execution of certain steps in the
compilation pipeline and adds significant static analysis and code generation. On the
benchmarks evaluated here, using the two-socket machine described earlier, compilation
times have been increased by a factor of 1.9x to 2.2x. While a factor of 2 is a large
increase, this cost is amortized over multiple runs of the application and is appropriate
considering the extensive restructuring of the application.
114
Chapter
6
Discussion
This chapter critically reflects on the research presented in this work. It is put into
context using related work from past research as well as the current state of the art,
providing an estimate for the impact this work might have on the scientific community.
The comparison with related work is then followed by discussing future work that could
not be explored as part of this research project but is highly interesting and could
improve its results.
115
Discussion
116
6.1 Related Work
locality of groups of thread blocks in the GPU’s L1 cache is analyzed and then use
as the motivation for a software-based clustering of thread blocks for better cache
utilization [132]. More explicitly focused on communication between thread blocks is
the analysis of Wang et al. which specifically investigates the network traffic of on-
chip networks between Streaming Multiprocessors (SMs) caused by inter-thread-block
locality [133] and propose hardware extensions to minimize redundant network traffic.
In a work proposing a software-based extension to the thread block scheduler that
leverages user-provided locality information, Vijaykumar et al. perform an analysis of
the NUMA effects between SMs arising from spatial and temporal locality between
different thread blocks [134]. The approach of Wang et al. is the most closely related
to the methodology of the analysis in this work. Both subdivide kernels launched on a
single GPU into partitions and then analyze the communication between them as a
function of their locality. While Wang et al. use SMs as the granularity, the analysis in
this work uses user-defined groupings of thread blocks and lifts them into a completely
separate virtual device. Neither of these approaches utilize memory traces, allowing to
replay arbitrary system configurations.
117
Discussion
and Memcopies representing messages [138]. However, the similarities between MPI
and pure multi-GPU CUDA semantics are superficial as this approach does not follow
the Single Program Multiple Data (SPMD) approach of MPI and the CUDA API does
not provide any collectives or other advanced features. There exist MPI extensions
that extend MPI to support memory located in GPUs, so-called CUDA-aware MPI,
but all messages still originate from the host code [139]. A spiritually closer fit to
MPI programming model is achieved by projects providing an implementation for
GPU-initiated messages, such as the Gravel project proposed by Orr et al. [140].
Another approach that alleviates the user from manual buffer management and
synchronization but still requires work distribution is using shared host memory between
GPUs. CUDA Unified Virtual Addressing and Unified Memory both transparently
provide such a shared memory environment through [14]. Both solutions require no
engineering effort from the user, which can simply access any memory in the system
from any GPU, albeit with dramatic consequences for application performance [141].
Oden et al. propose GGAS, a Global Address Space (GAS) implementation that
provides direct access to memory even on remote GPUs by providing GPUs direct
access to an interconnect and forwarding memory requests through the network [142].
An extension of the approach of providing fully implemented operators for the
user to combine (e.g. BLAS) is to only provide operator-patterns and let the user fill
in the operator-kernel. This approach is taken from functional programming where
function composition is generally favored over explicitly defining control flow One of
the most popular approaches is Google’s MapReduce [143], which has been adapted
to GPUs multiple times [144, 145], and is named after the two primary types of
computation it provides: map (one-to-one relation between elements) and reduce
(many-to-one relation). In the context of this work, projects such as Spark-GPU,
Gunrock, and Copperhead can be interpreted as an extension of this approach that
primarily changes the operator-patterns (with Copperhead leveraging the patterns of
the built-in functional operators in python) [146, 147, 148]. A generalization of this is
the Groute project which provides facilities to create graphs describing custom operator
patterns and then provide the operator implementation [149]. This approach most
closely resembles the one taken in this work with the exception that the programming
model is only partially kept intact (it is usually significantly restricted) to simplify
implementation and improve performance.
The projects SnuCL by Jungwon Kim et al. and rCUDA Duato et al. both provide
the infrastructure to transparently scale out a single node system to multiple nodes
by projecting GPUs on remote nodes into the local system, effectively providing a
118
6.1 Related Work
form of GPU virtualization [150, 3]. Although these works are related in that they,
too, address transparently scaling out GPU applications, they are considered to be
complementary, since they only address the transition from single-node to multi-node
systems on the API level without providing a way to partition applications.
119
Discussion
work primarily in that it is based on a fixed set of memory access patterns and requires
implementing the application in a specific framework.
Static analysis and in particular the memory access patterns collected from it have
been used to manage data dependencies and distribute workloads in the past. The
work of Jang et al. uses a fairly simple model that can be interpreted as an extension
of the linear model of SKMD, but the results are used for optional optimizations on
GPU code without partitioning [154].
More powerful models exist, such as scalar evolutions, which allow modeling non-
linear memory accesses [155, 63]. They are used as parts of various optimizations
throughout the LLVM optimizations and are used as a transient analysis to calculate
the polyhedral representation of a program in the Polly optimizer [32]. However,
their use for data distribution in Distributed Memory Systems (DMSs) seems to be
unpopular.
Jungwon Kim et al. implement an approach that exhibits some similarities with
the one taken by us [156]. They utilize a combination of a runtime system and compile-
time transformations to translate a single-GPU OpenCL application to execute as a
multi-GPU CUDA application. However, they use a custom model for memory access
patterns that relies on sampling array indices of individual threads and is limited
to strictly affine, unconditional in well-behaved loops accesses. Additionally, each
device buffer image is backed by a synchronized buffer in host memory instead of using
a tracker and no host-code transformations are required since the full framework is
implemented as a modified OpenCL Runtime.
The polyhedral model is a popular program representation for aggressive trans-
formations, including workload and data distribution, and originates from the desire
to accurately model deep loop nests [58, 157]. While it is tempting to assume that
any polyhedral transformations based on CPU code can be directly applied to GPU
projects, there are enough differences in the technology stacks, execution model and
memory model to consider these two separate [158]. The model has been used to
predict the performance of OpenCL kernel by creating a database of memory access
patterns and their measured performance, without attempting to distribute either work
or data [159]. It has also been used to optimize data transfers within GPU kernels.
Fauzia et al. optimize accesses to global memory by coalescing them and promoting
them to registers and shared memory where possible [160]. Similar work has been
done by Baskaran et al. that automatically manages shared memory as a cache for
data with high reuse [161]. The Apollo project by Martinez Camaaño et al. utilizes a
combination of polyhedral compilation and instrumentation to predictively parallelize
120
6.2 Future Work
loop nests for multi-core CPUs, supporting even non-linear loops through speculation
and a runtime system [162]. Moll et al. exploit memory accesses in the polyhedral
model to improve vectorization and GPU utilization by reorganizing kernels into regions
with less divergent control flow [102]. While kernels are split into sub-kernels in a
transient transformation, these sub-kernels are not targeted at workload distribution
and no data distribution across distributed memories is performed.
Polyhedral compilation has also been successfully used to partition applications
within specific domains, often written in Domain Specific Languages (DSLs). Denniston
et al. propose an extension to Halide, a DSL for complex pipelines of stencil compu-
tations, which enables automatic partitioning of the stencil codes across distributed
memory systems with minimized data transfers [67]. Bondhugula et al. extend this
for general-purpose programming languages in their work that partitions loop nests
and the corresponding data across distributed memory systems, relying on MPI for
the generated communication code [66]. While influential, their work focuses solely
on CPU based systems with no CPU-GPU interplay and relies solely on polyhedral
compilation for the resolution of data dependencies, instead of using a dynamic tracker
that allows arbitrary reshaping of arrays and data distributions.
121
Discussion
122
6.2 Future Work
worthwhile to still attempt the data flow analysis for buffers and utilize the knowledge
if it succeeds. Two optimizations immediately come to mind:
1. Data transfers can be issued earlier, as soon as the data is computed, instead
of at the time of the kernel launch. This opens up opportunities for overlap of
communication and computation, as described for the finer partition granularity,
which requires this kind of extensive static analysis for accurate results.
2. Unnecessary transfers caused by the lack of a shared state of the tracker can be
avoided for kernels between which the analysis is successful. For kernels with
read-only data that is shared between all devices, such as the planet’s positions
in N-Body benchmark, this approach can significantly reduce the amounts of
transferred data.
This list of improvement ideas is not exhaustive, but instead contains the most
promising candidates for optimizations according to the best of the author’s knowledge.
123
Chapter
7
Conclusion
This work has set out to investigate how the BSP programming model can be exploited
to provide an abstraction of the GPU setup in a given system. It was motivated by the
impressive computational power and memory bandwidth of GPUs, which is enabled by
a fairly complex programming model that requires highly trained programmers to fully
exploit these benefits.
The main challenges in exploiting the full power of GPUs are their sensitivity
to complex control flow, the specific ordering of memory accesses to utilize the full
bandwidth, and the severely limited communication between threads. Therefore, the
first step in this analysis was to collect memory accesses of different existing applications
and analyzing them with regards to how well they fit these requirements if they were
to be partitioned. A custom instrumentation framework was developed to collect the
memory accesses of commonly used CUDA benchmark suits. After collecting the
memory traces, they have then been used as input for a simulation that executes a
simplified version of these benchmarks, containing no computation, on multiple GPUs
by partitioning them. The partitioning is based on four simple mapping functions that
do not require any knowledge about the input application. The output of the simulation
is the data locality exhibited by thread blocks, with any non-local data accesses being
considered communication between either thread-blocks or GPUs, depending on where
the communication thread blocks are scheduled. The conclusion of this analysis is
promising and two-fold: first, the majority of GPU applications in common benchmarks
exhibit highly localized data accesses, both on the thread-block level and the GPU
level, and second, for the majority of applications, there exists a simple mapping where
125
Conclusion
the volume of communication grows slower than the total aggregated bandwidth with
the number of GPUs in a system.
This information was then taken as the basis to design and implement a prototype
for an automatically partitioning compiler prototype for CUDA applications. The
design of the prototype was influenced by the model used to represent work and data
dependencies. Due to the regular nature of GPU applications, the polyhedral model
was considered an appropriate choice and was selected to model thread blocks and
the memory accesses of these thread blocks. Then, the thread grid of the application
is interpreted as a super grid, in which each GPU is responsible for computing only
a subset of the original grid. The polyhedral model accurately models the memory
locations that are read and written by each partition, allowing the identification of
data dependencies that require communication. Communication can be optimized by
creating a simple coherence model based on CPU caches, which each GPU corresponding
to a cache. After designing this model, it was implemented using the LLVM compiler
project, which provides a modular design that eases experimentation and contains
an almost fully compliant CUDA compiler, which was reused for this analysis. The
prototype first performs a polyhedral analysis of the GPU kernels in an application
and then modifies the kernels to be position-independent in the super grid. Then, code
is generated that efficiently computes data dependencies between GPUs. A runtime
library implementing work splitting and the simple, cache-inspired coherence protocol
is embedded. Lastly, the main application code is augmented to use the runtime library
and the generated to split kernels across GPUs and issue optimized data transfers to
satisfy data dependencies.
The prototype was evaluated using custom benchmarks, as using the previously
chosen benchmark suites was prevented by technical limitations. Functionally, the
prototype’s results are mixed. The severe technical limitations of the approach prohibit
a straight forward adaptation of the prototype for general-purpose use, which is
primarily caused by the reliance on a full-scope polyhedral analysis of the GPU kernels
of an application. However, in cases where polyhedral analysis can provide a full-scope
analysis, the results are accurate and allow a straight forward and correct partitioning
of the applications. Performance-wise, the results are uplifting. In all applications,
speed-ups of up to 12.4x on 16 GPUs are achieved, and the overheads introduced by the
identification of data dependencies and the resulting communication are typically small.
While the small selection of applications certainly can not cover the full spectrum of
different types of GPU applications, care was taken to provide a fair selection.
In the context of the current state of research, this work falls into a niche. There
126
are efforts to simplify the utilization of multiple GPUs within a single node, but they
typically only provide partial abstraction and leave data and/or work distribution to
the end-user. This work is the first that uses static analysis in the form of polyhedral
analysis to fully abstract the underlying GPU setup and provide a fully intact single-
GPU programming model: users need not be concerned with whether multiple GPUs
are installed in the current system or how many. Work is automatically split where
possible and communication inserted in a way that minimizes transfers.
The reliance on static analysis as a critical step results in interesting benefits
and drawbacks. On one hand, it allows the assumption that every memory access is
accurately represented in the application model, opening the opportunity to generate
very efficient code that does not need to approximate or handle many corner cases. On
the other hand, it prevents a large set of applications from being compatible with this
approach. Supporting gradual degradation of the static analysis could have lessened the
impact of this problem, although in practice the analysis quickly degrades to extreme
over-approximation.
The work presented in this thesis resulted in several artifacts. The memory access
pattern analysis produces two reusable components: the instrumentation framework
and the simulation which can be used independently of each other and this work. They
enable further research regarding the locality behavior of GPUs, which is likely to stay
a relevant topic considering the physical limitation of data transport on accelerators.
The primary software artifact is surely the prototype implementation of the automatic
partitioning scheme. While it is limited by technical issues, it nonetheless shows
impressive results for the cases where static analysis succeeds and showcases the
possibilities of the approach with impressive speed-ups of up to 12.4x on 16 GPUs.
This is an uplifting result and encourages further research in this area. These artifacts
have been made publicly accessible and can be used either as-is or as the basis for
new research efforts. In conclusion, the artifacts in combination with the research are
valuable contributions to the field of heterogeneous computing and the supporting
community.
127
Acknowledgements
First, I would like to thank my supervisor, Prof. Dr. Holger Fröning, for the opportunity
to execute this project under his supervision, which was not in small part responsible
for my decision to join this doctoral program. His guidance throughout this process
has helped my professional and personal growth tremendously and made this work
possible.
The research project described in this thesis did of course not exist in a vacuum,
but enjoyed feedback and input from countless like-minded researchers. For this, I
want to thank Sudhakar Yalamanchili who, although no longer with us, continues to
inspire by his example and dedication throughout his career. I also want to thank
Sebastian Hack and Johannes Doerfert both for their discussions and technical advice,
as well as for their software contributions.
I would have not been able to finish this project without my fellow PhD students,
Benjamin Klenk, Felix Zahn, Günther Schindler, and Lorenz Braun, who I want thank
for their endless encouragement and discussions.
Heartfelt thanks goes to my family who has supported me throughout this journey
and helped me with all the smaller and larger life issues I have encountered on the way.
This list could not possibly include all the people I am indebted to thanks. Therefore,
want to collectively express my gratitude for the countless people I met and formed
lasting friendships throughout my time as a Ph.D. student.
129
List of Figures
131
3.7 Record format for memory accesses. The record format in the queue
does not includes bits 192 .. 207, they are only used for run-length
encoding on the disk. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.8 Two-phase operation of the queue: first the GPU inserts until allocations
cross a watermark, then, once commit also crosses the watermark, the
host clears and resets the queue. . . . . . . . . . . . . . . . . . . . . . . 33
3.9 Warp synchronized device access to the queue (simplified). . . . . . . . 34
3.10 Consumer thread code running on the host including run-length encod-
ing(simplified). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
3.11 Communication between kernels in unpartitioned and partitioned kernels. 38
3.12 Example of the evolution of the tracker state (denoted as “Mem.”) over
an application trace containing two kernel launches with three thread
blocks each. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
3.13 Two common combinations of kernel types of arbitrarily many ones. No
general rule exists that reliably matches thread blocks that communicate
with each other using only their thread indices. . . . . . . . . . . . . . 43
3.14 Two examples of thread block ranges. The left example has perfect
uniformity while the example on the right is heavily clustered, resulting
in unevenly sized partitions. . . . . . . . . . . . . . . . . . . . . . . . . 45
3.15 Visualization of the lexicographic mapping on an 8x8 grid. Thread
blocks are lexicographically ordered by comparing their position in in
dimension individually, starting in X, then Y and lastly Z. . . . . . . . 49
3.16 The colexicographic mapping, the counterpart to the lexicographic one.
All characteristics are the reverse of its sister mapping. . . . . . . . . . 49
3.17 A Z-order curve over a thread grid of size 8 × 8 × 1. Notice the recursive
partitioning. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
3.18 The thread block order of the hash-based mapping in a thread grid of
size 8 × 8 × 1. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
3.19 Excluded benchmarks and reasons for the exclusion. . . . . . . . . . . . 57
3.20 Benchmarks included in the analysis and three letter codes used in figures. 58
3.21 Addresses read from the GPU (rfgpu ) and addresses read from the Host
(rfhost ), both normalized to the number of all addresses read by the
application’s kernels. Many addresses fall in both categories. . . . . . . 59
132
3.22 The critical GPU data volume Rcrit i
, normalized to the total amount of
data read from the GPU Rgpu i
, for all kernels of analyzed applications.
A high fraction implies the majority of a kernels input data has been
generated in the previous kernel launch. . . . . . . . . . . . . . . . . . 61
3.23 Sum of all data read from remote device of all partitions, normalized to
the sum of data read from any non-host device. . . . . . . . . . . . . . 63
3.24 Total communication between partition vs number of partitions, normal-
ized to maximum per application. Categorized into the four different
types of scaling behavior. . . . . . . . . . . . . . . . . . . . . . . . . . . 65
4.1 Subdivision of a 2-dimensional thread grid using straight cuts across its
axes. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
4.2 Illustration of polyhedral memory access patterns as used in this work. 73
4.3 Possible evolution of buffer state over the runtime of an application with
two launches of different kernels. . . . . . . . . . . . . . . . . . . . . . . 75
4.4 Toolchain overview . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
4.5 Illustration of the work splitting as implemented in this work. Partitions
are created by shrinking and shifting the thread grid to the size specified
by α and β. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
4.6 An illustration of an example application model for a 2-dimensional,
out-of-place Jacobi stencil (5-point), applied to a 2-dimensional partition
of the thread grid. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
4.7 Code generation example for a polyhedral expression. Expressions
always produce a single scalar value and do not contain control flow. . . 84
4.8 Code generation example for polyhedral set. The generated code iterates
over all elements in the set by creating loops. . . . . . . . . . . . . . . . 85
4.9 The different optimization levels (excluding linearization of accesses) for
the memory access map before code generation. . . . . . . . . . . . . . 88
4.10 Two example use cases of the tracker component tracking the exclusive
owners of all array elements. All ranges are specified as half-closed
intervals. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
4.11 Pseudo code of the kernel launch replacement that is inserted by the
source-to-source rewriter. . . . . . . . . . . . . . . . . . . . . . . . . . . 99
133
5.4 Composition of the application runtime by type of activity for the
2mm-workload. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
5.5 Composition of the application runtime by type of activity for the
hotspot-workload. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
5.6 Composition of the application runtime by type of activity for the n-body
workload. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
134
List of Algorithms
135
List of Abbreviations
ABI Application Binary Interface
IR Intermediate Representation
137
SKMD Single Kernel Multiple Devices
SM Streaming Multiprocessor
138
Bibliography
[4] A. Matz and H. Fröning, “Quantifying the NUMA behavior of partitioned GPGPU
applications,” in Proceedings of the 12th Workshop on General Purpose Processing
Using GPUs. ACM, 2019, pp. 53–62.
[5] W. Aspray, “The intel 4004 microprocessor: What constituted invention?” IEEE
Annals of the History of Computing, vol. 19, no. 3, pp. 4–15, 1997.
[6] K. Akeley, “Reality engine graphics,” in Proceedings of the 20th annual conference
on Computer graphics and interactive techniques. ACM, 1993, pp. 109–116.
[7] M. Hopf and T. Ertl, “Accelerating 3d convolution using graphics hardware (case
study),” in Proceedings of the conference on Visualization’99: celebrating ten
years. IEEE Computer Society Press, 1999, pp. 471–474.
139
[10] T. J. Purcell, C. Donner, M. Cammarano, H. W. Jensen, and P. Hanrahan,
“Photon mapping on programmable graphics hardware,” in ACM SIGGRAPH
2005 Courses. ACM, 2005, p. 258.
[15] G. T. Davis and S. Ventrone, “Method and apparatus for substantially concurrent
multiple instruction thread processing by a single pipeline processor,” 1994, uS
Patent 5,357,617.
[17] C. Xu, S. R. Kirk, and S. Jenkins, “Tiling for performance tuning on different
models of gpus,” in 2009 Second International Symposium on Information Science
and Engineering. IEEE, 2009, pp. 500–504.
[19] C. Lattner and V. Adve, “LLVM: a compilation framework for lifelong program
analysis transformation,” in Proceedings of the international symposium on Code
generation and optimization: feedback-directed and runtime optimization, ser.
CGO ’04, Mar. 2004.
[20] A. W. Appel, “SSA is functional programming,” ACM SIGPLAN Notices, vol. 33,
no. 4, pp. 17–20, 1998.
140
[23] D. Grune, K. Van Reeuwijk, H. E. Bal, C. J. Jacobs, and K. Langendoen, Modern
compiler design. Springer Science & Business Media, 2012.
[30] D. A. Terei and M. M. Chakravarty, “An LLVM backend for GHC,” in ACM
Sigplan Notices, vol. 45, no. 11. ACM, 2010, pp. 109–120.
141
[37] D. August, J. Chang, S. Girbal, D. Gracia-Perez, G. Mouchard, D. A. Penry,
O. Temam, and N. Vachharajani, “UNISIM: An open simulation environment and
library for complex architecture design and collaborative development,” IEEE
Computer Architecture Letters, vol. 6, no. 2, pp. 45–48, 2007.
[40] X. Gong, R. Ubal, and D. Kaeli, “Multi2Sim Kepler: A detailed architectural GPU
simulator,” in 2017 IEEE International Symposium on Performance Analysis of
Systems and Software (ISPASS). IEEE, 2017, pp. 269–278.
[42] S. Lee and W. W. Ro, “Parallel GPU architecture simulation framework exploiting
work allocation unit parallelism,” in 2013 IEEE International Symposium on
Performance Analysis of Systems and Software (ISPASS). IEEE, 2013, pp.
107–117.
[43] T. Ball and J. R. Larus, “Efficient path profiling,” in Proceedings of the 29th annual
ACM/IEEE international symposium on Microarchitecture. IEEE Computer
Society, 1996, pp. 46–57.
142
[48] D. Bruening and S. Amarasinghe, “Efficient, transparent, and comprehensive
runtime code manipulation,” Ph.D. dissertation, Massachusetts Institute of
Technology, Department of Electrical Engineering . . . , 2004.
[52] G.-R. Uh, R. Cohn, B. Yadavalli, R. Peri, and R. Ayyagari, “Analyzing dynamic
binary instrumentation overhead,” in WBIA workshop at ASPLOS. Citeseer,
2006.
[57] M. Griebl and J.-F. Collard, “Generation of synchronous code for automatic
parallelization of while loops,” in European Conference on Parallel Processing.
Springer, 1995.
143
Implementation, Tucson, AZ, USA, June 7-13, 2008, 2018. [Online]. Available:
[Link]
[62] S. Verdoolaege, “isl: An integer set library for the polyhedral model,” in Interna-
tional Congress on Mathematical Software, vol. 6327. Springer, 2010.
[63] S. Pop, A. Cohen, and G.-A. Silber, “Induction variable analysis with delayed
abstractions,” in International Conference on High-Performance Embedded Ar-
chitectures and Compilers. Springer, 2005, pp. 218–232.
[66] U. Bondhugula, “Compiling affine loop nests for distributed-memory parallel ar-
chitectures,” in Proceedings of the International Conference on High Performance
Computing, Networking, Storage and Analysis. ACM, 2013.
[68] C. Bastoul, “Code generation in the polyhedral model is easier than you think,”
in Proceedings of the 13th International Conference on Parallel Architectures and
Compilation Techniques. IEEE Computer Society, 2004, pp. 7–16.
[69] W. Zuo, P. Li, D. Chen, L.-N. Pouchet, S. Zhong, and J. Cong, “Improving
polyhedral code generation for high-level synthesis,” in Proceedings of the Ninth
IEEE/ACM/IFIP International Conference on Hardware/Software Codesign and
System Synthesis. IEEE Press, 2013, p. 15.
144
[72] A. Li, S. L. Song, J. Chen, X. Liu, N. Tallent, and K. Barker, “Tartan: Evaluating
modern GPU interconnect via a multi-GPU benchmark suite,” in 2018 IEEE
International Symposium on Workload Characterization (IISWC), Sep. 2018, pp.
191–202.
[74] S. Shi and X. Chu, “Performance modeling and evaluation of distributed deep
learning frameworks on GPUs,” CoRR, vol. abs/1711.05979, 2017. [Online].
Available: [Link]
[76] G. Kim, M. Lee, J. Jeong, and J. Kim, “Multi-GPU system design with mem-
ory networks,” in 2014 47th Annual IEEE/ACM International Symposium on
Microarchitecture, Dec 2014, pp. 484–495.
[79] M. Doggett, “Texture caches,” IEEE Micro, vol. 32, no. 3, pp. 136–141, May
2012.
[81] Q. H. Dang, “Secure hash standard,” NIST, Tech. Rep., Jul. 2015. [Online].
Available: [Link]
145
[83] A. Danalis, G. Marin, C. McCurdy, J. S. Meredith, P. C. Roth,
K. Spafford, V. Tipparaju, and J. S. Vetter, “The scalable heterogeneous
computing (SHOC) benchmark suite,” in Proceedings of the 3rd Workshop
on General-Purpose Computation on Graphics Processing Units, ser. GPGPU-
3. New York, NY, USA: ACM, 2010, pp. 63–74. [Online]. Available:
[Link]
[84] J. A. Stratton, C. Rodrigues, I.-J. Sung, N. Obeid, L.-W. Chang, N. Anssari,
G. D. Liu, and W. mei W. Hwu, “Parboil: A revised benchmark suite for scientific
and commercial throughput computing,” IMPACT Technical Report, 2012.
[85] V. Adhinarayanan and W. Feng, “An automated framework for characterizing
and subsetting GPGPU workloads,” in 2016 IEEE International Symposium
on Performance Analysis of Systems and Software (ISPASS), April 2016, pp.
307–317.
[86] F. N. Sibai, “3d graphics performance scaling and workload decomposition
and analysis,” in 6th IEEE/ACIS International Conference on Computer and
Information Science (ICIS 2007), July 2007, pp. 604–609.
[87] K. Asanovic, R. Bodik, B. C. Catanzaro, J. J. Gebis, P. Husbands, K. Keutzer,
D. A. Patterson, W. L. Plishker, J. Shalf, S. W. Williams et al., “The land-
scape of parallel computing research: A view from berkeley,” Technical Report
UCB/EECS-2006-183, EECS Department, University of California, Berkeley,
Tech. Rep., 2006.
[88] J. W. Cooley and J. W. Tukey, “An algorithm for the machine calculation
of complex fourier series,” Mathematics of Computation, vol. 19, no. 90, pp.
297–301, 1965. [Online]. Available: [Link]
[89] L. S. Blackford, A. Petitet, R. Pozo, K. Remington, R. C. Whaley, J. Demmel,
J. Dongarra, I. Duff, S. Hammarling, G. Henry et al., “An updated set of
basic linear algebra subprograms (BLAS),” ACM Transactions on Mathematical
Software, vol. 28, no. 2, pp. 135–151, 2002.
[90] A. Matz, M. Hummel, and H. Fröning, “Exploring LLVM infrastructure for simpli-
fied multi-GPU programming,” in Proceedings of 9th International Workshop on
Programmability and Architectures for Heterogeneous Multicores (MULTIPROG-
2016), 2016.
[91] P. Charles, C. Grothoff, V. Saraswat, C. Donawa, A. Kielstra, K. Ebcioglu,
C. Von Praun, and V. Sarkar, “X10: An object-oriented approachto non-uniform
cluster computing,” in Acm Sigplan Notices, vol. 40, no. 10. ACM, 2005, pp.
519–538.
[92] H. Kaiser, T. Heller, B. Adelstein-Lelbach, A. Serio, and D. Fey, “HPX: A task
based programming model in a global address space,” in Proceedings of the
146
8th International Conference on Partitioned Global Address Space Programming
Models. ACM, 2014, p. 6.
[93] J. Lee, M. Samadi, Y. Park, and S. Mahlke, “SKMD: Single kernel on multiple de-
vices for transparent CPU-GPU collaboration,” ACM Transactions on Computer
Systems, vol. 33, no. 3, 2015.
[95] R. Bittner, E. Ruf, and A. Forin, “Direct GPU/FPGA communication via PCI
express,” Cluster Computing, vol. 17, no. 2, pp. 339–348, 2014.
[96] J. Lee, M. Samadi, and S. Mahlke, “VAST: The illusion of a large memory
space for GPUs,” in Proceedings of the 23rd International Conference on Parallel
Architectures and Compilation, ser. PACT ’14. New York, NY, USA: ACM,
2014. [Online]. Available: [Link]
[97] W. Pugh and D. Wonnacott, “Static analysis of upper and lower bounds on
dependences and parallelism,” ACM Transactions on Programming Languages
and Systems (TOPLAS), vol. 16, no. 4, pp. 1248–1278, 1994.
[99] J. Doerfert and S. Hack, “Polyhedral value & memory analysis,” 2017, 2017 US
LLVM Developers’ Meeting.
[102] S. Moll, J. Doerfert, and S. Hack, “Input space splitting for OpenCL,” in
Proceedings of the 25th International Conference on Compiler Construction,
CC 2016, Barcelona, Spain, March 12-18, 2016, 2016. [Online]. Available:
[Link]
147
[105] T. Grosser, J. Ramanujam, L.-N. Pouchet, P. Sadayappan, and S. Pop, “Optimistic
delinearization of parametrically sized arrays,” in Proceedings of the 29th ACM
on International Conference on Supercomputing. ACM, 2015, pp. 351–360.
[108] D. S. Watkins, Fundamentals of matrix computations. John Wiley & Sons, 2004,
vol. 64.
[112] D. Comer, “Ubiquitous b-tree,” ACM Computing Surveys (CSUR), vol. 11, no. 2,
pp. 121–137, 1979.
[116] R. McNaughton and H. Yamada, “Regular expressions and state graphs for
automata,” IRE transactions on Electronic Computers, no. 1, pp. 39–47, 1960.
148
[119] D. Merrill, M. Garland, and A. Grimshaw, “Scalable GPU graph traversal,” in
ACM SIGPLAN Notices, vol. 47, no. 8. ACM, 2012.
[128] D. Foley and J. Danskin, “Ultra-performance Pascal GPU and NVLink intercon-
nect,” IEEE Micro, vol. 37, no. 2, pp. 7–17, 2017.
149
DC, USA: IEEE Computer Society, 2013, pp. 275–282. [Online]. Available:
[Link]
[135] D. Schaa and D. Kaeli, “Exploring the multiple-GPU design space,” in 2009
IEEE International Symposium on Parallel & Distributed Processing. IEEE,
2009, pp. 1–12.
[136] L. Wang, W. Wu, Z. Xu, J. Xiao, and Y. Yang, “BLASX: A high performance
level-3 BLAS library for heterogeneous multi-GPU computing,” in Proceedings
of the 2016 International Conference on Supercomputing. ACM, 2016, p. 20.
150
[141] R. Landaverde, T. Zhang, A. K. Coskun, and M. Herbordt, “An investigation of
Unified Memory Access performance in CUDA,” in 2014 IEEE High Performance
Extreme Computing Conference (HPEC). IEEE, 2014, pp. 1–6.
[142] L. Oden and H. Fröning, “GGAS: Global GPU address spaces for efficient
communication in heterogeneous clusters,” in 2013 IEEE International Conference
on Cluster Computing (CLUSTER). IEEE, 2013, pp. 1–8.
[150] J. Kim, S. Seo, J. Lee, J. Nah, G. Jo, and J. Lee, “SnuCL: An OpenCL framework
for heterogeneous CPU/GPU clusters,” in Proceedings of the 26th ACM
International Conference on Supercomputing, ser. ICS ’12. New York, NY, USA:
ACM, 2012. [Online]. Available: [Link]
151
[152] J. Lee, M. Samadi, and S. Mahlke, “Orchestrating multiple data-parallel kernels
on multiple devices,” in International Conference on Parallel Architectures and
Compilation Techniques, ser. PACT ’15, vol. 24, 2015.
[153] P. Pandit and R. Govindarajan, “Fluidic kernels: Cooperative execution
of OpenCL programs on multiple heterogeneous devices,” in Proceedings
of Annual IEEE/ACM International Symposium on Code Generation
and Optimization, ser. CGO ’14. ACM, 2014. [Online]. Available:
[Link]
[154] B. Jang, D. Schaa, P. Mistry, and D. Kaeli, “Exploiting memory access patterns to
improve memory performance in data-parallel architectures,” IEEE Transactions
on Parallel and Distributed Systems, vol. 22, no. 1, Jan. 2011.
[155] M. Wolfe, “Beyond induction variables,” in ACM SIGPLAN Notices, vol. 27,
no. 7. ACM, 1992, pp. 162–174.
[156] J. Kim, H. Kim, J. H. Lee, and J. Lee, “Achieving a single compute device image
in OpenCL for multiple GPUs,” ACM SIGPLAN Notices, vol. 46, no. 8, pp.
277–288, 2011.
[157] C. Lengauer, “Loop parallelization in the polytope model,” in International
Conference on Concurrency Theory. Springer, 1993, pp. 398–416.
[158] S. Baghdadi, A. Größlinger, and A. Cohen, “Putting automatic polyhedral
compilation for GPGPU to work,” in Proceedings of the 15th Workshop on
Compilers for Parallel Computers (CPC’10), 2010.
[159] J. Fang, H. Sips, and A. Varbanescu, “Aristotle: a performance impact indicator
for the OpenCL kernels using local memory,” Scientific Programming, vol. 22,
no. 3, Jan. 2014.
[160] N. Fauzia, L.-N. Pouchet, and P. Sadayappan, “Characterizing and enhancing
global memory data coalescing on GPUs,” in Proceedings of the 13th Annual
IEEE/ACM International Symposium on Code Generation and Optimization, ser.
CGO ’15. IEEE Computer Society, 2015.
[161] M. M. Baskaran, U. Bondhugula, S. Krishnamoorthy, J. Ramanujam, A. Rountev,
and P. Sadayappan, “Automatic data movement and computation mapping
for multi-level parallel architectures with explicitly managed memories,” in
Proceedings of the 13th ACM SIGPLAN Symposium on Principles and Practice
of Parallel Programming, ser. PPoPP ’08. New York, NY, USA: ACM, 2008.
[Online]. Available: [Link]
[162] J. M. Martinez Caamaño, M. Selva, P. Clauss, A. Baloian, and W. Wolff,
“Full runtime polyhedral optimizing loop transformations with the generation,
instantiation, and scheduling of code-bones,” Concurrency and Computation:
Practice and Experience, vol. 29, no. 15, p. e4192, 2017.
152
153