← All posts

The GGUF Format: Seven Gigabytes That Explain Themselves

Every byte and number in this article comes from a single real file. All terminal output comes from one unedited recorded session. The test environment is listed at the end.

There is a file on disk with an unremarkable name:

gemma-4-12B-it-QAT-Q4_0.gguf

It takes up almost seven gigabytes and knows how to talk.

LM Studio opens it without a separate configuration file. llama.cpp understands the same file without a directory of supplementary descriptions, a database, or a neighboring JSON file. Everything it needs—the model architecture, parameters, tokenizer, chat template, and the weights themselves—is stored inside.

That is how GGUF works: it is not merely an archive of tensors, but a self-describing container. A loader does not need to know in advance what the file contains. It can open the file, read the header, and then proceed through a structure described by the file itself.

Let us see just how literally we should interpret the phrase “self-describing.”

We will start at byte zero and sequentially read the header, metadata, and tensor directory, without opening the weights themselves. Then we will try to predict the exact size of the seven-gigabyte file from its structure alone.

If the result matches stat down to the last byte, then the file really has told us everything about itself.

We will use the public model gemma-4-12B-it-QAT-Q4_0.gguf. The file is 6.5 GiB, supports resumable downloads, and is saved directly to the current directory:

$ curl -LO https://huggingface.co/lmstudio-community/\
gemma-4-12B-it-QAT-GGUF/resolve/main/gemma-4-12B-it-QAT-Q4_0.gguf

Now we can begin examining it.

The First 24 Bytes

A binary format is best studied not from a description, but from real bytes. Let us begin by printing the first 32:

$ xxd -l 32 gemma-4-12B-it-QAT-Q4_0.gguf
00000000: 4747 5546 0300 0000 9b02 0000 0000 0000  GGUF............
00000010: 2d00 0000 0000 0000 1400 0000 0000 0000  -...............

At first glance, this is just a sequence of hexadecimal numbers. But the structure begins to reveal itself immediately.

The first four bytes are:

47 47 55 46

In ASCII, this is the string GGUF—the format’s so-called magic number. A magic value lets the loader quickly determine what kind of file it has opened, even before parsing the rest of the contents.

The next four bytes are:

03 00 00 00

This is a 32-bit integer with the value 3, meaning that the format version is GGUF v3.

Numbers in GGUF are stored in little-endian order: the least significant byte comes first, followed by the more significant bytes. Therefore, the sequence

9b 02 00 00 00 00 00 00

must be read as the 64-bit number 0x29B, or 667 in decimal. That is exactly how many tensors the file contains.

The next eight bytes are:

2d 00 00 00 00 00 00 00

They give the number 45—the number of metadata key-value pairs.

The entire mandatory header fits into 24 bytes:

  • four bytes of magic;
  • four bytes for the version;
  • eight bytes for the tensor count;
  • eight bytes for the metadata entry count.

The next eight bytes are no longer part of the header:

14 00 00 00 00 00 00 00

This is the number 20—the length of the first metadata string.

Immediately after the compact header, the file begins introducing itself. The first 20-character string is the key:

general.architecture

Metadata: The Model’s Passport

The header is followed by 45 key-value pairs.

A key is always represented as a length-prefixed string. A value may have one of thirteen types: eight integer types, two floating-point types, bool, string, or array.

In the parser, those wire ids are not bare integers — they map onto an enum (strum derives the id-to-variant conversion and the printed name), so an unknown id fails loudly at the boundary instead of traveling through the code:

/// The 13 GGUF metadata value types, by their wire ids.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromRepr, Display)]
#[strum(serialize_all = "lowercase")]
#[repr(u32)]
pub enum ValueType {
    U8 = 0, I8 = 1, U16 = 2, I16 = 3, U32 = 4, I32 = 5, F32 = 6,
    Bool = 7, String = 8, Array = 9, U64 = 10, I64 = 11, F64 = 12,
}

Each parsed value lands in a typed MetaValue enum with the same thirteen variants; an array includes both its element type and its number of values, so the structure can describe anything from small lists of numbers to hundreds of thousands of tokenizer strings.

