29 · 長跑才會浮現的兩件事:錯誤預算與時鐘分歧
開發期間跑 20 到 30 分鐘的短輪,一切正常。等到要跑完整流程、一輪 80 分鐘,連續三輪死在同一個地方——而且死法看起來完全不像同一件事。
這一篇講兩個只在長跑才會浮現的機制。它們的共同點是:短測試在結構上就碰不到,所以不管你跑幾次短測試,都不會發現它們存在。
驗證狀態:內容來自一段現地場域的 Isaac Sim 6.0 調校(2026-08)。錯誤上限那一段有 Isaac 自己的 log 原句佐證;⚠ 「哪些錯誤計入、計數何時重置」沒有查證,「這些錯誤無害」也沒有被獨立驗證——原始紀錄怎麼標,這裡就怎麼標。時鐘那一段的換算式只在一個工作點驗過。
1. PhysX 的錯誤上限:填滿它的多半是無害的警告
三輪長跑(80、77、73 分鐘)死在同一件事。Isaac 印的是:
PhysX error: Exceeding maximum number of PhysX errors (1000).
[omni.physxui.scripts.extension] PhysX has reported too many errors,
simulation has been stopped.
這不是物理問題。 PhysX 有一個「累計錯誤筆數上限」,預設 1000,滿了就把模擬停掉。它算的是筆數,不管那些錯誤有多無害。
1.1 症狀完全不像「模擬停了」
這是最貴的部分:
容器活著、還在輸出 log、UDP 探針照樣回應、畫面也還在。只有物理不動。
其中一輪因此讓控制端對著已經死掉的物理跑了 82 分鐘,每一步都「成功」送出、每一步都沒有效果。
會發現是因為控制端有一道獨立的檢查:
PACE_SIM_DEAD 模擬時鐘連續 3 次沒有前進(卡在 855.533)
⚠ 判「還活著」不能看容器、log、探針回應或畫面,這四個在物理死掉之後全部照常。唯一有鑑別力的訊號是模擬時鐘有沒有前進。這與12 篇的靜默失敗是同一個家族,只是這次連「畫面還在動」都不能當證據。
1.2 填滿配額的是什麼
死亡前的統計窗口裡:
| 錯誤 | 筆數 | 佔比 |
|---|---|---|
PxArticulationLink::getGlobalPose() not allowed while simulation is running |
951 | 86% |
| 其他 | 157 | 14% |
| 總計 | 1108 | ← 上限 1000 |
這些是 API 檢查的警告(讀寫順序不對),值照樣拿得到、資料一直合理。把它當成「停掉模擬」的門檻才是問題。
🔑 修掉任何一類都不夠,要看總量。 先前修掉另一類(非剛體查詢),把它從約 36,000 筆壓到 104 筆——結果換 articulation 這一類自己撐到上限。配額是共用的,你只是把誰先撞線換了一個。
1.3 「是探針造成的」——一半對,一半被量測否掉
非剛體那一類確實是探針驅動的,正對照很乾淨:
沒有場景記錄探針在跑 → 0 筆 / 60s
有 → 468 筆 / 60s
但 articulation 這一類不是。把取樣間隔從 11.8 秒拉到 30.0 秒:
| 改前 | 改後 | |
|---|---|---|
| 取樣次數 / 10 分 | 51 | 22(減半) |
| articulation 錯誤 / 10 分 | 131 | 129(不變) |
| 速率 | 13.1 筆/分 | 13.4 筆/分 |
取樣次數減半而錯誤速率不變,「探針驅動」的假說在這一類上被推翻。
這一步值得學:同一個現象的兩個來源,一個是探針造成的、一個不是,而如果只做了第一個正對照就下結論,會把整個修法方向弄錯。
1.4 治本的路走不通,所以繞過
想改從 Fabric 讀世界變換(那在模擬進行中是合法的)。實測:
probe_pose target=/World/RT-A/main/fork_liftA1 → HasWorldXform=False
probe_pose target=/World/RT-A/main/fork_tilt → HasWorldXform=False
這個場景的 Fabric 根本沒有替那些 prim 填世界變換。死路,標記起來不要再試。
最後採用的是提高上限本身:
--/physics/maxNumberOfPhysXErrors=200000 # 預設 1000
(對應容器內 omni/physx/bindings/_physx.pyi 的 SETTING_MAX_NUMBER_OF_PHYSX_ERRORS。)
以實測的 13.4 筆/分算,200000 等於 250 小時,真正失控時仍然留了後備。驗收方式是跑一輪 80 分鐘、確認全程 0 筆 too many errors 且走到最後階段——做到了。
⚠ 提高上限的副作用:原本那個「模擬死掉」的訊號不見了。 判準必須改用模擬時鐘有沒有前進。繞過一個問題的同時要問:我剛剛拆掉了哪個警報?
1.5 誠實標註
- 「總錯誤達 1000 → 停掉模擬」是 Isaac 自己講的(log 原句),不是推論。但哪些錯誤計入、計數何時重置沒有查證;現有證據只支持「場景重載之後可以繼續跑」。
- ⚠ 「這些錯誤無害」沒有被獨立驗證。 論據是「值照樣拿得到、資料一直合理」與「PhysX 自己把它歸類為 API 檢查」,不是逐筆比對過。若之後出現無法解釋的位姿雜訊,這裡要重新懷疑。
- 13.4 筆/分是單一組態下的量測。掛更多探針會更高。200000 的餘裕很大,但換組態時要重量一次。
1.6 為什麼短測試碰不到
| 原因 | 說明 |
|---|---|
| 只殺長跑 | 短輪(20~30 分)累積不到 1000,開發期間從來不會遇到 |
| 失敗長得像物理失敗 | 停在某個動作組、等待逾時——看起來像參數沒調對 |
| 錯誤被歸類為「無害的雜訊」 | 既有文件寫「不是物理訊號,是量測工具的雜訊」。那句是對的,但漏了下半句:雜訊照樣計入上限 |
第三條最值得記:一個正確的判斷可以因為漏了半句而變成陷阱。「這些訊息無害」與「這些訊息不會造成後果」是兩件事。
2. 模擬時鐘與牆鐘分歧
第二個機制更隱蔽,因為它不會讓任何東西停下來——它只是讓物理跑得跟你以為的不一樣。
2.1 三個各自合理的事實,湊起來是災難
控制端 use_sim_time = False → 它所有的計時器都是牆鐘
Isaac 沒有發布 /clock → ROS 端根本拿不到模擬時間(實測 topic 不存在)
Isaac 的 real-time factor 0.21 → 模擬時間比牆鐘慢約 5 倍
控制端照牆鐘把軌跡串流出去:384 個點 / 12.77 秒 = 30.1 Hz,穩定得很。
但同一段時間裡,物理只前進了 12.77 × 0.21 ≈ 2.7 秒。那條路徑長約 3.7 m,所以在模擬世界裡,車速是 1.4 m/s——而規劃器宣告的是 0.40。
軌跡被以 3.5 倍速播放,而兩邊都沒有報錯,因為兩邊各自都是對的:控制端按時發點,模擬器按步演算,沒有人負責檢查這兩個節拍是不是同一個。
2.2 症狀清單:每一項都會被誤診成別的東西
| 量到的 | 宣告值 | 倍數 |
|---|---|---|
| 叉齒速度峰值 4.35 m/s | 0.45 | 9.7× |
| 沿齒加速度峰值 164 m/s² | 門檻 13.73 | 12× |
| 相鄰兩個物理步的步距差 5.3 → 73 mm | — | 14× |
| 角速度峰值 97°/s | 30 | 3.2× |
這四項如果分開看,會分別被歸因成「規劃器有 bug」「摩擦不夠」「CCD 沒開」「轉向增益太大」。它們是同一個原因。
換算式在一個工作點上驗過:暴衝窗的 RTF 是 0.17,預測速度 0.40 / 0.17 = 2.35 m/s,實測 2.2 m/s。
⚠ 只在一個工作點驗過。 要當定律用,得掃至少三個 RTF。
2.3 為什麼降速沒有用
直覺的處置是把命令速度調低。實際:把 max_velocity 從 0.45 降到 0.22,只把 2.35 m/s 降到 1.18——仍然是宣告值的 2.6 倍,而且行程時間變長,暴露在這個問題下的時間反而更久。
根因是比例失調,不是速度設定值。 對著一個比例問題調分子,永遠只能改善那個比例的絕對值,改不掉比例本身。
2.4 RTF 是個未受控的變因
| 情境 | RTF | 對應車速 |
|---|---|---|
| 引擎閒置 | 0.71 | 0.56 m/s |
| 跑批次(一個場景記錄 + 看門狗 + 接觸探針) | 0.21 | 1.9 m/s |
| 批次中的 30 秒窗(n=50) | 0.00 ~ 0.56 | 變動超過 3 倍 |
🔴 「探針壓低 RTF」這個歸因被量測否掉了。 同一台、同樣閒置,只差一個場景記錄探針:
A 只有接觸探針 RTF 0.258
B 接觸探針 + 場景記錄 RTF 0.234 → 只吃掉 9%
而同樣「閒置 + 只有接觸探針」,剛重啟引擎時量到 0.707、跑完一輪批次後量到 0.258。當下的 load average 是 14.85,模擬進程 415% CPU、串流轉發 271% CPU(headless 瀏覽器編碼),GPU 只有 20%——機器是 CPU 綁住的。
RTF 由機器整體負載主導,不是掛了幾支探針。 這讓問題更難繞過:負載不是能靠「少量測」控制的東西。
⚠ 這一條直接影響實驗設計:在時鐘節拍修好之前,任何物理參數的 A/B 比較都混著 RTF 這個未受控的變因。 兩組跑在不同的機器負載下,比出來的差異可能完全不是參數造成的。
2.5 三個方案,只有一個是解
| # | 方案 | 結果 |
|---|---|---|
| 1 | 少掛探針把 RTF 拉高 | 🔴 已量測否決(探針只佔 9%) |
| 2 | Isaac 發 /clock + 控制端 use_sim_time=True |
🔴 讀碼後證偽:單獨做完全沒有作用 |
| 3 | 把串流迴圈的節拍換成模擬時鐘 | ✅ 有效 |
⚠ #2 不是白做——它是 #3 的前提。 /clock 有在發、use_sim_time=true,get_clock() 才會回模擬時間。只是單獨做不會改變任何行為,因為串流迴圈根本沒在問時鐘。
這是一個常見的誤判形狀:做了一件正確且必要的事,行為沒變,於是把它當成無效而撤掉——結果把後面那一步的地基也拆了。
2.6 🔴 換成模擬時鐘之後,一定要有牆鐘逃生閘
模擬時間只在模擬器步進時前進。模擬一暫停就永遠等不到——沒有逃生閘的話會無聲地卡死在串流迴圈裡,而節點看起來一切正常。
實作是一個逾時上限(用過 10.0 秒):超過就印警告並改用牆鐘繼續。
換句話說,§1 的錯誤上限問題與這裡是互相咬合的:物理死掉 → 模擬時鐘不前進 → 用模擬時鐘節拍的控制端會卡死。同一個系統裡,兩個獨立的長跑機制在同一個點交會。
2.7 誠實標註
- 換算式「速度 = 命令速度 ÷ RTF」只在一個工作點驗過。
- RTF 是自己用
t_wall與t_sim相除算的,t_sim取自 timeline。若 timeline 時間與 PhysX 實際步進不完全一致,這個比值會有偏差。沒有第二個獨立來源驗證過。 - 還有其他迴圈沒改:只改了載運段的串流迴圈,叉齒動作仍是牆鐘節拍。
- RTF 0.21 與 0.71 之間變的不只一項,沒有做到只改一個變因,所以只能說「RTF 會在這個範圍變動」,不能歸因到其中任何一項。
- 歷史批次沒有記模擬時間,所以不能回推它們當時的 RTF——「舊資料跨日期不可比」目前無法驗證,只是合理的懷疑。
3. 這兩件事給長跑的一般教訓
① 短測試的覆蓋範圍有結構性的洞。 累積型的失效(配額、記憶體、誤差)在短輪裡不存在,不是機率低,是在結構上不可能出現。要驗長跑就得跑長跑,沒有替代品。
② 「活著」的判準要挑最難偽裝的那一個。 容器、log、探針回應、畫面全部可以在物理死掉之後照常。挑判準時問:這個訊號在壞掉的世界裡會不會也長成這樣?(27 篇 §6 是同一條規則。)
③ 繞過一個問題時,清點自己拆掉了哪些警報。 把錯誤上限提高到 200000 之後,「模擬停止」這個訊號消失了,必須另外補一個。
④ 「無害」不等於「沒有後果」。 那 951 筆警告確實無害,而它們照樣把模擬停掉了。
⑤ 未受控的變因會汙染所有 A/B。 RTF 由機器負載主導,而機器負載不在實驗設計裡——所有在它修好之前做的參數比較,證據等級都要降一級。
4. 檢查清單
- [ ] 長跑之前,確認「還活著」的判準是模擬時鐘有沒有前進,不是容器/log/畫面
- [ ] 知道 PhysX 錯誤上限預設 1000,而且無害的 API 警告照樣計入
- [ ] 量過自己這個組態的錯誤速率(筆/分),換組態要重量
- [ ] 提高上限之後,補上替代的死亡偵測
- [ ] 修掉一類錯誤之後重看總量,不要以為問題解決了
- [ ] 控制端的節拍源與模擬器的時間源是同一個;若不是,先量 RTF
- [ ] 改用模擬時鐘節拍時,一定有牆鐘逃生閘
- [ ] 做 A/B 之前,確認 RTF(或等價的負載指標)沒有在兩組之間漂移
延伸閱讀:12 長跑維運(狀態分歧、看門狗分層、靜默失敗)、28 誤差累積與補償(時鐘分歧正是誤差注入的根因之一)、19 調參實驗的方法論、27 失效模式分類學。