History

In C++ projects, it's a common convention to separate declarations and definitions into headers and sourcefiles, respectively. That way, the sourcefiles are compiled independently and linked so that the library or program can access all of the necessary function definitions. This has a number of advantages: it helps avoid ODR violations and ensures that complicated functions are only compiled and optimized once.

However, in the early days of CUDA, the tools did not support device linking. This meant there was no way to put a kernel's definition in a separate translation unit from the __device__ functions it used. As a result, it became a common practice for CUDA programs to put function definitions in headers with an inline attribute (to avoid ODR violations). Performance-wise, putting the function definitions in headers worked great, but it was seen as a faux-pas because of the unnecessary duplication of work to parse, compile and optimize the header's __device__ functions in multiple translation units.

Things changed with the CUDA 5.0 release. The tools added support for device linking which enabled developers to put __device__ function definitions in their own translation units and later link them with kernels. This helped improve compilation times, but it had an important downside: there was a performance penalty. The __device__ functions could be locally optimized and later linked into a kernel, but some optimizations were not possible with "separable compilation". As a result, if you adopted this pattern, the code it produced was noticeably slower than if one had used the header-defined approach. Early adopters of CUDA tended to care deeply about their application's performance, which meant that separable compilation didn't see much use at this time.

CUDA 11 improved separable compilation again by introducing device link-time optimization (dlto). This largely remedied the performance regression associated with separable compilation, but also made the device link step take longer.


So, the question is: as of 2026, should my project define functions in headers, or use separable compilation (and if so, should it do so with or without device linking)? In this document, we'll write up an example project and explore the run time and compile time implications of these different approaches.

 

Project Outline

Let's consider a simple CUDA C++ application that consists of a small class for doing vector arithmetic:

as well as one or more kernels that depend on it:

The vec4 member functions can be implemented by either:

  1. (direct) defining them in the header

  2. (separable compilation) defining them in a companion vec4.cu and using -rdc compiler flag

    Note: "relocatable device code" and "separable compilation" are different terms for the same thing

  3. (dlto) same as 2, except also using -dlto flag

     

Runtime Performance

If we time the kernel for a problem size of 1048576 elements and 64 inner iterations, we see the following:

The runtime of the direct header-based approach is practically the same as separable compilation with device link-time optimization. But, separable compilation without link time optimization is much slower.


But wait-- when separable compilation is enabled, the __device__ functions are still locally optimized. Why is the performance so much worse?

Let's investigate by profiling with NSight compute:

Right off the bat, we see an important discrepancy: the direct and dlto version use 22 registers, while the separable compilation version uses 37. Let's inspect the source view in Nsight Compute to track down where those registers are going.

cropped_direct_and_dlto

When we look at the SASS associated with a particular line of code from the direct version, we see the instructions we expect: a dot product becomes a bunch of fused multiply-adds. However, if we look at the SASS generated from the same line of CUDA for the separable compilation version we have

cropped_separable_compilation_only

No fused multiply adds here. Instead we see only IMAD, MOV and CALL.ABS.NOINC 0x0. What's going on?

CALL.ABS.NOINC indicates calling a function, and the only function associated with that line of source is the dot member function. If we look at the other vec4 member functions, we see a similar pattern: IMAD, MOV and CALL.ABS.NOINC 0x0.

What this means is that device linking from separable compilation alone (i.e. without -dlto) is not able to completely inline the device functions and optimize away the overhead associated with a function call from a separate translation unit. As a result, those function calls emit extra SASS instructions, and end up doing extra work.

However, dlto fixes this issue. In fact, if we compare the SASS code for direct and dlto, we find that they are actually identical.

Note: I'm not saying this is a guarantee that dlto always produces identical SASS.

 

Compilation Time

This project has an option to generate N kernels in separate sourcefiles that each use the vec4 class. I also include an intentionally long-to-compile member function in the vec4 class so that we can simulate a more expensive abstraction that appears in kernels. Let's take a look at the timings for the different compilation steps, as we vary the number of translation units that use the vec4 class:

KernelsVariantKernel compilationvec4_impl.cu compilationDevice linkTotal
1Direct2.27 s2.27 s
1Separable0.45 s2.10 s0.08 s2.63 s
1DLTO0.44 s1.18 s1.17 s2.79 s
2Direct4.54 s4.54 s
2Separable0.93 s2.10 s0.08 s3.11 s
2DLTO0.87 s1.21 s1.58 s3.66 s
4Direct9.11 s9.11 s
4Separable1.81 s2.12 s0.09 s4.02 s
4DLTO1.68 s1.19 s2.35 s5.22 s
8Direct18.19 s18.19 s
8Separable3.56 s2.08 s0.09 s5.73 s
8DLTO3.47 s1.17 s3.90 s8.54 s

The important trends to notice here are:

 

Summary

For historical reasons, CUDA projects used to put __device__ implementations in headers. Separable compilation's initial release improved compile times, but it also hurt runtime performance. This runtime/compiletime tradeoff soured many developers on the concept. However, as of CUDA 11.2, the dlto option gives us a 3rd option that, in many cases, is the best of both worlds.

If your project uses separable compilation, you might also try turning dlto on and off and measuring the differences in runtime and compile time. The example __device__ functions in this document were small, so the overhead associated with the non-inlined function calls is relatively high.

The sources used to generate the results shown in this document are available here.