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:
falsefor a fixed-size attribute (one tensor with transported data rows stacked along its leading dimension),truefor 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 elementireads 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_countfor per-row array transport — every entry must be below it, and an out-of-range entry is rejected withOVSTAGE_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 intomax(index_map) + 1uniform 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
countlogical elements where only set bits are written, leaving the remaining prims untouched. Use this — notindex_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 thecountlogical elements, including the unselected ones. Elementiis biti % 64of wordi / 64, so a non-null mask must contain at leastceil(count / 64)uint64_twords — 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#
Reading Attributes — read the sealed column back.
DLPack Tensor Exchange — tensor layout, residency, and the zero-copy map/unmap alternative.
Asynchronous Submit/Observe Model — ordinals, the write floor, and observing the write.