Building onnx_proto, the protobuf-free ONNX schema#
- Date:
2025-07
complete
Objective#
onnx-light started from the upstream ONNX pull request
onnx/onnx#7208. Its first goal was
to read and write .onnx files without linking libprotobuf. Every ONNX
model is the binary serialization of a
ModelProto message described in onnx.proto, so the onnx_proto
target had to reproduce that message hierarchy, its wire format, and enough of
the protobuf message API for existing consumers, while remaining a small,
self-contained C++ library.
The constraints were:
preserve the ONNX wire format so that files produced by
onnxandonnx-lightare byte-compatible;expose message classes with typed field accessors that feel like the protobuf-generated ones;
avoid the 2 GB message-size limit imposed by protobuf and support external-data / multi-file models natively;
keep the code generator-free — the messages are hand-written but macro-driven rather than emitted by
protoc.
Post-mortem#
The library was built bottom-up, from the wire primitives to the generated message classes and finally the compatibility surface consumers rely on.
Binary stream primitives#
The protobuf wire format is a flat concatenation of (tag, value) pairs where
the tag packs a field number and a 3-bit wire type
(see the format notes). stream.h /
stream.cc implement the reading and writing of that encoding:
varint, fixed32, fixed64, and length-delimited primitives;
a
BinaryStreamreader and aBinaryWriteStreamwriter that the rest of the library targets;zero-copy spans (
simple_span.h) so a tensor’sraw_datacan point directly into the source buffer instead of allocating a copy.
Field storage#
fields.h / fields.hpp provide the storage building blocks that back
every message field: optional scalars, optional strings
(simple_string.h), optional embedded messages, and repeated variants
(RepeatedField for scalars, RepeatedProtoField for messages). These
own their memory and offer move-friendly setters
(set_<name>(T&&), add_<name>(T&&)) so assembling a graph does not deep
copy every TensorProto.
Macro-generated messages#
Instead of running protoc, message classes are declared with the macros in
stream_class.h. Each .proto message maps to a
BEGIN_PROTO / FIELD_* / END_PROTO block in onnx.h, and the
macros expand into the storage, accessors, and the SERIALIZATION_METHOD
core (ParseFromStream, SerializeToStream, SerializeSize, and
PrintToStringStream). The per-message parse and serialize bodies live in
onnx.cc. This keeps the .proto field numbers next to the C++ code and
removes the build-time dependency on the protobuf compiler.
Protobuf-compatible API#
Consumers expect the protobuf message API (ParseFromString,
SerializeToString, ByteSizeLong, …). These convenience entry points
are provided as thin wrappers around the type-specific serialization core, so
a message parses and serializes as if it were protobuf-generated while the wire
work stays in one place. google_protobuf_compat.h supplies the handful of
google::protobuf symbols (repeated fields, stream types) that callers still
name directly.
Helpers and tools#
Around the messages, onnx_helper.h / onnx_helper.cc add the utilities
model tools depend on (attribute lookup, scalar reading such as
ReadScalarAsDouble, external-data handling), and onnx_verify.cc performs
model and tensor validation.
What worked#
Building on the wire primitives first meant the message layer only had to describe fields, not encoding rules. Byte-compatibility with
onnxwas verified by round-tripping real models.The macro approach kept the schema declaration compact and reviewable, and made it cheap to keep the
.protocomments and field numbers next to the generated C++.Owning field storage with move-aware setters avoided a class of accidental deep copies while still exposing a protobuf-like API.
Removing the
libprotobufdependency dropped the 2 GB limit and enabled native external-data, zero-copy parsing, and parallel load / save.
What remains#
The library initially bundled verification, hashing, encryption, and text printing next to the parser. Trimming that surface, hiding internal symbols, and reducing the generated per-message wrappers were later measured and delivered as a separate workstream (Reducing the lib_onnx_proto binary size).