Skip to content

Part 1: 练习

1. 项目全貌与编译系统

练习 1:阅读 Makefile

题目:打开项目根目录的 Makefile,回答以下问题:

  1. 默认目标(make 不带参数时)会构建哪些可执行文件?
  2. ds4.o 依赖哪些源文件?如果只修改了 ds4.hmake 会重新编译 ds4.o 吗?
  3. 在 Linux 上编译时,CFLAGS 会多出什么标志?它的作用是什么?
  4. ds4_metal.o 的编译命令和 ds4.o 有什么不同?为什么?
参考答案(先自己尝试,再展开查看)
  1. ds4ds4-serverds4-benchds4-evalds4-agent(由 all: 目标定义)
  2. 依赖 ds4.cds4.hds4_metal.h。修改 ds4.h 会触发重新编译,因为 ds4.h 在依赖列表中
  3. -DDS4_NO_METAL,这会在预处理阶段定义 DS4_NO_METAL 宏,使代码中的 #ifndef DS4_NO_METAL 条件编译跳过 Metal 相关代码
  4. ds4_metal.o$(OBJCFLAGS)(包含 -fobjc-arc)编译,因为 .m 文件是 Objective-C,需要 ARC(自动引用计数);而 ds4.o$(CFLAGS)(包含 -std=c99)编译

练习 2:追踪编译过程

题目:假设你运行 make clean && make ds4(在 macOS 上),请按顺序列出会执行的命令。提示:make 按依赖顺序构建。

参考答案(先自己尝试,再展开查看)
bash
# 第 1 步:编译 linenoise.o(行编辑库,无特殊依赖)
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -std=c99 -c -o linenoise.o linenoise.c

# 第 2 步:编译 ds4.o(推理引擎核心)
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -std=c99 -c -o ds4.o ds4.c

# 第 3 步:编译 ds4_metal.o(Metal GPU 桥接,Objective-C)
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -fobjc-arc -c -o ds4_metal.o ds4_metal.m

# 第 4 步:编译 ds4_cli.o(CLI 前端)
cc -O3 -ffast-math -mcpu=native -Wall -Wextra -std=c99 -c -o ds4_cli.o ds4_cli.c

# 第 5 步:链接所有 .o 文件为 ds4 可执行文件
cc -O3 -ffast-math ... -o ds4 ds4_cli.o linenoise.o ds4.o ds4_metal.o -lm -pthread -framework Foundation -framework Metal

练习 3:理解条件编译

题目:ds4.c 第 39-44 行有如下代码:

c
#ifndef DS4_NO_METAL
#include "ds4_metal.h"
#endif
#if defined(__ARM_NEON)
#include <arm_neon.h>
#endif

请解释:

  1. 在什么情况下 ds4_metal.h 不会被包含?
  2. __ARM_NEON 是在哪里定义的?为什么要检查它?
  3. 如果你在 Intel Mac 上编译,会发生什么?
参考答案(先自己尝试,再展开查看)
  1. 当 Makefile 定义了 -DDS4_NO_METAL 时(即 Linux 平台编译时),DS4_NO_METAL 被定义,#ifndef 条件为假,ds4_metal.h 不会被包含
  2. __ARM_NEON 由编译器预定义,当目标架构支持 ARM NEON SIMD 指令集时自动定义。检查它是因为 <arm_neon.h> 只在 ARM 处理器上可用,Intel 处理器没有这个头文件
  3. Intel Mac 上:__ARM_NEON 未定义,不包含 <arm_neon.h>;但 Intel Mac 仍然是 macOS(Darwin),所以 DS4_NO_METAL 未定义,会包含 ds4_metal.h(不过 Metal 性能可能不同)

练习 4:理解模型架构常量

题目:根据 ds4.c 第 82-105 行的常量,计算以下数值:

  1. 每个 token 的注意力计算需要多少个 query 向量元素?(提示:头数 × 每头维度)
  2. MoE 层中,被"跳过"(不激活)的专家占总专家数的比例是多少?
  3. DS4_THINK_MAX_MIN_CONTEXT 是 393216,这个值大约是多少 K token?
