Parallel Histogram
Parallel Histogram
Parallel patterns—parallel
histogram computation
An introduction to atomic operations
and privatization
9
CHAPTER OUTLINE
9.1 Background........................................................................................................200
9.2 Use of Atomic Operations....................................................................................202
9.3 Block versus Interleaved Partitioning...................................................................206
9.4 Latency versus Throughput of Atomic Operations..................................................207
9.5 Atomic Operation in Cache Memory......................................................................210
9.6 Privatization.......................................................................................................210
9.7 Aggregation........................................................................................................211
9.8 Summary............................................................................................................213
9.9 Exercises............................................................................................................213
Reference..................................................................................................................214
The parallel computation patterns that we have presented so far all allow the task of
computing each output element to be assigned to a thread. Therefore, these patterns
are amenable to the owner-computes rule, where every thread can write into their
designated output element(s) without concern about interference from other threads.
This chapter introduces the parallel histogram computation pattern, a frequently
encountered application computing pattern where each output element can poten-
tially be updated by all threads. As such, one must take care to coordinate among
threads as they update output elements and avoid any interference that corrupts the
final results. In practice, there are many other important parallel computation patterns
where output interference cannot be easily avoided. Therefore, the parallel histogram
computation pattern provides an example with output interference in these patterns.
We will first examine a baseline approach that uses atomic operations to serialize
the updates to each element. This baseline approach is simple but inefficient, often
resulting in disappointing execution speed. We will then present some widely used
optimization techniques, most notably privatization, to significantly enhance execu-
tion speed while preserving correctness. The cost and benefit of these techniques
depend on the underlying hardware as well as the characteristics of the input data. It
is therefore important for a developer to understand the key ideas of these techniques
in order to soundly reason about their applicability under different circumstances.
Programming Massively Parallel Processors. DOI: [Link] 199
Copyright © 2017 David B. Kirk/NVIDIA Corporation and Wen-mei W. Hwu. Published by Elsevier Inc. All rights reserved
200 CHAPTER 9 Parallel patterns—parallel histogram computation
9.1 BACKGROUND
A histogram is a display of the frequency of data items in successive numerical inter-
vals. In the most common form of histogram, the value intervals are plotted along
the horizontal axis and the frequency of data items in each interval is represented as
the height of a rectangle, or bar, rising from the horizontal axis. For example, a his-
togram can be used to show the frequency of alphabets in the phrase “programming
massively parallel processors.” For simplicity, we assume that the input phrase is in
all lowercase. By inspection, we see that there are four “a” letters, zero ‘b” letters,
one “c” letter, and so on. We define each value interval as a continuous range of four
alphabets. Thus, the first value interval is “a” through “d”, the second “e” through
“h”, and so on. Fig. 9.1 shows the histogram that displays the frequency of letters in
the phrase “programming massively parallel processors” according to our definition
of value interval.
Histograms provide useful summaries of data sets. In our example, we can see
that the phrase being represented consists of letters that are heavily concentrated in
the middle intervals of the alphabet and very light in the later intervals. Such shape
of the histogram is sometimes referred to as a feature of the data set, and provides a
quick way to determine if there are significant phenomena in the data set. For exam-
ple, the shape of a histogram of the purchase categories and locations of a credit card
account can be used to detect fraudulent usage. When the shape of the histogram
deviates significantly from the norm, the system raises a flag of potential concern.
Many other application domains rely on histograms to summarize data sets for
data analysis. One such area is computer vision. Histograms of different types of
object images, such as faces versus cars, tend to exhibit different shapes. By divid-
ing an image into subareas and analyzing the histograms for these subareas, one
can quickly identify the interesting subareas of an image that potentially contain the
objects of interest. The process of computing histograms of image subareas is the
basis of feature extraction in computer vision, where feature refers to patterns of
interest in images. In practice, whenever there is a large volume of data that needs
12
10
8
6
4
2
0
a-d e-h i-l m-p q-t u-x y-z
FIGURE 9.1
A histogram representation of “programming massively parallel processors.”
9.1 Background 201
to be analyzed to distill interesting events (i.e., “Big Data”), histograms are likely
used as a foundational computation. Credit card fraudulence detection and computer
vision obviously meet this description. Other application domains with such needs
include speech recognition, website purchase recommendations, and scientific data
analysis such as correlating heavenly object movements in astrophysics.
Histograms can be easily computed in a sequential manner, as shown in
Fig. 9.2. For simplicity, the function is only required to recognize lowercase letters.
The C code assumes that the input data set comes in a char array data[] and the
histogram will be generated into the int array histo[] (Line 1). The number of data
items is specified in function parameter length. The for loop (Line 2 through Line 4)
sequentially traverses the array, identifies the particular alphabet index into the index
variable, and increments the histo[index/4] element associated with that interval. The
calculation of the alphabet index relies on the fact that the input string is based on the
standard ASCII code representation where the alphabet characters “a” through “z”
are encoded in consecutive values according to the alphabet order.
Although one may not know the exact encoded value of each letter, one can
assume that the encoded value of a letter is the encoded value of “a” plus the alphabet
position difference between that letter and “a”. In the input, each character is stored
in its encoded value. Thus, the expression data[i] – “a” (Line 3) derives the alphabet
position of the letter with the position of “a” being 0. If the position value is greater
than or equal to 0 and less than 26, the data character is indeed a lowercase alphabet
letter (Line 4). Keep in mind that we defined the intervals such that each interval con-
tains four alphabet letters. Therefore, the interval index for the letter is its alphabet
position value divided by 4. We use the interval index to increment the appropriate
histo[] array element (Line 4).
The C code in Fig. 9.2 is quite simple and efficient. The data array elements are
accessed sequentially in the for loop so the CPU cache lines are well used whenever
they are fetched from the system DRAM. The histo[] array is so small that it fits well
in the level-one (L1) data cache of the CPU, which ensures very fast updates to the
histo[] elements. For most modern CPUs, one can expect execution speed of this
code to be memory bound, i.e., limited by the rate at which the data[] elements can
be brought from DRAM into the CPU cache.
FIGURE 9.2
A simple C function for calculating histogram for an input text string.
202 CHAPTER 9 Parallel patterns—parallel histogram computation
p r o g r a m m i n g m a s s i v e l y p a
0 1 0 3 0 0 0
a-d e-h i-l m-p q-t u-x y-z
FIGURE 9.3
Strategy I for parallelizing histogram computation.
9.2 Use of atomic operations 203
After the sequence, both customers logically conclude that they are now exclu-
sive owners of seat 9C. We can imagine that they will have an unpleasant situation
when they board the flight and find out that one of them cannot take the reserved seat!
Believe it or not, such unpleasant situations indeed happen in real life due to flaws in
airline reservation software.
For another example, some stores allow customers to wait for service without
standing in line. They ask each customer to take a number from one of the kiosks.
There is a display that shows the number that will be served next. When a service
agent becomes available, he/she asks the customer to present the ticket that matches
the number, verify the ticket, and update the display number to the next higher num-
ber. Ideally, all customers will be served in the order they enter the store. An unde-
sirable outcome would be that two customers simultaneously sign in at two kiosks
and both receive tickets with the same number. Once a service agent calls for that
number, both customers will feel that they are the one who should receive service.
In both examples, undesirable outcomes are caused by a phenomenon called race
condition, where the outcome of two or more simultaneous update operations var-
ies depending on the relative timing of the operations involved. Some outcomes are
correct and some are incorrect. Fig. 9.4 illustrates a race condition when two threads
attempt to update the same histo[] element in our text histogram example. Each row in
Fig. 9.4 shows the activity during a time period, with time progressing from top to bottom.
Fig. 9.4(A) depicts a scenario where Thread 1 completes all three parts of its
read-modify-write sequence during time periods 1 through 3 before Thread 2 starts
its sequence at time period 4. The value in the parenthesis in front of each operation
shows the value being written into the destination, assuming the value of histo[x] was
initially 0. With this interleaving, the value of histo[x] afterwards is 2, exactly as one
would expect. That is, both threads successfully incremented the histo[x] element.
The element value starts with 0 and ends at 2 after the operations complete.
In Fig. 9.4(B), the read-modify-write sequences of the two threads overlap. Note
that Thread 1 writes the new value into histo[x] at time period 4. When Thread 2 reads
histo[x] at time period 3, it still has the value 0. As a result, the new value it calculates
and eventually writes to histo[x] is 1 rather than 2. The problem is that Thread 2 read
histo[x] too early, before Thread 1 completes its update. The net outcome is that the
value of histo[x] afterwards is 1, which is incorrect. The update by Thread 1 is lost.
During parallel execution, threads can run in any order relative to each other.
In our example, Thread 2 can easily start its update sequence ahead of Thread 1.
FIGURE 9.4
Race condition in updating a histo[] array element.
204 CHAPTER 9 Parallel patterns—parallel histogram computation
Fig. 9.5 shows two such scenarios. In Fig. 9.5(A), Thread 2 completes its update
before Thread 1 starts its. In Fig. 9.5(B), Thread 1 starts its update before Thread
2 completes its. It should be obvious that the sequences in 9.5(A) result in correct
outcome for histo[x] but those in 9.5(B) produce incorrect outcome.
The fact that the final value of histo[x] varies depending on the relative timing
of the operations involved indicates that there is a race condition. We can eliminate
such variations by preventing the interleaving of operation sequences of Thread 1
and Thread 2. That is, we would like to allow the timings shown in Figs. 9.4(A) and
9.5(A) while eliminating the possibilities shown in Figs. 9.4(B) and 9.5(B). Such
timing constraints can be enforced with the use of atomic operations.
An atomic operation on a memory location is an operation that performs a read-mod-
ify-write sequence on the memory location in such a way that no other read-modify-write
sequence to the location can overlap with it. That is, the read, modify, and write parts
of the operation form an indivisible unit, hence the name atomic operation. In practice,
atomic operations are realized with hardware support to lock out other threads from oper-
ating on the same location until the current operation is complete. In our example, such
support eliminates the possibilities depicted in Figs. 9.4(B) and 9.5(B) since the trailing
thread cannot start its update sequence until the leading thread completes its update.
It is important to remember that atomic operations do not force particular thread
execution orderings. In our example, both orders shown in Fig. 9.4(A) and 9.5(B) are
allowed by atomic operations. Thread 1 can run either ahead of or behind Thread 2.
The rule being enforced is that if any one of the two threads begins an atomic opera-
tions to the same memory location, the trailing thread cannot perform any operations
to the memory location until the leading thread completes its atomic operation. This
effectively serializes the atomic operations being performed on a memory location.
Atomic operations are usually named according to the modification performed
on the memory location. In our text histogram example, we are adding a value to the
memory location so the atomic operation is called atomic add. Other types of atomic
operations include subtraction, increment, decrement, minimum, maximum, logical
and, logical or, etc.
A CUDA program can perform an atomic add operation on a memory location
through a function call:
int atomicAdd(int* address, int val);
FIGURE 9.5
Race condition scenarios where Thread 2 runs ahead of Thread 1.
9.2 Use of atomic operations 205
INTRINSIC FUNCTIONS
Modern processors often offer special instructions that either perform criti-
cal functionality (such as the atomic operations) or substantial performance
enhancement (such as vector instructions). These instructions are typically
exposed to the programmers as intrinsic functions, or simply instrinsics. From
the programmer’s perspective, these are library functions. However, they are
treated in a special way by compilers; each such call is translated into the cor-
responding special instruction. There is typically no function call in the final
code, just the special instructions in line with the user code. All major modern
compilers, such as Gnu C Compiler (gcc), Intel C Compiler and LLVM C
Compiler support intrinsics.
The function is an intrinsic function that will be compiled into a hardware atomic
operation instruction which reads the 32-bit word pointed to by the address argument
in global or shared memory, adds val to the old content, and stores the result back to
memory at the same address. The function returns the old value of the address.
Fig. 9.6 shows a CUDA kernel that performs parallel histogram computation based
on Strategy I. Line 1 calculates a global thread index for each thread. Line 2 divides
the total amount of data in the buffer by the total number of threads to determine the
number of characters to be processed by each thread. The ceiling formula, introduced
in Chapter 2, Data Parallel Computing, is used to ensure that all contents of the input
buffer are processed. Note that the last few threads will likely process a section that is
only partially filled. For example, if we have 1000 characters in the input buffer and
256 threads, we would assign sections of (1000 − 1)/256 + 1 = 4 elements to each of
the first 250 threads. The last 6 threads will process empty sections.
Line 3 calculates the starting point of the section to be processed by each thread
using the global thread index calculated in Line 1. In the example above, the starting
point of the section to be processed by thread i would be i*4 since each section con-
sists of 4 elements. That is, the starting point of thread 0 is 0, thread 8 is 32, and so on.
The for loop starting in line 4 is very similar to the one we have in Fig. 9.2. This
is because each thread essentially executes the sequential histogram computation on
its assigned section. There are two noteworthy differences. First, the calculation of
the alphabet position is guarded by an if-condition. This test ensures that only the
threads whose index into the buffer is within bounds will access the buffer. It is to
prevent the threads that receive partially filled or empty sections from making out-
of-bound memory accesses.
Finally, the increment expression (histo[alphabet_position/4]++) in Fig. 9.2
becomes an atomicAdd() function call in Line 6 of Fig. 9.6. The address of the loca-
tion to be updated, &(histo[alphabet_position/4]), is the first argument. The value
to be added to the location, 1, is the second argument. This ensures that any simultane-
ous updates to any histo[] array element by different threads are properly serialized.
206 CHAPTER 9 Parallel patterns—parallel histogram computation
__global__ void histo_kernel(unsigned char *buffer, long size, unsigned int *histo)
{
1. int i = threadIdx.x + blockIdx.x * blockDim.x;
2. int section_size = (size-1) / (blockDim.x * gridDim.x) +1;
3. int start = i*section_size;
FIGURE 9.6
A CUDA kernel for calculation histogram based on Strategy I.
p r o g r a mm i n g m a s s i v e l y p a p r o g r a mm i n g m a s s i v e l y p a
0 1 0 2 1 0 0 1 1 0 4 2 0 0
a-d e-h i-l m-p q-t u-x y-z a-d e-h i-l m-p q-t u-x y-z
FIGURE 9.7
Desirable access pattern to the input buffer for memory coalescing—Strategy II.
an interleaved partitioning strategy where each thread will process elements that are
separated by the elements processed by all threads during one iteration. In Fig. 9.7,
the partition to be processed by thread 0 would be elements 0 (“p”), 4 (“r”), 8 (“i”),
12 (“m”), 16 (“i”), and 20 (“y”). Thread 1 would process elements 1 (“r”), 5 (“a”), 9
(“n”), and 13 (“a”), 17 (“v”), and 21 (“_”). It should be clear why this is called inter-
leaved partitioning: the partition to be processed by different threads are interleaved
with each other.
Fig. 9.8 shows a revised kernel based on Strategy II. It implements interleaved
portioning in Line 2 by calculating a stride value, which is the total number threads
launched during kernel invocation (blockDim.x*gridDim.x). In the first iteration of
the while loop, each thread index the input buffer using its global thread index: Thread
0 accesses element 0, Thread 1 accesses element 1, Thread 2 accesses element 2, etc.
Thus, all threads jointly process the first blockDim.x*gridDim.x elements of the
input buffer. In the second iteration, all threads add blockDim.x*gridDim.x to their
indices and jointly process the next section of blockDim.x*gridDim.x elements.
The for- loop controls the iterations for each thread. When the index of a thread
exceeds the valid range of the input buffer (i is greater than or equal to size), the
thread has completed processing its partition and will exit the loop. Since the size of
the buffer may not be a multiple of the total number of threads, some of the threads
may not participate in the processing of the last section. So some threads will execute
one fewer for- loop iteration than others.
Thanks to the coalesced memory accesses, the version in Fig. 9.8 will likely exe-
cute several times faster than that in Fig. 9.6. However, there is still plenty of room
for improvement, as we will show in the rest of this chapter. It is interesting that the
code in Fig. 9.8 is actually simpler even though interleaved partitioning is concep-
tually more complicated than block partitioning. This is often true in performance
optimization. While an optimization may be conceptually complicated, its imple-
mentation can be quite simple.
__global__ void histo_kernel(unsigned char *buffer, long size, unsigned int *histo)
{
FIGURE 9.8
A CUDA kernel for calculating histogram based on Strategy II.
Time
DRAM load delay DRAM store delay DRAM load delay DRAM store delay
Transfer delay
Atomic operation N Atomic operation N+1
FIGURE 9.9
Throughput of atomic operation is determined by the memory access latency.
per clock per channel)*1G (clocks per second)*8 (Channels) = 128 GB/second.
Assuming each data accessed is 4 bytes, the system has a peak access throughput of
32G data elements per second.
However, when performing atomic operations on a particular memory location,
the highest throughput one can achieve is one atomic operation every 400 cycles (200
cycles for the read and 200 cycles for the write). This translates into a time-based
throughput of 1/400 atomics/clock*1G (clocks/second) = 2.5 M atomics/second.
This is dramatically lower than most users expect from a GPU memory system.
In practice, not all atomic operations will be performed on a single memory loca-
tion. In our text histogram example, the histogram has 7 intervals. If the input charac-
ters are uniformly distributed in the alphabet, the atomic operations evenly distributed
among the histo[] elements. This would boost the throughput to 7*2.5 M = 17.5 M
atomic operations per second. In reality, the boost factor tends to be much lower than
the number of intervals in the histogram because the characters tend to have biased
distribution in the alphabet. For example, in Fig. 9.1, we see that characters in the
example phrase are heavily biased towards the m-p and q-t intervals. The heavy con-
tention traffic to update these intervals will likely reduce the achievable throughput
to much less than 17.5 M atomic operations per second.
For the kernels of Figs. 9.6 and 9.8, low throughput of atomic operations will
have significant negative impact on the execution speed. To put things into perspec-
tive, assume for simplicity that the achieved throughput of the atomic operations is
17.5 M atomic operations per second. We see that the kernel in Fig. 9.8 performs
approximately six arithmetic operations (−, > =, <, /, +, +) with each atomic
operation. Thus the maximal arithmetic execution throughput of the kernel will be
6*17.5 M = 105 M arithmetic operations per second. This is only a tiny fraction of
the typical peak throughput of 1,000,000 M or more arithmetic operations per second
on modern GPUs! This type of insight has motivated several categories of optimiza-
tions to improve the speed of parallel histogram computation as well as other types
of computation using atomic operations.
210 CHAPTER 9 Parallel patterns—parallel histogram computation
9.6 PRIVATIZATION
The latency for accessing memory can be dramatically reduced by placing data
in the shared memory. Shared memory is private to each SM and has very short
access latency (a few cycles). Recall that this reduced latency directly translates into
increase throughput of atomic operations. The problem is that due to the private
nature of shared memory, the updates by threads in one thread block are no longer
visible to threads in other blocks. The programmer must explicitly deal with this lack
of visibility of histogram updates across thread blocks.
In general, a technique referred to as privatization is commonly used to address
the output interference problem in parallel computing. The idea is to replicate highly
contended output data structures into private copies so that each thread (or each sub-
set of threads) can update its private copy. The benefit is that the private copies can be
accessed with much less contention and often at much lower latency. These private
copies can dramatically increase the throughput for updating the data structures. The
downside is that the private copies need to be merged into the original data structure
after the computation completes. One must carefully balance between the level of
contention and the merging cost. Therefore, in massively parallel systems, privatiza-
tion is typically done for subsets of threads rather than individual threads.
In our text histogram example, we can create a private histogram for each thread
block. Under this scheme, a few hundred threads would work on a copy of the his-
togram stored in short-latency shared memory, as opposed to tens of thousands of
threads pounding on a histogram stored in medium latency second level cache or
long latency DRAM. The combined effect of fewer contending threads and shorter
access latency can result in orders of magnitude of increase in update throughput.
Rule of thumb: 9.7 Aggregation 211
When working on shared memory inside one block, use stride = blockDim.x.
When working on global memory across all blocks, use stride = blockDim.x * gridDim.x.
// Privatized bins
2. extern __shared__ unsigned int histo_s[];
3. for(unsigned int binIdx = threadIdx.x; binIdx < num_bins; binIdx +=blockDim.x) {
4. histo_s[binIdx] = 0u;
}
5. __syncthreads();
// Histogram
6. For (unsigned int i = tid; i < num_elements; i += blockDim.x*gridDim.x) {
int alphabet_position = buffer[i] – “a”;
7. if (alphabet_position >= 0 && alpha_position < 26) atomicAdd(&(histo_s[alphabet_position/4]), 1);
}
8. __syncthreads();
FIGURE 9.10
A privatized text histogram kernel.
Fig. 9.10 shows a privatized histogram kernel. Line 2 allocates a shared memory
array histo_s[] whose dimension is set during kernel launch. In the for - loop at Line
3, all threads in the thread block cooperatively initialize all the bins of their private copy
of the histogram. The barrier synchronization in Line 5 ensures that all bins of the pri-
vate histogram have been properly initialized before any thread starts to update them.
The for loop at Lines 6–7 is identical to that in Fig. 9.8, except that the atomic
operation is performed on the shared memory histo_s[]. The barrier synchroniza-
tion in Line 8 ensures that all threads in the thread block complete their updates
before merging the private copy into the original histogram.
Finally, the for loop at Lines 9–10 cooperatively merges the private histogram
values into the original version. Note that atomic add operation is used to update
the original histogram elements. This is because multiple thread blocks can simul-
taneously update the same histogram elements and must be properly serialized with
atomic operations. Note that both for loops in Fig. 9.10 are written so that the kernel
can handle histograms of arbitrary number of bins.
9.7 AGGREGATION
Some data sets have a large concentration of identical data values in localized areas.
For example, in pictures of the sky, there can be large patches of pixels of identi-
cal value. Such high concentration of identical values causes heavy contention and
reduced throughput of parallel histogram computation.
212 CHAPTER 9 Parallel patterns—parallel histogram computation
For such data sets, a simple and yet effective optimization is for each thread to
aggregate consecutive updates into a single update if they are updating the same
element of the histogram [Merrill 2015]. Such aggregation reduces the number of
atomic operations to the highly contended histogram elements, thus improving the
effective throughput of the computation.
Fig. 9.11 shows an aggregated text histogram kernel. Each thread declares three
additional register variables curr_index, prev_index and accumulator. The accu-
mulator keeps track of the number of updates aggregated thus far and prev_index
tracks the index of the histogram element whose updates has been aggregated. Each
// Privatized bins
2. extern __shared__ unsigned int histo_s[];
3. for(unsigned int binIdx = threadIdx.x; binIdx < num_bins; binIdx +=blockDim.x) {
4. histo_s[binIdx] = 0u;
}
5. __syncthreads();
FIGURE 9.11
An aggregated text histogram kernel.
9.9 Exercises 213
thread initializes the prev_index to −1 (Line 6) so that no alphabet input will match
it. The accumulator is initialized to zero (Line 7), indicating that no updates have been
aggregated.
When an alphabet data is found, the thread compares the index of the histogram
element to be updated (curr_index) with the index of the one currently being aggre-
gated (prev_index). If the index is different, the streak of aggregated updates to the
histogram element has ended (Line 12). The thread uses atomic operation to add the
accumulator value to the histogram element whose index is tracked by prev_index.
This effectively flushes out the total contribution of the previous streak of aggregated
updates. If the curr_index matches the prev_index, the thread simply adds one to
the accumulator (Line 17), extending the streak of aggregated updates by one.
One thing to keep in mind is that the aggregated kernel requires more statements and
variables. Thus, if the contention rate is low, an aggregated kernel may execute at lower
speed than the simple kernel. However, if the data distribution leads to heavy contention
in atomic operation execution, aggregation results in significant performance gains.
9.8 SUMMARY
Histogramming is a very important computation for analyzing large data sets. It also
represents an important class of parallel computation patterns where the output location
of each thread is data-dependent, which makes it infeasible to apply owner-computes
rule. It is therefore a natural vehicle for introducing the practical use of atomic opera-
tions that ensure the integrity of read-modify-write operations to the same memory
location by multiple threads. Unfortunately, as we explained in this chapter, atomic
operations have much lower throughput than simpler memory read or write operations
because their throughput is approximately the inverse of two times the memory latency.
Thus, in the presence of heavy contention, histogram computation can have surpris-
ingly low computation throughput. Privatization is introduced as an important optimi-
zation technique that systematically reduces contention and enables the use of local
memory such as the shared memory, which supports low latency and thus improved
throughput. In fact, supporting very fast atomic operations among threads in a block is
an essential use case of the shared memory. For data sets that cause heavy contention,
aggregation can also lead to significantly higher execution speed.
9.9 EXERCISES
1. Assume that each atomic operation in a DRAM system has a total latency of
100 ns. What is the maximal throughput we can get for atomic operations on
the same global memory variable?
a. 100 G atomic operations per second
b. 1 G atomic operations per second
214 CHAPTER 9 Parallel patterns—parallel histogram computation
REFERENCE
Merrill, D. (2015). Using compression to improve the performance response of parallel histo-
gram computation, NVIDIA Research Technical Report.