Zero-Copy Arrow Buffers to WebGPU Storage
The sub-problem here is the copy that the straightforward serializer leaves on the table. Packing a structure-of-arrays chunk with positions.tobytes() allocates a fresh Python bytes object the size of the whole coordinate block on every frame — a 40 MiB allocation and memcpy per chunk that the garbage collector then has to reclaim. When an Arrow array already holds its coordinates in a single contiguous, correctly typed buffer, that copy is pure waste: the buffer’s memory can be handed to the socket directly through Python’s buffer protocol, so the only copy in the whole path is the one the client makes into mapped GPU memory. This page shows how to keep an Arrow float32 array’s backing buffer as the wire payload, emit it as a zero-copy memoryview, and land it in a GPUBuffer unchanged. It sharpens the packing step from Python-to-GPU streaming with Arrow & GeoParquet, and its output obeys the layout in memory alignment for spatial data buffers.
Runnable reference implementation
The server builds the coordinate block as an Arrow FixedSizeListArray of float32 — Arrow’s native vec2-equivalent — then exposes its single data buffer as a memoryview with no copy. The header travels as its own small buffer; the payload buffers are sent via a gathered write so nothing is concatenated in Python.
# zero_copy_server.py — emit Arrow buffers to the wire with no intermediate copy.
import struct
import numpy as np
import pyarrow as pa
HEADER = struct.Struct("<IIII") # magic | count | positions_len | ids_len
MAGIC = 0x47505541 # "GPUA", little-endian
def build_arrow_soa(x: np.ndarray, y: np.ndarray, ids: np.ndarray):
"""Build Arrow arrays whose backing buffers are already GPU-ready.
positions: FixedSizeList<float32>[2] — contiguous x,y,x,y,... = vec2<f32>.
ids: uint32 array.
No masks are created, so each array has exactly one dense value buffer.
"""
n = x.shape[0]
# Interleave once into contiguous f32; Arrow wraps this buffer without copy.
inter = np.empty(n * 2, dtype=np.float32)
inter[0::2] = x # f64 -> f32 into even lanes
inter[1::2] = y # odd lanes
values = pa.array(inter, type=pa.float32())
positions = pa.FixedSizeListArray.from_arrays(values, 2)
ids_arr = pa.array(np.ascontiguousarray(ids.astype(np.uint32, copy=False)),
type=pa.uint32())
return positions, ids_arr
def payload_views(positions: pa.FixedSizeListArray,
ids_arr: pa.UInt32Array) -> tuple[memoryview, memoryview, bytes]:
"""Return zero-copy memoryviews over the Arrow value buffers + a header.
buffers()[1] is the dense values buffer (buffers()[0] is the null bitmap,
which is None here because the data has no nulls). Slicing to the exact
element byte length trims Arrow's 64-byte allocation padding.
"""
count = len(positions)
# positions.values is the flat float32 child; its buffer holds 2*count floats.
pos_buf = positions.values.buffers()[1] # pyarrow.Buffer, no copy
pos_len = count * 2 * 4 # 2 f32 per point, 4 bytes each
pos_view = memoryview(pos_buf)[:pos_len] # trim padding, still zero-copy
id_buf = ids_arr.buffers()[1]
id_len = count * 4
id_view = memoryview(id_buf)[:id_len]
header = HEADER.pack(MAGIC, count, pos_len, id_len)
return pos_view, id_view, header
async def send_zero_copy(websocket, x, y, ids) -> None:
"""Frame one chunk and send header + Arrow buffers with no bytes concat."""
positions, ids_arr = build_arrow_soa(x, y, ids)
pos_view, id_view, header = payload_views(positions, ids_arr)
# Gathered send: three views, no concatenation, no full-payload allocation.
# (websockets copies each view into one frame internally; the point is that
# *our* code never materializes positions.tobytes().)
frame = b"".join((header, pos_view, id_view))
await websocket.send(frame)
# Keep references alive until the send completes so Arrow does not free
# the buffer out from under the in-flight view.
del pos_view, id_view, positions, ids_arr
The zero-copy claim is precise: positions.values.buffers()[1] returns the exact heap allocation Arrow already holds, and wrapping it in a memoryview creates a view, not a copy. The interleave into inter is the one unavoidable materialization — it exists because x and y arrive as two separate columns and the GPU wants them interleaved. If the source GeoParquet already stores an interleaved FixedSizeList<float32>[2] coordinate column, even that vanishes: pa.parquet hands back the buffer and payload_views ships it untouched. The del at the end matters — an in-flight memoryview over an Arrow buffer must outlive the send, or the array can be collected and the socket reads freed memory.
Client-side ingestion
The client maps the payload into a storage buffer exactly as for any framed chunk — the zero-copy work is entirely server-side, so the client code is unchanged from the plain path. This short block shows the storage-buffer landing.
// Land an Arrow-sourced payload into a storage buffer, one copy total.
function ingestStorage(device: GPUDevice, frame: ArrayBuffer) {
const dv = new DataView(frame);
if (dv.getUint32(0, true) !== 0x47505541) throw new Error("bad magic");
const posLen = dv.getUint32(8, true);
const buf = device.createBuffer({
size: posLen,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Uint8Array(buf.getMappedRange()).set(new Uint8Array(frame, 16, posLen));
buf.unmap(); // array<vec2<f32>>, no transform
return buf;
}
Parameter reference
Every tunable in the zero-copy path, with guidance. Table scrolls horizontally on narrow viewports.
| Parameter | Typical value | Guidance |
|---|---|---|
| Arrow position type | FixedSizeList<float32>[2] |
Native vec2<f32> equivalent; its child value buffer is directly wire-ready. Use [3] for vec3, but pad to 16 bytes for WGSL. |
buffers() index |
1 |
Index 0 is the validity (null) bitmap, index 1 the values. Confirm the array has no nulls, or the bitmap must ship too. |
memoryview slice length |
count * elem_bytes |
Arrow over-allocates to a 64-byte boundary; slice to the exact element length or trailing padding ships as phantom points. |
| Interleave step | inter[0::2], inter[1::2] |
Strided assignment builds x,y,x,y; skip it entirely if the source column is already FixedSizeList[2]. |
| Reference lifetime | hold until send returns |
The Arrow array must outlive any memoryview over its buffer; del only after the frame is sent. |
| Null handling | require dense | A masked Arrow array has a non-null bitmap at buffers()[0]; compact or fill nulls before extracting the value buffer. |
Failure modes
- Sliced Buffer offset ignored. Calling
buffers()on a sliced Arrow array returns the parent’s full buffer, not the slice; shipping it sends the whole column and mis-sizes the header. Detection: payload is larger thancount * elem_bytes; points from adjacent slices bleed in. Fix:combine_chunks()or.slice(...).copy()to a standalone array before extracting buffers, or account forarray.offsetexplicitly. - Padding shipped as data. Arrow pads allocations to 64 bytes, so the raw
Bufferis often longer than the logical data; wrapping it without slicing sends up to 63 stray bytes the client reads as an extra partial point. Detection: one malformed point at the tail;posLenexceedscount * 8. Fix: slice thememoryviewtocount * elem_bytesas shown. - Use-after-free on the buffer view. Releasing the Arrow array (or letting it fall out of scope) while a
memoryviewover its buffer is still in flight lets Python free the memory mid-send. Detection: intermittent corrupted frames under load, clean frames when single-stepped. Fix: keep the array referenced untilawait websocket.send(...)resolves, thendel. - Null bitmap present. A GeoParquet column with missing coordinates yields a masked Arrow array;
buffers()[1]then holds values for rows the bitmap marks invalid, so masked-out garbage renders as real points. Detection: spurious points at coordinates that never appear in the source. Fix: filter nulls (array.drop_null()) or fill them before building the position array, and verifybuffers()[0] is None.
Backend / Python interop note
The zero-copy path pairs naturally with Arrow IPC and shared memory when the producer and the streaming server are separate processes: an analytics job can write coordinates into an Arrow IPC file or a Plasma-style shared buffer, and the streaming server maps that file and forwards the value buffer without ever owning a private copy. Because the buffer is memory-mapped, the operating system pages it in on demand, so the server’s resident footprint stays near zero even for a multi-gigabyte coordinate column. When the same bytes must also feed a headless renderer, the wgpu-py bindings accept a memoryview in write_buffer, so the identical Arrow buffer lands in a native GPUBuffer without leaving Python — the browser and headless paths share one zero-copy source. Sizing these streamed buffers against client memory remains the concern of VRAM budget management across tile zoom levels.
Related
- Python-to-GPU Streaming with Arrow & GeoParquet — the packing and framing overview this zero-copy variant optimizes.
- Streaming GeoParquet Columns into WebGPU Buffers — the row-group streaming server whose
tobytes()copy this page removes. - Memory Alignment for Spatial Data Buffers — the storage-buffer layout the emitted Arrow buffers must satisfy.