参考答案(先自己尝试,再展开查看)
  1. 64 × 512 = 32,768 个元素(这是 LoRA 压缩前的原始维度)
  2. (256 - 6) / 256 = 250/256 ≈ 97.7% 的专家不被激活——这就是 MoE 节省计算的核心原因
  3. 393216 / 1024 = 384K token(约 384K,注释里也写了"384K-token context window")

练习 5:概念思考题

题目:用自己的话回答:

  1. 为什么 ds4.c 不做成一个通用的 GGUF 加载器(像 llama.cpp 那样),而是只支持 DeepSeek V4 Flash?
  2. 为什么推理引擎需要两个可执行文件(ds4ds4-server),而不是一个?
  3. opaque type(不透明类型)的设计在什么场景下特别有用?
参考答案(先自己尝试,再展开查看)
  1. 专用化换来简化和优化:固定模型架构意味着不需要运行时动态读取维度、不需要通用张量抽象、可以硬编码所有内存布局,代码更简洁、性能更好。这是"做一件事并做好"的工程选择。
  2. 不同的使用场景ds4 是给人直接用的交互式工具;ds4-server 是给程序调用的网络服务。它们共享推理核心(ds4.o + ds4_metal.o),但前端交互方式完全不同。分开可以独立修改、部署。
  3. 库/模块的封装:当你的代码被其他模块使用时,opaque type 隐藏内部细节,防止调用者依赖实现细节。这样你可以自由修改内部结构而不破坏兼容性。类似于面向对象语言中的"私有成员"。

今日学习检查清单

  • 能说出 make 构建本项目时的完整步骤
  • 理解 ?=:=+= 在 Makefile 中的区别
  • 能解释 #ifndef 条件编译的工作原理
  • 能解释 ds4.c 的文件结构和设计哲学
  • 能列出 DeepSeek V4 Flash 的关键架构参数(层数、维度、专家数等)
  • 能解释 opaque type 的目的和好处
  • 能说出推理引擎在 LLM 系统中的角色

延伸挑战

挑战 1(中级):自定义编译目标

在 Makefile 中添加一个新目标 make info,运行时打印编译器版本、平台和所有 CFLAGS 变量。提示:用 @echo$(CC --version)

挑战 2(高级):阅读 Makefile 以外的构建配置

查看 ds4_bench.cds4_server.c 的编译规则,解释为什么 ds4_server 不链接 -framework Metal 但仍然能使用 GPU。提示:看 ds4.o 的依赖。

2. 内存管理与 mmap

练习 1:xmalloc 包装器

题目:ds4.c 的 xmalloc 定义如下:

c
static void *xmalloc(size_t size) {
    void *p = malloc(size);
    if (!p) ds4_die("out of memory");
    return p;
}

xmalloc_zeroedxmalloc + memset 代替 calloc

请回答:

  1. 为什么没有 xfree 包装器?free 不需要检查返回值吗?
  2. 如果你在 xmalloc 中想添加分配大小限制(比如单次最大 1GB),应该在哪里加?怎么加?
  3. ds4_alloc_guard_check 是怎么知道当前是否在守卫阶段的?
参考答案(先自己尝试,再展开查看)
  1. free 没有返回值(void),也不需要检查。free(NULL) 在 C 标准中是安全的空操作。所以不需要 xfree 包装器。
  2. xmalloc 中,malloc 调用前加检查:
    c
    if (size > 1024ULL * 1024 * 1024) ds4_die("allocation too large");
  3. 通过全局变量 g_alloc_guard_enabled(行 437)。ds4_alloc_guard_begin 设为 trueds4_alloc_guard_end 设为 false。每次 xmalloc/xcalloc/xrealloc 调用时检查这个标志。

练习 2:mmap 标志选择

题目:ds4.c 行 1197-1199 有如下代码:

c
const int mmap_flags = metal_mapping ? MAP_SHARED : MAP_PRIVATE;
void *map = mmap(NULL, (size_t)st.st_size, PROT_READ, mmap_flags, fd, 0);

请回答:

  1. Metal 路径用 MAP_SHARED,CPU 路径用 MAP_PRIVATE。为什么 Metal 需要 MAP_SHARED
  2. PROT_READ 表示什么?为什么不用 PROT_WRITE
  3. MAP_FAILEDNULL 有什么区别?mmap 失败时返回哪个?
