跳到主要内容

ISSUE-031: G5 大 cell 仿真 O(packets²) — PAXI segment_queue 线性扫描 + RcLinkWake 调度风暴

发现日期:2026-06-02 状态已修复(根因一 take_segments O(P²))+ 已修复(根因二 WRR 空扫推进,对齐 spec §5.10)+ 遗留(其余 wake-dependency 缺陷待系统排查) 类型实现 bug(根因一性能;根因二数值正确性 — RR 推进违反硬件 spec) 影响范围:根因一影响所有大数据量 cell 的速度(不改数值)。根因二影响数值(旧实现 RR 推进绑定仿真唤醒次数,偏离 spec §5.10「每次发出一个报文后」推进)。本 issue 还记录一个更深的系统性认识:G5 多处状态错误绑定「仿真唤醒次数」而非「硬件实际事件」,是一类待系统排查的建模缺陷。


[2026-06-04 引入回归] push-driven 重构把 per-LG 容量误写为全局 → 见 ISSUE-034

本次 push-driven SegmentDispatch 重构 (commit 3899aff) 引入一个帧静默丢失回归feed_rc_link全局 slot 容量 (rc_link_available_capacity = 8 LG free_slot 之和) 决定 take_segments 取多少,但 segment 按 hash_lg 投到特定 LG,单 LG 满时 submit_frame 丢帧 (返回值被 dispatch_segments 忽略) → segment 永久丢失 → 仿真提前终止。

spec (G5-RC-Link line 228/240) 的 slot 池是 per-LG 独立、slot 满应留 segment_queue 不丢帧;重构把 per-LG 容量契约误写成全局求和。修复 = per-LG 容量取货,详见 [[ISSUE-034]] (新增守恒护栏 incomplete_txns 防此类回归再次静默)。


问题现象

G5 alltoallv 仿真在大 cell 上墙钟时间异常增长,远超事件数线性预期。

实测 fat-tree N=64 ecmp dispatch S1,固定 N,仅扫 tokens_per_rank

tokbyteswall (修复前)标度
1643M1.58s
64173M (×4)4.83s (×3.1)近线性
256693M (×4)143.6s (×29.7)爆炸
10242774M (×4)~40 min

同一 N(拓扑、路由、规模全不变),仅数据量 ×4,墙钟 ×30 → wall ∝ bytes^2.4。千万级 packet 事件的 Rust 单线程仿真应秒级到分钟级,40 分钟是强烈的超线性信号。

tok=1024 N=64 单 cell ~40 分钟,导致 l2-curve profile 该档 22 个 cell 需 1 小时以上才能跑完。


调查过程

  • [实验] 建反馈环 _perf_probe.py:固定 fat-tree N=64 ecmp,扫 tok 测单 cell wall,确认 wall ∝ bytes^2.4(超线性,非线性)。

  • [假设 1] 事件队列 O(events²) → 排除sim_kernel.rsBinaryHeap 最小堆,push/pop O(log n)。

  • [假设 2] RC Link Go-Back-N 窗口扫描 → 排除rc_link_tx.rs 的 slots/vc_pending 全部 cap 在 config.type1_ost(固定硬件 OST 参数),不随 packet 增长。

  • [实验] py-spy --native 火焰图 → Windows 下采不到 Rust 栈(No stack counts found),改用插桩。

  • [实验] 在 sim loop 插桩 per-event-type 计数 + 耗时(G5_EVPROF env gated),对比 tok=64 vs 256:

    事件tok=64 counttok=256 count倍数tok=256 total
    RcLinkWake468,85221,328,598×45.5110s (80%)
    C2CLinkDone621k1553k×2.51.9s
    SwitchTick558k1367k×2.43.0s
    AckArrived50k120k×2.40.8s

    bytes 仅 ×4,其他事件都 ×2.4(正常线性),唯独 RcLinkWake count ×45.5、耗时占 80%

  • [查代码] RcLinkWake handler(multi_chip.rs)→ paxi.feed_rc_link()paxi_core.rs::take_segments()。发现 take_segments 从队头线性扫描整个 segment_queue(VecDeque),跳过未就绪 segment(CDMA 带宽门控 earliest_ready_ns > now),用 VecDeque::remove(i)(O(n))取就绪的。

  • [假设 3] 确认是根因take_segmentsRcLinkWake 调度 O(packets) 次,每次 O(queue_len),队列长 ∝ in-flight packets → O(packets²);叠加 remove(i) 的 O(n)。


根因分析

两层级联,核心在 take_segments 的 O(queue) 扫描:

第一层 — segment_queue 线性扫描paxi_core.rs::take_segments):

while i < self.segment_queue.len() {          // 扫整个队列
if seg.earliest_ready_ns > now_ns { // 未就绪 (带宽门控)
...; i += 1; // 跳过但留队列 -> 下次又从头扫
} else { self.segment_queue.remove(i); } // VecDeque::remove(i) = O(n)
}

