本來的計畫很單純:找一份 Ray Data workload,在 B200 上錄下 .nsys-rep,再用前 26 天建立的方法分析 GPU 到底在算、在搬資料,還是在等。
實際跑起來,第一份 profile 卻遲遲沒有出現。第一次是 Ray actor 根本沒有成功啟動;修好之後,第二次是 actor 已經跑完,Nsight report 卻在匯出途中被 teardown 殺掉。兩次的表面症狀都很像——輸出目錄裡沒有可用的報告——根因卻分別落在 worker 啟動與 worker 結束兩條不同路徑。
所以 Day 27 先不急著談 Ray Data 怎麼最佳化,而是回答一個更前面的問題:**我們要如何確認「沒有 profile」是 workload 沒跑、profiler 沒啟動,還是 report 來不及寫完?**這次調查最後形成兩張分開的 Ray PR:一張修我們新開的 #66093/PR #66094,另一張處理原本就存在、也直接擋住這次實驗的 #60904/draft PR #66129。
實驗機器為 8× NVIDIA B200,driver 595.71.05、CUDA 12.9、Ray 2.58.0/Ray master、PyTorch 2.14.0+cu130,以及 Nsight Systems 2026.1.3。初次重現使用實體 GPU 4,後續乾淨 profile 依機器空檔改用實體 GPU 7;程序都透過
CUDA_VISIBLE_DEVICES只看見一張卡,因此 trace 裡統一顯示為 device 0。本文不拿不同實體卡的效能數字直接互比。
工作負載刻意做得很小,目的是先把 Ray Data 的供料與 GPU 計算分開看:
ray.data.range
→ CPU 準備 32 個 blocks
→ GPU actor:H2D → forward → D2H
每批資料是 2048×8192 的 FP32 array,約 64 MiB;CPU 準備約 40 ms,GPU 端每批做 9 次矩陣乘法,約 39 ms。兩段時間刻意設得接近,之後比較容易看出它們究竟是串行,還是已經形成 pipeline。
這裡先講清楚一件重要的事:GPU 計算本身也是走 Ray Data API,不是另外自己開 process。GPU stage 是一個 callable class,透過 map_batches 放到 GPU actor 上(num_gpus=1)。這樣 profile 到的就是 Ray Data 真實的 actor 執行路徑,包括它的 batch 形成、object store 交付與 UDF 呼叫——如果繞過 Ray Data 自己開 process,反而測不到我們想觀察的排程行為。
class GpuStage: # GPU 計算 = 一個放在 GPU actor 上的 callable class
def __init__(self):
torch.cuda.set_device(0)
self.weight = torch.eye(WIDTH, device="cuda")
def __call__(self, batch):
x = torch.tensor(batch["x"], device="cuda") # H2D
for _ in range(ITERS):
x = x @ self.weight # GPU compute
return {"out": x[:, 0].cpu().numpy()} # D2H
Ray 官方文件支援在 runtime_env 開啟 Nsight,讓 nsys profile 只包住指定的 task 或 actor。我們把設定直接傳給 map_batches 的 GPU actor,讓 .nsys-rep 只錄這個 actor、不含 driver 與 CPU operators:
ds.map_batches(
GpuStage,
batch_size=2048,
compute=ray.data.ActorPoolStrategy(size=1), # 跑在 GPU actor 上
num_gpus=1,
runtime_env={
"nsight": {
"t": "cuda,nvtx",
"sample": "none",
"cpuctxsw": "none",
"stop-on-exit": "true",
}
},
)
完整可重現的最小範例(含 split / inline 兩種模式,以及 export → nsys-ai 的指令)放在 profiles/example_min.py,也一併收在 Hugging Face dataset 裡,想 repro 可以直接拿去跑。
一開始我曾把它寫成 ray_remote_args={"runtime_env": ...},被 map_batches 直接拒絕。這只是 API 用錯,不是 Ray bug;改成上面的寫法之後,才真正進入 profiler 的啟動路徑。
理想流程應該是:Ray 建立 runtime environment、用 Nsight 包住 Python worker、worker 完成工作,最後由 Nsight 匯出 report。這次兩個問題,剛好一前一後卡在這條路徑的兩端。
改完參數後,程式沒有明確報錯,而是卡在等待 actor。Ray log 每隔一段時間就再印一次 Running nsight profiler,Nsight 輸出目錄則一直是空的。
沿著 runtime environment plugin 往下查,問題出在兩段程式使用了不同的 Python 指令:
檢查 Nsight 設定:nsys profile ... <sys.executable>
真正啟動 worker:nsys profile ... python
前面的 validation 使用目前正在執行 Ray 的 sys.executable,所以會成功;後面的 modify_context 卻把先前 runtime-env plugin 選好的 context.py_executable 整段蓋掉,換成裸的 python。如果使用者以 .venv/bin/python script.py 啟動,但沒有 activate venv,而且 PATH 裡剛好沒有 python,就會出現最麻煩的狀態:環境檢查通過,worker 實際啟動失敗,Ray 隨後持續重試。
這個 bug 的精確描述不是「Ray 不支援 venv」,而是 Nsight plugin 丟掉了前面已經選好的 Python 啟動指令。那個指令也可能來自 uv、pip、conda 或使用者指定的 py_executable,不一定只是單一 interpreter 路徑。
我用一組正反對照確認它:
| 條件 | 結果 |
|---|---|
直接用 .venv/bin/python,PATH 沒有裸 python |
worker 無聲重試,沒有 report |
同一段程式,只把 venv 的 bin 加回 PATH |
worker 正常完成,產生 report |
先前的 #53597/PR #53598 曾修正 validation 使用錯 interpreter 的問題,但沒有涵蓋真正啟動 worker 的這一行。因此我們另外建立 issue #66093,並送出第一張修正:PR #66094。
修法不是再猜一個 python 放在哪裡,而是讓 Nsight 包住既有的 context.py_executable,不要把它取代掉:
修正前:nsys profile ... python
修正後:nsys profile ... <原本選好的 Python command>
截至 2026-09-13,PR #66094 仍在等待 reviewer 核准,但 11 個 CPU-only 測試、Ray microcheck、DCO、release 與文件檢查都已通過;同一個原本會無聲重試的情境,也已在 B200 機器上驗證能正常結束並產生 report。這張 PR 解的是「worker 能不能正確啟動」。
第一個問題排除後,actor 終於能跑,新的怪事才浮出來:Ray Data 已經完成 materialize(),程式也準備結束,輸出目錄裡卻有時沒有 .nsys-rep,或者只留下尚未寫完的空檔。
這次不是 worker 啟動失敗。要理解差別,先看 process 關係:
raylet
└── nsys profile ... # profiler launcher,負責最後匯出 report
└── Python worker # 真正執行 Ray actor
--stop-on-exit=true 的意思是目標 Python worker 結束後,Nsight 開始收尾並匯出報告;它不代表 worker 一結束,.nsys-rep 就已經完整落盤。若 driver 在這個空窗立刻呼叫 ray.shutdown(),raylet teardown 會清理 worker process group,連仍在寫檔的 nsys launcher 一起終止。
我把完整 Ray Data workload 縮成一個單 actor 的最小重現,只改 teardown 時機:
| teardown | 結果 |
|---|---|
| worker 結束後,driver 等 report 寫完再關閉 | REP OK,約 991 KB |
ray.get() 回傳後立刻 ray.shutdown() |
REP MISSING |
這個 A/B 把「偶爾沒有資料」收斂成穩定可重現的 teardown race,也補上既有 #60904 一直缺少的最小案例。暫時 workaround 是在 ray.shutdown() 前等待 report 出現並確認檔案非空;但 workaround 只能救這次實驗,不是框架層的解法。
真正的修正必須進到 raylet 的 C++ shutdown 路徑:辨認由 profiler 包住的 worker,在正常 graceful shutdown 時,等 Python worker 結束後留一段有上限的 flush 時間給 launcher,再清理 process group。這不能只在 driver 寫一個固定 sleep(10),也不能阻塞 raylet 處理 worker disconnect,否則修正 report 遺失時,反而可能製造新的 shutdown deadlock。
第二張修正已送成 draft PR #66129,專門處理 #60904 中這條可重現的 teardown report 遺失路徑。PR body 依序交代 launcher/child 的根因、wait/immediate A/B、由 fake-clock 測試保護的生命週期不變量,以及明確的 out-of-scope;另外附上 B200 build、3/3 重現、三個 C++ 測試與 AI 協助揭露。Commit message 也保留同一套根因與範圍說明,並附 DCO sign-off。
因為它們只是症狀相似,責任範圍完全不同:
| 第一張 PR | 第二張 PR | |
|---|---|---|
| 對應問題 | 我們建立的 #66093 | 原本就存在、擋住本次實驗的 #60904 中,teardown 導致 report 遺失的路徑 |
| 失敗階段 | worker 啟動前 | worker 結束後 |
| 根因 | Nsight plugin 蓋掉既有 Python command | raylet 在 report 匯出完成前清掉 profiler |
| 修正位置 | Python runtime-env plugin | C++ worker/raylet lifecycle |
| PR 狀態 | #66094 已開,checks 全綠、等待核准 | #66129 已開為 draft,等待 CI 與 review |
把兩者塞進同一張 PR,reviewer 必須同時檢查 command composition 與跨 process 的 shutdown lifecycle;任何回歸也很難判斷是哪一半造成。分開後,每張 PR 都有自己的最小重現、測試範圍與 rollback 邊界:第一張保證「啟動指令不被弄丟」,第二張保證「正常關閉時讓 profiler 有機會寫完」。
第一個問題先套用 #66094 的修正,第二個問題則暫時在 driver 等待 report 寫完,我們才拿到可以交給 nsys-ai(commit b36c4bd)的 patched_split.sqlite:
nsys-ai skill run gpu_idle_gaps patched_split.sqlite \
--trim 3.619 5.430 -p device=0
這段穩態窗口裡有 324 個 kernels。nsys-ai 顯示 565.95 ms 沒有 kernel 執行,佔窗口 31.3%;其中 339.6 ms 其實有 H2D copy 正在進行,真正連 kernel 與 copy 都沒有的等待約 226 ms。
今天先停在這裡。這幾個數字已經透露出 Ray Data pipeline 同時有「搬資料」與「等資料」兩種空檔,但現在直接跳到 pinned memory 或增加 concurrency,仍然只是建議,還不是最佳化成果。Day 28 會從這份終於可信的 profile 出發,對回 Ray Data 程式碼,再用成對實驗驗證哪個改動真的縮短處理時間。
這次原本要 profile Ray Data,最後卻先走完兩次縮小問題的流程:第一個「沒有 report」是 worker 啟動指令被換掉;第二個「沒有 report」是 worker 已完成,但 profiler 在匯出途中被關掉。若只看輸出資料夾,兩者幾乎一模一樣;只有把 validation、worker 與 launcher 的生命週期拆開,才看得出應該改哪一層。
這也是今天最重要的結論:**profile 不是檔案出現就自動成為證據;產生這份檔案的路徑,也必須先被驗證。**兩張 PR 分別修正啟動與結束路徑;排除這兩道關卡之後,明天才正式開始回答最初的問題:Ray Data 為什麼讓 GPU 等,以及要從哪一段 pipeline 下手。
RuntimeEnv API
notes.md