Shared Memory
Objects that live outside every lane and cross by id, for data too large to copy and for lanes that need to meet.
Shared objects live outside every lane’s memory, in the host, and each
lane reaches them by id. Handing one to another lane copies nothing: the other
lane gets the id of the same object. Most of them import from
rive/shared, and Port imports from
rive/ports.
The shared types
| Type | What it holds | Who waits |
|---|---|---|
Blob | bytes fixed once made | nobody |
SharedRegion | bytes behind one lock, plus atomic words | a lane taking the lock |
Ring<T> | values in arrival order, up to a byte capacity | nobody: a full ring refuses the send |
Snapshot<T> | the latest value, up to a byte capacity | nobody |
Signal | a counter that wakes awaits when it is notified | an await, or the main lane in waitWithin |
Port<T> | typed messages over a ring, with awaits for a message or for room | an await, or the main lane in
receiveWithin |
Barrier | the rounds where workers splitting one computation meet, with a share of floats from each | an await, until the round’s last participant arrives |
None of them ever makes a lane wait, except a region’s lock and the two bounded waits that only the main lane has. Audio processors cannot take a region’s lock. A lane holds at most one region’s lock at a time, and can neither join a job nor block on a signal while it holds one. So locks can never deadlock.
As a rule of thumb:
- A
Snapshotcarries knobs and meters: the latest value, where older ones no longer matter. - A
Ringcarries messages and samples in order. Writers and readers never wait, so the audio thread may use one. - A
Portcarries requests to a worker and replies from it. A worker can await a message, or room for one. - A
Signalwakes a worker. Notifying never waits, so the audio thread may notify. - A
SharedRegionholds bytes that several lanes change, behind a lock. - A
Blobholds large data that never changes, such as a mesh or a sample bank. - A
Barrierlets workers that split one computation meet; see Workers & Barriers.
Regions
A SharedRegion is a block of bytes behind one lock, zeroed when
it is made. exclusive(fn) runs fn holding the lock,
and hands it a view of the bytes. The view reads and writes numbers at byte
offsets, with methods such as getF32 and setU32. It
is valid only inside exclusive.
The view’s readInto and writeFrom copy a
range between the region and a buffer or array you hold, and allocate nothing.
Here a worker fills a region and notifies a signal, and the main lane copies
the values out once the signal has moved:
import { Layout, Context } from "rive/host";
import { Worker } from "rive/workers";
import { SharedRegion, Signal } from "rive/shared";
const kCount: i32 = 256;
@worker export async function fill(region: SharedRegion, ready: Signal): Promise<void> {
let values = new Float32Array(kCount);
for (let i = 0; i < kCount; i++) {
values[i] = <f32>i * 0.5;
}
region.exclusive((view) => {
view.writeFrom(0, values);
});
ready.notify();
}
export class Filled extends Layout {
region: SharedRegion = SharedRegion.create(<u32>kCount * 4);
ready: Signal = Signal.create();
values: Float32Array = new Float32Array(kCount);
seen: f64 = 0;
worker: Worker? = null;
override init(context: Context): bool {
// Read before the worker can notify, so no notify is missed.
this.seen = this.ready.seq;
this.worker = Worker.spawn(fill, this.region, this.ready);
return true;
}
override advance(seconds: f64): bool {
if (this.ready.changedSince(this.seen)) {
this.seen = this.ready.seq;
let values = this.values;
this.region.exclusive((view) => {
view.readInto(0, values);
});
}
return true;
}
}
A lock wait never outlasts the caller’s call budget, nor two seconds
in a lane without one. Past that, exclusive throws.
tryExclusive(fn, seconds) gives up instead, and answers false.
A region also has atomic words. atomicLoad,
atomicStore, atomicAdd and
atomicCompareExchange work on a 4 byte aligned word without
taking the lock. poisoned turns true once a lane trapped while
holding the region, so its bytes may be half written.
Capacities and sizing
A ring, port or snapshot holds only what fits its byte capacity, which is set when it is made:
Ring.create<T>(capacityBytes)andPort.create<T>(capacityBytes)default to 4096 bytes.Snapshot.create<T>(capacityBytes)defaults to 1024 bytes. A snapshot keeps two copies, so it takes twice its capacity from shared memory, and its capacity is at most 128 MB.- Any one shared object holds at most 256 MB. A file and its lanes hold at most 512 MB of shared memory at once, by default.
A value counts its encoded size against the capacity. Today that is its
encoded bytes, plus 4 for each shared object it names, and 4 more when it names
any. In a ring or port, add 4 more for its length. So an f64 needs
a ring of 12 bytes. The encoding belongs to the bake that made the file, and a
later bake may size values differently. So leave a capacity some room rather
than sizing it to the byte.
A value that needs more than the whole capacity never fits. A ring’s
send answers false, a snapshot keeps its last value, and a
port’s post throws. The console warns the first time it
happens, except on an audio processor. Send such data as a blob instead, as
Large data shows.
A ring that is only full for now also refuses a send. A port’s
post then answers false, and a worker can await
writable() and post again.
How long a shared object lives
- On the main lane, each handle keeps its object alive until the handle
is collected, or released with
release(). - A job, worker or audio processor holds each object it was started with or received, until it releases it or is gone.
- A value in flight through a ring or snapshot keeps what it names alive: a ring’s record until it is read, a snapshot’s value until another replaces it. So the sender may let go at once.
- An object a worker creates belongs to the script that spawned the worker. The worker holds it until it releases it, or until the worker is over and its handle goes. So a long lived worker releases each blob once it has posted it.
An object goes once nothing holds it, or with the script that made it.
Moving bytes by range
A blob, or a region under its lock, moves bytes in and out by range, so nothing needs a slice or a staging buffer first.
| Call | What it does |
|---|---|
Blob.from(buffer, byteOffset, byteLength) | copies
part of an ArrayBuffer into a new blob |
Blob.fromArray(values, offset, count) | copies part
of a typed array, or of an Array or StaticArray of
numbers, into a new blob. What a job builds becomes a blob in one copy, or
two on the web. |
blob.readInto(srcOffset, out, dstOffset, count) | copies
from a blob into part of out, and answers how much it copied:
fewer where the blob ends first |
view.readInto(offset, out, dstOffset, count) | copies
from a region into part of out |
view.writeFrom(offset, values, srcOffset, count) | copies
part of values into a region |
The buffer or array on the other side is an ArrayBuffer, a
typed array, or an Array or StaticArray of numbers.
An offset into a blob or a region counts bytes. Offsets and counts on the other
side count bytes in an ArrayBuffer, and values in an array. A
count left out covers the rest of the buffer or array on the other side.
readInto and writeFrom allocate nothing, so audio
code reads a blob with readInto.
Straight to the GPU
A blob’s bytes can go to a GPUBuffer without passing
through the script’s own memory:
buffer.updateFromBlob(dstOffset, blob, byteCount, srcOffset)uploads a blob’s bytes to a buffer. The host copies them straight from shared memory, and the blob may be released as soon as the call returns.GPUBuffer.fromBlob(usage, blob, immutable, byteCount, srcOffset)makes a new buffer from a blob’s bytes the same way. So a mesh that never changes can be animmutablebuffer.
Updating a mutable buffer while a draw still uses it is safe, since that draw keeps the bytes it was given. In this example a job skins a mesh into a blob, and the main lane uploads it once the job is over:
import { Layout, Context } from "rive/host";
import { GPUBuffer } from "rive/gpu";
import { BufferUsage } from "rive/gpu_enums";
import { Job, Jobs } from "rive/jobs";
import { Blob } from "rive/shared";
const kVertexBytes: i32 = 4096;
@job export function skin(pose: Float32Array): Blob {
// Sized for the worst case; only what was written is sent.
let vertices = new Float32Array(2 * kVertexBytes / 4);
for (let i = 0; i < kVertexBytes / 4; i++) {
vertices[i] = pose[i % pose.length];
}
return Blob.from(vertices.buffer, 0, kVertexBytes);
}
export class Skinned extends Layout {
vertices: GPUBuffer? = null;
skinning: Job<Blob>? = null;
override init(context: Context): bool {
this.vertices = new GPUBuffer(BufferUsage.vertex, <u32>kVertexBytes);
this.skinning = Jobs.run(skin, new Float32Array(16));
return true;
}
override advance(seconds: f64): bool {
let skinning = this.skinning;
if (skinning != null && !skinning.running) {
this.skinning = null;
let skinned = skinning.peek()!.value;
skinning.release();
this.vertices!.updateFromBlob(0, skinned);
skinned.release();
}
return true;
}
}
Large data
A value that crosses is copied by the lanes at both ends. The sender encodes it inside the call that sends it, and the reader decodes it inside the call that reads it. So a large result costs the frame that reads it. Numbers are copied as blocks, but every other object is made on its own:
- A typed array, or an
ArrayorStaticArrayof numbers, is one block. 4 MB decodes in under half a millisecond. - Every object, string and nested array is made one at a time. 350,000 objects of three numbers take about 4 ms to decode when the script runs compiled, and about 25 ms on the interpreter.
- The first large result also grows the memory of the lane that reads it, which costs several times more, once.
- What the main lane keeps of a result made of many objects is moved by the garbage collector at the next frame, which can cost more than the decode itself.
A Blob crosses by id whatever its size, so reading one decodes
nothing. For large data:
- Make the blob where the data is computed, in a job or a worker. Build
the numbers in a typed array, an
Arrayor aStaticArray, then return or sendBlob.fromArray(values). - Hand a fetched body to a job as the
Blobthatresponse.blob()resolves with, rather than as anArrayBufferfromarrayBuffer(), which first copies it into the lane. Await it into a local first, since an await cannot sit inside a call’s arguments. - Publish data that every job reads, such as a style, once to a
Snapshotlarge enough for it, and pass the snapshot. Its handle crosses by id, and each job then decodes the data on its own thread. - Read a blob without decoding it: upload it with
updateFromBloborGPUBuffer.fromBlob, or copy just the range you need withreadInto. - Keep messages small, and send large data, such as a tile’s geometry, as a blob the message names. Post the message and release your own handle on the blob. The message holds the blob until it is read, and the reader then holds it until it releases it.
- A
StaticArrayalways sends its whole length, so one kept as a buffer with a count of its own sends its capacity. Keep such a buffer as a typed array and sendsubarray(0, count), or hand overBlob.fromArray(buffer, 0, count).
Large blob assets
A blob asset reaches the lanes through the main lane, since only the main
lane reaches the host. context.blob(name) copies the
asset’s bytes into the main lane’s memory, and
Blob.from copies them again into shared memory. The main
lane’s memory never shrinks, so a large asset loaded whole leaves the
main lane that much bigger for good.
For now, split a large asset into several blob assets. Load one per advance
with context.blob and Blob.from, so the main lane
grows by about one part. Release each Blob once the workers that
read it have unpacked it.
For every detail of the API, run
rive docs animascript/api/shared and
rive docs animascript/api/ports. The full guide is
rive docs animascript/threading.