云端 Mac 上的 iOS 延迟分位数验收:最近秩与统计口径

云端 Mac 上的 iOS 延迟分位数验收:最近秩与统计口径

同一批请求,本地报表的 P95 是 190 毫秒,流水线却算出另一个数,先别归因于云端 Mac 的性能。两端可能分别用了最近秩和线性插值。在 VMFly 物理节点上运行统计验收时,应先固定算法、样本范围和空集合语义,再讨论接口是否变慢。本文给出不依赖网络服务的 Swift 脚本,适合放进 iOS 工程的统计模块回归流程。

固定分位数的计算契约

本文只接受整数百分位 1 至 100。将非负、有限的耗时从小到大排序,百分位为 q、样本数为 n,最近秩是 ceil(q × n / 100),从一开始编号。返回该位置的实际样本,不做相邻值插值。

例如,20 个递增样本从 10 到 200 毫秒,P50、P95、P99 分别是 100、190、200 毫秒。只有一个样本时,三个结果都等于它。这是算法定义,不代表尾部延迟已经得到充分观测。

空集合没有分位数。把它写成零,会让没有成功请求的窗口看起来比正常窗口更快。

输入为负数、无穷值或 NaN 时,本次计算失败。不要悄悄过滤异常值,否则采集错误会被包装成正常报表。生产系统若采用隔离策略,必须另外累计被拒绝的样本数。

实现不依赖浮点乘法的秩计算

保存以下内容为 percentile-check.swift。脚本使用完整 Xcode 工具链提供的 Swift,不要求修改应用工程。耗时统一使用毫秒;函数无法从裸数值判断单位,调用侧必须完成转换。

import Foundation

enum MetricError: Error, Equatable {
    case invalidPercentile
    case invalidDuration
}

func percentile(_ values: [Double], q: Int) throws -> Double? {
    guard (1...100).contains(q) else {
        throw MetricError.invalidPercentile
    }
    guard values.allSatisfy({ $0.isFinite && $0 >= 0 }) else {
        throw MetricError.invalidDuration
    }
    guard !values.isEmpty else {
        return nil
    }

    let sorted = values.sorted()
    let n = sorted.count
    let rank = (n / 100) * q + ((n % 100) * q + 99) / 100
    return sorted[rank - 1]
}

func expectError(_ expected: MetricError,
                 _ operation: () throws -> Void) {
    do {
        try operation()
        preconditionFailure("Expected metric error")
    } catch let error as MetricError {
        precondition(error == expected)
    } catch {
        preconditionFailure("Unexpected error type")
    }
}

let samples = (1...20).map { Double($0 * 10) }
let p50 = try percentile(samples, q: 50)
let p95 = try percentile(samples, q: 95)
let p99 = try percentile(samples, q: 99)
precondition(p50 == 100 && p95 == 190 && p99 == 200)

let empty = try percentile([], q: 95)
let single = try percentile([7], q: 99)
let repeated = try percentile([4, 4, 4], q: 95)
let zero = try percentile([0], q: 50)
let reversed = try percentile(Array(samples.reversed()), q: 95)
precondition(empty == nil && single == 7)
precondition(repeated == 4 && zero == 0 && reversed == p95)

for q in [0, 101] {
    expectError(.invalidPercentile) {
        _ = try percentile(samples, q: q)
    }
}
for value in [-1.0, Double.nan, Double.infinity] {
    expectError(.invalidDuration) {
        _ = try percentile([value], q: 95)
    }
}

秩计算把 n 拆成整百部分与余数,避免 Double 乘法在整数边界附近产生舍入偏差;也没有直接计算可能溢出的 n × q。由于 q 不超过 100,余数部分的中间结果有界,最终秩不超过样本数。

在节点上运行确定性验收

从代码仓库根目录执行下面的命令。脚本通过时没有输出,失败时进程返回非零状态;流水线直接使用退出状态判定,不必解析本地化日志。

set -eu
xcrun swiftc percentile-check.swift -o /tmp/percentile-check
/tmp/percentile-check

示例使用固定临时路径,适合单任务演示。并发流水线应把可执行文件放进各任务独立的工作目录,避免互相覆盖。不要添加 -Ounchecked,该模式会移除这里用作门禁的前置条件检查。

独立脚本验证的是示例实现。接入项目后,应将同一组向量迁入测试目标,直接调用生产统计函数,而不是长期维护一份只供测试的算法副本。固定随机种子补充排列测试时,还要保留原始输入,方便重放失败。

补齐窗口与样本边界

上线前至少检查四类变化:

  • 打乱输入顺序,结果不变;输入数组本身不应被统计函数改写。
  • 加入重复耗时后仍按样本个数排名,不能先去重。
  • 在 99、100、101 个样本处检查秩,防止整百边界偏移。
  • 扩大百分位时结果不下降,并且结果始终属于原样本集合。

所有百分位应来自同一份不可变快照。若采集器一边追加数组,一边分别计算 P50 和 P99,两个结果可能对应不同窗口。先在采集器的同步边界内复制样本,再离开该边界排序,避免排序占用采集路径。

超时请求不能作为零耗时加入数组。若仅统计成功请求,应明确标记“成功请求延迟”,同时输出失败数和超时数;否则成功率下降时,延迟反而可能看起来改善。

让回归报告能够复核

每次报告保存算法标识、单位、窗口起止、成功样本数、失败数、超时数及三个百分位。固定夹具可以保存完整向量;真实请求只保存完成排障所需的数据,不附带请求凭据或正文。

不要平均多个窗口的 P95 来冒充总窗口 P95。需要合并时,应重新计算合并样本,或使用明确支持合并且标注误差的统计结构。本文的排序实现适合精确验收,不适合无限增长的数据流。

最后把“统计实现正确”与“性能达标”分成两道门禁:前者用固定向量精确比较,后者在足够样本和一致窗口下比较业务阈值。这样一次失败才能明确指向算法回归、采集异常或真实延迟变化。

常见问题

P95 应该使用最近秩还是线性插值?

两种方法都有用途,但结果不能直接混比。本文选择最近秩,返回排序后实际存在的样本值;若现有报表使用插值,应保留原口径,或更新算法标识并重新建立基线。

没有成功请求时,可以把 P95 记成零吗?

不可以。空集合应返回无结果,并记录成功样本数为零;超时和失败另行计数。零耗时是有效测量值,不能兼作没有数据的标记。

给流水线加一台物理 Mac

Apple Silicon M4 与 M4 Pro 可选,整机只交给一个客户,非虚拟机。按构建负载选择机型,再选择适合团队的计费周期。

租用物理节点

把下一次构建交给专属节点

先确认硬件规格与计费周期,再为团队接入独享物理 Mac。