雲端 Mac 上的 iOS 延遲百分位驗收:最近排名與統計口徑

雲端 Mac 上的 iOS 延遲百分位驗收:最近排名與統計口徑

雲端 Mac 上的 iOS 延遲百分位驗收:最近排名與統計口徑

同一批請求,本機報表的 P95 是 190 毫秒,CI 流程卻算出另一個數值,先別歸因於雲端 Mac 的效能。兩邊可能分別採用了最近排名法與線性插值。在 VMFly 實體節點上執行統計驗收時,應先統一演算法、樣本範圍及空集合的處理語意,再討論介面是否變慢。本文提供不依賴網路服務的 Swift 腳本,適合納入 iOS 專案統計模組的迴歸測試流程。

固定百分位數的計算契約

本文只接受 1 至 100 的整數百分位。將非負且有限的耗時由小到大排序,百分位為 q、樣本數為 n 時,最近排名為 ceil(q × n / 100),排名從 1 開始。回傳該位置的實際樣本值,不在相鄰數值之間插值。

例如,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,餘數部分的中間結果有明確上限,最終排名不會超過樣本數。

在節點上執行確定性驗收

從程式碼儲存庫的根目錄執行以下命令。腳本通過時不會輸出任何內容,失敗時程序會傳回非零結束狀態;CI 流程可直接依結束狀態判定結果,不必解析本地化日誌。

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

範例使用固定的暫存路徑,適合單一工作的示範。平行執行的 CI 工作應將執行檔放在各自獨立的工作目錄,避免互相覆寫。不要加入 -Ounchecked,此模式會移除這裡用來把關的前置條件檢查。

獨立腳本驗證的是範例實作。整合進專案後,應將同一組測試向量移入測試目標,直接呼叫正式環境使用的統計函式,而不是長期維護一份僅供測試的演算法副本。使用固定隨機種子補充排列測試時,也要保留原始輸入,以便重現失敗情境。

補齊時間視窗與樣本邊界檢查

上線前至少檢查四類變化:

  • 打亂輸入順序後,結果應保持不變;統計函式也不應改寫輸入陣列本身。
  • 加入重複耗時後,仍應依樣本個數排名,不能先去除重複值。
  • 在樣本數為 99、100、101 時檢查排名,避免整百邊界偏移。
  • 提高百分位時,結果不應下降,且結果必須始終屬於原始樣本集合。

所有百分位數都應來自同一份不可變快照。若資料收集器一邊向陣列附加資料,一邊分別計算 P50 和 P99,兩個結果可能對應不同的時間視窗。應先在收集器的同步邊界內複製樣本,再離開該邊界進行排序,避免排序占用資料收集路徑。

逾時請求不能以零耗時加入陣列。若只統計成功請求,應明確標示為「成功請求延遲」,並同時輸出失敗數與逾時數;否則成功率下降時,延遲反而可能看起來有所改善。

讓迴歸報告可供複核

每次報告都應保存演算法識別資訊、單位、視窗起訖時間、成功樣本數、失敗數、逾時數及三個百分位數。固定測試資料可保存完整向量;真實請求則只保留排除問題所需的資料,不附帶請求憑證或本文內容。

不要將多個視窗的 P95 取平均,當成整體視窗的 P95。需要合併時,應對合併後的樣本重新計算,或採用明確支援合併且註明誤差的統計資料結構。本文的排序實作適合精確驗收,不適合無限增長的資料串流。

最後,將「統計實作正確」與「效能達標」拆成兩道驗收關卡:前者使用固定向量進行精確比較,後者則在樣本充足、時間視窗一致的條件下,比對業務門檻。如此一來,每次失敗才能明確指向演算法迴歸、資料收集異常或真實的延遲變化。

常見問題

百分位應使用最近排名法還是線性插值?

應遵循既有統計契約。本文的最近排名法會回傳實際樣本值;若原先採用插值,切換時必須更新算法識別並重新建立比較基準,不能直接混用數據。

沒有成功請求時,P95 可以記為零嗎?

不可以。應記錄沒有結果,並將成功樣本數設為零;失敗與逾時另外計數。零耗時本身是有效數值,不能用來表示缺少資料。

為 CI/CD 流程添一台實體 Mac

可選 Apple Silicon M4 與 M4 Pro,整台主機由單一客戶獨享,並非虛擬機。依照建置工作負載挑選機型,再選擇適合團隊的計費週期。

租用實體節點

將下一次建置交給專屬節點

先確認硬體規格與計費週期,再為團隊租用獨享的實體 Mac。