A small Rust parser walks through the entries sequentially and prints the file’s contents (its full source is in the appendix at the end):

$ gguf-info gemma-4-12B-it-QAT-Q4_0.gguf
header:      GGUF v3, 667 tensors, 45 metadata pairs

metadata (38 scalars, 7 arrays, ends at byte 15782884):
  general.architecture                         gemma4
  general.size_label                           12B
  gemma4.block_count                           48
  gemma4.embedding_length                      3840
  gemma4.feed_forward_length                   15360
  gemma4.attention.head_count                  16
  gemma4.context_length                        262144
  gemma4.attention.sliding_window              1024
  general.sampling.top_k                       64
  general.sampling.top_p                       0.95
  general.sampling.temp                        1
  tokenizer.chat_template                      {%- macro format_type_argument(type_value) -%}…

  tokenizer.ggml.tokens                        [string; 262144]
  tokenizer.ggml.scores                        [f32; 262144]
  tokenizer.ggml.token_type                    [i32; 262144]
  tokenizer.ggml.merges                        [string; 514906]

At this point, we already know most of what would normally have to be looked up in a separate configuration file.

The model reports the architecture name—gemma4; the number of transformer blocks—48; the hidden representation size—3,840; the feed-forward layer size—15,360; the number of attention heads—16; and the maximum context length—262,144 tokens.

To clarify: the architecture name determines how the model is structured; the number of transformer blocks indicates its depth; the hidden representation size is the amount of data passed between layers; the feed-forward layer size is the width of the internal transformations; the number of attention heads is the number of parallel ways the model can analyze context; and the maximum context is the amount of text the model can consider at once.

But the metadata is not limited to the architecture.

It also contains the recommended sampling values:

top_k = 64
top_p = 0.95
temperature = 1

The file also includes a Jinja chat template and the IDs of special-purpose tokens. As a result, the runtime receives not only a set of matrices, but also information about how to prepare a conversation and interpret the model’s output.

“One file” is not a marketing simplification here. It is one of the format’s defining properties.

Why the Metadata Takes Up Almost 16 Megabytes

A binary file header is usually expected to be small: a few numbers, flags, and strings. In our case, however, the metadata section ends at byte:

15 782 884

That is almost sixteen megabytes before the tensor directory and the weights themselves even begin.

Most of this space is not used to describe the architecture. Numbers such as the layer count or context length occupy only a handful of bytes.

The main consumer is the tokenizer:

tokenizer.ggml.tokens      [string; 262144]
tokenizer.ggml.scores      [f32; 262144]
tokenizer.ggml.token_type  [i32; 262144]
tokenizer.ggml.merges      [string; 514906]

The file contains 262,144 vocabulary strings, the same number of token scores and token types, and 514,906 BPE merges.

A BPE merge is a rule that tells the tokenizer to combine two adjacent tokens into a larger one; the list of 514,906 merges defines the order and priority of those combinations when text is tokenized.

The model stores not only the ability to continue a sequence of tokens, but also the rules by which the original text is converted into those tokens.

The largest part of its “profile” is not the architecture parameters, but the language vocabulary.

Lengths Read from a File Cannot Be Trusted

To read a string, the parser first obtains its length and then consumes the corresponding number of bytes:

pub fn string(&mut self) -> Result<String> {
    let len = self.u64()?;
    ensure!(len < 1 << 20, "string length {len} is hostile");
    Ok(String::from_utf8_lossy(&self.bytes(len as usize)?).into_owned())
}

The check

ensure!(len < 1 << 20, ...)

is not part of GGUF’s logic. It protects the parser itself.

It is important to understand that every length stored inside a binary file is a value supplied by an external source. A corrupted or deliberately crafted file may claim that the next string is several gigabytes long. If the loader trusts that number unconditionally, it may try to allocate an enormous buffer, read beyond the end of the file, or overflow while calculating offsets.

That is why the length is validated before the data is read.

A string that claims to be two gigabytes long is not necessarily a large string. It may be an attack.

In this article, we are simply reading a valid file carefully. But fields like these are exactly what loader fuzzing usually targets: random and boundary values are substituted to find places where a parser trusts its input too much.