参考答案(先自己尝试,再展开查看)
  1. Metal GPU 可以把 MAP_SHARED 映射的内存包装为"零拷贝 MTLBuffer"——GPU 直接从文件映射读取,不需要额外拷贝。MAP_PRIVATE 创建的是写时复制副本,Metal 无法零拷贝引用。
  2. PROT_READ 表示只读访问。模型权重文件不需要修改,用只读更安全(防止意外写入)。mmap 用 PROT_READ 后,任何写入尝试都会触发段错误(SIGSEGV)。
  3. mmap 失败返回 MAP_FAILED(定义为 (void *)-1),不是 NULL。这是 mmap 设计上的一个历史"坑"——NULL 是合法的返回值(表示系统选择了地址),所以用 -1 表示失败。

练习 3:读取 ds4_model 结构体

题目:根据以下 model_open 中的代码(简化版),推断 ds4_model 各字段是如何被填充的:

c
int fd = open(path, O_RDONLY);
struct stat st;
fstat(fd, &st);

void *map = mmap(NULL, st.st_size, PROT_READ, flags, fd, 0);

ds4_cursor c = cursor_at(m, 0);
cursor_u32(&c, &magic);
cursor_u32(&c, &m->version);
cursor_u64(&c, &m->n_tensors);
cursor_u64(&c, &m->n_kv);

请画出从磁盘到结构体字段的数据流。

参考答案(先自己尝试,再展开查看)
磁盘文件 (GGUF)

    ▼ open() + mmap()

m->map ──────────────────────────► 虚拟地址空间基地址
m->fd  ──────────────────────────► 文件描述符
m->size ← st.st_size ◄──────────── fstat() 获取文件大小

    ▼ cursor_at(m, 0) 从偏移 0 开始解析

    ├── 字节 0-3:   cursor_u32 → magic (临时变量,检查后丢弃)
    ├── 字节 4-7:   cursor_u32 → m->version
    ├── 字节 8-15:  cursor_u64 → m->n_tensors
    ├── 字节 16-23: cursor_u64 → m->n_kv

    ▼ 后续解析

    ├── parse_metadata() → m->kv[]
    └── parse_tensors()  → m->tensors[]

cursor(游标)是一种常用的二进制解析模式:维护当前位置,每读一个字段自动前进。


练习 4:理解 calloc 零页问题

题目:假设操作系统用"共享零页"优化 calloc:

calloc(1000, 4096) = 4MB 内存
  ├── 实际只分配 1 个物理页(全零)
  └── 所有 1000 个虚拟页都指向这个物理页(只读)

当程序首次写入第 500 页时:
  ├── 触发 page fault
  ├── 操作系统分配一个新的物理页
  ├── 复制零页内容
  └── 映射虚拟页 500 到新物理页

请解释:为什么这个优化对 ds4.c 的 KV cache 是危险的?

参考答案(先自己尝试,再展开查看)

KV cache 在推理热循环中逐 token 填充。假设 KV cache 有 10 万个页:

  • 用 calloc:10 万个虚拟页都指向共享零页。推理开始后,每处理一个 token,写入新的 KV 行,触发一个 page fault。100 个 token 就可能触发数百个 page fault。
  • 用 malloc + memset:初始化时就写入所有页。memset 触发所有 page fault,操作系统为每个虚拟页分配独立的物理页。
  • 在 macOS 上,当大量 page fault 同时发生,加上巨大的 mmap 映射,内核的虚拟内存计数器(cpt_mapcnt_inc)可能溢出,导致 kernel panic。
  • malloc + memset 把 page fault 集中在启动时(可控),而不是分散在推理热循环中(不可控)。

练习 5:Scratch Buffer 设计思考

题目:ds4.c 用预分配的 scratch buffer 避免热循环分配。假设你要设计一个类似的结构体来存储矩阵乘法的中间结果,需要以下缓冲区:

  • 输入向量 A(1024 个 float)
  • 输入向量 B(1024 个 float)
  • 输出向量 C(1024 个 float)
  • 临时缓冲区 temp(2048 个 float)

