This page was generated from unit-5.6-gpu/commonGPU.ipynb.
5.6 Common GPU programming from Python¶
One kernel source, written in a small macro vocabulary, compiled at run-time for whichever backend is loaded: CUDA, Metal, or a host reference backend that is always available and compiles the kernel with the system C++ compiler.
Getting started¶
ngsolve.gpu picks up whichever backend this build has - Metal, CUDA, or the host reference device if neither is there - so GetGPUDevice() never returns None.
[1]:
import numpy as np
from ngsolve.gpu import *
dev = GetGPUDevice()
print(dev, " backend =", backend, " fp64 =", dev.has_float64,
" max group =", dev.max_threads_per_group)
GPUDevice CPU reference backend = host fp64 = True max group = 1024
A kernel. KERNEL declares the entry point, GLOBAL_IN/GLOBAL are device buffers, VALUE a scalar passed by value, and GLOBAL_ID_X is the work-item index. Prepend GPUKernelPrelude, which defines these for the current backend.
[2]:
src = '''
KERNEL(saxpy, GLOBAL_IN(float,x), GLOBAL(float,y), VALUE(float,a), VALUE(int,n))
{
int i = GLOBAL_ID_X;
if (i < n) y[i] = a*x[i] + y[i];
}
'''
lib = dev.CompileSource(GPUKernelPrelude + src)
q = dev.DefaultQueue()
NewBuffer allocates device memory, H2D uploads a numpy array, D2H(n) reads values back. Buffers and scalars are single precision here: H2D/D2H use float32, and a Python float argument arrives as float, an int as int.
The launch geometry is as in CUDA - groups threadgroups of groupsize work items. Launches are asynchronous, Finish() waits.
[3]:
n, tg = 1000, 256
ngroups = (n+tg-1)//tg
hx = np.arange(n, dtype=np.float32)
x, y = dev.NewBuffer(4*n), dev.NewBuffer(4*n)
x.H2D(hx)
y.H2D(np.ones(n, dtype=np.float32))
q.Launch(lib.GetKernel("saxpy"), groups=[ngroups], groupsize=[tg], args=[x, y, 2.0, n])
q.Finish()
print("device :", y.D2H(5))
print("numpy :", 2*hx[:5]+1)
device : [1. 3. 5. 7. 9.]
numpy : [1. 3. 5. 7. 9.]
The point of the common syntax: the same source runs on the host reference backend, so a GPU result can be checked against a reference that shares no code with it.
[4]:
def run(device):
lib = device.CompileSource(GPUKernelPrelude + src)
q = device.DefaultQueue()
xb, yb = device.NewBuffer(4*n), device.NewBuffer(4*n)
xb.H2D(hx)
yb.H2D(np.ones(n, dtype=np.float32))
q.Launch(lib.GetKernel("saxpy"), groups=[ngroups], groupsize=[tg], args=[xb, yb, 2.0, n])
q.Finish()
return yb.D2H(n)
on_device = run(dev)
on_host = run(GetCPUDevice())
print("max difference :", np.abs(on_device-on_host).max())
max difference : 0.0
Reference: the kernel syntax¶
GPUKernelPrelude defines one vocabulary that expands to CUDA, Metal or plain C++.
Entry point and arguments. Arguments are positional: the i-th declared argument is the i-th entry of the args list of Launch.
|
declares an entry point |
|
device buffer, read and write |
|
device buffer, read only |
|
device buffer written with |
|
scalar passed by value |
Indices. All exist as _X, _Y and _Z.
|
index of this work item in the whole grid |
|
index inside its threadgroup |
|
index of the threadgroup |
|
work items per threadgroup |
|
number of threadgroups |
Group-shared memory and synchronisation.
|
array shared by the threadgroup |
|
two-dimensional variant |
|
wait until the whole group arrives |
|
atomic accumulate into a |
Types for helper functions.
|
qualifier for a function called from a kernel |
|
pointer into global / group-shared memory |
|
pointer into a |
Note that uint and float4 are not portable spellings - use unsigned, and index buffers element by element.
A kernel using group-shared memory and a barrier: each group reduces its slice of the input to one partial sum.
[5]:
reduce_src = '''
KERNEL(blocksum, GLOBAL_IN(float,x), GLOBAL(float,out), VALUE(int,n))
{
SHARED(float, s, 256);
int lid = LOCAL_ID_X;
int gid = GLOBAL_ID_X;
s[lid] = (gid < n) ? x[gid] : 0.0f;
BARRIER();
for (int stride = 128; stride > 0; stride >>= 1)
{ if (lid < stride) s[lid] += s[lid+stride]; BARRIER(); }
if (lid == 0) out[GROUP_ID_X] = s[0];
}
'''
libr = dev.CompileSource(GPUKernelPrelude + reduce_src)
out = dev.NewBuffer(4*ngroups)
q.Launch(libr.GetKernel("blocksum"), groups=[ngroups], groupsize=[tg], args=[x, out, n])
q.Finish()
partial = out.D2H(ngroups)
print("partial sums :", partial)
print("device total :", partial.sum(), " numpy :", hx.sum())
partial sums : [ 32640. 98176. 163712. 204972.]
device total : 499500.0 numpy : 499500.0
And one where many work items accumulate into the same locations, so the target is declared GLOBAL_ATOMIC and written with ATOMIC_ADD.
[6]:
hist_src = '''
KERNEL(histogram, GLOBAL_IN(float,x), GLOBAL_ATOMIC(float,bins),
VALUE(int,n), VALUE(int,nbins))
{
int i = GLOBAL_ID_X;
if (i < n) ATOMIC_ADD(&bins[i % nbins], x[i]);
}
'''
nbins = 4
libh = dev.CompileSource(GPUKernelPrelude + hist_src)
bins = dev.NewBuffer(4*nbins)
bins.H2D(np.zeros(nbins, dtype=np.float32))
q.Launch(libh.GetKernel("histogram"), groups=[ngroups], groupsize=[tg],
args=[x, bins, n, nbins])
q.Finish()
print("device :", bins.D2H(nbins))
print("numpy :", np.array([hx[i::nbins].sum() for i in range(nbins)]))
device : [124500. 124750. 125000. 125250.]
numpy : [124500. 124750. 125000. 125250.]
Buffers¶
MemType.Shared is addressable from host and device, MemType.Device lives only on the accelerator. On unified memory shared costs nothing; on a discrete card a device-only buffer avoids keeping a host copy. D2H works for both.
[7]:
for mt in [MemType.Shared, MemType.Device]:
b = dev.NewBuffer(4*n, mt)
print(f"{str(mt):18s} host_visible = {b.host_visible}")
MemType.Shared host_visible = True
MemType.Device host_visible = True
Using tinybla¶
tinybla is a small linear algebra library that lives inside the kernel: fixed size vectors and matrices in registers, views on buffers, and tile products across a threadgroup. Prepend TinyBlaPrelude after GPUKernelPrelude and open the namespace.
Per work item. Vec<S,T> and Mat<H,W,T> are held in registers, one per work item.
|
fixed size, |
|
with a scalar, a vector or a matrix |
|
|
|
reinterpret between the two |
|
sub-vectors |
Views on memory. BareMatrix is a pointer plus a leading dimension - no storage of its own - over a buffer or a SHARED_2D array.
|
view with explicit leading dimension |
|
view of a |
|
view starting at that entry |
|
swaps |
|
move the origin |
A kernel with one small matrix per work item - invert a 3x3 and apply it, the shape of a geometry transformation in an FE kernel. Checked against the host backend and numpy.
[8]:
tb_src = '''
using namespace tinybla;
KERNEL(transform, GLOBAL_IN(float,jac), GLOBAL_IN(float,v), GLOBAL(float,out), VALUE(int,n))
{
int i = GLOBAL_ID_X;
if (i >= n) return;
Mat<3,3,float> F;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
F(r,c) = jac[9*i + 3*r + c];
Vec<3,float> u;
for (int k = 0; k < 3; k++) u(k) = v[3*i+k];
Vec<3,float> res = Inv(F) * u;
for (int k = 0; k < 3; k++) out[3*i+k] = res(k);
out[3*n+i] = Det(F);
}
'''
m = 64
rng = np.random.default_rng(0)
J = rng.random((m,3,3)).astype(np.float32) + np.eye(3, dtype=np.float32)
V = rng.random((m,3)).astype(np.float32)
def run_tb(device):
lib = device.CompileSource(GPUKernelPrelude + TinyBlaPrelude + tb_src)
qq = device.DefaultQueue()
bj, bv, bo = device.NewBuffer(4*9*m), device.NewBuffer(4*3*m), device.NewBuffer(4*4*m)
bj.H2D(J.reshape(-1))
bv.H2D(V.reshape(-1))
qq.Launch(lib.GetKernel("transform"), groups=[1], groupsize=[64], args=[bj, bv, bo, m])
qq.Finish()
r = bo.D2H(4*m)
return r[:3*m].reshape(m,3), r[3*m:]
sol, det = run_tb(dev)
sol_host, det_host = run_tb(GetCPUDevice())
print("Inv(F)*u vs host :", np.abs(sol-sol_host).max(),
" vs numpy :", np.abs(sol-np.linalg.solve(J, V[...,None])[...,0]).max())
print("Det(F) vs host :", np.abs(det-det_host).max(),
" vs numpy :", np.abs(det-np.linalg.det(J)).max())
Inv(F)*u vs host : 0.0 vs numpy : 1.7881393e-07
Det(F) vs host : 0.0 vs numpy : 4.7683716e-07
Tiles across a threadgroup¶
WarpMatrix<H,W,T> is one tile held collectively by a simdgroup (32 lanes), which is how the matrix-free operators reach the tensor hardware. It is built from a BareMatrix and the work-item index, accumulates products with AddMM<K>, and is written back with Store.
|
a zero tile, |
|
load a tile |
|
|
|
write the tile back |
This one needs a real GPU: the shuffle primitives it is built on are implemented for Metal only, so the host reference backend cannot compile it.
[9]:
warp_src = '''
using namespace tinybla;
KERNEL(tilemul, GLOBAL_IN(float,a), GLOBAL_IN(float,b), GLOBAL(float,c))
{
unsigned tid = LOCAL_ID_X;
auto ma = MakeBareMatrix<RowMajor>(a, 16u); // 8 x 16
auto mb = MakeBareMatrix<RowMajor>(b, 8u); // 16 x 8
auto mc = MakeBareMatrix<RowMajor>(c, 8u); // 8 x 8
WarpMatrix<8,8,float> sum = 0.0f;
sum.AddMM<16>(ma, mb, tid);
sum.Store(mc, tid);
}
'''
K = 16
A = rng.random((8,K)).astype(np.float32)
B = rng.random((K,8)).astype(np.float32)
try:
libw = dev.CompileSource(GPUKernelPrelude + TinyBlaPrelude + warp_src)
ba, bb, bc = dev.NewBuffer(4*8*K), dev.NewBuffer(4*K*8), dev.NewBuffer(4*64)
ba.H2D(A.reshape(-1))
bb.H2D(B.reshape(-1))
q.Launch(libw.GetKernel("tilemul"), groups=[1], groupsize=[32], args=[ba, bb, bc])
q.Finish()
print("max error vs numpy :", np.abs(bc.D2H(64).reshape(8,8) - A@B).max())
except Exception as e:
print("WarpMatrix not available on this backend:", str(e).splitlines()[0])
WarpMatrix not available on this backend: ngs_gpu (cpu): kernel compile error:
The kernel sources used here are also available as a standalone script in ngstd/examples/gpukernel.py.