Kokkos: Memory & Performance

Views, memory spaces, mirrors, layouts, roofline

Lars Pastewka

Learning objectives

After this lecture, you can

  • allocate and manage data with Kokkos Views in the right memory space
  • move data between host and device with the mirror + deep_copy pattern
  • explain CPU caching and GPU coalescing, and why both reward unit-stride access along different axes
  • choose memory layouts (LayoutRight / LayoutLeft) and index orders that perform on both architectures
  • use the roofline model to decide whether a kernel is compute- or memory-bound

Kokkos Views

using Field_t = Kokkos::View<double*>;       // 1D
using Grid_t  = Kokkos::View<double**>;      // 2D
using LBM_t   = Kokkos::View<double***>;     // 3D (x, y, direction)

Field_t u("u", N);          // Allocate N doubles
Grid_t  rho("rho", Nx, Ny); // Allocate Nx × Ny doubles
  • Reference-counted (like std::shared_ptr) – assignment shares, does not copy
  • Allocations only happen when explicitly declared – no hidden allocations
  • Use Kokkos::deep_copy(dst, src) for actual data transfer

View dimensions: compile- vs run-time

View<double***>          a("a", N0, N1, N2); // 3 runtime, 0 compile
View<double**[N2]>       b("b", N0, N1);     // 2 runtime, 1 compile
View<double*[N1][N2]>    c("c", N0);         // 1 runtime, 2 compile
View<double[N0][N1][N2]> d("d");             // 0 runtime, 3 compile

Runtime-sized dimensions must come first. Use compile-time extents when sizes are known to help the compiler.

Memory spaces

Kokkos space Maps to
HostSpace CPU RAM
CudaSpace / HIPSpace GPU global memory
CudaUVMSpace / HIPManagedSpace Unified memory
ScratchMemorySpace Shared memory / LDS

Every view has a memory space set at compile time:

Kokkos::View<double*, Kokkos::CudaSpace> d_data("device", N); // GPU
Kokkos::View<double*, Kokkos::HostSpace> h_data("host", N);   // CPU

Figure 1: Host and device are separate address spaces; the only legal way to move data is an explicit deep_copy. The red crosses mark the two illegal access patterns we are about to attempt.

Anatomy of a kernel launch

When you call Kokkos::parallel_for(...):

  1. You declare views (allocations on the right memory space)
  2. You instantiate a functor / lambda that captures the views
  3. parallel_for copies the functor (the closure, ~tens of bytes) to the device, runs the kernel, and releases the device-side functor copy

The view data is not copied – views are like pointers; the lambda captures them by value (the pointer copies, the data does not).

Failed attempt 1: data on the device, populated on the host

View<double*, CudaSpace> array("array", size);

for (int i = 0; i < size; ++i)
    array(i) = read_from_file();   // ← segfault: GPU memory from CPU

double sum = 0;
Kokkos::parallel_reduce("Label",
    RangePolicy<Cuda>(0, size),
    KOKKOS_LAMBDA(const int i, double& s) { s += array(i); },
    sum);

Wrong: the host for loop touches GPU memory directly – the first red X in Figure 1 (host code dereferences d_view).

Failed attempt 2: data on the host, kernel on the device

View<double*, HostSpace> array("array", size);

for (int i = 0; i < size; ++i)
    array(i) = read_from_file();   // ✓ host populates host memory

double sum = 0;
Kokkos::parallel_reduce("Label",
    RangePolicy<Cuda>(0, size),
    KOKKOS_LAMBDA(const int i, double& s) { s += array(i); }, // ← illegal
    sum);

Wrong: the CUDA kernel cannot dereference a host pointer – the second red X in Figure 1 (device kernel dereferences h_view).

We need a way to keep both copies and move data between them on demand.

Mirroring pattern

The canonical Kokkos recipe for I/O:

// 1. Allocate the device view
using view_t = View<double*, Kokkos::CudaSpace>;
view_t view("data", size);

// 2. Create a host-side mirror
auto host_view = Kokkos::create_mirror_view(view);

// 3. Populate on the host (from file, etc.)
for (int i = 0; i < size; ++i) host_view(i) = read_from_file();

// 4. Deep copy host → device
Kokkos::deep_copy(view, host_view);

// 5. Run the kernel on the device
Kokkos::parallel_reduce("L", RangePolicy<Cuda>(0, size),
    KOKKOS_LAMBDA(const int i, double& s) { s += view(i); }, sum);

// 6. Deep copy device → host (if needed for output)
Kokkos::deep_copy(host_view, view);

create_mirror vs create_mirror_view

  • create_mirroralways allocates a new array on the host
  • create_mirror_view – allocates only if needed: if the view already lives on the host, the mirror simply references the same data

The _view form is the one you usually want – it makes the same code do nothing extra on a CPU build and the right thing on a GPU build.

Reminder: Kokkos never performs a hidden deep copy.

