eBPF AI 推理服务全链路可观测性工程实战

AI 推理服务的性能瓶颈往往隐藏在系统底层——GPU kernel launch 延迟、CPU 调度争用、内存拷贝阻塞、网络抖动、存储 I/O 排队。传统的应用层监控只能告诉你"慢",却无法告诉你"为什么慢"。eBPF(Extended Berkeley Packet Filter)为我们提供了一种零侵入、全链路的观测手段,从用户态代码到内核态驱动,从 syscall 到 GPU CUPTI,让每一个微秒级的延迟都无处遁形。

本文将深入探讨如何构建一套基于 eBPF 的 AI 推理服务可观测性工具栈,覆盖从请求入口到 GPU 计算完成的完整链路。

一、为什么 AI 推理服务需要 eBPF 级可观测性

1.1 AI 推理服务的独特性

与传统 Web 服务不同,AI 推理服务具有以下特征:

  • 异构计算:CPU 预处理 → GPU 计算 → CPU 后处理,涉及多种计算单元的协同
  • 批处理动态性:Dynamic Batching 导致请求处理时间高度不确定
  • 内存密集型:模型权重常驻显存,KV Cache 按需分配,内存压力巨大
  • 长尾延迟敏感:P99 延迟比平均延迟重要得多,GPU 利用率波动直接影响 Tail Latency
  • 调用链长:一个推理请求经过 API Gateway、Load Balancer、Preprocess、Scheduler、Inference Engine、Postprocess 等多个环节

1.2 传统监控的盲区

层级 传统监控工具 盲区
应用层 Prometheus metrics, APM 无法追溯到内核态原因
Runtime pprof, trace 采样频率低,遗漏短时事件
内核层 perf, ftrace 开销大,生产环境开启困难
GPU DCGM, nvprof 粗粒度,无法定位到具体 syscall

eBPF 的核心优势在于:在内核中安全地执行沙盒程序,以极低开销(通常 < 1% CPU)收集内核态任意事件。

二、全链路可观测性架构设计

2.1 观测点分布

┌─────────────────────────────────────────────────────────┐
│                     AI Inference Service                  │
├──────────┬──────────┬──────────┬────────────┬────────────┤
│  Network │  CPU     │  Memory  │  Storage   │  GPU       │
│  Layer   │  Layer   │  Layer   │  Layer     │  Layer     │
├──────────┼──────────┼──────────┼────────────┼────────────┤
│ sock     │ sched    │ mmap     │ block      │ ioctl      │
│ tcp      │ syscall  │ pagefault│ ext4/xfs   │ drm        │
│ udp      │ irq      │ brk      │ writeback  │ nvidia.ko  │
│ quic     │ softirq  │ mlock    │ fsync      │ CUPTI      │
└──────────┴──────────┴──────────┴────────────┴────────────┘
          ↕ eBPF probes (kprobe/uprobe/tracepoint) ↕
┌─────────────────────────────────────────────────────────┐
│              eBPF Maps (Per-CPU Ring Buffer)            │
├─────────────────────────────────────────────────────────┤
│              Userspace Agent (Go/Rust)                   │
├─────────────────────────────────────────────────────────┤
│              Prometheus / Grafana / OTel                │
└─────────────────────────────────────────────────────────┘

2.2 核心 eBPF 探针类型

以下是我们实际生产环境中部署的探针矩阵:

探针名称 挂载点 采集数据 用途
trace_sched_switch sched:sched_switch prev_pid, next_pid, delay CPU 调度延迟分析
trace_nv_ioctl sys_ioctl (DRM) cmd, arg, duration GPU kernel launch 追踪
trace_sys_enter raw_syscalls:sys_enter syscall_nr, args 系统调用频率与延迟
trace_tcp_retransmit skb:kfree_skb ip, tcp_seq 网络重传统计
trace_block_rq block:block_rq_complete dev, sector, duration 块设备 I/O 延迟
trace_oom_kill oom:oom_kill_process pid, totalpages OOM 事件捕获

三、GPU 计算路径追踪

3.1 CUDA Runtime 到 Driver 的全链路

一个 CUDA kernel 的执行路径如下:

Application Code (PyTorch/vLLM)
    ↓
CUDA Runtime Library (libcudart.so)
    ↓  cuLaunchKernel()
CUDA Driver (libcuda.so)
    ↓  cuMemcpyHtoD() / cuLaunchGridAsync()
