Set Up a C/C++ Project#

ovstage exposes a pure C API. A C/C++ consumer includes <ovstage/ovstage.h> for the data plane and links ovstage::ovstage or ovstage::ovstage_static; either CMake target provides the public include path.

Note

The examples build standalone with CMake against the released ovstage package using find_package(ovstage) (refer to examples/c/cmake/ovstage.cmake). ovstage is pre-release; if the pinned package cannot be fetched, the public package is not published yet. Refer to Getting Started in C.

Headers#

Include

Provides

<ovstage/ovstage.h>

The data-plane API, ovstage_get_path_dictionary, and the path-dictionary / DLPack types.

<ovx/path_dictionary/path_dictionary.h> + path_dictionary_utils.h

The path-dictionary functions (interning tokens, building path lists). Include these directly to call them.

<dlpack/dlpack.h>

The DLTensor types for attribute data.

Build Wiring#

The public examples wire up ovstage with a small CMake helper, examples/c/cmake/ovstage.cmake:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/../cmake")
include(ovstage)
ovstage_fetch()                 # find_package(ovstage), else fetch the release

add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE ovstage::ovstage)
ovstage_setup_runtime(myapp)    # rpath (Linux) / package bin/ on PATH (Windows)

ovstage::ovstage is the shared forwarding loader. The operating system loads it with the application; it opens the ovstage runtime on the first initialize/create call. ovstage::ovstage_static provides the same forwarding behavior as a static library. Linking either target brings in the public include/ tree, so #include <ovstage/ovstage.h> resolves with no extra include paths. Then follow the minimal example’s lifecycle: create → get path dictionary → intern / build path list → query → write → advance write floor → read → release. Refer to Application Flow.

Linux Toolchain Requirements#

The Linux packages target the manylinux_2_35 baseline (glibc 2.35+, e.g. Ubuntu 22.04). Linking an application against the SDK makes the linker walk the pre-packaged runtime’s dependencies, which resolve a few libraries from the host system: libX11.so.6, libGL.so.1, and libgomp.so.1 (Debian/Ubuntu packages libx11-6, libgl1, libgomp1). Install those before building, even if your own code never touches X11 or OpenGL.

The build must run with the distribution’s binutils (as, ld). GCC locates both via PATH, so a Homebrew/Linuxbrew installation that has linked its own binutils (Homebrew’s default on Linux since June 2026) silently replaces the system tools — even when CMake selected /usr/bin/c++. Depending on the Homebrew and host glibc versions, the build then fails at compile time (as: ... version 'GLIBC_2.xx' not found) or at link time with unresolved X11/GL symbols or GLIBC_2.xx version errors reported against the pre-packaged runtime libraries. Remedies, in preference order:

  • brew unlink binutils, or

  • remove the Homebrew bin directory from PATH for the build shell, or

  • add -B/usr/bin to the compiler flags (forces GCC to use the system binutils for this build only).

find_package(ovstage) performs a configure-time check and warns when it detects a Homebrew linker; pass -DOVSTAGE_SKIP_LINKER_CHECK=ON to CMake (or set that environment variable) to silence it if you are deliberately using a custom toolchain.

Diagnostics Caveat#

ovstage_get_error_string and ovstage_get_last_op_error are vtable-dispatched and need a live ovstage_instance_t*. Before ovstage_create_instance returns, you can only print the numeric status code or use the free function ovstage_get_last_error() (refer to Error Handling and Diagnostics).

Static Loader Package Root#

When linking ovstage::ovstage_static, pass the package bin/ directory via ovstage_config_entry_binary_package_root_path() to ovstage_initialize() before any other ovstage_* call. The path may include OVX_CONFIG_EXECUTABLE_DIR_TOKEN ("${executable_dir}"), which the loader substitutes with the absolute directory of the running executable — for example, when the package lives at <exe_dir>/ovstage/bin:

ovstage_config_entry_t entries[] = {
    ovstage_config_entry_binary_package_root_path(
        literal_to_ovx_string(OVX_CONFIG_EXECUTABLE_DIR_TOKEN "/ovstage/bin")),
    ovstage_config_entry_runtime_default_hierarchy_computation_model(
        OVSTAGE_HIERARCHY_COMPUTATION_MODEL_GPU_INCREMENTAL),
};
ovstage_config_t config = { entries, 2 };
ovstage_initialize(&config);

The runtime-default model is process-scoped. It drives automatic transform updates for newly created instances and can be selected explicitly:

ovstage_compute_hierarchy(
    stage,
    OVSTAGE_HIERARCHY_COMPUTATION_MODEL_RUNTIME_DEFAULT,
    input_ordinal,
    output_ordinal);

Configure a concrete CPU or GPU model on the first process reference. Passing RUNTIME_DEFAULT as the configured value leaves the runtime default unmodified; that config entry is ignored.

After creating an instance, ovstage_get_config() returns a borrowed view of the explicit entries that established the active process state. The view does not materialize omitted defaults and remains valid until the instance is destroyed.

Configuring Ordinal Admission#

Pass an entry from ovstage_config_entry_runtime_default_ordinal_admission_policy() to ovstage_initialize() before creating the first instance. The ovstage_ordinal_admission_policy_t choices are described in Ordinal Admission Policies. The default is OVSTAGE_ORDINAL_ADMISSION_NONDECREASING.

// Choose the policy before the first initialize or instance in this lifetime.
// NONDECREASING allows another edit at the latest ordinal, but rejects older edits.
ovstage_config_entry_t entry = ovstage_config_entry_runtime_default_ordinal_admission_policy(
    OVSTAGE_ORDINAL_ADMISSION_NONDECREASING);
const ovstage_config_t config{ &entry, 1 };
ASSERT_EQ(ovstage_initialize(&config), OVSTAGE_OK);
initialized_ = true;

ovstage_instance_desc_t desc{};
desc.name = "test.ovstage.ordinal-admission-config";
ASSERT_EQ(ovstage_create_instance(&desc, &stage_), OVSTAGE_OK);
ASSERT_NE(stage_, nullptr);
ovstage_config_t captured{};
ASSERT_EQ(ovstage_get_config(stage_, &captured), OVSTAGE_OK);
ASSERT_EQ(captured.entry_count, 1u);
EXPECT_EQ(captured.entries[0].key_type, OVSTAGE_CONFIG_KEY_TYPE_UINT64);
EXPECT_EQ(captured.entries[0].key.uint64_key, OVSTAGE_CONFIG_RUNTIME_DEFAULT_ORDINAL_ADMISSION_POLICY);
EXPECT_EQ(captured.entries[0].value.uint_value, OVSTAGE_ORDINAL_ADMISSION_NONDECREASING);

The policy is process-scoped. Omission or OVSTAGE_ORDINAL_ADMISSION_DEFAULT inherits active settings; on the first process reference it selects the nondecreasing default. A later concrete value must match. Conflicts in either hierarchy or admission configuration reject the whole initialization without changing state or acquiring a reference. Balance every successful initialization with ovstage_shutdown() and release all instances, including retained handles and attachments, before choosing a different policy. The original explicit entries remain available through ovstage_get_config(); omitted defaults are not added.

Minimal Program#

// 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

Where to Go Next#