nano-vllm-omni · 提示 ← P12P15 →
#21 · Track C · 数据面 · 书 p.91

P13 · ChunkAdapter:后台收发线程与顺序/清理语义

async_chunk 的心脏:调度线程只做快照入队,save 线程构造 payload 并 put,recv 线程轮询 get;还要处理 cancel、None payload,以及“清理时恰好有一个 chunk 在读”。

⏱ 75 min 难度 难 实现 mine/p13_chunk_adapter.py 验收 ./check.sh p13 提示 A-21 · 参考解 C-21 solutions/p13_chunk_adapter.py

题面速览

发送端(stage 0)                                  接收端(stage 1)
scheduler 线程: save_async(req, snapshot, final)   scheduler 线程: load_async(req) / poll_ready()
     │ 只做 deepcopy + 入队(不阻塞调度)               ▲ 已提交的 chunk,按序
save 线程: build_payload(snapshot)                 recv 线程: get(f"{req}_{src}_{next_id}")
           put(f"{req}_{stage}_{chunk_id}")                   拿到 → 在锁内检查“还注册着吗” → 提交
           成功后 chunk_id += 1
规则 不遵守会怎样
save_async 深拷贝快照 scheduler 下一步继续修改请求对象,后台线程读到的是“未来”的数据
build_payload 和 put 都在锁外、在 save 线程 慢的 Python 整形和 SHM 写阻塞调度,GPU 空等
chunk_id 只在 put 成功后递增;None payload 不占 id 接收端按连续 id 轮询,出现空洞就永远卡住
cancel 不能吞掉 final chunk final chunk 是下游唯一的 EOS,丢了下游请求就永远停在 WAITING_FOR_CHUNK
get 在锁外;提交前在锁内检查注册 token 是否还是同一个 请求刚被清理,后台线程把迟到的 chunk 又写回去,污染下一个同 id 请求
没有进展时退避 ~1ms,空闲时在 Condition 上睡 busy-poll 吃满一个核,和 tokenize 抢 GIL

验收要点:单请求 50 个 chunk 有序 · 10 请求 × 30 chunk、随机延迟下各自有序 · save_async 永不阻塞调用者 · 快照隔离 · cancel 后 final chunk 仍送达、chunk id 连续 · None payload 被跳过且 id 无空洞 · cleanup 是提交栅栏:正在 get 中的 chunk 不会在 cleanup 之后出现。

直觉与类比

传菜窗口和编号小票

后厨(发送端)每出一道菜贴一张编号小票:0、1、2…,放到传菜窗口(connector)。跑堂(recv 线程)按编号去窗口取:没看到 k 号,就绝不去拿 k+1 号——这就是顺序的来源。所以后厨只在菜真的放上窗口之后才撕下一张小票(put 成功后 id+1);“这一步没有新菜”(payload 为 None)就别撕小票。最后一道菜带牌子“本桌已上齐”(finished=True);客人中途退单,还没做的菜都可以不做,但“已上齐”的牌子一定要送(空盘子,data=None)。厨师下单时拿的是点菜单的复印件(deepcopy)。

翻台和桌卡(generation token)

7 号桌的客人走了(cleanup_receiver),马上又坐下一拨新客人,桌号还是 7。跑堂手里端着上一拨客人点的菜回来——如果他只问“7 号桌有人吗?”(有!)就会把旧菜端给新客人。正确做法:每拨客人入座时发一张独一无二的桌卡(object()),跑堂出发前记下桌卡,回来时核对“现在桌上的是不是同一张卡”(is),不是就倒掉。核对和上菜要在同一把锁里一口气做完。

框架视角 · T · SBWC · LIFE

