An Introduction to GPU Programming in Julia

      No Comments on An Introduction to GPU Programming in Julia
[A-FORMATTER]
How does the GPU work

This article aims to give a quick introduction about how GPUs work and specifically give an overlook of the current Julia GPU ecosystem and how easy it is to get simple GPU programs running. To make things easier, you can run all the code samples directly in the article if you have an account and click on edit.

First of all, what is a GPU anyways?

A GPU is a massively parallel processor, with a couple of thousand parallel processing units. For example the Tesla k80, which is used in this article, offers 4992 parallel CUDA cores. GPUs are quite different from CPUs in terms of frequencies, latencies and hardware capabilities, but this is somewhat similar to a slow CPU with 4992 cores!

The sheer number of parallel threads one can launch can yield massive speed-ups, but also makes it harder to utilize the GPU. Let’s have a detailed look at the disadvantages one buys into when utilizing this raw power:

  • A GPU is a separate piece of hardware with its own memory space and different architecture. As a result, there are long transfer times from RAM to the GPUs memory (VRAM). Even launching a kernel on the GPU (in other words scheduling a function call) introduces large latencies. Times are around ~10us for GPUs, compared to a few nano seconds on a CPU
  • Setting up a kernel can quickly become complicated without a high level wrapper
  • Lower precision is the default and higher precision computations can easily void all performance gains
  • GPU functions (kernels) are inherently parallel, so writing GPU kernels is at least as difficult as writing parallel CPU code, but the difference in hardware adds quite a bit of complexity
  • Related to the above, a lot of algorithms won’t port nicely to the GPU. For more details on what to expect, have a look at this blog post
  • Kernels are usually written in a C/C++ dialect, which is not the nicest language to write down your algorithms
  • There is a divide between CUDA and OpenCL, which are the dominant frameworks used to write low-level GPU code. While CUDA only supports Nvidia hardware, OpenCL supports all hardware but is a bit rough around the edges. One needs to decide what to use, and will get pretty much stuck with that decision

The good news is Julia, a high level scripting language, allows you to write both kernel and surrounding code in Julia itself, while running on most GPU hardware!

GPUArrays

Most highly parallel algorithms need to churn through quite a bit of data to overcome all the threading and latency overheads. So most algorithms will need arrays to manage all that data, which calls for a good GPU array library as a crucial foundation.

GPUArrays.jl is that foundation in Julia. It offers an abstract array implementation tailored towards using the raw power of highly parallel hardware. It contains all the necessary functionality to set up the GPU, launch Julia GPU functions and offers some basic array algorithms.

Being abstract means that it needs a concrete implementation coming in the form of CuArrays and CLArrays. They both offer exactly the same interface, thanks to inheriting all functionality from GPUArrays. The only difference shows up when allocating an array, which forces one to decide if the array lives on a CUDA or OpenCL device. More about this in the Memory section.

GPUArrays helps to reduce code duplication, because it allows one to write hardware independent GPU kernels which can be compiled to native GPU code by either CuArrays or CLArrays. So, lots of generic kernels can be shared between all packages inheriting from GPUArrays.

To help you choose: CuArrays only works with Nvidia GPUs, while CLArrays works with most available GPUs. CuArrays is more stable than CLArrays and works on Julia 0.7 already. The speed differences are mixed with no clear winner. I would suggest to try both and see what works best.

For this article I’m going to choose CuArrays, since this article is written for Julia 0.7 / 1.0, which still isn’t supported by CLArrays.

Performance

Let’s quickly motivate why we would want to move our calculations to the GPU with a simple interactive code example calculating the julia set:

118.6s

As you can see, for large arrays one gets a solid 60-80x speed-up by moving the calculation to the GPU. Getting this speed-up was as simple as converting the Julia array to a GPUArray.

One might think that the GPU performance suffers from being written in a dynamic language like Julia, but Julia’s GPU performance should be pretty much on par with the raw performance of CUDA or OpenCL. Tim Besard did a great job at integrating the LLVM Nvidia compilation pipeline to achieve the same – or sometimes even better – performance as pure CUDA C code. Tim published a pretty detailed blog post in which he explains this further. CLArrays approach is a bit different and generates OpenCL C code directly from Julia, which has the same performance as OpenCL C!

