open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/load_onnx.rs
Transaction Science ee12173c94 loader-gguf wave 36: ONNX initializer tensor extraction
Pure-Rust hand-rolled proto3 wire-format reader, ~400 LoC.
No `prost` build-time dep, no operator graph execution.

SCOPE — what wave 36 does

  Open + mmap any .onnx file.
  Walk ModelProto → GraphProto → repeated TensorProto
  initializer entries. Surface each as:

    OnnxTensorInfo {
        name:  String,
        dtype: OnnxDtype,
        shape: Vec<i64>,
        data:  OnnxTensorData,
    }

  OnnxTensorData::
    RawData{offset, len}   most common; bytes inline in
                           the mmap (the `raw_data` field).
    TypedArray(arrays)     older / smaller models that
                           store weights in the typed
                           repeated fields (float_data /
                           int32_data / int64_data /
                           uint64_data / double_data).
    External{location,..}  >2GB models that shard weights
                           to sibling files. Surfaced;
                           caller resolves the file.

SCOPE — what wave 36 does NOT do

  No operator graph execution — JouleClaw has its own
  kernel suite (waves 1-33). ONNX ops are out of substrate
  scope. We use ONNX as a tensor archive format.

  No protobuf reflection / message descriptor parsing —
  we hard-code the 6 field tags we care about (ModelProto
  fields 1/2/7/14, GraphProto field 5, TensorProto fields
  1/2/3/4/7/8/9/10/11/13). Future ONNX dtype additions
  pass through transparently as OnnxDtype::Unknown(u32).

DTYPE COVERAGE

  All 23 documented TensorProto::DataType variants
  (Float through Float4E2M1 + sub-byte Uint4/Int4 + all
  four FP8 flavours). Unknowns pass through, don't fail
  the parse.

PROTO3 WIRE READER

  ProtoReader handles wire types 0/1/2/5 — varint,
  64-bit, length-delimited, 32-bit. Hard-fails on unknown
  wire types with absolute-byte error offsets. Supports
  both packed and unpacked repeated fields. ~120 LoC.

NEW FILES

  crates/jouleclaw-loader-gguf/src/onnx.rs
  crates/jouleclaw-loader-gguf/examples/load_onnx.rs

  lib.rs: + pub mod onnx;

TESTS (7 new)

  varint_round_trip_small_and_large    canonical varints
  read_single_raw_tensor               full round-trip
  read_multiple_tensors                multi-dtype lookup
  dtype_round_trip_handles_unknown     forward-compat
  empty_model_parses_no_error          zero-init graphs
  dtype_element_sizes                  sub-byte sentinel
  truncated_proto_returns_error        bounds checking

  loader-gguf: 177 → 184 tests.

cargo check --workspace --all-targets clean.
cargo build --example load_onnx clean.

NEXT — Wave 37: CoreML mlpackage (ZIP of mlmodel +
weight.bin + Metal shaders).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-06-09 09:50:08 -04:00

159 lines
5.3 KiB
Rust

