从一个 Talker codec chunk 追踪到 Code2Wav,说明阶段之间传什么、何时能消费,以及取消后谁负责收回共享内存。
vLLM-Omni Connector:阶段契约、分块传输与资源生命周期
面向读者: 已熟悉 SGLang、vLLM 与 CUDA,想进一步定位 vLLM-Omni 跨阶段代码、推理修改影响并设计验证的读者。本文关注阶段边上的数据协议;请求生命周期见运行时篇,模型执行见阶段执行篇。
核心结论: Connector 把一份 payload 从生产者交给消费者,但可消费条件和资源回收条件由更高层的阶段契约决定。读懂一次传输,要同时追踪“谁产生什么”“哪次请求的第几块”“何时变为可运行”“谁还能访问这份数据”。内部按块执行、模型一次解码的分块以及客户端流式响应是不同层面的选择;并不存在让所有 stage 都流式化的通用目标。
范围与版本: 当前实现固定于公开提交 5ca23106fd6a0f500deea818bc2b7089306a210f,核对日期为 2026-10-05。主例使用 Qwen3-Omni 的 turn 模式、默认 model_runner: v1 及随仓库提供的 SHM 部署:Thinker、Talker、Code2Wav 三个 stage。本文追踪 scheduler 持有 chunk adapter 的 V1 路径;原生 MRv2 数据通道使用另一套就绪通知与 coordinator,不能直接套用相同对象关系。历史案例分别固定于 PR #7544 最终 head 216ba3636648dca9b61fb69a5cba9ede50c88c05、PR #8082 最终 head 546e4e4f27aec4b66d9c083cf6b76059475b8242。本文读取了源码、实际补丁、测试和审查记录,没有在本文环境启动模型、执行 GPU 测试或复测 PR 作者的结果;文中的时间线和帧计数是源码推演。1、2、21、29、42、46
阅读路径: L1 区分职责 → L2 建立对象和所有权 → L3 跟踪真实 chunk → L4 分析混合阶段契约 → L5 推演取消竞态与验证。
引入|为什么“把 tensor 发过去”还不够?
设想 Talker 已经生成四帧 codec,Code2Wav 却仍停在等待队列。原因可能是 codec 尚未达到发送阈值,也可能是 receiver 使用了错误的 request ID,还可能是接收成功后没有发布 ready 状态。这三种情况都表现为“没有音频”,但分别属于模型适配、传输寻址和调度衔接。
再设想客户端断开后,调度器已经删除请求,SHM 段仍继续增加。删除请求对象只改变调度器的视图;后台 sender 可能正处在 put() 中,稍后仍会创建共享内存。如果把取消理解成同步地删除所有对象,就会错过真正需要建立的执行顺序。
五层学习的终点是一条能够复查的解释链:
| 层次 | 要能回答的问题 |
|---|---|
| L1 职责与概念 | 谁决定阶段连接,谁组装 payload,谁搬运字节? |
| L2 对象与所有权 | 队列、身份、计数器、SHM 分别活多久,由谁回收? |
| L3 动态数据流 | 一个 codec frame 如何进入 chunk,又如何成为 Code2Wav 输入? |
| L4 执行机制 | 为什么流式上游可以接需要完整 waveform 的下游? |
| L5 正确性与性能 | 取消和 ID 复用如何破坏时序,怎样稳定地测出来? |
这些问题也适用于其他 pipeline,但本文的具体 key 格式、字段布局、阈值和清理实现只对所引用路径成立。
Level 1|职责边界:一条边包含三种工作
**本层结论:路由决定交给哪个 stage,适配决定下一阶段应当看到什么,传输决定这份数据怎样跨过进程边界。**三者相互配合,却不能用一个“通信层”概念替代。
1.1 路由、适配与传输分别知道什么?
Pipeline topology 描述 stage 的输入来源、执行类型与最终输出类型。主例中,stage 0 是 LLM_AR Thinker,stage 1 是 LLM_AR Talker,stage 2 是 LLM_GENERATION Code2Wav。Thinker 的 text 和 Code2Wav 的 audio 都是 final output;Talker 只提供中间结果。这里的 Code2Wav 属于 LLM generation 执行路径,不能因为它生成音频就归入 diffusion runtime。1
Orchestrator 管理请求应推进到哪里,以及一个 stage 输出应交给前端还是继续转发。配置解析再根据该阶段的 async 模式,选择相应的输入处理函数和下一阶段 payload 处理函数。例如 Talker 的 talker2code2wav_async_chunk 知道如何积累 codec frame;SHM connector 并不知道“25 帧”是什么意思。3、4
| 责任 | 主例中的对象或代码 | 需要维护的约束 |
|---|---|---|
| 阶段路由 | PipelineConfig、Orchestrator | 请求目标、前驱关系、最终输出是否齐全 |
| 模型适配 | talker2code2wav_async_chunk |
codec 布局、有效帧、上下文和结束语义 |
| 异步传输编排 | OmniChunkTransferAdapter |
排队、chunk 编号、ready 发布、取消与复用 |
| 字节传输 | SharedMemoryConnector |
key 对应的 bytes、锁、消费与 unlink |
| 客户端输出 | OpenAI serving 层 | 音频编码、响应协议、每段样本是否重复 |
表中的边界有利于定位修改。调整 chunk 帧数首先触及模型适配与 Code2Wav 的窗口;更换 SHM 后端首先触及可见性和生命周期。两种修改都可能影响端到端延迟,却不是在同一层修改“通信性能”。4、5、6
1.2 完整交接与内部 chunk 的区别在哪里?
完整交接要求上游先产出满足下游条件的一份完整结果,再提交或唤醒下游。内部 async chunk 允许上游还在生成时,就把可消费的部分送到已注册的下游请求中。在当前 orchestrator 中,是否提前提交下游由 stage_receives_chunks 决定;不接收 chunk 的阶段通过结束后的转发路径取得真实输入。3、7、37
“完整”是相对于下一阶段的输入契约而言。一个完整的 Talker 输出可以是一整段 codec;对 forced aligner 来说,需要的则是完整 waveform 与文本。完整交接仍然可以通过 connector 搬运大 payload,也可以在消费者内部再次分块计算,所以它不是“完全没有 chunk”的同义词。
async_chunk 描述阶段间增量交接;async_scheduling 是阶段内 scheduler 的同步/异步调度配置,实际作用取决于执行类型。当前解析器用它选择 AR 的同步或异步 scheduler,LLM_GENERATION 则固定选择 OmniGenerationScheduler。因此主例 Code2Wav 的 async_chunk: true 与 async_scheduling: false 并不矛盾,后者也不是 CUDA 异步执行开关。2、45
1.3 三种容易混淆的“流式”
| 机制 | 分块边界由谁决定 | 消费者 |
|---|---|---|
| 阶段间 codec chunk | Talker 的 stage-input processor | Code2Wav 请求 |
| Code2Wav 内部分块解码 | Code2Wav 解码实现 | 同一模型内部计算 |
| HTTP / WebSocket 流式响应 | serving 层与协议 | 外部客户端 |
当前 generate_audio() 在 async_chunk=True 时调用 chunked_decode_streaming();关闭它后仍会调用 chunked_decode(chunk_size=300, left_context_size=25)。后一种内部计算分块用于控制解码工作集,不意味着上游边仍逐块输送。8
仓库的 curl 主例也提供了一个直接反例:请求正文未设置 stream: true,部署却启用了内部 async chunk。因此服务器可以在内部重叠 Thinker、Talker 和 Code2Wav,最后向客户端返回普通完整响应。外部不流式,并不证明 stage 之间在串行等待整段结果。2、9
1.4 主例的物理部署与逻辑路径
1 | flowchart LR |
这张图只描绘本例:Thinker 放在 GPU 0,Talker 与 Code2Wav 放在 GPU 1。后两者同处一个 GPU,并不意味着共享 Python 对象,也不意味着 codec 自动走 GPU peer copy。该 SHM 实现先序列化对象,其 tensor 编码会 detach().cpu(),再写入 POSIX 共享内存;选择 SHM 是进程间传输选择。2、5、10
本层自检: 能否说明“内部流式但 HTTP 非流式”“同 GPU 但仍需 SHM”“生成音频但不是 diffusion”各自由哪条代码事实支持?
Level 2|对象、状态与资源:同一个 request 有几种身份?
**本层结论:身份决定状态归属,所有权决定清理权限,执行顺序决定清理何时安全。**只看一份 payload 的内容,无法判断晚到的数据还能不能提交。
2.1 Connector 对象与 transfer adapter 的边界
OmniConnectorBase.put(from_stage, to_stage, put_key, data) 返回成功状态、序列化大小及可选传输 metadata。get() 返回对象和字节数,或表示当前未获得数据的 None。同一个接口可以由不同传输后端实现;接口本身并未承诺 tensor 零拷贝或全局请求完成。6
OmniChunkTransferAdapter 在 connector 之上持有请求级状态。构造时创建 sender/receiver 状态锁、注册表及 connector,然后启动基类的后台收发线程。load_async() 把接收注册放入待轮询队列,save_async() 把待处理输出和请求快照放入待发送队列;它们的名字并不意味着调用时已经完成传输。11、12
1 | 阶段调度器 |
待发送队列里存的是工作项,SHM 中存的是经过适配与序列化的数据。待接收队列里存的是 _LoadEntry 注册对象,不是所有 chunk 的副本。这种区别解释了为什么“队列已经清空”既不等于“下游已经计算”,也不等于“SHM 已被消费”。11、12
2.2 request ID、external ID 与 chunk ID
在发送侧,put_req_chunk 按 external_req_id 记录下一个发送序号。在接收侧,get_req_chunk 按当前阶段的内部 request_id 记录期望序号。request_ids_mapping 连接这两种命名空间;发送失败则按 scheduler 使用的内部 ID 报告。11、13
主例 Talker 向 Code2Wav 发送第 k 块时,实际 key 为:
1 | external_req_id = R |
这里最后的整数是传输序号,不是 codec token ID,也不是 HTTP 响应段编号。key 中间保存源 stage,接收方按 stage_id - 1 构造相同名字;这是本文走读的相邻 stage adapter 路径,不能推广为任意 DAG 的寻址规则。13、14
_SenderGeneration 进一步区分同一 external ID 的不同发送生命周期。它是 adapter 本地的身份 token,保存 in_flight、cancelled、terminal_pending、num_puts 和 terminal_sent 等状态。它没有被附加到上述 SHM key;旧 generation 与新 generation 的隔离依赖身份检查和队列顺序,L5 会推演这一点。15、38
2.3 Payload metadata 与传输 metadata 是两套信息
主例 payload 使用 OmniPayloadStruct,其中的 codes.audio 保存 codec,meta.left_context_size 保存上下文帧数。meta.finished 说明流的终止语义,meta.is_segment_finished 则用于可恢复分段边界。这些字段属于模型与运行时共同遵守的消费协议。16、13
Connector 的返回值 metadata={"shm": {"name": ..., "size": ...}, "size": ...} 则描述字节在哪里以及有多大。SHM 可通过这个 handle 读取,也可直接按 key 查找;在本文 chunk 接收路径里,接收方能够由 request 和 chunk 序号重新构造 key。不能把 left_context_size 放进传输 handle 后就期待模型自动看见它。5、14
还有一处容易漏掉的边界:发送侧 processor 构造的是 struct,通用 msgpack 解码后接收侧得到的是 dict。所以发送代码访问 payload_data.meta.finished,接收代码访问 payload_data["meta"]["finished"]。扩展 schema 时必须检查两端,而不能只给 dataclass/struct 加一个字段。13、16
2.4 SHM 的正常所有权转移
写入侧在锁文件的排他锁保护下创建并填充 SHM,然后关闭自己的 handle。读入侧尝试取得锁,复制出 bytes,关闭并 unlink SHM,最后反序列化。close() 只关闭本进程句柄,unlink() 才移除命名对象;这是不同资源动作。5、17
| 资源 | 创建与持有 | 正常释放条件 |
|---|---|---|
| SHM 段 | sender 按精确 chunk key 创建 | receiver 读取 bytes 后 unlink |
| 锁文件 | sender 写入前创建 | receiver 消费后删除 |
sender _pending_keys |
sender 本地记录已写 key | 本地消费或发现远端已 unlink 后移除 |
接收注册 _LoadEntry |
receiver adapter 注册 | 当前 chunk 提交成功或请求清理 |
| 请求缓存与计数器 | adapter 按各自身份维护 | 对应 generation / 请求退役 |
receiver 在另一进程消费时,不能直接修改 sender 的 Python _pending_keys。因此“/dev/shm 中对象已经没了”和“sender 还记着这个 key”可以同时成立。当前 connector 用有界轮转扫描发现这些已消费项;这是一种 bookkeeping 回收,并不重新消费 payload。5、18
如果反序列化失败,bytes 已经读走且 SHM 已被 unlink,锁文件也应该删除。当前代码用 consumed 判断资源是否消耗,而不是用“对象是否反序列化成功”判断;这正是 #8082 修复的一部分。5、29
2.5 Ready、上游结束与本阶段结束不能合并
接收一块数据成功后,adapter 设置 ready,使 scheduler 有机会执行当前 chunk。收到 meta.finished 时,再把请求记入 upstream_exhausted_requests。但“上游再也不发数据”仍不等于“本阶段对已有输入的计算已经结束”。14
同理,sender 正常发送 terminal payload 后,可以释放自己的请求缓存,却不能立即 unlink 下游尚未读到的 terminal。取消则走另一条资源路径:没有消费者会负责消费的 chunk 必须由发送方回收。这两条路径共享清理入口,但清理条件不同;把它们统一成“请求结束就删全部 SHM”会引入新的挂起。15、19
本层自检: 若 R_1_3 仍存在,应先问它是否未消费、sender 是否仍在写、terminal 是否仍被下游需要,而不是只检查 orchestrator 是否还有 R。
Level 3|真实数据流:从四帧 codec 到一段音频
**本层结论:一个 chunk 不是被动切片,它包含模型挑选的有效新帧、重复上下文和运行时结束语义。**下面沿固定源码追踪一个实际请求形态,数值只用于手算,不伪造运行日志。
3.1 从仓库提供的请求开始
仓库脚本 run_curl_multimodal_generation.sh 支持 text 输入和单独的输出 modalities 参数。选择 text '["text","audio"]',正文会包含 Qwen3-Omni 模型名、system/user messages 及三个 stage 的 sampling params。用户文本是要求模型用 15 个词解释可扩展音频生成架构;请求发向 /v1/chat/completions。9
1 | bash examples/online_serving/qwen3_omni/run_curl_multimodal_generation.sh \ |
这是仓库相对目录下的调用示意;服务需要事先按对应模型与部署配置启动。脚本只把响应中的 text 打印出来,注释明确没有打印大段音频内容。因此不能由脚本终端只显示文本,判断 audio stage 没有执行。9
这个请求会需要 Thinker 的文本 final output 和 Code2Wav 的音频 final output。在 async chunk 路径中,orchestrator 预先提交接收 chunk 的下游请求,以 placeholder prompt 建立可管理的请求对象。这些零 token placeholder 是执行协调用的占位,并不是应该直接拿去合成的真实 codec。1、7
3.2 Thinker 先把 Talker 的条件准备出来
Thinker→Talker 的第一块不仅是一个文本 token。thinker2talker_async_chunk 从捕获的 hidden layers 中取得 embedding 与被接受的 hidden state,构造 embed.prefill、hidden_states.output、ids.all、ids.prompt,并携带 TTS BOS/EOS/PAD embedding 等条件。如果首块还只有原 prompt,没有满足 Talker 输入要求的首个生成位置,它会缓存并暂缓交接;后续 decode 块主要携带 embed.decode。20
这一边解释了为什么 Talker 的输入不是“把 Thinker 最后打印出的字符串再 tokenize”。它消费的是模型约定的条件表示,而文本 final output 仍可独立交给用户。本节不重述 Talker 投影与 MTP 的计算细节,相关执行边界见阶段执行篇。
3.3 Talker 输出中的 codec 从哪里来?
在 qwen3_omni.py 的 Talker 输出包装分支,代码从各请求的 model_intermediate_buffer 中取 codes.audio,合并后放入 OmniOutput.multimodal_outputs。普通路径是一行 codec frame 携带多个 codebook 的离散索引。MRv2 eager 路径还会明确提供 meta.codec_frame_valid;processor 必须遵守对应的有效性约定。8
AR scheduler 获得本次 inter_stage_output 后调用 chunk_transfer_adapter.save_async()。后台 save thread 才执行 talker2code2wav_async_chunk;所以“模型 step 完成”“排入发送队列”“实际 put 一块”是三个事件。某个 step 可以只积累一帧,尚不产生任何 connector put。13、30
3.4 四帧怎样成为第零块?
固定部署给出:
1 | initial_codec_chunk_frames: 4 |
processor 先读取 codes.audio。有显式 codec_frame_valid 时,以对应帧的有效性为准;普通路径跳过全零 prefill 占位,并排除第一 codebook 等于 stop token 的 post-EOS 帧。有效帧被追加到 code_prompt_token_ids[external_req_id]。2、4
假设请求 R 的普通路径连续得到四个有效帧,每帧 shape 为 [1,Q],Q 是模型 codebook 数。前三次调用只积累,第四次满足首块阈值。processor 将四帧拼成 [4,Q],转置成 [Q,4],再 flatten 成长度 4Q 的一维 tensor。4
1 | 模型输出(token-major) 传输排列(codebook-major) |
这个转置不能省略。接收侧随后按 [Q,T] reshape;若发送端直接 flatten [T,Q],tensor 长度仍合法,语义却已错位。这类错误通常不会以 transport exception 形式暴露,而会进入音频质量问题。4、8
首块的主要内容是 codes.audio=flattened_codes、meta.left_context_size=0、meta.finished=False。meta.first_audio 来自模型上游标记并受 chunk_id==0 限定;它不是所有首块必然为 True 的通用字段。adapter 还会补齐 scheduler 视角的 finished 和 segment-finished 标记。4、13
3.5 后续 25 帧为什么要带旧数据?
沿默认无 ramp 路径,发送顺序可手算为:
| chunk | 有效新帧 | 带入的左上下文 | 传输帧数 | 累计新帧 |
|---|---|---|---|---|
| 0 | 4 | 0 | 4 | 4 |
| 1 | 25 | 4 | 29 | 29 |
| 2 | 25 | 25 | 50 | 54 |
| 3 | 25 | 25 | 50 | 79 |
第二块只能重发已有的四帧;从后面的稳态块起,最多重发 25 帧。这不是网络层重复投递,而是 Code2Wav 解码窗口需要的重叠上下文。当前源码另支持显式配置 chunk ramp,但本例 YAML 未启用,不能把它描述成所有请求自动使用的动态策略。2、4
若请求结束时只多出 r 个有效帧,processor 会 flush 尾部。若恰好在边界结束且没有新有效帧,processor 可以返回 None;adapter 仍会构造没有 codec 的结束 payload。因此“没有新音频数据”不能成为丢弃 terminal 的理由,否则下游只看到暂时无数据,无法知道已经结束。4、13
3.6 从发送队列到共享内存
对第一块,sender 使用 R_1_0 调用 put(from_stage="1", to_stage="2", ...)。processor 此时已决定布局与 meta;connector 只负责序列化并写入 bytes。写入受对应锁文件保护,成功后返回 size/handle,并记录 sender 的 _pending_keys。5、13
当前源码还包含 SHM arrival wakeup:put 向目标 stage 的 receiver FIFO 发出提示。提示本身不携带模型 payload,也不能替代读取 key 的正确性检查;FIFO 不可用时实现仍保留轮询回退。这一机制已经存在于固定源码,但不属于后面分析的 #8082 补丁。5
接收 adapter 的 _poll_single_request() 取出 Code2Wav 的 _LoadEntry。在锁内确认它仍是有效注册后,取得 external ID 与期望 chunk 号,再在锁外执行 connector.get()。读取返回后重新检查注册对象身份,只有当前注册才能提交数据。14
3.7 收到 codec 后,怎样真正成为模型输入?
在 generation 分支,接收 adapter 取 payload_data["codes"]["audio"]。本例是一维 tensor,于是转成 token ID 列表并赋给 request.prompt_token_ids。其余 meta 合入 request.additional_information,其中 finished 被用于控制上游耗尽状态,而不是当作普通模型 metadata 继续累加。然后重置该 chunk 的计算计数,将内部请求 ID 加入 _finished_load_reqs。14
Code2Wav 的 schedule() 先经 _process_pending_omni_inputs() 调用 adapter 的 process_pending_chunks():已在 WAITING_FOR_CHUNK 且出现在 _finished_load_reqs 的请求恢复为 WAITING/RUNNING,转入 requests_with_ready_chunks;尚无输入者暂时移出本轮候选队列。scheduler 随后选择工作,输出后处理清掉已选中请求的 ready 标记;restore_queues() 再把暂时停放且仍存活的请求放回,留待下一轮检查。恢复队列不等于给未到输入的请求授予 ready。43、44
这也是模型输入布局必须追到消费者的原因:此处一维 audio codec 走 prompt IDs,不是永远作为 codes.audio tensor 挂在 sidecar。如果某模型改用二维 codec tensor,接收代码另有保留 tensor 与占位 prompt 的分支;不能只凭相同字段名认定数据路径相同。14
Code2Wav forward 根据 num_quantizers 和各请求的 seq_token_counts,把 token IDs 恢复为 [batch,Q,T]。它从 runtime_additional_information 取出各请求的 meta.left_context_size,再调用流式解码。waveform 返回时与 sample rate 包装成输出,继续沿 generation runtime 和 orchestrator 回到前端。8
3.8 重叠窗口怎样避免重复播放?
当前 chunked_decode_streaming() 解码包含左上下文的窗口,再裁剪有效输出。下面 window_frames 是本次 decode 分组的输入窗口宽度;request_frames 是当前请求由 seq_token_counts / num_quantizers 得到的实际帧数,包含左上下文、不含该请求为合批补齐的帧,不能替换成“有效新帧数”。结合解码器右侧暂留的 tail,计算:31
1 | tail = max(0, window_frames * total_upsample - decoded_samples) |
这个 tail 修正用前一块重新解码的上下文补上边界,不能粗暴简化成“固定丢掉左边 25 帧对应的所有 samples”。传输上重发 codec 与客户端重复播放 waveform 是两回事:前者提供模型上下文,后者必须由正确裁剪避免。31
本层自检: 给定累计 29 帧,应能指出 R_1_1 的帧数、layout、left context,以及接收后哪些字段进入 prompt、哪些字段进入 runtime metadata。
Level 4|PR #7544:一条 pipeline 为什么允许混合交接契约?
**本层结论:让对齐器等待完整 waveform,是保留其正确输入语义;上游 Talker→Code2Wav 仍可继续按块运行。**这个案例换用 Qwen3-TTS,不是给前面的 Qwen3-Omni 主例悄悄增加第四个 stage。
4.1 原始故障不是“音频传得慢”
PR #7544 于 2026-09-16 合并,修复 Qwen3-TTS 开启 async chunk 并注入 forced aligner 时,word_timestamps=true 请求可能挂起的问题。原有 aligner 继承 pipeline-wide async 标志,被 orchestrator 提前提交成 placeholder。scheduler 因而把它当成 chunk consumer,等待一条本来就不存在的输入流。21
实际 code2wav2aligner 的契约是:只处理 src.finished 的输出,抽取完整 waveform 与 sample rate,结合原文本构造 aligner 的文本 prompt 和 multimodal audio。它还记录真实音频时长用于后续时间戳处理。因此,让它等待 connector chunk 与它的 processor 完全不匹配;多等一会或调大 timeout 都不会补出这条输入。22
1 | flowchart LR |
4.2 修复把模式解析统一到阶段层
最终补丁引入 StageDeployConfig.async_chunk: bool | None。False 明确退出全局 async chunk;None 继承,True 也不能突破全局关闭。核心解析相当于 pipeline_async and stage_async is not False。23
| pipeline async | stage async | 解析结果 |
|---|---|---|
| False | None / False / True | False |
| True | None / True | True |
| True | False | False |
同一个 resolve_stage_async_chunk 同时用于结构化配置、legacy engine args 以及 processor 选择。这一点比单独增加布尔字段更重要:否则 scheduler 可能认为自己收 chunk,实际 processor 却按完整输入组织。当前固定源码在相同语义外还增加了 bool 类型校验;那是当前状态,不应回填成 #7544 最初补丁内容。3、23
注入 forced aligner 时,部署显式设置 async_chunk=False,同时维持 pooling stage 所需的非 async scheduling。共享判断 stage_receives_chunks 增加对解析后 async 标志的检查。这样 prewarm 会跳过 aligner,Code2Wav finished 后由正常转发调用 code2wav2aligner。7、22、24、39
4.3 为什么不能任意关闭某一端?
补丁也引入 validate_stage_async_chunk_edges。对于声明了 async producer processor 的边,producer 和 consumer 的解析模式必须一致;否则启动时直接报错。Talker→Code2Wav 属于这种边,只关闭其中一端会破坏协议。23
Code2Wav→aligner 的情况不同:Code2Wav 没有声明用于下一阶段的 async chunk producer,该边本来就是完整 waveform 交接。validator 因而不要求它与 aligner 同模式。“混合模式合法”不是允许在任意边上随意组合 True/False,而是让每条边有明确、可验证的输入契约。21、23
另一处修复是全局运行时判断。引入 stage opt-out 后,不能再从 stage 0 的值推断整条 pipeline 是否包含 async chunk。最终代码采用 any(resolved_stage.async_chunk);例如 [False,True,True] 仍要启用下游 chunk 相关编排。25
4.4 人工审查继续发现:不挂起仍可能算错
第一次人工 review 在 701706f6 请求修改。审查认可阶段 opt-out 的主要路径,但指出两个问题:stage 0 会错误关闭下游 chunking,以及流式时间戳请求只把最后一个 DELTA 音频块交给 aligner。后者已经不再挂起,却会在错误时间范围上生成时间戳。26
这揭示另一个边界:内部 stage 输入契约与向调用者输出的累计策略相互影响。如果 output processor 每次以 DELTA 排空早先音频,terminal 输出只有尾块;“terminal 已到”不保证“terminal 携带完整音频”。修复必须保留上游历史音频,而不只是延迟 aligner 调用。26、27
最终补丁对需要时间戳的流式请求 clone sampling params,再设为 RequestOutputKind.CUMULATIVE。clone 避免把这个请求的特殊需求写入共享默认参数。普通无时间戳流式请求仍使用原先的 DELTA 语义。27
4.5 内部累计,客户端只发送新尾部
假设 Code2Wav 依次产生 800、1200、400 个新 samples。内部累计输出应是 800、2000、2400 samples;最终 aligner 收到全部 2400。但若 serving 层把每个累计 tensor 原样发出去,客户端将收到 800+2000+2400=5200 samples,出现重复播放。28
最终修复用 emitted_samples 记录已发长度:对累计 tensor 先取 audio[..., emitted_samples:],更新计数,再进行 resampling 等输出处理。若这次输出长度未增长,则跳过空尾部。list 模式已有按 prev_count 选新 chunk 的逻辑,两种表示都需保持“每个样本只发一次”。27
这里不要混淆 L3 的左上下文裁剪。Code2Wav 裁剪消除模型窗口的重叠;serving 层裁剪消除累计输出的重复前缀。一个处理 codec 解码上下文,一个处理 API 输出策略;缺少任意一层都可能让用户听到重复内容。
4.6 测试怎样钉住这个契约?
test_cumulative_audio_terminal_output_supplies_full_aligner_waveform 用三段常数音频明确区分 800、1200、400 个样本。它先确认未 finished 的输出不生成 aligner 输入,再确认最终波形长度为 2400,三段值及边界正确,24 kHz 下总时长为 100 ms。这比只断言“有时间戳”更能排除“只对最后一块对齐”的错误。28
配置测试覆盖 pipeline/stage 模式组合、legacy/structured 一致性和非法边。orchestrator 测试确认 aligner 不被预提交,只在音频完成后进入,而且请求要等音频和 timestamps 两个 final output 都完成才 cleanup。serving 测试检查累计音频只发送一次,WebSocket E2E 检查最终词时间戳覆盖整段语音。21、28、40、41
最终人工 review 在 216ba363 转为 APPROVED。作者在后续评论报告 1,145 个 CPU 测试通过、3 个完整模型 GPU 测试通过;审查者明确注明自己的判断来自静态阅读,没有执行 PR 代码。这些是历史记录,既不是本文运行结果,也不能直接证明所有模型、设备或响应协议都兼容。26
当前固定源码还收紧了 API 边界:HTTP /v1/audio/speech 的 streaming 加 word timestamps 会返回错误并指向 WebSocket /v1/audio/speech/stream。因此读这段历史时,应把内部累计修复与当前允许的外部接口分别核对,不能从 PR 讨论推断当前任意流式 endpoint 都支持时间戳。32
本层自检: 如果新增一个句末评分器,能否分别说明其输入何时完整、是否接收 chunk、上游应保留什么历史,以及请求最终完成要等哪些输出?
Level 5|PR #8082:取消不是一次同步删除
**本层结论:取消正确性必须同时覆盖后台写入、旧任务提交、ID 复用和未消费资源。**修复的关键是建立先后关系,并用可控制的交错测试证明这些关系。
5.1 先界定 PR 实际修了什么
PR #8082 于 2026-09-24 合并,最终 head 为 546e4e4f。补丁处理的是已有 abort fence 之后仍留下的 SHM、锁文件和 producer bookkeeping,以及 receiver 取消 tombstone 的累积。它没有从零引入所有 generation/fence 机制,也没有宣称解决同一 issue 中的任意内存泄漏。29
在此前路径中,即使取消已阻止新 chunk 入队,仍可能存在已经开始但尚未返回的 put()。删除 adapter 字典不能撤销这次写入。若清理先 unlink,旧 put() 后创建 SHM,就得到请求消失后才出现的孤儿段。
5.2 一次取消与 external ID 复用的时序
设旧请求内部 ID 为 old,external ID 为 R,正在发送 R_1_0。后续新请求内部 ID 为 new,也想使用 R。以下顺序由历史补丁以及当前 sender 实现共同约束:15、19、33
| 时刻 | save thread | scheduler / 新请求 | 需要保证的条件 |
|---|---|---|---|
| t0 | 旧 generation 开始 put | — | key 属于旧 token |
| t1 | put 尚未返回 | abort old | 只标记 cancelled,不等待 I/O |
| t2 | 仍在 put | 尝试复用 R | 旧 token 仍 draining 时拒绝混入 |
| t3 | put 返回,进入 finally | — | 登记旧 generation 应清理的精确范围 |
| t4 | 执行 cleanup 工作项 | 新 generation 可排入其后 | 先清旧 key,再写新 key |
| t5 | 写入新 R_1_0 |
— | 旧清理不能追上并删除它 |
这里的“拒绝混入”不是静默接纳并延迟新请求。当前 save_async 发现同 ID token 仍 cancelled,会记录 send failure;安全复用应发生在旧 generation 已退役之后。测试证明的队列顺序与服务层是否允许用户主动复用 ID,是两个层面的问题。12、15
为何仅靠 generation identity 还不够?它能阻止晚到 put 的完成回调修改新请求状态,却不能自动删除已经创建的命名 SHM。反过来,仅靠 unlink 也不够,因为新 generation 可能重用同一个 key。需要同时保存“这是哪个 token 的完成”和“清理必须在什么队列位置发生”。15、33
5.3 精确 key 范围怎样记录?
补丁增加 num_puts,在调用 connector 之前记录当前可能创建的 key 上界。即便 connector 在创建段之后抛异常,或取消在 put 返回之前获胜,这个在途 key 也被纳入清理范围。若只在成功返回后才计数,恰好会漏掉最需要清理的失败交错。13、33
取消的 generation 退役时,adapter 将下面的任务追加到原 save queue:
1 | cleanup_shm = (external_id + "_" + source_stage, num_puts) |
这不是另启一个清理线程,也不是让 scheduler 扫描 /dev/shm。同一 save thread 保证旧 put 已结束,cleanup 与后续 put 又按同一队列排序。短锁只保护 token 与队列状态,耗时的 connector I/O 不在 scheduler 持有的锁内执行。19、33
5.4 为什么不用 request ID 前缀删除?
若 R 为 task,另一个合法 external ID 可以是 task_1_2。简单的 startswith("task_") 会把后者的 chunk 误判成前者资源。
#8082 将 SHM cleanup() 改成精确 key 删除,adapter 负责枚举自己确实可能创建的 key。29、34
这体现的是所有权分工:connector 只认识被要求删除的 key,adapter 才知道一个 generation 对应哪些 chunk。测试专门写入相似 ID 的 sibling key,取消一个请求后仍读取 sibling。只验证“被取消的 key 不存在”,而不验证“相似请求仍存在”,无法发现过度清理。34、35
5.5 正常 terminal 为什么要保留?
成功发出的 terminal 是下游知道输入已结束的必要证据。补丁新增 terminal_sent,只有 success and is_finished 才置 True;这种情况下 adapter 清理自己的状态,但不排入取消用的 SHM 删除。若 terminal put 失败,即使 backend 实际曾创建过段,也不能假定消费者会完成正常接收,仍要回收。13、33
terminal 尚在队列里时,terminal_pending fence 还要保住这次发送。cleanup_sender 记录 deferred cleanup,而最终任务在成功、返回失败或抛异常的所有出口释放 fence。这是此前已有机制与本次资源回收共同工作的地方,不能把“cancelled”简单解释成所有待发项无条件丢弃。15、19
当前固定源码比 #8082 多了一条最终兜底:当 orchestrator 确认各阶段都不再需要请求时,release_shm_resources 可回收 producer 成功发送但 consumer 提前停止后未读的段。它通过 cleanup_prefix 只匹配已跟踪 key 且剩余后缀为整数 chunk ID 的项。这与取消时枚举 generation 精确范围是不同入口,不能把当前所有成功结束回收行为归给 #8082。18、19、36
5.6 Receiver 为什么用注册对象身份?
旧 receiver work 也可能晚到。_LoadEntry 被放入队列后,请求可能取消、清理,甚至同一个内部 request ID 又被注册。如果只检查 ID 是否存在,旧 get 的返回可能写进新请求的 prompt 和 ready 状态。14
当前接收逻辑在 get 前后都检查:
1 | registered_load_entries.get(req_id) is entry |
cleanup 删除注册;重注册产生新 entry,旧对象自然失去提交资格。request-ID mapping 的发布也挪入同一受保护路径,避免一个旧队列项在 cleanup 后重新创建映射。由此不需要永久积累“曾取消过哪些 ID”的 tombstone 集合。14、33
注意 get 仍在状态锁外执行。取消可以迅速返回,晚到数据即使被 connector 读走,也不会重新恢复已经删除的模型输入。这是接收提交屏障;它保证状态不复活,不代表能够撤销所有底层 I/O。
5.7 Producer 的 bookkeeping 为什么还要轮转回收?
另一个进程消费 SHM 后,producer 的 _pending_keys 仍可能包含旧 key。补丁将它组织为 OrderedDict,每次 reap_consumed() 最多检查 64 项。还存在的段放回尾部,已不存在的段不再保留;save loop 即使没有新发送,也会调用回收。18、34
只总是检查前 64 项会饿死尾部:若队头都是未消费段,后面已经消费的 key 永远到不了扫描窗口。轮转让每一项最终被看见,同时限制单次扫描成本。但“每轮最多 64”不等于“整个 pending 集合最多 64”;若下游持续跟不上,真实未消费的数据仍会积累,需要另外分析 admission 与背压。18
5.8 用 Event 固定竞态,而不是碰运气
test_shm_abort_reclaims_inflight_put_without_blocking 包装真实 connector.put。sender 先设置 put_started,再等待 release_put;测试线程等到 put 确实进入后才触发 abort。随后断言 sender 仍活着,证明 abort 没有等 connector I/O;释放 put 后排空清理任务,确认 SHM、pending keys 和 sender 状态都被收回。35
这种测试控制的是发生关系:put started < abort returned < put released < cleanup completed。若仅 sleep 一段时间再 abort,机器负载改变就可能让取消发生在 put 之前,测试虽然通过,却没有覆盖目标竞态。
同一补丁的其他回归分别约束:
| 用例 | 排除的错误 |
|---|---|
| 取消后同 external ID 复用 | 旧 cleanup 删除新 generation 的 chunk |
| 相似 request IDs | 前缀匹配误删其他请求 |
| terminal 成功与失败 | 正常结束过早删除;失败后遗留 |
| stale receiver entry | cleanup 后恢复映射或输入状态 |
| 多次 receiver cleanup | tombstone 随已完成请求数永久增长 |
| idle save loop | 最后一批消费后 producer 记录永不回收 |
| 未读 key 在前、已读 key 在后 | 有界扫描始终困在队头 |
| deserialize 抛错 | SHM 已消费但锁文件仍遗留 |
这些用例各自对应一个不变量;它们的价值不来自测试数量,而是能否区分修复前后的具体坏序列。34、35
5.9 从修复推导性能与验证边界
对稳态新帧数 C、左上下文 L,codec 传输与解码窗口通常约为 C+L 帧。本例 C=25、L=25,稳态处理窗口相对新增帧约有 2 倍帧数;这是窗口长度比,不是实测端到端耗时比。首块 4 帧降低等待形成首块的门槛,但增加小消息、序列化、唤醒及较小解码批次的占比,最终收益必须结合实际关键路径测量。2、4、31
本例 Talker 和 Code2Wav 共用 GPU 1,时间重叠也可能引入算力和显存带宽竞争。SHM 的 CPU 序列化、复制与轮询同样可能成为瓶颈。所以调小 chunk 时,应同时观测首音频时间、后续块间隔、尾延迟、队列长度与已分配未消费资源,而不能只看单次 put 时间。2、5、10
#8082 作者报告:相关 connector suite 为 424 passed、3 skipped;跨进程 5,000 次传输含 4,285 次正常消费和 715 次未读取消,最后 pending keys、请求拥有的 SHM 与锁文件均为零。
模型验证包含 14 个完成响应、12 个首块后断连,以及取消后的正常请求和健康检查。PR 描述标注的验证提交为 4843c857,最终 head 另有后续提交。这些记录支持特定传输与取消回归得到修复,不证明多日 RSS 无增长,更不覆盖作者明确排除的 torch stable-ABI 字符串泄漏。29
若要为自己的改动建立证据,应先复现上述确定性交错,再运行实际完成/断连请求,最后按问题目标设计长时间负载。“单元测试通过”“真实模型响应成功”“长期资源趋势稳定”回答不同的问题,不能互相替代。
本层自检: 能否画出一个旧 put、新 generation、cleanup 三者交错,指出哪一个身份检查和哪一个队列顺序共同阻止误删?
结尾|把一条边作为可验证的协议阅读
接手新的跨阶段改动时,先沿同一条请求链找出 producer、processor、adapter、connector 与 consumer。记录 payload 的 shape/layout、标识符、结束字段和资源释放点,再提出修改。如果不能解释“接收成功后谁让 scheduler 继续”,或“取消返回后谁还可能写 SHM”,说明执行链还没有闭合。
可用以下问题检查自己的分析:
- 下游需要一块、累计前缀还是完整最终输入?相关配置能否提前拒绝不匹配?
- 同样大小的 tensor 是否仍有相同布局和语义?结束标记能否没有数据?
- 内部 ID、external ID、chunk 序号与 generation 是否各有明确作用?
- 正常完成、取消、发送失败和消费者提前停止,分别由谁释放资源?
- 验证是否稳定覆盖指定交错,而不是仅证明一次请求恰好成功?
更完整的系统地图见架构篇;调度器怎样等待、恢复和完成请求见运行时篇。Code2Wav 的具体执行路径见阶段执行篇,独立 diffusion 路径见Diffusion Runtime 篇。全部版本与来源集中在参考资料。
引用与源码定位
以下当前源码链接均固定于 5ca23106fd6a0f500deea818bc2b7089306a210f;标记为历史的条目使用对应 PR 最终 head。行号用于直接定位本节讨论的函数或测试,不表示只阅读了该处片段。