TL;DR — A GGUF parser is the kind of project that quietly proves you understand file formats, memory mapping, endianness, and tensor metadata end-to-end. We’ll build one in Rust that streams tensor shards off disk into memory-mapped buffers and re-exports them as safetensors tensors, with real code, a working test, and a clear extension roadmap for CV signal.
Why This Project Stands Out on a CV
Hiring managers skim portfolios fast. A web app in React and a CRUD API in FastAPI blend into a blur. A from-scratch binary format parser does not. Here’s what this specific project signals, and the roles it opens doors to.
Demonstrated skills
- Binary format literacy. GGUF (GPT-Generated Unified Format) is a real, evolving binary container used by llama.cpp and the wider local-LLM ecosystem. Parsing it means reading the GGUF spec, handling version fields, key-value metadata, and typed tensor descriptors — exactly the skill set used in codec work, protocol implementations, and database storage engines.
- Memory-mapped I/O. Streaming shards via
mmapinstead ofread()calls shows you understand virtual memory, page faults, and zero-copy data paths. This is the bread and butter of high-performance data systems like RocksDB, DuckDB, and Polars. - Cross-format interop. Re-emitting tensors into the safetensors layout proves you can design adapters, not just consumers. Adapter work is core to data pipeline engineering and SDK design.
- Rust fluency. The implementation below uses
memmap2,bytemuck, and zero-copy&[u8]slices — the kind of code that shows up intokio,hyper, and thearrow-rsecosystem. - Testing discipline. Round-tripping a small model through the parser is a tight, satisfying test that fits on a resume bullet.
Roles this signals for
- ML infrastructure engineer (think Anyscale, Modal, Together)
- Systems engineer on inference runtimes (think vLLM, TGI, llama.cpp)
- Data platform engineer working on columnar formats (Parquet, Arrow, Lance)
- Storage engine engineer on embedded databases
- Quantization-aware ML engineer
Put it on your CV as: “Built a from-scratch GGUF parser in Rust that streams tensor shards via mmap into safetensors-compatible buffers, including KV metadata extraction and a round-trip test harness.” That’s a sentence a hiring manager will actually read twice.
Architecture Overview
The system has four clean components. They map cleanly to Rust modules, which keeps the project reviewable in one sitting.
- Header reader — reads the magic bytes (
GGUF), version (currently2or3), tensor count, and KV metadata count. Validates endianness (0x00000001magic constant encodes little-endian). - Metadata walker — iterates over the typed key-value section: strings, arrays, and GGUF’s enumeration of scalar types (
uint8,int32,float32,bool,string,array). This is where you learn the GGUF “general.name”, “llama.context_length”, and tokenizer fields. - Tensor index reader — walks the tensor info array: each tensor has a name, n_dims, dims[], a GGML type (e.g.
Q8_0,Q4_K), and an offset. Critically, the offset is relative to apaddingboundary, not file start. - Shard streamer — opens the file with
memmap2::Mmap, slices into per-tensor byte ranges, casts them to typed slices withbytemuck, and writes them into a safetensors file using thesafetensorsRust crate.
┌──────────────────────────────────────────────────────────────┐
│ GGUF file on disk │
│ ┌────────────┬─────────────┬──────────────┬───────────────┐ │
│ │ Header │ KV metadata │ Tensor index │ Tensor data │ │
│ │ magic/ver │ (typed) │ (names+off) │ (shards) │ │
│ └────────────┴─────────────┴──────────────┴───────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ HeaderReader MetadataWalker IndexReader ShardStreamer │
│ │ │ │
│ └─────► safetensors writer ◄────────┘
└──────────────────────────────────────────────────────────────┘
The crucial design choice: never copy a tensor’s bytes. The mmap slice is the source of truth; the safetensors writer serializes headers and memcpys the slice into the output buffer.
Building It Step by Step
We’ll build a small Rust crate. Add to Cargo.toml:
[package]
name = "gguf2safetensors"
version = "0.1.0"
edition = "2021"
[dependencies]
memmap2 = "0.9"
bytemuck = { version = "1.16", features = ["derive"] }
safetensors = "0.4"
byteorder = "1.5"
thiserror = "1"
Step 1 — Define the typed GGUF enums
GGUF defines its own type system for metadata values and for tensor element types. Encoding these as Rust enums with explicit discriminants is the cleanest approach.
// src/types.rs
use byteorder::{LittleEndian, ReadBytesExt};
use std::io::{Read, Result as IoResult};
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GgufType {
Uint8 = 0,
Int8 = 1,
Uint16 = 2,
Int16 = 3,
Uint32 = 4,
Int32 = 5,
Float32 = 6,
Bool = 7,
String = 8,
Array = 9,
Uint64 = 10,
Int64 = 11,
Float64 = 12,
}
impl GgufType {
pub fn read(r: &mut impl Read) -> IoResult<Self> {
let v = r.read_u32::<LittleEndian>()?;
Ok(match v {
0 => Self::Uint8, 1 => Self::Int8,
2 => Self::Uint16, 3 => Self::Int16,
4 => Self::Uint32, 5 => Self::Int32,
6 => Self::Float32, 7 => Self::Bool,
8 => Self::String, 9 => Self::Array,
10 => Self::Uint64, 11 => Self::Int64,
12 => Self::Float64,
other => return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unknown gguf type tag {other}"),
)),
})
}
}
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GgmlType {
F32 = 0,
F16 = 1,
Q4_0 = 2,
Q4_1 = 3,
Q5_0 = 6,
Q5_1 = 7,
Q8_0 = 8,
Q8_1 = 9,
Q2_K = 10,
Q3_K = 11,
Q4_K = 12,
Q5_K = 13,
Q6_K = 14,
Q8_K = 15,
// ... extend as needed; this covers the common cases.
}
impl GgmlType {
pub fn block_size(&self) -> usize {
match self {
Self::F32 | Self::F16 => 1,
Self::Q4_0 | Self::Q5_0 | Self::Q8_0 => 32,
Self::Q4_1 | Self::Q5_1 | Self::Q8_1 => 32,
Self::Q2_K | Self::Q3_K | Self::Q4_K
| Self::Q5_K | Self::Q6_K | Self::Q8_K => 256,
}
}
pub fn type_size(&self) -> usize {
match self {
Self::F32 => 4,
Self::F16 => 2,
Self::Q4_0 => 18, // 2 (scale) + 16 (4-bit quants packed)
Self::Q4_1 => 20,
Self::Q5_0 => 22,
Self::Q5_1 => 24,
Self::Q8_0 => 34,
Self::Q8_1 => 36,
Self::Q2_K => 84,
Self::Q3_K => 110,
Self::Q4_K => 144,
Self::Q5_K => 176,
Self::Q6_K => 210,
Self::Q8_K => 292,
_ => 0,
}
}
}
These block-size constants are the heart of GGUF’s quantization scheme. Get them wrong and the offset arithmetic in Step 3 silently corrupts every tensor.
Step 2 — Parse the header and KV metadata
// src/header.rs
use crate::types::GgufType;
use byteorder::{LittleEndian, ReadBytesExt};
use std::collections::HashMap;
use std::io::{Cursor, Read, Seek, SeekFrom};
#[derive(Debug, Default)]
pub struct GgufHeader {
pub version: u32,
pub tensor_count: u64,
pub kv_count: u64,
pub metadata: HashMap<String, MetadataValue>,
}
#[derive(Debug, Clone)]
pub enum MetadataValue {
Uint(u64),
Int(i64),
Float(f64),
Bool(bool),
String(String),
Array(Vec<MetadataValue>),
}
const GGUF_MAGIC: u32 = 0x46554747; // "GGUF" little-endian
pub fn read_header(buf: &mut impl Read + Seek) -> std::io::Result<GgufHeader> {
let magic = buf.read_u32::<LittleEndian>()?;
if magic != GGUF_MAGIC {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"not a GGUF file (magic mismatch)",
));
}
let version = buf.read_u32::<LittleEndian>()?;
if !(2..=3).contains(&version) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported GGUF version {version}"),
));
}
let tensor_count = buf.read_u64::<LittleEndian>()?;
let kv_count = buf.read_u64::<LittleEndian>()?;
let mut metadata = HashMap::with_capacity(kv_count as usize);
for _ in 0..kv_count {
let (key, value) = read_kv(buf)?;
metadata.insert(key, value);
}
Ok(GgufHeader { version, tensor_count, kv_count, metadata })
}
fn read_string(buf: &mut impl Read) -> std::io::Result<String> {
let len = buf.read_u64::<LittleEndian>()? as usize;
let mut s = vec![0u8; len];
buf.read_exact(&mut s)?;
Ok(String::from_utf8(s).map_err(|_| std::io::Error::new(
std::io::ErrorKind::InvalidData, "non-utf8 string in gguf metadata",
))?)
}
fn read_kv(buf: &mut impl Read)
-> std::io::Result<(String, MetadataValue)>
{
let key = read_string(buf)?;
let ty = GgufType::read(buf)?;
let value = read_value(buf, ty)?;
Ok((key, value))
}
fn read_value(buf: &mut impl Read, ty: GgufType) -> std::io::Result<MetadataValue> {
use MetadataValue::*;
Ok(match ty {
GgufType::Uint8 => Uint(buf.read_u8()? as u64),
GgufType::Int8 => Int(buf.read_i8()? as i64),
GgufType::Uint16 => Uint(buf.read_u16::<LittleEndian>()? as u64),
GgufType::Int16 => Int(buf.read_i16::<LittleEndian>()? as i64),
GgufType::Uint32 => Uint(buf.read_u32::<LittleEndian>()? as u64),
GgufType::Int32 => Int(buf.read_i32::<LittleEndian>()? as i64),
GgufType::Uint64 => Uint(buf.read_u64::<LittleEndian>()?),
GgufType::Int64 => Int(buf.read_i64::<LittleEndian>()?),
GgufType::Float32 => Float(buf.read_f32::<LittleEndian>()? as f64),
GgufType::Float64 => Float(buf.read_f64::<LittleEndian>()?),
GgufType::Bool => Bool(buf.read_u8()? != 0),
GgufType::String => String(read_string(buf)?),
GgufType::Array => {
let elem_ty = GgufType::read(buf)?;
let len = buf.read_u64::<LittleEndian>()? as usize;
let mut out = Vec::with_capacity(len);
for _ in 0..len {
out.push(read_value(buf, elem_ty)?);
}
Array(out)
}
})
}
Notice the alignment: GGUF v3 uses 32-byte alignment for tensor data blocks, while metadata is byte-packed. Mixing these up is the #1 parser bug — check ggml-common.h when in doubt.
Step 3 — Parse the tensor index and compute aligned offsets
// src/tensors.rs
use crate::types::GgmlType;
use byteorder::{LittleEndian, ReadBytesExt};
use std::io::{Read, Seek, SeekFrom};
#[derive(Debug, Clone)]
pub struct TensorInfo {
pub name: String,
pub dims: [u64; 4],
pub ggml_type: GgmlType,
pub relative_offset: usize, // offset from start of tensor data section
}
const TENSOR_ALIGNMENT: usize = 32;
pub fn tensor_byte_size(info: &TensorInfo) -> usize {
let n_elements: u64 = info.dims.iter().product();
let block_elems = info.ggml_type.block_size() as u64;
let n_blocks = n_elements / block_elems
+ if n_elements % block_elems != 0 { 1 } else { 0 };
n_blocks as usize * info.ggml_type.type_size()
}
pub fn read_tensor_index(
buf: &mut (impl Read + Seek),
tensor_count: u64,
) -> std::io::Result<(Vec<TensorInfo>, u64)> {
let mut tensors = Vec::with_capacity(tensor_count as usize);
for _ in 0..tensor_count {
let name_len = buf.read_u64::<LittleEndian>()? as usize;
let mut name = vec![0u8; name_len];
buf.read_exact(&mut name)?;
let name = String::from_utf8(name).map_err(|_| std::io::Error::new(
std::io::ErrorKind::InvalidData, "non-utf8 tensor name",
))?;
let n_dims = buf.read_u32::<LittleEndian>()? as usize;
if n_dims > 4 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData, "n_dims > 4",
));
}
let mut dims = [1u64; 4];
for d in dims.iter_mut().take(n_dims) {
*d = buf.read_u64::<LittleEndian>()?;
}
let ty_tag = buf.read_u32::<LittleEndian>()?;
let ggml_type = match ty_tag {
0 => GgmlType::F32, 1 => GgmlType::F16,
2 => GgmlType::Q4_0, // extend per Step 1
other => return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unknown ggml type {other}"),
)),
};
let relative_offset = buf.read_u64::<LittleEndian>()? as usize;
tensors.push(TensorInfo { name, dims, ggml_type, relative_offset });
}
// Tensor data starts at the next 32-byte boundary after the index.
let data_start = align_up(buf.stream_position()? as usize, TENSOR_ALIGNMENT);
Ok((tensors, data_start as u64))
}
fn align_up(x: usize, a: usize) -> usize { (x + a - 1) & !(a - 1) }
Step 4 — Stream shards into a safetensors file via mmap
// src/main.rs
use memmap2::Mmap;
use safetensors::Dtype;
use safetensors::serialize::{serialize_to_file, TensorMetadata};
use std::collections::BTreeMap;
use std::fs::File;
use std::path::Path;
use gguf2safetensors::header::{read_header};
use gguf2safetensors::tensors::{read_tensor_index, tensor_byte_size};
fn ggml_to_safetensors_dtype(t: crate::GgmlType) -> Option<Dtype> {
use crate::GgmlType::*;
Some(match t {
F32 => Dtype::F32,
F16 => Dtype::F16,
// Quantized types: safetensors carries them as raw bytes with a
// custom dtype string. For now, skip them or surface as raw u8.
_ => return None,
})
}
pub fn convert(input: &Path, output: &Path) -> std::io::Result<()> {
let file = File::open(input)?;
let mmap = unsafe { Mmap::map(&file)? };
let mut cur = std::io::Cursor::new(&mmap[..]);
let header = read_header(&mut cur)?;
let (tensors, data_start) = read_tensor_index(&mut cur, header.tensor_count)?;
// Build a safetensors metadata map. We materialize only F32/F16 tensors
// for this starter build; quantized shards are exposed as raw u8 buffers
// tagged with their GGML type in the metadata.
let mut metadata: BTreeMap<String, TensorMetadata> = BTreeMap::new();
let mut slices: Vec<(String, Vec<u8>, Vec<usize>)> = Vec::new();
for t in &tensors {
let byte_len = tensor_byte_size(t);
let abs_offset = data_start as usize + t.relative_offset;
let shard = &mmap[abs_offset..abs_offset + byte_len];
if let Some(dt) = ggml_to_safetensors_dtype(t.ggml_type) {
let shape: Vec<usize> = t.dims.iter().map(|&d| d as usize).collect();
metadata.insert(t.name.clone(), TensorMetadata { dtype: dt, shape });
slices.push((t.name.clone(), shard.to_vec(), shape));
} else {
// Quantized: keep raw bytes, expose GGML type in metadata.
metadata.insert(
t.name.clone(),
TensorMetadata { dtype: Dtype::U8, shape: vec![byte_len] },
);
slices.push((t.name.clone(), shard.to_vec(), vec![byte_len]));
}
}
let f = File::create(output)?;
serialize_to_file(slices, &metadata, &f)?;
Ok(())
}
fn main() -> std::io::Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = Path::new(&args[1]);
let output = Path::new(&args[2]);
convert(input, output)
}
A few deliberate choices worth calling out:
- We use
BTreeMapfor deterministic iteration order, which is a documented requirement of the safetensors format. - Quantized tensors are kept as opaque
U8byte buffers rather than silently dropped. That decision sets up a clean extension point. - The mmap lives for the duration of
convert, so all tensor slices are zero-copy.
Running and Testing It
Running locally
# Pull a small model; TinyLlama is a favorite.
huggingface-cli download TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF \
tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf --local-dir ./models
cargo run --release -- ./models/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf \
./out/tinyllama.safetensors
You should see a ./out/tinyllama.safetensors file roughly the same size as the input.
Round-trip test
A parser without a test is a guess. Here’s a property-style test using a tiny synthetic GGUF.
// tests/roundtrip.rs
use gguf2safetensors::header::read_header;
use gguf2safetensors::tensors::{read_tensor_index, tensor_byte_size};
use std::io::Cursor;
#[test]
fn parses_synthetic_header() {
// Build a minimal GGUF v3 stream by hand: header + 1 KV + 1 tensor info.
let mut bytes = Vec::new();
bytes.extend_from_slice(&0x46554747u32.to_le_bytes()); // magic
bytes.extend_from_slice(&3u32.to_le_bytes()); // version
bytes.extend_from_slice(&1u64.to_le_bytes()); // tensor_count
bytes.extend_from_slice(&0u64.to_le_bytes()); // kv_count
// tensor name
bytes.extend_from_slice(&2u64.to_le_bytes());
bytes.extend_from_slice(b"x");
bytes.extend_from_slice(&1u32.to_le_bytes()); // n_dims
bytes.extend_from_slice(&4u64.to_le_bytes()); // dim
bytes.extend_from_slice(&0u32.to_le_bytes()); // ggml = F32
bytes.extend_from_slice(&0u64.to_le_bytes()); // offset
let mut cur = Cursor::new(&bytes);
let h = read_header(&mut cur).unwrap();
assert_eq!(h.version, 3);
assert_eq!(h.tensor_count, 1);
let (t, data_start) = read_tensor_index(&mut cur, h.tensor_count).unwrap();
assert_eq!(t.len(), 1);
assert_eq!(t[0].name, "x");
assert_eq!(t[0].dims[0], 4);
assert_eq!(t[0].ggml_type, gguf2safetensors::types::GgmlType::F32);
assert_eq!(tensor_byte_size(&t[0]), 16); // 4 * f32
assert_eq!(data_start % 32, 0);
}
Run the test with cargo test. Then validate the output on a real file using Hugging Face’s own loader:
from safetensors import safe_open
with safe_open("out/tinyllama.safetensors", framework="pt") as f:
for k in f.keys():
t = f.get_tensor(k)
print(k, tuple(t.shape), t.dtype)
If you see the expected tensor names — token_embd.weight, blk.0.attn_q.weight, output.weight, and so on — your parser is correct.
Sanity checks worth adding
magic == GGUF_MAGICfor every file tested.tensor_countmatches the actual tensor array length.- Sum of all
tensor_byte_size(t)plus the data section offset equals the file length. - For F32/F16 tensors, the produced safetensors hashes match a reference conversion done with llama.cpp’s converter.
These last two are what the transformers.js GGUF loader tests against. Reusing their expectations is free verification.
Extending It: Your Roadmap to Senior-Level
The starter build is honest but small. Each of these upgrades turns it into something a senior engineer would actually recognize.
- Decode quantized tensors natively. Wire up
Q4_K,Q5_K,Q6_K, andQ8_Kdequantization using the reference math fromggml-quants.c. Reason: this is the actual hard part of any GGUF reader, and the code review surface area teaches you bit manipulation, fixed-point math, and SIMD considerations. - Add async shard prefetching with
tokio. When a file exceeds RAM, prefetch the next N tensor shards into a bounded channel while the consumer is decoding the current one. Reason: this is the exact pattern used by vLLM’s weight loader and by Hugging Face Hub’s snapshot download. It signals distributed-systems thinking. - Persist a
duckdborsqliteindex of every converted model. Store tensor names, shapes, dtypes, byte ranges, and quantization schemes. Reason: this is what production model registries (think BentoML, MLflow) do internally. It also makes your CLI trivially queryable:gguf2st list --filter "Q4_K". - Add Prometheus metrics and
tracingspans around each phase. Emitparse_duration_seconds,tensor_bytes_total,shard_latency_seconds. Reason: observability is the cheapest credibility boost in any backend CV bullet. Pair it with a Grafana dashboard JSON checked in to the repo. - Implement fault tolerance via a content-addressed store. Store converted shards in a content-addressed layout (SHA-256 of bytes as filename) keyed by
(model_id, tensor_name, ggml_type, sha_of_header). Reason: this is the architecture behind LakeFS and Nix, and it makes reruns idempotent — a property every CI pipeline quietly wants. - Write a
criterionbenchmark comparing your mmap path against aread()-based reference. Add a benchmark that materializes TinyLlama end to end and reports throughput, peak RSS, and page-fault count viaperf stat. Reason: a single screenshot of “your path is 1.4x faster at 40% less RSS” is worth a hundred lines of commentary in an interview.
Do two of those well and you have a project that reads as “this person ships systems software.” Do four and you have a project that reads as “this person could land mid-level on an inference team on day one.”
Key Takeaways
- A GGUF parser is a dense, real-world systems project: binary format literacy, mmap, typed enums, alignment math, and format interop in one tidy crate.
- The architecture decomposes cleanly into header reader, metadata walker, tensor index reader, and shard streamer — each testable in isolation.
memmap2+ zero-copy slices +safetensors’serialize_to_fileis the shortest path to a working conversion, and it avoids ever loading a model into RAM.- Quantized tensors are the real challenge; the F32/F16 path is a useful 30% of the work that gets a demo running fast.
- The extension roadmap — quantization decoding, async prefetching, a content-addressed store, observability, and benchmarking — is what turns a toy into a portfolio-grade artifact.
Further Reading
- GGUF specification (ggml-org/ggml) — the canonical format reference; cross-check every offset against this doc.
- llama.cpp
gguf-pyparser — the reference Python implementation; read it side-by-side with your Rust code to find edge cases. - safetensors format spec — byte layout, header constraints, and the
serialize_to_filecontract. ggml-common.h— block-size constants for every quantization format; the single source of truth fortype_size.ggml-quants.c— the dequantization routines you’ll reimplement in Rust for the senior-level upgrade.- Rust
memmap2crate docs — platform notes forMAP_POPULATE, huge pages, and Windows behavior. - Linux kernel
mmap(2)man page — the syscall your code ultimately rests on; understandingMAP_PRIVATEvsMAP_SHAREDmatters when you add async prefetching. - Brendan Gregg on memory mapping — the canonical reference for
perf stat, page faults, and zero-copy I/O patterns.