クラウドMacで検証するiOS遅延パーセンタイルと最近順位法

クラウドMacで検証するiOS遅延パーセンタイルと最近順位法

同じリクエスト群なのに、ローカルのレポートでは P95 が 190 ミリ秒、パイプラインでは別の値になる。そんなとき、すぐにクラウド Mac の性能が原因だと判断してはいけません。一方が最近順位法、もう一方が線形補間を使っている可能性があります。VMFly の物理ノードで統計処理の受け入れ検証を行う際は、まずアルゴリズム、対象サンプルの範囲、空集合の扱いを固定し、そのうえで API が遅くなったかどうかを検討します。本記事では、ネットワークサービスに依存せず、iOS プロジェクトの統計モジュールの回帰テストに組み込める Swift スクリプトを紹介します。

パーセンタイル計算の契約を固定する

ここでは、1 から 100 までの整数パーセンタイルだけを受け付けます。非負かつ有限の所要時間を昇順に並べ、パーセンタイルを q、サンプル数を n とすると、最近順位法の順位は ceil(q × n / 100) です。順位は 1 から数え、その位置にある実際のサンプルを返します。隣接する値の補間は行いません。

たとえば、10 から 200 ミリ秒まで増加する 20 個のサンプルでは、P50、P95、P99 はそれぞれ 100、190、200 ミリ秒になります。サンプルが 1 個だけなら、3 つの結果はいずれもその値です。これはアルゴリズムの定義によるもので、テールレイテンシを十分に観測できたことを意味するわけではありません。

空集合にパーセンタイルはありません。ゼロとして記録すると、成功したリクエストがない集計期間が、正常な集計期間より速く見えてしまいます。

入力に負数、無限大、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 を 100 ごとの部分と余りに分け、Double の乗算によって整数の境界付近で丸め誤差が生じるのを避けています。また、オーバーフローする可能性がある n × q も直接計算しません。q は 100 以下なので、余りを扱う部分の中間値には上限があり、最終的な順位もサンプル数を超えません。

ノード上で決定的な受け入れ検証を実行する

コードリポジトリのルートディレクトリで、次のコマンドを実行します。スクリプトは成功時には何も出力せず、失敗時にはプロセスがゼロ以外の終了ステータスを返します。パイプラインでは終了ステータスで直接判定できるため、ローカライズされたログを解析する必要はありません。

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

この例では、一時ファイルのパスを固定しているため、単一ジョブでの実行例に適しています。並列に動くパイプラインでは、互いに上書きしないよう、各ジョブ専用の作業ディレクトリに実行ファイルを配置してください。-Ounchecked は追加しないでください。このモードでは、ここで合否判定に使っている事前条件チェックが取り除かれます。

単体のスクリプトで検証できるのは、あくまでこの実装例です。プロジェクトへ組み込んだら、同じテストベクトルをテストターゲットへ移し、本番の統計関数を直接呼び出してください。テスト専用のアルゴリズムのコピーを長期にわたって保守する形にはしないでください。乱数シードを固定して並べ替えテストを追加する場合も、失敗を再現できるよう、元の入力を保存しておきます。

集計期間とサンプル数の境界を確認する

本番投入前に、少なくとも次の 4 種類の変化を確認してください。

  • 入力の順序を変えても結果が変わらないこと。また、統計関数が入力配列そのものを書き換えないこと。
  • 同じ所要時間のサンプルを追加しても、サンプル数に基づいて順位を決めること。先に重複を除去してはいけません。
  • サンプル数が 99、100、101 のときの順位を確認し、100 の倍数をまたぐ境界でずれが生じないこと。
  • パーセンタイルを大きくしても結果が小さくならず、結果が常に元のサンプル集合に含まれること。

すべてのパーセンタイルは、同じ不変スナップショットから計算する必要があります。収集処理が配列に追加しながら P50 と P99 を別々に計算すると、2 つの結果が異なる集計期間に対応する可能性があります。まず収集処理の同期区間内でサンプルをコピーし、その区間を抜けてからソートしてください。これにより、ソート処理で収集経路を塞がずに済みます。

タイムアウトしたリクエストを所要時間ゼロとして配列に追加してはいけません。成功したリクエストだけを集計するなら、「成功リクエストのレイテンシ」と明示し、失敗数とタイムアウト数も併せて出力します。そうしないと、成功率が低下したときに、かえってレイテンシが改善したように見えるおそれがあります。

回帰レポートを検証可能にする

各レポートには、アルゴリズムの識別子、単位、集計期間の開始と終了、成功サンプル数、失敗数、タイムアウト数、3 つのパーセンタイルを保存します。固定フィクスチャならベクトル全体を保存できます。実際のリクエストについては、トラブルシューティングに必要なデータだけを保存し、リクエストの認証情報や本文は含めないでください。

複数の集計期間の P95 を平均して、全期間の P95 として扱ってはいけません。統合が必要な場合は、サンプルを結合して再計算するか、マージを明示的にサポートし、誤差が明記された統計データ構造を使用します。本記事のソートによる実装は、厳密な受け入れ検証には適していますが、無制限に増え続けるデータストリームには向きません。

最後に、「統計実装が正しいこと」と「性能基準を満たしていること」は、別々のゲートに分けてください。前者は固定ベクトルで厳密に比較し、後者は十分なサンプル数と同一の集計期間を確保したうえで、業務上のしきい値と比較します。こうすることで、失敗の原因がアルゴリズムの回帰なのか、収集の異常なのか、実際のレイテンシの変化なのかを明確に切り分けられます。

よくある質問

最近順位法と線形補間のどちらを選ぶべきですか。

既存の計測仕様に合わせます。本稿の最近順位法は実際の標本値を返します。補間方式から変更する場合は方式の識別子を更新し、比較用の基準値も作り直してください。

成功したリクエストがない場合、P95をゼロにできますか。

できません。結果なしとして成功標本数をゼロで記録し、失敗やタイムアウトは別に数えます。ゼロは有効な計測値なので、欠測の印には使いません。

ビルド用に物理Macを1台追加

Apple Silicon M4とM4 Proから選べる、1台を1社で専有する物理Macです。ビルド負荷に合わせて機種を選び、チームに合った課金プランをお選びください。

物理Macをレンタル

次のビルドは専有ノードで

まずハードウェアの仕様と課金プランを確認し、チーム用の専有物理Macを導入しましょう。