Reading Attributes#
ovstage.Stage.read_attributes() reads attribute
values for a query. It returns a read handle; fetch its groups
of results and release each group after use.
By default, reads return CPU buffers. The experimental placement= argument
can select a CUDA copy instead. A placed read with an availability event requires
device synchronization after fetching a group and before using its payload;
see Experimental Device Placement. NumPy group.array() is CPU-only.
First advance the watermark to cover the changes you need, and wait for that advance. See ovstage Overview for the read model.
Read → Fetch → Release#
This example writes values, advances the watermark, and reads them through a NumPy view in Python or a tensor pointer in C. It then releases the result group. Python releases the read handle on context-manager exit; the C example also releases it during cleanup.
# Write one value per prim, then advance the watermark before reading.
# wait() reports asynchronous failures; the context manager and finally
# block release the read and its result even if consuming the data fails.
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), advance the watermark to ordinal 1,
// then read the column back.
// Describe three contiguous CPU floats, one per prim in query order.
// The tensor references this array; keep it alive until the write completes.
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;
// This is one scalar per prim, rather than a variable-length array per prim.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;
// Submit the values at ordinal 1. UPSERT creates missing prims/attributes.
// waitOp checks both acceptance and completion before we proceed.
ovstage_enqueue_result_t enq =
ovstage_write_attribute(stage, query, attrArg, /*ordinal*/ 1, write, OVSTAGE_PRIM_MODE_UPSERT);
waitOp(stage, enq, "write_attribute");
// Finish writes through ordinal 1 for all attributes. Once this completes,
// the values can be read and further writes at ordinals <= 1 are rejected.
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");
// Read the values at ordinal 1 (a snapshot: no start ordinal). A later change
// to this attribute would make this read fail with OUT_OF_RANGE; use
// UINT64_MAX as the end to read current values.
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");
// Fetch the result data after the read completes. This uniform example
// returns one group; general readers fetch until END_OF_ITERATION.
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;
}
// Borrow the returned floats while the group is alive. Releasing the group
// invalidates this pointer; copy the values first if they must live longer.
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
Groups and Lifetime#
Keep the read handle in a
withblock and callread.wait().Fetch all results with
read.groups(), or callread.fetch_next()until it returnsNone. Do not assume one group contains the whole result.Release each group with its own
withblock orovstage.Stage.release_group().Release the read handle after fetching its results. Context-manager exit does this for you.
read.wait() observes execution errors and releases operation tracking. It
returns the handle, so read.wait().fetch_next() is valid inside the read’s
with block. Keep the handle in a variable; chaining directly from
stage.read_attributes(...) leaves it to the finalizer.
group.semantic gives the attribute’s ovstage.AttributeSemantic.
If the native library supplies a value unknown to the Python bindings, it returns
that value as an integer.
Groups own their storage separately from the read handle. Releasing the handle
does not release its groups. An unreleased group causes overlapping writes
to the same attributes and prims to fail. Dropping a group without releasing it triggers
a finalizer and ResourceWarning; release it explicitly instead.
Both group.array(i) and DLPack consumer arrays borrow the group’s memory.
They do not keep the group alive. Never take a view from a temporary such as
read.fetch_next().array(0) or
np.from_dlpack(read.fetch_next().dlpack(0)). Keep the group alive while using
its views, and copy any values that must outlive it. The group rejects access
after release, but previously returned views cannot detect that release.
See DLPack Tensor Exchange.
In C, ovstage_fetch_read_next() returns groups until
OVSTAGE_ERROR_END_OF_ITERATION. Release each with
ovstage_release_group(), then release the handle with
ovstage_release_read().
These snippets copy each selected heat value before releasing its group.
The query selects /World/Writes/A, /World/Writes/B, and /World/Writes/C;
the earlier write examples set their values to 300, 400, and 200, and advance
the watermark to 15. paths is the stage’s PathDictionary (dict in C).
The C helpers waitOp(), check(), and checkOvx() wait for operations
and check stage or dictionary errors. The copies remain usable after cleanup:
import numpy as np
import ovstage
from ovstage import (AttributeSemantic, DLDataType, DLDataTypeCode, OrdinalRange, OvstageError,
PrimMode, WriteDesc, make_dltensor)
# Copy selected values before releasing each group; copies outlive the read.
heat = paths.intern_token("heat")
saved = []
with stage.read_attributes(query, [heat], OrdinalRange.latest(15)) as read:
read.wait()
for group in read.groups():
with group:
rows = group.array(0)
for i in range(group.prim_count):
row = group.data_row_index(i) if group.has_data_index_map else i
saved.append(float(rows[row]))
# Sorting is for display; read groups need not arrive in query order.
print("Copied heat values:", sorted(saved))
# Copied heat values: [200.0, 300.0, 400.0]
const ovx_string_t name = literal_to_ovx_string("heat");
ovx_token_t attribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &attribute), "intern attribute");
ovstage_ordinal_range_t range{};
range.end_ordinal = 15;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &attribute, 1, range, &read), "read attributes");
std::vector<float> saved;
for (;;)
{
ovstage_read_group_t group{};
const auto status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
if (status == OVSTAGE_ERROR_END_OF_ITERATION)
break;
check(stage, status, "fetch group");
const DLTensor& tensor = group.data.tensors[0];
const auto* values = reinterpret_cast<const float*>(
static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
for (uint32_t i = 0; i < group.prims.count; ++i)
{
const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
saved.push_back(values[row]); // Copy the value before releasing its group.
}
check(stage, ovstage_release_group(stage, &group), "release group");
}
waitOp(stage, ovstage_release_read(stage, read), "release read");
// Copied values remain valid after both groups and the read are released.
std::sort(saved.begin(), saved.end());
std::printf("Copied heat values:");
for (float value : saved)
std::printf(" %.1f", value);
std::printf("\n");
// Copied heat values: 200.0 300.0 400.0
Fixed-Size Result Shape#
A raw fixed-size read group has one scalar-lane tensor with a leading
transported data-row axis. A point3f reads as (N, 3) and a matrix4d
with MATRIX semantic as (N, 4, 4); both have dtype.lanes == 1.
N is the transported data-row count and may differ from data.count
(the logical prim count): it can be smaller when data.index_map shares
rows, or larger when a query touches only part of a transported bucket. A
convenience write shape is not stored; the trailing read dimensions derive
from the stored component count and semantic. Python DLPack preserves them.
These snippets read the three-component velocity values written by the
same example. They select the correct tensor row for each prim before accessing
its components. For matrices, reshape the Python view to (-1, 4, 4) or use
16 components per row in C:
velocity = paths.intern_token("velocity")
with stage.read_attributes(query, [velocity], OrdinalRange.latest(15)) as read:
read.wait()
for group in read.groups():
with group:
# Raw and NumPy shapes are (N, 3), with scalar lanes.
vectors = np.from_dlpack(group.dlpack(0))
for i in range(group.prim_count):
row = group.data_row_index(i) if group.has_data_index_map else i
print("Velocity:", vectors[row].tolist())
# One of [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0].
const ovx_string_t name = literal_to_ovx_string("velocity");
ovx_token_t attribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &attribute), "intern attribute");
ovstage_ordinal_range_t range{};
range.end_ordinal = 15;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &attribute, 1, range, &read), "read attributes");
for (;;)
{
ovstage_read_group_t group{};
const auto status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
if (status == OVSTAGE_ERROR_END_OF_ITERATION)
break;
check(stage, status, "fetch group");
// Raw tensor shape is (N, 3), with scalar float32 lanes.
const DLTensor& tensor = group.data.tensors[0];
const auto* values = reinterpret_cast<const float*>(
static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
for (uint32_t i = 0; i < group.prims.count; ++i)
{
const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
const float* vector = values + row * 3;
// Use vector[0..2] here, while the group still owns the buffer.
(void)vector;
}
check(stage, ovstage_release_group(stage, &group), "release group");
}
waitOp(stage, ovstage_release_read(stage, read), "release read");
Reading Built-in Metadata#
The reserved attributes usd-path, usd-schemas, usd-prim-type,
usd-ancestor-types, usd-parent, and usd-children use 64-bit identifiers. Resolve names and
paths through the Path Dictionary. The Runtime Loop reads
usd-prim-type after population.
usd-path, usd-parent, and usd-children are computed from current scene
structure. Latest reads return them with the requested end ordinal on the result,
but that ordinal does not select historical structure. These attributes have
no per-write change records, so change-range reads return no groups for them.
They do not require a watermark advance.
usd-children can span multiple groups; fetch them all. A prim without a
value contributes no row: for example, the root has no parent and a leaf has
no children. Writes, deletes, and maps of these three names or usd-active
fail with ErrorCode.NOT_SUPPORTED. Reads and filters of usd-active also
fail: live prims are always active. The name is retained in the API for now
and may be removed.
These snippets query the direct children of /World/Group, read usd-path,
and resolve the identifiers to strings. This is the separate Queries example,
whose scene contains /World/Group/Left and /World/Group/Right. The Python tab includes its imports. The C helper resolvePath() resolves a path identifier through
the dictionary:
import pathlib
import numpy as np
import ovstage
from ovstage import Filter, FilterOp, Predicate
from ovstage import OrdinalRange
from ovstage import ErrorCode, OvstageError, PopulationDomain, instancing, population
# Query the direct children, then read their current path identifiers.
children_filter = Filter([
Predicate("usd-parent", FilterOp.IN, ["/World/Group"]),
])
path_attribute = paths.intern_token("usd-path")
matched_paths = []
with stage.query(filter=children_filter) as query:
query.wait()
with stage.read_attributes(query, [path_attribute], OrdinalRange.latest(2)) as read:
read.wait()
for group in read.groups():
with group: # Release every group after resolving its identifiers.
path_ids = group.array(0)
for i in range(group.prim_count):
row = group.data_row_index(i) if group.has_data_index_map else i
matched_paths.append(paths.path_to_string(int(path_ids[row])))
print("Matched paths:", " ".join(sorted(matched_paths)))
# Matched paths: /World/Group/Left /World/Group/Right
// Query the direct children of /World/Group.
const ovx_string_t parent = literal_to_ovx_string("/World/Group");
ovstage_predicate_t predicate{};
predicate.attribute.string = literal_to_ovx_string("usd-parent");
predicate.op = OVSTAGE_FILTER_OP_IN;
predicate.values = &parent;
predicate.value_count = 1;
ovstage_filter_t filter{};
filter.predicates = &predicate;
filter.count = 1;
ovstage_query_handle_t query = OVSTAGE_INVALID_QUERY_HANDLE;
waitOp(stage, ovstage_query(stage, &filter, nullptr, 0, &query), "query paths");
// usd-path contains prim-path identifiers resolved through the stage dictionary.
const ovx_string_t name = literal_to_ovx_string("usd-path");
ovx_token_t pathAttribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &pathAttribute),
"intern usd-path");
ovstage_ordinal_range_t range{};
range.end_ordinal = 2; // No start ordinal: read current paths.
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &pathAttribute, 1, range, &read), "read paths");
std::vector<std::string> matchedPaths;
for (;;)
{
ovstage_read_group_t group{};
const ovstage_api_status_t fetched =
ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
if (fetched == OVSTAGE_ERROR_END_OF_ITERATION)
break;
check(stage, fetched, "fetch_read_next");
const DLTensor& tensor = group.data.tensors[0];
const auto* pathIds = reinterpret_cast<const uint64_t*>(
static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
for (uint32_t i = 0; i < group.prims.count; ++i)
{
const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
matchedPaths.push_back(resolvePath(dict, pathIds[row]));
}
check(stage, ovstage_release_group(stage, &group), "release_group");
}
waitOp(stage, ovstage_release_read(stage, read), "release_read");
waitOp(stage, ovstage_release_query(stage, query), "release_query");
std::sort(matchedPaths.begin(), matchedPaths.end());
std::printf("Matched paths:");
for (const std::string& path : matchedPaths)
std::printf(" %s", path.c_str());
std::printf("\n");
// Matched paths: /World/Group/Left /World/Group/Right
Read Ranges and Errors#
A snapshot selects every queried path for each requested attribute. An inclusive
change range [start, end] selects only pairs changed in that interval.
Neither form restores earlier values. The checks below describe the underlying
read failure; asynchronous wait/fetch can instead expose OP_FAILED with a
diagnostic. See Error Handling and Diagnostics before branching on an error code.
Note
usd-active is currently not supported: a live prim is always
active, so the attribute carries no information, and reads or filter
predicates naming it return NOT_SUPPORTED. The name remains in the
header contract for stability only and is subject to removal in a future
release.
Note
This build stores only current attribute values. A snapshot
(OrdinalRange.latest(end) in Python) checks every queried path for each
requested attribute. A change range [start, end] checks only queried
(attribute, path) pairs changed within that range.
For each requested attribute, both forms apply these checks in order:
A selected, unsealed change at or before
endreturnsOVSTAGE_ERROR_WRITE_FLOOR_VIOLATION.A recorded change after
endon a selected path returnsOVSTAGE_ERROR_OUT_OF_RANGE, whether that later change is sealed or not.
Changes on unrelated paths do not affect these checks. For example, seal a
write at 3, then overwrite it at 5. Reading that attribute with end 4 returns
OUT_OF_RANGE. Sealing 5 does not restore the old value. Use a newer end
or UINT64_MAX ((1 << 64) - 1 in Python) for current state. An end of
UINT64_MAX skips the second check, but a selected unsealed change still
returns WRITE_FLOOR_VIOLATION. Because a later change after the end makes
a bounded read return OUT_OF_RANGE, a bounded read is not a replay point. A zero-initialized C range is a snapshot at
ordinal 0, not a request for current values.
While the end is above the attribute’s write floor, changes between the floor
and the end can still be admitted and, once sealed, change what the same
request returns. Once the end is at or below the floor, later writes, deletes
and maps cannot change the recorded values that a successful read of the same
request returns for paths the query still selects. This does not cover
derived built-ins or query membership, which reflect current state, changes
that ovstage_compute_hierarchy records at or below the floor, or history
below the retention frontier.
A returned group’s ordinal is informational. It may be above or below the requested end and is not, by itself, proof of which state the payload represents.
These checks use recorded changes. Snapshot reads of values without change
records, including derived built-ins, return current state. Queries select
current paths, not the paths present at an earlier ordinal. Under
FLOOR_ONLY admission, a write or delete above the applicable floors can
be admitted at an older ordinal than an existing change to the same attribute
and path; reads then return the most recently applied change.
STRICTLY_INCREASING and the default NONDECREASING policy reject such older changes
against retained history.
Before relying on an old snapshot or interval, query the retention frontier
with ovstage_get_oldest_preserved_ordinal (Stage.get_oldest_preserved_ordinal()
in Python). History below that frontier may have been discarded, so a
successful read does not guarantee the requested earlier state. For example,
after an attribute’s deletion history is discarded, a snapshot from before
that deletion may return no row for the attribute instead of OUT_OF_RANGE.
In the write-options example, A and B last changed heat at ordinal 14,
while C last changed at 12. These snippets select the inclusive interval
[14, 14], so only A and B contribute values. The watermark is already 15,
and no later writes have replaced those values:
# Only A and B changed heat at ordinal 14; C last changed at ordinal 12.
heat = paths.intern_token("heat")
changed = []
with stage.read_attributes(query, [heat], OrdinalRange.between(14, 14)) as read:
read.wait()
for group in read.groups():
with group:
if group.is_delete:
continue # Deletions carry no value tensor.
values = group.array(0)
for i in range(group.prim_count):
row = group.data_row_index(i) if group.has_data_index_map else i
changed.append(float(values[row]))
print("Heat values changed at 14:", sorted(changed))
# Heat values changed at 14: [300.0, 400.0]
const ovx_string_t name = literal_to_ovx_string("heat");
ovx_token_t attribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &attribute), "intern attribute");
ovstage_ordinal_range_t range{};
// A and B changed at 14; C last changed at 12.
range.has_start_ordinal = true;
range.start_ordinal = 14;
range.end_ordinal = 14;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &attribute, 1, range, &read), "read attributes");
std::vector<float> saved;
for (;;)
{
ovstage_read_group_t group{};
const auto status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
if (status == OVSTAGE_ERROR_END_OF_ITERATION)
break;
check(stage, status, "fetch group");
if (group.is_delete)
{
// Deletions carry no value tensor; still release their group.
check(stage, ovstage_release_group(stage, &group), "release deletion group");
continue;
}
const DLTensor& tensor = group.data.tensors[0];
const auto* values = reinterpret_cast<const float*>(
static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
for (uint32_t i = 0; i < group.prims.count; ++i)
{
const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
saved.push_back(values[row]); // Copy the value before releasing its group.
}
check(stage, ovstage_release_group(stage, &group), "release group");
}
waitOp(stage, ovstage_release_read(stage, read), "release read");
std::sort(saved.begin(), saved.end());
std::printf("Heat values changed at 14:");
for (float value : saved)
std::printf(" %.1f", value);
std::printf("\n");
// Heat values changed at 14: 300.0 400.0
Retained Change Records#
Before relying on an old interval, call
ovstage.Stage.get_oldest_preserved_ordinal(), wait for the returned
ordinal query, fetch its value, and release the query. Records of which
attributes changed on which prims are exact at or above that value. Older records
may have been combined or discarded; do not assume a fixed retention depth.
This boundary describes change records, not stored versions of values. A value can be unavailable even when its change record is retained.
These snippets check whether an interval starting at ordinal 14 is within the retained change records. They release the ordinal query after fetching its result; the check does not guarantee that the interval’s values still exist:
# Query the boundary before relying on exact membership of an old interval.
with stage.get_oldest_preserved_ordinal() as ordinal_query:
ordinal_query.wait()
oldest = ordinal_query.fetch()
requested_start = 14
if requested_start < oldest:
raise RuntimeError("Change records for this interval are no longer exact")
# A subsequent range read can still fail if a newer write replaced its values.
// Query the boundary before relying on exact membership of an old interval.
ovstage_ordinal_query_handle_t ordinalQuery{};
waitOp(stage, ovstage_get_oldest_preserved_ordinal(stage, &ordinalQuery), "query oldest ordinal");
ovstage_ordinal_t oldest = 0;
check(stage, ovstage_fetch_ordinal(stage, ordinalQuery, OVSTAGE_TIMEOUT_INFINITE, &oldest), "fetch ordinal");
waitOp(stage, ovstage_release_ordinal_query(stage, ordinalQuery), "release ordinal query");
const ovstage_ordinal_t requestedStart = 14;
if (requestedStart < oldest)
{
std::fprintf(stderr, "Change records for this interval are no longer exact\n");
std::exit(EXIT_FAILURE);
}
// A subsequent range read can still fail if a newer write replaced its values.
Further Reading#
Queries — build the query a read runs over.
Writing Attributes — produce the data you read back.
DLPack Tensor Exchange — interpreting the returned tensors and their residency.
See Device Placement (Experimental) for the experimental GPU-to-GPU workflow, extension access, event ordering, and buffer lifetimes.