跳转至

Part 1 阅读材料:理解 LLM 推理生命周期

阅读目标

  • 作为 Hw2 与实验二的配套阅读材料,帮助你从系统实现的角度理解一次 LLM 推理的完整生命周期;
  • 建立“程序读了什么、算了什么、输出了什么”这条主线,而不是只停留在名词记忆;
  • 理解 Model Loading TimeTTFTTPOTPrefillDecodeKV Cache、冷启动之间的关系;
  • 为完成 Hw2 中与性能测量、资源瓶颈分析相关的书面作业,以及实验二中的后续代码阅读与实现做好准备。

阅读前提

  • 已具备基本的 C 语言代码阅读能力,能看懂函数调用、结构体和简单循环;
  • 已理解用户态 / 内核态、系统调用、进程创建与等待等基础操作系统概念;
  • 下文中出现的 tools/user/kernel/docs/ 等路径,均默认是相对于课程代码仓库根目录而言;
  • 若你此前已经完成 Lab0Lab1 的基础环境配置和代码阅读,本页内容会更容易理解。

与实验二的关系

本文是 Hw2 与实验二的配套阅读材料。本文不以完整展开 Transformer 的数学细节为目标,而是从系统实现的角度说明课程代码中的一次 LLM 推理过程。

阅读主线

阅读本页时,请始终围绕下面三个问题:

  • 程序读了什么?
  • 程序算了什么?
  • 程序输出了什么?

注意事项

当前课程仓库中的实现是一个教学版推理服务,而不是通用的LLM推理系统。

  • 基础实验默认以 llmrun_smol 为主;
  • llmrun_qwen 主要是附加实验相关。

1. 什么是大语言模型(LLM)与 Transformer 架构?

在讨论底层系统调用、内存分配与文件装载之前,有必要先给出大语言模型(Large Language Model, LLM)在系统实现层面的基本定义。对于本实验而言,重点不在于讨论模型是否“理解”语言,而在于明确它接收什么输入、执行什么计算以及产生什么输出。

1.1 大语言模型(LLM)的数学本质:自回归预测

从计算机系统的角度看,LLM 可以抽象为一个规模很大的参数化函数。它的基本任务是自回归预测(Autoregressive Prediction)。 给定一段由离散整数表示的历史上下文,即一串 token id,模型通过多层线性变换、非线性变换与归一化运算,输出下一个位置上的概率分布。系统再依据这一分布选取一个新的 token,将其追加到序列末尾,并继续进行下一轮预测。 因此,从实现角度看,LLM 推理的核心过程可以概括为:输入历史 token 序列,计算下一个 token 的分布,并迭代生成后续结果。

1.2 Transformer:现代 LLM 的统一底层引擎

实现上述预测机制的模型架构曾有多种,例如早期的 RNNLSTM。但在当前主流生成式大模型中,更常见的选择是 Transformer 架构。本实验中使用的 SmolQwen 模型也属于这一范畴。

Transformer 架构(如图1所示)最初由 Google 于 2017 年提出。为了适应自回归生成任务,现代生成式大模型通常采用仅保留 Decoder(解码器)的变体。 因此,当我们讨论 LLM 推理过程中的计算密度、访存压力、I/O 成本和缓存组织时,本质上是在分析 Transformer Decoder 在具体软件栈与硬件环境中的执行特征。

Transformer 推理主链路示意图

图 1:Transformer 推理主链路示意图

1.3 系统视角下的 Transformer 计算图

从系统工程的视角看,Transformer 可以理解为一个高度结构化的静态计算图(Computational Graph)。输入数据在这张计算图中按照固定顺序流经各个算子,因此其计算与存储访问模式具有较强的规律性。

正因为其执行路径相对固定,我们可以较为清晰地分析不同阶段的资源消耗特征,并将这些特征与课程代码中的实现对应起来。

2. LLM 推理过程介绍

在阅读运行时代码之前,还需要进一步明确模型处理对象的表示形式以及基本处理步骤。采用这一视角,有助于把“输入数据、中间状态、输出结果”对应到具体文件、缓冲区和函数调用。

从抽象流程看,一次推理大致可以表示为:

输入文本
-> Token 序列
-> Embedding 向量
-> 多层 Transformer Block
-> 词表打分
-> 下一个 Token

2.1 文本是如何变成机器可计算数据的

模型不能直接以字符串作为计算对象。对于机器而言,文本首先会被切分为若干 token,每个 token 对应词表中的一个整数编号,即 token id

随后,模型通过嵌入表将这些离散编号映射为定长向量,这一步称为 embedding。因此,Transformer 直接处理的并不是原始文本本身,而是:

  • 一串 token id
  • 一串由 token id 查表得到的向量。