To get a better idea of the performance and see some comparisons to multithreadded CPU code, I collected some more benchmarks.

Memory

GPUs have their own memory space with video memory (VRAM), different caches, and registers. Whatever you do, any Julia object must get transferred to the GPU before you can work with it. Not all types in Julia work on the GPU.

To give you an overview first let’s see what Julia types there are:

All those Julia types behave differently when transferred to the GPU or when created on the GPU. You can use the following table to get an overview of what to expect:

\tiny \left[ \begin{array}{ccccccc} \text{} & \text{ isbits type} & \text{immutable type} & \text{mutable type} & \text{julia array} & \text{gpu array} & \text{(device) local memory} \\ \text{location of creation} & \text{} & \text{} & \text{} & \text{} & \text{} & \text{} \\ \text{on CPU} & \text{yes, copy} & \text{no ptrs, copy} & \text{copy} & \text{no} & \text{by reference} & \text{no} \\ \text{on GPU} & \text{yes} & \text{may contain device ptr} & \text{no (on 0.7 if elimated)} & \text{no} & \text{no} & \text{yes} \\ \end{array} \right]

Location of creation describes if the object was created on the CPU and then transferred to the GPU kernel, or if it was created on the GPU inside the kernel. The table shows if it is possible to create an instance of a type, and for the transfer from CPU to GPU, the table also indicates if the object gets copied or passed by reference.

Garbage Collection

A big difference when working with the GPU is that there is no garbage collector (GC) on the GPU. This is gladly not a big issue, since the kind of high performance kernel one writes for the GPU shouldn’t create any GC-tracked memory to begin with.

Implementing a GC for the GPU is possible, but remember that every kernel executed is massively parallel. Creating and tracking lots of heap memory in every one of the ~1000 gpu threads will quickly destroy any performance gains, so it’s really just not worth the effort.

As an alternative to heap allocated arrays inside the kernel, you can use GPUArrays. The GPUArray constructor will create GPU buffers and transfer the data to VRAM. If you call Array(gpu_array) the array will get transferred back to RAM, represented as a normal Julia Array. The Julia handle to those gpu arrays is tracked by Julia’s GC and if it’s not used anymore, the GPU memory will be freed.

Consequently, one can only use stack allocation on the device, and for the rest pre-allocated GPU buffers are used. As transfers are expensive, it is common to reuse and pre-allocate as much as possible when programming the GPU.

The GPUArray Constructors
Array Operations

Lots of operations are already defined. Most importantly, GPUArrays support Julia’s fusing dot broadcasting notation. This notation allows you to apply a function to each element of an array, and create a new array out of the return values of f. This functionality is usually referred to as a map. The broadcast refers to the fact that arrays with different shapes get broadcasted to the same shape.

This is how it works:

The fusion happens because the Julia compiler will rewrite this expression into one lazy broadcast call that gets the call tree passed, which then can fuse the whole call tree into one function before looping over the array.

If you want a more throrough and interactive explanation of how broadcasting works, you can have a look at this great guide: julia.guide/broadcasting

This means any Julia function that runs without allocating heap memory (only creating isbits types), can be applied to each element of a GPUArray and multiple dot calls will get fused into one kernel call. As kernel call latency is high, this fusion is a very important optimization.

Some more operations supported by GPUArrays:

GPUArrays in the real world

Let’s jump right into some cool use cases.

This GPU accelerated smoke simulation was created with GPUArrays + CLArrays and runs on both GPU or CPU, with the GPU version being up to 15x faster:

There are many more use cases, including solving differential equations, FEM simulations, and solving PDEs.

Let’s walk through a simple Machine Learning example, to see how GPUArrays can be used:

6.0s
accuracy(tX, tY) = 0.101 accuracy(tX, tY) = 0.888 accuracy(tX, tY) = 0.919
Predicted: [6]

