Introduction

In a previous document, we showed how to write a simple "hello world" program in CUDA. The CPU and GPU code looked almost identical: both called printf to write our "hello world" message out to the terminal.

However, if you try to extend this program to do something marginally more interesting like

you'll run into a compilation error to the tune of

The CPU code is clearly well-formed, but it looks like something isn't right with the GPU code.

 

Important
Why was okay to call printf from inside a kernel, but calling important_calculation fails?

 

Function Annotations

Well, if we want to be able to invoke a function inside a kernel, it must be marked with the __device__ annotation. Since the important_calculation function has no such annotations, we're not allowed to use it in our kernel.

Tip
It turns out we were able to use printf in the kernel because it's such a ubiquitous function that the CUDA toolkit provides a __device__ version for us to make life easier.

With that in mind, let's go back and change our code from earlier to insert a __device__ annotation. This should make our program compile, right?

Unforunately, it fixed the error message inside the kernel, but now the compiler is unhappy with the CPU code. It tells us that __host__ functions can't call __device__ functions, but what is a host function?

__host__ just means it can run on the CPU. By default, "regular" C++ functions are implicitly __host__. However, since we explicitly marked important_calculation as __device__ only, that function as written is not allowed to be used on the CPU side of things.

The fix is simple, since our intention is to use the function in both __host__ and __device__ contexts, we mark it as __host__ __device__.

But Why?

Important
Why do I have to mark functions as __host__ or __device__, why can't the compiler just figure everything out for me?

The main reason is that kernel code can do things that don't make sense on the CPU, and CPU code can do things that don't make sense on the GPU. For example:

One could argue that the compiler could analyze the body of a function to determine whether or not it is suitable to run on CPU or GPU, although this would be challenging. Another important reason is that this aspect of the language dates back to CUDA v1.0. Back then, the hardware and compiler toolchains were much simpler than they were today, so it was likely not practical to try and automatically infer if a function was suitable for CPU/GPU execution, so that decision was left up to the developer.


Software Development Implications

Important
My project uses all sorts of C++ libraries, how do I use them in my CUDA kernels?

Many regular C++ libraries cannot be used inside CUDA kernels. This includes most of the C++ standard library, Abseil, Boost, etc. Their source code is not appropriately annotated with __device__ and it's not just a simple matter of sprinkling some annotations here and there. Their underlying implementations make use of features not available in kernels (e.g. frequent dynamic memory allocation) so there is no easy way to port those libraries to work in a kernel.

However, all is not lost. At the time of writing, CUDA has been around for two decades and has no shortage of libraries and tools available. NVIDIA provides many useful libraries (most of which are bundled in the CUDA toolkit)

And there is also an extensive community of open source libraries.

So, there is no shortage of available libraries for CUDA, but projects do require some refactoring to adopt them.

 


 

Anyway, now that we have all that in mind, let's look at some examples of how we can annotate the different kinds of functions to make them usable from inside a kernel.

Examples

Free Functions

Member Functions / Functors

Lambda Functions

Important
If you define a lambda on the host, and use it inside a kernel it is called an "extended" lambda and you must include the "--extended-lambda" flag when compiling.

Virtual Functions

Virtual functions are problematic inside kernels.

Take a look the following code, and guess what happens when we run the program.


When we check for error codes after launching my_kernel, we get

CUDA error: an illegal memory access was encountered

so something is not right, but what is going on?

First of all, f lives in CPU memory, so trying to dereference a host pointer from a kernel leads to an invalid memory access. Okay, so let's actually make a copy of the object in device memory and pass that to the kernel:

Nope, that didn't fix it, we still get an error when invoking my_kernel. This time the error code is:

CUDA error: misaligned address

Debugging with compute-sanitizer (more info on this tool later) reveals more info about what is going wrong, saying that this line of the kernel

has an Invalid __global__ read of size 8 bytes. However, all 3 variables (out, in and f) are in device memory and accessed properly, so what is the problem?

It turns out the vtable is the issue. We made sure that the object itself and the operands are available in GPU memory, but that's not enough. Dynamic dispatch uses another pointer to track the virtual functions of a base class, but that vtable still lives on the host, not the device. So, when the program tried to look up the appropriate function pointer for the derived class' virtual function, it triggers an invalid memory access!

In reality, virtual functions inside kernels are both dangerous and detrimental to performance. As a result, the CUDA programming guide makes the following, unambiguous statement:

"It is not allowed to pass as an argument to a __global__ function an object of a class derived from virtual base classes."

So: think twice before porting virtual function-based interfaces to the GPU!

Tip

Although using virtual functions inside kernels is problematic, using virtual functions for higher level abstractions (e.g. to decide which kernels to launch) is still a useful pattern!

Constexpr Functions

constexpr functions already satisfy many of the same requirements as __device__ functions:

As a result, nvcc has a flag to make constexpr functions implicitly work inside kernels:

Summary