//! Real-model bring-up for ONNX files.
//!
//! ```bash
//! cargo run --release --example load_onnx -- path/to/model.onnx
//! ```
use jouleclaw_loader_gguf::onnx::{
read_onnx_file, OnnxDtype, OnnxTensorData,
};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Instant;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("usage: load_onnx <path.onnx>");
std::process::exit(1);
}
let path = PathBuf::from(&args[1]);
println!("model: {}", path.display());
let t0 = Instant::now();
let m = read_onnx_file(&path).map_err(|e| format!("parse: {:?}", e))?;
let load_secs = t0.elapsed().as_secs_f64();
println!();
println!("=== load ===");
println!(" load_wall_clock_secs: {:.3}", load_secs);
println!(" ir_version: {}", m.ir_version);
if let Some(p) = &m.producer_name {
println!(" producer: {}", p);
}
println!(" tensor_count: {}", m.tensors.len());
println!(" mmap_len: {} ({})", m.mmap.len(), humanize_bytes(m.mmap.len() as u64));
// Byte accounting.
let mut raw_bytes: u64 = 0;
let mut typed_bytes: u64 = 0;
let mut external_count = 0u32;
let mut external_bytes: u64 = 0;
for t in &m.tensors {
match &t.data {
OnnxTensorData::RawData { len, .. } => raw_bytes += *len as u64,
OnnxTensorData::TypedArray(a) => {
typed_bytes += (a.float_data.len() * 4
+ a.int32_data.len() * 4
+ a.int64_data.len() * 8
+ a.uint64_data.len() * 8
+ a.double_data.len() * 8) as u64;
}
OnnxTensorData::External { length, .. } => {
external_count += 1;
external_bytes += length.unwrap_or(0);
}
}
}
println!();
println!("=== tensor data ===");
println!(" raw_data_bytes: {} ({})", raw_bytes, humanize_bytes(raw_bytes));
println!(" typed_array_bytes:{} ({})", typed_bytes, humanize_bytes(typed_bytes));
println!(
" external: {} tensors, {} ({} on disk in sibling files)",
external_count,
external_bytes,
humanize_bytes(external_bytes),
);
// Dtype distribution.
let mut counts: BTreeMap<String, (u32, u64)> = BTreeMap::new();
for t in &m.tensors {
let key = format!("{:?}", t.dtype);
let bytes = match (&t.data, t.dtype.element_size()) {
(OnnxTensorData::RawData { len, .. }, _) => *len as u64,
(OnnxTensorData::External { length, .. }, _) => length.unwrap_or(0),
(OnnxTensorData::TypedArray(_), Some(es)) => {
(t.n_elements() as u64) * (es as u64)
}
(OnnxTensorData::TypedArray(_), None) => 0,
};
let e = counts.entry(key).or_insert((0, 0));
e.0 += 1;
e.1 += bytes;
}
println!();
println!("=== dtype distribution ===");
for (k, (c, b)) in &counts {
println!(" {:<14} count={:>5} size={}", k, c, humanize_bytes(*b));
}
// Top tensors by size.
let mut sorted: Vec<&_> = m.tensors.iter().collect();
sorted.sort_by_key(|t| {
std::cmp::Reverse(match &t.data {
OnnxTensorData::RawData { len, .. } => *len as u64,
OnnxTensorData::External { length, .. } => length.unwrap_or(0),
OnnxTensorData::TypedArray(_) => {
(t.n_elements() as u64) * (t.dtype.element_size().unwrap_or(0) as u64)
}
})
});
println!();
println!("=== top 10 tensors by byte size ===");
for t in sorted.iter().take(10) {
let bytes = match &t.data {
OnnxTensorData::RawData { len, .. } => *len as u64,
OnnxTensorData::External { length, .. } => length.unwrap_or(0),
OnnxTensorData::TypedArray(_) => {
(t.n_elements() as u64) * (t.dtype.element_size().unwrap_or(0) as u64)
}
};
let kind = match &t.data {
OnnxTensorData::RawData { .. } => "raw",
OnnxTensorData::TypedArray(_) => "typed",
OnnxTensorData::External { .. } => "extern",
};
println!(
" [{:>6}] {:?} {:<60} {:>14} ({})",
kind,
t.dtype,
truncate(&t.name, 60),
bytes,
humanize_bytes(bytes),
);
let _ = OnnxDtype::Float; // silence unused-import in some build configs
}
if !m.metadata.is_empty() {
println!();
println!("=== metadata ===");
for (k, v) in &m.metadata {
let v_display = if v.len() > 80 { format!("{}…", &v[..80]) } else { v.clone() };
println!(" {}: {}", k, v_display);
}
}
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("…{}", &s[s.len() - max + 1..])
}
}
fn humanize_bytes(n: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if n >= GB {
format!("{:.2} GiB", n as f64 / GB as f64)
} else if n >= MB {
format!("{:.2} MiB", n as f64 / MB as f64)
} else if n >= KB {
format!("{:.2} KiB", n as f64 / KB as f64)
} else {
format!("{} B", n)
}
}