一個跑著推理服務的 GPU 叢集,要知道它現在的狀況,數字來自三個不同的地方。卡自己回報忙不忙、健不健康、吃多少電;Kubernetes 知道哪張卡配給了誰;推理引擎知道請求排了多久、延遲多少。
這三組數字回答的是不同的問題,而容易出錯的地方是拿其中一組去回答另一組的問題。使用率很高不代表服務正常。
今天把這三層逐層看過:每一層有哪些指標、各自在量什麼、哪些預設拿不到,以及哪些數字本身帶著條件。今天只談這些指標本身,沒有實際去啟用 Prometheus 和 Grafana。
| 層 | 誰出 | 回答什麼 |
|---|---|---|
| 硬體 | DCGM Exporter | 這張卡忙不忙、忙在哪裡、健不健康、吃多少電 |
| 叢集 | Kubernetes 的 API 和指標上的標籤 | 這張卡配給了誰、配出去了沒有 |
| 引擎 | vLLM 的 /metrics |
請求排多久、KV cache 用了幾成、首字多快 |
實際會問的問題多半跨層。卡很忙,是誰在用?某個服務很慢,是卡的問題還是排隊的問題?要回答這種問題,三層之間必須有共同的識別欄位。
DCGM Exporter 把 GPU 的狀態變成 Prometheus 格式。它的欄位分三類:清單裡開著的、清單裡被註解掉要自己打開的、以及 DCGM_EXP_ 開頭的。
前兩類是從卡讀出來的,實際吐出哪些由那份 collector 的 CSV 決定。第三類不是從卡讀的,是 exporter 自己算出來的。
上游有兩份預設的 CSV,dcp-metrics-included.csv 和 default-counters.csv,裡面開著和註解掉的欄位不一樣。DCGM_EXP_ 那組只出現在後者,而且六行全是註解掉的。所以 exporter 讀的是前者的時候,那組連取消註解都做不到,要整份清單換掉。
| 欄位 | 量什麼 | 預設 |
|---|---|---|
DCGM_FI_DEV_GPU_UTIL |
過去一個取樣區間裡,有一個以上的 kernel 在卡上執行的時間百分比 | 開 |
DCGM_FI_PROF_GR_ENGINE_ACTIVE |
圖形或計算引擎有任何一部分在動的時間比例 | 開 |
DCGM_FI_PROF_SM_ACTIVE |
每個 SM 上至少有一個 warp 活著的時間比例,對所有 SM 平均 | 關 |
DCGM_FI_PROF_SM_OCCUPANCY |
SM 上駐留的 warp 數,相對於它能同時容納的最大值 | 關 |
DEV_GPU_UTIL 只問有沒有 kernel 在執行,不問那個 kernel 用了多少運算單元。所以它接近 100% 的時候,完全可能只有一小部分硬體在動。GR_ENGINE_ACTIVE 也是這一類。
DEV_GPU_UTIL 的取樣區間在 1 秒到 1/6 秒之間,依產品而定。PROF_ 那組沒有給這個數字,只說值是一段區間的平均,不是瞬時值。
後兩個量的是 SM 內部的狀況。
SM_ACTIVE 要到 0.8 以上才算有效利用這張卡,而那是必要條件不是充分條件;低於 0.5 則很可能是沒有有效利用。另外它的 active 不等於正在運算,一個在等記憶體回來的 warp 也算 active。
SM_OCCUPANCY 要不要看,取決於工作負載卡在哪裡。受記憶體頻寬限制的時候,這個值高表示卡用得比較有效;受算力限制的時候,高就不一定代表什麼。
這兩個欄位預設是關的。
| 欄位 | 量什麼 |
|---|---|
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE |
tensor 管線(HMMA 和 IMMA)活躍的週期比例。值為 1 等於每隔一個週期就發一道 tensor 指令 |
DCGM_FI_PROF_DRAM_ACTIVE |
有資料送往或來自記憶體的週期比例。值為 1 等於整段時間每個週期都有一道記憶體指令,而實務上做得到的峰值大約是 0.8 |
DCGM_FI_DEV_MEM_COPY_UTIL |
過去一個取樣區間裡,記憶體被讀或被寫的時間百分比 |
DCGM_FI_DEV_FB_USED / FB_FREE / FB_RESERVED |
記憶體用了、剩下、保留了多少 |
PIPE_TENSOR_ACTIVE 和 DRAM_ACTIVE 這兩個擺在一起看,才分得出瓶頸在算力還是在搬資料。推理的 decode 階段是逐個 token 產出,每產一個都要把權重再讀一遍,所以典型的形狀是頻寬吃得很重而 tensor 管線很低。
名字裡有 utilization 的欄位有四個:DEV_GPU_UTIL、DEV_MEM_COPY_UTIL、DEV_ENC_UTIL、DEV_DEC_UTIL。其中 DEV_MEM_COPY_UTIL 的名字最容易引起誤解,它量的是記憶體有多少時間在被讀寫,不是記憶體用了幾成;要看容量得用 FB_USED 那一組。它的取樣區間跟 DEV_GPU_UTIL 一樣,也是 1 秒到 1/6 秒。
還有一件事要先知道:這些比例的分母不一樣。PROF_ 那組各自寫明了自己是什麼的比例,GR_ENGINE_ACTIVE 算的是時間而 DRAM_ACTIVE 算的是週期。DEV_ 那組只給一個百分比,分母要去翻定義。
XID 是驅動發出的錯誤報告,寫在作業系統的 kernel log 裡,每一種錯誤有一個固定的編號,而同一個編號在不同的驅動版本之間意思一樣。它說的是這張卡上發生了一個錯誤,但來源可能是硬體、驅動本身,也可能是應用程式去碰了不該碰的記憶體,所以同一個編號背後的原因不只一種。
| 欄位 | 看什麼 | 預設 |
|---|---|---|
DCGM_FI_DEV_XID_ERRORS |
最後一次遇到的 XID 錯誤的值,樣本上帶 err_code 和 err_msg 標籤 |
開 |
DCGM_EXP_XID_ERRORS_COUNT |
設定的時間窗內觀察到的 XID 錯誤,按 xid 分組 |
關 |
DCGM_EXP_XID_ERRORS_TOTAL |
exporter 啟動以來觀察到的 XID 錯誤總數,按 xid 分組 |
關 |
DCGM_FI_DEV_CORRECTABLE_REMAPPED_ROWS |
可修正錯誤造成的記憶體壞格重映射數 | 開 |
DCGM_FI_DEV_UNCORRECTABLE_REMAPPED_ROWS |
不可修正錯誤造成的重映射數 | 開 |
DCGM_FI_DEV_ROW_REMAP_FAILURE |
重映射失敗了沒有 | 開 |
DCGM_EXP_GPU_HEALTH_STATUS |
DCGM 回報的當前健康狀態 | 關 |
DCGM_EXP_CLOCK_EVENTS_COUNT |
設定的時間窗內觀察到的時鐘事件,按 clock_event 分組 |
關 |
DCGM_EXP_CLOCK_EVENTS_TOTAL |
exporter 啟動以來的時鐘事件總數,數的是狀態轉換 | 關 |
DEV_XID_ERRORS 是一個 gauge,只留得住最後一次,所以兩次抓取之間發生的事會被蓋掉。DCGM_EXP_ 那兩個就是為了這件事存在的:COUNT 數的是設定的時間窗內觀察到的,TOTAL 數的是 exporter 啟動以來的累計,而 exporter 啟動或 collector 因為熱重載被重建的時候會歸零。
這兩組 DCGM_EXP_ 的樣本上都會帶一個 window_size_in_ms 標籤,寫的是那個時間窗有多長,所以讀到的數字知道是多久之內的。
時鐘事件那一對的語意跟 XID 那一對不完全一樣:CLOCK_EVENTS_TOTAL 數的是狀態從不活躍轉成活躍的次數,不是樣本筆數,所以一次持續的限流只會加一。
限流的原因(功耗上限、硬體降速、軟體或硬體溫度保護、功率剎車)有兩條路可以拿:原始欄位 DCGM_FI_DEV_CLOCKS_EVENT_REASONS,或 exporter 從它派生的 DCGM_EXP_CLOCK_EVENTS_ 那組。
兩條預設都拿不到,但狀態不一樣。派生那組在清單裡被註解掉,取消註解就有;原始欄位連註解都沒有,要自己加一行進去。
另外有一組記錄違規時間的計數器(功耗、溫度、同步加速、板卡上限、低使用率、可靠性),同樣預設關著。
| 欄位 | 看什麼 |
|---|---|
DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL |
NVLink 上所有通道加起來的吞吐 |
DCGM_EXP_P2P_STATUS |
每一條 peer link 的 NVLink 點對點狀態 |
DCGM_FI_PROF_PCIE_TX_BYTES / RX_BYTES |
PCIe 上每秒傳出和收到的位元組,含協定標頭 |
DCGM_FI_DEV_PCIE_REPLAY_COUNTER |
PCIe 重試次數 |
卡與卡之間走 NVLink,卡跟主機之間走 PCIe。模型切到多張卡上跑的時候,前兩個欄位看的是那些卡互相傳資料傳得順不順;而權重和輸入從主機搬進卡裡走的是 PCIe。
前兩列在單張卡的機器上是空的,要兩張以上的卡才有東西可看。
| 欄位 | 看什麼 |
|---|---|
DCGM_FI_DEV_POWER_USAGE |
瞬時功耗,是一個 gauge |
DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION |
驅動上次重載以來的累積能耗,是一個 counter |
要知道一段時間花了多少電,用累積能耗的前後差值。瞬時功耗的更新頻率有限,拿它去積分會把間隔的誤差帶進答案。
而那個累積量的起點是驅動上次重載,不是開機。驅動重載不需要重開機,所以這個 counter 會在跟重開機無關的時候歸零。
第一是多工。 PROF_ 那組讀的是硬體的 performance counter,數量有限,所以有些指標要分多次才採得完,不是全部都能一起採。DCGM 把欄位分組,同一個字母組的不同子組之間用統計取樣分批採集,不同字母組可以同時採。分組取決於硬體,例如 SM 活躍度和 tensor 使用率在某些卡上不能一起採,在另一些卡上可以。要知道手上這張卡怎麼分,dcgmi profile -l -i 0 會印出來。
多工還有一個副作用:採樣頻率拉高的時候會回 0。那個 0 跟卡閒著的 0 在圖上沒有分別。
第二是採集間隔。 Exporter 的 --collect-interval 預設是 30000 毫秒,也就是 30 秒換一次值。如果抓取的間隔比這個短,Prometheus 裡會有連續好幾個點是同一個值,而那不是空值,所以畫出來是一條看起來正常的曲線。從這種資料算平均、相關係數或 rate,精度是編的。
下面的數字來自一台 g6e.2xlarge、一張 L40S,GPU 由 DRA 分配。上面跑 Qwen2.5-7B-Instruct 的 vLLM,負載是 16 路併發、短 prompt、輸出跑滿 1024 個 token,所以 prefill 只有兩個 token,整段幾乎是純 decode。
PROF_ 那組有多工的限制,所以先確認這幾個欄位在這張卡上能不能一起採:
dcgmi profile -l -i 0
+----------------+----------+------------------------------------------------------+
| Group.Subgroup | Field ID | Field Tag |
+----------------+----------+------------------------------------------------------+
| A.1 | 1002 | sm_active |
| A.1 | 1003 | sm_occupancy |
| A.1 | 1004 | tensor_active |
| A.1 | 1007 | fp32_active |
| A.3 | 1008 | fp16_active |
| B.0 | 1005 | dram_active |
| C.0 | 1009 | pcie_tx_bytes |
| C.0 | 1010 | pcie_rx_bytes |
| D.0 | 1001 | gr_engine_active |
+----------------+----------+------------------------------------------------------+
tensor_active 在 A.1、dram_active 在 B.0、gr_engine_active 在 D.0,三個不同的字母組,所以下面那幾個數字是同一刻測到的。這張卡上 sm_active 和 sm_occupancy 跟 tensor_active 同在 A.1,所以打開它們不會多出一輪採集。
閒置,模型已經載入、沒有請求。namespace="d28" 是那個工作負載所在的 namespace,用來挑出屬於它的那一條 series:
curl -s localhost:9400/metrics | grep 'namespace="d28"' | awk -F'[{ ]' '/^DCGM_FI_(DEV_GPU_UTIL|DEV_MEM_COPY_UTIL|PROF_GR_ENGINE_ACTIVE|PROF_DRAM_ACTIVE|PROF_PIPE_TENSOR_ACTIVE)/ {print $1, $NF}'
DCGM_FI_DEV_GPU_UTIL 0
DCGM_FI_DEV_MEM_COPY_UTIL 0
DCGM_FI_PROF_DRAM_ACTIVE 0.004969
DCGM_FI_PROF_GR_ENGINE_ACTIVE 0.005819
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE 0
兩個整數百分比的欄位是 0,而三個 PROF_ 欄位裡有兩個還看得到 0.5% 的底噪。
滿載,每五秒取一次,取十次:
for i in $(seq 10); do printf '%2d ' "$i"; curl -s localhost:9400/metrics | grep 'namespace="d28"' | awk -F'[{ ]' '/^DCGM_FI_(DEV_GPU_UTIL|DEV_MEM_COPY_UTIL|PROF_GR_ENGINE_ACTIVE|PROF_DRAM_ACTIVE|PROF_PIPE_TENSOR_ACTIVE)/ {sub(/^DCGM_FI_/,"",$1); printf "%s=%s ", $1, $NF}'; echo; sleep 5; done
1 DEV_GPU_UTIL=97 DEV_MEM_COPY_UTIL=100 PROF_DRAM_ACTIVE=0.569529 PROF_GR_ENGINE_ACTIVE=0.676652 PROF_PIPE_TENSOR_ACTIVE=0.020159
2 DEV_GPU_UTIL=97 DEV_MEM_COPY_UTIL=100 PROF_DRAM_ACTIVE=0.569529 PROF_GR_ENGINE_ACTIVE=0.676652 PROF_PIPE_TENSOR_ACTIVE=0.020159
3 DEV_GPU_UTIL=97 DEV_MEM_COPY_UTIL=100 PROF_DRAM_ACTIVE=0.569529 PROF_GR_ENGINE_ACTIVE=0.676652 PROF_PIPE_TENSOR_ACTIVE=0.020159
4 DEV_GPU_UTIL=97 DEV_MEM_COPY_UTIL=100 PROF_DRAM_ACTIVE=0.569529 PROF_GR_ENGINE_ACTIVE=0.676652 PROF_PIPE_TENSOR_ACTIVE=0.020159
5 DEV_GPU_UTIL=96 DEV_MEM_COPY_UTIL=99 PROF_DRAM_ACTIVE=0.569529 PROF_GR_ENGINE_ACTIVE=0.676652 PROF_PIPE_TENSOR_ACTIVE=0.020159
6 DEV_GPU_UTIL=96 DEV_MEM_COPY_UTIL=99 PROF_DRAM_ACTIVE=0.808647 PROF_GR_ENGINE_ACTIVE=0.965353 PROF_PIPE_TENSOR_ACTIVE=0.029083
7 DEV_GPU_UTIL=96 DEV_MEM_COPY_UTIL=99 PROF_DRAM_ACTIVE=0.808647 PROF_GR_ENGINE_ACTIVE=0.965353 PROF_PIPE_TENSOR_ACTIVE=0.029083
8 DEV_GPU_UTIL=96 DEV_MEM_COPY_UTIL=99 PROF_DRAM_ACTIVE=0.808647 PROF_GR_ENGINE_ACTIVE=0.965353 PROF_PIPE_TENSOR_ACTIVE=0.029083
9 DEV_GPU_UTIL=96 DEV_MEM_COPY_UTIL=99 PROF_DRAM_ACTIVE=0.808647 PROF_GR_ENGINE_ACTIVE=0.965353 PROF_PIPE_TENSOR_ACTIVE=0.029083
10 DEV_GPU_UTIL=96 DEV_MEM_COPY_UTIL=99 PROF_DRAM_ACTIVE=0.808647 PROF_GR_ENGINE_ACTIVE=0.965353 PROF_PIPE_TENSOR_ACTIVE=0.029083
| 欄位 | 閒置 | 滿載 | 這個數字的意思 |
|---|---|---|---|
DEV_GPU_UTIL |
0 | 96 到 97 | 只問有沒有 kernel 在跑,所以接近 100 |
PROF_GR_ENGINE_ACTIVE |
0.0058 | 0.677 到 0.965 | 同樣是有在動就高 |
PROF_PIPE_TENSOR_ACTIVE |
0 | 0.020 到 0.029 | Tensor Core 只用了 2 到 3% |
PROF_DRAM_ACTIVE |
0.0050 | 0.570 到 0.809 | 瓶頸在搬資料,而 0.809 已經到實務峰值 |
DEV_MEM_COPY_UTIL |
0 | 99 到 100 | 跟 GPU_UTIL 一樣粗,分得出差別的是 DRAM_ACTIVE |
使用率 96% 配 tensor 管線 2%,就是 decode 的形狀。而這組數字如果只看 DEV_GPU_UTIL 那一列,結論會是這張卡用滿了。
DRAM_ACTIVE 的 0.808647 已經是實務上的峰值,所以這不是頻寬吃得重,是頻寬到頂了。這個工作負載受的是記憶體頻寬的限制,而受頻寬限制的時候 SM_OCCUPANCY 才是有意義的那個指標,偏偏它預設關著。
另外十次取樣只拿到兩組值,在第 5 和第 6 次之間換了一次。每五秒抓一次而值 30 秒才換,所以抓到的多半是同一個值。
硬體指標本身只認得卡,不認得 Pod。要把兩者接起來需要額外的設定。
| 標籤 | 什麼時候出現 | 預設 |
|---|---|---|
gpu、UUID、pci_bus_id、device、modelName |
一直都有,這是卡的身分 | 有 |
hostname |
預設就加,--no-hostname 才會拿掉 |
有 |
pod、namespace、container |
開 Kubernetes 模式,而且 pod-resources 的對應成功 | 無 |
pod_uid |
另外開啟 Pod UID 收集 | 無 |
dra_claim_name、dra_claim_namespace、dra_driver_name、dra_pool_name、dra_device_name |
開啟 DRA 對應而且對得上 | 無 |
GPU_I_PROFILE、GPU_I_ID |
MIG 的實例 | 看硬體 |
這張表的預設不一樣:hostname 本來就有,要關掉才動旗標;Pod 那三個和 dra_ 那組本來都沒有,要開才有。
pod、namespace、container 這三個是傳統的路徑,靠的是 kubelet 的 pod-resources 對應。而 DRA 配出來的卡走的是另一條,要那組 dra_ 標籤才知道是哪一份 claim 把卡配給了誰。
不透過 exporter 也有三個管道可以看動態配置的資源:kubelet 的裝置指標、ResourceClaim 的 status、以及裝置健康監控。
第一個是 kubelet 的 PodResourcesLister 這個 gRPC 服務,裡面有一個 DynamicResource 訊息,帶裝置名字和 claim 名字。exporter 那組 dra_ 標籤提供的是同一批資訊。
監控程式要看到節點上每一張卡,但不能把卡佔走。DRA 用兩個欄位表達這件事。
allocationMode: All 要的是 pool 裡全部符合條件的裝置,而 pool 怎麼切是驅動決定的,GPU 的驅動通常一個節點一個 pool。adminAccess: true 是給監控和管理服務用的,它不理其他人對同一張卡的存取模式和資源配置。
allocationMode: All 單獨用的時候碰到已經配出去的卡會失敗,adminAccess: true 就是那個例外。而它不是誰都能寫,namespace 上要有 resource.kubernetes.io/admin-access=true 這個標籤。
後果是:同一張卡上,監控程式持有一份 claim,工作負載也持有一份,而指標是按持有者打標籤的。於是同一個欄位、同一張卡會出現兩條 series,數值一樣而歸屬不同。
前面那台機器上抓同一個功耗欄位,不過濾的話是兩條:
curl -s localhost:9400/metrics | grep '^DCGM_FI_DEV_POWER_USAGE' | awk '{print $NF}'
115.595
115.595
而那張卡實際的功耗是:
nvidia-smi --query-gpu=power.draw --format=csv,noheader
115.63 W
sum() 會得到 231.19 W,正好兩倍。所以讀 DCGM 指標之前要先用 namespace 過濾掉監控自己那一份。
卡忙不忙跟服務好不好是兩件事。使用率很高的時候,請求可能全都在排隊;使用率很低的時候,也可能只是因為沒有請求進來。要看服務得問引擎。
vLLM 把指標開在 /metrics,格式是 Prometheus 的。
| 指標 | 看什麼 |
|---|---|
vllm:num_requests_running |
在模型執行批次裡的請求數 |
vllm:num_requests_waiting |
等著的請求數 |
vllm:kv_cache_usage_perc |
被用掉的 KV cache block 的比例,0 到 1 |
vllm:time_to_first_token_seconds |
首個 token 的時間 |
vllm:request_time_per_output_token_seconds |
按請求算的每 token 時間 |
vllm:inter_token_latency_seconds |
連續兩次輸出之間的時間 |
vllm:e2e_request_latency_seconds |
從頭到尾的請求延遲 |
vllm:prefix_cache_queries / vllm:prefix_cache_hits |
prefix cache 的查詢和命中 |
vllm:prompt_tokens_total / vllm:generation_tokens_total |
輸入和產出的 token 累計數 |
kv_cache_usage_perc 的名字帶著 perc,但它是 0 到 1 的比例,不是 0 到 100。
延遲那四個是直方圖而不是單一數值,所以要取分位數。而每 token 的時間有兩個指標,一個按請求算,一個算相鄰兩次輸出之間的間隔,兩者不是同一個東西。
prefix cache 的命中記成兩個 counter 而不是一個命中率的 gauge,因為比率要在查詢的時候算才有意義。這兩個 counter 數的是 token 數,所以算出來的是 token 層級的命中率,不是請求層級的。
單看任何一層都會得到誤導的結論。
| 只看這一層 | 會得到的錯誤結論 |
|---|---|
| 硬體 | 使用率 97% 等於卡用滿了。實際上可能只有一小部分運算單元在動 |
| 叢集 | claim 配出去了等於服務正常。配到卡跟跑得起來是兩件事 |
| 引擎 | 延遲很好等於資源夠。也可能是因為沒有請求進來 |
把三層擺在一起看,靠的是硬體指標上那組身分標籤。沒有 pod 和 namespace,剩下的就是一堆按 GPU UUID 索引的數字,和一堆按 Pod 名字索引的數字,兩邊只能人工對。
Grafana 官方庫裡那份 NVIDIA DCGM Exporter Dashboard 就是這樣:它的模板變數只有 instance 和 gpu,沒有一格是按 Pod 切的,所以在一台多卡多租戶的機器上它回答不了誰在用這張卡。
硬體那一層要看的不只是使用率。DEV_GPU_UTIL 只問有沒有 kernel 在跑,而看得到 SM 內部的 SM_ACTIVE 和 SM_OCCUPANCY 預設是關的。瓶頸在算力還是頻寬要靠 PIPE_TENSOR_ACTIVE 對 DRAM_ACTIVE 才分得出來,而受頻寬限制的工作負載正好是 SM_OCCUPANCY 有意義的那種。健康那一組裡 XID 和記憶體壞格是預設開的,限流的原因不是。
叢集那一層的工作是把卡對到 Pod。DRA 配出來的卡走的是 dra_ 那組標籤,不是傳統的 pod-resources 路徑。而監控程式自己也持有一份 claim,所以同一張卡會有兩條 series,加總前要先過濾。
引擎那一層回答服務好不好,而那跟卡忙不忙沒有必然關係。延遲的指標是直方圖,看分位數不看平均。
最後是這些數字可不可信。PROF_ 那組有多工的限制,頻率拉高會回 0;而 exporter 預設 30 秒才換一次值,抓得比這個快只會拿到同一個值。這兩件事都不會讓圖表看起來有問題,所以要先知道。
DCGM Exporter Metrics - NVIDIA
DCGM Feature Overview - NVIDIA
dcgm-exporter 命令列參考 - NVIDIA
DCGM Exporter - NVIDIA Cloud Native
NVIDIA/dcgm-exporter
dcgm-exporter dcp-metrics-included.csv
dcgm-exporter default-counters.csv
Observability of Dynamic Resources - Kubernetes
Dynamic Resource Allocation - Kubernetes
nvmlUtilization_t - NVML API Reference
Xid Errors - NVIDIA DCGM Field Identifiers - NVIDIA
NVIDIA DCGM Exporter Dashboard, Grafana
resource/v1 types.go - kubernetes/kubernetes
Production Metrics - vLLM
Metrics design - vLLM