The Tensor Directory

The tensor directory begins immediately after the metadata.

It does not contain the weights themselves. It is a table of contents in which each tensor has the following fields:

  • name;
  • number of dimensions;
  • dimensions;
  • storage type;
  • offset within the data section.

The directory contains 667 entries in total. On average, each one occupies about sixty bytes:

tensor directory (ends at byte 15822741):
  output_norm.weight           3840             F32   offset            0
  rope_freqs.weight            256              F32   offset        15360
  token_embd.weight            3840 × 262144    Q6_K  offset        16384
  blk.0.attn_k.weight          3840 × 2048      Q4_0  offset    825769984
  … 659 more …
  blk.47.ffn_up.weight         3840 × 15360     Q4_0  offset   6926847456
  blk.47.post_ffw_norm.weight  3840             F32   offset   6960040448

The tensor names follow a regular structure.

There are global tensors:

output_norm.weight
rope_freqs.weight
token_embd.weight

They are followed by blocks:

blk.0
blk.1

blk.47

Each block contains attention matrices, feed-forward network matrices, and normalization tensors.

The metadata has already told us that the architecture contains 48 blocks. The directory confirms this not as a declaration, but through concrete addresses ranging from blk.0 to blk.47.

Offsets are measured not from the beginning of the file, but from the beginning of the data section. This matters: the loader locates the start of the weights once, then obtains the address of any tensor with a simple addition:

tensor_address = data_start + tensor_offset

The Model Can Be Counted Without Reading the Weights

The directory contains the dimensions of every tensor. This means the number of parameters in a tensor can be obtained with ordinary multiplication.

For the matrix:

3840 × 2048

that gives:

7 864 320 parameters

Now sum the products of the dimensions for all 667 tensors:

parameters:  11.91 B (11907350576)
weights:     6.48 GiB
quantization mix:
  F32    338 tensors    0.00 GiB   0.0%
  Q4_0   328 tensors    5.71 GiB  88.1%
  Q6_K     1 tensors    0.77 GiB  11.9%

The result is:

11 907 350 576 parameters

This is where the 12B label comes from. It is not a separate number that must be taken on trust: it can be reconstructed from the directory.

The quantization mix is visible there as well.

Of the 667 tensors:

  • 338 are stored in F32;
  • 328 are stored in Q4_0;
  • one is stored in Q6_K.

The tensor count alone says little about how much space they occupy. Hundreds of F32 tensors may be almost negligible if each contains only a few thousand elements. A single enormous matrix, meanwhile, can account for a substantial portion of the file.

That is exactly what happens here.

What Q4_0 Means

Without quantization, each F32 weight occupies four bytes, or 32 bits.

Quantization stores model weights in a more compact format using fewer bits, reducing the model’s size and potentially speeding up computation. Instead of F32, formats such as F16, INT8, or INT4 may be used, as well as more complex formats that are not aligned to byte boundaries.

For a model with 11.9 billion parameters, F32 would require tens of gigabytes for the weights alone. To reduce the size, the values are stored in quantized form.

In Q4_0, weights are divided into blocks of 32 values. One such block occupies 18 bytes:

  • 16 bytes contain 32 four-bit values;
  • another two bytes store a shared scale for the block.

On average, this gives:

18 × 8 / 32 = 4.5 bits per weight

There is no separate “half-bit” attached to each weight: the block can be represented as a structure such as { scale: f16, values: [u8; 16] }. The scale’s 16 bits are shared across 32 weights, so each weight accounts for an average of another 16 / 32 = 0.5 bits on top of the four bits used for the value itself.

This is how the 328 main attention and feed-forward matrices are stored. Together they occupy 5.71 GiB, or 88.1% of all weight data.

But one tensor breaks the pattern:

token_embd.weight  3840 × 262144  Q6_K

This is the vocabulary embedding matrix. It contains more than a billion parameters—about 8.5% of the entire model—and occupies 0.77 GiB.

Embeddings are numerical vectors through which the model represents tokens and their meaning. A researcher usually cannot say what any individual number means: the meaning is distributed across many coordinates at once.

This tensor uses Q6_K quantization, with an average density of 6.56 bits per weight.

