Foundry/tt/convert/ft_tt_weight_convert.h

452 lines
22 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/**
* @file ft_tt_weight_convert.h
* @brief The Phase 3 weight conversion path: a GGUF checkpoint in, a tiled
* Blackhole weight artifact out, streaming, never full-model resident.
*
* ---------------------------------------------------------------------------
* RUN IT ON THE CARD HOST, NOT THE MAC — MEASURED, and it reverses spec §8.4
* ---------------------------------------------------------------------------
* §8.4 put this on the Mac because it is bulk scalar work and the card host is 2
* no-AVX cores at 1.8 GHz. That reasoning is about CPU and the binding
* constraint turned out to be MEMORY: peak RSS is **76-215 MB on the card host
* against 641 MB on macOS**, because `MADV_DONTNEED` is advisory-weak on Darwin
* and the Mac holds the whole GGUF mapping instead of dropping it behind the
* read cursor. The card host is also where the checkpoints already are, so nothing
* crosses the LAN. **MiniCPM5-1B is ~45 s there; the full 50.9 GiB Qwen3.8 is
* ~23 minutes.** Nothing here uses an SIMD intrinsic, so the same source builds
* on both and the artifact is byte-identical either way — this is an operating
* instruction, not a portability limit.
*
* ---------------------------------------------------------------------------
* WHY IT NEVER HOLDS A TENSOR
* ---------------------------------------------------------------------------
* The unit of work is a **strip**: 32 logical rows of one tensor, the full
* width. A strip of the widest tensor in MiniCPM5-1B is a few hundred KB, so
* peak host memory is set by the strip buffers and not by the model. The GGUF
* side is an mmap that is read once, sequentially, and advised away behind us.
* The acceptance number the plan asks for — peak RSS under 8 GB for a full
* model — is not a close call under this shape; it is measured anyway, because
* "it should be small" is not a measurement.
*
* ---------------------------------------------------------------------------
* THE ARTIFACT
* ---------------------------------------------------------------------------
* Little-endian throughout, which both the Mac and the card host are, and which the
* card is.
*
* magic 'FTTW' | version | tensor_count | flags
* directory_offset | data_offset (uint64 each)
* ... tensor data, each tensor 64-byte aligned ...
* ... directory: tensor_count fixed-size entries ...
*
* The directory is written LAST because a tensor's size is not known until it
* has been converted — which is what lets the whole thing stream to a pipe or
* a sequentially written file with no seeking back over the data.
*
* ---------------------------------------------------------------------------
* TWO 2-D LAYOUTS, AND WHY A MATMUL WEIGHT GETS THE SECOND ONE
* ---------------------------------------------------------------------------
* `layout` in each directory entry says which, and it is a FORMAT field, not a
* hint: read the wrong one and every index is in range and every answer is
* wrong.
*
* FT_TT_LAYOUT_SOURCE (0)
* GGUF's own order. ceil(rows/32) x ceil(cols/32) tiles in ROW-major tile
* order, `rows` = dims[1], `cols` = dims[0] (the fastest axis). This is
* what an EMBEDDING table wants, because it is indexed BY ROW: token id
* picks a row, and transposing it would turn a lookup into a gather.
*
* FT_TT_LAYOUT_MATMUL_NMAJOR (1)
* A matmul weight. TRANSPOSED to K x N = [in_features x out_features] —
* the orientation Phase 4 decided (foundry/tt/ft_tt_op_matmul.h) — and its
* tile grid stored COLUMN-major, tile index nt*Kt + kt.
*
* The transpose has to happen here and nowhere else: a Bfp8_b/Bfp4_b tile
* shares one exponent across 16 elements ALONG A ROW, so transposing a PACKED
* tile means unpacking and repacking it. Upstream of the packer it is free.
*
* The column-major tile grid is the second half of the same change and it is a
* measured one. The matmul reader fetches a block of `depth` K-tiles per NoC
* barrier; in row-major tile order those are strided by Nt, so each is its own
* transaction, and transaction COUNT is what the reader is bound by — the same
* observation was worth 1.28x on the A side alone (docs/tt_bringup_log.md
* §16.2). Column-major makes a block's B tiles consecutive, so the whole block
* is one read.
*
* **It costs nothing to produce.** A conversion strip is 32 SOURCE rows, i.e.
* one out-feature tile column, and the Kt target tiles it yields are exactly
* the ones that are consecutive in N-major order. The streaming order the
* converter already had IS the new order; the only added work is a 32x32
* transpose per tile.
*
* Edge tiles are zero-padded, in whichever axis the padding lands after the
* transpose.
*
* **1-D tensors are stored row-major and untiled, and that is a decision, not
* an omission.** Blackhole broadcasts a norm weight in hardware, so it never
* needs 32x replication in DRAM — but ROW broadcast wants a tile with a filled
* 0-row and COL wants one with a filled 0-column, and which of the two RMSNorm
* needs follows from the matmul orientation, which is Phase 4's to decide. The
* untiled vector is the only form BOTH are derivable from, so the artifact
* stores it and the loader or the reader kernel shapes it. 16 fp32 = 64 B =
* one face row = the artifact's alignment, so that shaping is an aligned copy.
* `docs/tt_bringup_log.md` §13.2.
*
* @status Converts MiniCPM5-1B end to end at every ladder rung.
* @issues None
* @todo None
*/
#ifndef FT_TT_WEIGHT_CONVERT_H
#define FT_TT_WEIGHT_CONVERT_H
#include <stdint.h>
#include <stdio.h>
#include "ft_tt_precision_ledger.h"
/** 'FTTW' little-endian. */
#define FT_TT_ARTIFACT_MAGIC 0x57545446u
/**
* Bumped whenever the on-disk meaning changes.
*
* 1 every 2-D tensor in GGUF order, row-major tile grid, `reserved` zero
* 2 `reserved` became `layout`, and a matmul weight is transposed to
* [in x out] with a column-major tile grid
*
* The bump is the point: a v2 artifact fed to a v1 consumer would be read with
* the wrong orientation and the wrong tile stride, both in range and both
* silent. A hard version refusal is the only thing that turns that into an
* error. Readers here accept both and branch on `layout`.
*/
#define FT_TT_ARTIFACT_VERSION 2u
/** The oldest artifact version a reader in this tree still understands. */
#define FT_TT_ARTIFACT_VERSION_MIN 1u
/** How a 2-D tensor's tiles are arranged. See the header note. */
typedef enum {
FT_TT_LAYOUT_SOURCE = 0, /**< GGUF [out x in], row-major tile grid */
FT_TT_LAYOUT_MATMUL_NMAJOR = 1 /**< [in x out], column-major tile grid */
} ft_tt_tensor_layout;
/**
* Header `flags` bits. A reader that meets a bit it does not know must refuse
* the file rather than ignore it: every flag here changes what the BYTES MEAN.
*/
/** `attn_q`/`attn_k` output rows are permuted from INTERLEAVED to SPLIT-HALF. */
#define FT_TT_ARTIFACT_FLAG_ROPE_SPLIT_HALF 0x00000001u
/**
* The rotary PAIR INDEX is reflected: the slot a consumer calls pair `p` holds
* the model's pair `half - 1 - p`.
*
* **ORTHOGONAL to the bit above, and deliberately so.** The two bits answer two
* independent questions and the four combinations are all meaningful:
*
* | flags | WHERE a pair's partner is | WHICH pair a slot holds |
* |---|---|---|
* | `0x0` | adjacent (GGUF interleaved) | `p` |
* | `0x1` | `+half` (split-half) | `p` |
* | `0x2` | adjacent (GGUF interleaved) | `half - 1 - p` |
* | `0x3` | `+half` (split-half) | `half - 1 - p` |
*
* The consumer owes exactly one thing for this bit: apply frequency
* `half - 1 - p` at slot `p`. Do that and the attention score is EXACTLY the
* unreflected one — the sum over pairs is the same set of terms in a different
* order — and fail to and every rotary feature gets the wrong frequency, which
* is why this is a header bit and not a naming convention.
*/
#define FT_TT_ARTIFACT_FLAG_ROPE_PAIRS_REVERSED 0x00000002u
/** Every flag bit this build understands. Anything else is from the future. */
#define FT_TT_ARTIFACT_FLAGS_KNOWN \
(FT_TT_ARTIFACT_FLAG_ROPE_SPLIT_HALF | FT_TT_ARTIFACT_FLAG_ROPE_PAIRS_REVERSED)
/**
* Which rotary pairing `attn_q`/`attn_k` are stored for.
*
* ---------------------------------------------------------------------------
* WHY A WEIGHT PERMUTATION IS A ROPE DECISION
* ---------------------------------------------------------------------------
* A head's rotary pairing is a property of the CHECKPOINT, not of the kernel.
* HF publishes Llama-family q/k projections in SPLIT-HALF order — feature `j`
* pairs with `j + rot/2` — and llama.cpp's HF->GGUF conversion PERMUTES the
* output rows into INTERLEAVED order, pairing `(2i, 2i+1)`, because that is
* what its own `rope_norm` wants. So a GGUF `llama` checkpoint is a permuted
* checkpoint, and that permutation is the only reason our on-card RoPE kernel
* cannot serve it: `ft_tt_op_rope.h` refuses interleaved pairing because an
* interleaved partner is an adjacent column INSIDE a face, where a split-half
* partner (with `rot/2` a multiple of 32) is the SAME position in a different
* TILE — a permutation of tiles and the identity inside one.
*
* Running llama.cpp's permutation backwards in the converter therefore turns a
* kernel capability gap into a build-time row shuffle.
*
* **IT CHANGES NO MATH, AND THAT IS PROVABLE RATHER THAN ASSERTED.** Attention
* consumes q and k only through `q . k`, which is invariant under a permutation
* applied to BOTH — and the same one is, because it is a function of the head
* geometry, not of the tensor. `v` is not permuted and `attn_output` is not
* permuted, so nothing downstream of the score ever sees a reordered feature
* axis.
*
* What DOES move, and it is worth stating rather than discovering: a
* block-float format shares one exponent across 16 elements of a stored tile
* ROW, which after the N-major transpose is 16 consecutive OUTPUT features.
* Permuting the output rows regroups those 16, so a Bfp8_b artifact is not
* bit-identical to its unpermuted twin — it is a different, equally valid
* quantization of the same weights. At the bf16 and fp32 rungs there are no
* blocks and the permutation is EXACTLY inert, which is the rung the unit test
* pins it at.
*
* ---------------------------------------------------------------------------
* THE CONSUMER HAS TO BE TOLD, AND THE ARTIFACT IS WHERE
* ---------------------------------------------------------------------------
* A permuted `attn_q` fed to an interleaved RoPE is silently wrong in exactly
* the way this converter's `layout` field exists to prevent, so the choice is
* recorded in the header as FT_TT_ARTIFACT_FLAG_ROPE_SPLIT_HALF and the host
* forward selects its rotary form from the FLAG, not from the architecture.
*/
/**
* ---------------------------------------------------------------------------
* AND WHY THERE ARE TWO MORE THAT NO CHECKPOINT ASKS FOR
* ---------------------------------------------------------------------------
* The last two values are INSTRUMENTS, not checkpoint formats, and they exist to
* answer one question with a control rather than an argument.
*
* `--rope-pairing split-half` took the chat-parity gate's worst document from
* **1.227205 to 1.032823** with nothing else moving — the artifacts are the same
* size, the same tensors, the same ledger, and the only difference is which 16
* output features share a block-float exponent. Two artifacts is a sample of
* two, so `SPLIT_HALF_REVERSED` is a THIRD draw over the SAME two tensors: the
* pair index is reflected, so the exponent blocks regroup again while the
* attention score stays exact (`q . k` sums one term per pair, and reflecting
* the labelling permutes the summands without changing the set).
*
* `INTERLEAVED_REVERSED` is the crossed control that separates the two things
* split-half changed at once. Split-half did two things: it moved a pair's
* partner from ADJACENT to `+half`, and it therefore stopped a pair's two
* components sharing an exponent block. This value reflects the pair index while
* KEEPING the partner adjacent — a different draw of the same shape as source.
* So the four values isolate the variable:
*
* | value | partner | exponent block holds |
* |---|---|---|
* | `SOURCE` | adjacent | both components of 8 pairs |
* | `SPLIT_HALF` | `+half` | one component of 16 pairs |
* | `SPLIT_HALF_REVERSED` | `+half` | one component of 16 other pairs |
* | `INTERLEAVED_REVERSED` | adjacent | both components of 8 other pairs |
*
* Neither instrument is loadable by the on-card RoPE kernel: its table is built
* with the frequency in slot order, and it refuses an adjacent partner outright.
* The host forward refuses both by name rather than serving them wrong.
*/
typedef enum {
FT_TT_ROPE_PAIRING_SOURCE = 0, /**< store `attn_q`/`attn_k` exactly as GGUF has them */
FT_TT_ROPE_PAIRING_SPLIT_HALF = 1, /**< undo llama.cpp's permutation; NeoX/split-half order */
FT_TT_ROPE_PAIRING_SPLIT_HALF_REVERSED = 2, /**< split-half, pair index reflected. Instrument. */
FT_TT_ROPE_PAIRING_INTERLEAVED_REVERSED = 3 /**< GGUF order, pair index reflected. Instrument. */
} ft_tt_rope_pairing;
/** Tensor payloads start on this boundary; the NoC's DRAM alignment is 64 B. */
#define FT_TT_ARTIFACT_ALIGNMENT 64u
/** Name field width in a directory entry. Matches ft_gguf_map's limit. */
#define FT_TT_ARTIFACT_NAME_BYTES 128
/** On-disk artifact header. 40 bytes, no padding by construction. */
typedef struct {
uint32_t magic;
uint32_t version;
uint32_t tensor_count;
uint32_t flags;
uint64_t directory_offset;
uint64_t data_offset;
uint64_t total_bytes;
} ft_tt_artifact_header;
/** On-disk directory entry. Fixed size, so the directory is indexable. */
typedef struct {
char name[FT_TT_ARTIFACT_NAME_BYTES];
uint32_t format; /**< ft_tt_tensor_format */
uint32_t role; /**< ft_tt_tensor_role, for diagnostics */
uint32_t n_dims;
/** The STORED tile grid, not the logical one. For FT_TT_LAYOUT_MATMUL_NMAJOR that
* means tile_rows = Kt = ceil(in/32) and tile_cols = Nt = ceil(out/32), i.e. the
* transpose of what `dims` says. `dims` stays logical and in GGML order. */
uint32_t tile_rows; /**< 0 when the tensor is stored untiled */
uint32_t tile_cols;
uint32_t layout; /**< ft_tt_tensor_layout; always 0 in a v1 artifact */
uint64_t dims[4]; /**< logical, GGML order */
uint64_t data_offset;
uint64_t byte_count;
} ft_tt_artifact_entry;
/** The two on-disk structures are a format, so their sizes are pinned. */
#define FT_TT_ARTIFACT_HEADER_BYTES 40u
#define FT_TT_ARTIFACT_ENTRY_BYTES 200u
/** Per-tensor result, reported as the conversion runs. */
typedef struct {
char name[FT_TT_ARTIFACT_NAME_BYTES];
ft_tt_tensor_role role;
ft_tt_tensor_format format;
uint64_t rows;
uint64_t cols;
uint64_t source_bytes;
uint64_t converted_bytes;
double pcc; /**< against the fp32 source; 1.0 when the format is fp32 */
double max_abs_error;
double rms_error;
/**
* rms_error divided by the RMS of the SOURCE, i.e. the error as a fraction
* of the signal. This, not `rms_error`, is the number that compares across
* tensor types: a norm weight and an ffn_down live at different scales, so
* their absolute RMS errors are not on the same axis and ranking them by it
* ranks them by magnitude. 0.0 for an all-zero tensor.
*/
double relative_rms_error;
/**
* Relative RMS of the SOURCE against the reference, when
* ft_tt_convert_measure_against() supplied one. Zero otherwise.
*
* This is the half of a double transcode that the on-card format is not
* responsible for: converting a Q8_0 checkpoint measures
* `relative_rms_error` against the bf16 the model was released as, and this
* field says how much of that was already spent before the packer ran.
*/
double source_relative_rms_error;
} ft_tt_convert_tensor_report;
/** Called once per tensor, in directory order. Return non-zero to abort. */
typedef int (*ft_tt_convert_progress_fn)(const ft_tt_convert_tensor_report *report, void *user);
/** Options for one conversion run. */
typedef struct {
ft_tt_ledger_mode mode;
int measure_pcc; /**< round-trip every tensor and report PCC */
ft_tt_convert_progress_fn progress;
void *progress_user;
ft_tt_rope_pairing rope_pairing; /**< default 0 = FT_TT_ROPE_PAIRING_SOURCE */
/**
* A MEASUREMENT INSTRUMENT, and deliberately not a ledger rung.
*
* Every 2-D tensor whose name CONTAINS this substring is encoded at
* `format_override` instead of the format the ledger chose. NULL disables
* it, and 1-D tensors (norms, biases) are never touched — a shared exponent
* over a normalization scale is not what this measures.
*
* It exists to answer a question the ledger cannot express: what share of
* the artifact's total squared error does ONE tensor family carry? Degrade
* that family alone, read the residual-divergence plateau, and the share
* inverts directly, because divergence is linear in injected error. Naming a
* SUBSTRING rather than a role is the whole point — `attn_output` and
* `attn_q` are one role, and the question is about one of them.
*
* It is not a rung because an artifact built this way must never be mistaken
* for a shipping one: it has no name on the ladder, the converter announces
* every tensor it moves, and `ft_tt_fttw_list` reads the real formats back
* out of the written file.
*/
const char *format_override_name_contains;
ft_tt_tensor_format format_override; /**< read only when the substring is set */
} ft_tt_convert_options;
/** Status codes. Negative is failure. */
enum {
FT_TT_CONVERT_OK = 0,
FT_TT_CONVERT_ERROR = -1,
FT_TT_CONVERT_ENOMEM = -2,
FT_TT_CONVERT_EINVAL = -3,
FT_TT_CONVERT_EIO = -4,
FT_TT_CONVERT_EUNSUPPORTED = -8, /**< a tensor type this path cannot read */
FT_TT_CONVERT_EUNCLASSIFIED = -9, /**< a 2-D tensor whose role no rule recognises */
FT_TT_CONVERT_EROPEGEOMETRY = -10 /**< rope pairing asked for, head geometry does not allow it */
};
/**
* Which layout a tensor is stored in, from its role and rank — or a REFUSAL.
*
* Only a MATMUL WEIGHT is transposed. The distinction that matters is
* `token_embd`, which is 2-D and looks exactly like a weight: it is indexed BY
* ROW by a token id, so transposing it would turn every embedding lookup into
* a strided gather across the whole tensor. `output`/`lm_head` IS a matmul
* weight and is transposed, even though it shares the embedding's shape.
*
* **AN UNRECOGNISED 2-D TENSOR HAS NO ANSWER AND THIS FUNCTION SAYS SO.** It
* used to return FT_TT_LAYOUT_SOURCE, on the reasoning that a transpose we have
* not reasoned about is the change that would be silently wrong. That reasoning
* was half right and the half it missed is the dangerous one: NOT transposing a
* tensor the kernels read N-major is equally silent and equally wrong, and it
* is what happened to `ssm_out` — a real 6144->5120 matmul weight — the first
* time this classifier met a GDN model. There is no safe default because BOTH
* defaults are a guess about a kernel's expectations. So the guess is refused
* and the caller has to add a rule.
*
* @param role from ft_tt_ledger_role()
* @param n_dims tensor rank, as GGUF declares it
* @param out receives FT_TT_LAYOUT_SOURCE or FT_TT_LAYOUT_MATMUL_NMAJOR;
* untouched when the role has no layout
* @return FT_TT_CONVERT_OK, or FT_TT_CONVERT_EUNCLASSIFIED for a 2-D
* tensor of unknown role
*
* Thread-safe: yes Reentrant: yes Signal-safe: yes
*/
int ft_tt_convert_layout_for_checked(ft_tt_tensor_role role, uint32_t n_dims, ft_tt_tensor_layout *out);
/**
* Convert a GGUF checkpoint to a Blackhole weight artifact.
*
* @param gguf_path input checkpoint
* @param output_path artifact to create, truncating any existing file
* @param options may be NULL for defaults (production ledger, no PCC)
* @return FT_TT_CONVERT_OK, or a negative status
*
* Thread-safe: yes (no shared state)
* Reentrant: yes
* Signal-safe: no
*/
int ft_tt_convert_gguf(const char *gguf_path, const char *output_path, const ft_tt_convert_options *options);
/**
* Measure only: walk every tensor and report the PCC each candidate format
* would give, writing nothing. This is the Phase 3 quality gate.
*
* @param gguf_path input checkpoint
* @param format the candidate format to score every 2-D tensor at
* @param progress called once per tensor
* @param user passed through to `progress`
* @return FT_TT_CONVERT_OK, or a negative status
*/
int ft_tt_convert_measure(const char *gguf_path, ft_tt_tensor_format format, ft_tt_convert_progress_fn progress,
void *user);
/**
* Measure, but score the round trip against a SECOND checkpoint rather than
* against the input's own values.
*
* The question this answers is the one a lossy source raises and a self-scoring
* gate cannot: converting a Q8_0 checkpoint and comparing the result to the
* Q8_0 values reports the block-float error only, and quietly treats the Q8_0
* quantization as if it were the truth. Point `reference_gguf_path` at the
* BF16 release the model was published as and the report is against the actual
* weights, with `source_relative_rms_error` splitting out how much of it the
* source had already spent.
*
* The two checkpoints must agree on every tensor's name and shape; a tensor
* missing from the reference, or a shape mismatch, is an error rather than a
* skip — silently scoring 90% of a model is how a regression hides.
*
* @param gguf_path the checkpoint being converted
* @param reference_gguf_path the truth to score against; NULL means self-score
* @param format the candidate format for every 2-D tensor
* @return FT_TT_CONVERT_OK, or a negative status
*/
int ft_tt_convert_measure_against(const char *gguf_path, const char *reference_gguf_path,
ft_tt_tensor_format format, ft_tt_convert_progress_fn progress, void *user);
#endif /* FT_TT_WEIGHT_CONVERT_H */