Bulk Transfers

Wire-ins, wire-outs, and triggers handle the small stuff: control bits, status flags, single registers. They’re built for low-latency access to discrete pieces of state, and the cost of a transaction is dominated by the round-trip, not the payload. Bulk transfers are the other side of that line. When the application needs to move a block of data — a frame buffer, a capture record, a configuration payload, a window of waveform samples, a tensor for an inference accelerator — the cost is dominated by the data itself, and pipes are the mechanism.

The Classic dataport exposes two pipe operations: writeToPipeIn to send a buffer to the FPGA, and readFromPipeOut to receive one. Both work in terms of ArrayBuffer and typed array views, both transfer the full byte length of the buffer you hand them, and both are designed to be efficient on payloads that range from a few kilobytes to many megabytes.

ArrayBuffer and Views

The Classic data port’s transfer methods accept a DataBuffer, which is one of:

  • ArrayBuffer — a raw block of bytes with no associated type.
  • ArrayBufferView — a typed wrapper around an ArrayBuffer, such as Uint8Array, Uint16Array, Uint32Array, or DataView.

The underlying memory is the same in both cases. A view simply tells JavaScript how to interpret those bytes when you index into it: Uint8Array[i] returns one byte, Uint16Array[i] returns two bytes interpreted as a 16-bit unsigned integer, and so on. Choosing a view that matches the natural width of your FPGA data lets you read and write integers directly without unpacking bytes manually.

Subarrays

Typed arrays (like Int8Array, Float32Array, and Uint8Array) expose a subarray(begin, end) method that returns a new view pointing at a slice of the same underlying memory — no copy is made. This method is useful for performance-sensitive work:

const data = new Uint8Array(1024);
const slice = data.subarray(256, 512);  // view over bytes [256, 512) of `data`
slice[0] = 0xff;                        // also writes to data[256]Code language: TypeScript (typescript)

The slice subarray references a block of 256 bytes that is offset by 256 bytes from the start of the source data array. Both the original data array and the slice subarray share the same underlying memory.

Classic Dataport

A common pattern is to send a block of input data to the FPGA for processing — an FFT, a filter, an inference pass, a hardware-accelerated transform — and read the result back out. The two halves use the same primitives:

const input = new Uint16Array(512);
// ...populate input with data to process...

const output = new Uint16Array(512);
let bytesRead = 0;

await workQueue.post(async () => {
    await fpgaDataPort.writeToPipeIn(0x80, input);
    bytesRead = await fpgaDataPort.readFromPipeOut(0xa0, output);
});Code language: TypeScript (typescript)

The address (0x80, 0xa0) identifies the target pipe endpoint configured in the FPGA design. The transfer length is determined by the buffer’s byteLength, not by its element count — a Uint16Array of 512 elements transfers 1024 bytes. readFromPipeOut fills the buffer in place and returns the number of bytes actually read.

Both calls share a single work queue ticket so the write and the read execute as one transaction. Whatever signaling the gateware uses to indicate “result is ready” — a status wire, a trigger, an internal handshake — happens between these two calls without other device traffic interleaving.

AXI Note

On AXI devices, the equivalent operations are axiStream.write() and axiStream.read() for streaming transfers, or the AXI Full transfer methods for memory-mapped bulk access.

For a complete working example with pattern generation, host-side verification, and UI integration, see the PipeTest example.

Transferring a Portion of a Buffer

When working with large payloads, it’s often necessary to read into or write from a portion of an ArrayBuffer rather than the whole thing. This comes up when streaming a capture into a pre-allocated buffer in chunks, when feeding a transfer with data assembled from multiple sources, or when the device prefers a smaller transfer size than the total payload.

The subarray method on a typed array returns a new view over a slice of the same underlying memory — no copy is made. Pass that view to the transfer method and the data lands directly in the right place:

const buffer = new Uint8Array(totalSize);
const segmentSize = 4096;

await workQueue.post(async () => {
    let offset = 0;
    while (offset < totalSize) {
        const length = Math.min(segmentSize, totalSize - offset);
        const segment = buffer.subarray(offset, offset + length);
        const bytesRead = await fpgaDataPort.readFromPipeOut(0xa0, segment);
        offset += bytesRead;
    }
});Code language: TypeScript (typescript)

Each iteration reads up to segmentSize bytes directly into the next region of buffer. There is no intermediate allocation and no copy between segments — the segment is just a window onto the same memory.

This pattern scales naturally to large buffers and is the foundation of any streaming or chunked transfer workflow. For a real-world example using zero-copy chunking on a large buffer, see the Camera app.