Appearance
Part 1: 构建与加载
从 Makefile 到 GGUF 二进制格式,理解项目如何编译、模型如何加载到内存。
1. 项目全貌与编译系统
看不懂代码就先跑起来。今天从编译系统入手,理解项目怎么从 .c 变成可执行文件,再俯瞰整体架构——后续 14 天的所有代码走读都建立在这个全景之上。
C 知识点
1. Makefile 基础
Makefile 是一个构建脚本,告诉 make 工具如何把源文件编译成可执行文件。
核心语法:
makefile
target: prerequisites
recipe # 注意:必须用 Tab 缩进,不能用空格ds4 的 Makefile 展示了几个重要概念:
变量赋值(?= 和 :=)
makefile
CC ?= cc # ?= 表示如果未定义才赋值(用户可覆盖)
CFLAGS ?= -O3 -ffast-math -mcpu=native -Wall -Wextra -std=c99
UNAME_S := $(shell uname -s) # := 立即执行 shell 命令?=(条件赋值):只有变量未定义时才设置,用户可以在命令行覆盖:make CC=clang:=(立即展开):定义时立即求值,不是使用时才求值=(延迟展开):使用时才求值(本项目没用到)
条件编译(平台检测)
makefile
ifeq ($(UNAME_S),Darwin)
# macOS:链接 Metal 框架
METAL_LDLIBS := $(LDLIBS) -framework Foundation -framework Metal
CORE_OBJS = ds4.o ds4_metal.o
else
# Linux:禁用 Metal,纯 CPU
CFLAGS += -DDS4_NO_METAL
CORE_OBJS = ds4.o
endif伪目标(.PHONY)
makefile
.PHONY: all clean test声明这些目标不对应实际文件,即使存在同名文件也要执行。
自动变量
makefile
ds4.o: ds4.c ds4.h ds4_metal.h
$(CC) $(CFLAGS) -c -o $@ ds4.c$@= 当前目标名(这里是ds4.o)$^= 所有依赖(这里是ds4_cli.o linenoise.o ds4.o ds4_metal.o)$<= 第一个依赖
2. 编译流程
从 .c 文件到可执行文件分四步:
在 Makefile 中,这些步骤被拆成两条规则:
编译(.c → .o)
makefile
ds4.o: ds4.c ds4.h ds4_metal.h
$(CC) $(CFLAGS) -c -o $@ ds4.c-c 标志告诉编译器只编译不链接,生成目标文件(.o)。
链接(.o → 可执行文件)
makefile
ds4: ds4_cli.o linenoise.o $(CORE_OBJS)
$(CC) $(CFLAGS) -o $@ ds4_cli.o linenoise.o $(CORE_OBJS) $(METAL_LDLIBS)把多个 .o 文件和库合并成最终的可执行文件。
编译选项解读:
| 标志 | 含义 |
|---|---|
-O3 | 最高级别优化 |
-ffast-math | 放宽浮点精度要求以换取速度(不严格遵守 IEEE 754) |
-mcpu=native | 针对当前 CPU 生成最优指令 |
-Wall -Wextra | 开启所有常见警告 |
-std=c99 | 使用 C99 标准 |
-lm | 链接数学库(sin, cos, sqrt 等) |
-pthread | 链接 POSIX 线程库 |
-framework Metal | 链接 Apple Metal GPU 框架(仅 macOS) |
3. 条件编译(#ifdef)
在 C 代码中,用预处理指令控制哪些代码参与编译:
c
#ifndef DS4_NO_METAL
#include "ds4_metal.h"
#endif
#if defined(__ARM_NEON)
#include <arm_neon.h>
#endif#ifndef= "if not defined":如果没有定义DS4_NO_METAL宏,就包含 Metal 头文件- Makefile 中通过
-DDS4_NO_METAL定义这个宏(-D= define) - 这样同一份代码可以在有/无 GPU 的平台上编译
4. 头文件包含约定
c
#include <stdio.h> // 尖括号:系统头文件,从系统路径搜索
#include "ds4.h" // 引号:项目头文件,先从当前目录搜索ds4.c 包含了 18 个系统头文件和 2 个项目头文件,覆盖了:
- 文件操作:
<fcntl.h>,<unistd.h>,<sys/stat.h> - 内存映射:
<sys/mman.h> - 线程:
<pthread.h> - 类型定义:
<stdint.h>,<stdbool.h>,<stddef.h>
LLM 知识点
1. 推理引擎是什么
大语言模型(LLM)的训练和推理是两个完全不同的过程:
- 训练:用海量数据更新模型参数,需要巨大的 GPU 集群,花费数周甚至数月
- 推理:用训练好的模型生成文本,单台机器就能运行
推理引擎就是专门做推理的软件。它的工作是:
- 从磁盘加载模型文件(包含所有训练好的参数)
- 接收用户输入(文本),转换成数字序列(token IDs)
- 用矩阵运算计算"下一个最可能的词"
- 重复步骤 3,直到生成完整回答
2. ds4.c 的定位
ds4.c 是一个专用推理引擎,只为 DeepSeek V4 模型家族服务(Flash 和 PRO 两个变体)。
它的设计哲学是"垂直整合"(deliberately vertical):
- 不依赖外部框架:没有 PyTorch、没有 TensorFlow,JSON 解析、HTTP 服务器、分词器全部手写
- 固定模型架构:所有模型参数(层数、维度、专家数等)都硬编码为常量,不做通用 GGUF 加载器
- Metal 优先:专门为 Apple Silicon GPU 优化,CPU 路径仅用于正确性验证
- 模块化扩展:新增
ds4_agent.c(自主编码代理)、ds4_web.c(浏览器工具)、ds4_kvstore.c(模块化 KV 存储)三大模块
3. 模型参数概览
从 ds4.c 的常量定义中,可以看到 DeepSeek V4 Flash 的架构(详细模型信息参见 MODEL_CARD.md):
参数之间的数学关系:
注意力头的维度:64 头 × 512 维/头 = 32,768(多头拼接后的总维度)。嵌入维度 4,096 是输入/输出的隐藏维度,通过投影矩阵连接——QKV 投影将 4,096 映射到 32,768,输出投影再从 32,768 映射回 4,096。
嵌入层参数量:129,280(词表)× 4,096(嵌入维度)≈ 530M 参数。每个 token 对应一个 4,096 维的向量。
MoE 激活比例:6 / 256 ≈ 2.3%。每次推理只激活 256 个路由专家中的 6 个(加 1 个共享专家 = 7 个),284B 总参数中实际参与计算的只有一小部分。
284B 总参数的大致构成:嵌入矩阵 ~530M + 43 层 ×(每层注意力参数 + MoE 专家参数)。MoE 专家是参数量的主要来源:256 个专家 × 每个专家的 FFN 权重。
上下文窗口与 KV 缓存:1,000,000 tokens × 层数 × 头维度,决定了推理时的显存需求。
关键洞察:虽然模型有 284B 参数,但每次推理只激活约 6/256 的 MoE 专家参数,所以实际计算量远小于同等规模的密集模型。
MODEL_CARD.md 包含从 DeepSeek 官方模型卡提取的关键信息,包括 DeepSeek-V4 家族两个模型的对比:
| 模型 | 总参数 | 激活参数 | 上下文长度 |
|---|---|---|---|
| DeepSeek-V4-Flash | 284B | 13B | 1M tokens |
| DeepSeek-V4-Pro | 1.6T | 49B | 1M tokens |
架构上使用压缩稀疏注意力(CSA)+ 重度压缩注意力(HCA):前两层只用原始滑动窗口(128 token),之后偶数层按 ratio-4 压缩(带 indexer 选择最多 512 行),奇数层按 ratio-128 压缩。这是百万 token 上下文不需要全量 KV cache 的关键。
4. GGUF 格式简介
GGUF(GPT-Generated Unified Format)是 llama.cpp 生态使用的模型文件格式:
- 二进制格式,紧凑高效
- 包含模型参数(张量/tensor)和元数据(模型名称、架构参数等)
- 支持多种量化级别(2-bit、4-bit 等),用更少的位存储参数以节省内存
ds4.c 支持:
- Q2(2-bit 量化):128GB 内存可运行,模型文件约 81GB
- Q4(4-bit 量化):需要 256GB+ 内存,精度更高
download_model.sh 现在支持 pro 和 pro-imatrix 目标,可下载 DeepSeek V4 PRO(1.6T 总参数)的量化模型。PRO 模型需要 512GB 内存的 Mac Studio。
5. 项目的可执行文件
| 二进制 | 用途 | 对应源文件 |
|---|---|---|
./ds4 | 交互式命令行聊天 | ds4_cli.c + linenoise.c |
./ds4-server | HTTP API 服务器 | ds4_server.c |
./ds4-bench | 吞吐量基准测试 | ds4_bench.c |
./ds4-eval | 能力评估(93 道题回归测试) | ds4_eval.c |
./ds4-agent | 自主编码代理(9607 行) | ds4_agent.c + ds4_web.c |
make 默认编译 ds4、ds4-server、ds4-bench、ds4-eval、ds4-agent。
./ds4-server 提供与 OpenAI/Anthropic 兼容的 API,可以被编程工具(如 Claude Code)直接调用。
6. 发布前 QA 检查清单(QA_BEFORE_RELEASES.md)
make test 只覆盖核心回归;发布前还有一份手动 QA 清单 QA_BEFORE_RELEASES.md(14 节),把每个子系统该验什么列成 checklist:
| 章节 | 验证范围 |
|---|---|
| 1-2 | 仓库与构建完整性、核心回归测试 |
| 3-4 | Metal Flash 路径、Metal PRO 路径 |
| 5 | SSD Streaming |
| 6-7 | CUDA / DGX Spark、ROCm / Strix Halo |
| 8-9 | 分布式推理、磁盘 KV Cache |
| 10-11 | Server APIs、ds4-agent |
| 12-14 | 下载脚本与模型文件、性能与功耗、发布签字 |
这份清单的存在本身就说明:推理引擎的"正确"是跨后端、跨模型变体的——同一份代码要在 Metal Flash、CUDA PRO、ROCm Strix Halo、分布式、SSD streaming 这些组合上都跑对,单元测试覆盖不到的组合靠发布前的人工 checklist 兜底。
2. 内存管理与 mmap
81GB 的模型文件怎么装进内存?答案是——根本不装。今天学习 mmap 零拷贝加载、内存分配的错误处理策略,以及为什么推理引擎必须在初始化阶段完成所有内存分配。
设计决策推导:81GB 模型的内存策略
问题 1:81GB 模型文件,直接 fread 读进内存?
└─ 不行:需要 81GB 连续物理内存,启动慢(磁盘 I/O)
└─ 方案:mmap — 让操作系统按需加载页面,启动瞬间完成(只建映射)
首次访问某页时触发 page fault,OS 从磁盘读取该页
问题 2:mmap 的内存可以被 GPU 直接使用吗?
└─ MAP_SHARED 映射的内存可以被 Metal 包装为"零拷贝 MTLBuffer"
GPU 直接从文件映射读取权重,不需要额外的显存拷贝
→ 这就是为什么 Makefile 按平台分支编译
问题 3:推理过程中需要各种临时缓冲区(KV cache、注意力分数等),
能不能在热循环中 malloc/free?
└─ 绝对不行!malloc 是非确定性操作(可能触发系统调用、锁竞争)
└─ 方案:预分配 scratch buffer — 启动时一次性分配所有工作内存,
推理循环中只做指针操作,零 malloc
→ 这就是为什么没有 xfree
问题 4:calloc 天然初始化为零,为什么用 malloc + memset?
└─ calloc 的"共享零页"优化让所有虚拟页指向同一个物理零页
推理时逐 token 写入触发大量 page fault
macOS 上大 mmap + 大量 page fault 可能导致 kernel panic
└─ 方案:malloc + memset 在启动时集中触发所有 page fault
把内存初始化的代价集中到可控的启动阶段C 知识点
1. malloc/free 与错误处理
C 语言中动态内存分配的基本 API:
c
void *malloc(size_t size); // 分配 size 字节,不初始化
void *calloc(size_t n, size_t size); // 分配 n×size 字节,初始化为零
void *realloc(void *ptr, size_t size); // 调整已分配内存大小
void free(void *ptr); // 释放内存关键规则:分配可能失败(返回 NULL),必须检查。
ds4.c 用包装函数统一处理错误:
c
static void *xmalloc(size_t size) {
void *p = malloc(size);
if (!p) ds4_die("out of memory"); // 失败就直接退出
return p;
}这种模式叫 x-包装器(x-wrapper):x 前缀表示"失败时退出"。好处:
- 每次调用不用重复写 NULL 检查
- 错误处理策略统一
- 代码更简洁
2. mmap — 零拷贝文件映射
mmap(memory map)把文件内容直接映射到进程的虚拟地址空间:
c
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);工作原理:
mmap 的优势:
- 零拷贝:没有内核空间到用户空间的数据拷贝
- 懒加载:只有实际访问某页时才从磁盘读取(page fault)
- 统一接口:文件内容当数组用,
m->map[offset]直接访问
ds4.c 中的 mmap 调用(model_open() 约 1174-1219 行):
c
static void model_open(ds4_model *m, const char *path, bool metal_mapping) {
int fd = open(path, O_RDONLY); // 打开文件
struct stat st;
fstat(fd, &st); // 获取文件大小
// Metal 用 MAP_SHARED(Metal 可直接作为 GPU 缓冲区)
// CPU 用 MAP_PRIVATE(避免 macOS 的 VM 内核 bug)
const int mmap_flags = metal_mapping ? MAP_SHARED : MAP_PRIVATE;
void *map = mmap(NULL, st.st_size, PROT_READ, mmap_flags, fd, 0);
// 之后就可以用 map 指针直接访问文件内容
m->map = map;
m->size = st.st_size;
}MAP_SHARED vs MAP_PRIVATE:
MAP_SHARED
- 多个进程共享同一映射
- 修改对其他进程可见
- Metal GPU 可以直接引用这块内存(零拷贝 GPU 缓冲区)
MAP_PRIVATE
- 写时复制(copy-on-write)
- 不影响原文件
- ds4.c 在 CPU 路径使用它,是为了规避 macOS 内核的 VM 计数 bug
3. xmalloc_zeroed — 为什么不用 calloc?
ds4.c xmalloc_zeroed()(约 482-498 行)有一个特殊的函数:
c
static void *xmalloc_zeroed(size_t n, size_t size) {
const size_t total = n * size;
void *p = xmalloc(total ? total : 1);
memset(p, 0, total); // 手动清零,而不是用 calloc
return p;
}为什么不用 calloc?注释解释了原因:
大块未触碰的 calloc 内存可能被虚拟内存系统通过共享零页来管理。CPU 解码的 KV cache 每次增长一个 token,所以用 calloc 会把数千次首次触碰缺页移到生成阶段。在 Darwin 上我们观察到这会导致内核
cpt_mapcnt_inc溢出 panic。
简单说:
calloc返回的"零"内存可能共享一个物理零页(虚拟内存优化)- 第一次写入时触发 page fault,需要分配真实物理页
- 对于巨大的 KV cache,大量同时 page fault 可能让 macOS 内核崩溃
malloc + memset提前触发所有 page fault,把风险控制在初始化阶段
4. posix_madvise — 内存访问建议
c
int posix_madvise(void *addr, size_t len, int advice);ds4.c 用 POSIX_MADV_WILLNEED 告诉内核"这块内存马上要用":
c
posix_madvise(m->map, m->size, POSIX_MADV_WILLNEED);这不是强制操作,只是给内核一个提示,让内核提前把页面从磁盘读入缓存。
5. __thread — 线程局部存储
c
static __thread int g_parallel_depth; // `g_parallel_depth` 定义处(约 613 行)__thread 关键字使每个线程拥有变量的独立副本:
- 线程 A 的
g_parallel_depth和线程 B 的g_parallel_depth是不同的变量 - 不需要 mutex 保护,因为每个线程只访问自己的副本
- 线程创建时初始化为零
在 ds4.c 中,它跟踪当前线程在 CPU 并行工作池中的递归深度,防止递归爆炸。
6. 预分配缓冲区模式(Scratch Buffer)
ds4.c 不在热循环中分配内存。它在推理开始前一次性分配所有需要的缓冲区:
c
typedef struct {
float *plain; // 50+ 个预分配缓冲区
float *cur;
float *next;
float *q;
float *qr;
// ... 每个对应推理中的一个计算步骤
} ds4_cpu_decode_scratch;初始化时一次性分配:
c
static void cpu_decode_scratch_init(ds4_cpu_decode_scratch *s, uint32_t ctx_size) {
s->plain = xmalloc(DS4_N_EMBD * sizeof(float));
s->cur = xmalloc(hc_dim * sizeof(float));
// ... 50 次 xmalloc ...
}释放时一次性全部释放:
c
static void cpu_decode_scratch_free(ds4_cpu_decode_scratch *s) {
free(s->output_norm);
free(s->output_embd);
// ... 50 次 free ...
}还有一个分配守卫确保没有意外的热循环分配:
c
ds4_alloc_guard_begin("CPU token generation");
for (int i = 0; i < n_predict; i++) {
// 如果这里有 malloc 调用,程序会立即退出并报错
}
ds4_alloc_guard_end();7. 整数溢出保护
c
static void *xmalloc_zeroed(size_t n, size_t size) {
if (size != 0 && n > SIZE_MAX / size) ds4_die("allocation size overflow");
// ...
}当 n * size 超过 SIZE_MAX(size_t 能表示的最大值)时,乘法会回绕,导致分配过小的缓冲区。这是经典的安全漏洞(CWE-190)。检查 n > SIZE_MAX / size 可以防止这种情况。
LLM 知识点
1. 模型文件加载策略
一个 DeepSeek V4 Flash 的 2-bit 量化模型约 81GB。如何高效加载它?
传统方式(不适合):
fread把整个文件读入内存 → 81GB 拷贝,启动很慢- 只需要部分数据时也要全部读取
mmap 方式(ds4.c 的选择):
mmap立即返回,没有数据拷贝- 实际访问某页时才触发 page fault,从磁盘读取
- Metal GPU 可以零拷贝引用映射区域
懒加载的好处:
- 启动速度快:
mmap调用几乎是瞬间完成 - 内存按需使用:只有推理用到的权重页面会被加载
- 共享缓存:操作系统页缓存可以被多个进程共享
2. 权重预热(Weight Warmup)
可选的预热步骤,在推理前主动触碰所有权重页面:
c
static void model_warm_weights(const ds4_model *m) {
const uint64_t page = sysconf(_SC_PAGESIZE);
for (uint64_t off = start; off < end; off += page) {
checksum += p[off]; // 每页读一个字节,强制 page fault
}
}为什么要预热?
- 推理时首次访问某权重页面会触发 page fault → 暂停几十毫秒
- MoE 模型中,不同 token 路由到不同专家,page fault 时机不可预测
- 预热把所有 page fault 集中到启动阶段,推理时就不会卡顿
3. KV Cache 的内存布局
KV Cache 存储注意力机制的历史状态,是推理中最大的内存消耗之一。
每个 KV cache 行的大小 = DS4_N_HEAD_DIM × sizeof(float) = 512 × 4 = 2048 字节。
DeepSeek V4 Flash 的 KV cache 非常紧凑(这是选择它的重要原因之一),使得百万级上下文在本地成为可能。
4. 零拷贝张量访问
c
static const void *tensor_data(const ds4_model *m, const ds4_tensor *t) {
return m->map + t->abs_offset; // 直接返回 mmap 中的偏移
}这行代码是整个内存策略的核心:张量数据永远不离开 mmap 区域。没有拷贝,没有额外的缓冲区。推理引擎的指针直接指向文件映射中的位置。
总结:ds4.c 的内存策略
核心理念:在热路径上零分配,把所有内存操作集中到初始化阶段。
3. GGUF 二进制格式
模型以 GGUF 二进制文件分发。不理解文件格式,就不知道 81GB 里装了什么、怎么找到每一层权重。今天学习二进制解析、struct 对齐和"尽早失败"的验证策略。
C 知识点
1. struct 内存布局与对齐
C 编译器会在结构体成员之间插入"填充字节"(padding),确保每个成员正确对齐:
c
// 量化块结构体 `block_q2_K`(约 126-151 行)
typedef struct {
uint8_t scales[QK_K / 16]; // 16 字节,偏移 0
uint8_t qs[QK_K / 4]; // 64 字节,偏移 16
uint16_t d; // 2 字节,偏移 80
uint16_t dmin; // 2 字节,偏移 82
} block_q2_K; // 总计 84 字节对齐规则:
uint8_t:1 字节对齐(任意地址)uint16_t:2 字节对齐(偶数地址)uint32_t:4 字节对齐(4 的倍数地址)float:4 字节对齐uint64_t:8 字节对齐
在 block_q2_K 中,字段顺序经过精心安排,没有浪费填充空间。84 字节恰好被 static_assert 验证:
c
DS4_STATIC_ASSERT(ds4_block_q2_k_size, sizeof(block_q2_K) == 84);2. static_assert — 编译时断言
ds4.c 用一个巧妙的技巧实现编译时断言 DS4_STATIC_ASSERT(约 153 行):
c
#define DS4_STATIC_ASSERT(name, cond) typedef char name[(cond) ? 1 : -1]原理:
- 如果
cond为真,声明typedef char name[1]— 合法 - 如果
cond为假,声明typedef char name[-1]— 非法,编译报错
这是 C99 中的 portable static assert。C11 引入了标准的 _Static_assert,但 ds4.c 用 -std=c99 编译。
3. 二进制解析 — Cursor 模式
ds4.c 用 cursor(游标)模式解析 GGUF 二进制文件:
c
typedef struct {
const uint8_t *base; // mmap 基地址
uint64_t size; // 总大小
uint64_t pos; // 当前位置
char error[256]; // 错误信息
} ds4_cursor;核心操作:
c
// 检查是否有 n 字节可读
static bool cursor_has(ds4_cursor *c, uint64_t n) {
if (n > c->size || c->pos > c->size - n) {
cursor_error(c, "truncated GGUF file");
return false;
}
return true;
}
// 读 n 字节到 dst,推进位置
static bool cursor_read(ds4_cursor *c, void *dst, uint64_t n) {
if (!cursor_has(c, n)) return false;
memcpy(dst, c->base + c->pos, (size_t)n);
c->pos += n;
return true;
}
// 读一个 u32
static bool cursor_u32(ds4_cursor *c, uint32_t *v) {
return cursor_read(c, v, sizeof(*v));
}
// 读一个字符串(先读长度,再读内容)
static bool cursor_string(ds4_cursor *c, ds4_str *s) {
uint64_t len;
if (!cursor_u64(c, &len)) return false;
if (!cursor_has(c, len)) return false;
s->ptr = (const char *)(c->base + c->pos);
s->len = len;
c->pos += len;
return true;
}注意 cursor_string 返回的 ds4_str 是一个非拥有引用——s->ptr 直接指向 mmap 内部,没有拷贝。
4. 字节序(Endianness)
ds4.c 不做字节序转换。所有 cursor_read 用 memcpy 直接拷贝到原生类型。
c
memcpy(dst, c->base + c->pos, (size_t)n);这意味着 ds4.c 假设运行平台是小端序(little-endian)。GGUF 格式也定义为小端序。Apple Silicon(ARM)和 Intel x86 都是小端序,所以这个假设在目标平台上成立。
5. ds4_str — 轻量字符串视图
c
typedef struct {
const char *ptr; // 指向 mmap 中的字符串数据
uint64_t len; // 字符串长度
} ds4_str;不同于 C 标准库的以 \0 结尾的字符串,ds4_str 用长度字段标记边界。好处:
- 不需要拷贝/分配内存
- 可以包含
\0字符 - O(1) 获取长度
LLM 知识点
1. GGUF 文件格式
GGUF 是一种二进制模型文件格式,结构如下:
关键设计:
- 头部在前:先读头部,再根据头部信息定位数据
- 元数据与数据分离:元数据(模型名称、参数等)在前面,张量数据在后面
- 相对偏移:张量目录存储相对于
tensor_data_pos的偏移,ds4.c 在解析时转为绝对偏移
2. 量化块格式
模型权重不需要 32 位浮点数精度。量化用更少的位存储权重,节省内存:
| 格式 | 每权重位数 | 块大小 | 用途 |
|---|---|---|---|
| F32 | 32 | 1024 字节/256 权重 | 未量化(参考) |
| Q8_K | 8 | 292 字节/256 权重 | 高精度量化 |
| Q4_K | 4 | 144 字节/256 权重 | 中等量化 |
| Q2_K | ~2.6 | 84 字节/256 权重 | 激进量化 |
| IQ2_XXS | ~2.06 | 66 字节/256 权重 | 超激进量化 |
每个量化块(block)包含 256 个权重值(QK_K = 256):
c
// Q2_K:每个 block 有 84 字节
typedef struct {
uint8_t scales[16]; // 缩放因子:16 组,每组 1 字节
uint8_t qs[64]; // 量化权重:256 个值,每个 2 bit(64 字节 = 256×2/8)
uint16_t d; // 全局缩放
uint16_t dmin; // 全局最小值偏移
} block_q2_K;3. 模型验证 — "早失败"原则
ds4.c 读取模型元数据后,会严格验证每一个参数:
c
config_expect_u32("embedding_length", n_embd, DS4_N_EMBD); // 必须是 4096
config_expect_u32("expert_count", n_expert, DS4_N_EXPERT); // 必须是 256
config_expect_u32("attention.head_count", n_head, DS4_N_HEAD); // 必须是 64
// ... 30 多个参数全部检查 ...如果任何一个参数不匹配,程序立即退出。这体现了"早失败"原则:
- 比在推理时产生错误结果好得多
- 比静默使用错误的模型参数好得多
- 错误信息明确指出哪个参数不匹配
4. 张量(Tensor)概念
张量是多维数组的数学术语。在 LLM 中:
| 维度 | 名称 | 例子 |
|---|---|---|
| 0D | 标量 | 学习率 |
| 1D | 向量 | 偏置项 bias |
| 2D | 矩阵 | 权重矩阵 W |
| 3D+ | 张量 | MoE 专家权重 [256专家, 2048, 4096] |
ds4.c 中的张量结构体支持最多 8 维:
c
typedef struct {
ds4_str name;
uint32_t ndim; // 维度数
uint64_t dim[DS4_MAX_DIMS]; // 每维大小
uint32_t type; // 数据类型(F32/Q2_K/Q4_K 等)
uint64_t abs_offset; // 在 mmap 中的绝对偏移
uint64_t elements; // 总元素数(dim[0]×dim[1]×...)
uint64_t bytes; // 在文件中占用的字节数
} ds4_tensor;