Ядро

Раннеры

Четыре движка, один интерфейс: каждый раннер возвращает mpsc::Receiver<LogLine>, который потоково отдаёт вывод в реальном времени и закрывается по завершении прогона. Выбор движка задаётся через ExecutionPlan.

Нативный движок шагов (step::runner)

Чистый Rust, без внешних бинарников. run_steps(steps, config, tx) выполняет список шагов под одним из трёх профилей нагрузки, который вычисляется из конфигурации через step::schedule::Schedule:

  • Fixed — vus + duration: config.vus задач tokio, каждая из которых циклически выполняет список шагов, пока не истечёт длительность.
  • Ramping VUs — stages: (в стиле k6): задача-супервизор каждые ~100 мс пересчитывает целевое число VU линейной интерполяцией между целями ступеней (первая ступень наращивает с 0, каждая следующая — от предыдущей цели). При увеличении нагрузки порождаются новые задачи VU; при уменьшении помечаются самые новые VU, которые завершают текущий шаг и выходят на ближайшей границе шага (плавная остановка). Длительность прогона равна сумме длительностей ступеней.
  • Arrival-rate — arrival: (открытая модель): диспетчер вычисляет моменты старта итераций, обращая кусочно-линейный интеграл интенсивности, и выдаёт разрешения пулу воркеров, который стартует с pre_allocated_vus (по умолчанию 1) и лениво растёт до max_vus. В отличие от закрытых моделей с циклом VU, новые итерации стартуют по расписанию, даже когда тестируемая система замедляется — разрешение, которое некому обслужить (пул насыщен до max_vus), отбрасывается, учитывается в сводной метрике dropped_iterations и логируется не чаще одного раза в 5 с. Счётчик выводится даже при значении 0, поэтому гейты std/thresholds@v1 вида dropped_iterations: ["count==0"] корректно срабатывают на чистых прогонах, а не падают с ошибкой о неизвестной метрике. С arrival.co_correct: true пул ставит разрешения в очередь вместо отбрасывания (семантика zrk/wrk2): подвисание становится задержкой в engine_arrival_iteration_ms, отсчитанной от запланированного старта.

Для прогонов со ступенями/arrival строка vus в сводке показывает наблюдаемый параллелизм — vus....................: <last> min=<min> max=<max> — а периодическая строка [stats] дополняется хвостом vus=N с текущим значением. Прогоны Fixed сохраняют исторические форматы без изменений.

  • Context на каждый VU — выводы шагов и интерполяция ${{ }} изолированы между VU и сохраняются между итерациями одного VU

HTTP-движок

У std/http@v1 два клиента за одним действием:

  • Нативный HTTP/1.1 fast-path. Работает по умолчанию для http:// и https:// в стандартном pool. Каждый VU пишет запрос одним буфером и разбирает ответ в своей задаче. Keep-alive соединения хранятся в пуле шарда: до 64 простаивающих на хост, закрываются после 60 с простоя. TLS работает на rustls/ring с корнями webpki, а при insecure: true принимает любой сертификат. Формат запроса и вывод такие же, как у reqwest: accept, accept-encoding: gzip с прозрачной распаковкой, host, content-length, тела chunked и до EOF, timeout шага и max_response_body_bytes.
  • reqwest обрабатывает всё остальное: multipart, source_ips, логин в URL, pool: shared / pool: dedicated и продолжение редиректа (начиная с первого 3xx).

Ещё две оптимизации включаются автоматически:

  • Статичные шаги. Если в исходном with: нет ни одного ${…} (ни интерполяции, ни генераторов), собранный запрос переиспользуется.
  • Облегчённый вывод. Если ни один шаг не читает __last__ и нет отдельного шага std/check, то шаг без outputs: / check: сохраняет только status и duration_ms. Тело всё равно читается целиком, но не декодируется.

VU работают на однопоточном event loop, по одному на шард HTTP-клиента (модель gcannon/zrk), а не на runtime с work-stealing.

Переменная окруженияЭффект
PERFSCALE_HTTP_CLIENT=reqwestОтключить fast-path, все запросы идут через reqwest
PERFSCALE_VU_RUNTIME=sharedЗапускать VU на общем многопоточном runtime (исполнитель до 0.32)

Маркер завершения в потоках агента

Когда прогон выполняется через машинного агента (perfscaled), поток логов каждого движка завершается ровно одной системной строкой после всего вывода движка:

[sys] __perfscale_exit__=<code|signal|unknown>
  • code — код завершения движка (0 = успех; нативный движок возвращает 1 при нарушенном гейте порогов severity: fail или при некорректном профиле нагрузки)
  • signal — процесс движка был завершён сигналом (например, OOM killer)
  • unknown — код завершения определить не удалось

