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

Why an Arrow buffer offset has to be re-aligned before upload Two strips show the same Arrow record batch region. The first shows the raw IPC layout: a metadata block of unpredictable length precedes the column data, so the coordinate column begins at an arbitrary byte offset — 68 in this example — which is not a multiple of four and cannot be the source offset of a typed-array view. The second shows the corrected layout, where the column data has been padded by the writer to begin at offset 72, a multiple of eight, so a Float32Array view can be constructed over it directly and handed to writeBuffer with no copy. 4-BYTE WORD 0 4 8 12 16 20 24 28 32 schema + metadata (68 B) coordinate column Raw stream column at 68 offset 68 is not 4-byte aligned — a Float32Array view over it throws schema + metadata (68 B) pad coordinate column Padded column at 72 the writer pads to 8 bytes, so the view is constructible and the upload is a straight copy Use pyarrow’s IPC writer rather than assembling the stream by hand and the padding is handled for you.
Arrow already specifies 8-byte buffer alignment, so a conformant writer produces the lower layout. The upper one appears when a hand-rolled serialiser concatenates metadata and data without padding, and the symptom is a RangeError in the browser rather than anything about alignment.

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.

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.

typescript
// 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

Where a zero-copy path quietly stops being zero-copy A table of four operations with whether each preserves the zero-copy property. Slicing an ArrayBuffer with slice copies the bytes, while constructing a typed-array view with subarray does not. Reprojecting coordinates in JavaScript copies and costs per-record work, while reprojecting them in a compute shader does not. Filtering rows in the browser copies, while filtering them on the Arrow table in Python does not. Converting f64 to f32 in the browser copies, while casting the column in Python before serialisation does not. ZERO-COPY · WHAT BREAKS IT Copies? Do this instead ArrayBuffer.slice yes subarray view Reproject in JS yes reproject in WGSL Filter rows in the browser yes filter the Arrow table Narrow f64 → f32 in JS yes cast the column in Python If any step in the browser is proportional to the row count, the path is not zero-copy any more.
Each of these is individually reasonable and collectively fatal. A path with three of them has the cost profile of GeoJSON while carrying the complexity of Arrow, which is the worst of both.

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

The four objects a coordinate passes through, and the one copy Four objects in sequence. The fetch response body is a stream of bytes. Calling arrayBuffer collects it into a single ArrayBuffer, which is the one unavoidable copy in the path. A Float32Array view is then constructed over a byte range of that ArrayBuffer, which copies nothing. Finally writeBuffer transfers the view into the GPU buffer, which is a DMA transfer rather than a JavaScript-level copy. ONE COPY, THREE VIEWS Response body a stream of bytes ArrayBuffer arrayBuffer() the one real copy Float32Array view byteOffset + length copies nothing GPU buffer queue.writeBuffer DMA, not a JS copy Streaming the body straight into writeBuffer per chunk removes even that copy, at the cost of chunk bookkeeping.
Only the second box copies anything, and it copies once regardless of how many columns the batch holds. Every additional column is another view over the same bytes, which is precisely the property that makes the columnar format worth the extra plumbing.
  • 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 than count * elem_bytes; points from adjacent slices bleed in. Fix: combine_chunks() or .slice(...).copy() to a standalone array before extracting buffers, or account for array.offset explicitly.
  • Padding shipped as data. Arrow pads allocations to 64 bytes, so the raw Buffer is 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; posLen exceeds count * 8. Fix: slice the memoryview to count * elem_bytes as shown.
  • Use-after-free on the buffer view. Releasing the Arrow array (or letting it fall out of scope) while a memoryview over 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 until await websocket.send(...) resolves, then del.
  • 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 verify buffers()[0] is None.

Keep in mind that “zero-copy” describes the path, not any individual call. There is exactly one copy in it, from the network into an ArrayBuffer, and the discipline is about not adding a second — every extra pass over the data costs the row count again.

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.

Worth checking explicitly during development: assert that the byte length of the typed-array view divides evenly by the expected element stride, and that the element count matches the row count reported in the batch metadata. Both are one-line checks, both are free, and between them they catch every case where the schema and the buffer have drifted apart — which is the failure that otherwise surfaces as geometry drawn at plausible but wrong coordinates.

Up: Python-to-GPU Streaming with Arrow & GeoParquet