NVIDIA Kernel Driver (nvidia.ko)
    ↓  ioctl(DRM_IOCTL_NVGPU)
GPU Hardware Execution

我们在关键节点放置 uprobe:

// eBPF 程序:追踪 cuLaunchKernel 调用
SEC("uprobe//usr/lib/x86_64-linux-gnu/libcudart.so.12:cuLaunchKernel")
int trace_cu_launch(struct pt_regs *ctx) {
    u64 ts = bpf_ktime_get_ns();
    u32 pid = bpf_get_current_pid_tgid() >> 32;

    struct launch_event evt = {};
    evt.pid = pid;
    evt.timestamp = ts;
    bpf_probe_read_user_str(&evt.kernel_name, sizeof(evt.kernel_name),
                           (void *)PT_REGS_PARM1(ctx));

    // 记录 per-thread launch 时间戳
    bpf_map_update_elem(&launch_starts, &pid, &evt, BPF_ANY);

    // 发送到 ring buffer
    bpf_ringbuf_submit(&events, &evt, sizeof(evt), 0);
    return 0;
}

SEC("uretprobe//usr/lib/x86_64-linux-gnu/libcudart.so.12:cuLaunchKernel")
int trace_cu_launch_ret(struct pt_regs *ctx) {
    u64 ts = bpf_ktime_get_ns();
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    struct launch_event *start = bpf_map_lookup_elem(&launch_starts, &pid);

    if (start) {
        u64 duration = ts - start->timestamp;
        // 记录 launch 延迟分布
        u32 slot = bpf_log2l(duration / 1000); // microseconds
        u64 *count = bpf_map_lookup_elem(&launch_hist, &slot);
        if (count) __sync_fetch_and_add(count, 1);
    }
    return 0;
}

3.2 GPU Kernel Execution 时间分析

仅追踪 launch 不够,还需要测量 GPU 端实际执行时间。我们使用 perf_event 配合 CUDA 流回调来实现精确计时:

// 结合 CUPTI 和 eBPF 的 GPU 端追踪
static void CUPTIAPI cupti_callback(void *userdata,
                                     CUpti_CallbackDomain domain,
                                     CUpti_CallbackId cbid,
                                     const void *cbInfo) {
    if (domain == CUPTI_CB_DOMAIN_RUNTIME_API &&
        cbid == CUPTI_RUNTIME_TRACE_CBID_cudaLaunchKernel) {
        const cudaLaunchKernel_params *params =
            (cudaLaunchKernel_params *)cbInfo;

        // 将 GPU kernel 元数据关联到 eBPF 进程上下文
        gpu_kernel_meta meta;
        meta.correlation_id = params->correlationId;
        meta.stream = (uint64_t)params->stream;
        meta.start_ns = get_clock_ns();

        bpf_map_update_elem(&gpu_kernels, &meta.correlation_id, &meta, BPF_ANY);
    }
}

四、CPU 调度与推理延迟根因分析

4.1 调度延迟直方图

推理服务对 CPU 调度延迟极其敏感。一个 batch 准备阶段的调度排队可能导致数十毫秒的延迟抖动:

SEC("tp_btf/sched_switch")
int BPF_PROG(trace_sched_switch, bool preempt,
             struct task_struct *prev,
             struct task_struct *next) {
    u32 prev_pid = prev->pid;
    u32 next_pid = next->pid;

    // 只关注推理服务进程
    u64 cpu = bpf_get_smp_processor_id();

    // 记录 prev 任务的 off-cpu 开始时间
    if (is_target_pid(prev_pid)) {
        u64 now = bpf_ktime_get_ns();
        bpf_map_update_elem(&offcpu_start, &prev_pid, &now, BPF_ANY);
    }

    // 记录 next 任务的 on-cpu 唤醒时间
    if (is_target_pid(next_pid)) {
        u64 *start = bpf_map_lookup_elem(&offcpu_start, &next_pid);
        if (start) {
            u64 duration = bpf_ktime_get_ns() - *start;

            // 分类统计
            if (duration < 10000) { // < 10us
                __sync_fetch_and_add(&hist_under_10us, 1);
            } else if (duration < 100000) { // < 100us
                __sync_fetch_and_add(&hist_100us, 1);
            } else if (duration < 1000000) { // < 1ms
                __sync_fetch_and_add(&hist_1ms, 1);
            } else {
                __sync_fetch_and_add(&hist_over_1ms, 1);
                // 记录长时间 off-cpu 事件
                struct offcpu_event evt = {
                    .pid = next_pid,
                    .duration_ns = duration,
                    .prev_state = prev->__state
                };
                bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
                bpf_ringbuf_submit(&ringbuf, &evt, sizeof(evt), 0);
            }
            bpf_map_delete_elem(&offcpu_start, &next_pid);
        }
    }
    return 0;
}