Just by converting the arrays to GPUArrays (with gpu(array)) we were able to move the entire computation to the GPU and get a pretty nice speed improvement. This is thanks to Julia’s sophisticated AbstractArray infrastructure, into which GPUArrays seamlessly integrates. Subsequently, if you leave out the conversion to a GPUArray, the code will also run with normal Julia arrays – but then of course on the CPU. You can try this out by changing use_gpu = true to use_gpu = false and rerun the initialization and training cells. Comparing GPU and CPU, I get 975 seconds for a CPU run and 29 seonds for the GPU – which is a nice speed-up of ~33x.

Another nice property to look at is that GPUArrays never had to implement automatic differentiation explicitly to support the backward pass of the neuronal network efficiently. This is because Julia’s automatic differentiation libraries work for arbitrary functions and emit code that can run efficiently on the GPU. This helps a lot to get Flux working on the GPU with minimal developer effort – and makes Flux GPU support work efficiently even for user defined functions. That this works out of the box without coordination between GPUArrays + Flux is a pretty unique property of Julia, which is explained in great detail in: Why Numba and Cython are no substitute for Julia

Writing GPU Kernels

One can get pretty far by just using the generic abstract array interface of GPUArrays without ever writing any GPU kernels. However, at some point one might need to implement an algorithm that needs to run on the GPU and can’t be expressed by a combination of generic array algorithms!

The nice thing is that GPUArrays takes away quite a bit of work with a layered approach that lets you start off with high level code, but allows you to pretty much write low-level kernels similarly to what one would find in most OpenCL/CUDA examples. It also allows you to execute kernels both on OpenCL or CUDA devices, abstracting away any differences in those frameworks.

The function that makes this possible is named gpu_call. It can be called as gpu_call(kernel, A::GPUArray, args) and will call kernel with the arguments (state, args...) on the GPU. State is a backend specific object to implement functionality like getting the thread index. A GPUArray needs to get passed as the second argument to dispatch to the correct backend and supply the defaults for the launch parameters.

Lets use gpu_call to implement a simple map kernel:

Let’s try to figure out what this is doing! In simple terms, this will call the julia function kernel length(A) times in parallel on the GPU. Each parallel invocation of kernel has a thread index, which we can use to safely index into the arrays A and B. If we calculated our own indices instead of using linear_index, we’d need to make sure that we don’t have multiple threads reading and writing to the same array locations. So, if we wrote this in pure Julia with threads, an equivalent version would look like this:

speedup: [1.0, 2.00136, 52.2077]

Because the function isn’t doing a lot of work, we don’t see perfect scaling, but the threaded and GPU version still give us a nice speed-up.

The GPU is a bit more complex than what the thread example allows us to show, since the hardware threads are laid out in blocks of threadsgpu_call abstracts that away in the simple version, but it can also be used with more complex launch configurations:

Threads index: Tuple{Int64,Int64}[(1, 1) (1, 2) (1, 1) (1, 2); (2, 1) (2, 2) (2, 1) (2, 2); (1, 1) (1, 2) (1, 1) (1, 2); (2, 1) (2, 2) (2, 1) (2, 2)] Block index: Tuple{Int64,Int64}[(1, 1) (1, 1) (1, 2) (1, 2); (1, 1) (1, 1) (1, 2) (1, 2); (2, 1) (2, 1) (2, 2) (2, 2); (2, 1) (2, 1) (2, 2) (2, 2)]

In the above example you can see the iteration order of a more complex launch configuration. Figuring out the right iteration + launch configuration is crucial to achieve state of the art GPU performance – but won’t be part of this simple introduction. There are plenty of GPU tutorials for CUDA and OpenCL which explain this in great detail and those principles are identical when programming the GPU in Julia.

Conclusion

Julia has come a long way to bring composable high-level programming to the high performance world. Now it’s time to do the same for the GPU.

The hope is that Julia lowers the bar for people to start programming on GPUs, and that we can grow an extendable platform for open source GPU computing. The first success story, of automatic differentiation working out of the box via Julia packages that haven’t even been written for the GPU, gives a lot of reason to believe in the success of Julia’s extendable and generic design in the domain of GPU computing.


Source: https://nextjournal.com/sdanisch/julia-gpu-programming?utm_campaign=Data_Elixir&utm_medium=email&utm_source=Data_Elixir_205

Leave a Reply

Your email address will not be published. Required fields are marked *