整体架构与 Dual-AR 数据流
本章先不深入任何一个函数,而是把 s2.cpp 运行时的对象关系、张量形状和一帧数据的完整旅程讲清楚。后面所有章节都是这张图的局部放大。
1. 运行时对象关系
核心 C++ 类只有四个,全部位于命名空间 s2:
Pipeline(s2_pipeline.cpp)
├── Tokenizer s2_tokenizer.cpp Qwen3 BPE,读 tokenizer.json
├── SlowARModel s2_model.cpp 权重、两套调度器、KV cache
│ ├── hparams_ 结构超参(维度/层数/码本…)
│ ├── weights_.ctx_w 只存元数据的 GGUF 上下文(无数据)
│ ├── sched_ Slow-AR 的后端调度器(图容量 32768)
│ ├── fast_sched_ Fast-AR 的后端调度器(图容量 16384)
│ ├── memory_k_ / memory_v_ F16 KV cache(预分配)
│ └── 后端 buffer 向量 GPU/CPU 权重各若干块
└── AudioCodec s2_codec.cpp 卷积 codec + VQ 主机缓存
├── Impl(pImpl) 全部 codec 参数与权重张量
├── semantic_vq / residual_vq 预归一化的 VQ 码本(CPU 侧缓存)
└── decode_cache 按帧数缓存的解码图
外部消费者有三个:CLI(main.cpp)、HTTP 服务(s2_server.cpp)、C 导出层(s2_export_api.cpp)。它们都只跟 Pipeline 打交道,不直接操作模型。
generate()(s2_generate.cpp:11)是夹在 Pipeline 和模型之间的无状态算法函数:输入模型、生成参数和 PromptTensor,驱动双 AR 循环,输出整数码矩阵。
2. 关键维度一览
这些数字来自 GGUF 元数据,缺失时由 s2_model.cpp:258-288 与 s2_codec.cpp:699-703 给出默认值,是理解后续形状的前提:
| 维度 | 值 | 含义 |
|---|---|---|
vocab_size | 155776 | Qwen3 词表大小 |
semantic_begin/end | 151678 / 155773 | 语义 token 在词表中的区间(共 4096 个) |
embedding_length | 2560 | Slow-AR 隐状态维度 |
block_count | 36 | Slow-AR 层数 |
feed_forward_length | 9728 | SwiGLU FFN 中间维度 |
head_count / head_count_kv | 32 / 8 | GQA:32 查询头,8 KV 头 |
head_dim | 80(2560/32) | 无 QK-norm 时;有 q_norm 张量时以其形状为准 |
| RoPE base | 1 000 000 | Qwen3 长上下文设定 |
codebook_size | 4096 | 每个码本的条目数 |
num_codebooks | 10 | 1 个语义码本 + 9 个残差码本 |
| Fast-AR | 4 层,窗口 ≤11,head_dim 128 | 逐帧码本解码器 |
| 采样率 / hop | 44100 Hz / 512 | codec 帧率 |
samples_per_frame | 2048 | 一帧 = hop × ∏下采样倍数 = 512×4 |
注意一帧的两个含义:codec 帧 = 2048 个采样点(约 46.4 ms),而生成端的一帧(一个语义步)对应一个 codec 帧。samples_per_frame_ 的计算在 s2_codec.cpp:859-868。
3. 一帧数据的完整旅程
3.1 输入侧:多行 token 矩阵
S2 系列模型的输入不是普通语言模型的一维 token 序列,而是一个 (num_codebooks+1) × T 的矩阵,共 11 行:
- 第 0 行:语义流——文本 BPE token、特殊标记,以及参考音频段的语义码(码本索引 +
semantic_begin); - 第 1~10 行:码本流——每个位置放该码本的离散码(取值 0..4095),仅在”参考音频占位”段有值,其余位置填 0 并由语义掩码屏蔽。
这个矩阵由 build_prompt() 构造(详见第 09 章),在 s2_generate.cpp:39-44 转置成列优先布局后交给 prefill():
转置前(行优先):prompt.data[row * cols + pos]
转置后(列优先):prompt_tm[col * rows + row] → flat_tokens[t * codebook_dim + cb]
模型内部统一使用列优先,codebook_dim = num_codebooks + 1 = 11(s2_model.cpp:799)。
3.2 Slow-AR:一个语义 token,外加 10 个码本嵌入
每个位置 t 的输入向量这样构成(s2_model.cpp:941-959):
x_t = sem_scale · embedding[ semantic_id_t ] (第 0 行查主嵌入表)
+ Σ_{cb=1..10} sem_mask[t] · embedding_cb[ code_id_{t,cb} ] (码本行查各自的小嵌入表)
其中:
- 10 个码本共用一张
codebook_embeddings表(10×4096 行),码本 cb 的码 id 要加上偏移cb * codebook_size再查表(s2_model.cpp:903-921); sem_scale = 1/√11、attn_scale = 1/√head_dim(s2_model.cpp:890-891);- 在纯文本位置掩码为 0,码本项不贡献,只有语义嵌入——所以文本段的行为与普通语言模型一致。
36 层 Transformer(RMSNorm → GQA 注意力 → SwiGLU FFN)之后,取最后位置隐状态 hidden(2560 维),输出 logits 由 tied embedding 得到:logits = hidden × embeddings.weight^T(s2_model.cpp:1058)。
3.3 交接:一个语义步内的 10 次 Fast-AR
Slow-AR 每走一步,采样出一个语义 token main_token。它同时是本帧第 0 个码本的码:sem_code = main_token - semantic_begin。然后 Fast-AR 接手(s2_generate.cpp:127-143):
Fast-AR 输入 = [ Slow-AR 的 hidden(2560), ← 经 fast_project_in
本帧已生成的前缀码 embeddings(第 0 个=sem_code, 第1..k个…) ]
窗口长度 = 已生成码本数 + 1(≤11),4 层小 Transformer 全量重算(无 KV cache)
输出 = codebook_size=4096 个 logits → 采样得到下一个码本的码
重复 9 次,凑齐第 1~9 个残差码本。于是一个语义步产出 10 个码:[sem_code, residual_1, …, residual_9]。
3.4 回灌与前进
一帧 10 码齐了之后,这 11 个值(语义 token + 10 个码本码)作为下一位置的完整输入,调用一次 Slow-AR 的 step()(s2_generate.cpp:165-174),KV cache 保留全部历史,进入下一帧。如此循环直到采到 <|im_end|> 或达到 max_new_tokens。
┌─────────────────────── 帧 n ───────────────────────┐
文本/前缀 ──Slow-AR(step)──► main_token ──► Fast-AR×9 ──► 10 码
▲ │
└──── 11 值回灌 + KV 增长 ◄──┘
3.5 输出侧:码矩阵 → 波形
生成结果 codes 按码本行存放:codes[cb * n_frames + f](s2_generate.cpp:145-147)。codec 解码时:
10 × T 离散码
│ quantizer decode:语义VQ(quantizers.0) + Σ 残差VQ(quantizers.i),逐级相加 + 上采样
▼
latent(卷积序列)
│ waveform decoder:ConvNeXt/残差块上采样,tanh 限幅
▼
T × 2048 个 44.1kHz 采样点
解码图的拼装在 build_decode_codes_stage_backend(),完整 decode() 在 s2_codec.cpp:1297。
4. 两种”图”的节奏
ggml 的执行模型是”建图 → 调度 → 计算”。s2.cpp 针对不同阶段用了不同的缓存策略,这是理解性能特性的关键:
| 路径 | 图的生命周期 | 上下文预算 | 说明 |
|---|---|---|---|
Slow-AR step() | 复用(eval_cached) | 约 10 MB 可复用 ctx | 每帧形状不变(单步 11 行),建一次反复跑 |
Slow-AR prefill() | 每次新建,可分块 | 随 chunk 大小 | CUDA 强制 1 token/块;其他后端自适应 8~64 |
Fast-AR fast_decode() | 每次新建 | 约 8 MB | 窗口长度随码本序增长(1→10),图必变;无 KV cache |
| Codec GPU 解码 | 按 n_frames 缓存 | 256 MB | fused 图,失败的帧数拉黑 |
| Codec CPU 解码 | 两张图分别建 | 96 + 128 MB | 量化器解码、波形解码分离 |
| Codec 编码 | 两张图 | 128 + 96 MB | 编码器图 + RVQ 前处理图 |
调度器本身是成员级单例:Slow-AR sched_ 图容量 32768、Fast-AR fast_sched_ 16384(s2_model.cpp:595-602)。
5. 后端与放置策略概览
后端枚举定义在 s2_backend.h:CPU = -1、Vulkan = 0、CUDA = 1、Metal = 2。权重放置有一条容易被忽略但很重要的策略线:
- CUDA:嵌入表(
embeddings/codebook_embeddings/fast_embeddings)与最终 norm 强制留在 CPU(s2_model.cpp:486 起、README.md:45)。原因是 K 量化嵌入的 CUDAget_rows不被支持,且曾经导致声音克隆 prefill 不稳定;语义 prefill 也因此强制逐 token(s2_model.cpp:37)。 - Vulkan / Metal:非全卸载时同样保留嵌入;当所有 36 层都上 GPU(
full_model_offload)时,整个模型都在 GPU(s2_model.cpp:481)。 - 当驱动对单块 buffer 有大小上限时,权重会被自动切成多块放置(allocate_weight_buffers()),而不是申请一块巨型连续显存。
- codec 默认
--codec-auto:用 8 帧零码在 CPU/GPU 上各跑一次,只有 GPU 快于 0.90× CPU 时间才用 GPU;GPU 初始化或分配失败一律退回 CPU(详见第 10 章)。
6. 小结
s2.cpp 的架构可以浓缩成三句话:
- 矩阵进、矩阵出:11 行 token 矩阵是 S2 多码本建模的统一接口,文本与参考音频在同一布局下共存;
- 一大一小两个 AR:Slow-AR 带 KV cache 跨步前进、每步交接一个隐状态;Fast-AR 无状态地在每步内全量重算、逐码本解码;
- 一切皆 ggml 图:生成路径复用图、prefill 分块建图、codec 按帧数缓存图——图的缓存粒度基本决定了各阶段的性能行为。
第 03 章先落地到仓库本身:代码怎么组织、怎么构建、为什么构建时要给 ggml 打补丁。
关键文件
| 文件 | 职责 |
|---|---|
| include/s2_model.h | SlowARModel 类、hparams、权重结构 |
| include/s2_pipeline.h | Pipeline 与 PipelineParams |
| src/s2_prompt.cpp | 11 行 PromptTensor 构造 |
| src/s2_generate.cpp | 双 AR 交接的主循环 |
| src/s2_codec.cpp | 码 → 波形解码 |