.. _l-design-no-copy-ownership: ModelProto creation and no-copy ownership ========================================== This page explains exactly who owns tensor ``raw_data`` when ``no_copy=True`` is enabled, when ownership is transferred, and when memory is released. It also documents the in-place tensor consolidation function :func:`onnx_light.onnx.consolidate_tensors_to_buffer` (C++: :cpp:func:`~onnx_light::ConsolidateTensorsToBuffer`) that produces the same kind of shared-buffer ownership after loading. Options class hierarchy ----------------------- Buffer-related options (alignment, size threshold) are shared across several operations and are factored into a common base class: * :cpp:struct:`~onnx_light::TensorBufferOptions` – base class with ``raw_data_threshold`` (default: 0) and ``alignment`` (default: 0). * :cpp:struct:`~onnx_light::ParseOptions` inherits :cpp:struct:`~onnx_light::TensorBufferOptions`; its ``raw_data_threshold`` defaults to 1024 bytes. * :cpp:struct:`~onnx_light::SerializeOptions` inherits :cpp:struct:`~onnx_light::TensorBufferOptions`; its ``raw_data_threshold`` defaults to ``kSmallTensorDataThresholdBytes`` (64 bytes). Any function that accepts a :cpp:struct:`~onnx_light::TensorBufferOptions` reference also accepts :cpp:struct:`~onnx_light::ParseOptions` or :cpp:struct:`~onnx_light::SerializeOptions` objects. Core objects and where ownership lives -------------------------------------- Every tensor stores bytes in ``TensorProto::raw_data`` (type :cpp:class:`~onnx_light::utils::ByteSpan`). That :cpp:class:`~onnx_light::utils::ByteSpan` object is a member of the :class:`~onnx_light.onnx_lib.TensorProto` instance, so its lifetime is tied to the model object graph (``ModelProto -> GraphProto -> TensorProto``). :cpp:class:`~onnx_light::utils::ByteSpan` has two storage modes: * **Owned mode**: it owns an internal byte buffer. * **Borrowed mode**: it stores a pointer plus an optional ``std::shared_ptr`` owner token. When the borrowed mode also carries a shared owner token, the backing storage remains alive as long as the corresponding :class:`~onnx_light.onnx_lib.TensorProto` (or a copy of it) is alive. Attaching a custom deleter to a TensorProto ------------------------------------------- Rather than managing lifetime through a raw ``std::shared_ptr`` owner token, you can attach an arbitrary cleanup function directly to a tensor via :cpp:func:`~onnx_light::TensorProto::set_raw_data_with_deleter` or the lower-level :cpp:func:`~onnx_light::utils::ByteSpan::assign_with_deleter`. The deleter is called exactly once when the last copy of the owner token inside the ByteSpan is destroyed — that is, when the tensor (and all copies of it that share the same buffer) goes out of scope or its raw_data is overwritten/cleared. The deleter is a zero-argument callable (lambda, function pointer, or functor) returning ``void``. Internally it is wrapped in a ``std::shared_ptr`` with a custom deleter and stored as the ``owner_`` token, so all the copy/move/clear semantics of :cpp:class:`~onnx_light::utils::ByteSpan` apply without any change. C++ example:: #include "onnx_proto/onnx.h" #include // for std::malloc / std::free // Allocate tensor data outside of TensorProto's normal allocators. const size_t n_bytes = 4 * sizeof(float); uint8_t *buf = static_cast(std::malloc(n_bytes)); // … fill buf … TensorProto tensor; tensor.set_data_type(TensorProto::DataType::FLOAT); tensor.ref_dims().push_back(4); // Hand the buffer to the tensor. free() will be called when the tensor is // destroyed (or when the raw_data is overwritten/cleared). tensor.set_raw_data_with_deleter(buf, n_bytes, [buf]() { std::free(buf); }); // The tensor now owns buf's lifetime through the deleter. buf must not be // freed elsewhere. A no-op deleter is valid and costs nothing extra:: tensor.set_raw_data_with_deleter(ptr, sz, []() {}); // Equivalent to: tensor.ref_raw_data().assign_borrowed(ptr, sz); The lower-level :cpp:func:`~onnx_light::utils::ByteSpan::assign_with_deleter` works the same way:: span.assign_with_deleter(ptr, sz, []() { /* custom cleanup */ }); To attach a deleter to data that is already stored (without replacing the bytes or changing the storage mode), use :cpp:func:`~onnx_light::TensorProto::attach_raw_data_deleter` or the lower-level :cpp:func:`~onnx_light::utils::ByteSpan::attach_deleter`:: // raw_data already populated (owned or borrowed); just register cleanup. tensor.attach_raw_data_deleter([]() { /* custom cleanup */ }); Taking ownership of tensor data while parsing --------------------------------------------- :cpp:struct:`~onnx_light::ParseOptions` exposes a ``raw_data_callback`` hook that is invoked for every :class:`~onnx_light.onnx_lib.TensorProto` once its ``raw_data`` has been parsed (inline or external). The callback receives the freshly parsed tensor and returns a deleter (a zero-argument callable); when the returned deleter is non-empty it is attached to the tensor's ``raw_data`` via :cpp:func:`~onnx_light::utils::ByteSpan::attach_deleter`, so it fires once when the buffer is released. Return an empty ``std::function`` to leave ownership unchanged. The callback works regardless of where the bytes live — on disk (a ``no_copy`` borrowed view of an mmap or external weights file) or in CPU memory (an owned buffer) — because the deleter is layered on top of the existing storage without moving the bytes:: ParseOptions options; options.raw_data_callback = [](TensorProto &tensor, GraphProto *graph) -> std::function { // Inspect tensor.ref_raw_data() and the parent graph (``graph`` is nullptr for a // standalone tensor); optionally relocate it (e.g. to a device) and return the matching // cleanup. Returning {} keeps the default ownership. return [name = tensor.ref_name()]() { /* release resources */ }; }; model.ParseFromString(bytes, options); By default ``raw_data_callback`` is empty and parsing behaves exactly as before. The same hook is available from Python as :attr:`onnx_light.onnx.ParseOptions.raw_data_callback`. The callback is called as :attr:`onnx_light.onnx.ParseOptions.raw_data_callback`. The callback is called as ``fn(tensor, graph)`` with the freshly parsed :class:`~onnx_light.onnx.TensorProto` and its parent :class:`~onnx_light.onnx.GraphProto` (or ``None`` for a standalone tensor) and must return either ``None`` (ownership unchanged) or a zero-argument callable used as the deleter:: import onnx_light.onnx as onnx options = onnx.ParseOptions() options.raw_data_callback = lambda tensor, graph: print(tensor.name, len(tensor.raw_data)) model = onnx.ModelProto() model.ParseFromString(serialized, options) For the common case of *only* reporting progress while keeping the default allocation, assign a :class:`onnx_light.onnx.RawDataCallback` instead. It forwards every parsed tensor to its ``on_tensor`` callable and always returns ``None``, so tensor ownership is left to the default allocator:: options = onnx.ParseOptions() options.raw_data_callback = onnx.RawDataCallback( lambda tensor: print(tensor.name, len(tensor.raw_data)) ) See the :ref:`l-example-plot-raw-data-callback` gallery example for a complete walk-through. ----------------------------------------- Ownership is assigned while parsing each tensor: * Inline ``raw_data`` in the protobuf payload: * ``no_copy=False``: bytes are copied into :cpp:class:`~onnx_light::utils::ByteSpan` owned mode. * ``no_copy=True`` (from in-memory bytes): :cpp:class:`~onnx_light::utils::ByteSpan` borrows from the input bytes buffer. No shared owner token is attached, so the caller owns the input bytes lifetime. * External-data tensors (``data_location=EXTERNAL``): * ``no_copy=False``: bytes are copied into :cpp:class:`~onnx_light::utils::ByteSpan` owned mode. * ``no_copy=True``: :cpp:class:`~onnx_light::utils::TwoFilesStream` memory-maps (or file-maps on Windows) the weights file once, returns a slice pointer and a ``shared_ptr`` owner, and :cpp:class:`~onnx_light::utils::ByteSpan` stores both in borrowed mode. In other words, external-data no-copy transfers lifetime management to shared ownership held by each tensor, while inline-bytes no-copy keeps lifetime management with the caller. In-place consolidation with ConsolidateTensorsToBuffer ------------------------------------------------------ The function ``ConsolidateTensorsToBuffer(ModelProto &model, const TensorBufferOptions &opts)`` (Python: :func:`onnx_light.onnx.consolidate_tensors_to_buffer`) takes an already-loaded model and moves all qualifying tensor payloads into a single contiguous buffer, reproducing the shared-buffer ownership pattern of the no-copy external-data loading scenario: 1. All tensors whose ``raw_data.size() >= opts.raw_data_threshold`` are selected. 2. A single buffer is allocated. If ``opts.alignment > 0``, each tensor's offset within the buffer is rounded up to the nearest multiple of ``alignment`` bytes, and the buffer start itself is aligned to the same boundary. 3. Each tensor's bytes are copied into the buffer at the computed offset. 4. Each tensor's ``raw_data`` is switched to **borrowed mode** with a shared owner token pointing to the new buffer, so the buffer stays alive as long as any tensor references it. The function returns a ``std::shared_ptr`` (Python: the function returns ``None``; the buffer lifetime is managed by the tensors). Tensors that are smaller than the threshold remain in their original owned or borrowed state. This is useful for: * Reducing memory fragmentation after loading a model that was parsed without the no-copy option. * Enabling memory-mapping of all tensor weights after the fact. * Preparing a model for inference runtimes that benefit from a single contiguous tensor weight region. Usage example (C++):: #include "onnx_helper.h" // Load a model normally. ModelProto model; utils::FileStream stream("model.onnx"); ParseOptions parse_opts; ParseProtoFromStream(model, stream, parse_opts); // Consolidate all tensors into a single 64-byte-aligned buffer. TensorBufferOptions buf_opts; buf_opts.alignment = 64; auto buf = ConsolidateTensorsToBuffer(model, buf_opts); // buf is now the shared buffer; the model's tensors borrow from it. Usage example (Python):: import onnx_light.onnx as onnxl model = onnxl.load("model.onnx") opts = onnxl.TensorBufferOptions() opts.alignment = 64 # optional: align each tensor to 64-byte boundaries opts.raw_data_threshold = 1024 # optional: only consolidate tensors >= 1 KB onnxl.consolidate_tensors_to_buffer(model, opts) # After this call, large tensors borrow from a single shared buffer. Loading scenarios summary ------------------------- .. list-table:: :header-rows: 1 :widths: 30 20 25 25 * - Load scenario - ``no_copy`` - ``TensorProto::raw_data`` storage - Who must keep backing memory alive * - ``onnxl.load("model.onnx")`` (single-file) - ``False`` (default) - Owned copy - :class:`~onnx_light.onnx_lib.TensorProto` / model * - ``onnxl.load("model.onnx", no_copy=True)`` (single-file) - ``True`` - Owned copy (file stream cannot borrow inline payload) - :class:`~onnx_light.onnx_lib.TensorProto` / model * - ``onnxl.load(model_bytes, no_copy=False)`` - ``False`` - Owned copy - :class:`~onnx_light.onnx_lib.TensorProto` / model * - ``onnxl.load(model_bytes, no_copy=True)`` - ``True`` - Borrowed pointer into ``model_bytes`` - **Caller** (must keep ``model_bytes`` alive) * - ``onnxl.load("model.onnx", load_external_data=True, no_copy=False)`` - ``False`` - Owned copy - :class:`~onnx_light.onnx_lib.TensorProto` / model * - ``onnxl.load("model.onnx", load_external_data=True, no_copy=True)`` - ``True`` - Borrowed pointer + shared owner token - Shared ownership via :cpp:class:`~onnx_light::utils::ByteSpan` in model tensors * - ``onnxl.consolidate_tensors_to_buffer(model)`` (post-load) - n/a - Borrowed pointer + shared owner token - Shared ownership via :cpp:class:`~onnx_light::utils::ByteSpan` in model tensors When memory is released ----------------------- * Owned mode memory is released when :cpp:class:`~onnx_light::utils::ByteSpan` is destroyed. * **Copy scenarios** (``no_copy=False``) always use owned storage; memory is released when each :cpp:class:`~onnx_light::utils::ByteSpan` is destroyed with the model/tensor object. * **No-copy + external-data** stores borrowed pointers with a shared owner token; mapped/shared weights are released only when the last referencing :cpp:class:`~onnx_light::utils::ByteSpan` is destroyed. * **No-copy + inline bytes** stores borrowed pointers without owner token; tensors are valid only while the caller-managed input bytes object exists. * **ConsolidateTensorsToBuffer** creates a single shared buffer and stores a shared owner token in each tensor's :cpp:class:`~onnx_light::utils::ByteSpan`; the buffer is released when all referencing :cpp:class:`~onnx_light::utils::ByteSpan` objects (and any external ``shared_ptr`` returned by the C++ function) are destroyed. Model copy/move behavior ------------------------ Moving model/tensor objects preserves :cpp:class:`~onnx_light::utils::ByteSpan` ownership state: * owned buffers remain owned by the destination object, * borrowed pointers remain borrowed, * shared owner tokens (when present in no-copy external-data) move with the tensors. This means: * In **copy scenarios**, model data remains owned by model objects. * In **no-copy external-data scenarios**, data remains valid after the :cpp:class:`~onnx_light::utils::TwoFilesStream` parser object is destroyed because each tensor keeps a shared owner token for the mapped buffer. * In **no-copy inline-bytes scenarios**, tensors still depend on the original caller-provided bytes object lifetime. * After **ConsolidateTensorsToBuffer**, tensors remain valid regardless of whether the caller retains the returned ``shared_ptr`` because each tensor holds its own owner token.