The .trit format, version 1
A .trit file is a single self-describing container holding one quantized
model: an embedded copy of the source config.json, a tensor table, and a
payload of ternary and dense tensors.
Its one real design commitment is that ternary weights are stored as bit planes, in the exact order and grouping the compute engine consumes them. A 64-column block of one row occupies 16 contiguous bytes -- one 64-bit positive mask and one 64-bit negative mask. That block is simultaneously:
- one cycle's weight input to the
tritcoreRTL engine, and - one iteration of the CPU kernel's inner loop, on every supported ISA.
So a memory-mapped .trit is not a container the runtime decodes into some
other layout. It is the layout. Nothing between the page cache and the
accumulator rewrites a byte.
Version 1 is the only version this build reads or writes. Version 0, which used interleaved 2-bit codes, is rejected with a message naming the migration command; see Migrating from v0.
1. Why bit planes
A ternary weight has three states, so two bits is the natural budget either way.
Version 0 spent them as an interleaved code per weight -- 00 = 0, 01 = +1,
10 = -1 -- packed four weights to a byte. Version 1 spends the same two bits
planar: all the "is this weight +1" bits of a 64-column block in one word, all
the "is this weight -1" bits in the next.
The bit count is identical. What changes is what a machine can do with one load.
y[r] = sum over c of W[r][c] * x[c] W[r][c] in {-1, 0, +1}
= sum of x[c] where W[r][c] = +1
- sum of x[c] where W[r][c] = -1
With planes, each of those two sums is a mask applied to a vector of
activations. On AVX-512 the mask is a k register and the whole operation is
two vmovdqu8 under mask plus two vpdpbusd. On NEON and AVX2 the mask is
expanded to byte lanes first. In every case the decision "does this weight
participate, and with which sign" costs no branch, no table lookup, and no
per-weight arithmetic.
With interleaved codes, extracting the sign of lane k means shifting by 2k
and masking -- per weight, defeating the vectorization entirely, and the reason
the pre-pivot scalar kernel ran a branch per weight.
The RTL core wants exactly the same thing: 64 lanes each asking "am I set in pos? in neg?" is 128 wires and a mux, with no 2-bit decode in front of it.
Ternary weights are 2.000 bits each, exactly, when the column count is a multiple of 64 -- as every projection in BitNet b1.58 2B4T is.
2. File layout
All integers are little-endian. All offsets are byte offsets.
off size field
0 4 magic, ASCII "TRIT"
4 4 u32 version = 1
8 4 u32 header_flags (reserved; must be 0)
12 8 u64 payload_hash (FNV-1a-64 over the payload; 0 = not computed)
20 4 u32 config_len (<= 16 MiB)
24 n config (UTF-8 JSON: the source config.json verbatim)
. 4 u32 tensor_count (<= 1048576)
. . tensor records (tensor_count of them, see 2.2)
. . zero padding (until the offset is a multiple of 64)
. . payload
payload_start -- the offset just past the padding -- is always a multiple of
64. This is a format invariant, not an optimization: mapping bases are
page-aligned, so an aligned payload_start plus aligned tensor offsets is what
puts every tensor on a cache-line boundary in the mapped image. That is what
lets a dense f32 tensor be borrowed from the map instead of copied.
2.1 Header fields
header_flags and the per-tensor flags are both reserved and must be
zero. A reader rejects unknown bits rather than ignoring them, so a future
format extension cannot be silently half-understood by an old binary.
payload_hash is FNV-1a-64 over the payload region. It is cheap,
dependency-free, and detects a truncated download or a half-written file. It is
not a security primitive and is not claimed to be one. A writer that cannot or
will not compute it stores 0, which readers report as "absent" rather than
treating as a mismatch. The hash is only checked on request -- see
Validation.
config is the source config.json, byte for byte. Keeping the original
rather than a re-serialized subset means fields this runtime does not yet
understand survive conversion, and a .trit remains diffable against the
checkpoint it came from.
2.2 Tensor record
size field
2 u16 name_len (1..=1024)
n name (UTF-8, unique within the file)
1 u8 dtype (0 = F32, 1 = Trit, 2 = BF16)
1 u8 ndim (1..=4)
1 u8 reserved0 (must be 0)
1 u8 reserved1 (must be 0)
4*d u32 dims[ndim] (row-major, outermost first)
4 f32 scale (must be finite and > 0)
4 u32 flags (reserved; must be 0)
8 u64 offset (relative to payload_start; multiple of 64)
8 u64 byte_len
The two reserved bytes are not padding for its own sake: they put dims on a
4-byte boundary within the record and leave room for a future layout byte
(blocked or tiled ternary orderings, per-row scales) at no cost.
scale is the dequantization factor for ternary tensors and 1.0 for dense
ones. It is rejected unless finite and strictly positive -- a NaN scale would
otherwise propagate silently into every logit with no error raised anywhere.
Tensors are written sorted by name. The converter enforces this so that
converting the same checkpoint twice produces byte-identical files, which is
what makes a checksum of a .trit mean anything.
2.3 Ternary payload
A ternary tensor must be 2-D: rows = dims[0], cols = dims[1].
beats_per_row = ceil(cols / 64)
byte_len = rows * beats_per_row * 16
Row r occupies beats_per_row consecutive beats starting at
r * beats_per_row * 16. Beat b of row r is the 16 bytes at
(r * beats_per_row + b) * 16:
bytes 0..8 u64 pos bit k set => W[r][b*64 + k] = +1
bytes 8..16 u64 neg bit k set => W[r][b*64 + k] = -1
Bit k is the k-th least significant bit. Neither bit set means the weight is
0. The dequantized value is scale * trit.
Two invariants hold for every beat:
P1 -- the planes are disjoint: pos & neg == 0.
Both bits set is invalid. It is not "reserved", not "implementation-defined",
and not silently coerced -- it is an error, and every consumer must treat it as
one. This is deliberate. A lane that tests pos first would produce +x; a CPU
kernel that accumulates the two planes separately and subtracts would produce
+x - x = 0. Defining the state as an error is what stops the software and
hardware paths from disagreeing on a file neither should have accepted. It is
the direct descendant of v0's illegal 0b11 code, which the RTL core already
flagged with a sticky err.
P2 -- padding bits are clear.
When cols is not a multiple of 64, the final beat of each row covers columns
that do not exist. Those bits must be zero in both planes. With
tail = (cols % 64 == 0) ? UINT64_MAX : (UINT64_C(1) << (cols % 64)) - 1
every row's last beat must satisfy (pos | neg) & ~tail == 0.
The width is part of the rule, not incidental notation. tail is a 64-bit
unsigned value and the shift is performed in 64 bits: written with a
default-width literal, 1 << r is undefined for r >= 32 in C and wraps to
zero in a 32-bit language, so a reader would form the wrong mask for every
remainder from 32 to 63 -- rejecting valid files, or accepting padding that is
not clear. Both planes and the complement ~tail are 64-bit likewise.
P2 is what makes zero padding exact rather than merely conventional: a padded
column contributes nothing to the accumulator, so a kernel may process whole
64-column blocks unconditionally and never needs a scalar tail loop. Activation
buffers are sized to beats_per_row * 64 with zeros in the tail for the same
reason.
Both invariants are cheap to establish at write time and O(payload) to verify, so they are checked on demand rather than at every open.
2.4 Dense payload
dtype = 0 (F32) and dtype = 2 (BF16) store prod(dims) elements row-major,
little-endian, with byte_len = prod(dims) * elem_size where elem_size is 4
or 2. scale is 1.0.
Dense tensors carry the embedding table, the LM head, and the normalization
gains. They are not incidental: in BitNet b1.58 2B4T they are 1,315 MB against
521 MB of ternary weights, so at batch 1 the LM head alone moves 2.5x the
bytes that every ternary projection moves combined. BF16 storage exists to halve
that. It is opt-in (tritc convert --embed-dtype bf16) because it changes
numerics and therefore needs its own quality gate, not because it is
experimental.
3. A complete file
Two rows of three ternary weights, W = [[+1, -1, 0], [0, +1, +1]], scale 0.5,
config {"hidden_size":3}. This is the real output of TritWriter, not a
reconstruction.
0000 54 52 49 54 01 00 00 00 00 00 00 00 60 f8 e9 b2 |TRIT........`...|
0010 58 bd 4a 4a 11 00 00 00 7b 22 68 69 64 64 65 6e |X.JJ....{"hidden|
0020 5f 73 69 7a 65 22 3a 33 7d 01 00 00 00 01 00 77 |_size":3}......w|
0030 01 02 00 00 02 00 00 00 03 00 00 00 00 00 00 3f |...............?|
0040 00 00 00 00 00 00 00 00 00 00 00 00 20 00 00 00 |............ ...|
0050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
0060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
0070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
0080 01 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00 |................|
0090 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
| Offset | Bytes | Meaning |
|---|---|---|
0000 |
54 52 49 54 |
magic TRIT |
0004 |
01 00 00 00 |
version 1 |
0008 |
00 00 00 00 |
header_flags 0 |
000c |
60 f8 e9 b2 58 bd 4a 4a |
payload_hash 0x4a4abd58b2e9f860 |
0014 |
11 00 00 00 |
config_len 17 |
0018 |
7b 22 ... 7d |
{"hidden_size":3} |
0029 |
01 00 00 00 |
tensor_count 1 |
002d |
01 00 |
name_len 1 |
002f |
77 |
name w |
0030 |
01 |
dtype 1 = Trit |
0031 |
02 |
ndim 2 |
0032 |
00 00 |
reserved |
0034 |
02 00 00 00 |
dims[0] = 2 rows |
0038 |
03 00 00 00 |
dims[1] = 3 cols |
003c |
00 00 00 3f |
scale = 0.5f |
0040 |
00 00 00 00 |
flags 0 |
0044 |
00 ... 00 |
offset 0 |
004c |
20 00 00 00 00 00 00 00 |
byte_len 32 |
0054 |
zeros | padding to payload_start |
0080 |
payload_start = 128, a multiple of 64 |
The payload is 2 rows * 1 beat * 16 bytes = 32 bytes:
| Offset | pos | neg | Decodes to |
|---|---|---|---|
0080 |
0x01 (bit 0) |
0x02 (bit 1) |
row 0 = [+1, -1, 0] |
0090 |
0x06 (bits 1,2) |
0x00 |
row 1 = [0, +1, +1] |
Columns 3..63 of each beat are clear in both planes, satisfying P2.
Regenerate this dump with:
cargo run -p trit-core --example hexdump
4. Validation
The file is untrusted input. Checks split by cost.
At open() -- O(tensor_count), header only. Fast enough to keep
time-to-first-token unaffected even on a 1.8 GB model.
magic != "TRIT"->bad magic: not a .trit fileversion == 0-> names the migration command (see below)version > 1->unsupported .trit version {v}header_flags != 0->unknown header flags 0x...- any field read past EOF ->
truncated .trit file config_len > 16 MiB, or config not valid UTF-8name_lenzero or> 1024; duplicate tensor name- unknown
dtype;ndimoutside1..=4; nonzeroreserved0/1orflags - shape product overflow
scalenot finite, or<= 0offset % 64 != 0offset + byte_lenpast end of payload- two tensors' payload extents overlap
dtype == Tritwithndim != 2byte_len != rows * beats_per_row * 16for ternary tensorsbyte_len != prod(dims) * elem_sizefor dense tensors
On request -- O(file size). TritFile::open_verified, TritFile::verify,
tritc verify, tritc convert --verify.
- P1 violated -> names the tensor, row, beat and column
- P2 violated -> names the tensor and row
payload_hashmismatch (skipped when the stored hash is 0)
Because checks 1-16 run up front, every accessor afterwards is infallible by construction: a span resolved from a validated file cannot be out of bounds, so the hot path carries no bounds-check-and-propagate machinery. The truncation suite cuts the example file at every byte of its header region and asserts each prefix produces an error rather than a panic.
5. Migrating from v0
Version 0 stored ternary weights as interleaved 2-bit codes -- 00 = 0,
01 = +1, 10 = -1, 11 illegal -- four weights per byte, weight k at bits
2k..2k+2 of byte k/4. It had no alignment guarantee, no payload hash, no
reserved fields, and no defined tensor ordering.
Opening a v0 file produces:
.trit v0 (2-bit interleaved codes) is no longer supported;
re-run 'tritc convert', or 'tritc upgrade --input <old> --output <new>'
Re-converting is the preferred path when the source checkpoint is at hand:
tritc convert --input models/bitnet-2b4t --output models/bitnet-2b4t.trit --verify
tritc upgrade covers the case where only the .trit survives. It reads v0
through a frozen parser that lives in tritc alone -- trit-core contains no v0
code at all, so the runtime cannot grow a legacy path by accident.
The upgrade is a pure re-encoding: same trits, same scales, same tensor set,
only the layout differs. That claim is tested rather than asserted. On the real
BitNet b1.58 2B4T checkpoint, tritc upgrade of the v0 file produces a result
byte-identical to a fresh tritc convert of the original safetensors --
2,084,044,800 trits and 210 scales reproduced exactly.
6. What a reader is allowed to assume
For a file that opened successfully:
payload_start % 64 == 0, and every tensoroffset % 64 == 0.- A ternary tensor's bytes are exactly
rows * beats_per_rowbeats, in row order, each beat{pos, neg}little-endian. - Every tensor's extent lies inside the file and overlaps no other tensor.
- Every
scaleis finite and positive. - Tensor names are unique.
For a file that additionally passed verification:
pos & neg == 0everywhere.- No padding bit is set past
cols. - The payload matches its stored hash, when one is stored.
A reader may not assume the file is trusted, that tensor names match any particular model architecture, or that the embedded config is complete or consistent with the tensors present. Those are the model loader's business, and it reports them as model errors rather than format errors.