从系统角度看,这一步完成了从文本表示到数值表示的转换。

2.2 Attention 层在做什么

Attention 的主要作用是根据当前位置与历史位置之间的相关性,对上下文信息进行加权聚合。 在计算当前 token 的表示时,模型不仅使用当前位置的向量,还会访问先前各个位置的表示,并根据相关性选择性地整合这些历史信息。 从系统实现的角度看,Attention 更值得关注的是它的数据依赖关系:

  • 它必须访问历史位置的数据;
  • 它必须比较当前信息与历史信息;
  • 它必须把取回的结果重新组合成当前位置的输出。

因此,可以先将 Attention 理解为:读取历史上下文状态,并据此构造当前位置的上下文表示。

2.3 FFN 层在做什么

FFN(前馈网络)负责对当前位置的向量表示进行进一步变换。 与 Attention 不同,FFN 不再沿序列维度访问历史位置,而是仅针对当前位置的表示执行逐位置的非线性变换。它的典型作用包括:

  • 重组当前位置的特征;
  • 放大有用信息;
  • 抑制无用信息;
  • 为下一层提供更合适的表示。

因此,FFN 可以概括为:对当前位置的表示进行非线性特征变换。

2.4 从整体上看,LLM 在做什么

综合上述步骤,LLM 在推理阶段的宏观行为可以概括为:

  • 输入一串 token
  • token 变成向量;
  • 经过多层 Attention + FFN 反复处理;
  • 对整个词表打分;
  • 选出一个“下一个 token”;
  • 把这个 token 接到序列末尾,再继续下一轮。

因此,本实验中最需要把握的基本结论是:

LLM 推理的核心任务,是在给定历史 token 序列的条件下预测下一个 token

3. LLM 推理的生命周期:Tokenization、Prefill 与 Decode

理解了宏观流程以后,下一步要建立“生命周期”视角。一次完整的推理过程,通常可以分成下面几个阶段:

  1. Tokenization
  2. Prefill
  3. Decode
  4. Detokenization

课程代码中的边界划分

在当前课程代码中,Guest 运行时并不负责完整的 tokenizationdetokenization。这两部分被有意放在 Guest runtime 之外,以便突出系统路径本身的实现逻辑。

3.1 Tokenization 与 Detokenization 在当前课程代码中的位置

在完整的工业级推理系统中,用户输入的文本通常首先由 tokenizer 编码为 token id 序列,模型生成的 token id 再由 detokenizer 还原为自然语言字符串。

但在当前 2026Spring-Kernel-Base 中,Guest 侧边界又进一步做了简化:

  • 仓库直接提供教师预导出的模型资产包,如 models/smol.zipmodels/qwen.zip
  • Makefile 会将这些资产解压并打包进 Guest 文件系统中的 /AI/SMOL/AI/QWEN
  • INFO.TXT 会保留面向人类阅读的元数据,例如示例 prompt_tokensexpected_tokensdecoded_expected
  • Guest 侧的 llmrun_smol / llmrun_qwen 接口直接接收 token id,来源是命令行参数或 --stdin 输入,而不是原始文本。

这意味着:当前 Guest 侧的核心任务不是做文本分词,而是装载模型资产、读取 token id 请求并完成前向计算。

3.2 Prefill:先把历史上下文状态建立起来

Prefill 阶段处理的是整段 prompt。它的关键任务在于:

  • 并行处理 'prompt' 中的所有 token
  • 生成第一个输出 token
  • 为每一层建立可复用的历史状态;
  • 把这些状态写进 KV Cache ,避免后续冗余计算。

因此,Prefill 的作用可以概括为:

将原始 prompt 转换为后续生成阶段可以直接复用的上下文状态。

3.3 Decode:每次只生成一个新 token

Prefill 完成后,系统进入 Decode 阶段。这个阶段的特点是:

  • 根据请求的所有历史缓存,生成一个新的 token
  • 下一轮计算仍然要依赖之前所有历史状态。

虽然每一步通常只新增一个 token,但随着序列长度增加,每一步需要访问的历史 K/V 状态也会增加,因此访存成本会逐步上升。

3.4 为什么 Prefill 更像 Compute-bound,Decode 更像 Memory-bound

从系统资源消耗模式看,这两个阶段并不相同:

阶段 主要任务 典型特征 更容易遇到的瓶颈
Prefill 处理整段 prompt,建立 KV Cache 层计算密集,权重复用高 Compute-bound
Decode 每次生成一个新 token,并读取历史状态 新计算少,但历史缓存读取越来越重 Memory-bound

其原因可以概括为:

  • Prefill 需要对整段输入执行完整的层计算,因此主要受计算吞吐限制;
  • Decode 每一步新增计算较少,但需要持续读取越来越长的历史状态,因此更容易受到内存访问与数据搬运的限制。

