Writing Attributes#

write_attribute copies a column of data — one value per prim in a query — into the stage at an explicit ordinal. The write is asynchronous: it enqueues and returns an op_index, and the data becomes readable only after you advance the write floor to seal that ordinal.

The Write → Seal Sequence#

The minimal example builds a DLTensor over one float per prim, writes it at ordinal 1, and advances the write floor:

# Write one float per prim into the "temperature" column at ordinal 1,
# seal it by advancing the write floor to 1, then read it back. Tensor
# data crosses as a numpy array (CPU) via DLPack; async ops return an
# Operation whose .wait() raises OvstageError on failure. The read is
# a context manager -- block exit releases its handle even when an
# error interrupts -- and the fetched group is released in a finally.
stage.write_attribute(
    query, attr, ordinal=1, tensors=np.array([1.0, 2.0, 3.0], np.float32), is_array=False
).wait()
stage.advance_write_floor(ordinal=1).wait()

with stage.read_attributes(query, [attr], OrdinalRange.latest(1)) as read:
    read.wait()
    group = read.fetch_next()
    if group is None:
        raise SystemExit("read returned no group at ordinal 1")
    try:
        # group.array(i) is a zero-copy numpy view of tensor i (CPU).
        print("read back ordinal", group.ordinal, group.array(0))  # -> [1. 2. 3.]
    finally:
        stage.release_group(group)
