ninfer 三元制品:容器格式与几何
本技能是格式层的权威速查:文件怎么排、平面在哪、置换怎么算。改打包器或写验收脚本 前先读这里,不要凭直觉推偏移。
源码位置:src\pack.py、src\tools\artifact\、src\MAPPING.json。
(dist\ 是 src\ 的运行副本,由 install.cmd 生成,改代码只改 src\。)
1. .ninfer 容器(tools/artifact/container.py)
offset 0 : MAGIC = b"NINFER\x00\x02" 8 字节
offset 8 : json_bytes (uint64 LE) 8 字节
offset 16 : JSON 目录,json_bytes 字节,零空白紧凑 UTF-8
payload_offset = align_up(16 + json_bytes, 4096)
payload_offset + obj.offset : 对象负载字节
关键点:目录里的 offset 是 payload 相对的,不是文件相对的。Artifact.payload() 内部
才加上 payload_offset。
对齐常数:PREFIX_BYTES = 16、PAYLOAD_ALIGNMENT = 4096、
PLANE_ALIGNMENT = 256、K_ALIGNMENT = 128。四种 layout 的对象对齐都是 256;
resource(raw-bytes-v1)对齐是 1。
目录 JSON 的成员集合是精确相等,多一个少一个都拒绝
| 位置 | 必须恰好是 |
|---|---|
| 根 | identity, objects |
| identity | model_id, weights_id(都非空) |
| tensor 条目 | name, kind, shape, format, layout, offset, bytes |
| resource 条目 | name, kind, encoding, offset, bytes |
kind只能是"tensor"或"resource";resource 的encoding只能是raw-bytes-v1。bytes必须恰好等于encoded_size(layout, format, shape),否则报tensor <name> stores N bytes; layout requires M。_validate_ranges()按顺序逐个强制:offset >= cursor(不许重叠/乱序)、offset % alignment == 0、offset + bytes <= payload_bytes。plan_objects()在计划阶段就拒绝重名:duplicate object name: <name>。
2. 三元数字格式(tools/artifact/numeric.py)
PQ2_0_G128 = TernaryFormat("PQ2_0_G128", 128, 32, 0) # (name, group_size, base_bytes, high_bytes)
PTQ1_0_G128 = TernaryFormat("PTQ1_0_G128", 128, 24, 2)
⚠️ 这两个注册是本地重建的,不在上游 ninfer 里。 发布包只发了
pack.py(它会用到这两个 名字)和 artifact 层的 C++ 实现,没发 Python 侧的注册。所以在干净的 ninfer 树上get_format("PQ2_0_G128")会抛unknown numeric format。 必须用ninfer-ternary-bonsai-ada那份tools\artifact,不能用ninfer-4090-windows那棵树的numeric.py——它的副本会在row_split_geometry处抛ValueError: unknown numeric format: 'PQ2_0_G128'。
为什么 QuantFormat 表达不了:row_split_geometry 对 bits != 8 一律推
base_bytes_per_group = group_size // 2 = 64,而 PQ2_0 实际每组 32 字节 base、
PTQ1_0 每组 24 字节 base + 2 字节 high。所以走独立分支。
NUMERIC_FORMATS 共 12 项(3 direct + 4 quant + 1 nvfp4 + 1 fp8-row + 2 ternary)。
3. row-split 平面几何
row_split_geometry(format, shape) -> RowSplitGeometry # 16 个字段
shape 必须 rank 2 且维度为正整数。推导:
k_pad = align_up(k, 128)
groups_per_row = k_pad // 128
base_row_bytes = groups_per_row * base_bytes_per_group
high_row_bytes = groups_per_row * high_bytes_per_group
scale_row_bytes = groups_per_row * 2 # 每组一个 binary16,恒定
base_offset = 0
high_offset = align_up(base_bytes, 256)
scale_offset = high_offset + align_up(high_bytes, 256)
payload_bytes = scale_offset + scale_bytes
RowSplitGeometry 字段顺序:
n, k, k_pad, groups_per_row, base_bytes_per_group, high_bytes_per_group, base_row_bytes, high_row_bytes, scale_row_bytes, base_offset, base_bytes, high_offset, high_bytes, scale_offset, scale_bytes, payload_bytes
high_offset 即使 high_bytes == 0 也会算,所以 PQ2_0 仍有一个(空的)high 平面位置,
scale_offset == high_offset。
每格式的 scale 平面位置 —— 这是最常被硬编码搞错的地方
| | 每组 base | 每组 high | 每组 scale | 块大小 | scale 起点 |
|---|---|---|---|---|---|
| PQ2_0_G128 | 32 | 0 | 2 | 34 | rows * groups * 32 |
| PTQ1_0_G128 | 24 | 2 | 2 | 28 | rows * groups * 26 |
实例子尺寸(与 docs/03-三元模型转-NInfer.md §1.1 记录的一致):
| shape | 格式 | payload_bytes |
|---|---|---|
| (248320, 5120) 词表 | PQ2_0 | 337,715,200 |
| (248320, 5120) 词表 | PTQ1_0 | 278,118,400 |
| (12288, 5120) value_z | PQ2_0 / PTQ1_0 | n*1360 / n*1088 |
| (7168, 5120) query_key | PQ2_0 / PTQ1_0 | n*1360 / n*1088 |
PQ2_0 是 34 B / 128 权重 = 2.125 bit/权重。
平面顺序 ≠ 块内字节顺序
这是最容易反直觉的一点:artifact 侧的平面顺序和 ggml 侧的块内顺序不是一回事。
PQ2_0 34 字节块 : [ scale 2 ][ base/qs 32 ] scale 在【前】
PTQ1_0 28 字节块 : [ qs 24 ][ qh 2 ][ scale 2 ] scale 在【后】
推论:验收工具绝不能硬编码 scale 偏移。 必须从 row_split_geometry() 取。
把 PTQ1_0 的 scale 当成在 rows*groups*base_bytes(即落在 high 平面里)读,实测在一份
健康产物上给出 neg=118596 nan=8552 median=1.3496——数字看着像那么回事,其实读的是
错地方。正确的 PTQ1_0 偏移读出来是 neg=0 nan=0 median=0.0168。
PQ2_0 上 groups_per_row = 40(k=5120),块流是 [scale][base]... 逐组交替;
PTQ1_0 上同样是 40 组,块流是 [base][high][scale]...。k=6144 时 groups_per_row = 48。
4. 块 ↔ 平面 互转(pack.py 补丁 3 引入的唯一命名处)
GGML_GROUP = 128
GGML_BLOCK = {"PTQ1_0_G128": 28, "PQ2_0_G128": 34}
BLOCK_PLANE_SLICES = {
"PTQ1_0_G128": ((0, 24), (24, 26), (26, 28)), # base, high, scale
"PQ2_0_G128": ((2, 34), (None, None), (0, 2)), # base, 无 high, scale
}
GGML_GROUP= 128,每组权重数。消费者用它算gpr = cols // 128。GGML_BLOCK= 含 scale 的单组块字节数。Gguf.blocks()独立地推28 if tt == T_PTQ1_0 else 34,_check_geometry_constants.py断言两者一致。BLOCK_PLANE_SLICES= 每格式三个(start, stop)字节区间,顺序是平面顺序(base, high, scale),不是字节顺序——PQ2_0 的 scale 区间是(0,2)但排在第三位。None表示该平面不存在。
verify\_check_geometry_constants.py 就是对着这三处做断言:切片能复现 block_to_planes、
长度之和等于块大小、planes_to_block 是精确逆、以及 k ∈ {5120, 6144, 17408} 时
base_bytes_per_group/high_bytes_per_group 与切片长度一致。它没有 main(),
断言在 import 时执行——想手动跑就直接 python 这个文件,别从别的模块 import 它。
5. 行置换:perm48 / perm_row
def perm48(i):
# 48 条 HEAD 轴上 tiled (3,16) -> grouped (16,3)
# 目标 head g*3+t 来自源 head t*16+g
return (i % 3) * 16 + (i // 3)
def perm_row(i):
head, inner = divmod(i, 128) # V_HEAD_DIM = 128
return perm48(head) * 128 + inner
value_z 的行轴是 48 head × 128 行,所以置换必须在 head 粒度上做:
src_row = perm48(row // 128) * 128 + row % 128
🚨 把
perm48直接作用在 6144 行索引上不是双射:perm48(1) == perm48(48) == 16。 详见ninfer-ternary-traps技能,这是整个验收链存在的理由。
attn_q 交错
attn_q.weight 是 N=12288,视为 48 个 256 行的块:偶数块 0,2,…,46 是 24 个 query head
(顺序不变),奇数块 1,3,…,47 是 24 个 output-gate head。取法:
ent = [(name, (2*h + parity)*256 + r) for h in range(heads) for r in range(256)]
# parity=0 -> query, parity=1 -> gate
pack.py 的 deinterleave() 会强制 n % 512 == 0,否则
f"{name}: {n} rows is not a multiple of 512"。
6. 常量
| 名字 | 值 |
|---|---|
| HIDDEN | 5120 |
| V_HEADS | 48 |
| V_HEAD_DIM | 128 |
| QK_ROWS | 4096 |
| V_ROWS | 6144 |
| SIGN_WIDTHS | [5120, 6144, 17408],和 28672 |
| T_PQ2_0 / T_PTQ1_0 | 142 / 143 |
| block_size | 1024 |
| sign_mode | explicit |
| transform | normalized-sylvester-walsh-hadamard |
| prism.hadamard.gdn_v_grouped | 1 |
7. 三元编解码器在 pack.py,不在 tools/artifact
tools/artifact 里没有三元编解码器。只有 encode_row_split / assemble_row_planes /
decode_row_split_codes / dequantize_row_split 这四个,它们都显式拒绝 TernaryFormat:
ValueError: row-split encoding requires a grouped quantized format
真正的编解码在 pack.py(numpy):
| 函数 | 作用 |
|---|---|
| dq_pq2_0(raw, chunk_groups=1<<18) | 34 字节 PQ2_0 组 → ndarray(fork 补丁 1:float32 + 分块 + 预分配) |
| dq_ptq1_0(raw, chunk_groups=1<<18) | 28 字节 PTQ1_0 组 → ndarray(fork 基线已修) |
| assemble_ternary(fmt, shape, row_fn) | 逐行 ggml 块 → row-split payload 平面 |
| disassemble_ternary(fmt, shape, payload) | 精确逆:row-split 平面 → 连续 ggml 块流 |
assemble_ternary / disassemble_ternary 互为精确逆,这就是无损性的证明。
verify/_ternary_ref.py 的 Gguf.raw() 硬编码 PQ2_0_BLOCK_BYTES = 34,所以它作为
reader 是格式通用的,但 raw() 是 PQ2_0 专属。PTQ1_0 必须走
ternary_rows.gguf_row_keys()(用 pack.BLOCK_PLANE_SLICES / GGML_BLOCK)。
相关技能
ninfer-ternary-pack— 打包流程、模板要求、fork 补丁ninfer-ternary-accept— 七道验收门怎么用ninfer-ternary-traps— 这些几何背后的失效模式
Scan to join WeChat group