雲端 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,整台主機由單一客戶獨享,並非虛擬機。依照建置工作負載挑選機型,再選擇適合團隊的計費週期。
租用實體節點