P13
T 每个 stage 进程:调度线程(P10 busy loop)+ save 线程 + recv 线程(daemon)。线程之间 Lock / Condition / Event;进程之间只有 connector 上的 key
S 发送端:待发送队列(调度线程 append、save 线程 popleft)、每请求下一个发送 id(save 线程写)、已取消集合(调度线程写、save 线程读 → 加锁)。接收端:注册表 req → token、下一个接收 id、已提交未取走的列表(两边都写 → 同一把锁)
B build_payload、connector.put、connector.get:慢,全部在锁外、在后台线程。空闲时 Condition.wait(timeout);有请求没数据时退避 ~1ms。调度线程上没有任何阻塞
W 调度线程:只做快照入队、登记、取结果。save 线程:只做 build + put,FIFO。recv 线程:只做 get + 提交,不准持锁 get
C 调度线程 → save 线程:deque + Condition;save → recv(跨进程):connector 的 key;recv → 调度线程:ready 字典,poll_ready 在锁内整体换出
L start:建两个线程;stop:置 Event → 对两个 Condition notify_all → 带超时 join。每个请求:load_async 发 token → 收到 final 或 cleanup_receiver 时注销;发送端在 final 发出后复位 id 和取消标记
I 除 start / stop,所有公开方法都在调度线程上调用,持锁时间必须是微秒级
F ① chunk id 从 0 连续,只在 put 成功后前进,None 的非 final 块不占 id;② final 块一定发出(被取消时 data=None);③ 只有 token 身份没变时才提交;④ 每个提交的块在 poll_ready 里恰好出现一次、按顺序
E chunk 在 load_async 之前就 put 了(connector 一直保存它,照样能收到);put 抛异常(id 不前进);清理时恰好在读;同名请求重新注册;stop() 时线程正睡在 wait 上

易错点

逐步图:例 1 的 save 线程(build = n if n % 3 else None,final 在 n=6)

▶ snd(stage_id=2) → rcv(src_stage_id=2),rcv.load_async("u9") 已调用
1. (u9, 1, F)

build → 1 → put u9_2_0 → next_send_id = 1

2. (u9, 2, F)

build → 2 → put u9_2_1 → next_send_id = 2

3. (u9, 3, F)

build → None 且非 final → 跳过,不消耗 id(仍是 2)

4. (u9, 4, F) / (u9, 5, F)

put u9_2_2、u9_2_3 → next_send_id = 4

5. (u9, 6, T)

build → None,但是 final → 仍然 put,data=None → u9_2_4;之后删除计数并复位。

conn.puts == ["u9_2_0", "u9_2_1", "u9_2_2", "u9_2_3", "u9_2_4"]——6 次 save,5 个连续 id

6. 接收端

recv 线程 next_recv_id 依次 0→1→2→3→4→5;拿到 u9_2_4 的 finished=True 后 u9 被注销,不再放回轮询队列。

rcv.poll_ready() -> {"u9": [{data:1},{data:2},{data:4},{data:5},{data:None, finished:True}]}

逐步图:cleanup 竞态(connector 的 get 很慢)

▶ 只检查 “q in registered” 为什么不够
1. t0 调度线程 load_async(q)

registered[q] = tok1

2. t1 recv 线程

锁内读到 (tok1, id=0);锁外进入 get("q_0_0")……

3. t2 调度线程 cleanup_receiver(q)

registered[q] 删除(recv 线程还在 get 中)

4. t3 调度线程 load_async(q)(同名新请求)

registered[q] = tok2

5. t4 get 返回数据

锁内比较 registered[q] is tok1?

结果

False → 丢弃。如果只检查 q in registered,t4 会是 True,旧请求的数据就混进了新请求。

三级提示

L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)

例 1 见逐步图(注意 key 里的数字:发送端用自己的 stage_id,接收端用 src_stage_id 拼同一个 key)。

例 2:快照隔离 + poll_ready 是“取走自上次以来的”

live = ["a"]; rcv.load_async("v")
snd.save_async("v", live, is_final=False)
live.append("b")                        # 调度线程继续改活对象
snd.save_async("v", live, is_final=True)
live.append("c")
# 收到的 data 依次是 ["a"]、["a", "b"];conn.puts == ["v_0_0", "v_0_1"]

snd.save_async("q", 1, False); snd.save_async("q", 2, False)    # 稍等
rcv.poll_ready() -> {"q": [{"data": 1, ...}, {"data": 2, ...}]}
rcv.poll_ready() -> {}                                          # 已经被取走了
snd.save_async("q", 3, True)                                    # 稍等
rcv.poll_ready() -> {"q": [{"data": 3, "finished": True}]}

chunk 在 load_async 之前就已被 put,load_async 之后照样能收到。

例 3:cancel_sender(build_payload 阻塞在 gate 上):save "p0"(开始 build,卡住)→ 排队 p1、p2、p3 → save "end"(final) → cancel_sender("w") → 放行。接收端得到 [{"data": "p0", "finished": False}, {"data": None, "finished": True}],conn.puts == ["w_0_0", "w_0_1"]。p0 会不会被发出取决于取消时它是否已过了取消检查——两种都可接受;但 id 必须连续、final 必须到。