// Write one float per prim into the "temperature" column (UPSERT creates
// the prims on first write), seal it by advancing the write floor to
// ordinal 1, then read the column back.
float values[] = { 1.0f, 2.0f, 3.0f };
int64_t shape[] = { 3 };
int64_t strides[] = { 1 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 1;
tensor.dtype = { kDLFloat, 32, 1 }; // {code, bits, lanes}
tensor.shape = shape;
tensor.strides = strides;

ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;

ovstage_enqueue_result_t enq =
    ovstage_write_attribute(stage, query, attrArg, /*ordinal*/ 1, write, OVSTAGE_PRIM_MODE_UPSERT);
waitOp(stage, enq, "write_attribute");

ovstage_write_floor_desc_t writeFloor{};
writeFloor.ordinal = 1;
writeFloor.scope = OVSTAGE_SCOPE_ALL;
enq = ovstage_advance_write_floor(stage, &writeFloor);
waitOp(stage, enq, "advance_write_floor");

ovstage_ordinal_range_t range{};
range.end_ordinal = 1;
range.has_start_ordinal = false;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
enq = ovstage_read_attributes(stage, query, &attr, 1, range, &read);
waitOp(stage, enq, "read_attributes");

ovstage_read_group_t group{};
status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
check(stage, status, "fetch_read_next");
if (group.data.tensor_count != 1 || !group.data.tensors[0].data)
{
    std::fprintf(stderr, "unexpected read layout\n");
    return EXIT_FAILURE;
}
const float* out = static_cast<const float*>(group.data.tensors[0].data);
std::printf("read back ordinal %llu: %.1f %.1f %.1f\n", (unsigned long long)group.ordinal, out[0], out[1], out[2]);
ovstage_release_group(stage, &group); // the tensor data is only valid until the group is released

Key Parameters#

  • attribute — the column key, given as a token or string (refer to String Handling).

  • ordinal — must be above the current write floor, or the write is rejected with OVSTAGE_ERROR_WRITE_FLOOR_VIOLATION.

  • is_array — declares the attribute kind explicitly: false for a fixed-size attribute (one tensor with transported data rows stacked along its leading dimension), true for a ragged/array attribute (a single packed tensor or one tensor per data row). Refer to DLPack Tensor Exchange.

  • tensors — the DLPack payload; can be CPU- or CUDA-resident.

Fixed-Size Write Shapes#

The canonical fixed-size input has shape = [N] and places the complete per-row tuple width in dtype.lanes. Convenience inputs such as (N, 3) with one lane for a point or (N, 4, 4) with one lane for a matrix are also accepted. Their trailing dimensions are folded into lanes and are not preserved: raw reads and maps return (N,) with 3 or 16 lanes. Here N is the source data-row count; index_map can associate multiple logical target prims with the same source row. Without index_map, N must equal the logical target count. A flat (N * L,) one-lane tensor is not inferred as N rows of width L; use (N, L) or canonical lanes. Array/ragged attributes do not use this rule.

Array Write Element Widths#

Array writes do not fold trailing dimensions into lanes. dtype.lanes is the element width and is taken exactly as sent, so an array payload’s element count is total_bytes / (bits * lanes / 8) regardless of its shape. A (P, 3) one-lane tensor is therefore 3 * P scalar elements, not P three-component ones — the opposite of the fixed-size rule above.

This matters because (P, 3) with one lane is what NumPy and Warp emit for a vec3f array. Writing one against an existing float3[] column is rejected, but on an attribute that does not exist yet nothing contradicts it: the write succeeds and creates a float[] column of 3 * P scalars. This is the one place where a descriptor that omits the producer’s intent yields a wrong schema rather than an error.

State the element width on the descriptor rather than relying on the shape. make_dltensor re-describes the producer’s buffer in place — a validated, metadata-only change with no copy:

float3 = ovstage.DLDataType(code=ovstage.DLDataTypeCode.kDLFloat, bits=32, lanes=3)
points = ovstage.make_dltensor(warp_points, dtype=float3)  # (P,3) lanes=1 -> (P,) lanes=3

Targeting a Subset of the Query#

count is the number of logical elements a write addresses — the leading count prims of the query, in query order. It defaults to the query’s full prim count and may not exceed it. Two mutually exclusive parameters refine that element axis, and in the C API both require an explicit non-zero count:

  • index_map selects source data, not targets. index_map[i] is the transported row that logical element i reads from, so it gathers, reorders, or broadcasts rows — index_map = [0, 0] writes one source row to two prims. The map holds one entry per logical element. Where the payload declares its own row count — shape[0] for a fixed-size write, tensor_count for per-row array transport — every entry must be below it, and an out-of-range entry is rejected with OVSTAGE_ERROR_INVALID_ARGUMENT. Rows the map never references are simply unused, so a payload may be wider than the query it is written through. Packed array transport declares no row count of its own, so there the map defines one: the payload is cut into max(index_map) + 1 uniform rows. Rows above the highest entry cannot be expressed that way — use one tensor per row when rows must be described individually.

  • mask selects targets: a bitmask over the count logical elements where only set bits are written, leaving the remaining prims untouched. Use this — not index_map — to write some prims of a wider query. A mask does not change how the payload is cut into rows, so the payload must still carry a row for every one of the count logical elements, including the unselected ones. Element i is bit i % 64 of word i / 64, so a non-null mask must contain at least ceil(count / 64) uint64_t words — the runtime reads exactly that many, and a shorter buffer is read past its end.

A common mistake is reaching for index_map to pick target prims. To write only the second prim of a two-prim query, use mask with the second bit set, or build a query that covers just that prim.

Note

In Python, count is filled in for you when index_map is given without it (defaulting to len(index_map), which addresses only the query’s leading prims). mask has no default: supply count and enough 64-bit words to cover it, or the binding raises ValueError. The query-prim-count default applies only when neither parameter is present. An explicit count must be positive there — 0 is this contract’s spelling of “the whole query”, so the binding rejects it rather than let len() of an empty selection widen a write to every prim.

Sealing with the Write Floor#

A write is not observable until ovstage_advance_write_floor seals its ordinal. Advancing the floor is monotonic in effect: a backwards advance clamps rather than erroring. Reads then target data at or below the floor.

Attribute Semantics#

Writes carry an ovstage_attribute_semantic_t (AttributeSemantic in Python; NONE by default). Geometric semantics stamp a role on the column — for example a 4×4 transform is written with the MATRIX semantic. Identity semantics (token / relationship / connection path ids) pin the column’s base type and require pre-interned id payloads. For a worked transform-write example over successive ordinals, refer to Runtime Loop.

Passing the Attribute as String or Token#

# Attribute arguments accept an interned token (int) or a plain str:
# a token skips the per-call dictionary lookup, a str is interned for
# you at call time.
stage.write_attribute(
    query, "temperature", ordinal=2, tensors=np.array([4.0, 5.0, 6.0], np.float32),
    is_array=False,
).wait()
stage.advance_write_floor(ordinal=2).wait()
// Attributes pass as ovx_string_or_token_t. We already hold an interned
// token, so set it (token != 0) and leave the string empty to skip a lookup.
ovx_string_or_token_t attrArg{ attr, {} };

Where to Go Next#