Execution & Memory

A processor juggles two very different “operands”:

  • Executionfast, small: the ALU/FPU computes on a register word, 8 bytes (one double), in ~1 ns.
  • Memoryslow, large: caches and DRAM only move data in fixed cache lines / transactions, 64 B (CPU) or 32–128 B (GPU), at ~100–300 ns.

CPU threads vs GPU threads

The architectures differ in whether a thread individually issues and executes its instructions.

  • CPU thread: independent
  • GPU thread (lane): 32 lanes form a warp — 32 lanes run the same instruction on different data.

CPU caching: cache lines

CPUs fetch memory in 64-byte cache lines. Access data[0]data[0]data[7] (doubles) loaded into L1.

Sequential: ~87% hit rate, effective latency ≈ L1 (few ns)

Random: ~0% hit rate, every access pays full DRAM latency (100–300 ns)

10–100× performance difference from access pattern alone.

GPU coalescing

32 threads in a warp issue loads simultaneously.

Coalesced (thread \(i\) reads data[i]):

→ 32 addresses merge into one 128-byte transaction. Full bandwidth.

Non-coalesced (thread \(i\) reads data[i * STRIDE]):

→ Up to 32 separate transactions. 32× bandwidth waste.

Rule: ensure unit-stride access within a warp.

Caching vs. coalescing – the symmetry

Figure 2: CPU: thread \(t\)’s next access at \(i+1\) → cached. GPU: thread \(t+1\)’s current access at \(i+1\) → coalesced.

Both reward unit-stride memory access – but along different axes of your data.

⇒ You usually need different memory layouts on CPU and GPU. Pure portable code (OpenMP, OpenACC) can’t help with this. Kokkos can.

Shape is just interpretation

A View’s data is a flat 1-D buffer. The shape (n×n) and layout only define a map from indices to a linear offset — they move no bytes.

  • LayoutRight: offset = \(i\cdot n + j\)
  • LayoutLeft : offset = \(i + j\cdot n\)

A(1,2) is offset 5 under Right, 7 under Left.

Layouts and memory spaces

View<T**, Layout, Space> – the layout is a template parameter.

Figure 3: LayoutRight: the rightmost index is stride-1. LayoutLeft: the leftmost index is stride-1.

Layout Stride-1 axis Default for
LayoutRight rightmost (row-major, C-style) HostSpace
LayoutLeft leftmost (column-major, Fortran-style) CudaSpace / HIPSpace

If you don’t specify a layout, Kokkos picks the right one for the memory space – that is the whole point of letting Kokkos choose.

Index mapping rule of thumb

Kokkos maps work indices differently per execution space:

  • CPU (OpenMP, Threads): contiguous chunks per thread → caching
  • GPU (Cuda, HIP): strided across threads in a warp → coalescing

Rule of thumb: write your views so the iteration index is the first index of every view access. Combined with the default layouts, this gives cached access on CPU and coalesced access on GPU – the same source code.

view(workIndex, ..., ...) = ...;   // ✓ portable
view(..., workIndex, ...) = ...;   // potentially slow on one architecture

Layout matters: empirical evidence

Figure 4: Bandwidth (GB/s) vs. number of rows N for the inner-product kernel ⟨y|Ax⟩, run on Haswell (HSW), Knights Landing (KNL) and Pascal P100. LayoutLeft is fast on the GPU; LayoutRight is fast on the CPUs. From the Sandia Kokkos tutorial (SAND2020-7475).

Compute-bound vs. memory-bound

Arithmetic intensity: \(I = \text{flops} / \text{bytes transferred}\)

\[P \leq \min(\pi,\; I \cdot \beta)\]

  • \(\pi\) = peak compute (flop/s), \(\beta\) = peak bandwidth (byte/s)
  • \(I < \pi/\beta\): memory-bound (most scientific codes!)
  • \(I > \pi/\beta\): compute-bound (dense linear algebra)

The roofline model

Ridge point for A100: \(I^* = 9.7\text{ Tflop/s} / 2.0\text{ TB/s} \approx 4.9\) flop/byte.

Most codes are memory-bound

Operation \(I\) (flop/byte)
Vector addition 0.04
Dot product 0.13
5-point stencil ~0.3
LBM collision ~0.7
Dense GEMM (\(N\)=1000) ~80

Everything except dense matrix-matrix multiply is deeply memory-bound.

⇒ Optimize memory access patterns, not arithmetic.

Summary

  • Peak flop/s is misleading – memory bandwidth is the real limit
  • Coalescing (GPU) / cache-friendliness (CPU) delivers the biggest speedups
  • Data layout (AoS vs SoA, LayoutLeft vs LayoutRight) directly impacts performance
  • Mirrors + deep_copy are how you bridge host and device cleanly
  • Profile with perf stat -e cache-misses (CPU) or bandwidth measurement (GPU)

Reading