跳转至

Part 2 阅读材料:从 llmrun --stdin 到“请求队列 + 后台 worker”

阅读目标

  • 基于当前作业配套代码中的 llmrunpipe 与进程机制,理解为什么 AI service 很自然会从“用户态直跑”演化成“请求队列 + 后台 worker”;
  • 能够说明 producer、consumer、请求槽、队列、后台 worker、wait/query 分别承担什么职责;
  • 为完成 Hw2 中与异步接口、阻塞点和结构演化相关的书面分析题做好准备。

阅读范围

本部分主要围绕以下文件展开:

  • user/llmrun_smol.c
  • user/llmrun_qwen.c
  • user/llmrun_support.h
  • user/llmrun_support.c
  • kernel/core/pipe.c
  • user/sh.c

为了完成 Part 2 题目,下文会在这些代码的基础上再做一层合理抽象,用 ai_submitai_waitai_query 作为说明记号。它们在本页中是帮助你组织分析的接口,不要求与你后续实验中的最终实现完全一致。

阅读建议

回答 Part 2 时,只需要抓住“为什么会出现队列、worker、阻塞点和异步接口”这条主线,不需要把本页所有示意结构都原样搬进答案。

与 Part 1 阅读材料的衔接

本页默认你已经阅读过 Part 1 阅读材料的 4.4 和 4.5。那两节已经解释了当前 base 中:

  • 内核只提供通用 OS 支撑,而不是专门的 ai_service
  • llmrun_* 如何装载资产、建立 runtime、接收命令行或 --stdin 输入

因此,本页不再重复展开 session_init() 的装载细节,而是只抓住这些事实继续往前推:为什么这条路径会自然演化成“请求队列 + 后台 worker”。

1. 先看当前的“用户态直跑”路径

在 Part 1 阅读材料的 4.4 / 4.5 基础上,可以把当前最简单的一次推理路径概括成:

调用进程
  -> fork / exec llmrun
  -> llmrun 完成初始化
  -> 处理一次请求
  -> 打印结果
  -> 退出

这里的“完成初始化”已经包含前一份阅读材料里介绍过的内容,例如:

  • 进入 /AI/SMOL/AI/QWEN
  • 装载 CFG.BINEMB.BINNRM.BINLxx.BINROP.BIN 等资产;
  • 分配 K/V cache、workspace 以及其他运行期缓冲区。

本页不再重复展开这些步骤,而是只保留一个与 Part 2 有关的结论:

当前 base 的确存在一条“用户态直跑”的最小路径,而后续异步服务结构正是从这条路径继续抽象出来的。


2. 为什么 --stdin 已经暴露了“后台 worker”的雏形

在不重复前面装载细节的前提下,--stdin 模式只需要抓住下面两件事:

  1. 初始化很重 资产装载与 cache / workspace 分配都在进程启动阶段完成,因此一次 exec llmrun_* 伴随着明显冷启动开销。
  2. 同一进程可以连续处理多次请求 --stdin 模式不是“处理一次就退出”,而是读取一行请求、执行一次、输出一次结果,然后继续等待下一行输入。

把这两点放在一起,就会得到本页最重要的结构性直觉:

如果一个后端初始化成本高、而且可以在同一进程里连续处理多次请求,那么它就天然适合被组织成长期驻留的 worker。


3. 为什么不能长期停留在“同步直跑”

如果把 AI 推理始终做成“谁调用,谁同步等到结束”的一次性路径,那么服务结构会面临至少三个问题。

3.1 调用者会被整段计算路径长期占住

同步路径通常意味着:

提交请求
  -> 装载模型
  -> 执行推理
  -> 回传结果
  -> 返回

调用者从头到尾都要被这条长路径绑住,中间既不能快速返回,也不方便把“排队”和“执行”两个阶段分开表达。

3.2 冷启动成本会被重复支付

如果每条请求都重新:

fork -> exec llmrun -> 装载资产 -> 处理一次 -> 退出

那么模型装载和缓存分配成本会被重复支付很多次。

--stdin 给出的另一种思路是:

fork -> exec llmrun --stdin -> 装载一次 -> 连续处理多次 -> 最后退出

这正是“复用”和“长驻”的价值所在。

3.3 同步结构很难表达排队与背压

一旦系统同时到来多个请求,就必须回答:

  • 谁先执行?
  • 请求放在哪里等?
  • 满了怎么办?
  • 结果何时能被调用者取回?

同步调用不擅长表达这些问题,而“请求槽 + 队列 + worker”正是更自然的系统答案。


4. 从当前路径抽象出异步 AI service

为了回答 Part 2 题目,可以把当前可见的 llmrun --stdin 路径进一步抽象成下面这组接口:

int ai_submit(const uint32 *tokens, int token_count, int predict_count);
int ai_wait(int reqid, char *out, int out_cap);
int ai_query(int reqid, struct ai_status *st);

一个合理的系统链路可以表示为:

调用进程
  -> ai_submit(...)
  -> 在请求槽中登记元数据
  -> 把请求槽编号压入循环队列
  -> 后台 worker/ai_daemon 取出请求
  -> 执行 llmrun 或复用长驻 runtime
  -> 写回结果与状态
  -> ai_wait(...) / ai_query(...)