请写出:

  1. 结构体定义
  2. 初始化函数
  3. 释放函数
参考答案(先自己尝试,再展开查看)
c
typedef struct {
    float *a;
    float *b;
    float *c;
    float *temp;
} matmul_scratch;

static void matmul_scratch_init(matmul_scratch *s) {
    s->a    = xmalloc(1024 * sizeof(float));
    s->b    = xmalloc(1024 * sizeof(float));
    s->c    = xmalloc(1024 * sizeof(float));
    s->temp = xmalloc(2048 * sizeof(float));
}

static void matmul_scratch_free(matmul_scratch *s) {
    if (!s) return;
    free(s->temp);
    free(s->c);
    free(s->b);
    free(s->a);
    memset(s, 0, sizeof(*s));
}

要点:

  • xmalloc 确保分配失败时退出
  • 释放后用 memset 清零,防止使用已释放的指针
  • 释放顺序和分配顺序相反(虽然对 free 来说不必须,但这是好习惯)

今日学习检查清单

  • 能解释 xmalloc/xcalloc/xrealloc 的作用和为什么没有 xfree
  • 能说出 mmap 相比 fread 的三个优势
  • 能解释 MAP_SHARED 和 MAP_PRIVATE 的区别以及 ds4.c 的选择原因
  • 理解为什么 ds4.c 用 malloc+memset 代替 calloc
  • 理解 posix_madvise(MADV_WILLNEED) 的作用
  • 能解释 __thread 线程局部存储的工作原理
  • 能描述模型加载的完整调用链(open → mmap → parse → bind)
  • 理解预分配 scratch buffer 模式的好处
  • 能解释 volatile 在 weight warming 中的作用

延伸挑战

挑战 1(中级):对比 mmap 和 read 的加载时间

写一个小程序,分别用 freadmmap 读取一个 1GB 的文件,对比首次访问的时间差异。用 clock_gettime(CLOCK_MONOTONIC) 计时。

挑战 2(高级):分析 scratch buffer 的内存布局

阅读 ds4.c 中 scratch buffer 的分配代码,画出完整的内存布局图(包含每个子缓冲区的名称、大小和偏移)。计算 43 层总共需要多少 scratch 空间。

3. GGUF 二进制格式

练习 1:计算量化块大小

题目:给定以下结构体定义,手动计算 sizeof 每个结构体(考虑对齐填充):

c
typedef struct {
    float   d;           // 4 字节
    int8_t  qs[256];     // 256 字节
    int16_t bsums[16];   // 32 字节
} block_q8_K;

提示:float 是 4 字节对齐,int16_t 是 2 字节对齐,int8_t 是 1 字节对齐。

参考答案(先自己尝试,再展开查看)
偏移 0:   d (float, 4 字节)
偏移 4:   qs[0..255] (int8_t, 256 字节)
偏移 260: bsums[0..15] (int16_t, 32 字节)
          bsums 需要 2 字节对齐,260 是偶数 ✓
偏移 292: 结束

sizeof(block_q8_K) = 292 字节 ✓(与 static_assert 一致)

没有填充!因为字段顺序排列得当。


练习 2:读取 GGUF 头部

题目:假设一个 GGUF 文件的头部 24 字节(十六进制)如下:

47 55 47 46  03 00 00 00  F0 01 00 00 00 00 00 00  60 00 00 00 00 00 00 00

请解析出 magic、version、n_tensors、n_kv 四个字段(小端序)。

参考答案(先自己尝试,再展开查看)
magic:     47 55 47 46 → 0x46554747 → "GGUF" ✓
version:   03 00 00 00 → 3
n_tensors: F0 01 00 00 00 00 00 00 → 0x01F0 = 496 个张量
n_kv:      60 00 00 00 00 00 00 00 → 0x60 = 96 个元数据 KV 对

小端序:低位字节在前。03 00 00 00 = 0x00000003 = 3。


练习 3:理解 cursor_read

题目cursor_read 函数实现如下:

c
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;
}

请回答:

  1. 如果 c->pos = 100, c->size = 200, n = 150,会发生什么?
  2. 为什么用 memcpy 而不是直接赋值(如 *(uint32_t*)dst = ...)?
  3. cursor_string 中的字符串存储在哪里?需要 free 吗?
