NUMA-aware SAXPY
This example demonstrates how to define and run a SAXPY kernel (single-precision Y[i] = a * X[i] + Y[i]) such that it runs efficiently on a system with multiple memory domains (NUMA) with the multithreaded CPU backend. (You likely will need to fine-tune the value of N on your system of interest if you care about the particular measurement.)
# EXCLUDE FROM TESTING# pin Julia's threads before the first launch:# julia -t 128,0 -e 'using ThreadPinning; pinthreads(:numa); include("examples/numa_aware.jl")'using BenchmarkToolsusing KernelAbstractions@kernel function saxpy_kernel(a, @Const(X), Y) I = @index(Global) @inbounds Y[I] = a * X[I] + Y[I]end@kernel function fill_kernel(A, x) I = @index(Global) @inbounds A[I] = xend""" measure_membw(; kwargs...) -> membw, flopsEstimate the memory bandwidth (GB/s) by performing a time measurement of aSAXPY kernel. Returns the memory bandwidth (GB/s) and the compute (GFLOP/s)."""function measure_membw( backend = CPU(); verbose = true, N = 1024 * 500_000, dtype = Float32, init = :parallel, ) bytes = 3 * sizeof(dtype) * N # num bytes transferred in SAXPY flops = 2 * N # num flops in SAXY workgroup_size = 1024 a = dtype(3.1415) X = KernelAbstractions.allocate(backend, dtype, N) Y = KernelAbstractions.allocate(backend, dtype, N) if init == :serial # The calling thread touches all the memory first fill!(X, dtype(1)) fill!(Y, dtype(2)) else # The threads that run the kernels touch the memory first fill_kernel(backend, workgroup_size)(X, dtype(1), ndrange = size(X)) fill_kernel(backend, workgroup_size)(Y, dtype(2), ndrange = size(Y)) KernelAbstractions.synchronize(backend) end t = @belapsed begin kernel = saxpy_kernel($backend, $workgroup_size, $(size(Y))) kernel($a, $X, $Y, ndrange = $(size(Y))) KernelAbstractions.synchronize($backend) end evals = 2 samples = 10 mem_rate = bytes * 1.0e-9 / t # GB/s flop_rate = flops * 1.0e-9 / t # GFLOP/s if verbose println("\tMemory Bandwidth (GB/s): ", round(mem_rate; digits = 2)) println("\tCompute (GFLOP/s): ", round(flop_rate; digits = 2)) end return mem_rate, flop_rateendmeasure_membw(CPU());# On a system with multiple NUMA domains, this places all the memory in the domain of the calling thread# measure_membw(CPU(); init=:serial);Important remarks:
The CPU backend runs kernels on Julia's threads (see CPU). For NUMA locality:
- Pin Julia's threads before the first launch with ThreadPinning.jl.
pinthreads(:numa)spreads threads across NUMA domains;pinthreads(:cores)follows the machine's topology. Both pin only the default pool. Start Julia with-t N,0or passthreadpool = :allto pin small launches on the launching thread too. Without pinning, threads can migrate away from the memory they first touched. - Choose the number of threads with
julia -t Nor theJULIA_KA_CPU_THREADSenvironment variable; seeCPU. - Initialize data with a parallel kernel. Under the NUMA first-touch policy, a memory page belongs to the domain of the thread that first writes it.
fill!places pages in the calling thread's domain. The example usesKernelAbstractions.allocatefollowed by an initialization kernel (init = :parallel). Launches with as many workgroups start each block of them on the same thread, asThreads.@threads :staticdoes, so later kernels mostly work on the data their thread touched first.KernelAbstractions.zeros(backend, dtype, N)andKernelAbstractions.onesalso use a kernel for arrays of bits types of at least 1 MiB; other arrays are filled on the calling thread.
Demonstration:
On a system with two AMD EPYC 7502 (2 × 32 cores, 2 NUMA domains), the example (N = 512_000_000, workgroups of 1024), started with -t T,0 for each thread count T, gets the following memory bandwidth, in GB/s:
| threads | initialization | pinthreads(:numa) | pinthreads(:cores) |
|---|---|---|---|
| 64 | init = :parallel | 243 | 243 |
| 64 | zeros and ones | 241 | 242 |
| 32 | init = :parallel | 158 |
A plain Threads.@threads :static loop with its threads pinned by ThreadPinning.jl gets 240 GB/s with 64 threads. Initializing serially puts all the memory in one domain. Without pinning, the threads that touch the memory first aren't reliably the ones that work on it later, and the numbers vary from run to run with where the operating system places the threads.
On a system with a single memory domain, pinning and the initialization don't change where memory is placed: on an AMD Ryzen 9 9950X with 8 or 16 threads, all configurations get 53–55 GB/s.