这三类接口的职责可以先粗略理解成:

  • ai_submit 负责把一个请求放进系统,并尽快返回一个 reqid
  • ai_wait 负责等待某个具体请求完成,并在成功时取回结果
  • ai_query 负责非阻塞地观察这个请求当前处于什么状态

这一步抽象的关键

当前代码已经说明:请求可以被长期驻留的进程处理,输入可以通过字节流送达,结果可以晚于提交时刻才产生。把这三点合在一起,最自然的接口就不再是“同步直跑”,而是“submit + queue + worker + wait/query”。


5. 请求槽和循环队列分别负责什么

如果要把“排队”和“执行”分开,就至少需要两类对象:

  1. 请求槽数组
  2. 循环队列

下面是一种只用于理解结构的示意:

struct ai_request_preview {
    int reqid;
    int owner_pid;
    int state;
    int err;
    int result_len;
};

struct ai_service_preview {
    struct spinlock lock;
    int q[AI_NREQ];
    int qhead;
    int qtail;
    int qcount;
    int worker_pid;
    struct ai_request_preview reqs[AI_NREQ];
};

可以这样理解这两类对象的分工:

  • reqs[] 负责保存“某个请求是谁提交的、现在是什么状态、结果是否可取”等元数据
  • q[] 负责保存“哪些请求还在等待 worker 处理”
  • qhead / qtail / qcount 负责管理一个有界循环队列

因此:

  • 请求槽承担的是“请求对象本身”的存放;
  • 循环队列承担的是“等待执行顺序”的管理。

这正是 Part 2 任务 A 里要求你画出来的两个不同层次。


6. 把 pipe 类比到 AI service

kernel/core/pipe.c 是理解这套结构最好的参照物之一。它告诉你:

  • producer 可以把数据放进有界缓冲区;
  • consumer 可以从缓冲区取数据;
  • 缓冲区满时,producer 睡眠;
  • 缓冲区空时,consumer 睡眠。

把它类比到 AI service,可以得到下面这张映射表:

pipe 中的角色 AI service 中的角色
pipewrite ai_submit
piperead worker 从队列取请求
data[] 请求槽编号队列 q[]
写端 producer 调用进程
读端 consumer 后台 worker
缓冲区满则阻塞写者 队列满或无空槽时阻塞提交者
缓冲区空则阻塞读者 队列空时阻塞 worker

需要特别注意的一点是:

  • pipe 传的是字节流;
  • AI service 传的更像是“请求槽编号”或“请求对象引用”。

但它们的并发骨架是相同的,都是 producer-consumer。


7. 至少三类阻塞点是怎么出现的

一旦系统从“同步直跑”变成“异步提交 + 后台处理”,阻塞点就至少会分成下面三类。

阻塞者 它在等什么 谁负责唤醒 为什么会出现
ai_submit 调用者 等待空槽,或等待队列可继续入队 worker 完成旧请求后释放槽位,或从队列取走请求后腾出空间 系统资源是有界的,不能无限接收请求
后台 worker 等待队列非空 新的提交者把请求压入队列后唤醒 没有请求时,worker 不应忙等
ai_wait 调用者 等待某个具体请求变成完成或失败 worker 写回结果并更新状态后唤醒 结果不是提交瞬间就能拿到的

如果把它和 sleep() / wakeup() 一起看,会更清楚:

  • 有界资源会自然带来“等空位”;
  • 后台消费者会自然带来“等非空”;
  • 单个请求生命周期会自然带来“等完成”。

这正对应 Part 2 任务 B 中的三种典型方向:

  • 队列空,worker 无事可做
  • 槽位满,新的提交无法进入
  • 请求未完成,调用者在 wait 中等待

8. 为什么 AI 更适合异步

把 AI 推理改成“submit + 后台 worker + wait/query”,核心收益可以概括为下面五点:

  • 复用 模型权重、运行时缓冲区和长驻进程可以被多次请求共享
  • 解耦 提交请求和真正执行推理可以分成两个阶段,不必绑死在一次同步调用里
  • 排队 多个请求到来时,系统可以明确表达“谁先做、谁后做”
  • 背压 当槽位或队列满了,系统可以阻塞提交者,而不是无限膨胀内部状态
  • 长驻 重的初始化只做一次,冷启动成本可以被多个请求摊薄

因此,对 AI 这类“计算重、状态重、初始化重、执行时间长”的负载而言,异步结构往往比同步直跑更稳定,也更接近真实服务系统的组织方式。

读这页时最值得抓住的直觉

llmrun --stdin 告诉你:同一个进程可以连续处理多次请求。pipe 告诉你:有界缓冲区天然会带来 producer-consumer、睡眠与唤醒。把这两点合起来,就很自然会走向“请求队列 + 后台 worker”的结构。


9. 读到这里,你应该已经能回答的四个问题

  1. 为什么 llmrun --stdin 可以视为后台 worker 的雏形
  2. 为什么同步“用户态直跑”不适合长期 AI 服务
  3. 请求槽和循环队列分别承担什么职责
  4. AI service 至少会出现哪些阻塞点,以及它们分别在等什么

如果你已经能说清这四点,就可以进入 Part 2 题目了。