参考答案(先自己尝试,再展开查看)
  1. cursor_has(150) 检查 150 > 200 || 100 > 200 - 150,即 150 > 200 || 100 > 50,第二个条件为真,返回 false。这是边界检查,防止越界读取。
  2. 直接赋值(*(uint32_t*)ptr = value)可能在某些架构上触发未对齐访问(alignment fault)。memcpy 不要求对齐,总是安全的。编译器通常会把小量 memcpy 优化为一条 load 指令。
  3. cursor_string 返回的字符串直接指向 mmap 内存(s->ptr = c->base + c->pos),不需要 free。mmap 内存在 model_close 时用 munmap 整体释放。

练习 4:static_assert 原理

题目DS4_STATIC_ASSERT 定义为:

c
#define DS4_STATIC_ASSERT(name, cond) typedef char name[(cond) ? 1 : -1]

请回答:

  1. 为什么 (cond) ? 1 : -1 能实现编译时检查?
  2. 如果写 DS4_STATIC_ASSERT(foo, sizeof(int) == 8) 在 32 位系统上,会发生什么?
  3. 为什么不直接用 C11 的 _Static_assert
参考答案(先自己尝试,再展开查看)
  1. C 标准不允许数组大小为负数。当 cond 为假时,数组大小为 -1,编译器会报错(如 "size of array 'foo' is negative")。当 cond 为真,数组大小为 1,正常通过。由于 cond 必须是编译时常量表达式,这个检查在编译时完成。
  2. sizeof(int) 在 32 位系统上是 4,4 == 8 为假,数组大小为 -1,编译报错。
  3. _Static_assert 是 C11 引入的,而 ds4.c 用 -std=c99 编译。C99 没有标准的 static assert,所以用这个数组技巧。此外 _Static_assert 的错误信息更友好(可以指定消息字符串),但数组技巧在所有 C 编译器上都可用。

练习 5:理解模型验证

题目config_validate_model 检查 30 多个参数。假设你有一个 GGUF 文件,它的 embedding_length 是 8192 而不是 4096。请描述从文件打开到错误报告的完整过程。

参考答案(先自己尝试,再展开查看)
1. model_open() 打开文件,mmap 成功
2. parse_metadata() 读取所有 KV 对,包括 "deepseek4.embedding_length" = 8192
3. parse_tensors() 读取张量目录
4. ds4_engine_open() 调用 config_validate_model()
5. config_validate_model() 中:
   a. required_u32(m, "deepseek4.embedding_length") → 8192
   b. config_expect_u32("embedding_length", 8192, DS4_N_EMBD=4096)
   c. 8192 != 4096 → 打印:
      "ds4: expected embedding_length=4096 for DeepSeek4 Flash, got 8192"
   d. exit(1) — 程序终止

这种"早失败"设计确保不兼容的模型文件不会导致后续推理产生无意义的输出。


今日学习检查清单

  • 能画出 GGUF 文件的字节布局(头部 → 元数据 → 张量目录 → 张量数据)
  • 理解 cursor 模式的三个操作:has / read / skip
  • 能手动计算简单结构体的 sizeof(考虑对齐填充)
  • 能解释 static_assert 的原理
  • 理解为什么 cursor_string 返回的字符串不需要 free
  • 理解小端序的含义以及 ds4.c 为什么不做字节序转换
  • 能解释 parse_metadata 的懒加载策略
  • 能解释 parse_tensors 中为什么要两步解析(先读信息再计算偏移)
  • 能说出 Q2_K 和 Q4_K 量化块的大小和每权重位数
  • 理解"早失败"验证原则的意义

延伸挑战

挑战 1(中级):写一个最小 GGUF 解析器

用 C 写一个独立程序,接受 GGUF 文件路径,打印:magic number、version、张量数量、前 5 条元数据 KV。不使用 ds4.c 的代码,自己实现游标模式。

挑战 2(高级):量化格式对比

xxdhexdump 打开模型的 GGUF 文件,定位到张量数据区域。对比同一个权重在 Q2_K 和 Q4_K 格式下的二进制布局差异,解释为什么 Q4_K 的精度更高。