segment_queue 长度 ∝ in-flight segment(∝ bytes)。未就绪 segment(earliest_ready > now)留在队列被反复全扫,每个 RcLinkWake 都从头扫一遍。

第二层 — RcLinkWake 高频调度:每个 packet 相关事件(C2CLinkDone / CreditReturn / AckArrived)+ feed_rc_link 自重复,都 schedule_at(RcLinkWake)。调度次数 ∝ packets。

合成:O(packets) 次 wake × O(packets) 扫描 = O(packets²),再叠加 remove(i) 的 O(n)。

关键洞察earliest_ready = submit_time + seg_idx × interval,同 txn 内单调递增take_segments 语义等价于"取所有 earliest_ready ≤ now 的 segment"。txn 数 = O(N²)(alltoallv 参与对数,4032 for N=64)是固定的,不随 tokens 增长;segment 数才 ∝ bytes。按 txn 遍历是 O(常数),按 segment 全扫才是 O(packets)。


业界对比

调研 4 类事件驱动仿真器如何处理"仲裁器唤醒/credit 激活"类事件(详见 .cache/iforge-research/des-wakeup-events/):

仿真器机制关键设计
ns-3EventId.IsPending() 守卫调度 wake 前查重,已 pending 则跳过
OMNeT++cMessage 指针唯一 + isScheduled()同一 self-message 不能在队列出现两次,框架层强制去重
BookSim2 (NoC)_active 标志空闲 router if(!_active) return,wake 复杂度与 packet 数解耦
Garnet/gem5 (NoC)reactive checkReschedule()credit 返还只通知直接上游单一节点,不广播
htsim (数据中心)_servicing 标志 + 单例 Paceridle→busy 边沿只触发一次;N 流共享 1 个调度事件

最关键的业界共识:credit/ack 返还只唤醒"直接上游那一个仲裁器",由它统一处理所有等待 packet,绝不广播给所有等待者。若每次 credit/ack 唤醒所有等待发送者重新竞争,就是 O(待发 packet) 次唤醒 → 爆炸。G5 的 RcLinkWake 正是反面(广播式 + 无去重)。

通用去重模式的正确性陷阱(DES 理论,Pidd 1998 / gem5):Schedule-If-Not-Pending 的唯一风险是"被跳过的 wake 若代表不同请求方,handler 必须服务所有等待方,否则漏激活死锁"。G5 的 RcLinkWake handler 是 feed_rc_link() + 遍历所有 tx_lgs arbitrate()全量处理结构,天然满足该前提 → 去重对 G5 安全。


解决方案

已采用:take_segments 改 per-txn 存储

segment_queue: VecDeque<SegmentInfo> → 按 txn 分桶:

seg_txn_order: VecDeque<u64>,                    // active txn id, 创建顺序 (txn-major)
seg_by_txn: HashMap<u64, VecDeque<SegmentInfo>>, // 每 txn segment, seg_idx 顺序
seg_total: usize, // 总数 (for len/has_pending)

take_segments 按 txn 遍历(O(active_txn) = O(N²) 常数),从每 txn 队头取就绪前缀(队头单调,未就绪即 break 换 txn),pop_front O(1) 取代 remove(i) O(n)。

顺序完全保持(txn-major + seg_idx),与原单队列 FIFO 扫描逐项等价(顺序 / earliest_wake / budget-break 三处等价性已逐一验证)→ 数值 bit-identical。

涉及文件

文件改动
perfmodel/evaluation/g5/src/tier6/paxi_core.rsstruct 字段 + new + create_segments push + take_segments 重写 + has_pending_segments / segment_queue_len + 2 处 test

备选方案(未采用)

方案缺点
增大 MTU(1536→9000)线性加速但改 latency 数值,与已跑数据不可比
segment_queue 改 min-heap by earliest_ready跨 txn 取出顺序变(earliest_ready-major vs txn-major),破坏数值
只改 remove(i)→retain单次降 O(queue²)→O(queue),但不改 O(packets²) 量级(scan 仍 O(queue))

验证

指标修复前修复后结论
latency tok=6421.1 μs21.1 μs[OK] bit-identical
latency tok=25640.4 μs40.4 μs[OK] bit-identical
wall tok=256138s35.6s3.87×
wall tok=1024~40 min6.5 min (393s)~6×
RcLinkWake per_ns5152848单次 scan O(P)→O(txn),8.9×
标度指数bytes^2.4bytes^1.46大幅改善
cargo test356 pass, 3 ignored[OK] 语义正确

复现:G5_EVPROF=1 python docs/validation/EP拓扑路由评估/scripts/_perf_probe.py 64 64,256