In other words, the quantizer allocated roughly one and a half times as many bits per parameter to the embeddings as it did to most of the other matrices.

The reason lies in the importance of this tensor: the embedding participates in the representation of every input token and is tied to the model’s output vocabulary. An error in it affects not just one layer or block, but the entire stream of tokens.

The directory does not explain why the quantizer made this decision. But it does reveal the decision itself: almost the entire model is stored in Q4_0, while the vocabulary receives the more precise Q6_K format.

The remaining 338 F32 tensors are mostly small normalization tensors. They are numerous, but their combined size does not even register at the hundredth-of-a-percent level.

Saving a few bits on tiny tensors is almost pointless, so they can remain at full precision without noticeably increasing the file size.

Predicting the File Size

By this point, we have read the header, metadata, and tensor directory.

The parser has reached byte:

15 822 741

But the weights themselves, which occupy 6.48 GiB, have not been opened.

Now let us see whether the information already obtained is enough to calculate the size of the entire file.

The data section must begin at an aligned address. The required alignment is stored in the metadata and is 32 bytes.

The directory ends at offset:

15 822 741

The next address divisible by 32 is:

15 822 752

There are eleven bytes of padding between the directory and the data.

Padding carries no useful values. It ensures that the data section begins on a boundary convenient for memory access.

The directory also reports the offset and type of every tensor. Given its dimensions and quantization format, we can calculate how many bytes it occupies.

The farthest tensor determines the end of the data section:

let data_start = directory_end.div_ceil(alignment) * alignment;
let predicted = data_start + data_size;

The calculation produces:

predicted:   6975878560 bytes
actual:      6975878560 bytes (stat)
EXACT

Predicted size:

6 975 878 560 bytes

Actual size:

6 975 878 560 bytes

The match is exact.

We did not read a single weight, yet we were able to state the size of a seven-gigabyte file down to the final byte.

GGUF’s self-describing nature is literal here: the file contains enough information to reconstruct not only the names and shapes of its tensors, but also its own physical layout.

Why Offsets Are Relative to the Data Section

The absolute position of each tensor could have been stored relative to the beginning of the file. GGUF instead uses offsets relative to the beginning of the data section.

This divides the file into two logical parts:

  1. the description;
  2. the weights themselves.

The loader first parses the relatively small region containing the header, metadata, and directory. It then calculates data_start and interprets every offset relative to that point.

This approach is convenient for memory mapping.

The operating system can create a memory mapping, after which the file’s contents become available to the process as a range of virtual memory. To access a particular tensor, there is no need to read all preceding data sequentially or unpack a shared archive.

It is enough to calculate:

data_start + offset

and take a slice of the required length.

Of course, the physical file pages still have to be brought into memory as they are accessed. But the operating system handles that. The runtime does not need to copy all 6.5 GiB into a separate buffer in advance.

This is why large model files can open quickly: “loading a model” does not always mean immediately reading every byte of its weights.

How a model larger than the available RAM operates through memory mapping and page eviction is a separate topic.

One File Instead of a Set of Conventions

GGUF solves several problems at once.

The metadata describes the architecture and runtime parameters. Tokenizer arrays make it possible to convert text into tokens without an external vocabulary. The directory maps tensor names to their shapes, types, and addresses. The data section stores the weights themselves in formats the runtime understands.

As a result, the same file can be passed to different compatible programs. They do not need to agree on the name of a neighboring JSON file, the directory layout, or how separate binary fragments should be matched together.

The format becomes the contract.

This also makes independent analysis tools possible. Our gguf-info does not run the model, implement a transformer, or know how to generate text. It consists of roughly four hundred lines of Rust — a module that types the wire format and a binary that reads and reports — sequentially walking the file’s self-description.

Such a tool can answer practical questions before the weights are loaded:

  • which architecture is stored inside;
  • how many layers it has;
  • what context size it declares;
  • which tokenizer it uses;
  • how many parameters the model contains;
  • which quantization formats are used;
  • how much space each tensor occupies;
  • where the data section begins and ends.

In effect, it is an equivalent of file(1) for models.

Self-Description Makes the Format Verifiable