4.2 syscall 延迟热力图

// 追踪推理服务中耗时最长的系统调用
SEC("tp/raw_syscalls/sys_enter")
int trace_sys_enter(struct trace_event_raw_sys_enter *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (!is_inference_pid(pid)) return 0;

    u64 ts = bpf_ktime_get_ns();
    u32 syscall_nr = ctx->id;

    struct sys_enter_key key = { .pid = pid, .syscall = syscall_nr };
    bpf_map_update_elem(&syscall_starts, &key, &ts, BPF_ANY);
    return 0;
}

SEC("tp/raw_syscalls/sys_exit")
int trace_sys_exit(struct trace_event_raw_sys_exit *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (!is_inference_pid(pid)) return 0;

    struct sys_enter_key key = { .pid = pid, .syscall = ctx->id };
    u64 *start = bpf_map_lookup_elem(&syscall_starts, &key);
    if (!start) return 0;

    u64 duration = bpf_ktime_get_ns() - *start;

    // 按 syscall 类型聚合延迟
    u64 *total = bpf_map_lookup_elem(&syscall_latency, &ctx->id);
    u64 *count = bpf_map_lookup_elem(&syscall_count, &ctx->id);
    if (total) __sync_fetch_and_add(total, duration);
    if (count) __sync_fetch_and_add(count, 1);

    bpf_map_delete_elem(&syscall_starts, &key);
    return 0;
}

五、内存分配与 Page Fault 追踪

5.1 内存分配热点检测

推理服务的内存分配模式具有明显的阶段性:模型加载阶段大量 mmap,推理阶段以 CUDA malloc 为主,批处理阶段频繁的小对象分配。

// 追踪 mmap 调用,识别大内存映射
SEC("kprobe/__x64_sys_mmap")
int trace_mmap(struct pt_regs *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (!is_target_pid(pid)) return 0;

    size_t len = PT_REGS_PARM3(ctx); // length parameter
    int flags = PT_REGS_PARM5(ctx);  // flags parameter

    // 只记录 > 100MB 的映射
    if (len < 100 * 1024 * 1024) return 0;

    struct mmap_event evt = {
        .pid = pid,
        .length = len,
        .flags = flags,
        .timestamp_ns = bpf_ktime_get_ns()
    };
    bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
    bpf_ringbuf_submit(&ringbuf, &evt, sizeof(evt), 0);

    return 0;
}

// 追踪 page fault
SEC("tp/exceptions/page_fault_user")
int trace_page_fault(struct trace_event_raw_page_fault *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (!is_target_pid(pid)) return 0;

    struct pf_event evt = {
        .pid = pid,
        .address = ctx->address,
        .ts_ns = bpf_ktime_get_ns()
    };
    bpf_get_current_comm(&evt.comm, sizeof(evt.comm));

    // 进行 page fault 地址直方图统计
    u32 zone = get_address_zone(ctx->address);
    u64 *count = bpf_map_lookup_elem(&pf_zone_hist, &zone);
    if (count) __sync_fetch_and_add(count, 1);

    return 0;
}

六、网络层追踪与请求级可观测性

6.1 gRPC 推理请求追踪

AI 推理服务通常使用 gRPC/HTTP2 接收请求。我们追踪从 TCP 包到应用层请求解析的全链路:

// 追踪 TCP 层的推理请求到达时间
SEC("sockops")
int trace_tcp_connection(struct bpf_sock_ops *skops) {
    u32 family = skops->family;
    if (family != AF_INET && family != AF_INET6) return 0;

    u32 local_port = skops->local_port;
    u32 dst_port = bpf_ntohl(skops->remote_port);

    // 只关注推理服务端口 (默认 50051 gRPC)
    if (local_port != 50051 && dst_port != 50051) return 0;

    // 记录连接元数据
    struct conn_info info = {
        .src_ip = skops->local_ip4,
        .dst_ip = skops->remote_ip4,
        .src_port = local_port,
        .dst_port = dst_port,
        .bytes_received = skops->bytes_received,
        .bytes_acked = skops->bytes_acked
    };

    u32 idx = (u32)bpf_get_smcc_cookie();
    bpf_map_update_elem(&conn_map, &idx, &info, BPF_ANY);
    return 0;
}

