从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核 | 深度诊断 |

关键优化点:

  1. PID过滤:eBPF程序中尽早过滤无关PID,避免map操作
  2. 事件聚合:在内核侧预聚合简单统计,只发送聚合结果
  3. Perf buffer调优:高QPS场景增大buffer大小减少丢事件
  4. 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推理可观测性领域仍在快速发展,值得关注的方向:

  1. NVIDIA官方eBPF支持:NVIDIA正在推动GPU-side eBPF(基于GSP firmware),未来可直接在GPU侧执行BPF程序,实现真正的SM级追踪

  2. CUDA Graph感知:CUDA Graph将多个kernel打包执行,eBPF需要识别Graph边界,逐kernel拆解计时

  3. Chunked Prefill追踪:长输入切分后的并行Prefill,需要在多Stream间追踪同步事件

  4. 与LTS(Long-Term Support)内核版本的CO-RE兼容:生产环境大量5.15/6.1 LTS内核,需要更好的跨版本兼容方案


结语

LLM推理可观测性是一个交叉领域难题——它需要同时理解CUDA编程模型、Linux内核调度机制、以及分布式系统追踪体系。eBPF的独特价值在于:它提供了从用户态到内核态、从系统调用到GPU驱动的完整透视能力,且能以亚毫秒级开销在生产环境持续运行。

当你能精确回答"第37个Token为什么慢了17ms"时,你的推理服务才真正具备了生产级可靠性。而这,正是eBPF赋予我们的能力。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部