A self-describing format has another advantage: its claims can be cross-checked.

The header declares 667 tensors, so the parser must read exactly 667 directory entries.

The metadata declares 48 blocks, so the names should include blk.0 through blk.47.

The 12B label can be compared with the sum of the products of the tensor dimensions:

11 907 350 576

The tensor offsets, types, and shapes make it possible to calculate the total volume of the weights.

The alignment and the end of the directory determine the start of the data section.

The last tensor determines the end of the file.

Any of these relationships can serve as an integrity check. If the numbers stop agreeing, the file is corrupted, truncated, or incorrectly constructed.

As mentioned earlier, this does not mean that GGUF is automatically safe from every kind of invalid input. A parser must still validate lengths, arithmetic overflows, offset ranges, and the file’s actual size.

But the format provides enough structure for those checks to be possible in the first place.

What Is Inside a Model File

From the outside, GGUF looks like one large binary object. Inside, its organization is much easier to understand:

  • a 24-byte header reports the version and the number of records that follow;
  • metadata describes the architecture, tokenizer, and runtime parameters;
  • the directory lists the tensors, their dimensions, types, and offsets;
  • an aligned data section contains the quantized weights.

In our file, the description ends at approximately the sixteenth megabyte. It is followed by 6.48 GiB of weights for an 11.9-billion-parameter model.

Most of the working matrices are packed in Q4_0. The enormous vocabulary embedding is stored at higher precision in Q6_K. Small normalization tensors remain in F32.

We were able to determine all of this without running the model or reading the contents of its tensors.

Finally, the file passed the strictest test: from its description alone, we calculated its actual size—6 975 878 560 bytes.

A match down to the byte is the best possible answer to the question of what a self-describing format means.

GGUF does not merely store weights.

It explains what those weights are, how they are laid out, and where the file itself ends.

Appendix: Full Source Files

format.rs — the wire format as types
//! The GGUF wire format as Rust types: every id the file can utter is an
//! enum variant, every value a typed `MetaValue` — no stringly-typed
//! parsing, no bare integers. Reading stays explicit byte-walking on
//! purpose: the format is the subject here, and a serde Deserializer
//! would wrap these exact reads in trait plumbing without removing one.
//!
//! Every length that arrives from the file is untrusted and checked
//! before use — a taste of the discipline the fuzzing episode expands.

use anyhow::{Context, Result, ensure};
use std::fmt;
use std::fs::File;
use std::io::{BufReader, Read};
use strum::{Display, FromRepr};

/// Sequential reader that tracks its absolute offset in the file.
pub struct Cursor {
    reader: BufReader<File>,
    pub position: u64,
}

impl Cursor {
    pub fn new(file: File) -> Self {
        Self {
            reader: BufReader::new(file),
            position: 0,
        }
    }

    pub fn bytes(&mut self, len: usize) -> Result<Vec<u8>> {
        let mut buffer = vec![0_u8; len];
        self.reader
            .read_exact(&mut buffer)
            .with_context(|| format!("unexpected end of file at offset {}", self.position))?;
        self.position += len as u64;
        Ok(buffer)
    }

    pub fn u32(&mut self) -> Result<u32> {
        Ok(u32::from_le_bytes(self.bytes(4)?.try_into().unwrap()))
    }

    pub fn u64(&mut self) -> Result<u64> {
        Ok(u64::from_le_bytes(self.bytes(8)?.try_into().unwrap()))
    }

    pub fn string(&mut self) -> Result<String> {
        let len = self.u64()?;
        ensure!(len < 1 << 20, "string length {len} is hostile");
        Ok(String::from_utf8_lossy(&self.bytes(len as usize)?).into_owned())
    }
}

/// The 13 GGUF metadata value types, by their wire ids.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromRepr, Display)]
#[strum(serialize_all = "lowercase")]
#[repr(u32)]
pub enum ValueType {
    U8 = 0,
    I8 = 1,
    U16 = 2,
    I16 = 3,
    U32 = 4,
    I32 = 5,
    F32 = 6,
    Bool = 7,
    String = 8,
    Array = 9,
    U64 = 10,
    I64 = 11,
    F64 = 12,
}

