从GPU内核到Token延迟:eBPF在LLM推理可观测性中的工程实践
当你的LLM推理服务P99延迟突然从200ms飙到2s,GPU利用率显示99%,你如何定位瓶颈是在CUDA内核、PCIe传输、还是KV Cache换页?传统APM工具在此刻集体失声——它们看得见HTTP请求,却看不见GPU SM里的Warp Scheduler在等待什么。本文将系统讲解如何利用eBPF从内核态穿透到GPU驱动层,构建LLM推理全链路可观测性体系。
一、为什么LLM推理需要新的可观测性方案
1.1 传统监控的盲区
LLM推理服务与常规Web服务有本质区别:
常规请求:Client → API Gateway → Business Logic → DB → Response
LLM推理:Client → API Gateway → Tokenize → Prefill(KV生成) → Decode(逐Token) → Response
↑
GPU Kernel执行
内存分配/释放
NCCL通信(多卡)
传统APM能追踪HTTP span,但对于GPU内部的执行细节几乎完全无感知。当用户反馈"响应慢"时,你只能看到容器层面的CPU/内存指标,真正的瓶颈隐藏在CUDA驱动的黑盒中。
1.2 LLM推理的延迟分解
一次LLM推理延迟可以拆解为:
| 阶段 | 典型耗时占比 | 可观测性难度 |
|---|---|---|
| Tokenization | 1-5% | 低(CPU,可ptrace) |
| Prefill(首次前推) | 10-40% | 高(GPU长时kernel) |
| Decode(逐Token生成) | 50-85% | 极高(短kernel+内存bound) |
| KV Cache管理 | 5-20% | 高(驱动层) |
| 多卡通信(NCCL) | 0-30% | 极高(驱动/硬件) |
核心难点:Decode阶段每个Token的kernel执行时间可能只有几十微秒,传统采样profiling难以捕捉,而eBPF的uprobe/tracepoint组合可以实现微秒级精准追踪。
1.3 CUDA Profiling的局限
nvprof/nsys等工具存在生产环境部署障碍:
- 开销大:nsys采集时性能下降30%-70%,不可用于生产
- 侵入式:需要重启进程并注入库,无法attach到运行中服务
- 数据孤岛:CUDA事件与系统级事件(调度、IO、网络)时间线分离
- 无上下文:不知道某个CUDA kernel对应的是哪个用户请求
二、eBPF穿透GPU驱动层的技术路径
2.1 CUDA驱动调用链分析
┌─────────────────────────────────────────────────────────┐
│ User Space │
│ Application (vLLM/SGLang/TGI) │
│ │ │
│ ▼ │
│ CUDA Runtime API (cudaMalloc, cudaLaunchKernel, ...) │
│ │ │
│ ▼ │
│ CUDA Driver API (cuMemAlloc, cuLaunchKernel, ...) │
├─────────────────────────────────────────────────────────┤
│ Kernel Space │
│ nvidia.ko (内核模块) │
│ - nvidia_ioctl (用户态入口) │
│ - GPU调度/内存管理/DMA │
└─────────────────────────────────────────────────────────┘
2.2 四层追踪策略
第1层:用户态uprobe - 追踪cudaLaunch*系列调用
→ 捕获kernel名称、grid/block维度、shared memory
→ 低开销,可生产部署
第2层:tracepoint - 追踪nvidia内核模块的ioctl入口
→ 捕获GPU内存分配/映射事件
→ 配合cuMemAlloc追踪KV Cache生命周期
第3层:kprobe - 追踪DMA-Buf/RDMA子系统
→ 多卡推理时的跨卡通信瓶颈
→ GPUDirect Storage加载权重
第4层:ARM PMU/NVTX - 硬件性能计数器(需特殊配置)
→ SM活跃率、Tensor Core利用率、显存带宽
→ 高开销,仅用于定位阶段
三、核心实现:LLM推理全链路追踪eBPF程序
3.1 程序骨架
// llm_trace.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
// 请求上下文映射:pid+tid → request_id
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536);
__type(key, u64); // pid_tgid
__type(value, u64); // request_id (从用户态注入)
} request_ctx SEC(".maps");
// CUDA kernel执行记录
struct kernel_event {
u64 timestamp_ns;
u32 pid;
u32 tid;
u64 request_id;
char kernel_name[64];
u32 grid_size;
u32 block_size;
u32 shared_mem;
u64 duration_ns;
};
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} kernel_events SEC(".maps");
// 追踪cudaLaunchKernel入口
SEC("uprobe/cudaLaunchKernel")
int BPF_KPROBE(trace_cuda_launch,
const void* function,
dim3 gridDim, dim3 blockDim,
size_t sharedMem, cudaStream_t stream)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 *req_id = bpf_map_lookup_elem(&request_ctx, &pid_tgid);
if (!req_id)
return 0; // 不在追踪范围内
struct kernel_event evt = {};
evt.timestamp_ns = bpf_ktime_get_ns();
evt.pid = pid_tgid >> 32;
evt.tid = pid_tgid;
evt.request_id = *req_id;
evt.grid_size = gridDim.x * gridDim.y * gridDim.z;
evt.block_size = blockDim.x * blockDim.y * blockDim.z;
evt.shared_mem = (u32)sharedMem;
// 尝试读取kernel名称(通过函数地址反查symbol table)
bpf_probe_read_kernel_str(evt.kernel_name, sizeof(evt.kernel_name),
(void*)DECODE_FUNC_PTR(function));
bpf_perf_event_output(ctx, &kernel_events, BPF_F_CURRENT_CPU,
&evt, sizeof(evt));
return 0;
}
char LICENSE[] SEC("license") = "GPL";
3.2 用户态Agent:关联请求与Kernel
# llm_observer.py - 用户态收集器核心
import ctypes
import json
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
frombcc import BPF
@dataclass
class InferenceRequest:
request_id: str
arrived_at: float
tokens_in: int
tokens_out: int = 0
kernels: list = field(default_factory=list)
stage_timings: Dict[str, float] = field(default_factory=dict)
ttft: Optional[float] = None # Time To First Token
class LLMInferenceObserver:
"""eBPF驱动的LLM推理可观测性收集器"""
def __init__(self, bpf_prog_path: str, model_name: str):
self.bpf = BPF(src_file=bpf_prog_path)
self.model_name = model_name
self.active_requests: Dict[str, InferenceRequest] = {}
# 加载uprobe,attach到目标进程的cudaLaunchKernel
self.bpf.attach_uprobe(
name="libcuda.so",
sym="cudaLaunchKernel",
fn_name="trace_cuda_launch"
)
# 设置perf buffer回调
self.bpf["kernel_events"].open_perf_buffer(
self._handle_kernel_event
)
def _handle_kernel_event(self, cpu, data, size):
"""处理来自eBPF的kernel事件"""
evt = self.bpf["kernel_events"].event(data)
# 按request_id聚合kernel信息
req = self.active_requests.get(evt.request_id)
if not req:
return
kernel_info = {
"name": evt.kernel_name.decode('utf-8', errors='replace'),
"grid": evt.grid_size,
"block": evt.block_size,
"shared_mem": evt.shared_mem,
"timestamp_ns": evt.timestamp_ns,
}
# 阶段识别:根据kernel名称推断所处推理阶段
stage = self._classify_kernel(kernel_info["name"])
kernel_info["stage"] = stage
req.kernels.append(kernel_info)
def _classify_kernel(self, kernel_name: str) -> str:
"""根据CUDA kernel名称判断推理阶段"""
name_lower = kernel_name.lower()
if any(k in name_lower for k in ["prefill", "context"]):
return "prefill"
elif any(k for k in ["decod", "generate"] if k in name_lower):
return "decode"
elif "nccl" in name_lower or "allreduce" in name_lower:
return "communication"
elif "softmax" in name_lower or "layernorm" in name_lower:
return "attention"
elif "gemm" in name_lower or "hgemm" in name_lower:
return "matmul"
else:
return "other"
def poll(self, timeout_ms: int = 100):
"""轮询eBPF事件"""
self.bpf.perf_buffer_poll(timeout_ms)
def end_request(self, request_id: str) -> InferenceRequest:
"""结束请求追踪,生成报告"""
req = self.active_requests.pop(request_id)
# 计算各阶段耗时
for stage in ["prefill", "decode", "communication", "attention", "matmul"]:
stage_kernels = [k for k in req.kernels if k["stage"] == stage]
if stage_kernels:
req.stage_timings[stage] = len(stage_kernels)
return req
3.3 完整的推理追踪Pipeline
# deploy/llm-observer-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: llm-inference-observer
spec:
template:
spec:
hostPID: true # 关键:需要访问宿主机进程空间
containers:
- name: observer
image: ybb-pres/llm-ebpf-observer:v1.2.0
securityContext:
privileged: true # eBPF需要CAP_BPF + CAP_PERFMON
volumeMounts:
- name: tracefs
mountPath: /sys_kernel/debug/tracing
- name: modules
mountPath: /lib/modules
readOnly: true
env:
- name: OBSERVE_PID # 目标推理进程PID
valueFrom:
fieldRef:
fieldPath: metadata.annotations['inference.svc/pid']
- name: MODEL_NAME
value: "llama-3-70b"
- name: SAMPLE_RATE # 采样率:生产环境1%
value: "0.01"
volumes:
- name: tracefs
hostPath:
path: /sys/kernel/debug/tracing
- name: modules
hostPath:
path: /lib/modules
四、关键指标与告警体系
4.1 黄金指标定义
基于eBPF追踪数据,定义以下SLI/SLO体系:
┌──────────────────────────────────────────────────────────────┐
│ LLM推理可观测性黄金指标 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. TTFT (Time-To-First-Token) │
│ 定义:请求到达 → 首个Token生成 │
│ 组成:Tokenize + Prefill(GPU) + 调度等待 │
│ 健康值:P99 < 300ms (70B模型, A100×8) │
│ │
│ 2. TPOT (Time-Per-Output-Token) │
│ 定义:相邻两个Decode Token的平均间隔 │
│ 组成:单次Decode kernel + KV Cache访问 │
│ 健康值:P99 < 50ms (短序列), < 80ms (长序列) │
│ │
│ 3. GPU Kernel Efficiency │
│ 定义:有效Compute Kernel / 总GPU时间 │
│ 计算:(GEMM+Attention时间) / (Total GPU Active Time) │
│ 健康值:> 75% (低效原因:内存分配等待、NCCL等待) │
│ │
│ 4. KV Cache Fragmentation Rate │
│ 定义:KV Cache池中的碎片率 │
│ 来源:eBPF追踪cuMemAlloc/cuMemFree的size分布 │
│ 健康值:< 15% │
│ │
│ 5. Decode Stall Ratio │
│ 定义:Decode阶段kernel等待调度的时间占比 │
│ 健康值:< 5% (高于此值说明GPU被抢占或资源不足) │
│ │
└──────────────────────────────────────────────────────────────┘
4.2 延迟根因分析决策树
# 自动化根因分析逻辑
def diagnose_latency_anomaly(req: InferenceRequest) -> dict:
"""基于eBPF数据的自动根因分析"""
result = {"root_cause": "unknown", "confidence": 0.0, "details": []}
# 根因1:Prefill阶段延迟高
if req.stage_timings.get("prefill", 0) > PREFILL_THRESHOLD_MS:
prefill_kernels = [k for k in req.kernels if k["stage"] == "prefill"]
# 检查input length
if req.tokens_in > 8192:
result["root_cause"] = "long_prefill_input"
result["confidence"] = 0.9
result["details"].append(
f"输入长度{req.tokens_in},超过阈值8192,"
f"建议开启Chunked Prefill")
else:
result["root_cause"] = "prefill_kernel_slow"
result["confidence"] = 0.7
# 根因2:Decode Stall高
elif req.stage_timings.get("stall_ratio", 0) > 0.05:
# 检查KV Cache碎片
if req.kv_fragmentation > 0.20:
result["root_cause"] = "kv_cache_fragmentation"
result["confidence"] = 0.85
result["details"].append(
f"KV Cache碎片率{req.kv_fragmentation:.1%},"
f"建议启用PagedAttention v2")
else:
result["root_cause"] = "gpu_contention"
result["confidence"] = 0.6
# 根因3:NCCL通信瓶颈
elif req.stage_timings.get("communication", 0) > COMM_THRESHOLD_MS:
result["root_cause"] = "inter_gpu_communication"
result["confidence"] = 0.8
result["details"].append(
f"NCCL通信占比过高,"
f"建议检查NVLink带宽或使用Tensor Parallelism降维")
return result
4.3 生产级告警规则
# prometheus-alerts-llm-inference.yaml
groups:
- name: llm_inference_latency
rules:
# TTFT P99告警
- alert: LLMTTFTHigh
expr: |
histogram_quantile(0.99,
rate(llm_ttft_seconds_bucket[5m])
) > 0.5
for: 3m
labels:
severity: warning
annotations:
summary: "模型 {{ $labels.model }} TTFT P99过高: {{ $value }}s"
# GPU Kernel效率异常下降
- alert: GPUKernelEfficiencyDrop
expr: |
(
rate(llm_gpu_kernel_efficient_seconds[5m])
/ rate(llm_gpu_kernel_total_seconds[5m])
) < 0.6
for: 5m
labels:
severity: critical
annotations:
summary: "GPU Kernel效率降至{{ $value | humanizePercentage}},
可能存在资源争用或内存瓶颈"
# Decode Stall比率异常
- alert: DecodeStallRatioHigh
expr: |
rate(llm_decode_stall_seconds[5m])
/ rate(llm_decode_total_seconds[5m])
> 0.10
for: 5m
labels:
severity: warning
annotations:
summary: "Decode Stall比率{{ $value | humanizePercentage}},
GPU调度出现瓶颈"
五、高级场景:多租户GPU争用分析
5.1 MIG/MPS场景下的隔离度量
在多租户共享GPU的场景中,eBPF可以实现传统工具无法做到的跨租户干扰分析:
// 追踪目标:nvidia.ko调度器中的进程切换
SEC("kprobe/nvidia_gpu_sched_runlist")
int BPF_KPROBE(trace_gpu_sched_switch,
struct nvidia_channel *ch, struct nvidia_pid *prev, struct nvidia_pid *next)
{
struct tenant_interference_event evt = {};
evt.timestamp_ns = bpf_ktime_get_ns();
evt.prev_tenant_id = BPF_CORE_READ(prev, tenant_id);
evt.next_tenant_id = BPF_CORE_READ(next, tenant_id);
evt.switch_latency_ns = BPF_CORE_READ(ch, switch_time);
// 如果前后租户不同,记录一次上下文切换干扰
if (evt.prev_tenant_id != evt.next_tenant_id) {
// 累积每个租户被抢占的次数
u64 *preempt_count = bpf_map_lookup_elem(
&tenant_preempt_counters, &evt.prev_tenant_id);
if (preempt_count)
__sync_fetch_and_add(preempt_count, 1);
}
bpf_perf_event_output(ctx, &interference_events, BPF_F_CURRENT_CPU,
&evt, sizeof(evt));
return 0;
}
5.2 干扰热力图
通过eBPF数据构建GPU争用热力图,展示不同时间段各租户之间的干扰程度:
# 干扰分析报告生成
def generate_interference_report(tenant_data: dict, window: str = "1h"):
"""生成多租户GPU争用分析报告"""
report = {
"window": window,
"overall_interference_index": 0.0,
"tenant_impacts": [],
"recommendations": []
}
for tenant_id, events in tenant_data.items():
preempt_events = [e for e in events if e["type"] == "preempted"]
total_events = len(events)
if total_events == 0:
continue
interference_ratio = len(preempt_events) / total_events
report["tenant_impacts"].append({
"tenant_id": tenant_id,
"interference_ratio": interference_ratio,
"preempt_count": len(preempt_events),
"avg_switch_latency_us": mean(
e["switch_latency_ns"] for e in preempt_events
) / 1000,
})
# 排序找出最受影响的租户
report["tenant_impacts"].sort(
key=lambda x: x["interference_ratio"], reverse=True
)
# 生成建议
max_interference = report["tenant_impacts"][0]["interference_ratio"]
if max_interference > 0.3:
report["recommendations"].append(
"租户{}被抢占率超过30%,建议使用MIG硬隔离".format(
report["tenant_impacts"][0]["tenant_id"]))
return report
六、生产部署最佳实践
6.1 性能开销控制
eBPF在生产环境的开销需要严格控制。以下是我们在实测中获得的数据:
| 采集策略 | 额外延迟(P99) | CPU开销 | 适用场景 | ||----------|-------------|---------|---------| | 关闭追踪 | 0% | 0% | 基准 | | 1%采样 + kernel过滤 | < 0.3% | < 0.1核 | 正常运行 | | 10%采样全量事件 | < 1.2% | < 0.3核 | 日常监控 | | 100%采样 + 详细追踪 | < 3.5% | < 0.8核 | 问题定位 | | 全量+硬件PMC | < 8.0% | < 1.5核 | 深度诊断 |
关键优化点:
- PID过滤:eBPF程序中尽早过滤无关PID,避免map操作
- 事件聚合:在内核侧预聚合简单统计,只发送聚合结果
- Perf buffer调优:高QPS场景增大buffer大小减少丢事件
- CO-RE编译:使用BTF+CO-RE,避免现场编译开销
6.2 与现有可观测体系集成
# 将eBPF数据注入OpenTelemetry pipeline
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
class eBPF2OTelBridge:
"""eBPF追踪数据 → OpenTelemetry Span桥接器"""
def __init__(self, otlp_endpoint: str):
provider = TracerProvider()
processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint=otlp_endpoint))
provider.add_span_processor(processor)
self.tracer = provider.get_tracer("llm.inference")
def emit_inference_span(self, req: InferenceRequest):
"""将推理请求的eBPF数据转为OTel Span"""
with self.tracer.start_as_current_span(
"llm.inference",
start_time=int(req.arrived_at * 1e9)
) as span:
# 标准属性
span.set_attribute("llm.model", req.model_name)
span.set_attribute("llm.tokens.input", req.tokens_in)
span.set_attribute("llm.tokens.output", req.tokens_out)
span.set_attribute("llm.ttft.ms", req.ttft * 1000)
# GPU kernel阶段子span
for kernel in req.kernels:
with self.tracer.start_span(
f"gpu.kernel.{kernel['name']}",
start_time=kernel['timestamp_ns']
) as kspan:
kspan.set_attribute("gpu.kernel.grid", kernel['grid'])
kspan.set_attribute("gpu.kernel.block", kernel['block'])
kspan.set_attribute("gpu.kernel.stage", kernel['stage'])
# 阶段时序信息
for stage, count in req.stage_timings.items():
span.set_attribute(f"llm.stage.{stage}.kernel_count", count)
6.3 灰度上线策略
Phase 1(第1周):开发环境验证
- 100%采样 + 完整验证数据准确性
- 与nsys/nvprof输出交叉校验
Phase 2(第2周):测试环境压测
- 验证不同负载下(10/50/100/500 QPS)的开销
- 确认不影响SLA
Phase 3(第3周):生产灰度1%
- 仅1个推理节点开启追踪
- 对比开启前后的P99延迟差异
Phase 4(第4周):生产10%节点
- 验证指标Pipeline稳定性
- 告警规则调优
Phase 5(第5周起):全面部署
- 所有推理节点开启1%基线追踪
- 异常时动态提升采样率
七、实战案例:一次P99延迟飙升的根因定位
7.1 故障现象
- 时间:周五晚高峰19:30
- 现象:Llama-2-70B推理服务 TPOT P99从35ms飙升至180ms
- 常规检查:GPU利用率98%(正常)、CPU 45%(正常)、内存80%(正常)
7.2 eBPF数据分析
// 异常请求追踪摘要
{
"request_id": "req-20261004-193022-a7b3",
"tokens_in": 2048,
"tokens_out": 128,
"ttft_ms": 285,
"total_latency_ms": 18432,
"stage_analysis": {
"prefill": {"kernel_count": 48, "stage_ms": 285},
"decode": {"kernel_count": 3840, "stage_ms": 18147},
"stall_ratio": 0.34, // ← 异常!正常应 < 5%
"kv_cache": {"allocated_mb": 9843, "fragmentation": 0.28}
},
"top_stall_kernels": [
{"name": "flash_decode_kv_kernel", "stall_ms": 6234},
{"name": "paged_attention_v2", "stall_ms": 4102}
]
}
7.3 根因定位
定位过程:
1. Stall Ratio 34% 远超正常值 < 5%
2. KV Cache碎片率 28% (阈值 15%)
3. 碎片集中在 Decode 阶段的 paged_attention_v2 调用
根因:Paged Allocator的Block Size(16 tokens)与实际平均序列长度(130 tokens)
不匹配,导致每序列产生约7个不完整Block,累计产生大量无法复用的碎片。
修复方案:
- 短期:将Block Size从16调整为32,碎片率降至9%
- 长期:引入Variable Block Size,按请求长度动态选择
修复后:TPOT P99 恢复至 38ms
八、未来展望
eBPF在LLM推理可观测性领域仍在快速发展,值得关注的方向:
-
NVIDIA官方eBPF支持:NVIDIA正在推动GPU-side eBPF(基于GSP firmware),未来可直接在GPU侧执行BPF程序,实现真正的SM级追踪
-
CUDA Graph感知:CUDA Graph将多个kernel打包执行,eBPF需要识别Graph边界,逐kernel拆解计时
-
Chunked Prefill追踪:长输入切分后的并行Prefill,需要在多Stream间追踪同步事件
-
与LTS(Long-Term Support)内核版本的CO-RE兼容:生产环境大量5.15/6.1 LTS内核,需要更好的跨版本兼容方案
结语
LLM推理可观测性是一个交叉领域难题——它需要同时理解CUDA编程模型、Linux内核调度机制、以及分布式系统追踪体系。eBPF的独特价值在于:它提供了从用户态到内核态、从系统调用到GPU驱动的完整透视能力,且能以亚毫秒级开销在生产环境持续运行。
当你能精确回答"第37个Token为什么慢了17ms"时,你的推理服务才真正具备了生产级可靠性。而这,正是eBPF赋予我们的能力。

发表评论 取消回复