This guide covers GPU memory management in SpawnDev.ILGPU — allocating buffers, transferring data, and reading results back asynchronously.
GPU memory is separate from CPU memory. You allocate buffers on the GPU, pass views into kernels, and copy results back to CPU arrays when done.
| Concept | Description |
|---|---|
MemoryBuffer1D<T, Stride> |
Host-side handle to GPU memory — allocated and disposed on the CPU |
ArrayView<T> |
GPU-side reference — passed to kernels, indexable like an array |
CopyToHostAsync |
Reads data from GPU back to a CPU array |
Flush |
Submits queued GPU work to the device without waiting — valid on every backend (no-op on desktop; submits the batched encoder/queue on the browser backends) |
SynchronizeAsync |
Submits and waits for all GPU work to complete — the browser-safe way to wait (the synchronous Synchronize() throws on the browser backends) |
// Allocate empty buffer
using var buffer = accelerator.Allocate1D<float>(1024);
// Allocate and initialize from an array
float[] hostData = { 1, 2, 3, 4, 5 };
using var buffer = accelerator.Allocate1D(hostData);// Allocate 2D buffer (e.g., for image processing)
int width = 800, height = 600;
using var buffer = accelerator.Allocate2DDenseX<uint>(new Index2D(width, height));
// Access extent for kernel launch
kernel(buffer.IntExtent, buffer.View, ...);Stride controls how multi-dimensional data is laid out in memory:
| Stride | Use Case |
|---|---|
Stride1D.Dense |
1D buffers (default) |
Stride2D.DenseX |
2D buffers — rows are contiguous (X varies fastest) |
When in doubt, use Dense for 1D and DenseX for 2D. These match standard C# array layouts.
var data = new float[] { 1, 2, 3, 4, 5 };
using var buffer = accelerator.Allocate1D(data);
// Buffer now contains the array data on the GPUusing var buffer = accelerator.Allocate1D<float>(1024);
float[] newData = ComputeNewData();
buffer.CopyFromCPU(newData);Scalar values (int, float, etc.) are passed directly to kernels as parameters — no buffer needed:
static void MyKernel(Index1D index, ArrayView<float> data, float multiplier, int offset)
{
data[index] = data[index] * multiplier + offset;
}
// Pass scalars directly
kernel((Index1D)length, buffer.View, 2.5f, 10);When data originates in JavaScript — fetched from a WebSocket, decoded by a FileReader, pulled from IndexedDB, returned by fetch, produced by MediaRecorder, etc. — going through CopyFromCPU would marshal every byte through .NET arrays for no reason. CopyFromJS is the zero-copy path: data stays in JS, gets pushed straight into the GPU buffer. Available on all three browser backends (WebGPU, WebGL, Wasm). Not available on desktop (CUDA / OpenCL / CPU don't have JS).
using SpawnDev.ILGPU;
using SpawnDev.BlazorJS.JSObjects;
// Get the IBrowserMemoryBuffer for any GPU buffer
var browserBuffer = (IBrowserMemoryBuffer)((IArrayView)buffer.View).Buffer;
// From a JS TypedArray (e.g. data already in JS land)
Int16Array jsData = ...; // 16-bit PCM samples from a WebSocket message
browserBuffer.CopyFromJS(jsData);
// From a raw ArrayBuffer (e.g. a fetch() result body)
ArrayBuffer rawBytes = ...; // image bytes from a fetch response
browserBuffer.CopyFromJS(rawBytes);
// Optional target offset — write into the middle of an existing GPU buffer
browserBuffer.CopyFromJS(jsData, targetByteOffset: 1024);Per-backend behavior:
- WebGPU — calls
queue.WriteBufferdirectly on the JS-side typed array. Zero copy through .NET, no managed allocation. - WebGL — copies the JS bytes into the buffer's backing array and sets
NeedsUpload = true; the data is uploaded to the GPU on the next dispatch (not immediately, by design — WebGL has no async write queue). - Wasm — pure JS→JS copy within the SharedArrayBuffer that backs Wasm linear memory. Worker threads see the new bytes immediately.
When to use it:
- Streaming pipelines where JS produces data (audio decode, video frames, network bytes)
- Loading models / assets from
fetchorIndexedDB - Canvas → GPU pipelines where you read
ImageDataonce on the JS side and want to skip the .NET round trip
If your data is already in a byte[] / float[] / etc., use CopyFromCPU — that's the right path for managed-array sources. CopyFromJS is specifically for when the data hasn't crossed into .NET yet and you want to keep it that way.
CopyFromJS uploads data that is already fully resident in a JS TypedArray / ArrayBuffer. When the source is a stream — a multi-hundred-MB model file, a Blob / File, a WebTorrent piece stream — you don't want the whole thing in memory at once. ArrayView<T>.CopyFromStreamAsync streams a .NET Stream into a GPU buffer in bounded chunks (default 16 MiB), reading exactly view.Length * sizeof(T) bytes:
using SpawnDev.ILGPU;
using var buf = accelerator.Allocate1D<float>(weightCount);
await buf.View.CopyFromStreamAsync(sourceStream); // default 16 MiB chunks
await buf.View.CopyFromStreamAsync(sourceStream, chunkSizeInBytes: 4 * 1024 * 1024);
await buf.View.SubView(offset, count).CopyFromStreamAsync(sourceStream); // into a sub-range- All 6 backends. On CUDA / OpenCL / CPU it
awaitsStream.ReadExactlyAsyncinto a pooled buffer and copies each chunk (a model streaming off disk / network never blocks a thread). Correct on the browser backends too via the managed hop. - Browser zero-copy fast path. If the source
StreamimplementsSpawnDev.BlazorJS.Toolbox.IJSReadStream, the browser backends read each chunk as a JSUint8Arrayand upload it viaCopyFromJS— the bytes go JS → GPU without ever entering the .NET/WASM managed heap. (WebGPU requires 4-byte-aligned transfers, which fp32 and even-countHalfalways satisfy; an odd-countHalfupload falls back to the padded managed path.) - Exact length. A stream that ends before
view.Length * sizeof(T)bytes throwsEndOfStreamException— a truncated asset surfaces instead of silently zero-padding the tail.
IJSReadStream (in SpawnDev.BlazorJS.Toolbox) is the primitive that enables the fast path — a Stream whose data can be read while it stays in JS:
| Member | Meaning |
|---|---|
bool CanReadSync |
true if a synchronous read works (ArrayBufferStream — data already in JS memory); false for async-only sources (BlobStream, network streams). |
Task<Uint8Array> ReadUint8ArrayAsync(int count, …) |
Reads up to count bytes from the current Position as a JS Uint8Array (stays JS-side), advances Position; empty Uint8Array at EOF. |
Uint8Array ReadUint8Array(int count) |
Synchronous counterpart (throws when CanReadSync is false). |
Implemented by BlobStream (Blob / File, CanReadSync = false) and ArrayBufferStream (ArrayBuffer, CanReadSync = true). Any Stream that implements it gets the zero-copy upload automatically.
Use CopyFrom to copy data between GPU buffers. This works on all six backends — it's a native GPU operation with no CPU involvement:
using var source = accelerator.Allocate1D(new float[] { 1, 2, 3, 4, 5 });
using var dest = accelerator.Allocate1D<float>(5);
// Copy GPU→GPU (fast, native, works everywhere)
dest.CopyFrom(source);On WebGPU this maps to CopyBufferToBuffer. On CUDA/OpenCL it's a device-to-device memcpy. On Wasm it copies within the SharedArrayBuffer. No shader compilation, no kernel dispatch.
Not all copy operations work the same way on every backend. This table shows what works and what throws:
| Operation | Method | CPU / CUDA / OpenCL | WebGPU | WebGL | Wasm |
|---|---|---|---|---|---|
| GPU→GPU | CopyFrom |
Sync | CopyBufferToBuffer |
TF readback | SharedArrayBuffer |
| CPU→GPU | CopyFromCPU / Allocate1D(data) |
Sync | queue.WriteBuffer |
texImage2D |
SharedArrayBuffer |
| JS→GPU | IBrowserMemoryBuffer.CopyFromJS |
N/A | queue.WriteBuffer |
Backing-array copy + NeedsUpload |
JS→JS within SharedArrayBuffer |
| GPU→CPU (async) | CopyToHostAsync |
Sync fallback | mapAsync(Read) |
Readback | SharedArrayBuffer |
| GPU→CPU (sync) | CopyTo / CopyToCPU / GetAsArray1D |
Sync | THROWS | THROWS | THROWS |
| Stream→GPU (chunked) | ArrayView.CopyFromStreamAsync |
ReadExactlyAsync + copy |
IJSReadStream→CopyFromJS |
IJSReadStream→backing copy |
IJSReadStream→JS copy |
| GPU→Stream (chunked) | ArrayView.CopyToStreamAsync |
readback + WriteAsync |
IJSWriteStream→WriteUint8ArrayAsync |
IJSWriteStream→WriteUint8ArrayAsync |
IJSWriteStream→WriteUint8ArrayAsync |
Key rules:
- GPU→GPU copies: always use
CopyFrom. It's fast, native, and works on all backends. - GPU→CPU reads: always use
CopyToHostAsync. The sync methods (CopyTo,CopyToCPU,GetAsArray1D) throwNotSupportedExceptionon browser backends (WebGPU, WebGL, Wasm) because they require async GPU readback (mapAsync). - Never replace
CopyFromwith a kernel dispatch (e.g., a Scale-by-1 kernel).CopyFromis a hardware copy command — it's faster, simpler, and doesn't require shader compilation. Using a kernel dispatch for GPU→GPU copies can cause initialization errors on WebGPU when the accelerator isn't fully ready.
After launching a kernel, you must synchronize before reading results:
kernel((Index1D)length, bufA.View, bufB.View, bufC.View);
// Flush() SUBMITS the batched work without waiting — valid on every backend
// (no-op on desktop; submits the command encoder / worker queue on the browser backends).
accelerator.Flush();
// SynchronizeAsync() SUBMITS and WAITS for GPU completion — the browser-safe way to wait.
await accelerator.SynchronizeAsync();Semantics (the sync/async contract). Three distinct surfaces — none of them transfer data (use
CopyToHostAsync()to read results back):
Flush()submits pending work to the device but does not wait. It is fire-and-forget, so it is valid synchronously on every backend (desktop: a no-op, work is already submitted as it is enqueued; browser: submits the batched command encoder / worker queue and returns). Use it to submit periodically during a long dispatch loop. It is the honestly-named replacement for the old browser habit of callingSynchronize()as a non-blocking flush.SynchronizeAsync()submits and waits for all submitted GPU work to complete. This is the portable way to wait before disposing buffers a pending dispatch references (a host readback viaCopyToHostAsync()already drains, so it does not need a separate wait).awaitit.Synchronize()(synchronous wait) blocks until done on the desktop backends (CPU/CUDA/OpenCL) but throwsNotSupportedExceptionon the browser backends (WebGPU/WebGL/Wasm) — the single Blazor thread cannot block-wait, so the throw makes the misuse loud instead of silently reading stale data. To wait on a browser backend,await SynchronizeAsync(); to submit without waiting, callFlush().See async.md for the full per-operation sync/async surface.
The simplest way to read GPU data. Works with all six backends (WebGPU, WebGL, Wasm, CUDA, OpenCL, CPU):
using SpawnDev.ILGPU;
// Read entire buffer to a new array
float[] results = await buffer.CopyToHostAsync<float>();
// Read 1D buffer directly
var results = await buffer1D.CopyToHostAsync<float>();For render loops, avoid allocating a new array every frame:
// Allocate once
float[] cachedResults = new float[bufferLength];
// Reuse every frame
await buffer.CopyToHostAsync(cachedResults);Reads only a sub-range of a GPU buffer to a host array. The byte range outside the view never crosses the device-host boundary - this is a real per-backend partial copy, not a full-buffer readback followed by a CPU-side slice.
Use this when a single GPU buffer holds multiple logical regions (per-channel image planes, per-tensor model outputs, per-frame audio chunks, etc.) and you need each region as its own host array:
using SpawnDev.ILGPU;
// One GPU buffer with three logical regions (Y / U / V planes for YUV 4:2:0):
var y = await dRecon.View.SubView(0, yLen ).CopyToHostAsync();
var u = await dRecon.View.SubView(yLen, uvLen).CopyToHostAsync();
var v = await dRecon.View.SubView(yLen + uvLen, uvLen).CopyToHostAsync();Each call only transfers its own slice's bytes. Compare with the full-buffer pattern, which reads the whole buffer and slices on the CPU:
// AVOID — reads the entire dRecon buffer to host every call,
// then slices on the CPU. Fine for small buffers, wasteful for large ones.
var full = await dRecon.CopyToHostAsync<byte>();
var y = new byte[yLen]; Buffer.BlockCopy(full, 0, y, 0, yLen);
var u = new byte[uvLen]; Buffer.BlockCopy(full, yLen, u, 0, uvLen);
var v = new byte[uvLen]; Buffer.BlockCopy(full, yLen + uvLen, v, 0, uvLen);Per-backend implementation (no fallback to full-buffer + slice on any backend):
| Backend | Underlying primitive |
|---|---|
| WebGPU | queue.CopyBufferToBuffer(srcBuf, srcByteOffset, staging, 0, byteCount) -> mapAsync(Read, 0, byteCount). Staging is sized to the slice, not the parent buffer. |
| WebGL | GL-worker ReadbackAndGetUint8ArrayAsync(buf, sourceByteOffset, byteCount) partial range path. |
| Wasm | new Uint8Array(SharedBuffer, byteOffset, byteCount) window onto the SAB slot. The rest of wasm linear memory is not touched. |
| CUDA / OpenCL / CPU | ILGPU's native view.CopyToCPU(target). The view's start offset and length encode the partial range, so this is one cudaMemcpy / clEnqueueReadBuffer / direct memcpy of just the slice's bytes. |
Two overloads are provided so that MemoryBuffer1D.View.SubView(...) resolves naturally without an explicit cast:
public static Task<T[]> CopyToHostAsync<T>(this ArrayView<T> view)
where T : unmanaged;
public static Task<T[]> CopyToHostAsync<T, TStride>(this ArrayView1D<T, TStride> view)
where T : unmanaged
where TStride : struct, IStride1D;The ArrayView1D overload forwards to the ArrayView<T> overload via view.BaseView, which is already the sliced range on a SubView'd 1D view.
Throws:
InvalidOperationExceptionif the view has no backing buffer.ArgumentOutOfRangeExceptionif the view's byte range exceeds the buffer's length.
When NOT to use this overload:
- You want the entire buffer's contents - use
buffer.CopyToHostAsync<T>()directly. TheMemoryBufferoverload exists for that case and avoids the SubView object construction. - You're writing into a pre-allocated array - use
buffer.CopyToHostAsync(targetArray)for the per-frame render loop pattern. The partial-readback overload always allocates a freshT[].
Returns a JavaScript Uint8Array for direct use with browser APIs (Canvas, WebGL textures, etc.):
// Get raw bytes as a JS Uint8Array
using var bytes = await browserBuffer.CopyToHostUint8ArrayAsync(0, byteCount);
// Use directly with Canvas ImageData
destPixels.Set(bytes);This is the fastest path for GPU → Canvas rendering. See Advanced Patterns for the full render pipeline.
All GPU-backed buffers implement IBrowserMemoryBuffer, which provides CopyToHostUint8ArrayAsync:
var internalBuffer = ((IArrayView)outputBuffer).Buffer;
if (internalBuffer is IBrowserMemoryBuffer browserBuffer)
{
// Fast JS-interop readback
using var bytes = await browserBuffer.CopyToHostUint8ArrayAsync(0, byteCount);
destPixels.Set(bytes);
}
else
{
// CPU fallback
outputBuffer.View.BaseView.CopyToCPU(resultArray);
}The save-side mirror of CopyFromStreamAsync. ArrayView<T>.CopyToStreamAsync streams a GPU buffer out to a .NET Stream in bounded chunks (default 16 MiB) — so saving a multi-hundred-MB buffer to OPFS / disk / network never materializes the whole thing at once:
using SpawnDev.ILGPU;
// Save to any .NET Stream (e.g. a MemoryStream, a file):
using var ms = new MemoryStream();
await buf.View.CopyToStreamAsync(ms); // default 16 MiB chunks
await buf.View.CopyToStreamAsync(ms, chunkSizeInBytes: 4 * 1024 * 1024);
await buf.View.SubView(offset, count).CopyToStreamAsync(ms); // just a sub-range- All 6 backends. The base path does an async GPU→CPU readback of each chunk (
CopyToRawAsync) and writes it to the targetStream— correct everywhere via the managed hop. - Browser zero-copy fast path. If the target
StreamimplementsSpawnDev.BlazorJS.Toolbox.IJSWriteStream, the browser backends read each chunk back as a JSUint8Array(CopyToHostUint8ArrayAsync) and write it viaWriteUint8ArrayAsync— the bytes go GPU → JS → stream without ever entering the .NET/WASM managed heap.
IJSWriteStream (in SpawnDev.BlazorJS.Toolbox) is the write-side sibling of IJSReadStream:
| Member | Meaning |
|---|---|
bool CanWriteSync |
true if a synchronous write works (ArrayBufferStream — in-memory JS buffer); false for async-only sinks (FileSystemHandleWritableStream — OPFS / disk). |
Task WriteUint8ArrayAsync(Uint8Array data, …) |
Writes data at the current Position (stays JS-side), advances Position. |
void WriteUint8Array(Uint8Array data) |
Synchronous counterpart (throws when CanWriteSync is false). |
Implemented by FileSystemHandleWritableStream (OPFS / disk, CanWriteSync = false) and ArrayBufferStream (in-memory ArrayBuffer, CanWriteSync = true — a full duplex IJSReadStream + IJSWriteStream).
Saving a GPU buffer to an OPFS file — the full pattern:
using SpawnDev.BlazorJS.JSObjects;
using SpawnDev.BlazorJS.Toolbox;
using var storage = JS.Get<StorageManager>("navigator.storage");
using var root = await storage.GetDirectory();
using var fileHandle = await root.GetFileHandle("scene.bin", create: true);
// ⚠ await using — the OPFS close() is the async disk commit. CopyToStreamAsync WRITES the
// bytes but does NOT own/close the target stream; a plain `using` can return before the file
// is flushed, leaving an empty/short file (browser-timing dependent). await using (or an
// explicit await writable.CloseAsync()) guarantees the bytes are on disk before you read back.
await using var writable = await fileHandle.GetWritableStream();
await packedBuf.View.CopyToStreamAsync(writable);
CopyToStreamAsyncrequires SpawnDev.BlazorJS 3.5.14+ forIJSWriteStreamand theFileSystemHandleWritableStreamasync commit (DisposeAsync/CloseAsync).
Dispose in reverse order of creation:
using var context = await Context.CreateAsync(builder => builder.AllAcceleratorsAsync());
using var accelerator = await context.CreatePreferredAcceleratorAsync();
using var buffer = accelerator.Allocate1D<float>(1024);
// ... use buffer ...
// `using` disposes in reverse: buffer → accelerator → context- Buffers are tied to their accelerator — you cannot use a buffer from one accelerator with another
- Dispose buffers before their accelerator — disposing out of order causes errors
- Views don't need disposal —
ArrayView<T>is a lightweight struct (likeSpan<T>) - Don't access disposed buffers —
CopyToHostAsyncon a disposed buffer throwsObjectDisposedException
For real-time rendering, the buffer readback system supports a zero-allocation path:
// Cache the staging buffer (created once, reused)
// This happens automatically inside WebGPUBuffer<T>.CopyToHostAsync
// Pre-allocate the result array
uint[] cachedResult = new uint[pixelCount];
// Each frame: no GC allocations
await nativeBuffer.CopyToHostAsync(cachedResult, 0, pixelCount);The WebGPUBuffer<T> internally maintains a cached staging buffer for readback operations. The first call creates it; subsequent calls reuse it (as long as the size doesn't increase).
- Batch your uploads — call
CopyFromCPUbefore launching kernels, not inside render loops - Reuse buffers — allocate once, use many times, dispose at cleanup
- Avoid per-frame allocation — pre-allocate host arrays and use the
CopyToHostAsync(destination)overload - Use
IBrowserMemoryBufferfor Canvas rendering — it skips the .NET array entirely when possible - Buffer pooling is automatic — the WebGPU backend pools scalar parameter buffers internally