Chapter 2: C# Language Prerequisites
OpenCL is a C library from the 1980s–90s era of systems programming. To call it from C# we need a few low-level language features that are not taught in typical C# courses. This chapter explains exactly what they are and why we need them.
2.1 Why Unsafe Code?
In normal C# (“managed code”), the garbage collector (GC) is responsible for memory:
- It allocates objects on the heap
- It tracks which objects are still referenced
- It periodically moves objects in memory to compact the heap
This is safe and convenient, but it creates a problem when calling native C functions: if we pass the address of a C# array to an OpenCL function and the GC moves that array while the C function is reading from it, the C function reads garbage or crashes.
We therefore need two things:
- Pointers – the ability to work with raw memory addresses
fixed– the ability to tell the GC “don’t move this array while I’m using it”
Both require the unsafe keyword. All projects in this course have this in their .csproj:
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>2.2 Pointers
A pointer is a variable that holds a memory address instead of a value.
Normal variable: Pointer variable:
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ int x = 42 │ │ int* p │──────► │ 42 │
│ address:1000 │ │ value: 1000 │ │ address:1000 │
└───────────────┘ └───────────────┘ └───────────────┘
In C#, pointer syntax uses * and &:
int x = 42;
unsafe
{
int* p = &x; // & = "address of" → p now holds the address of x
*p = 99; // * = "dereference" → write 99 through the pointer
// x is now 99
}Pointer types in our samples:
| Type | Meaning |
|---|---|
int* | pointer to an int |
float* | pointer to a float |
byte* | pointer to a byte |
void* | pointer to anything (untyped) |
nuint* | pointer to a native-sized unsigned int |
nint* | pointer to a native-sized int (= handle) |
You need unsafe anywhere you use pointer types or dereference operations.
2.3 nint and nuint — Native-Sized Integers
nint and nuint (C# 9+) are integers whose size matches the native platform word size:
- On a 64-bit OS:
nint= 64-bit (same aslong) - On a 32-bit OS:
nint= 32-bit (same asint)
Why do we use them for OpenCL handles?
OpenCL is a C library. In C, an opaque handle is typically typedef void* cl_platform_id. A C# nint is exactly the right size to store a raw pointer or handle on any platform.
nint platform = /* OpenCL handle returned by GetPlatformIDs */;
nint device = /* OpenCL handle returned by GetDeviceIDs */;
nint context = /* OpenCL handle returned by CreateContext */;We never inspect the bits inside these handles. We just pass them back to other OpenCL functions, exactly like an opaque ID card.
nuint (native unsigned int) appears in OpenCL where C uses size_t — byte counts, array sizes, etc.:
nuint bufBytes = (nuint)(N * sizeof(float)); // size in bytes for CreateBuffer
nuint logSize; // size of compiler log string2.4 The fixed Statement
fixed pins a managed array in memory so the GC cannot move it during a native call.
float[] data = new float[1000];
unsafe
{
fixed (float* ptr = data) // pin 'data' and get a raw pointer to element [0]
{
// 'ptr' is valid here; GC will not move 'data'
SomeNativeFunction(ptr);
}
// array is unpinned here; GC may move it again
}In our OpenCL samples, fixed is used whenever we:
- Upload data from a C# array to a device buffer (
CopyHostPtr) - Download a result from the device into a C# array (
EnqueueReadBuffer) - Pass an array of handles to OpenCL (
GetPlatformIDs,GetDeviceIDs)
Example from Sample 01:
nint[] platforms = new nint[numPlatforms];
unsafe
{
fixed (nint* p = platforms)
cl.GetPlatformIDs(numPlatforms, p, (uint*)null);
}The fixed block gives us a raw nint* that points to the first element of the managed array, valid for the duration of the block.
2.5 Null Pointers
Many OpenCL functions accept optional pointer parameters. When we don’t want to provide a value, we pass a null pointer.
// C equivalent: clGetPlatformIDs(0, NULL, &count)
unsafe { cl.GetPlatformIDs(0u, (nint*)null, out numPlatforms); }The cast (nint*)null creates a null pointer of the correct type. OpenCL treats a null pointer as “I don’t want this output / this parameter is optional”.
2.6 stackalloc — Stack Allocation
stackalloc allocates a small array on the stack instead of the heap. This means:
- No GC involvement
- No need for
fixed(stack memory doesn’t move) - The array is automatically freed when the enclosing block exits
- Size limit: the stack is typically 1–8 MB, so don’t put large arrays here
We use it for the NDRange size arrays (always small: 1–3 elements):
unsafe
{
nuint* gs = stackalloc nuint[] { (nuint)N, (nuint)N }; // [N, N]
nuint* ls = stackalloc nuint[] { (nuint)16, (nuint)16 }; // [16, 16]
cl.EnqueueNdrangeKernel(queue, kernel, 2u,
(nuint*)null, gs, ls,
0u, (nint*)null, out nint _);
}The nuint* we get from stackalloc is directly usable as a C pointer — no fixed needed.
2.7 The ref Keyword with Native APIs
Some Silk.NET wrapper methods accept ref T parameters instead of T* pointers. This is a convenience provided by the wrapper — behind the scenes it still passes a pointer.
// Setting a scalar kernel argument (4-byte int):
int n = N;
cl.SetKernelArg(kernel, 3u, (nuint)sizeof(int), ref n);
// ^^^
// 'ref n' gives OpenCL a pointer to n's value
// OpenCL reads the 4 bytes from that address
// Setting a buffer argument (handle = 8 bytes on 64-bit):
cl.SetKernelArg(kernel, 0u, (nuint)IntPtr.Size, ref bufA);
// ^^^^^^^^^^^^^^^^^^^^^^^^
// IntPtr.Size = 8 on 64-bit; bufA is an nint handle2.8 sizeof in Unsafe Context
sizeof(T) returns the size in bytes of a value type. In unsafe context it works on any primitive type and even pointers:
sizeof(int) // 4
sizeof(float) // 4
sizeof(double) // 8
sizeof(nuint) // 8 on 64-bit, 4 on 32-bitThis is used frequently when telling OpenCL how many bytes to allocate or copy:
nuint bufBytes = (nuint)(N * sizeof(float)); // total size of a float array2.9 Two-Call Pattern for Lists
The OpenCL C API has a recurring pattern for retrieving lists (platforms, devices, log strings):
- First call — pass
0for count andnullfor the output array → OpenCL returns the actual count - Allocate the output array with the right size
- Second call — pass the count and the array → OpenCL fills the array
// Step 1: how many platforms?
uint numPlatforms;
unsafe { cl.GetPlatformIDs(0u, (nint*)null, out numPlatforms); }
// Step 2: allocate
nint[] platforms = new nint[numPlatforms];
// Step 3: fill
unsafe
{
fixed (nint* p = platforms)
cl.GetPlatformIDs(numPlatforms, p, (uint*)null);
}This pattern appears in GetPlatformIDs, GetDeviceIDs, GetProgramBuildInfo, GetPlatformInfo, GetDeviceInfo, and many others.
2.10 out _ — Discarding Output
Many OpenCL functions return an error code (int) and also fill output parameters. When we don’t need the error code (because we’re keeping the sample simple), we can discard it with _:
nint queue = cl.CreateCommandQueue(context, device, CommandQueueProperties.None, out _);
// ^^^^^
// discard the error code intIn production code you would check the error code and handle failures. In our samples we skip this for brevity.
2.11 Summary Table
| Feature | What it means | Where you see it |
|---|---|---|
unsafe | Enables pointer types and operations | Every fixed / pointer block |
int* / float* | Raw pointer to array element | Buffer upload/download, GetDeviceInfo |
nint | Native-sized int, used for opaque handles | Platform, device, context, queue, buffer, kernel handles |
nuint | Native-sized uint, used for sizes | Buffer sizes, string sizes, NDRange sizes |
fixed (T* p = arr) | Pin a managed array; get a raw pointer | Any time we pass a C# array to OpenCL |
stackalloc | Stack-allocate a small array without GC | NDRange globalSize / localSize arrays |
(T*)null | Typed null pointer | Optional OpenCL parameters |
ref T | Pass by reference (OpenCL reads the bytes) | SetKernelArg for scalars and handles |
sizeof(T) | Size of a value type in bytes | Buffer allocation, SetKernelArg |
out _ | Discard an output parameter | Error codes from Create* functions |
Understanding these is enough to read all 8 samples. You don’t need to write new unsafe code — just understand what the existing patterns do.