6.2 请求级端到端追踪

通过 uprobe 挂钩 gRPC 框架的序列化/反序列化函数,构建请求级追踪:

// 在 gRPC 的 Deserialize 函数入口处挂钩
SEC("uprobe//usr/local/lib/libgrpc++.so:_ZN4grpc8internal13CallOpRecvMsgD2Ev")
int trace_grpc_recv(struct pt_regs *ctx) {
    u64 ts = bpf_ktime_get_ns();
    u32 pid = bpf_get_current_pid_tgid() >> 32;

    // 从 gRPC 请求上下文中提取 request_id
    // 这需要理解 gRPC internal memory layout
    // 简化版:记录请求到达与处理完成时间差
    struct request_span span = {
        .pid = pid,
        .ts_enter = ts,
        .type = GRPC_REQUEST_START
    };
    bpf_ringbuf_submit(&ringbuf, &span, sizeof(span), 0);
    return 0;
}

七、生产级部署方案

7.1 eBPF Agent 架构

一个完整的生产级 eBPF 代理需要处理以下挑战:

  • 高吞吐事件处理:单节点 10万+ 推理请求/秒,每秒可能产生百万级 eBPF 事件
  • 低开销目标:CPU 占用 < 1%,内存 < 100MB
  • 内核版本兼容:不同内核版本的 tracepoint 名称不同
  • 安全权限:需要 CAP_BPF 或 root 权限

推荐架构:

[Kernel Space]
    ↓ eBPF Programs (compiled to BPF bytecode)
    ↓ BPF Ring Buffers (per-CPU, lock-free)
[Userspace Agent - Rust/Go]
    ↓ Dedicated reader goroutine per Ring Buffer
    ↓ Event batching & aggregation
    ↓ OTLP export / Prometheus exposition
[Observability Backend]
    ↓ Tempo/Jaeger (Traces) + Prometheus (Metrics) + Loki (Logs)

7.2 关键代码片段

Rust 实现的 eBPF Agent 核心循环:

use aya::{Bpf, programs::{trace_point::TracePointLink, KProbe}};
use aya::maps::RingBuf;
use tokio::sync::mpsc;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 加载编译好的 eBPF 字节码
    let mut bpf = Bpf::load_file("target/bpfel-unknown-none/release/inference-observe")?;

    // 挂载 tracepoint
    let program: &mut TracePoint = bpf.program_mut("trace_sched_switch").unwrap().try_into()?;
    program.load()?;
    program.attach("sched", "sched_switch")?;

    // 挂载 kprobe 追踪 ioctl
    let kprobe: &mut KProbe = bpf.program_mut("trace_nv_ioctl").unwrap().try_into()?;
    kprobe.load()?;
    kprobe.attach("drm_ioctl", 0)?;

    // 访问 Ring Buffer
    let mut ringbuf = bpf.map_mut("RINGBUF").unwrap();
    let (tx, mut rx) = mpsc::channel(10000);

    // 启动 Ring Buffer 读取循环
    for cpu_id in online_cpus()? {
        let tx = tx.clone();
        let mut rb: RingBuf = ringbuf.next(&cpu_id).unwrap();

        tokio::spawn(async move {
            let mut poll = tokio::time::interval(Duration::from_millis(10));
            loop {
                poll.tick().await;
                while let Some(event) = rb.next() {
                    let _ = tx.send(event.to_vec()).await;
                }
            }
        });
    }

    // 处理事件流
    while let Some(event) = rx.recv().await {
        process_event(&event);
    }

    Ok(())
}

7.3 性能数据采集配置

实际部署中,我们通过 BPF Map 暴露预聚合的 Prometheus 指标:

// 预聚合 Prometheus 指标在 BPF 侧完成
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, MAX_SYSCALL_NR);
    __type(key, u32);
    __type(value, struct latency_bucket);
} syscall_latency SEC(".maps");

// 用户态定期读取并暴露
struct latency_bucket {
    u64 count;
    u64 total_ns;
    u64 min_ns;
    u64 max_ns;
    u64 hist[8];  // 1us, 10us, 100us, 1ms, 10ms, 100ms, 1s, >1s
};

八、实战案例:定位推理尾延迟根因

8.1 问题背景

某 AI 推理服务 P99 延迟周期性从 50ms 跳变至 200-500ms,持续 3-5 秒后恢复。传统的应用层监控只看到延迟升高,无法确定根因。

