Reducing the lib_onnx_proto binary size#
- Date:
2026-08
complete
Objective#
The CMake target lib_onnx_proto produces liblib_onnx_proto.so (the shared
library linked by consumers that only need to parse and serialize ONNX models).
That dependency should not pull in unrelated features or pay for one copy of
every convenience wrapper generated for every message class.
The primary objective is to reduce both:
the installed size of
onnx_light/onnx_py/liblib_onnx_proto.so;the mapped code and read-only data needed at runtime.
Size reduction must preserve the ONNX wire format, parser and serializer behavior, external-data support required by ordinary models, and the ability to exchange proto objects across onnx-light shared libraries.
Post-mortem#
The work was delivered as a sequence of small pull requests. Each one made the next step easier to measure or safer to deploy:
Pull request |
Change |
Result |
Role in the sequence |
|---|---|---|---|
Enabled function and data sections plus linker garbage collection on GNU/Clang, Apple, and MSVC builds. |
Allowed unused code to be discarded, but no isolated size measurement was recorded. |
Established the linker foundation used by the later reductions. |
|
Added a cross-platform size reporter to CI and stripped Release wheel artifacts explicitly. |
Reduced the installed Linux library from 2,178,120 to 1,810,208 bytes, a saving of 367,912 bytes (16.9%). |
Turned binary size into an observable release metric before changing the ABI surface. |
|
Hid symbols by default on ELF and Mach-O and introduced
|
Reduced the stripped library from 1,810,208 to 1,625,024 bytes and defined dynamic symbols from 2,128 to 1,191. |
Let |
|
Replaced per-message out-of-line convenience wrappers with the
|
Reduced the stripped library from 1,625,024 to 1,001,208 bytes and defined dynamic symbols from 1,191 to 652. |
Removed the structural source of duplication rather than relying on compiler flags to fold it. |
|
Added a tested 1.2 MiB installed-size budget to Linux CI. |
Produced no immediate size reduction. |
Converted the achieved size into a regression guard. |
The comparable stripped baseline therefore decreased from 1,810,208 to 1,001,208 bytes: 809,000 bytes, or 44.7%. Including Release stripping, the installed artifact decreased by 1,176,912 bytes (54.0%). The defined dynamic symbol count fell by 69.4%, from 2,128 to 652. The largest individual gain came from removing duplicated generated wrappers, not from a more aggressive optimization level.
What worked#
Measuring the packaged library, ELF sections, exported symbols, and shared dependencies prevented file-size changes from being mistaken for code-size changes. Stripping reduced the installed artifact but did not reduce mapped executable code.
Reducing symbol visibility before consolidating wrappers separated two overlapping effects: dead-code elimination at link time and elimination of duplicated source-level implementations.
Keeping the type-specific wire-format core unchanged limited the refactoring risk. Compatibility entry points moved to an inline CRTP adapter, while parsing, serialization, size computation, and printing remained explicit per-message operations.
The sequence
measure -> constrain exports -> remove duplication -> enforce budgetkept every pull request reviewable and left a measurement after each architectural change.
What remains#
The 1.00 MiB stretch target was reached without MinSizeRel, LTO, or
identical-code-folding experiments beyond the existing linker configuration.
Those options were not pursued because their expected gain was smaller and
more toolchain-dependent. Splitting verification, crypto, hashing, external
data tools, and text printing into optional libraries may still save
100–300 KiB for parser-only consumers, but it changes target composition and
dependency ownership and should be treated as a separate project.
Measured baseline#
The current Linux x86-64 Release library was measured with OpenSSL enabled:
Metric |
Size |
Observation |
|---|---|---|
File on disk |
2,178,120 bytes |
The ELF file is not stripped |
File after |
1,810,208 bytes |
Immediate 16.9% packaging reduction |
Allocated ELF sections |
1,803,125 bytes |
Approximate mapped image |
|
1,161,276 bytes |
64.4% of allocated sections |
|
271,559 bytes |
Cost of the large exported ABI |
Unwind and exception tables |
211,113 bytes |
|
|
40,008 bytes |
Not the main contributor |
The shared object defines approximately 2,119 dynamic symbols. Symbol-name classification finds about 270 parsing, 629 serialization, 179 size, and 30 printing exports. These groups overlap, but they show that generated per-message forwarding methods dominate the public surface.
The baseline can be reproduced with:
stat --format='%s' onnx_light/onnx_py/liblib_onnx_proto.so
size -A -d onnx_light/onnx_py/liblib_onnx_proto.so
readelf --dyn-syms -W onnx_light/onnx_py/liblib_onnx_proto.so
nm -S --size-sort --demangle \
onnx_light/onnx_py/liblib_onnx_proto.so
Why the library is large#
Generated message API#
SERIALIZATION_METHOD currently adds a broad API to every proto class:
two
ParseFromStringoverloads;zero-copy, stream, file-descriptor, array, and iostream parsing;
string, array, stream, file-descriptor, and iostream serialization;
size computation and text printing.
Most wrappers perform the same adaptation around a much smaller wire-format
core. Because the shared library exports them for every message class, the
linker must retain them even with -ffunction-sections and
--gc-sections.
Exported ABI#
The target does not use hidden visibility on ELF platforms. Template
instantiations, compatibility wrappers, helper methods, and message methods
therefore enter .dynsym and .dynstr. The symbol names are particularly
large because many exported functions contain fully expanded C++ template
types.
Mixed responsibilities#
lib_onnx_proto currently includes more than wire parsing and
serialization:
model and tensor verification;
advanced external-data rewriting and alignment;
encrypted model I/O and the OpenSSL dependency;
BLAKE3 hashing;
thread-pool support;
general
onnx_light_helpersutilities;text-format printing.
These features are useful, but a parser/serializer-only consumer should not have to load all of them.
Compiler-generated metadata#
Exceptions, many out-of-line functions, and the large dynamic ABI generate substantial unwind, exception, relocation, and procedure-linkage tables. Removing source code without reducing the number of retained functions and exports will therefore leave a significant secondary cost.
Optional feature splitting#
Feature splitting is a separate workstream, not part of the primary binary size plan below. It changes target composition and dependency ownership, so its impact must be measured independently from code-generation and linker improvements.
The minimal shared library should own only:
message storage and field access needed across shared-library boundaries;
binary stream primitives;
wire parsing and serialization;
ordinary inline and external tensor payload loading;
the minimal public API needed by the Python bindings and dependent onnx-light libraries.
Optional responsibilities should move behind separate targets:
Candidate target |
Responsibility |
Current sources to examine |
|---|---|---|
|
Minimal parser, serializer, messages, and streams |
|
|
Verification and advanced external-data transformations |
|
|
Content hashes |
BLAKE3 sources and hash-specific message methods |
|
Encrypted model I/O |
|
|
Human-readable proto printing |
|
An onnx_proto_full interface target may link all components for existing
high-level consumers. The minimal target must not acquire optional
dependencies transitively.
Generated API reduction#
The highest-priority structural change is to stop emitting every convenience wrapper for every message.
Each message still needs a small type-specific core:
ParseFromStream(BinaryStream&, ParseOptions&)
SerializeToStream(BinaryWriteStream&, SerializeOptions&) const
SerializeSize(BinaryWriteStream&, SerializeOptions&) const
String, array, iostream, zero-copy, and file-descriptor entry points should be implemented as inline CRTP/mixin wrappers or shared non-template adapters. Only wrappers used by a consumer are then instantiated in that consumer.
Text printing should not be part of the mandatory serialization macro. It can
be supplied by lib_onnx_proto_text or enabled explicitly for builds that
need protobuf-compatible debug output.
This refactoring is an ABI change and must be measured separately from feature splitting. Wire compatibility does not require preserving every out-of-line convenience symbol.
This step is now implemented with ProtoMessageAdapter<T>. Generated
messages inherit its inline compatibility API, while only
ParseFromStream, SerializeToStream, SerializeSize, and text
printing remain substantial type-specific out-of-line methods. CopyFrom
keeps a 30-byte type-specific entry point for ABI compatibility, but delegates
its serialization/deserialization pipeline to one type-erased shared
implementation. The Release build no longer exports one copy of each string,
array, iostream, zero-copy, and file-descriptor adapter for every message.
On the same local Linux Release configuration used for step 2, the defined dynamic symbol count decreased from 1,191 to 652. The stripped library decreased from 1,625,024 to 1,001,208 bytes, a reduction of 623,816 bytes (about 609 KiB).
Visibility and linking#
After identifying the cross-library ABI, the ELF and Mach-O builds should use:
set_target_properties(lib_onnx_proto PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES)
An ONNX_LIGHT_PROTO_API annotation or linker version script should expose
only symbols required by public consumers and other onnx-light shared
libraries. Internal templates, helper functions, and implementation details
must remain hidden.
This step is now implemented for ELF and Mach-O builds. The shared target uses
hidden visibility by default, while proto messages, stream types, and the
documented helper API are explicitly marked with ONNX_LIGHT_PROTO_API.
Windows retains WINDOWS_EXPORT_ALL_SYMBOLS until explicit DLL import/export
annotations are introduced there.
On the local Linux Release baseline, the change reduced the defined dynamic symbol count from 2,128 to 1,191. The stripped library decreased from 1,810,208 to 1,625,024 bytes, a reduction of 185,184 bytes (about 181 KiB).
The existing function/data sections and dead-section elimination should be
retained. Once visibility is reduced, --gc-sections can discard code that
is currently kept alive only because it is exported. Identical code folding
may be enabled when supported by the selected linker.
Build and packaging improvements#
Release wheels should strip unneeded static symbols. This is an immediate reduction of approximately 368 KB in the measured build and does not change the runtime ABI.
The following build variants may optionally be compared if additional size headroom becomes necessary:
ReleaseversusMinSizeRel;-O2versus-Os;link-time optimization or ThinLTO;
-fno-semantic-interpositionfor hidden internal functions;linker identical-code folding.
These options are secondary to API consolidation and feature splitting. Compiler flags alone cannot remove thousands of intentionally exported functions.
Static linking remains useful for a standalone parser because the final linker can retain only referenced sections. Python extensions still need one shared proto implementation so that message objects and RTTI are not duplicated across modules.
Measurement plan#
Every experiment should record:
unstripped and stripped file sizes;
allocated section sizes;
.text, dynamic symbol/string, relocation, and unwind sizes;number of defined dynamic symbols;
parse and serialization time on the same representative models;
peak memory while parsing;
required shared-library dependencies.
Measurements must use a clean build with a recorded compiler, linker, build
type, architecture, ONNX_ML setting, and OpenSSL setting. Comparing files
from different configurations is not actionable.
Proposed budgets#
For the same Linux x86-64 configuration as the baseline:
Milestone |
Installed size |
Required change |
|---|---|---|
Packaging baseline |
At most 1.75 MiB |
Strip release artifacts |
Minimal parser/serializer |
At most 1.25 MiB |
Reduce exports and generated wrappers |
Stretch target |
At most 1.00 MiB |
Consolidate generated wrappers and enable size-oriented linking |
The parser and serializer must remain wire-compatible. Performance regressions must be reported alongside size gains rather than hidden by the aggregate binary-size number.
Implementation order#
Implemented: strip the installed Release artifact and report its file, section, text, dynamic-symbol, and dependency sizes in CI. See PR #4333.
Implemented: introduce hidden visibility and an explicit
ONNX_LIGHT_PROTO_APIcross-library export boundary. See PR #4344.Implemented: replace per-message convenience implementations with the inline
ProtoMessageAdapter<T>CRTP adapter. See PR #4349.Optional: compare
MinSizeRel, LTO, and linker folding if additional size headroom becomes necessary.Implemented: enforce a 1.2 MiB installed-size budget in CI for the Linux x86-64 Release build. See PR #4355.
Outcome by step#
The following measured results and optional projection use the 2.08 MiB unstripped Linux baseline. They are presented in implementation order; visibility, wrapper consolidation, and garbage collection may eliminate some of the same code.
Step |
Change |
Direct gain |
Resulting size |
Status |
|---|---|---|---|---|
1 |
Strip Release artifacts |
359 KiB measured |
1.73 MiB |
Measured |
2 |
Hide internal symbols and reduce exports |
181 KiB measured |
1.55 MiB |
Measured |
3 |
Share or inline per-message convenience wrappers |
609 KiB measured |
0.95 MiB |
Measured |
4 |
Optional: |
50–150 KiB |
0.85–1.15 MiB |
Not pursued |
5 |
Enforce the CI budget |
No immediate reduction |
Prevents regressions |
Implemented |
The optional feature split could save a further 100–300 KiB and remove dependencies such as OpenSSL from parser-only deployments. It is deliberately excluded from the cumulative figures because it changes library composition rather than optimizing the same target.
The measured stripped library is now approximately 0.95 MiB after wrapper consolidation, meeting the stretch target without the optional compiler and linker experiments in step 4. CI enforces the 1.2 MiB installed-size budget on the matching Linux x86-64 Release build.