impl ValueType {
    pub fn parse(id: u32) -> Result<Self> {
        Self::from_repr(id).with_context(|| format!("unknown metadata value type {id}"))
    }
}

/// One parsed metadata value — arrays keep their shape, not their body.
pub enum MetaValue {
    U8(u8),
    I8(i8),
    U16(u16),
    I16(i16),
    U32(u32),
    I32(i32),
    F32(f32),
    Bool(bool),
    String(String),
    Array { elem: ValueType, count: u64 },
    U64(u64),
    I64(i64),
    F64(f64),
}

impl MetaValue {
    pub fn read(cursor: &mut Cursor, kind: ValueType) -> Result<Self> {
        Ok(match kind {
            ValueType::U8 => Self::U8(cursor.bytes(1)?[0]),
            ValueType::I8 => Self::I8(cursor.bytes(1)?[0] as i8),
            ValueType::U16 => Self::U16(u16::from_le_bytes(cursor.bytes(2)?.try_into().unwrap())),
            ValueType::I16 => Self::I16(i16::from_le_bytes(cursor.bytes(2)?.try_into().unwrap())),
            ValueType::U32 => Self::U32(cursor.u32()?),
            ValueType::I32 => Self::I32(cursor.u32()? as i32),
            ValueType::F32 => Self::F32(f32::from_le_bytes(cursor.bytes(4)?.try_into().unwrap())),
            ValueType::Bool => Self::Bool(cursor.bytes(1)?[0] != 0),
            ValueType::String => Self::String(cursor.string()?),
            ValueType::Array => {
                let elem = ValueType::parse(cursor.u32()?)?;
                let count = cursor.u64()?;
                ensure!(count < 1 << 32, "array length {count} is hostile");
                for _ in 0..count {
                    Self::read(cursor, elem)?;
                }
                Self::Array { elem, count }
            }
            ValueType::U64 => Self::U64(cursor.u64()?),
            ValueType::I64 => Self::I64(cursor.u64()? as i64),
            ValueType::F64 => Self::F64(f64::from_le_bytes(cursor.bytes(8)?.try_into().unwrap())),
        })
    }

    /// Integer view, for values that configure the parser (alignment).
    pub fn as_u64(&self) -> Option<u64> {
        match self {
            Self::U8(value) => Some(*value as u64),
            Self::U16(value) => Some(*value as u64),
            Self::U32(value) => Some(*value as u64),
            Self::U64(value) => Some(*value),
            _ => None,
        }
    }
}

impl fmt::Display for MetaValue {
    fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::U8(value) => write!(out, "{value}"),
            Self::I8(value) => write!(out, "{value}"),
            Self::U16(value) => write!(out, "{value}"),
            Self::I16(value) => write!(out, "{value}"),
            Self::U32(value) => write!(out, "{value}"),
            Self::I32(value) => write!(out, "{value}"),
            Self::F32(value) => write!(out, "{value}"),
            Self::Bool(value) => write!(out, "{value}"),
            Self::String(value) => write!(out, "{value}"),
            Self::Array { elem, count } => write!(out, "[{elem}; {count}]"),
            Self::U64(value) => write!(out, "{value}"),
            Self::I64(value) => write!(out, "{value}"),
            Self::F64(value) => write!(out, "{value}"),
        }
    }
}

/// The ggml tensor types this file may carry, by their wire ids.
/// Unknown ids fail loudly instead of guessing.
#[allow(non_camel_case_types)] // wire names: Q4_0 is the type's real name
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, FromRepr, Display)]
#[repr(u32)]
pub enum GgmlType {
    F32 = 0,
    F16 = 1,
    Q4_0 = 2,
    Q4_1 = 3,
    Q5_0 = 6,
    Q5_1 = 7,
    Q8_0 = 8,
    Q2_K = 10,
    Q3_K = 11,
    Q4_K = 12,
    Q5_K = 13,
    Q6_K = 14,
    BF16 = 30,
}

impl GgmlType {
    pub fn parse(id: u32) -> Result<Self> {
        Self::from_repr(id).with_context(|| format!("unknown tensor type {id}"))
    }