4. 回到当前 Kernel Base:输入、计算与输出的实现分工

在概念层面完成基本划分之后,下一步需要回到当前我们的实验仓库本身。接下来,我们将沿着“程序读了什么、算了什么、输出了什么”这条主线,来分析当前代码中一次 LLM 推理的完整生命周期。

4.1 先看整条调用链

在当前仓库中,一次最小 LLM 调用链可以概括为:

models/smol.zip / models/qwen.zip
  -> Makefile: smol-assets / qwen-assets
  -> models/SMOL / models/QWEN
  -> Makefile: fsimg-smol / fsimg-qwen / fsimg-llm
  -> tools/mkfsimg.py --add-dir AI/SMOL=... / AI/QWEN=...
  -> QEMU 中的 /AI/SMOL 或 /AI/QWEN
  -> sh 启动 llmrun_smol 或 llmrun_qwen
  -> chdir("/AI/SMOL") / chdir("/AI/QWEN")
  -> llm_runtime_init()
  -> 读取 CFG.BIN / EMB.BIN / NRM.BIN / Lxx.BIN / ROP.BIN (/LTY.BIN)
  -> llm_session_run()
  -> llm_drive_decode()
  -> token_forward()
  -> stdout 输出生成出的 token id

如果进一步阅读进阶实验,则 Guest 侧主程序会切换为 user/llmrun_qwen.c;它复用同一套共享 runtime 框架,但会根据 LTY.BIN 区分不同层类型。

默认 fsimg 不会自动带上模型资产

当前 make fsimg 只会打包用户程序本身;若希望 Guest 真正看到 /AI/SMOL/AI/QWEN,需要使用 make fsimg-smolmake fsimg-qwenmake fsimg-llm 或对应的 qemu-smolqemu-qwenqemu-llm 目标。

4.2 当前 Guest 内核在做什么

由于当前 base 中没有专门的 ai_service,内核扮演的是通用操作系统支撑层,而不是 LLM 业务编排层。llmrun_* 依赖内核提供的主要能力包括:

  • exec / wait / shell 启动流程:让 sh 能像启动普通用户程序一样启动 llmrun_*
  • chdir / stat / open / read:让用户态程序能够进入 /AI/... 目录并读取模型资产;
  • sbrk:为权重、cache 和 workspace 申请堆内存;
  • uptime:记录一次前向的大致 tick 开销;
  • 控制台输出:把生成结果打印到标准输出。

因此,在当前实现里,内核并不理解 prompt 的语义,也不解析模型格式,更不会把结果写到某个专门的 AI 输出文件中。所有与 LLM 相关的特定逻辑,都放在用户态 llmrun_* 和共享 runtime 中。

4.3 Guest 用户态 runtime 读了什么

user/llmrun_smol.cuser/llmrun_qwen.c 的主程序本身都比较薄。真正的共享装载与执行框架主要位于:

  • user/llmrun_support.h
  • user/llmrun_support.c

其中,session_init() 先通过 chdir() 进入资产目录,再调用 llm_runtime_init()。后者会依次完成:

  • load_model_cfg():读取并校验 CFG.BIN
  • load_layer_kinds():在 Qwen 路径下解析 LTY.BIN
  • load_file("EMB.BIN"):装入 embedding;
  • load_file("NRM.BIN"):装入最终归一化参数;
  • 循环读取 Lxx.BIN,再通过 parse_layer_blob() 解析每层权重;
  • load_file("ROP.BIN"):装入 RoPE 表;
  • rt->seq 申请运行期序列缓冲区。

在此之后,程序还会继续分配:

  • K/V cache
  • Qwen 额外需要的 linear_conv_cachelinear_state_cache
  • 一次前向所需的 workspace。

当前 runtime 的装载策略依旧较为直接:通过 open + read_full + xmalloc 将资产整体读入用户态堆内存。它的优点是实现简单、透明、便于调试;代价则是冷启动 I/O 成本明显,而且权重与 cache 都驻留在单个用户进程地址空间中。

如果你想打印 Model Loading Time,应该看哪里

对当前代码框架而言,最自然的埋点位置有两种:

  • 若你想测“模型资产装入内存”本身的时间,建议围绕 llm_runtime_init() 附近打印,因为 CFG.BINEMB.BINNRM.BINLxx.BINROP.BIN 等加载逻辑都集中在这里;
  • 若你想测“程序启动后,为一次可运行会话完成初始化”的时间,建议围绕 session_init() 附近打印,因为它除了调用 llm_runtime_init(),还会继续分配 K/V cache、workspace,以及 Qwen 额外需要的 cache。

