Appearance
Part 1: 代码走读
1. 项目全貌与编译系统
追踪 1:从 make 到可执行文件的完整路径
依赖关系图
make (默认目标: all)
├── ds4 (CLI 交互式聊天)
│ ├── ds4_cli.o ← ds4_cli.c + ds4.h + linenoise.h
│ ├── linenoise.o ← linenoise.c + linenoise.h
│ ├── ds4.o ← ds4.c + ds4.h + ds4_metal.h
│ └── ds4_metal.o ← ds4_metal.m + ds4_metal.h + metal/*.metal
│
├── ds4-server (HTTP API 服务器)
│ ├── ds4_server.o ← ds4_server.c + ds4.h
│ ├── ds4.o ← (同上)
│ └── ds4_metal.o ← (同上)
│
├── ds4-bench (吞吐量基准测试)
│ ├── ds4_bench.o ← ds4_bench.c + ds4.h
│ └── ds4.o + ds4_metal.o
│
└── ds4-eval (能力评估,93 道题回归测试)
├── ds4_eval.o ← ds4_eval.c + ds4.h
└── ds4.o + ds4_metal.o核心库 ds4.o + ds4_metal.o 被所有可执行文件共享。
追踪编译命令
bash
# 步骤 1:编译推理引擎核心
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -std=c99 -c -o ds4.o ds4.c
# 步骤 2:编译 Metal GPU 桥接层
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -fobjc-arc -c -o ds4_metal.o ds4_metal.m
# 步骤 3:编译 CLI 前端
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -std=c99 -c -o ds4_cli.o ds4_cli.c
# 步骤 4:编译行编辑库
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -std=c99 -c -o linenoise.o linenoise.c
# 步骤 5:链接 CLI
cc -O3 -ffast-math ... -o ds4 ds4_cli.o linenoise.o ds4.o ds4_metal.o -lm -pthread -framework Foundation -framework Metal注意第 5 步把所有 .o 文件和库合并成最终可执行文件。
追踪 2:ds4.c 文件头部(行 1-120)
文件头注释(行 1-15)
ds4.c:735KB, ~20,000+ 行开头的注释说明了这个文件的设计哲学:
"This file is deliberately vertical" 这个文件是刻意垂直整合的——它拥有 GGUF 加载、固定张量布局、CPU 参考内核、Metal 图驱动器和分词器连接。
关键词:mmap based(基于内存映射加载),张量数据留在内核页缓存中,直到推理需要它。
标准库包含(行 17-35)
ds4.c 的 18 个 #include 可以按功能分组:
| 功能 | 头文件 |
|---|---|
| 文件 I/O | <fcntl.h>, <unistd.h>, <sys/stat.h>, <sys/file.h> |
| 内存管理 | <sys/mman.h>, <stdlib.h> |
| 字符串/内存 | <string.h>, <ctype.h> |
| 数学运算 | <math.h>, <float.h> |
| 类型定义 | <stdint.h>, <stdbool.h>, <stddef.h>, <inttypes.h> |
| 线程 | <pthread.h> |
| 输入输出 | <stdio.h> |
| 可变参数 | <stdarg.h> |
| 时间 | <time.h> |
| 错误 | <errno.h> |
条件包含(行 37-44)
c
#include "ds4.h" // 公共 API —— 始终包含
#ifndef DS4_NO_METAL
#include "ds4_metal.h" // Metal GPU 支持 —— 仅 macOS
#endif
#if defined(__ARM_NEON)
#include <arm_neon.h> // ARM SIMD 指令 —— 仅 ARM 芯片
#endif这展示了条件编译的两种用法:
#ifndef DS4_NO_METAL:在 Makefile 中通过-DDS4_NO_METAL禁用 GPU#if defined(__ARM_NEON):编译器自动根据 CPU 架构定义的宏
数学常量与模型超参(行 46-71)
c
#define DS4_NEG_INF (-1.0e30f) // 不是真正的 -∞,是足够大的负数
#define DS4_POS_INF ( 1.0e30f) // 同理,用于 softmax 中的掩码
#define DS4_RMS_EPS ( 1.0e-6f) // RMSNorm 的 epsilon,防止除零为什么用 1.0e30f 而不是 INFINITY?
float的范围约 ±3.4e38,1e30 足够大- 避免浮点异常和 NaN 传播
- 在 softmax 等运算中,任何正常值减去 1e30 都会变成 0(exp(-大数) ≈ 0)
DeepSeek V4 Flash 架构常量(行 82-105)
c
enum {
DS4_N_LAYER = 43, // Transformer 层数
DS4_N_EMBD = 4096, // 嵌入维度(每个 token 的向量长度)
DS4_N_VOCAB = 129280, // 词表大小
DS4_N_HEAD = 64, // 注意力头数
DS4_N_HEAD_KV = 1, // KV 头数(GQA:64:1 分组)
DS4_N_HEAD_DIM = 512, // 每个头的维度
DS4_N_EXPERT = 256, // MoE 专家总数
DS4_N_EXPERT_USED = 6, // 每个 token 激活的专家数
...
};为什么用 enum 而不是 #define?
- 编译器可以进行类型检查
- 调试器中可以看到枚举名(
DS4_N_LAYER比43有意义得多) - 所有常量在一个
enum块中,便于阅读和维护
追踪 3:ds4.h 公共 API(行 1-161)
设计模式:opaque type(不透明类型)
c
typedef struct ds4_engine ds4_engine; // 前向声明,不暴露内部
typedef struct ds4_session ds4_session; // 同上这是 C 语言实现封装的经典手法:
- 头文件只声明类型名,不定义结构体字段
- 结构体的完整定义藏在 ds4.c 中
- 外部代码(CLI、server)只能通过指针操作,不能直接访问字段
好处:
- 内部实现可以随意修改,不影响调用者
- 强制使用者通过 API 函数操作,不会误用内部状态
- 编译时间更快(修改 ds4.c 不需要重新编译 ds4_server.c)
API 层次
ds4_engine 模型(加载一次,只读)
├── open/close 生命周期管理
├── generate 简单推理(一次性)
└── tokenize 分词
ds4_session 会话(每次对话一个,可变)
├── create/free 生命周期管理
├── sync 同步到指定 prompt 前缀(复用 KV cache)
├── argmax/sample 选择下一个 token
└── eval 执行一步推理
ds4_tokens 动态数组
└── push/free/copy 管理操作关键概念:ds4_session_sync() 是高性能的关键——如果新 prompt 和已有 cache 有共同前缀,只需计算新增部分,不需要从头开始。
追踪 4:项目文件大小对比
ds4.c 735 KB 推理引擎核心(最大)
ds4_metal.m 639 KB Metal GPU 桥接
ds4_server.c 269 KB HTTP 服务器
linenoise.c 83 KB 行编辑库
ds4_cli.c 48 KB CLI 前端
ds4_metal.h 31 KB Metal 头文件
ds4.h 6 KB 公共 API
linenoise.h 5 KB 行编辑头文件ds4.c 占了项目代码的大部分。它是一个典型的"单文件巨石"(monolithic)设计,所有推理逻辑集中在一个文件中。
2. 内存管理与 mmap
追踪 1:模型加载完整调用链
从用户运行 ./ds4 -p "Hello" 到模型加载完成的调用链:
main() [ds4_cli.c]
└── ds4_engine_open() [ds4.h 约 81 行, ds4.c 约 15716 行]
├── xcalloc(1, sizeof(*e)) 分配引擎结构体
├── model_open(&e->model, path, metal, true) 加载主模型
│ ├── open(path, O_RDONLY) 打开文件
│ ├── fstat(fd, &st) 获取文件大小
│ ├── mmap(NULL, size, PROT_READ, 映射到内存
│ │ MAP_SHARED/MAP_PRIVATE, fd, 0)
│ ├── cursor_u32() × 4 解析 GGUF 头部
│ ├── parse_metadata(m, &c) 解析元数据表
│ │ └── calloc(n_kv, sizeof(kv)) 分配 KV 数组
│ ├── parse_tensors(m, &c) 解析张量目录
│ │ └── calloc(n_tensors, ...) 分配张量数组
│ └── model_prefetch_cpu_mapping(m) 若 CPU 后端且 prefetch_cpu=true
├── model_warm_weights() 可选预热
│ └── posix_madvise(WILLNEED) 提示内核预读
│ └── for each page: p[off] 触发 page fault
├── vocab_load() 加载分词器
├── config_validate_model() 验证模型参数
└── weights_bind() 绑定权重指针
└── tensor_data(m, t) 返回 mmap 偏移model_open 的 prefetch_cpu 参数:第四个参数控制是否触发 CPU 映射预取。正常推理引擎传 true,但 ds4_dump_text_tokenization() 只需要分词器,不需要遍历巨大的张量数据,因此传 false 以节省时间。
关键数据流
磁盘文件 (81GB GGUF)
│
▼ open() + mmap()
│
虚拟地址空间 (m->map 指针)
│
├── parse_metadata() → m->kv[] 元数据(模型名称、参数等)
├── parse_tensors() → m->tensors[] 张量目录(名称、偏移、大小)
│
▼ weights_bind()
│
weights 结构体(指针数组,全部指向 mmap 内部)
├── tok_embd → m->map + offset_0 词嵌入矩阵
├── layer[0].wq → m->map + offset_1 第 0 层 query 权重
├── layer[0].wk → m->map + offset_2 第 0 层 key 权重
├── ...
└── output → m->map + offset_N 输出投影追踪 2:ds4_model 结构体(行 895-908)
c
typedef struct {
int fd; // 文件描述符(mmap 需要)
const uint8_t *map; // mmap 返回的基地址
uint64_t size; // 文件/映射总大小
uint32_t version; // GGUF 版本号(必须是 3)
uint64_t n_kv; // 元数据 KV 对数量
uint64_t n_tensors; // 张量数量
uint64_t alignment; // 数据对齐要求
uint64_t tensor_data_pos; // 张量数据在文件中的起始位置
ds4_kv *kv; // 元数据数组(calloc 分配)
ds4_tensor *tensors; // 张量目录数组(calloc 分配)
} ds4_model;内存所有权:
map:mmap 拥有,munmap释放kv:calloc分配,free释放tensors:calloc分配,free释放fd:open返回,close关闭
model_close(行 1057-1065)— 释放顺序
c
static void model_close(ds4_model *m) {
if (!m) return;
free(m->kv); // 先释放小对象
free(m->tensors);
if (m->map) munmap((void *)m->map, (size_t)m->size); // 再释放大映射
if (m->fd >= 0) close(m->fd); // 最后关文件
memset(m, 0, sizeof(*m)); // 清零结构体
m->fd = -1; // 重置为无效值
}注意 memset(m, 0, sizeof(*m)) 和 m->fd = -1:确保关闭后结构体处于安全的"空"状态。
追踪 3:错误处理层次
ds4.c 有三个层次的错误处理:
致命错误(不可恢复)
c
// 行 400-403
static void ds4_die(const char *msg) {
fprintf(stderr, "ds4: %s\n", msg);
exit(1);
}
// 行 413-416
static void ds4_die_errno(const char *what, const char *path) {
fprintf(stderr, "ds4: %s '%s': %s\n", what, path, strerror(errno));
exit(1);
}用于:内存不足、文件不存在、格式错误等。这类错误发生后程序无法继续运行。
警告(可恢复)
c
// posix_madvise 失败时只打印警告
if (rc != 0) {
ds4_log(stderr, DS4_LOG_WARNING,
"ds4: warning: POSIX_MADV_WILLNEED failed: %s\n", strerror(rc));
}posix_madvise 只是建议,失败了不影响正确性,只是性能可能受影响。
分配守卫(调试用)
c
// 行 437-459: 分配守卫基础设施
static bool g_alloc_guard_enabled;
static const char *g_alloc_guard_phase;
static void ds4_alloc_guard_check(const char *op, size_t size) {
if (!g_alloc_guard_enabled) return; // 守卫未启用,跳过
fprintf(stderr, "ds4: internal allocation during %s: %s(%zu)\n",
g_alloc_guard_phase, op, size);
exit(1); // 热循环中有分配就报错
}
// 行 14470: 在生成循环中启用守卫
ds4_alloc_guard_begin("CPU token generation");
for (int i = 0; i < n_predict && pos < ctx_size; i++) {
// 这里任何 malloc/calloc/realloc 都会触发 ds4_die
}
ds4_alloc_guard_end();追踪 4:xmalloc_zeroed vs calloc(行 482-498)
c
static void *xmalloc_zeroed(size_t n, size_t size) {
// 溢出检查
if (size != 0 && n > SIZE_MAX / size) ds4_die("allocation size overflow");
const size_t total = n * size;
void *p = xmalloc(total ? total : 1);
/* 注释解释了为什么不用 calloc:
* 大块未触碰的 calloc 内存可能通过共享零页管理。
* CPU 解码 KV cache 每次增长一个 token,用 calloc 会把
* 首次缺页移到生成阶段。Darwin 上观察到内核 panic。 */
memset(p, 0, total);
return p;
}调用链:
kv_cache_init() [行 6027]
└── for each layer:
└── xmalloc_zeroed(raw_cap * DS4_N_HEAD_DIM, sizeof(float)) [行 482]
├── 溢出检查
├── xmalloc(total) malloc + NULL 检查
└── memset(p, 0, total) 手动清零追踪 5:线程局部存储(行 605-614)
c
// 行 605-610: 线程池结构体
typedef struct {
// ... pthread 同步原语 ...
} ds4_thread_pool;
// 行 612-614: 全局变量
static ds4_thread_pool g_pool; // 全局共享的线程池
static __thread int g_parallel_depth; // 每线程独立的递归深度
static uint32_t g_requested_threads; // 请求的线程数__thread 的内存模型:
线程 1 (主线程) 线程 2 (工作线程)
┌─────────────────┐ ┌─────────────────┐
│ g_parallel_depth │ │ g_parallel_depth │
│ = 0 │ │ = 1 │
└─────────────────┘ └─────────────────┘
独立副本 独立副本
g_pool ←─── 两个线程共享同一个线程池 ───→ g_pool追踪 6:模型预热的页面扫描(行 1328-1353)
c
static void model_warm_weights(const ds4_model *m) {
const uint64_t start = m->tensor_data_pos; // 张量数据起始位置
const uint64_t end = m->size; // 文件末尾
const uint64_t page = sysconf(_SC_PAGESIZE); // 系统页大小(通常 16KB)
volatile uint64_t checksum = 0; // volatile 防止优化器删除
// 先用 posix_madvise 提示内核
posix_madvise(p + start, end - start, POSIX_MADV_WILLNEED);
// 每页读一个字节,强制 page fault
for (uint64_t off = start; off < end; off += page) {
checksum += p[off];
}
checksum += p[end - 1]; // 最后一个字节
}为什么用 volatile?编译器看到 checksum 计算后没有被使用,可能会优化掉整个循环。volatile 告诉编译器"这个变量可能被外部修改,不要优化掉对它的读写"。
实际上这里的目的不是真的要 checksum,而是强制触发每一页的 page fault。
3. GGUF 二进制格式
追踪 1:GGUF 文件头部解析
调用链
model_open() [行 1174]
├── open(path, O_RDONLY) 打开文件
├── fstat(fd, &st) 获取大小
├── mmap(NULL, size, PROT_READ, ...) 内存映射
│
├── cursor_at(m, 0) 创建游标,从偏移 0 开始
│ base = m->map
│ size = m->size
│ pos = 0
│
├── cursor_u32(&c, &magic) 读 4 字节 → 0x46554747 ("GGUF")
│ └── cursor_read(&c, &magic, 4)
│ └── memcpy(&magic, base+0, 4) → pos=4
│
├── cursor_u32(&c, &version) 读 4 字节 → 3
│ └── memcpy(&version, base+4, 4) → pos=8
│
├── cursor_u64(&c, &n_tensors) 读 8 字节 → 张量数量
│ └── memcpy(&n_tensors, base+8, 8) → pos=16
│
└── cursor_u64(&c, &n_kv) 读 8 字节 → 元数据 KV 数量
└── memcpy(&n_kv, base+16, 8) → pos=24GGUF 头部字节布局
偏移 大小 字段 值
0 4 magic "GGUF" = 0x47 0x55 0x47 0x46
4 4 version 3
8 8 n_tensors ~500+(取决于模型)
16 8 n_kv ~100+(模型元数据对数)
24 ... metadata[0] 开始追踪 2:parse_metadata(行 1093-1119)
解析流程
parse_metadata(m, &c) [行 1093]
│ pos = 24(头部之后)
│
├── calloc(n_kv, sizeof(ds4_kv)) 分配 KV 数组
│
└── for i = 0..n_kv-1: 遍历每个 KV 对
│
├── cursor_string(&c, &kv->key) 读 key
│ ├── cursor_u64(&c, &len) 读字符串长度
│ └── s->ptr = base + pos; pos += len 指向 mmap 内部
│
├── cursor_u32(&c, &kv->type) 读类型(0=bool, 1=u32, ...)
│
├── kv->value_pos = c->pos 记录 value 起始位置(不解析!)
│
└── skip_value(&c, kv->type, 0) 跳过 value 内容懒加载策略
ds4_kv 只存储 value_pos(value 在文件中的偏移),不立即解析:
c
typedef struct {
ds4_str key; // key 字符串(指向 mmap)
uint32_t type; // value 类型
uint64_t value_pos; // value 在 mmap 中的偏移
} ds4_kv;只有在需要时(如 config_validate_model),才用 cursor_at(m, kv->value_pos) 读取具体值。这避免了为每个元数据值分配内存。
skip_value — 递归跳过(行 932-970)
c
static bool skip_value(ds4_cursor *c, uint32_t type, int depth) {
uint64_t scalar = scalar_value_size(type);
if (scalar != 0) return cursor_skip(c, scalar); // 标量类型:直接跳过
if (type == GGUF_VALUE_STRING) { // 字符串:读长度+跳过
ds4_str ignored;
return cursor_string(c, &ignored);
}
if (type == GGUF_VALUE_ARRAY) { // 数组:递归跳过每个元素
uint32_t item_type;
uint64_t len;
cursor_u32(c, &item_type);
cursor_u64(c, &len);
for (uint64_t i = 0; i < len; i++)
skip_value(c, item_type, depth + 1);
return true;
}
}追踪 3:parse_tensors(行 1123-1170)
解析流程
parse_tensors(m, &c) [行 1123]
│
├── calloc(n_tensors, sizeof(ds4_tensor)) 分配张量数组
│
└── for i = 0..n_tensors-1: 遍历每个张量
│
├── cursor_string(&c, &t->name) 名称
├── cursor_u32(&c, &t->ndim) 维度数
├── for d = 0..ndim-1:
│ └── cursor_u64(&c, &t->dim[d]) 每维大小
│ └── t->elements *= t->dim[d] 计算总元素数
├── cursor_u32(&c, &t->type) 数据类型
└── cursor_u64(&c, &t->rel_offset) 相对偏移
│
├── m->tensor_data_pos = align_up(c->pos, m->alignment)
│ 计算张量数据的起始位置(对齐到 alignment 字节边界)
│
└── for i = 0..n_tensors-1: 转换为绝对偏移
t->abs_offset = tensor_data_pos + t->rel_offset
验证: abs_offset + bytes <= m->size 不超出文件范围为什么分两步解析?
- 先读元信息:名称、维度、类型、相对偏移
- 再计算绝对偏移:需要知道
tensor_data_pos,它是在所有张量元信息读完之后才确定的
追踪 4:量化块结构体大小验证
c
// 行 153
#define DS4_STATIC_ASSERT(name, cond) typedef char name[(cond) ? 1 : -1]
// 行 154-157
DS4_STATIC_ASSERT(ds4_block_q2_k_size, sizeof(block_q2_K) == 84); // ✓ 16+64+2+2 = 84
DS4_STATIC_ASSERT(ds4_block_q4_k_size, sizeof(block_q4_K) == 144); // ✓ 2+2+12+128 = 144
DS4_STATIC_ASSERT(ds4_block_q8_k_size, sizeof(block_q8_K) == 292); // ✓ 4+256+32 = 292
DS4_STATIC_ASSERT(ds4_block_iq2_xxs_size, sizeof(block_iq2_xxs) == 66); // ✓ 2+64 = 66block_q4_K 内存布局验证
c
typedef struct {
uint16_t d; // 2 字节,偏移 0
uint16_t dmin; // 2 字节,偏移 2
uint8_t scales[12]; // 12 字节,偏移 4
uint8_t qs[QK_K/2]; // 128 字节,偏移 16 (QK_K=256, 256/2=128)
} block_q4_K; // 总计 144 字节 ✓128 个 uint8_t 存储 256 个 4-bit 权重:每个字节存 2 个权重(高 4 bit + 低 4 bit)。
追踪 5:config_validate_model(行 2343-2425)
config_validate_model(&e->model) [行 2343]
│
├── 从元数据读取模型参数
│ required_u32(m, "deepseek4.block_count") → 43
│ required_u32(m, "deepseek4.embedding_length") → 4096
│ required_u32(m, "deepseek4.vocab_size") → 129280
│ required_u32(m, "deepseek4.attention.head_count") → 64
│ ...
│ (30 多个参数)
│
├── 逐个与硬编码常量比较
│ config_expect_u32("block_count", 43, DS4_N_LAYER) ✓
│ config_expect_u32("vocab_size", 129280, DS4_N_VOCAB) ✓
│ ...
│
└── 任何一个不匹配 → fprintf(stderr, ...) + exit(1)这个函数确保"文件里的模型"和"代码期望的模型"完全一致。是"专用引擎"设计的关键体现。