    /// (bytes per block, elements per block).
    pub fn block(self) -> (u64, u64) {
        match self {
            Self::F32 => (4, 1),
            Self::F16 | Self::BF16 => (2, 1),
            Self::Q4_0 => (18, 32),
            Self::Q4_1 => (20, 32),
            Self::Q5_0 => (22, 32),
            Self::Q5_1 => (24, 32),
            Self::Q8_0 => (34, 32),
            Self::Q2_K => (84, 256),
            Self::Q3_K => (110, 256),
            Self::Q4_K => (144, 256),
            Self::Q5_K => (176, 256),
            Self::Q6_K => (210, 256),
        }
    }
}

/// One tensor directory entry.
pub struct Tensor {
    pub name: String,
    pub shape: Vec<u64>,
    pub kind: GgmlType,
    pub offset: u64,
}

impl Tensor {
    pub fn read(cursor: &mut Cursor) -> Result<Self> {
        let name = cursor.string()?;
        let dims = cursor.u32()?;
        ensure!(dims <= 4, "tensor {name} claims {dims} dimensions");
        let shape: Vec<u64> = (0..dims).map(|_| cursor.u64()).collect::<Result<_>>()?;
        let kind = GgmlType::parse(cursor.u32()?)?;
        let offset = cursor.u64()?;
        Ok(Self {
            name,
            shape,
            kind,
            offset,
        })
    }

    pub fn elements(&self) -> u64 {
        self.shape.iter().product()
    }

    pub fn byte_size(&self) -> Result<u64> {
        let (block_bytes, block_elems) = self.kind.block();
        let elements = self.elements();
        ensure!(
            elements % block_elems == 0,
            "tensor {} has {} elements, not divisible into {}-element blocks",
            self.name,
            elements,
            block_elems
        );
        Ok(elements / block_elems * block_bytes)
    }
}
main.rs — the gguf-info report
//! Scene 02: `file(1)` for model files — read what a GGUF says about itself.
//!
//! The program walks the three layers of the container in order — header,
//! key–value metadata, tensor directory — WITHOUT touching tensor data.
//! Everything it prints is read or computed from those bytes alone,
//! including the exact file size the directory implies. The format is
//! self-describing; this program is the proof.

mod format;

use anyhow::{Context, Result, ensure};
use clap::Parser;
use format::{Cursor, GgmlType, MetaValue, Tensor, ValueType};
use std::collections::BTreeMap;
use std::fs::File;
use std::path::PathBuf;

/// Prints architecture, metadata, tensor table, quantization mix and the
/// predicted file size of a .gguf — from its self-description alone.
#[derive(Parser)]
struct Args {
    /// Path to the .gguf file
    model: PathBuf,
    /// How many tensor-table rows to print (head and tail)
    #[arg(long, default_value_t = 4)]
    rows: usize,
}

fn human(bytes: u64) -> String {
    format!("{:.2} GiB", bytes as f64 / (1 << 30) as f64)
}

