C---深度学习-七-

C++ 深度学习(七)

原文:Deep Learning with C++

译者:飞龙

协议:CC BY-NC-SA 4.0

include

include

include

include

include

include

include

include

struct Histogram {

double min_val;

double max_val;

std::vectorstd::uint64_t bins;

Histogram(double min_v, double max_v, std::size_t n_bins)
min_val(min_v), max_val(max_v), bins(n_bins, 0) {

if (n_bins == 0) {

throw std::invalid_argument("n_bins must be > 0");

}

if (max_val <= min_val) {

throw std::invalid_argument("max_val must be greater than min_val");

}

}

};


2. **将值分配到分箱**:创建一个函数,将数值映射到其对应的直方图分箱:

```cpp

std::size_t bin_index(const Histogram& h, double value) {

    if (value <= h.min_val) return 0;

    if (value >= h.max_val) return h.bins.size() - 1;

    const double width =

        (h.max_val - h.min_val) / static_cast<double>(h.bins.size());

    std::size_t idx = static_cast<std::size_t>((value - h.min_val) / width);

    if (idx >= h.bins.size()) {

        idx = h.bins.size() - 1;

    }

    return idx;

}

  1. 更新直方图:每个输入的特征值递增一个分箱。此更新在常数时间内完成:

    
    void update_histogram(Histogram& h, double value) {
    
        const std::size_t idx = bin_index(h, value);
    
        h.bins[idx]++;
    
    }
    
    
  2. 将计数转换为比例:在计算PSI之前,必须将直方图计数归一化为比例:

    
    std::vector<double> to_proportions(const Histogram& h, double epsilon = 1e-12) {
    
        const std::uint64_t total =
    
            std::accumulate(h.bins.begin(), h.bins.end(), std::uint64_t{0});
    
        std::vector<double> proportions(h.bins.size(), 0.0);
    
        if (total == 0) {
    
            const double uniform = 1.0 / static_cast<double>(h.bins.size());
    
            std::fill(proportions.begin(), proportions.end(), uniform);
    
            return proportions;
    
        }
    
        for (std::size_t i = 0; i < h.bins.size(); ++i) {
    
            proportions[i] =
    
                std::max(static_cast<double>(h.bins[i]) / static_cast<double>(total),
    
                         epsilon);
    
        }
    
        const double sum =
    
            std::accumulate(proportions.begin(), proportions.end(), 0.0);
    
    

for (double& p : proportions) {

    p /= sum;

}

return proportions;

}

5. 计算 PSI

PSI 将参考分布与当前分布进行比较。数值越高表示分布偏移越大。以下是一种典型的解释:

  • PSI < 0.1:轻微或没有漂移
  • PSI 0.1 到 0.25:中等漂移
  • PSI > 0.25:显著漂移

下面的代码用 C++ 实现了 PSI:先针对两个比例向量,再针对两个转换为归一化分箱比例的直方图。


double compute_psi(const std::vector<double>& ref,

                   const std::vector<double>& cur) {

    if (ref.size() != cur.size()) {

        throw std::invalid_argument("ref and cur must have the same size");

    }

    double psi = 0.0;

    for (std::size_t i = 0; i < ref.size(); ++i) {

        psi += (cur[i] - ref[i]) * std::log(cur[i] / ref[i]);

    }

    return psi;

}

double compute_psi(const Histogram& reference,

                   const Histogram& current,

                   double epsilon = 1e-12) {

    if (reference.bins.size() != current.bins.size()) {

        throw std::invalid_argument("Histogram bin counts must match");

    }

    if (reference.min_val != current.min_val ||

        reference.max_val != current.max_val) {

        throw std::invalid_argument("Histogram ranges must match");

    }

    const std::vector<double> ref = to_proportions(reference, epsilon);

    const std::vector<double> cur = to_proportions(current, epsilon);

    return compute_psi(ref, cur);

}

上述代码片段展示了一个固定分箱直方图的简单实现,以及用于比较参考分布与当前生产窗口的 PSI 计算。

这些轻量级摘要和统计检验共同提供了一种持续监控数据和预测行为的实用方法,使生产系统能够在不给推理路径增加显著开销的情况下及早发现分布变化。

选择正确的响应

并非所有漂移都需要重新训练。请选择能够以最低风险恢复性能的最小充分干预措施。以下指南概述了常见的漂移场景以及用于应对这些场景的典型响应:

  • 先验/标签偏移(类别频率改变;特征看起来熟悉):从决策阈值调整或更新分数到动作的映射开始。如果概率保真度很重要,请应用事后校准(例如,温度缩放或 Platt 缩放)。在推出之前,请在影子模式下验证更改。

    • 示例:在 KYC 改进后,欺诈模型的正样本基础比率减半。保持旧阈值会导致过度的拒绝。重新调整阈值并重新校准模型头部,可以在不改变权重的情况下恢复精确率/召回率。
  • 存在明显数据问题的特征(协变量)漂移:审计特征管道——过期的归一化常量、单位变化(kPa→psi)、词汇表爆炸或缺失值处理退化。对变换应用热修复(例如,提升 schema 版本),回填参考窗口,并重新评估。

    • 示例:一个电子商务数据源开始以美分输出价格,但管道仍然除以 100,导致特征范围被压缩。修复变换立即可恢复准确性。
  • 没有管道错误但质量下降的特征漂移:边界可能需要移动。安排针对近期数据的定向重新训练,同时保持模型架构不变,以隔离影响。通过影子评估或金丝雀发布推广更新后的模型,并将自动回滚与 p99 延迟、错误率和领域关键绩效指标(KPI)关联。

    • 示例:推荐模型面临目录刷新。新的长尾商品改变了共现结构。使用最新交互数据进行重新训练可恢复 CTR。
  • 标签稳定且 KPI 可接受的预测漂移:监控校准和置信度阈值。这种偏移可能是良性的(例如,具有不同先验的新群体),也可能是概念变化的早期指标。在影子模式中引入挑战者模型以构建先行指标。

    • 示例:语音模型为具有新口音的地区提供服务。置信度得分下降,但词错误率(WER)保持稳定。在收集带标签样本以确定是否需要重新训练的同时,继续监控校准。
  • 概念漂移(策略或世界已改变):预期需要重新训练,并可能需要特征重新设计。考虑从更新的教师模型训练蒸馏学生模型,以满足延迟或成本目标。如果群体发生分化,则部署特定于群体的模型或路由。

    • 示例:审核策略扩展了禁止类别。新标签需要重新标注的数据和更新的词汇或语义特征。学生模型将教师模型的能力蒸馏到适用于边缘部署的 INT8 工件中。
  • 区分操作错误与统计漂移:未知类别或 NaN 值的激增是数据契约违规,应触发管道所有者的调查。相比之下,在数据契约完好情况下,PSI 平稳但持续上升通常表明真正的统计漂移,应提醒模型所有者。

虽然这些响应处理了可以从输入和预测中立即观察到的漂移信号,但生产系统通常只在延迟之后才收到真实标签。下一节将解释当标签在数小时或数天后到达时,如何监控模型质量并检测性能变化。

处理延迟标签

在许多生产系统中,真实标签在做出预测后数小时、数天甚至数周才到达。这种延迟使得无法立即评估模型质量,因此在真实结果可用之前,监控系统必须依赖先行指标。

常见的先行指标包括以下内容:

  • 冠军–挑战者分歧(在影子流量上)。模型之间的分歧通常表明生产模型正在遇到新的情况。

  • 弃权率或覆盖率,例如人工审核队列增加或基于规则的回退增加。

  • 预测分布监控,包括类别构成、置信度分布或得分百分位数,这些即使在实际标签到达之前也能揭示行为变化。

  • 下游代理信号,例如退款率、申诉、安全标记或推荐系统中的点击率变化。

当标签最终到达时,使用滞后感知滚动窗口评估模型性能,确保仅将预测与已完成的结果进行比较。跨群体切片(例如,区域、设备类型或获客渠道)计算准确率、提升度和校准指标,以避免辛普森悖论,即聚合指标看起来稳定,而子组性能却在恶化。

如果资源允许,维护一个定向仲裁或主动学习流;对高不确定性或高影响的预测进行采样以进行人工标注。这会加速反馈循环,并在怀疑漂移时帮助更快地识别真正的性能回退。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_460_0.png

图 11.2:生产 ML 系统中带延迟标签的滞后感知评估

预测通过冠军–挑战者分歧、预测分布检查和代理信号立即得到监控。一旦真实标签到达,滞后评估使用滚动窗口和回填分析、静态留出集以及延迟偏差校正来评估真实模型性能。表现不佳的群体以及不确定或高影响的案例随后可被路由到主动学习流中,以进行人工标注和更快的模型改进。

操作化漂移监控

只有系统能够以一致且低风险的方式响应漂移时,漂移检测才有价值。在实践中,这意味着将漂移监控视为正常生产操作的一部分,而不是特殊情况的故障排查任务。目标是尽早让分布变化变得可见,为采取行动定义明确的阈值,并将这些信号连接到标准的运营工作流。

一个实用的漂移监控设置通常包括以下要素:

  • 漂移预算:为每个特征和每个输出定义预算,例如 PSI 阈值或允许的 ECE 范围,以及延迟、可靠性和成本 SLO。

  • 核心仪表盘:在与生产健康状态相同的仪表盘中显示漂移。警报应报告发生了什么变化、变化了多少、从何时开始、以及针对哪些群体。

第 11 章:调试与重新训练已部署模型

  • 变更即代码:将漂移应对措施——阈值调整、特征管道修复和重新训练——视为正常的生产变更。尽可能使用影子评估,通过金丝雀发布并配合自动回滚来推进,同时保持上一代冠军模型处于热备状态。变更成功后,轮换参考窗口,使监控不再将系统与过时的基线进行比较。

按这种方式处理,漂移将更易于管理。轻量级监控让变更可见,清晰的运行手册让应对措施保持适度,重新训练则成为计划性维护步骤,而非紧急反应。一个可用于生产的漂移监控工作流应包含以下内容:

  • 针对数值特征、类别特征、预测结果和健康信号的实时监控
  • 清晰定义并可视化的分群(cohort)
  • 带滞回的告警阈值
  • 链接到阈值更新、管道修复和重新训练的运行手册
  • 用于影子评估、金丝雀发布和回滚的模板
  • 明确定义的参考窗口处理流程

下一节将把这些监控信号转化为调试工作流,展示如何使用结构化日志、指标和追踪来复现故障并隔离其根因。

用证据进行调试

生产调试是一个经验性过程,而非凭空猜测。当出现异常时,例如准确率下降、p99 延迟飙升或内存不足(OOM)事件,任务是确定到底发生了什么变化,在受控条件下复现问题,并隔离根因是数值问题、数据问题还是并发问题。要可靠地做到这一点,依赖于三个核心的可观测性工具:

  • 结构化日志:每个请求都自带描述信息且可查询
  • 指标:随时间汇总速率、延迟、负载大小和错误
  • 追踪:将端到端延迟分解为命名阶段(解析、预处理、排队、推理和后处理)

有了这些工具,复现过程变得更加系统化:固定随机种子、固定形状,并捕获代表性输入。根因分析也更有针对性,因为搜索范围可以缩小到特定阶段、子系统或故障类别,而不必依赖猜测。

下图总结了可观测性如何通过将观察、复现和隔离故障的工作流与三个核心工具(结构化日志、指标和追踪)联系起来,从而支持生产调试。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_462_0.png

图 11.3:模型推理可观测性的三大支柱

该图将调试工作流——观察、复现、隔离——与使该工作流切实可行的三个可观测性工具联系起来:

  • 结构化日志捕获请求级细节,如请求标识、模型版本、预处理版本以及输入形状或数据类型
  • 指标随时间汇总系统行为,包括请求速率、延迟分位数、错误率和负载大小
  • 追踪将端到端延迟分解为命名跨度,如解析、预处理、排队、推理和后处理,从而更容易定位时间消耗在哪里

这些工具共同帮助工程师检测回归、在受控条件下复现故障,并隔离导致问题的阶段或子系统。下一步是将这些想法落实到代码中。以下示例展示了如何在 C++ 中为结构化日志、指标和追踪实现轻量级插桩。

结构化日志

结构化日志应紧凑、机器可读,并且每行只输出一个事件,以便下游系统能够高效地过滤、分组和关联。每条记录至少应包含以下内容:

  • 身份:request_id、用户/租户、区域、分群(例如 app_version)
  • 模型:model_name、model_version、preprocess_version、schema_version
  • 负载摘要(不含 PII):形状、数据类型、范围、批大小、设备
  • 预算:提示信息,如 deadline_ms 或请求优先级
  • 结果:状态、延迟切片、内存余量、缓存命中信息

以下示例展示了一个简单的 C++ 结构化日志辅助函数:


#include <chrono>

#include <iomanip>

#include <iostream>

#include <sstream>

#include <string>

inline std::string iso8601_now() {

    using namespace std::chrono;

    auto t = system_clock::now();

    auto s = time_point_cast<std::chrono::seconds>(t);

    auto subsecs = duration_cast<microseconds>(t - s).count();

    std::time_t tt = system_clock::to_time_t(t);

    std::tm tm = *std::gmtime(&tt);

    std::ostringstream os;

    os << std::put_time(&tm, "%FT%T") << "." << std::setw(6)

       << std::setfill('0') << subsecs << "Z";

    return os.str();

}

在实践中,这个辅助函数在请求生命周期的关键点(如入口和完成时)调用最为有用。

以下是在推理前后使用它的示例:


log_structured("INFO", "recv", "r-7f23", "resnet50", "1.12.3", "cuda:0", 8,

               "{\"shape\":[8,3,224,224],\"deadline_ms\":200}");

// ... inference happens ...

log_structured("INFO", "done", "r-7f23", "resnet50", "1.12.3", "cuda:0", 8,

               "{\"p50_ms\":12.1,\"p99_ms\":27.4,\"gpu_mem_mb\":1420,\"cache_hit\":true}");

结构化日志对于基于分群的调试尤其有价值。例如,假设在来自 app_version=17.2 的流量增加后不久出现准确率回退。由于日志包含分群标识、输入形状和预处理版本,工程师可以快速发现只有该分群出现了新的输入形状和预处理范围。这指向的是数据契约问题,而非模型质量回退。

进程内指标

日志提供了细节,但不是监控趋势的最佳方式。为此,你需要在推理进程内部维护低成本、始终开启的指标。一个小的进程内聚合器——通常由原子操作和环形缓冲区构建——可以用极小的开销汇总请求计数、排队时间、推理时间、负载大小和错误计数。

以下示例展示了一个最小的指标结构体:


#include <atomic>

#include <cstdint>

struct Metrics {

    std::atomic<uint64_t> req_total{0};

    std::atomic<uint64_t> err_total{0};

    std::atomic<uint64_t> q_time_us{0};

    std::atomic<uint64_t> infer_time_us{0};

    std::atomic<uint64_t> bytes_in{0};

    std::atomic<uint64_t> bytes_out{0};

    void observe_request(uint64_t q_us, uint64_t i_us,

                         uint64_t in_b, uint64_t out_b) {

        req_total++;

        q_time_us += q_us;

        infer_time_us += i_us;

        bytes_in += in_b;

        bytes_out += out_b;

    }

    void observe_error() {

        err_total++;

    }

};

当指标以你的监控栈可以抓取或摄入的格式定期导出时,它们就变得有用了。一种简单的方法是每隔几秒刷新一次快照。以下 C++ 代码是定期刷新指标的辅助函数:


#include <iostream>

inline void flush_metrics(const Metrics& m) {

    std::cout

        << "req_total " << m.req_total.load() << "\n";

}


 << "err_total " << m.err_total.load() << "\n"

 << "q_time_us " << m.q_time_us.load() << "\n"

 << "infer_time_us " << m.infer_time_us.load() << "\n"

 << "bytes_in " << m.bytes_in.load() << "\n"

 << "bytes_out " << m.bytes_out.load() << "\n";

}

一旦这些指标可用,调试问题就变得更加具体。平均批大小是否下降,导致利用率降低?队列时间增长而推理时间保持不变,是否意味着存在批处理截止时间问题?bytes_in 是否突增,表明有效载荷膨胀?这些信号有助于确定下一步实验。

追踪

指标告诉你延迟发生了变化;追踪则告诉你在哪里发生变化。追踪将端到端延迟分解为命名的时间片段(span),使工程师能够区分,例如,解析请求所花费的时间与在微批处理器中等待或在 GPU 上执行所花费的时间。

添加追踪的一种简单方式是使用作用域计时器,将 span 的持续时间写入结构化日志:


#include <chrono>

#include <string>

struct Span {

    std::string name, req_id;

    std::chrono::high_resolution_clock::time_point t0;

    Span(std::string n, std::string r)

        : name(std::move(n)), req_id(std::move(r)), t0(std::chrono::high_resolution_clock::now()) {}

    ~Span() {

        auto us = std::chrono::duration_cast<std::chrono::microseconds>(

            std::chrono::high_resolution_clock::now() - t0).count();

        log_structured("TRACE", "span", req_id, "resnet50", "1.12.3", "cuda:0", 1, std::string("{\"span\":\"") + name + "\", \"dur_us\":" + std::to_string(us) + "}");

    }

};

然后可以按阶段对请求处理器进行插桩:


void handle_request(const Request& r) {

    Span s_all("req_total", r.id);

    { Span s_parse("parse", r.id); /* parse request */ }

    { Span s_pre("preprocess", r.id); /* preprocess */ }

    { Span s_queue("queue", r.id); /* micro-batcher wait */ }

    { Span s_infer("infer", r.id); /* model.forward(...) */ }

    { Span s_post("postprocess", r.id); /* postprocess */ }

}

如果 P99 延迟上升了 80 毫秒,而追踪显示 queue span 增加了约 70 毫秒,同时推理时间保持平稳,那么问题很可能出在节奏控制或批处理上,而不是模型内核变慢。下图展示了这一模式。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_467_0.png

图 11.4:推理请求的瀑布式追踪示例

请求追踪将端到端延迟分解为解析、预处理、排队、推理和后处理等 span。展开的 queue span 显示,延迟主要来自批处理等待时间,而不是计算时间。

结构化日志、指标和追踪共同提供了调试所需的原始证据。下一步是让故障可复现,从而将这些信号转化为受控的诊断过程。

使故障可复现

无法复现的缺陷很难有信心去修复。复现需要相同的位、相同的数学运算和相同的控制流。在实践中,这意味着要固定随机性来源、记录足够的元数据以重建请求,并部署低成本的哨兵检查,以便在故障源头附近捕获故障。

从随机性、形状和输入入手:

  • 随机种子:固定所有随机数生成器,包括 std::mt19937 和库随机数生成器(RNG)。
  • 形状和数据类型:显式记录它们;隐式广播和类型提升常常隐藏缺陷。
  • 复现包(repro pack):当异常发生时,捕获一个紧凑的数据包,其中包含模型版本、预处理版本、标志位、随机种子、归一化输入或可复现哈希,以及设备和驱动/运行时信息。

以下代码片段展示了一个最小化的复现包定义:


#include <random>

#include <string>

struct ReproPack {

    std::string model_ver;

    std::string preprocess_ver;

    std::string device;

    std::string flags;

    uint64_t seed;

    // Optional: small binary blob for normalized inputs, or hashes plus reconstruction method.

};

inline std::mt19937 make_rng(uint64_t seed) {

    return std::mt19937{static_cast<unsigned>(seed)};

}

一条实用的运维准则是:只存储复现故障所需的内容,同时避免保存原始用户负载,除非策略明确允许。在许多系统中,复现包应包含以下之一:

  • 经过归一化的张量,敏感字段已脱敏
  • 稳定的哈希以及一种确定性的重建方法
  • 受限环境中隔离样本的指针

这样既能保持调试流程高效,又不会使其变成不受控的数据留存途径。

另一个实用的防护措施是,在数值上有风险的操作(如归一化、除法、对数、指数或 softmax)之后,立即添加哨兵检查,例如廉价的 NaN/Inf 守卫:


#include <cmath>

template <typename T>

bool has_nan_inf(const T* data, size_t n) {

    for (size_t i=0;i<n;++i)

        if (!std::isfinite(static_cast<double>(data[i]))) return true;

    return false;

}

如果哨兵被触发,请记录当前活跃的 span 路径并持久化一个复现包。在金丝雀或影子环境中,通常最好快速失败。在生产环境中,系统通常需要一条回退路径,例如基于规则的响应、缓存结果或降级但安全的输出。

掌握了可复现的故障后,下一个任务就是正确地对缺陷进行分类。

隔离缺陷类别

大多数生产环境调试可归为三大类:数值类缺陷、数据契约类缺陷和并发类缺陷。关键在于快速分类,因为每个类别对应不同的诊断工具和可能的修复方案。

数值类缺陷

数值类缺陷通常表现为概率不稳定、NaN 或 Inf 传播,或不同设备和精度下的输出差异。常见症状包括:

  • Softmax 或 sigmoid 意外饱和
  • FP16 或 BF16 失败而 FP32 保持稳定
  • 输出随编译器或硬件目标而变化
  • 量化模型将置信度压缩到极端值

典型策略包括:

  • 使用数值稳定的形式,例如对 softmax 使用 log-sum-exp
  • 为除法或平方根添加 epsilon 截断
  • 将 FP32 与 FP16 或 BF16 的输出进行比较
  • 诊断期间禁用激进的快速数学优化
  • 检查平台上的非规范数(denormal)和刷新为零(flush-to-zero)行为

以下辅助函数展示了稳定的 log-sum-exp 实现:


#include <algorithm>

#include <cmath>

float stable_logsumexp(const float* x, int n) {

    float m = x[0];

    for (int i = 1; i < n; ++i) {

        m = std::max(m, x[i]);

    }

    double acc = 0.0;

    for (int i = 0; i < n; ++i) {

        acc += std::exp(double(x[i] - m));

    }

    return m + float(std::log(acc));

}

根据用例不同,例如 INT8 量化模型,对于较大的 logits,置信度可能会饱和到 0 或 1,而 FP32 参考值依然表现良好。这种情况下,修复方案很可能涉及在易受影响的层进行重新缩放、重新校准或混合精度。

数据契约类缺陷

数据契约类缺陷发生在模型与传入数据在结构、编码、单位或允许值上不再一致时。症状包括:

  • 未知类别突然增多
  • 特征上的 PSI 或 KS 指标突增
  • 形状或数据类型不匹配
  • 权重不变但校准漂移

典型策略包括:

  • 在每个请求上强制使用模式版本
  • 对未知模式版本进行路由或拒绝
  • 监控未知 token 的比例
  • 从已知良好的时间窗口重新计算归一化统计量
  • 为单位假设编写显式测试,例如毫秒与秒、美分与美元

调试与重新训练已部署模型

一个常见的例子是:支付数据流将货币单位从美元切换为美分,而特征管线仍然继续除以 100。于是,模型看到的特征范围被压缩,校准效果下降,准确率也随之降低——尽管权重并没有改变。问题出在变换(transform)环节,而不是重新训练。

并发与同步错误

当服务在并行执行时的行为与单独执行时不同,就会出现并发错误。常见症状包括:

  • 非确定性故障
  • 偶发超时
  • 死锁
  • 释放后使用(use-after-free)
  • GPU 内核故障在远离原始 bug 的位置才暴露

在 CPU 侧,常见策略包括:

  • 使用 AddressSanitizer(ASan)、ThreadSanitizer(TSan)以及未定义行为检测器(undefined-behavior sanitizers)进行编译
  • 尽量减少共享的可变状态
  • 在可行的情况下优先使用队列或消息传递
  • 在热点路径上,使用原子操作处理简单计数器,而不是加锁

在 GPU 侧,首先要逐一检查每个 CUDA 调用,并在可疑路径上强制同步。下面的代码片段展示了 CUDA 错误检查宏:


#include <cassert>

#include <cuda_runtime.h>

#include <iostream>

#define CUDA_CHECK(call) do { \

    cudaError_t err__ = (call); \

    if (err__ != cudaSuccess) { \

        std::cerr << "CUDA error " \

                  << cudaGetErrorString(err__) \

                  << " @ " << __FILE__ << ":" << __LINE__ << "\n"; \

        assert(false); \

    } \

} while (0)

在诊断时,通常值得设置 CUDA_LAUNCH_BLOCKING=1,或者在可疑启动之后插入 cudaDeviceSynchronize(),这样故障会在更接近真正源头的位置暴露出来。compute-sanitizer 等设备端工具对于捕获非法访问和竞争条件特别有用。还要检查缓冲区生命周期、流顺序和分配器行为;很多看似随机的 GPU 故障其实是由高负载下的释放后使用或碎片化引起的。

下表总结了常见的 CUDA 故障以及首要诊断步骤:

CUDA 症状 可能原因 首要诊断步骤
非法内存访问 索引越界、过期指针、已释放缓冲区 使用 compute-sanitizer 运行
未指定的启动失败 较早的异步内核故障延迟暴露 设置 CUDA_LAUNCH_BLOCKING=1
突发负载下的 OOM 碎片化、分配器频繁波动、批次突增 检查分配器日志和批次大小
非确定性错误输出 流顺序问题、竞争、过期缓冲区重用 添加显式同步/事件,并对比隔离运行

表11.1:常见 CUDA 运行时问题快速诊断参考

只有在高 每秒查询数(QPS)下才会出现的罕见崩溃,往往其实是并发问题,而不是数值问题。例如,如果追踪数据显示队列等待时间很长,而分配器日志显示频繁重新分配,那么使用带有预分配内存区域的池化分配器,或许能同时消除碎片化和该崩溃。

一旦理解了主要的 bug 类型,就更容易诊断服务系统中最常见的运维困惑之一:排队时间与计算时间。

区分排队时间与计算时间

当 p99 延迟上升时,原因往往不是模型内核本身,而是请求等待分批所花费的时间。一个带有监控的小型微批处理器可以让这种区别变得显而易见。下面的示例展示了一个极简的批处理工作线程:

  • 以下是所需的头文件:

    
    #include <condition_variable>
    
    #include <deque>
    
    #include <future>
    
    #include <mutex>
    
    #include <thread>
    
    

    这些头文件提供了微批处理器所使用的同步与并发原语。条件变量让工作线程休眠,直到新请求到达;deque 存储排队任务;promise–future 对则允许异步返回结果。互斥锁保护共享状态,thread 头文件则用于在后台运行批处理工作线程。

  • 以下是张量占位符:

    
    using Tensor = torch::Tensor;
    
    

    这一行将 Tensor 定义为应用实际使用的张量类型的别名。在本示例中,它指代 torch::Tensor,但分批逻辑换成其他张量表示方式也同样适用。使用别名能让代码更紧凑,也便于以后更换为不同的后端。

  • 以下是一个排队请求:

    
    struct Task {
    
        std::string id;
    
        Tensor x;
    
        std::promise<Tensor> p;
    
    };
    
    

    每个排队的请求都表示为一个任务。它包含请求标识符、输入张量,以及一个最终会保存输出张量的 promise。这让调用方可以立即提交工作,稍后通过与之一一对应的 future 获取结果。

  • 以下是类定义:

    
    class MicroBatcher {
    
    public:
    
        MicroBatcher(size_t max_batch, int max_delay_ms);
    
        ~MicroBatcher();
    
        std::future<Tensor> submit(std::string id, Tensor x);
    
    private:
    
        void run();
    
        size_t max_batch_;
    
        int max_delay_ms_;
    
        std::mutex mu_;
    
        std::condition_variable cv_;
    
        std::deque<Task> q_;
    
        std::thread worker_;
    
        bool stop_;
    
    };
    
    

    MicroBatcher 类拥有队列、同步原语和后台工作线程。两个配置参数 max_batch_ 和 max_delay_ms_ 决定了最多能合并多少个请求,以及系统在处理一个未满批次前愿意等待多长时间。这两个参数共同控制吞吐量与延迟之间的权衡。

  • 以下是构造函数:

    
    MicroBatcher::MicroBatcher(size_t max_batch, int max_delay_ms)
    
        : max_batch_(max_batch),
    
          max_delay_ms_(max_delay_ms),
    
          stop_(false) {
    
        worker_ = std::thread([this] { run(); });
    
    }
    
    

    构造函数初始化分批参数并启动后台工作线程。从此刻起,批处理器就可以接受请求了。让工作线程在独立线程中运行,可以使提交与执行异步进行。

  • 以下是析构函数:

    
    MicroBatcher::~MicroBatcher() {
    
        {
    
            std::lock_guard<std::mutex> lk(mu_);
    
            stop_ = true;
    
        }
    
        cv_.notify_all();
    
        worker_.join();
    
    }
    
    

    析构函数会干净地关闭工作线程。它在持有互斥锁时设置 stop 标志,如果工作线程正在条件变量上等待,则将其唤醒,然后 join 该线程。这可以确保所有资源都被安全释放,也确保批处理器不会在仍有工作进行时退出。

  • 以下是提交请求的方法:

    
    std::future<Tensor> MicroBatcher::submit(std::string id, Tensor x) {
    
        std::promise<Tensor> p;
    
        auto f = p.get_future();
    
        {
    
            std::lock_guard<std::mutex> lk(mu_);
    
            q_.push_back({std::move(id), std::move(x), std::move(p)});
    
        }
    
        cv_.notify_one();
    
        return f;
    
    }
    
    

    submit 函数将请求封装为任务,追加到共享队列中,并唤醒工作线程。它会立即返回一个 future,使调用方无需阻塞即可继续执行。一旦批次处理完成,工作线程会兑现对应的 promise,调用方就可以通过该 future 获取结果。

  • 以下是工作线程主循环:

    
    void MicroBatcher::run() {
    
        for (;;) {
    
            std::deque<Task> batch;
    
            {
    
                std::unique_lock<std::mutex> lk(mu_);
    
                if (stop_ && q_.empty()) break;
    
                if (q_.empty()) {
    
                    cv_.wait(lk);
    
                    continue;
    
                }
    
                auto deadline =
    
                    std::chrono::steady_clock::now() +
    
                    std::chrono::milliseconds(max_delay_ms_);
    
                while (q_.size() < max_batch_) {
    
                    if (cv_.wait_until(lk, deadline) == std::cv_status::timeout) {
    
                        break;
    
                    }
    
                    if (stop_) break;
    
                }
    
            }
    
        }
    
    }
    
    

调试与重新训练已部署模型


while (!q_.empty() && batch.size() < max_batch_) {

    batch.push_back(std::move(q_.front()));

    q_.pop_front();

}

}

// Run one batched inference here.

for (auto& t : batch) {

    t.p.set_value(Tensor{});

}

}

}

工作循环是微批处理器的核心。它等待请求出现,然后启动一个计时器并持续收集请求,直到批次达到配置的最大大小,或等待时间达到配置的上限。批次组装完成后,工作器释放锁,并通常会执行一次批推理调用。推理完成后,它会兑现每个 promise,让原始调用方收到各自的输出。这种设计让排队时间变得明确,并帮助工程师在排查延迟退化时,将批处理延迟与计算时间区分开来。

一种常见的故障模式是这样的:为了提升吞吐量,批处理截止时间从 8 毫秒增加到 25 毫秒,但移动端用户的 p95 延迟突然变差。追踪显示额外时间花在了 queue 上,而不是 infer 上。修复方法不是修改模型架构或权重,而是对延迟敏感的分群使用更小的批处理延迟。

使用以证据为先的运维手册

一份有用的运维手册应当从最具区分度的信号开始,再逐步推进到成本最高的调查手段。一个简单的顺序如下:

  1. 追踪:定位有问题的 span:队列、预处理、推理,还是后处理?
  2. 批处理统计:平均/p95 批大小是下降(利用率损失)还是飙升(队列截止时间过长)?
  3. 分配器日志:GPU 余量是否缩小(碎片化/新模型)?是否出现 OOM 重试?
  4. 缓存:分词器/嵌入缓存是否变冷?淘汰策略是否改变?
  5. 部署:模型、预处理、驱动或运行时是否有更新?将时间戳关联起来,如果对齐则回滚。
  6. 分群:是否有新的 app_version、设备或区域出现了更重的预处理或更大的负载?
  7. GPU 健康状态:时钟是否被限制,或是否存在流式多处理器(SM)占用率回退?如果需要,对一个样本进行剖析。

以下清单可供值班工程师在事件期间用作值班调试清单:

  • 从追踪中识别占主导地位的慢 span
  • 比较排队时间和推理时间
  • 检查批大小分布
  • 按分群检查错误率峰值
  • 确认最近的部署和配置变更
  • 检查分配器和缓存健康状况
  • 确认问题是否在固定种子和固定形状下可复现

以这种方式使用时,以证据为先的调试用可复现的方法取代了直觉猜测。结构化日志解释每个请求,指标揭示趋势,追踪显示时间消耗在哪里。一旦故障可复现并被正确分类,修复就会变得有针对性,而不是猜测性的。

下一节将从诊断转向干预:如何有目的地重新训练、安全地发布,并让部署与不断变化的环境保持同步。

有目的地重新训练

生产模型应该因为证据表明有必要而刷新,而不是因为某个仪表盘略微变动,或团队感到不安。有目的地重训意味着三件事:决定何时需要重训,让重训过程可复现,并在与生产环境相同的约束下评估候选模型。

决定何时重训

并非每个异常都值得一个新模型。首要任务是区分两类情况:一类可以通过阈值更新或校准修复来处理,另一类则是学到的决策边界已经不再合适。

例如,在欺诈系统中,周末假期可能同时改变设备分布和欺诈基准率。如果特征分布发生漂移,但在线校准仍然良好,模型可能不需要重训。如果特征管道保持稳定,而校准变差,那么更新阈值或校准可能就足够了。相比之下,概念漂移发生在特征与结果之间的关系发生变化时——由于新的攻击者行为、策略更新或底层任务的变化。这时重训才变得必要。

推荐系统也表现出类似的模式。目录刷新可能会改变物品共现结构和冷启动行为。如果评分分布变窄,并且点击率或停留时长指标专门对新用户或新物品下降,那么重训最相关的模型组件可以在不改变整个系统的情况下恢复性能。下图总结了这一决策流程。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_478_0.png

图 11.6:重训触发条件与决策流程

系统监控特征、预测和标签漂移,在线 KPI 输入决策节点。如果问题是校准或阈值,该路径会先更新这些;否则,重训分支会启动管道。

一旦重训有理有据,下一个要求就是可复现性。生产重训应当可重复、可审计,并与服务阶段使用的数据和特征逻辑保持一致。

让重训具有确定性

重训应该在几个月后也能被另一位工程师复现。这首先要冻结一个限定时间范围的数据快照并记录其哈希。分割应基于时间或实体,具体取决于泄漏风险。如果季节性很重要,可以显式地对近期数据重新加权,而不是通过临时采样隐式实现。

特征生成应与生产完全一致,理想情况下通过共享库或特征存储实现。当变换改变时,模式版本也应随之改变。种子、超参数、损失定义和正则化设置都应作为训练配方的一部分记录下来。

然后,模型注册表应记录以下内容:

  • 数据集版本和哈希
  • 特征模式版本
  • 代码提交
  • 种子值
  • 离线指标
  • 导出的产物,如 FP32、FP16、BF16 或 INT8 变体

如果候选模型无法从这些要素中重建,就不能安全上线。下图展示了从快照到发布的重训漏斗。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_479_0.png

图 11.7:候选模型评估漏斗

候选模型依次经过数据快照、训练、离线评估、硬件基准测试、影子测试、金丝雀发布和完全部署。在发布之前,必须同时验证模型质量和服务行为。

候选评估漏斗概述了发布的各个阶段;下一节将说明用于决定候选模型是否可以进入下一阶段的统计与系统标准。

在服务约束下评估模型

离线胜利是必要的,但还不够。从大型教师模型蒸馏出的学生模型可能显示 AUC 提升 0.3 个百分点,但延迟预算超了 2 倍,或内存上限超出 1 GB。需要从两个维度进行评估:

  • 统计质量应包含在 AUC、F1 和对数损失等指标上的持平或改进,以及 ECE 或 Brier 分数等校准度量。这些检查还应按区域、设备、应用版本或其他关键分群进行切片,以确保收益没有掩盖某个子组中的回退。
  • 系统性能应包含 p50、p95 和 p99 延迟;稳态和峰值内存;加载时间;以及在目标硬件上以实际批大小运行的吞吐量。如果 INT8 提高了速度,但使校准劣化超过约定阈值,那么即使吞吐量更好,也可能不可接受。

将这些期望编码下来的一种实用方式是通过一个小型版本化验收门禁。下面是一个示例:


# acceptance.yaml

offline:

  require:

    auc_delta: ">= -0.001" # no worse than -0.1 pt vs champion

    logloss_pct: "<= +2%"  # ≤2% regression

    ece_pct: "<= +10%"     # calibration within 10%

systems:

  target: "A10G"

  require:

    p95_ms: "<= 35"

    max_mem_gb: "<= 3.0"

    load_ms: "<= 500"

    throughput_qps: ">= champion"

只有同时通过这两个部分的候选模型才能进入在线测试。

第 11 章:调试和重新训练已部署的模型

安全发布

通过渐进式发布阶梯,将模型从实验室推向生产环境。在影子模式下,将所有请求镜像到候选模型,同时不影响用户。将候选模型的决策、分数和延迟与当前线上模型(champion)进行对比。如果分歧集中在某一个群体(cohort),例如一种新设备类型,往往就能揭示概念漂移正在哪里显现。当标签延迟到达时,影子评估尤其有价值,因为它能在真实结果可用之前提供早期信号。

如果影子评估表现正常,模型就可以进入金丝雀发布(canary rollout)。一份实用的发布计划应包含以下要素:

  • 从小流量开始:先接入一小部分流量,例如 5%
  • 定义自动化护栏:按群体监控 p95 延迟、错误率和领域 KPI 变化量
  • 设置回滚条件:例如,如果高风险商户的拒付率上升 0.15 个百分点,或 p95 延迟连续超过 45 毫秒达 10 分钟以上,欺诈模型应回滚
  • 逐步放量:如果金丝雀保持健康,就逐步增加流量占比,例如 5%、25%、50%、100%
  • 让此前的线上模型保持热备:之前的模型应常驻内存,这样回滚只是一次配置变更,而不是冷启动

这种分阶段的方法降低了部署风险,同时确保在发布推进过程中,模型质量和在线服务行为都保持在可接受的范围内。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_481_0.png

图 11.8:带护栏的发布阶梯

图中展示了一条从影子模式到 5%、25%、50%、100% 的阶梯式发布路径,每一道关口都会检查延迟、错误率和领域 KPI 的回滚阈值。逐步推进;一旦触发护栏即自动回滚。

发布后闭环

发布并不是终点——而是重置基线的时刻。将漂移检测和校准所用的参考基准更新为新模型,归档血缘信息(lineage)——数据哈希、代码、产物、验收报告以及影子/金丝雀仪表盘——并按你偏好的节奏安排下一次检查点,同时让漂移触发器保持启用。如果你保留了人在回路(human-in-the-loop)通道(例如一个用于高不确定度案例的小型人工裁决队列),就将这些新鲜、富含信息的标签喂入下一个快照,以便在模型最吃力的地方加以改进。

两个具体场景

第一个场景是金融科技欺诈检测。在移动操作系统发布之后,漂移监控发出告警:device_type 的 PSI 不断上升,“unknown”值增加了 15%。你首先修补特征管道(SDK 开始产生一个新的枚举值)并提升 schema 版本号;无需改动模型,校准便迅速恢复。两个月后,假日购物改变了商户构成和基础比率;尽管特征稳定,校准仍然恶化。你重新校准阈值,并在几分钟内完成部署。到了 1 月,攻击者转变策略;影子模式显示分歧集中在礼品卡商户上。你用最近六周的数据重新训练,保持架构不变,导出 FP16 和 INT8 变体,以 p95 ≤35 ms 为门槛,并通过带拒付率护栏的金丝雀发布上线。

第二个场景是电商搜索排序。春季商品目录刷新新增了数千个 SKU。预测漂移表现为分数分布变宽;冷启动商品的 CTR 趋于平缓。你只用最新的目录数据和用户行为重新训练了物品塔(item tower)的嵌入,保留用户塔(user tower)以降低风险。学生模型通过了离线关卡,在 FP16 下 p95 延迟改善 20%,并顺利通过 10% 的金丝雀发布,没有任何 KPI 回退。全量发布在 48 小时内完成;参考基准和预算也随之更新。

按照这种方式,重新训练变成了一项常规的系统运维操作,而不是一场救火演习:触发条件有统计依据,管道端到端可复现,评估遵循生产约束,发布在改进模型的同时保护用户和收入。

小结

本章将已部署的模型视为一个处于持续观察之下的系统。我们首先展示了生产模型如何随环境变化而发生漂移,以及如何用轻量级、常驻运行的摘要和稳定的统计检验来检测这种漂移。你学会了如何区分特征漂移、预测漂移、先验偏移和概念漂移,以及如何使用基于群体的监控,将局部故障与大规模系统变更区分开。

随后,我们转向生产环境中的调试。结构化日志、指标和追踪提供了观察回退、复现故障和定位根因所需的证据。借助固定种子、可复现的输入,以及针对数值不稳定、数据契约违例和并发 bug 的定向检查,调试变成了一种有章可循的过程,而不是靠猜测。

最后,我们把重新训练视为一项运维决策。重新训练应当由证据触发,通过可复现的管道实施,在真实的服务约束下评估,并通过影子测试和带回滚护栏的金丝雀发布逐步推进。综合起来,这些实践有助于让模型质量、延迟和成本保持在生产环境所要求的服务水平之内。

在下一章中,我们将延续这一运维视角,专注于对已部署模型进行长期监控。你将学习如何定义面向生产的指标、在标签延迟到达时追踪质量,以及如何将监控信号连接到告警和更长期的系统维护。

思考题

以下问题回顾了本章的主要观点,并帮助你强化有效调试、监控和重新训练已部署模型所需的运维思维:

  1. 定义并对比协变量(特征)漂移、标签/先验偏移、预测漂移和概念漂移。针对每一种,说明一个可能的补救措施。

  2. 你的仪表盘显示,分类特征 device_type 的“unknown”值突然激增,而模型权重没有变化,准确率却在下降。你首先会调查什么?为什么?

  3. 你为数值特征维护固定分箱直方图,并计算一小时实时窗口与季节匹配参考窗口之间的 PSI。amount_usd 的 PSI 反复测得 0.28。你会如何应对?

  4. 在高吞吐量服务的漂移检测中,为什么我们更倾向于使用滚动摘要(直方图、分位数和 Top-K 表),而不是存储原始负载?

  5. 一次部署后,p95 延迟回退了 20 毫秒。在日志、指标或追踪这三大可观测性支柱中,哪一个能告诉你时间消耗在哪里?为什么?

  6. 尽管模型计算耗时没有变化,p99 延迟却随流量峰值翻了一倍。请说出两个可能的原因,以及一种确认它们的方法。

  7. 请列出在一条结构化、自描述的推理日志行中应包含的五个字段(不含个人身份信息,PII)。

  8. 在制作可复现包(repro pack)时,为什么要固定 RNG 种子并记录张量形状/数据类型?

  9. 在 GPU 上,某个内核会间歇性失败,而且失败发生的时间远晚于导致它的代码。请解释 CUDA_LAUNCH_BLOCKING=1 会改变什么,以及你会在何时使用 compute-sanitizer(cuda-memcheck 的现代替代工具)。

  10. 在线校准发生了漂移,但输入的 PSI 保持平稳。在重新训练之前,可以尝试哪种低风险修复?

  11. 针对候选模型,定义影子测试与金丝雀发布的区别,并分别给出你在每个阶段会监控的一项指标。

  12. 某个候选模型在离线评估中使 AUC 提升了 0.3 个百分点,但在目标 GPU 上的延迟超出了你的延迟 SLO 达 2 倍。它是否应该进入金丝雀发布?请解释你的答案。

  13. 在在线测试之前,说出你会编入验收策略的一项离线关卡和一项系统关卡。

  14. 描述一种快速流程,用于在生产环境中区分数值稳定性 bug 与数据契约 bug。

  15. 在发布新的线上模型后,列出三项“闭环”后续工作。

延伸阅读

以下参考资料为想要更深入探索本章主题的读者提供了额外的背景知识和实践细节:

  • 斯库利等人(Sculley et al.),《机器学习系统中隐藏的技术债务》。一篇关于模型训练之外运维陷阱的经典论文。
  • 布雷克等人(Breck et al.),《ML 测试评分》。一份适用于 ML 系统生产就绪性和监控的检查清单。
  • OpenTelemetry 规范。提供了追踪、指标和日志的标准概念;有助于设计横跨预处理、推理和后处理的追踪跨度。
  • Prometheus 文档。涵盖指标暴露格式、直方图/摘要语义、SLO 告警规则和漂移预算。

第 11 章

  • NVIDIA CUDA C++ 编程指南 / Nsight Systems 与 Nsight Compute 指南。它们是用于 GPU 调试、性能分析和稳定性检查的实用工具。
  • Evidently AI 文档(漂移和数据质量报告)。PSI/KS、校准检查和分群仪表盘的具体实现。
  • Alibi Detect(漂移检测)。适用于在线服务的协变量漂移和概念漂移方法。
  • Bird 等人,《公平性与校准》。为什么校准很重要,以及如何衡量它(ECE/Brier),尤其是在分布偏移情况下。
  • 关于量化和蒸馏的论文(例如 Jacob 等人的 INT8 量化;Hinton 等人的蒸馏)。当重训练与系统约束交织时。
  • Google SRE 工作手册(SLI/SLO)。告警设计(延迟/错误预算)的基础,可复用于机器学习端点。

答案

以下是复习题的答案,并强化本章介绍的核心概念:

  1. 答案:
    • 协变量漂移:输入分布发生变化
    • 标签/先验偏移:类别比例发生变化
    • 预测漂移:模型输出分布发生变化
    • 概念漂移:潜在的 X→Y 关系发生变化
  2. 在特征管道中排查数据契约问题(新的 device_type 枚举或解析逻辑变更)。未知率飙升指向数据摄取/编码环节,而非学到的决策边界。
  3. 如果 PSI 在多个监控窗口内保持在 0.28 左右,说明特征分布存在实质性漂移。确认没有单位/schema 变更;如果管道干净,安排一次针对性的新数据重训练。如果是季节性/预期变化,可考虑分群特定阈值或重新校准。
  4. 摘要统计具有隐私保护、维护成本低、比较速度快等优点。它们避免了繁重的存储/连接操作,让你能在微秒级计算 PSI/KS,从而在热路径上实现持续监控。
  5. 追踪(span)可以精准定位慢速阶段(排队 vs 预处理 vs 推理 vs 后处理)。指标显示延迟上升;追踪则显示具体在哪里。
  6. 原因:排队/微批处理效率低下(批次更小、等待更长),以及资源争用/冷缓存(分词器/嵌入缓存未命中)。可通过追踪(更大的“queue” span)和指标中的批次大小分布来确认;缓存命中率指标可佐证。
  7. 示例字段:ts、request_id、model_name、model_version、schema_version、device(cpu/cuda:0)、batch_size、形状/数据类型,以及时间预算(截止时间)。
  8. 随机种子和形状/dtype 可确保相同数值路径的确定性重现;细微的 bug 往往取决于广播规则、精度和由 RNG(随机数生成器)驱动的分支。
  9. CUDA_LAUNCH_BLOCKING=1 强制内核同步启动,因此失败点会立即显现。使用 compute-sanitizer 捕获设备上的非法内存访问、竞态条件和越界访问。
  10. 应用阈值和/或校准更新(例如 Platt 缩放/温度缩放)。这会使预测概率与观测结果对齐;阈值调整在不重训练模型的情况下改变决策规则。
  11. 影子模式:候选模型运行在镜像流量上,对用户无影响;监控分歧率和延迟持平情况。金丝雀发布:候选模型服务一小部分真实请求;按分群监控 p95 延迟、错误率和领域 KPI,并支持自动回滚。
  12. 不能。候选模型除了满足离线质量外,还必须达到系统 SLO。延迟超出预算 2 倍会损害用户体验;应继续迭代(编译/量化/剪枝)或拒绝。
  13. 离线门禁:AUC ≥ 冠军模型 − 0.001,且 ECE ≤ +10%。系统门禁:在 A10G、batch=8 下,p95 ≤ 35 ms 且 VRAM ≤ 3 GB。两项都必须通过才能进行在线测试。
  14. 数值问题与数据契约:在固定输入集上分别用 FP32 与 FP16 复现;如果 FP32 稳定而 FP16 失败,应怀疑数值稳定性。如果失败与 schema/版本不匹配或未知率飙升相关,应怀疑数据契约。在变换边界添加 NaN/Inf 哨兵值。
  15. 重置参考基准(漂移/校准基线),归档血缘关系(数据/代码/工件与验收报告),并在保持漂移触发器激活的同时安排下一次检查(节奏);将人工判定的困难样本输入下一个快照。

获取本书 PDF 版本及更多资源

扫描二维码(或访问 packtpub.com/unlock)。按书名搜索本书,确认版本,然后按照页面上的步骤操作。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_487_0.pnghttps://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_487_1.png

注意:请准备好发票。直接从 Packt 网站购买则无需发票。

12 监控已部署模型

一旦模型进入生产环境,目标就从强大的离线准确率转向稳定的真实世界行为。关键问题不再仅仅是它在留出测试集上表现是否良好,还包括它在真实流量下是否依然快速、可靠、成本高效且可观测。监控正是让这些预期变得可衡量的手段。

在本章中,我们将模型及服务栈视为一个真实运行的生产系统。我们为质量、延迟和资源使用定义核心服务级别指标(SLI),并展示如何用 C++ 以低开销直接对这些指标进行埋点。我们还会区分监控与可观测性:监控聚焦于你主动跟踪并设置告警的一小组信号,而可观测性则提供日志、指标和追踪,用于在信号变化时解释这些信号。

由于生产环境中的标签往往延迟到达,本章还介绍在真实标注延迟时如何监控在线质量。我们引入领先指标,例如置信度漂移、冠军模型-挑战者模型分歧率和弃权率,并展示如何在不拖慢热路径的情况下,将延迟标签重新合并到滚动质量指标中。

在本章中,我们将探讨几个相关主题:

  • 模型监控的重要性
  • 需要跟踪的关键指标
  • C++ 埋点:Prometheus 风格指标、结构化日志和追踪/span
  • 真实世界中的在线质量
  • 从信号到行动

学完本章后,你将能为你的 C++ 推理服务构建一个低开销的监控层。你会为延迟、质量和资源使用定义有意义的 SLI 和 SLO;通过一个极简的 /metrics 端点暴露指标;输出带有请求 ID、模型版本、张量形状和时序数据的结构化日志;并添加作用域追踪来分解请求延迟。有了这些信号,仪表盘和告警就能尽早呈现漂移、性能退化或资源压力,使团队能够在问题影响用户之前调整阈值、重新校准模型或触发重训练工作流。

模型监控的重要性

机器学习模型在线上取得成功,不仅仅是因为它在离线评估中表现良好。它成功的原因在于,随着真实世界条件的变化,周边服务仍保持可观测、可靠,并处于既定的运维限制之内。监控将“快速”“准确”“可靠”等宽泛目标转化为团队可以可视化并采取行动的可衡量、具体信号。

这些信号被称为服务级别指标(SLI)。推理服务的常见示例包括请求延迟百分位数(p95 或 p99,对应第 95 或第 99 百分位的响应时间)、错误率、吞吐量(每秒查询数或每秒 token 数)、GPU 内存余量(可用 VRAM),以及期望校准误差(ECE)等模型质量指标。

服务级别目标(SLO)定义这些信号的可接受边界,例如 p95 延迟低于 120ms(P95 <120ms)、可用性高于 99.9%(availability ≥ 99.9%),或 ECE 低于既定阈值。SLI 和 SLO 共同将模糊的愿望转化为可操作的运维契约。

延迟是最重要的 SLI 之一,因为用户感受到的是尾部,而不是中位数。一个中位数 30ms 的系统,如果 p95 上升到 180ms,用户仍可能觉得卡顿。对于大语言模型(LLM)等流式模型,首字节/首个 Token 时间(TTFB/TTFT)与总完成时间同等重要,因为更早返回第一个 token 会锚定用户对响应速度的感知。

可用性是另一个基础信号。一个目标可用性为 99.9% 的服务(通常称为“三个九”)每月允许约 43.8 分钟的停机时间。目标与完美正常运行时间之间的差值就是错误预算,团队可以将其用于计划内变更(如部署和迁移),而不是反复处理事故。跟踪错误预算的消耗情况,可以让发布决策更依赖数据,而非直觉。

质量与校准和速度同等重要。仅凭准确率无法说明概率模型在生产环境中是否表现可靠。如果一个分类器报告 0.80 的置信度,那么随着时间的推移,这些样本中大约 80% 应当是正确的。ECE 衡量预测概率与观测结果在置信区间上的吻合程度。当校准误差恶化,而

第 12 章 监控已部署模型

在准确率保持稳定的情况下,温度缩放等重校准方法通常可以在需要重新训练之前恢复可靠性。

监控还能让推理成本变得可见。由于生产工作负载通常主导稳态支出,成本应与延迟和质量一并跟踪。每 1,000 次请求的成本、VRAM 余量、传输开销和吞吐量等信号,有助于揭示 INT8 量化或更紧凑的批处理等变更是否真正提升了效率。

有效的监控还需要群体感知(cohort-aware)的 SLI。按区域、设备类别、应用版本、流量来源或输入形状细分指标,使局部回归不会消失在全局平均值之中。整体 p95 保持稳定,仍可能掩盖特定区域或设备分段的性能下降。

最重要的是,监控应当指导行动。延迟以排队为主时,意味着需要调整批处理或并发设置。资源压力可能要求降低精度、缩小批次或修改并发限制。校准漂移可能要求在考虑重新训练之前更新阈值或进行重校准。

简而言之,监控在模型质量、运维可靠性和成本之间形成了闭环。当流量模式或输入分布发生变化时,SLI 能够揭示回归是来自排队、计算、数据漂移还是基础设施约束。这些证据正是生产系统在现实变化中保持可管理性的关键。

为了具体说明,下一个示例展示了如何在滚动采样窗口上高效计算 p95。由于尾延迟支撑着多个重要的 SLI,百分位计算常常是最值得优先实现的基础组件之一。


#include <vector>

#include <cmath>

#include <algorithm>

// Returns the 95th percentile in O(n) average time by partially selecting

// the kth element.

double p95(std::vector<double>& samples) {

    if (samples.empty()) return 0.0;

    const auto k = static_cast<size_t>(std::ceil(0.95 * samples.size())) - 1;

    std::nth_element(samples.begin(), samples.begin() + k, samples.end());

    return samples[k];

}

这个小型工具说明了 p95 延迟等常见监控信号如何在不完全排序采样窗口的情况下被高效计算。

本章其余部分会反复出现几个术语。SLI 是你测量的信号,SLO 是你为这些信号设定的目标边界。其他运维术语也经常出现在监控仪表盘中。TTFB(首字节时间,time to first byte)和 TTFT(首令牌时间,time to first token)衡量流式系统的响应速度。QPS,即每秒令牌数,描述吞吐量。VRAM 余量指可用于批处理和模型执行的空闲 GPU 内存量。微批处理(micro-batching)在遵守延迟限制的前提下临时将兼容的请求分组,以提高硬件利用率。准入控制(admission control)通过在容量超限时尽早拒绝请求(例如返回 HTTP 429)来保护服务可靠性。

这些术语共同构成了生产推理服务的运维词汇。你选择监控的指标以及为它们设定的目标,最终决定了你的 C++ 部署在真实流量下的可靠性表现。

为了让这些概念落地,我们接下来将测量本身与基于测量构建的目标和承诺区分开来,然后概述生产推理服务中值得跟踪的指标。

从愿景到契约:定义有用的 SLI 与 SLO

SLI 是你所测量的质量指标,例如 p95 延迟、ECE 或 VRAM 余量。SLO 是为该信号设定的目标边界,例如 p95 延迟低于 120ms 或可用性高于 99.9%。服务级别协议(SLA)是叠加在 SLO 之上的业务承诺,通常带有惩罚条款,因此 SLA 只应在系统能够持续满足的稳定 SLO 确立之后设定。好的 SLO 应同时具备群体感知和可操作性。 单一的全局指标可能掩盖局部故障,因此 SLO 应按设备、区域、应用版本或输入大小进行拆分。它们还应指明工程师可以调节的具体旋钮,例如批大小、微批延迟、精度、并发度、缓存预算或准入控制。

目标一旦确定,接下来的问题就是测量什么。在实践中,生产监控依赖四种指标类型。计数器(Counter)跟踪请求数、错误数或输出的令牌数等总量。仪表(Gauge)跟踪队列深度或可用 VRAM 等上下波动的值。直方图(Histogram)捕获延迟和负载大小等分布,在需要关注 p95 或 p99 时通常是正确选择。摘要(Summary)提供预计算的统计快照,但由于跨实例聚合较为困难,通常谨慎使用。例如,请求延迟通常用直方图监控,而 VRAM 余量最好以仪表形式暴露。

基数纪律与指标选择同样重要。按模型版本、设备、区域、应用版本和粗略的输入形状桶标记的直方图可能非常有用。按请求 ID 标记的指标则是灾难性的,因为高基数标签会造成指标爆炸,压垮后端并掩盖你真正需要的信号。

以下固定桶延迟直方图的更新成本足够低,可以在每次请求时更新,并且可以让你从桶计数中离线计算百分位数:


struct Histo {

    // Buckets up to 1s: [0-100),[100-200),...,[900000-1000000),[>=1s]

    static constexpr int kN = 11;

    static constexpr int kWidth = 100000; // 100 ms in us

    std::array<std::atomic<uint64_t>, kN> buckets{};

    void observe(uint64_t us) {

        size_t idx = (us >= kWidth * (kN - 1)) ? (kN - 1) : (us / kWidth);

        buckets[idx].fetch_add(1, std::memory_order_relaxed);

    }

};

这种轻量结构说明了一条重要的监控原则:有用的生产遥测数据应当采集成本低、易于聚合,并直接与治理系统的 SLI 和 SLO 相关联。

解读性能、质量与资源信号

指标告诉你什么发生了变化;追踪则帮助你解释为什么发生变化。追踪将端到端延迟分解为具名的跨度(span)。你并不需要追踪厂商才能开始:作用域计时器和结构化日志就足以让关键路径变得可见,后续还可以将相同的边界导出到 OpenTelemetry(一个开源的端到端分布式追踪工具)。为 parse、preprocess、queue、infer、postprocess、serialize、network_write 等跨度命名,有助于在几分钟而不是几小时内回答“额外的 120ms 花到哪里去了?”这样的问题。如果排队时间飙升,问题很可能出在批处理或并发上。如果 infer 时间增长而批大小保持稳定,回归更可能出在模型、内核或运行时。如果 postprocess 增长,瓶颈可能是 CPU 密集型的令牌化或图像变换。

一个最小化的作用域跨度如下所示:


struct Span {

    const char* name;

    std::chrono::high_resolution_clock::time_point t0;

    Span(const char* n) : name(n), t0(std::chrono::high_resolution_clock::now()) {}

    ~Span() {

        auto us = std::chrono::duration_cast<std::chrono::microseconds>(

            std::chrono::high_resolution_clock::now() - t0).count();

        std::cout << "{\"span\":\"" << name << "\",\"dur_us\":" << us << "}\n";

    }

};

在模型调用周围像这样使用它:Span s("infer");。之后,相同的计时可以导出到指标接收器或追踪后端。

尾延迟通常是排队问题,而非计算问题。利特尔定律(Little’s Law)告诉我们:系统中的平均请求数等于到达率乘以平均等待时间。当突发到达超过服务能力(计算能力)时,queue 会增长,p99 会迅速上升。微批处理通过摊薄内核启动开销并改善矩阵尺寸来提高 GPU 效率,但等待过久才形成批次会增大 TTFB 或 TTFT。因此,监控需要在延迟之外同时显示批大小分布和排队等待时间,以便你根据 SLO 调整微批截止时间。例如,8ms 的最大延迟可能仍能让 TTFT 保持在 100ms 以下,同时使 p95 ≤ 120ms 的延迟保持在目标范围内。

延迟并不是唯一重要的信号。校准和 ECE 也应放在同一讨论中,因为两者都是生产环境中的一等公民 SLI:一个衡量响应能力,另一个衡量可信度。要计算 ECE,按置信度对预测分桶(例如 10 个桶),然后将每个桶中的平均预测概率与实际正确率进行比较。ECE 就是这些绝对差距的加权平均值。如果 ECE 上升而准确率保持平稳,模型可能仍然能很好地对样本进行排序,但不再给出可靠的预测概率。在这种情况下,温度缩放或 Platt 缩放等重校准方法通常比立即重新训练更合适。

第十二章:监控已部署模型

即使标签稍后才到达,也可以仅用分箱计数和置信度之和来维护一个紧凑的流式 ECE 摘要:


struct CalibBin {

    std::atomic<uint64_t> n{0}, correct{0};

    std::atomic<double> sum_conf{0.0};

};

struct ECE {

    std::vector<CalibBin> bins;

    explicit ECE(int k=10) : bins(k) {}

    static int bindex(double p, int k){ int i = int(p * k); return (i>=k)? (k-1): (i<0?0:i); }

    void observe(double p, bool is_correct) {

        int i = bindex(p, bins.size());

        bins[i].n.fetch_add(1, std::memory_order_relaxed);

        bins[i].sum_conf.fetch_add(p, std::memory_order_relaxed);

        if (is_correct) bins[i].correct.fetch_add(1, std::memory_order_relaxed);

    }

    double ece() const {

        uint64_t N=0; for (auto& b: bins) N += b.n.load();

        if (!N) return 0.0;

        double total = 0.0;

        for (auto& b: bins) {

            auto n = b.n.load();

            if (!n) continue;

            double conf = b.sum_conf.load() / double(n);

            double acc = double(b.correct.load()) / double(n);

            total += (double(n)/double(N)) * std::abs(acc - conf);

        }

        return total;

    }

};

应定期计算 ece(),而不是在每个请求上都计算;仅当其在持续窗口内超过 SLO 时才发出告警。

资源可见性同样重要。在基于 GPU 的推理服务器上,利用率、流式多处理器(SM)占用率、VRAM 余量以及主机到设备(H2D)或设备到主机(D2H) 传输量(字节/秒)的可观测性不足是最常见的盲点之一。将空闲 VRAM 和分配失败(OOM)导出为仪表(gauge)和计数器(counter);将 H2D 和 D2H 传输量作为 PCIe 压力的代理指标进行跟踪,并将内核启动次数与平均批大小进行对比,以检测利用率不足和过多的内核开销。一个简单的经验法则:如果利用率低于 40% 且 p95 延迟很高,那么服务可能正在遭受排队或批处理不佳的问题,而不是原始计算饱和。如果利用率很高而 VRAM 余量很低,那么量化、剪枝或模型分片可能是更好的干预手段。

这些信号还应该区分队列(cohort)。模型行为很少是静止不变的,许多性能回退都始于某一条狭窄的流量切片。按队列(地区、设备、应用版本、流量来源、输入长度/形状)细分 SLI,并比较时间对齐的窗口,例如上周二上午 9–10 点与本周二上午 9–10 点。这种方法尊重季节性,并且更容易区分真正的性能回退与正常流量模式。队列感知监控有助于团队修复正确的层级——无论是预处理 bug、阈值问题、数据契约不匹配,还是区域性基础设施问题——而无需在全局模型层面过度反应。

将监控转化为实践

监控只有在能够驱动决策时才有意义。每个重要的 SLI 都应对应一份操作手册。如果 p95 上升且主要由排队时间主导,则建议缩短微批次截止时间或增加 worker 并发。如果 ECE 上升而准确率稳定,则建议重新校准或调整阈值。如果 VRAM 余量缩小,则建议降低精度(如 FP16 或 INT8)、缩小批大小或调整并发限制。如果可用性燃尽率超过阈值,则可能需要暂停或回滚发布。这些是通常可以快速调整的运行时杠杆。重新训练是一个较慢的工作流,应保持独立,并拥有自己的保护措施和 SLO。

支持这一循环所需的埋点并不一定要很重。一个实用的起点是使用计数器、仪表和直方图进行小型的进程内注册,每 30 到 60 秒刷新到一个文本端点或结构化日志中。在每个阶段周围添加作用域 span(scoped spans),并为每个请求输出一行 JSON,包含请求 ID、模型版本、设备、批大小、形状信息和各阶段耗时。避免记录原始 PII 或完整载荷。这种程度的埋点能在仅增加极小开销的同时,为你提供生产监控所需的大部分价值。

一条结构化请求日志可能如下所示:


std::cout

    << "{\"ts\":\"" << /* ISO8601 */ << "\","

    << "\"req\":\"" << request_id << "\","

    << "\"model\":\"my_model\",\"ver\":\"1.3.2\","

    << "\"device\":\"cuda:0\",\"batch\":" << batch

    << ",\"shape\":[ " << B << "," << C << "," << H << "," << W << " ],"

    << "\"ttft_ms\":" << ttft_ms << ",\"p95_ms\":" << p95_ms

    << ",\"vram_free_mb\":" << vram_free_mb << "}\n";

这些日志在事故期间会成为你的事实依据,也是仪表盘、告警和事后复盘(postmortems)的原始素材。

监控不是一项附带任务;它是生产推理服务操作系统的一部分。它把期望转变为可度量的契约,揭示性能损失发生的位置,让成本可见,并将漂移从意外变成可控信号。实际目标是选择少量有意义的 SLI,设定切合实际的 SLO,对它们进行轻量但正确的埋点,并确保每个重要信号都指向一个具体的行动。生产生命周期中的其他一切——导出、部署、优化和运维——都依赖于把这个基础打牢。

图 12.1 从高层概括了这一思路。它的三个面板将生产监控的主要维度整合到一个运维视图中。按队列(cohort)划分的 ECE 显示了概率估计是否仍然可信;延迟分位数和 TTFB 显示了服务在真实流量下是否响应良好;相对于 GPU 利用率的每千请求成本则让效率可见。综合来看,这些面板说明了为什么单一指标是不够的。一个服务可能在满足平均延迟目标的同时出现某个队列的漂移,或者保持准确却因效率问题而变得过于昂贵。监控之所以重要,不是因为它产生更多仪表盘,而是因为它将系统行为与用户和服务应该维护的业务结果联系在一起。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_497_0.png

图 12.1:SLO 一览——质量、延迟和成本

一个三面板仪表盘视图,显示(a)按队列划分的校准误差(ECE),(b)包含 p50、p95、p99 和 TTFB 的延迟分布,以及(c)每千请求成本与 GPU 利用率的对比。

如果说图 12.1展示了监控的广度,那么图 12.2则聚焦于其中最重要的一课:平均延迟是不够的。图中展示的两个服务具有几乎相同的平均延迟,但它们的分布却暗示着截然不同的用户体验。服务 A 紧凑且可预测,p95 和 p99 仍然相对接近中心;而服务 B 有一条很长的右尾,将高百分位延迟推得远得多。只报告平均值的仪表盘会让人觉得它们之间几乎没什么差别。百分位数揭示了真实情况:服务 B 上有相当一部分用户经历的延迟远比平均值所暗示的更差。这种模式在生产系统中很典型。延迟有一个由计算设定的实际下界,但没有可比的上界,因此排队延迟、重试、垃圾回收停顿或瞬时争用都可能使右尾急剧拉长。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_498_0.png

图 12.2:为什么尾部很重要

两个服务可能拥有相似的平均延迟,但尾部行为却大不相同;用户感受到的是 p95 和 p99,而不是平均值。

下一幅图将这一思路从单纯的延迟扩展到吞吐量、延迟和成本之间更广泛的运维权衡。图 12.3 使用合成的但合理的数值来说明吞吐量–延迟–成本边界在实践中的典型形态。随着批大小增大,每千请求成本最初会下降,因为固定 GPU 成本被分摊到更多工作上,而 p95 延迟在一个有用的中间范围内保持低于 SLO。超出该范围后,延迟急剧上升,因为更大的批会增加排队延迟并使服务更接近饱和。INT8 曲线绘制在 FP16 曲线的下方和右方,以反映一种常见的生产模式:较低精度通常会减少内存压力并提高吞吐量,使系统

第12章

吞吐量、延迟与成本

这些具体数值仅用于说明,并非来自特定部署的实际测量,但整体趋势是合理的,因为它反映了常见的服务动态:摊销效应初期会提升效率,而当利用率逼近实际极限时,排队效应将占据主导。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_499_0.png

图12.3:吞吐量–延迟–成本前沿

随着批处理大小增大,成本起初会下降,同时p95延迟保持在SLO范围内,但更大的批处理最终会将服务推向饱和。降低精度(如INT8)可以通过减少内存压力和提高吞吐量来移动这一前沿。

在掌握了监控词汇和核心SLI/SLO概念之后,下一步是确定在实时推理服务中哪些信号值得持续关注,以及如何在实践中解读它们。

需要跟踪的关键指标

监控框架就绪后,下一步是确定在实时推理服务中哪些信号值得持续关注。在实践中,三组指标决定您的C++服务系统是否保持可靠、响应迅速且成本高效:在线准确性与校准(模型是否保持正确?其概率是否可信?)、延迟与吞吐量,以及CPU、GPU和内存利用率。这些指标共同不仅显示模型是否行为正确,还显示周围的推理服务栈是否达到运营目标。让我们逐一审视这些指标。

在线准确性与校准

在线准确性是模型在真实流量下测量到的预测质量,而不仅仅是在离线测试集上的表现。在生产环境中,真实标签往往在预测结果交付几分钟、几小时甚至几天后才到达,因此质量监控通常会将即时代理指标与后续对账相结合。换句话说,你通常无法实时或在高频路径上计算完整的混淆矩阵,但你可以跟踪早期预警信号(代理指标),然后稍后关联标签来计算精确指标。

校准问的是一个不同但同样重要的问题:当模型输出0.8的置信度时,它是否真的在80%的情况下正确?仅靠准确性无法回答这个问题——它只告诉你最高概率的预测是否正确,而不能告诉你分数本身是否可信。一个模型可以对每个案例进行正确排序,但输出的概率仍然毫无意义。这一点很重要,因为生产系统几乎总是依据分数采取行动,而不仅仅是标签。例如,欺诈检测系统会将任何超过0.7置信度阈值的内容转交给人工审核。一个过度自信的模型——在只有40%正确率的案例上输出0.8的置信度——会用它发出的误报淹没该队列。而一个不够自信的模型会将真正的欺诈压制在阈值以下,使其漏过。无论哪种情况,阈值都会悄然失效。量化或温度缩放等常见干预措施可能在不影响top-1准确性的情况下显著改变校准,这意味着模型可能通过你的准确性检查,却仍然破坏你的决策逻辑。在任何由分数驱动下游操作的系统中,校准不是可选项。两个特别有用的指标是:

  • 期望校准误差(ECE):按置信度水平对预测分桶,并在每个桶内将平均预测概率与实际观测准确率进行比较。一个校准良好的模型,在80%时间正确的桶中,其平均预测概率应约为0.8。ECE是所有桶的加权平均差距;越低越好。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_500_0.png

图12.4:ECE图

(a) 一个校准良好的模型——每个条形代表一个置信度分桶,其高度与对角线一致,意味着模型声明的置信度与实际准确率相符。ECE较低。(b) 一个过度自信的模型——条形始终低于对角线,意味着模型声称高置信度,但实际正确的频率远低于其暗示的水平。ECE较高。双箭头标记了每个桶中预测置信度与实际准确率之间的差距;ECE是所有这些差距的加权平均值。完美模型的每个条形都应恰好触及对角线,且ECE为零。

  • 布里尔分数:预测概率与实际结果(二分类中为0或1)之间的均方误差,同时惩罚较差的判别能力和较差的校准。与ECE只衡量置信度分数是否诚实不同,布里尔分数还会在模型正确时自信、错误时不确定的情况下给予奖励。越低越好。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_501_0.png

图12.5:布里尔分数图

(a) 来自欺诈检测系统的四个具体案例,说明布里尔分数如何对每个预测进行惩罚。一个自信而正确的预测(分数0.8,结果1)产生0.04的小惩罚。一个自信而错误的预测(分数0.8,结果0)产生0.64的大惩罚——同样的置信度,相反的结果,成本却是十六倍。任一方向的不确定预测都落在中间。所有案例的平均值即为布里尔分数。(b) 两种结果的完整惩罚曲线。红色曲线显示真实结果为欺诈(1)时的惩罚:当分数接近1时惩罚向零下降,而当模型自信地预测错误方向时惩罚急剧上升。蓝色曲线对非欺诈结果呈镜像关系。关键见解是对称性——模型因正确时自信而获得奖励,也因错误时自信而受到同等严厉的惩罚。一个始终输出0.5模型的分数为0.25,与结果无关;低于该值意味着模型在提供真正的信息。

两者都应按队列(如设备类别、地区、应用版本或输入长度)进行监控,这样局部性的退化才不会消失在全局平均值中。

如何实现埋点(C++)

在实践中,在线质量监控通常遵循两阶段模式:先跟踪廉价的代理指标,然后在真实标签可用时稍后进行精确对账。在标签到达之前,有用的在线信号包括类别直方图、置信度十分位数以及主模型–挑战者模型分歧率。这些指标有助于在不减慢推理速度的情况下检测输出分布漂移、日益增长的过度自信或早期漂移迹象。

当标签真正到达时,服务可以在后台更新滚动混淆摘要和校准统计信息。这些时间窗口应尽可能与季节性对齐——例如,将本周二上午与上周二上午进行比较,而不是将不相关的流量时段进行对比。下表总结了在线质量监控的两个阶段及各阶段的用途。

阶段 信号或更新 目的
标签到达前 类别直方图、置信度十分位数、主模型–挑战者分歧 跟踪输出漂移、过度自信和概念漂移的早期在线代理指标
标签到达后 滚动混淆矩阵和校准摘要 使用延迟的真实标签重建精确质量指标,窗口与季节性对齐

表12.1:延迟标签到达前后监控模型质量

以下ECE聚合器(10个分桶)说明了如何将延迟标签折叠回轻量级的滚动校准摘要中。


struct CalibBin {

    uint64_t n = 0, correct = 0;

    double sum_conf = 0.0;

};

struct ECE {

    std::vector<CalibBin> bins;

    explicit ECE(int k=10) : bins(k) {}

};


static int bindex(double p, int k){

    int i = int(p * k);

    return std::min(std::max(i, 0), k-1);

}

void observe(double prob, bool is_correct) {

    int i = bindex(prob, (int)bins.size());

    bins[i].n++;

    bins[i].sum_conf += prob;

    if (is_correct) bins[i].correct++;

}

double value() const {

    uint64_t N = 0; for (auto &b: bins) N += b.n;

    if (!N) return 0.0;

    double ece = 0.0;

    for (auto &b: bins) if (b.n) {

        double acc = double(b.correct) / double(b.n);

        double conf = b.sum_conf / double(b.n);

        ece += (double(b.n)/double(N)) * std::abs(acc - conf);

    }

    return ece;

}

};

在实践中,ECE 可以衡量预测概率与置信度分箱内观测结果是否一致。另一个补充指标是 Brier 分数(如下所示),它以单一流式统计量汇总概率预测的总体平方误差。


struct Brier {

    uint64_t n = 0; double sum = 0.0;

    void observe(double prob, int label01) {

        double err = prob - double(label01);

        sum += err * err; n++;

    }

    double value() const { return n ? sum / double(n) : 0.0; }

};

这两个指标结合在一起,可以更容易地判断在真实流量下预测概率是否仍然可信。

如何解读数据

对这些指标的解读与计算本身同样重要。如果某个群体的 ECE 急剧上升而准确率保持平稳,那么问题很可能是校准漂移,而不是排序质量完全崩溃。在这种情况下,重新校准或调整阈值通常是首选应对措施。同样,在完整标签到达之前,冠军模型与挑战模型之间不断上升的不一致率也可能预示着漂移或特定群体的性能退化。

例如,在“Android 低内存”群体中,如果 ECE 从 1.5% 跃升至 5%,而准确率保持稳定,通常重新校准比重新训练更合适。如果在欧盟流量中冠军与挑战模型的不一致率上升,下一步应先调查特征分布,之后再对比(延迟的)标注性能,然后再决定是否更改发布策略。

综上所述,这些指标即使在标签延迟的情况下也能让在线质量可观测。下一类指标将从预测质量转向服务响应能力。

延迟与吞吐量

延迟决定了用户感知的响应速度。在实践中,应跟踪端到端延迟的 p50、p95 和 p99,然后将总请求时间分解为主要阶段,如解析、预处理、排队、推理、后处理和序列化。对于 LLM 或 TTS 等流式响应端点,首字节/首 token 时间(TTFB/TTFT)与完整完成时间同样重要,因为早期输出会强烈影响用户体验。

表 12.2:用于追踪端到端推理延迟的请求处理阶段

阶段 含义
parse 读取并校验原始请求
preprocess 对输入进行分词、调整尺寸、归一化或其他转换,使其成为模型可用的格式。
queue 因 GPU 或工作进程繁忙,请求等待所花费的时间
infer 实际的模型前向计算
postprocess 对原始模型输出进行解码、过滤和格式化。
serialize 对最终响应(如 JSON 或 protobuf)进行编码,并写入传输通道

为了让这些阶段更具体,下图拆解了一个推理请求,展示延迟在服务路径上是如何累积的。同时还说明了为什么阶段级可见性对于定位真正的性能瓶颈至关重要。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_505_0.png

图 12.6:单个推理请求的剖析

图 12.6 将单个推理请求分解为六个按顺序排列的阶段——解析(4ms)、预处理(1ms)、排队(28ms)、推理(85ms)、后处理(10ms)和序列化(6ms)——端到端总延迟为 145ms。(a)面板显示,TTFB/TTFT 在 129ms 时达到,此时解析、预处理、排队、推理均已完成,而后处理和序列化在响应完全发送前又增加了最后 16ms。(b)面板清楚地表明,推理(59%)和排队(19%)主导了总延迟,合计占请求时间的大部分,而解析和序列化贡献相对较小。(c)面板进一步展示了为什么阶段级监控很重要:当只有排队阶段从 28ms 增加到 95ms 时,即使其他所有阶段保持不变,端到端延迟也由 145ms 上升到 212ms。因此,该图既说明了延迟在服务路径上的分布,也说明了每个阶段的可见性对于诊断真实瓶颈至关重要。

吞吐量是指服务在满足延迟 SLO 的前提下能够处理的持续负载量:请求/响应场景用 QPS 衡量,生成式负载用每秒 token 数衡量。两者都很重要:在可接受的 p95 下保持高 QPS 说明服务具备良好的扩展能力;在低 TTFT 下保持高 token/s 则说明生成路径依然高效且响应迅速。如图 12.2 和 图 12.3 所示,吞吐量不能孤立解读。图 12.2 说明了为什么尾部延迟比单一中心统计量更重要;图 12.3 则展示了追求更高批处理效率如何最终将系统推向排队和饱和。在实践中,负载增加通常首先表现为 p95 和 p99 的退化,而非中位延迟的大幅变化。因此,吞吐量应始终与尾部延迟及阶段级计时(尤其是排队时间与推理时间)一起解读,这样才能将扩展瓶颈归因到服务路径中正确的部分。

如何埋点(C++)

在实践中,延迟和吞吐量埋点应首先从两个互补视角入手:一是阶段级计时,用于定位时间消耗在何处;二是轻量级分布跟踪,用于汇总大量请求的行为特征。

  • a. 端到端与阶段计时器:

    
    struct Span {
    
        const char* name;
    
        std::chrono::high_resolution_clock::time_point t0;
    
        Span(const char* n) : name(n), t0(std::chrono::high_resolution_clock::now()) {}
    
        ~Span() {
    
            using namespace std::chrono;
    
            auto us = duration_cast<microseconds>(high_resolution_clock::now() - t0).count();
    
            std::cout << "{\"span\":\"" << name << "\",\"dur_us\":" << us << "}\n";
    
        }
    
    };
    
    // Usage:
    
    void handle_request(...) {
    
        Span s_all("request_total");
    
        { Span s("parse"); /* ... */ }
    
        { Span s("preprocess"); /* ... */ }
    
        { Span s("queue"); /* micro-batch wait ... */ }
    
        { Span s("infer"); /* model forward ... */ }
    
        { Span s("postprocess"); /* ... */ }
    
        { Span s("serialize"); /* ... */ }
    
    }
    
    
  • b. 这些作用域计时器将排队、推理和后处理的影响分开,而不是把所有内容合并成一个端到端数字,从而让关键路径变得可见。

  • c. 阶段计时就绪后,下一步就是将请求延迟作为分布而不是单一平均值来跟踪。固定分桶的直方图非常适合这一需求,因为它们可以以极低的开销支持百分位估算。

  • d. 固定分桶直方图:

    
    struct LatHisto {
    
        // 0–10ms, 10–20ms, ..., 90–100ms, >=100ms (microseconds)
    
        static constexpr int kN=11, kStepUs=10000;
    
        std::array<std::atomic<uint64_t>, kN> b{};
    
        void observe(uint64_t us) {
    
            size_t i = (us >= kStepUs*(kN-1)) ? (kN-1) : (us/kStepUs);
    
            b[i].fetch_add(1, std::memory_order_relaxed);
    
        }
    
    };
    
    
  • e. 通过累积分桶离线计算百分位数,这样在热路径上无需执行完整排序。

对于 LLM 或 TTS 等流式端点,总请求延迟还不够。还需要测量输出开始的速度以及持续输出的稳定性。记录第一个 token 或字节发出时的时间戳以计算 TTFT;维护已生成 token 的移动总计以估算每秒 token 数;跟踪 token 之间的时间间隔以检测解码器停滞。

如何解读数据

这些指标的价值在于它们如何指导诊断。如果 p95 延迟从 85 ms 上升到 140 ms,同时排队阶段占主导且推理保持稳定,那么问题不在于模型执行,而在于排队:流量突发超出了服务容量。在这种情况下,正确的应对措施是缩短微批截止时间、增加副本或提高工作进程并发度,而不是花时间优化内核。如果 TTFT 从 70 ms 恶化到 150 ms,而每秒 token 数保持不变,那么瓶颈更可能出在分词或提示词组装环节,而非解码本身。将分词移到专用线程池或缓存已分词的提示前缀往往是更好的修复方式。如果 QPS 卡在 300,而 GPU 利用率仍约为 35%,平均批大小只有 1.2,则说明服务处于欠批处理状态。启用微批处理(例如预留 5-8 ms 的延迟预算)可以提高平均批大小、提升利用率,并在 p95 仍满足 SLO 的前提下增加吞吐量。

3) CPU/GPU/内存利用率

监控已部署模型

CPU、GPU 与内存利用率

利用率指标可以揭示服务栈是否在高效利用硬件资源。

CPU 利用率有助于判断瓶颈是预处理、分词、图像解码还是网络 I/O。较高的 iowait 往往指向外部存储或网络压力,而非计算压力。线程池也需要密切关注,因为过度订阅会增加上下文切换开销,并拖累尾延迟。

GPU 利用率反映设备繁忙程度,而 VRAM 余量则指示当前批处理策略是否接近内存上限。这两个指标应结合解读。GPU 利用率低且延迟高,通常意味着存在排队或批处理不佳。利用率高但 VRAM 所剩无几,则提示相反的问题:服务接近 OOM、分配器碎片化,或内存驱动的不稳定。

内存利用率在主机端和设备端都很重要。在主机端,常驻内存集(RSS)——进程当前占用的物理 RAM 部分——是反映真实内存压力的有用粗略信号。主机 RSS 上升会影响进程稳定性、缩小批处理余量,并可能表明存在泄漏或缓冲区增长。在设备端,GPU VRAM 决定服务能否安全扩展批量大小而不触发内存耗尽。因此,分配失败、缺页和碎片化症状都应被视作头等运维信号。

如何埋点(C++)

主机内存和 CPU 指标通常可直接从操作系统获取,而 GPU 指标通常需要厂商接口,例如 NVIDIA 管理库(NVML)——一种 C API,可暴露 GPU 利用率、内存使用量和可用 VRAM 等运行信号。目标不是收集所有可能的信号,而是暴露最能解释硬件压力的一小组仪表和计数器。

  • CPU(Linux): 采样 /proc/stat 和进程时间以估算使用率,或使用 getrusage。如需粗略的主机内存信号,可从 /proc/self/status(VmRSS)读取进程的 RSS。

#include <fstream>

size_t read_rss_kb() {

    std::ifstream f("/proc/self/status");

    std::string k; size_t v;

    while (f >> k >> v) if (k == "VmRSS:") return v; // in kB

    return 0;

}

  • 通过 NVIDIA NVML 获取 GPU 指标

#include <nvml.h>

struct GpuStats { unsigned util=0; unsigned mem_used=0, mem_total=0; };

GpuStats sample_gpu(int idx=0) {

    static bool inited = (nvmlInit()==NVML_SUCCESS);

    (void)inited;

    nvmlDevice_t dev; nvmlDeviceGetHandleByIndex(idx, &dev);

    nvmlUtilization_t u; nvmlDeviceGetUtilizationRates(dev, &u);

    nvmlMemory_t m; nvmlDeviceGetMemoryInfo(dev, &m);

    return { (unsigned)u.gpu, (unsigned)(m.used/1024/1024), 

            (unsigned)(m.total/1024/1024) };

}

通常在后台线程中每隔几秒采样这些值即可。将它们作为 gpu_util、vram_used_mb、vram_free_mb 等仪表暴露,可直接了解服务距离饱和有多近。

复制量也值得跟踪,因为 H2D 和 D2H 传输开销可能成为本已优化良好的系统中隐藏的瓶颈。

  • H2D/D2H 字节数。 封装复制操作并递增计数器:

std::atomic<uint64_t> h2d_bytes{0}, d2h_bytes{0};

// When copying:

h2d_bytes += nbytes; // cudaMemcpyHostToDevice

d2h_bytes += nbytes; // cudaMemcpyDeviceToHost

最后,分配器级别的信号有助于在内存不稳定演变为崩溃之前识别问题。统计成功和失败的分配次数,并记录来自 cudaMalloc 的内存耗尽情况,能提供最后一道有用的可见性保障。

如何解读数字

当这些利用率信号指向可能的瓶颈时最为有用:

  • GPU 利用率约 30%、p95 延迟高、平均批量大小≈1:加速器未得到充分利用,瓶颈更可能出在批处理或并发上,而非原始模型计算。请开启微批处理,例如将队列截止时间设为 8ms 或更短,并/或通过每个 GPU 运行多个模型实例来增加并发,从而在不破坏 TTFT 目标的前提下提升利用率。

  • VRAM 余量 < 5%,负载下 OOM 激增:服务正接近内存上限。可考虑量化(FP16/INT8)、更小的批量大小,或使用池化分配器以减少碎片化。如果使用统一内存,可执行预取(cudaMemPrefetchAsync)并提供内存建议以减少缺页。

  • 高峰期 CPU iowait 激增:瓶颈可能在于预处理、存储或外部 I/O,而非模型执行本身。可缓存预处理产物(如分词后的提示词或解码后的图像),或将前后处理拆分为一个具有独立自动扩缩策略的服务。对于文本系统,将分词器进程绑定到专用 CPU 核心可能会有所帮助;对于图像工作负载,有界工作线程池通常更有效。

指标如何驱动行动(关联 SLO)

指标之所以重要,是因为它们能触发具体且可逆的行动:

  • 某个群体(cohort)的在线准确率或 ECE 越界: 对该群体重新校准,例如使用温度缩放(temperature scaling),并按需调整阈值。如果之后确认存在概念漂移,可安排一次有针对性的重新训练,并通过影子阶段和金丝雀阶段推行变更,同时具备回滚保护。

  • 当 p95 或 p99 延迟上升且队列跨度占主导时,减小微批处理截止时间、增加服务实例或每 GPU 并发度,或添加准入控制(如 HTTP 429 快速失败)以保护尾延迟。

  • 如果每秒 token 数保持稳定而 TTFT 上升,则应并行化或优化提示词组装、缓存分词后的前缀,或更早地流式输出部分结果。瓶颈可能是预处理或分词,而非解码。

  • GPU 利用率低且批量大小很小: 服务很可能处于欠批处理状态。可启用微批处理和请求合并,或考虑 FP16 或 INT8 等更低精度,以提高可行的批处理上限。

  • VRAM 余量不断缩小: 通过量化或剪枝降低内存压力,限制每个请求的上下文长度,池化并复用设备缓冲区,使用固定主机内存以提高主机到设备的传输效率

这些调节手段应纳入服务运行手册。每次 SLO 违规都应映射到一份简短的变更清单,这些变更可以快速应用,然后用同一组指标进行验证。长期来看,保持这三类指标族的健康——质量、延迟与吞吐、利用率——可以预防许多事故,并有助于同时控制面向用户的性能和运营成本。

在关键指标及其解读就位后,下一步是在服务内部暴露这些指标。在实践中,这意味着一个轻量级埋点层,既能低成本采集信号,又能以运维人员可以检查和告警的形式呈现。下一节将展示如何在 C++ 中使用指标、结构化日志和链路追踪来实现这一点。

C++ 埋点:Prometheus 风格指标、结构化日志与链路追踪/跨度

一个生产模型直到你能逐分钟、逐请求地观测它,才算真正可运维。在 C++ 中,这通常意味着三个组件协同工作:

  • 一个监控栈可抓取的指标端点,
  • 采用机器可解析 JSON 的结构化日志,便于用户搜索和关联,
  • 轻量级链路追踪或跨度,将端到端延迟分解为具名阶段。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_511_0.png

图 12.7:生产可观测性的三个组件及其关系

图 12.7 展示了三个互补组件——指标、结构化日志和链路追踪——如何协同工作以提供这种可见性,以及它们如何串联成一条从告警到根因的单一诊断路径。本小节将解释每个组件为何重要、如何在热路径上安全使用,以及如何用可放入服务的小型自包含 C++ 代码片段实现。目标很务实:你应该能够快速暴露 /metrics、输出结构化日志并用跨度标注关键路径,然后逐步完善实现,例如添加 OpenTelemetry,而无需抛弃最初的设计。

监控已部署的模型

格式、设计原则与一个最小化注册表

Prometheus 文本暴露格式是 C++ 推理服务的一个实用默认选择,因为它对人类可读、易于生成,并且被 Grafana 和 Alertmanager 等工具广泛理解。你不需要引入重量级依赖就能开始。在实践中,目标是以一种在请求路径上更新成本低、且易于从服务外部抓取的形式,暴露一小部分计数器、仪表和直方图。几条简单的设计规则就能让这一点成为可能。

下表总结了本章中引用的主要工具、接口和运维系统,以及它们在监控和插桩一个生产级 C++ 推理服务时的典型作用。

类别 工具/接口 本章中的典型用途
监控 Prometheus 从一个 /metrics 端点抓取计数器、仪表和直方图
监控 Grafana, Datadog 展示延迟、质量、吞吐量和利用率的仪表板
告警 Alertmanager 当阈值或 SLO 被违反时发出告警
追踪 OpenTelemetry, Jaeger, Tempo, OTLP 导出并检查整个服务路径上的追踪与跨度信息
日志记录 spdlog 为 JSON 风格日志提供结构化日志后端
GPU 监控 NVML 读取 GPU 利用率、内存使用量和空闲显存(VRAM)
Linux 主机指标 /proc/stat, getrusage, /proc/self/status 收集 CPU 使用率和主机内存信号,如 RSS
CUDA 内存与传输 cudaMemcpy, cudaMalloc, cudaMemPrefetchAsync 跟踪拷贝开销、分配失败和内存行为
C++ 指标导出 prometheus-cpp, Prometheus text exposition 以易于抓取的格式暴露指标
服务端点 POSIX socket HTTP handler 从进程提供轻量级 /metrics 端点

表 12.3:部署推理系统中用于监控、告警、追踪、日志记录和资源可见性的常用工具与接口

上面列出的工具只有在插桩本身经过精心设计时才有用。在实践中,几条简单的原则能让指标采集保持轻量、稳定,并且适合生产环境使用。

设计原则

少数几条设计规则就能让指标在一个生产推理服务中安全且实用。目标是在请求路径上保持低成本更新、保持标签有限,并保持暴露格式足够简单,以便无需重量级依赖即可集成。

  • 线程安全:在快速路径更新中使用原子操作,并避免在请求路径上加锁。这很重要,因为指标经常被多个工作线程并发更新。热路径上的锁竞争会增加延迟、降低吞吐量,并扭曲你试图测量的信号本身。
  • 基数控制:保持标签集合小而有限,例如 model、version、device,绝不要使用按请求区分的标识符。这很重要,因为高基数的标签会产生过多唯一的时间序列,从而增加内存使用、拖慢查询,并可能压垮监控后端。
  • 固定桶直方图:优先使用有界直方图,这类直方图更新成本低,足以用于 p95 和 p99 估算。这很重要,因为百分位数在运维上很关键,但采集原始样本或在每个请求上计算精确分位数对于生产流量来说成本过高。
  • 单一导出器线程:定期或在收到 /metrics 请求时构建暴露文本,而不是在每次更新时格式化指标。这很重要,因为渲染输出的成本远高于递增计数器或直方图。将导出与更新分离可以保持请求处理的高效,并使插桩层更简单、更可预测。

因此,一个最小化的指标注册表可以由几个简单的构建块组成:指标原语、标签、注册表和一个小型导出器。在查看计数器、仪表、直方图和注册表本身的代码之前,先了解一下这些组件在更大的监控路径中的位置会很有帮助。

https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_514_0.png

图 12.8:从 C++ 推理服务到基于 Prometheus 的监控技术栈的指标流

该图展示了在 C++ 推理服务内部添加的插桩如何融入更大的运维系统。服务通过 /metrics 端点导出计数器、仪表和直方图。然后,这些指标由指标服务器抓取、存储和评估,被仪表板和外部工具查询,并在阈值或规则被突破时路由到告警路径。在接下来的讨论中,焦点将缩小到服务端实现:小型指标原语、进程内注册表,以及用于暴露这些指标的最小端点。

在了解了整体流程之后,我们现在可以转向最小的构建块:指标原语本身。

指标原语(metrics.hpp)

第一步是定义服务将导出的基本指标类型。在实践中,计数器跟踪总量,仪表跟踪当前值,直方图捕获分布信息,例如延迟。


// metrics.hpp

#pragma once

#include <array>

#include <atomic>

#include <cstdint>

struct Counter {

    std::atomic<uint64_t> value{0};

    void inc(uint64_t n = 1) {

        value.fetch_add(n, std::memory_order_relaxed);

    }

    uint64_t get() const {

        return value.load(std::memory_order_relaxed);

    }

};

struct Gauge {

    std::atomic<long long> value{0};

    void set(long long v) {

        value.store(v, std::memory_order_relaxed);

    }

    long long get() const {

        return value.load(std::memory_order_relaxed);

    }

};

struct Histogram {

    // Fixed buckets in microseconds: 1,2,4,8,16,32,64,128,256, >=256 ms

    static constexpr int K = 10;

    static constexpr std::array<uint64_t, K - 1> bounds_us{

        1000, 2000, 4000, 8000, 16000, 32000, 64000, 128000, 256000};

    std::array<std::atomic<uint64_t>, K> buckets{};

    std::atomic<uint64_t> sum_us{0};

    std::atomic<uint64_t> count{0};

    void observe(uint64_t us) {

        size_t i = 0;

        while (i < K - 1 && us > bounds_us[i]) ++i;

        buckets[i].fetch_add(1, std::memory_order_relaxed);

        sum_us.fetch_add(us, std::memory_order_relaxed);

        count.fetch_add(1, std::memory_order_relaxed);

    }

};

这些原语有意设计得很小。它们的更新成本足够低,可以在请求路径上使用,表达力也足以覆盖本章前面讨论的大多数运维信号。

标签

如图 12.8所示,同一个指标通常需要按模型、版本或设备等维度进行拆分。标签通过为每个时间序列附加键值对来提供这种额外的上下文。


#include <sstream>

#include <string>

#include <utility>

#include <vector>

struct Labels {

    std::vector<std::pair<std::string, std::string>> kv;

    std::string to_text() const {

        if (kv.empty()) return "";

        std::ostringstream os;

        os << "{";

        for (size_t i = 0; i < kv.size(); ++i) {

            if (i) os << ",";

            os << kv[i].first << "=\"" << kv[i].second << "\"";

        }

        os << "}";

        return os.str();

    }

};

这种表示方式有意保持简单,但它足以按有限的操作维度对指标进行分组,并通过 /metrics 渲染导出。

注册表

有了标签之后,下一步是将多个带标签的序列组织到同一个注册表下。注册表将指标名称和标签集合映射到对应的计数器、仪表或直方图,以便服务能够一致地更新和导出它们。


#include <mutex>

#include <unordered_map>

class Registry {

public:

    Counter& counter(const std::string& name, const Labels& lbl = {}) {

        return get<Counter>(counters_, name, lbl);

    }

};

监控已部署模型


    Gauge& gauge(const std::string& name, const Labels& lbl = {}) {

        return get<Gauge>(gauges_, name, lbl);

    }

    Histogram& histo(const std::string& name, const Labels& lbl = {}) {

        return get<Histogram>(histos_, name, lbl);

    }

注册表不仅存储指标对象;它还需要以可供抓取的文本格式渲染它们。


    std::string render_metrics_text() const {

        std::ostringstream os;

        // Counters

        for (auto& it : counters_) {

            os << "# TYPE " << it.first << " counter\n";

            for (auto& row : it.second) {

                os << it.first << row.first.to_text() << " "

                   << row.second.get() << "\n";

            }

        }

        // Gauges

        for (auto& it : gauges_) {

            os << "# TYPE " << it.first << " gauge\n";

            for (auto& row : it.second) {

                os << it.first << row.first.to_text() << " "

                   << row.second.get() << "\n";

            }

        }

        // Histograms

        for (auto& it : histos_) {

            os << "# TYPE " << it.first << " histogram\n";

            for (auto& row : it.second) {

                const auto& lbl = row.first;

                const auto& h = row.second;

                uint64_t cum = 0;

                for (size_t i = 0; i < Histogram::K - 1; ++i) {

                    cum += h.buckets[i].load(std::memory_order_relaxed);

                    Labels l = lbl;

                    l.kv.emplace_back(

                        "le", std::to_string(Histogram::bounds_us[i] / 1000.0));

                    os << it.first << "_bucket" << l.to_text() << " "

                       << cum << "\n";

                }

                cum += h.buckets[Histogram::K - 1].load(std::memory_order_relaxed);

                Labels linf = lbl;

                linf.kv.emplace_back("le", "+Inf");

                os << it.first << "_bucket" << linf.to_text() << " "

                   << cum << "\n";

                os << it.first << "_sum" << lbl.to_text() << " "

                   << (h.sum_us.load() / 1000.0) << "\n";

                os << it.first << "_count" << lbl.to_text() << " "

                   << h.count.load() << "\n";

            }

        }

        return os.str();

    }

一个小助手就足以在需要时查找现有序列或创建新序列。


private:

    template <typename T>

    using Table =

        std::unordered_map<std::string, std::vector<std::pair<Labels, T>>>;

    template <typename T>

    static T& get(Table<T>& table,

                  const std::string& name,

                  const Labels& lbl) {

        std::lock_guard<std::mutex> lk(mu_);

        auto& vec = table[name];

        for (auto& row : vec) {

            if (row.first.kv == lbl.kv) return row.second;

        }

        vec.push_back({lbl, T{}});

        return vec.back().second;

    }

    inline static std::mutex mu_;

    Table<Counter> counters_;

    Table<Gauge> gauges_;

    Table<Histogram> histos_;

};

此时,服务可以在进程内维护带标签的计数器、仪表和直方图。下一步是通过 HTTP 暴露它们,以便图 12.8中的指标服务器能够抓取它们。

导出 /metrics (小型 HTTP 处理器)

图中,离开推理服务的箭头对应于对/metrics的抓取。一个小型 HTTP 处理器足以演示这一模式。


// metrics_server.cpp

#include "metrics.hpp"

#include <netinet/in.h>

#include <thread>

#include <unistd.h>

void serve_metrics(Registry& reg, int port = 9100) {

    int fd = socket(AF_INET, SOCK_STREAM, 0);

    int yes = 1;

    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

    sockaddr_in addr{};

    addr.sin_family = AF_INET;

    addr.sin_addr.s_addr = INADDR_ANY;

    addr.sin_port = htons(port);

    bind(fd, (sockaddr*)&addr, sizeof(addr));

    listen(fd, 16);

    std::thread([fd, &reg]() {

        for (;;) {

            int c = accept(fd, nullptr, nullptr);

            if (c < 0) continue;

            std::string body = reg.render_metrics_text();

            std::ostringstream resp;

            resp << "HTTP/1.1 200 OK\r\n"

                 << "Content-Type: text/plain; version=0.0.4\r\n"

                 << "Content-Length: " << body.size() << "\r\n\r\n"

                 << body;

            auto s = resp.str();

            (void)write(c, s.data(), s.size());

            close(c);

        }

    }).detach();

}

然后,服务可以注册具体指标,并在请求处理期间更新它们。


Registry R;

auto& req_total = R.counter(

    "inference_requests_total",

    {{{"model", "m1"}, {"version", "1.3"}}});

auto& err_total = R.counter(

    "inference_errors_total",

    {{{"model", "m1"}, {"version", "1.3"}}});

auto& lat_histo = R.histo(

    "inference_latency_us",

    {{{"model", "m1"}, {"version", "1.3"}}});

auto& gpu_util = R.gauge(

    "gpu_util_percent",

    {{{"device", "cuda:0"}}});

auto& vram_free = R.gauge(

    "gpu_vram_free_mb",

    {{{"device", "cuda:0"}}});

serve_metrics(R, 9100);

然后,在你的请求处理器中:


// In the request handler

uint64_t t0 = /* now_us */;

req_total.inc();

// ... work ...

uint64_t dur = /* now_us */ - t0;

lat_histo.observe(dur);

这是服务参与图中所示指标路径所需的最低要求:本地埋点、抓取端点,以及一个存储和查询结果时间序列的下游指标服务器。

结构化日志

指标总结了一段时间内的行为,但日志保留了请求级别的证据。结构化 JSON 日志比自由格式的printf输出更易于搜索和关联,尤其是在始终包含请求 ID 和追踪 ID 的情况下。

一个小助手就足以开始了:


// log.hpp

#pragma once

#include <chrono>

#include <iomanip>

#include <iostream>

#include <sstream>

#include <string>

inline std::string iso8601_now() {

    using namespace std::chrono;

    auto t = system_clock::now();

    auto s = time_point_cast<std::chrono::seconds>(t);

    auto sub = duration_cast<microseconds>(t - s).count();

    std::time_t tt = system_clock::to_time_t(t);

    std::tm tm = *std::gmtime(&tt);

    std::ostringstream os;

    os << std::put_time(&tm, "%FT%T") << "."

       << std::setw(6) << std::setfill('0') << sub << "Z";

    return os.str();

}

示例用法:


log_json("INFO", "recv", "r-7f23", "t-9ab1",

         "resnet50", "1.12.3", "cuda:0", 8,

         "{\"shape\":[8,3,224,224]}");

log_json("INFO", "done", "r-7f23", "t-9ab1",

         "resnet50", "1.12.3", "cuda:0", 8,

         "{\"ttfb_ms\":38.2,\"lat_ms\":91.5,\"p99_ms\":128.0,\"mem_mb\":1420}");

这些日志不会经过图中所示的指标路径,但它们通过保留仅靠仪表盘无法提供的请求级上下文来补充指标。

追踪与跨度

指标显示延迟发生了变化;跨度则显示变化发生的位置。这正是本章前面讨论过的请求路径分解,现在在服务内部可见。

一个基于 RAII 的小型跨度辅助工具就足够了:

posted @ 2026-09-25 20:41  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报