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:

  1. Pointers – the ability to work with raw memory addresses
  2. 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:

TypeMeaning
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 as long)
  • On a 32-bit OS: nint = 32-bit (same as int)

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 string

2.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 handle

2.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-bit

This is used frequently when telling OpenCL how many bytes to allocate or copy:

nuint bufBytes = (nuint)(N * sizeof(float));  // total size of a float array

2.9 Two-Call Pattern for Lists

The OpenCL C API has a recurring pattern for retrieving lists (platforms, devices, log strings):

  1. First call — pass 0 for count and null for the output array → OpenCL returns the actual count
  2. Allocate the output array with the right size
  3. 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 int

In production code you would check the error code and handle failures. In our samples we skip this for brevity.


2.11 Summary Table

FeatureWhat it meansWhere you see it
unsafeEnables pointer types and operationsEvery fixed / pointer block
int* / float*Raw pointer to array elementBuffer upload/download, GetDeviceInfo
nintNative-sized int, used for opaque handlesPlatform, device, context, queue, buffer, kernel handles
nuintNative-sized uint, used for sizesBuffer sizes, string sizes, NDRange sizes
fixed (T* p = arr)Pin a managed array; get a raw pointerAny time we pass a C# array to OpenCL
stackallocStack-allocate a small array without GCNDRange globalSize / localSize arrays
(T*)nullTyped null pointerOptional OpenCL parameters
ref TPass by reference (OpenCL reads the bytes)SetKernelArg for scalars and handles
sizeof(T)Size of a value type in bytesBuffer allocation, SetKernelArg
out _Discard an output parameterError 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.