fn main() -> Result<()> {
    let args = Args::parse();
    let file = File::open(&args.model).with_context(|| format!("open {:?}", args.model))?;
    let actual_size = file.metadata()?.len();
    let mut cursor = Cursor::new(file);

    // Layer 1: the 24-byte header.
    let magic = cursor.bytes(4)?;
    ensure!(&magic == b"GGUF", "not a GGUF file (magic {magic:02x?})");
    let version = cursor.u32()?;
    ensure!(version == 3, "unsupported GGUF version {version}");
    let tensor_count = cursor.u64()?;
    ensure!(
        tensor_count < 100_000,
        "tensor count {tensor_count} is hostile"
    );
    let kv_count = cursor.u64()?;
    ensure!(kv_count < 10_000, "metadata count {kv_count} is hostile");

    println!("header:      GGUF v{version}, {tensor_count} tensors, {kv_count} metadata pairs");

    // Layer 2: key–value metadata. Scalars are kept, arrays are walked
    // (their bytes must be consumed to find the tensor directory) but
    // only their shape is remembered.
    let mut alignment = 32_u64;
    let mut scalars: Vec<(String, MetaValue)> = Vec::new();
    let mut arrays: Vec<(String, MetaValue)> = Vec::new();
    for _ in 0..kv_count {
        let key = cursor.string()?;
        let kind = ValueType::parse(cursor.u32()?)?;
        let value = MetaValue::read(&mut cursor, kind)?;
        if key == "general.alignment" {
            alignment = value
                .as_u64()
                .with_context(|| format!("general.alignment is not an integer ({value})"))?;
        }
        match value {
            MetaValue::Array { .. } => arrays.push((key, value)),
            scalar => scalars.push((key, scalar)),
        }
    }
    let metadata_end = cursor.position;

    println!(
        "\nmetadata ({} scalars, {} arrays, ends at byte {metadata_end}):",
        scalars.len(),
        arrays.len()
    );
    for (key, value) in scalars.iter().chain(&arrays) {
        let mut text = value.to_string();
        if text.len() > 50 {
            text.truncate(47);
            text.push('');
        }
        println!("  {key:<44} {text}");
    }

    // Layer 3: the tensor directory.
    let tensors: Vec<Tensor> = (0..tensor_count)
        .map(|_| Tensor::read(&mut cursor))
        .collect::<Result<_>>()?;
    let directory_end = cursor.position;

    println!("\ntensor directory (ends at byte {directory_end}):");
    let show: Vec<usize> = (0..tensors.len().min(args.rows))
        .chain(tensors.len().saturating_sub(args.rows)..tensors.len())
        .collect();
    let mut last_shown = None;
    for index in show {
        if last_shown == Some(index) {
            continue;
        }
        if last_shown.is_some_and(|last| index != last + 1) {
            println!("  … {} more …", tensors.len() - 2 * args.rows);
        }
        let tensor = &tensors[index];
        let shape = tensor
            .shape
            .iter()
            .map(u64::to_string)
            .collect::<Vec<_>>()
            .join(" × ");
        println!(
            "  {:<28} {:<16} {:<5} offset {:>12}",
            tensor.name, shape, tensor.kind, tensor.offset
        );
        last_shown = Some(index);
    }

    // What the directory implies: parameter count, quantization mix, and
    // the exact size of the file that must follow.
    let mut by_type: BTreeMap<GgmlType, (u64, u64)> = BTreeMap::new();
    let mut parameters = 0_u64;
    let mut data_size = 0_u64;
    for tensor in &tensors {
        let size = tensor.byte_size()?;
        let entry = by_type.entry(tensor.kind).or_default();
        entry.0 += 1;
        entry.1 += size;
        parameters += tensor.elements();
        data_size = data_size.max(tensor.offset + size);
    }

    println!(
        "\nparameters:  {:.2} B ({parameters})",
        parameters as f64 / 1e9
    );
    println!("weights:     {}", human(data_size));
    println!("quantization mix:");
    for (kind, (count, bytes)) in &by_type {
        let share = *bytes as f64 / data_size as f64 * 100.0;
        println!(
            "  {:<5} {count:>4} tensors  {:>10}  {share:4.1}%",
            kind.to_string(),
            human(*bytes)
        );
    }

    // The self-description test: header + metadata + directory + padding
    // + data must land exactly on the file's last byte.
    let data_start = directory_end.div_ceil(alignment) * alignment;
    let predicted = data_start + data_size;
    println!("\nalignment:   {alignment} (data begins at byte {data_start})");
    println!("predicted:   {predicted} bytes");
    println!("actual:      {actual_size} bytes (stat)");
    println!(
        "{}",
        if predicted == actual_size {
            "EXACT"
        } else {
            "MISMATCH"
        }
    );
    Ok(())
}

Test environment: gemma-4-12B-it-QAT-Q4_0.gguf (lmstudio-community), 6 975 878 560 bytes, GGUF v3; parser: approximately 400 lines of Rust 1.97.1 (a wire-format module and a report binary), release build; tensor data was not read; Fedora 43. All terminal blocks come from one unedited recorded session.

Newsletter

New playgrounds in your inbox

Get an email when a new playground drops, plus the occasional engineering deep-dive. No spam, unsubscribe anytime.