Потребителям следует считать маркер авторитетным результатом прогона: шум в stderr (например, k6 console.log) не означает неудачу, если код завершения равен 0. Потребители старше агента 0.3.6 не видят маркера и возвращаются к эвристикам; старые потребители, читающие поток нового агента, просто выводят эту строку как обычное системное сообщение.

  • Тайминги HTTP из std/http@v1 питают общие метрики; другие действия (WebSocket, gRPC, TCP/UDP) вносят счётчики и гистограммы задержек (например, ws_msg_rtt, grpc_req_duration) через тот же коллектор
  • Завершается k6-совместимым блоком сводки + Done — Xs wall clock
  • vus: 0 приводится к 1; строки длительности парсятся через parse_duration_ms ("90", "500ms", "1.5s", "1m30s", "1h" — разрешение в миллисекундах, минимум 1 мс). Длительности ступеней проверяются строго: непарсящиеся или нулевые значения завершают прогон (и perfscale lint) с понятной ошибкой, так же как и сочетание stages с arrival, и arrival без max_vus >= 1

k6 (runner::k6)

Обёртка вокруг существующей установки k6:

  1. скрипт записывается в $TMPDIR/perfscale-<uuid>.js
  2. запускается k6 run --no-color <script> с перенаправленными stdio
  3. stdout/stderr стримятся как LogLine; временный файл удаляется на выходе

Два режима:

ФункцияВозвращаетПрименение
run_streaming(script)Receiver<LogLine>вывод в реальном времени
run_oneshot(script)RunResult { exit_code, success, stdout, stderr, script }сначала собрать, потом проверить

Конфигурация нагрузки (VU, ступени, пороги) задаётся в собственном блоке options скрипта — perfscale не подставляет флаги k6.

Отсутствующий бинарник → k6 not found in PATH — install from https://k6.io/....

locust (runner::locust)

Обёртка вокруг существующей установки locust в headless-режиме:

locust -f <script> --headless -u <users> -r <spawn_rate> -t <duration> --csv <tmp-prefix> [--host <host>]

LocustOpts { users, spawn_rate, duration, host } маппится из общего RunConfig через LocustOpts::from_run_config (vus → users и spawn_rate).

Во время прогона собственные stdout/stderr locust стримятся напрямую. После выхода раннер парсит строку Aggregated из <prefix>_stats.csv и выводит тот же блок сводки, что и остальные движки:

http_req_duration......: avg=42.50ms p(50)=40ms p(90)=60ms p(95)=68ms p(99)=85ms min=10ms max=120ms
http_req_failed........: 2.00%
http_reqs..............: 100 10.50/s

Временные CSV-файлы (_stats, _stats_history, _failures, _exceptions) удаляются после завершения. Отсутствующий или усечённый CSV даёт строку system (failed to read locust stats: ...), а не ошибку — вывод процесса уже был отстримлен.

Отсутствующий бинарник → locust not found in PATH — install with pip install locust.

JMeter (runner::jmeter)

Обёртка вокруг существующей установки jmeter в не-GUI режиме:

jmeter -n -t <plan.jmx> -j <temp>/jmeter.log -l <temp>/results.jtl -J…

План полностью определяет форму нагрузки (группы потоков, таймеры, пропускная способность); свойства -J key=value параметризуют его из CLI. Сэмплы прогона попадают в приватный CSV .jtl с принудительным набором полей (saveservice-свойства раннера идут последними, поэтому пользовательские свойства не могут его сломать); оба временных файла удаляются после прогона. Во время выполнения собственные stdout/stderr jmeter стримятся напрямую, а с report.during_run: true из .jtl стримятся кумулятивные снапшоты метрик. После выхода .jtl складывается в общее хранилище метрик для полной k6-совместимой сводки:

http_req_duration......: avg=4.00ms p(50)=3.00ms p(90)=6.00ms p(95)=8.00ms p(99)=21.00ms min=1.00ms max=42.00ms
http_req_failed........: 0.25%
http_reqs..............: 1200 80.0/s

Серии по JMeter-label (ограниченная кардинальность, bucket переполнения __jmeter_other__), http_req_latency/http_req_connect и data_received/data_sent живут в том же хранилище, поэтому для прогонов JMeter работают гейты std/thresholds@v1 из after:, отчёты --report json/junit/html и потоковые репортёры. План с нулевым числом сэмплов откатывается на трансляцию консольной summary = (только avg/min/max); повреждённые строки .jtl пропускаются с предупреждением, .jtl без достоверных строк (или с невалидным заголовком) либо нарушенный гейт fail завершают прогон с кодом 1. abort_on_fail не поддерживается (отклоняется с понятной ошибкой) — прогоном управляет jmeter, гейты вычисляются после.

Отсутствующий бинарник → jmeter not found in PATH — install from https://jmeter.apache.org/download_jmeter.cgi (requires a JRE).

Выбор движка

nativek6locustJMeter
Требуется установкане нужнабинарник k6python + locustJRE + jmeter
Язык сценариевшаги YAMLJavaScriptPythonтест-план .jmx (XML)
Возможности скриптинганизкие (4 действия)высокиевысокиевысокие
Модель нагрузкификсированные VU, stages: с ростом или arrival-ratestages/thresholds/scenariosusers/spawn-rateгруппы потоков/таймеры (в плане)
Лучше всего длясмоук-тесты, гейты CI, простые API-сценариисложные наборы k6, которые у вас уже естькоманды с фокусом на Pythonсуществующие наборы JMeter