简单理解就是:

  • llm_runtime_init() 更接近纯粹的 Model Loading Time
  • session_init() 更接近“运行前准备完成”为止的总初始化时间。

需要注意的是,当前请求 token 不来自某个固定输入文件llmrun_* 接收的输入来自:

  • 命令行参数,例如 llmrun_smol --predict 1 504 3575 282 4649 314
  • --stdin 服务模式下的一行请求,格式为 <predict> <token0> <token1> ...

后续如果在 Part 2 中讨论“为什么要进一步引入 ai_submit、后台 worker 与请求队列”,默认已经以本节为前提,不再重复介绍 session_init() 或各类资产文件的加载顺序。

4.4 token_forward() 中究竟算了什么

在基础实验的 user/llmrun_smol.c 中,核心前向函数是 token_forward()。若只保留主干路径,它的工作可以概括为:

读 embedding -> 跑层计算 -> 更新状态 -> 扫描词表 -> 选出下一个 token

更具体地说,其主要流程是:

  1. rt->seq[pos] 取出当前 token id
  2. EMB.BIN 对应的量化 embedding 表中取出这一行向量;
  3. Smol 路径,逐层执行 llm_apply_full_layer()llm_apply_ffn()
  4. Qwen 路径,按 LTY.BIN 指定的层类型在 llm_apply_full_layer()apply_linear_layer() 之间切换,再执行 FFN
  5. 把新的状态写入 K/V cache,并按需读取历史状态;
  6. 做最终归一化;
  7. 通过 argmax_embed() 在 embedding 矩阵上扫描整个词表,得到下一个 token id

从系统角度看,token_forward() 本质上是在反复执行以下几类操作:

  • 读 embedding;
  • 读层权重;
  • 读写 cache;
  • 做矩阵乘加与归一化;
  • 生成下一个 token id

4.5 Prefill 和 Decode 在代码中的对应位置

在当前代码中,PrefillDecode 并没有被拆成两个完全独立的主循环,而是统一放在 llm_drive_decode() 中:

for (int pos = 0; pos < total_steps; pos++) {
    int next = token_forward(rt, kcache, vcache, linear_conv_cache, linear_state_cache, pos, ws);
    if (pos >= token_count - 1) {
        int gen_idx = pos - (token_count - 1);
        rt->seq[token_count + gen_idx] = (uint32)next;
    }
}

这里需要注意的是:

  • pos < token_count - 1 时,系统还在消费用户提供的输入 token,并为整段上下文建立历史状态,这一段就是 Prefill
  • pos >= token_count - 1 时,系统开始把 next token 回写到 rt->seq,这一段就是 Decode

因此,PrefillDecode 的区别不在于是否被写成两个独立函数,而在于当前迭代是在“消化已有输入”,还是在“追加新生成结果”。

如果你想打印 TTFTTPOT,应该看哪里

当前代码里,最适合做 TTFT / TPOT 埋点的位置就是 llm_drive_decode() 附近,而不是更底层的矩阵乘函数里。原因很简单:这里同时看得到“本次请求何时开始真正进入前向循环”以及“第几个生成 token 何时写回 rt->seq”。

你可以按下面的思路理解:

  • TTFT:在 llm_drive_decode() 开始处记录起点时间;当分支 if (pos >= token_count - 1) 第一次成立、也就是第一个生成 token 即将写回 rt->seq 时打印时间差;
  • TPOT:也放在同一个分支附近观察。最直接的办法是每次生成一个 token 就打印一次与上一个生成 token 的时间差;如果你只想看平均值,也可以在第一个生成 token 之后开始累计,到循环结束后再除以后续生成 token 的数量。

如果你希望把“请求校验 + cache 清空”这部分也算进首 token 延迟,那么起点可以再往外挪到 llm_session_run() 附近;如果你只想突出 Prefill / Decode 主循环本身,那么直接以 llm_drive_decode() 作为起点会更清晰。

4.6 最终输出了什么

在当前实验中,Guest llmrun 的直接输出并不是自然语言字符串,也不是写入某个 /AI.<pid>.OUT 文件,而是直接打印到标准输出的一行文本。其内容是:

  • 生成出的 token id
  • 多个 token 之间用空格分隔;
  • printf("%s\\n", result) 在末尾补一个换行。

这段输出由 llm_format_generated_tokens() 负责格式化,因此从系统边界的角度看,更准确的描述是:

当前 Guest runtime 输出的是十进制 token id 序列,而不是已经 detokenize 完成的自然语言句子。

面向人类阅读的文字说明,仍然主要保留在宿主机准备的 INFO.TXT 中;若需要把生成结果还原为自然语言,还需要在 Guest 之外再做一次 detokenization。