第二轮调查:去重尝试暴露更深的缺陷

修复 take_segments 后,剩余瓶颈是 RcLinkWake 被调度的次数本身超线性(tok=1024 仍 224M,占 48%)。尝试用 wake 去重压低它,三次尝试均改变了 latency 数值:

尝试做法tok=256 latency 变化
1. kernel 去重SimKernel 拦截同 (chip, ns) 重复 wake40.356 → 40.0(1%
2. feed 去重只去重 multi_chip:373 feed 自重复 wake40.356 → 40.311(0.11%)
3. WRR 修复 + feed 去重先修 RR 再去重tok=256 fat-tree 12/12 全差,最大 dmodk 2%
  • [实验] 高精度对比:feed 去重 tok=128 共 20 cell,19 个 bit-identical,仅 fat-tree-k16-sp 差 0.0057%。tok=256 则 12/12 全差。差异随数据量增大、稀疏分布在临界 cell
  • [查代码] 定位 rc_link_tx.rs::try_arbitratevc_rr_ptr(VC 加权轮转指针)在「被唤醒但当前 VC 无帧可发」(空扫)时也推进。RR 状态因此耦合 try_arbitrate 调用次数 = RcLinkWake 数量。
  • [查 spec] 回到硬件 spec RCLINK_AFH_SPEC_v2.4 §5.10,确认真实仲裁行为 → 见下节。WRR 修复后 tok=256 仍 12/12 全差,说明 RR 只是 wake-dependency 的第一处,还有其他处。

原文(RCLINK_AFH_SPEC_v2.4 §5.10)

  • slot 四状态:EMPTY / WAIT_GRANT / WAIT_ACK / WAIT_CRD
  • "所有 WAIT_GRANT 状态的报文按照进入队列的先后顺序仲裁,先进入队列的优先输出"(FIFO)
  • bank 轮转(tx_bank_rr_en):按 qpid 低位分 bank,bank 轮流发
  • "每次发出一个报文后,记录发出报文 qpid_msb,检查队列中各 slot 报文的 qpid[1:0],与 qpid_msb 相同的 slot 对应的 mask 置 1,mask 置 1 的 slot 不参与下一次发送仲裁"
  • "当队列中仅存在其中一个 bank 的报文时,slot 的 mask 不生效"(work-conserving)

关键:硬件的 bank/VC 轮转状态在「每次发出一个报文后」推进 —— 绑定实际发送,不绑定仲裁尝试次数

G5 旧实现WRR 修复后硬件 spec §5.10
RR 推进时机空扫/仲裁尝试也推进只在发帧时推进「每次发出一个报文后」
符合硬件?❌ 违反 spec✅ 对齐

结论:旧实现的 latency(如 fat-tree-ecmp tok256 = 40.356)是 bug 值(RR 推进绑定了仿真仲裁次数);WRR 修复后的 40.259 才符合 spec §5.10。这不是「改 baseline」,是修正一个数值正确性 bug。

业界印证:BookSim(cycle-driven,RR 每 cycle 推进)、Garnet/htsim(event-driven work-conserving,发送绑定资源就绪事件)—— 所有仿真器的状态推进都绑定「实际工作」,绝不绑定「检查/唤醒次数」。


解决方案 2:WRR 修复(对齐 spec §5.10)

rc_link_tx.rs::try_arbitrate 改为标准加权轮转:vc_rr_ptr 只在实际发帧时按配额推进,空扫 / credit-block 跳过但不推进。

for i in 0..n {
let vc_idx = (self.vc_rr_ptr + i) % n; // 从 ptr 起临时遍历, 不 mutate ptr
if let Some(...) = self.find_sendable(vc_id, now_ns) {
if *credit < ... { continue; } // credit 不够: 跳过, 不推进 ptr
// ... 发帧 ...
if vc_idx != self.vc_rr_ptr { self.vc_rr_counter = 0; } // 切 VC 重置配额
self.vc_rr_counter += 1;
if self.vc_rr_counter >= weight { self.vc_rr_counter = 0; self.vc_rr_ptr = (vc_idx + 1) % n; }
else { self.vc_rr_ptr = vc_idx; }
return Some(...);
}
// 空 VC: 跳过, 不推进 ptr
}

涉及文件:perfmodel/evaluation/g5/src/tier6/rc_link_tx.rs(try_arbitrate 4 处)。cargo test 356 pass。


重新认识:wake-dependency 是一类系统性建模缺陷

WRR 修复后 tok=256 仍 12/12 差异(最大 2%),证明 RR 只是第一处。本质问题:

G5 多处状态错误地绑定「仿真唤醒次数」而非「硬件实际事件」。去重改变 wake 数,暴露了所有这些缺陷。

Wake-dependency 候选清单(opus agent 系统排查产出)

系统走查 rc_link_tx.rs / paxi.rs / paxi_core.rs / rc_link_rx.rs / switch.rs / c2c_network.rs + 6 个 event handler,对照 spec §5.10 铁律(状态绑定「每次发出一个报文后」)。按 latency 影响排序:

优先级位置缺陷应绑定事件spec 依据
✅ 已修rc_link_tx.rs vc_rr_ptr空扫推进 RR 指针实际发帧RCLINK §5.10
高-2rc_link_tx.rs:704 find_sendablerate-block 后缺精确 wake —— 只 blocked_flows.push 不返回 earliest-ready 时刻rate-block 后 arm last_send+min_interval 的 wakeG5-RC-Link spec L270-271 已要求,代码未实现
高-1c2c_network.rs:57-80link_load 滑动窗口衰减锚定访问时刻而非物理时间积分物理时间精确积分 link busy需查 spec(仅 adaptive 路由 UGAL/flowlet 时影响;static 路由不消费 link_load
高-3switch.rs:181ECN RED 全局 PRNG 流绑定出队批次per-packet 确定哈希(业界做法)需查 spec
中-1switch.rs SwitchTickiSLIP 指针本身合规,风险在 tick 批次划分对事件密度敏感SwitchTick 严格周期触发指针已合规
中-2paxi.rs:295 feed_rc_linkcapacity 快照随 wake 时刻的 ACK 释放进度变 → 喂入时刻偏移slot 释放事件精确触发重检查需查 spec §两级背压
中-3rc_link_rx.rs:132 ACK MERGEpoll 相位锚定首包到达时刻(根源在上游数据包到达时刻)行为本身合规
diag_* / in_flight_packets_on_linkfalse positive —— 前者设计计调用次数,后者不被任何决策消费N/A不修

头号嫌疑 = 高-2:这是唯一「spec 已写明但代码未实现」的缺陷(其余多为「需查 spec 确认正确行为」)。spec L270-271 + eq:rc-dcqcn-pacing 明确要求 rate-block 时调度 earliest-ready 的 RcLinkWake,但 find_sendable 没返回该时刻 → 被 pacing 阻塞的帧发送时刻完全靠「碰巧哪个 wake 唤醒」。

fat-tree tok=256 伪差范围:fat-tree-ecmp/dmodk/sp 都是 static 路由,故 高-1(adaptive link_load)对其不影响,可先排除。残余 12/12 伪差来自 高-2(DCQCN)/ 高-3(ECN RED)/ 中-2(capacity)—— 取决于 DCQCN/ECN 是否在该场景激活(incast 触发)。

修复策略:先定位真凶(看 fat-tree tok=256 实际触发 DCQCN / ECN / capacity 哪个),集中修,而非盲目全修 6 个。建议顺序 高-2(最明确,spec 已要求)→ 回归 tok=256 看伪差收敛 → 据残余继续 高-3 / 中-2。每修一处用「去重前后 bit-identical」作验收。修完后 wake 数不影响 latency → 去重自然 bit-identical + 全部更接近硬件真实。

完整排查报告(每候选 5 字段:位置/为何 wake-dependent/应绑定事件/spec 依据/latency 影响)见会话记录;此表为可执行 work-list。


当前已固化的两个正确修复

修复性质状态
take_segments per-txn性能(不动 wake,纯优化单次成本)bit-identical,6× 提速,已验证
WRR 空扫推进数值正确性(对齐 spec §5.10)改正 bug 值,cargo test 356 pass

两者都朝硬件真实方向,建议保留。wake 去重(18× 额外提速)依赖剩余 wake-dependency 全部修完才能 bit-identical,列为后续工程。


后续优化(性能,独立于正确性)

修完 wake-dependency 后,剩余性能优化:

  1. RcLinkWake 去重:wake-dependency 全修后即可安全去重,count O(P^1.7)→O(P),再 ~18×。
  2. SwitchTick 活跃追踪(BookSim _active):per_ns 2436 单次最贵,需确认是否 polling。收益小(tok=1024 仅 7s)。
  3. C2CLinkDone 路由 String→int keyc2c_network.rs 每跳 to_string() + String HashMap,改整数 id。常数优化。

临时插桩 / 验证脚本待清理

  • multi_chip.rsG5_EVPROF event-profile 插桩 + G5_NO_FEED_DEDUP 去重开关 + feed_wake_at feed 去重逻辑(tag [DEBUG-7f3a])。
  • docs/validation/EP拓扑路由评估/scripts/_perf_probe.py / _perf_compare.py(临时验证脚本)。
  • 决策固化后清理:grep -r "DEBUG-7f3a"注意:若保留 feed 去重需先修完 wake-dependency;若暂不保留,回退 feed 去重 + G5_NO_FEED_DEDUP 开关,保留 take_segments + WRR。