Проверка перцентилей задержки iOS на облачном Mac

Проверка перцентилей задержки iOS на облачном Mac

Для одной и той же группы запросов локальный отчёт показывает P95 в 190 миллисекунд, а пайплайн получает другое значение. Не спешите объяснять это производительностью облачного Mac: возможно, в одном случае используется метод ближайшего ранга, а в другом — линейная интерполяция. При приёмочной проверке статистических расчётов на физических узлах VMFly сначала нужно зафиксировать алгоритм, состав выборки и поведение для пустого набора данных, а уже затем выяснять, замедлился ли API. В этой статье приведён Swift-скрипт, который не зависит от сетевых сервисов и подходит для регрессионных проверок статистического модуля iOS-проекта.

Фиксируем контракт расчёта перцентилей

В этой реализации допустимы только целочисленные перцентили от 1 до 100. Отсортируем конечные неотрицательные значения длительности по возрастанию. Если q — перцентиль, а n — число образцов, то ближайший ранг равен ceil(q × n / 100). Позиции нумеруются с единицы. Функция возвращает фактическое значение на этой позиции, без интерполяции между соседними значениями.

Например, для 20 возрастающих значений от 10 до 200 миллисекунд P50, P95 и P99 равны соответственно 100, 190 и 200 миллисекундам. Если образец всего один, все три результата равны ему. Это следствие определения алгоритма, а не подтверждение того, что хвостовые задержки уже достаточно хорошо изучены.

У пустого набора нет перцентилей. Если записать вместо них ноль, окно без успешных запросов будет выглядеть быстрее обычного.

Если во входных данных есть отрицательное число, бесконечность или NaN, расчёт завершается ошибкой. Не отбрасывайте недопустимые значения молча: иначе ошибка сбора данных превратится в отчёт, который выглядит корректным. Если в рабочей системе такие образцы изолируются, их количество необходимо учитывать отдельно.

Вычисляем ранг без умножения чисел с плавающей точкой

Сохраните следующий код в файл percentile-check.swift. Скрипт использует Swift из полного набора инструментов Xcode и не требует изменений в проекте приложения. Все значения длительности задаются в миллисекундах: функция не может определить единицу измерения по самому числу, поэтому преобразование должен выполнить вызывающий код.

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 при отсутствии успешных запросов?

Нет. Сохраняйте отсутствие результата и нулевое число успешных измерений. Ошибки и тайм-ауты считайте отдельно: ноль уже является допустимой длительностью.

Добавьте физический Mac в CI/CD-пайплайн

Выберите Apple Silicon M4 или M4 Pro: вся машина в вашем распоряжении, без виртуализации и совместного использования. Подберите модель под нагрузку сборки и удобный для команды период оплаты.

Арендовать физический узел

Передайте следующую сборку выделенному узлу

Сначала выберите конфигурацию и период оплаты, затем подключите к работе команды выделенный физический Mac.