关键问题

  1. save_async 在调度线程上执行,必须立即返回。它最多只能做哪两件事?build_payload 为什么不能在这里调用?
  2. chunk_id 什么时候 +1:入队时、build 之后、还是 put 成功之后?
  3. build_payload 返回 None 时什么情况跳过、什么情况仍要 put?final 块被 cancel 后 data 是什么?
  4. 接收端怎么知道下一个要 get 的 key?每个请求要记住哪些状态?收到 finished 后还要轮询吗?
  5. recv 线程在锁外 get 时,调度线程 cleanup 又 load_async 同一个 req。recv 回来怎么判断“这块属于已作废的那次注册”?
  6. 10 个请求都在轮询但都没数据时 recv 线程该做什么?完全没有请求时呢?
  7. stop() 要迅速返回。save 线程正睡在 Condition.wait() 上,怎么叫醒?如果它恰好在 notify 之前一瞬间才去 wait 呢?

细节

  • save_async / cancel_sender / load_async / poll_ready / cleanup_receiver 都在调度线程上调用;SAVE 线程只做 build + put;RECV 线程只做 get + 提交。
  • put 的对象固定是 {"data": payload, "finished": is_final};poll_ready 原样返回,按 id 顺序放在 {req: [...]};没有新内容的请求不出现。
  • per-registration token:load_async 时生成 object() 存起来;cleanup 时删掉;recv 线程 get 之前记下 token,回来后在锁内用 is 比较。
  • Condition.wait(timeout=...) 返回后必须重新检查条件;给 wait 加较短超时(如 0.1s),丢通知也只是多睡一会儿。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • 发送端:FIFO deque (req, snapshot, is_final) + Condition 唤醒 save 线程;next_send_id: dict[req, int];cancelled: set[req]。只有一个 save 线程按 FIFO 处理,同一请求的 chunk 天然有序。
  • save 线程一次迭代:取一项 → 查 cancelled(短暂持锁)→ 被取消且非 final:丢弃 → 被取消且 final:data=None → 否则锁外 build → data 为 None 且非 final:跳过 → put 到当前 id 的 key → 成功后 id+1;final 时清理 id 计数和 cancelled 标记。put 抛异常时不 +1。
  • 接收端:registered: dict[req, token]、next_recv_id、ready: dict[req, list]、轮询队列 + Condition。load_async 幂等。
  • recv 线程一轮:对当前队列里的每个请求轮询一次:锁内读 (token, next_id),没注册了就扔掉;锁外 get;没数据放回队尾;有数据锁内比对 token → 一致才 append、id+1;finished 就注销(不再放回),否则放回。一轮结束:队列空 → Condition 上睡(带超时);有请求但没进展 → 睡 ~1ms;有进展 → 立刻下一轮。
  • poll_ready 原子交换:锁内把 ready 整个换成新的空 dict 并返回旧的。
  • cleanup_receiver 是提交栅栏:锁内删除 registered、next_recv_id、ready。由于“比对 token + 提交”也在同一把锁内,cleanup 返回之后任何提交要么已发生(已被删掉),要么会看到 token 不匹配而丢弃。
  • stop():设 Event,对两个 Condition 分别 with cond: cond.notify_all(),再 join(带超时)。线程设为 daemon。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. 只做:deepcopy 快照 + 入队(并 notify)。build_payload 可能很慢,会阻塞调度。
  2. put 成功之后。入队时 +1 会让被跳过的 None 和 put 失败的块各占一个 id,接收端按 id 顺序轮询,会永远等那个不存在的 key。
  3. None 且非 final → 跳过;final → 无论如何都 put;被 cancel 的 final → data=None。
  4. key = f"{req}_{src_stage_id}_{next_id}";每个请求:注册 token、下一个 id、已提交未取走的列表。收到 finished 后停止轮询并注销。
  5. 用 token 的身份比较(is);只查是否在 registered 中不够(见竞态逐步图)。
  6. 有请求无数据:短暂退避(~1ms)后再轮询;没有请求:在 Condition 上睡,直到 load_async 唤醒(带超时兜底)。
  7. stop 设 Event 并 notify_all;wait 带超时,即使错过通知也最多晚一个超时周期醒来,醒来后检查 Event 退出。

面试官追问 & 对照源码

对照源码:distributed/omni_connectors/transfer_adapter/base.py:20-140 · chunk_transfer_adapter.py:381-503 · :539-555 · :714-723 · :991-1042