8.2 eBPF 分析过程

  1. 调度延迟分析:开启 trace_sched_switch 后,发现延迟跳变期间 GPU 等待进程经历大量 off-cpu 事件,平均等待时间从 2μs 跳变至 800μs。

  2. Syscall 分析:trace_sys_exit 显示延迟期间 ioctl(DRM_IOCTL_NVGPU) 调用延迟从 5μs 跳变至 300μs+,主要发生在 GPU Query 查询时。

  3. GPU 利用率关联:同步抓取 GPU 利用率发现,延迟升高时 GPU 利用率从 95% 骤降至 40%,说明 GPU 在空转等待 CPU 提交 task。

  4. 根因定位:进一步追踪发现是batch scheduler中一个 malloc 触发了 jemalloc 的 madvise(MADV_DONTNEED) 释放大页内存,导致后续 GPU 内存分配(首次触碰)触发大量 page fault,进而拖慢 cuMemcpy 操作。

8.3 修复方案

// 修复:对 GPU 内存使用 mlock + 预分配池
cudaError_t preallocate_gpu_pool(size_t pool_size) {
    void* base;
    cudaError_t err = cudaMalloc(&base, pool_size);
    if (err != cudaSuccess) return err;

    // mlock 防止被 swap(虽然 GPU 内存不会被 swap,但主机端 pinned memory 会)
    if (mlock(base, pool_size) != 0) {
        madvise(base, pool_size, MADV_HUGEPAGE); // 大页减少 TLB miss
        madvise(base, pool_size, MADV_WILLNEED); // 预加载页表
    }

    return cudaSuccess;
}

修复后,P99 延迟稳定在 55ms 以内,消除了周期性尾部抖动。

九、最佳实践与陷阱清单

9.1 推荐做法

  • 使用 Ring Buffer perf_event 替代 BPF Map polling:减少用户态与内核态的同步开销
  • Per-CPU Map 替代共享 Map:避免跨 CPU 缓存同步
  • 事件采样而非全量采集:对高频事件(如 syscall)采用每 N 次采样 1 次的策略
  • 内核态预聚合:在 BPF 程序中直接计算直方图和百分位,只上传聚合结果
  • 使用 BTF-enabled CO-RE:一次编译,跨内核版本运行

9.2 常见陷阱

  • BPF 程序验证器限制:循环必须有界,栈空间严格限制(最大 512 字节),复杂逻辑必须放在用户态
  • Map 内存巨大:每 CPU 的 Ring Buffer 默认 8-64 MB,多核系统下需要控制
  • Probe 挂载点变更:不同环境下函数名可能 inline 或改名,需要 fallback 到 Tracepoint
  • 与 kata/容器安全策略冲突:某些容器 runtime 限制 CAP_BPF,需要额外配置

9.3 开源工具链推荐

工具 用途
eBPF Collectors (Linux kernel 官方 eBPF 示例和库
bcc/bpftrace 快速原型和一次性追踪
Aya (Rust) 生产级 eBPF Agent 开发
Tetragon (Cilium) 安全 + 可观测性一体化
Pixie Kubernetes 场景即插即用
Pyroscope Parca 持续性能分析

十、总结

eBPF 为 AI 推理服务提供了一种前所未有的全链路可观测性能力。从 GPU kernel 的每一次 launch,到 CPU 调度的每一微秒排队,从内存的每一个 page fault,到网络的每一个 TCP 重传——eBPF 让每个环节都变得透明可追溯。

在 AI 推理服务日益复杂化的今天,仅有应用层监控已经远远不够。eBPF 级可观测性不再是"锦上添花",而是生产级推理服务的必备基础设施。随着 eBPF + AI 的融合加深(如 eBPF 辅助的 GPU 调度、eBPF 特征采集辅助 ML 推理优化),我们有理由相信这个领域将持续产生更多工程创新。


关键 Takeaways:

  1. eBPF 能以 <1% CPU 开销提供 deepest visibility,是生产级推理服务的理想观测方案
  2. 全链路可观测性需要覆盖 Network - CPU - Memory - Storage - GPU 五大层级
  3. 用户态预聚合 + Ring Buffer 是实现高性能事件采集的关键模式
  4. BTF CO-RE 一次编译多版本部署是生产环境的必要能力
  5. 调度延迟和 syscall 延迟是推理尾延迟的两大隐藏元凶
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部