Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .github/workflows/perf-https-gates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Perf — HTTPS gates

# CWIST 3.9 TLS performance gates (issues #306 / #307).
#
# Gate 1 (tls_rtt_p50_ms) is the #307 regression test: a sequential TLS
# client WITHOUT TCP_NODELAY, which stalls ~43ms per request on pre-#307
# code and ~0.2ms on healthy code. The other gates cover HTTPS churn,
# keep-alive throughput and 1MiB transfer, each with a plaintext control.
# See scripts/ci/https_perf_gates.sh for measured baselines.
on:
pull_request:
branches: [dev]
paths:
- '.github/workflows/perf-https-gates.yml'
- 'scripts/ci/https_perf_gates.sh'
- 'scripts/ci/https_gates_eval.py'
- 'src/net/http/https.c'
- 'src/net/http/https_upgrade_hook.c'
- 'src/net/http/tls_chain.c'
- 'src/https/**'
- 'lib/boringssl'
push:
branches: [dev]
paths:
- '.github/workflows/perf-https-gates.yml'
- 'scripts/ci/https_perf_gates.sh'
- 'scripts/ci/https_gates_eval.py'
- 'src/net/http/https.c'
- 'src/net/http/https_upgrade_hook.c'
- 'src/net/http/tls_chain.c'
- 'src/https/**'
- 'lib/boringssl'
workflow_dispatch:

permissions:
contents: read

jobs:
https-gates:
name: HTTPS performance gates
runs-on: ubuntu-latest
# Write is needed only for the post-gate history commit on dev pushes;
# that step never runs on pull_request events, and fork PRs get a
# read-only token regardless.
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with: { submodules: recursive }

- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake libcurl4-openssl-dev libnghttp2-dev \
libbrotli-dev wrk apache2-utils libssl-dev

- name: Run HTTPS gates
run: scripts/ci/https_perf_gates.sh https-gates-result.json

- name: Evaluate gates
run: python3 scripts/ci/https_gates_eval.py https-gates-result.json

- name: Upload result
if: always()
uses: actions/upload-artifact@v4
with:
name: https-gates-result
path: https-gates-result.json

# History update: only after the gates passed on a dev push, so the
# same-CPU medians never absorb a regression. PR runs never touch the
# history file.
- name: Update gate history on dev
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
run: python3 scripts/ci/https_gates_eval.py --update https-gates-result.json

- name: Commit updated history
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
uses: actions/github-script@v7
with:
script: |
const { execSync } = require('child_process');
try {
execSync('git diff --quiet -- benchmarks/https_gates.json');
console.log('no history changes');
} catch {
execSync('git config user.name "github-actions[bot]"');
execSync('git config user.email "github-actions[bot]@users.noreply.github.com"');
execSync('git add benchmarks/https_gates.json');
execSync(`git commit -m "bench(ci): record HTTPS gate run [skip ci]"`);
execSync('git push');
}
15 changes: 15 additions & 0 deletions benchmarks/https_gates.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[
{
"timestamp": "2026-10-04T21:07:18+0900",
"runner_hw": "12 vCPU | AMD Ryzen 5 5600X 6-Core Processor",
"tls_rtt_p50_ms": 0.084,
"tls_rtt_p90_ms": 0.11,
"https_churn_rps": 1596.46,
"http_churn_rps": 14244.68,
"https_keepalive_rps": 151444.8,
"http_keepalive_rps": 274146.39,
"https_keepalive_ratio": 0.552,
"https_big_gbps": 4.06,
"http_big_gbps": 11.88
}
]
137 changes: 137 additions & 0 deletions scripts/ci/https_gates_eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Evaluate the HTTPS gate row produced by https_perf_gates.sh.

Gate policy mirrors runner_baseline.py (which this imports): an absolute
backstop that must hold on any hardware, plus a same-runner-CPU comparison
against the median of earlier runs once at least ``min_samples`` exist on
that CPU. Absolute numbers move a lot between CI CPUs, so the backstops are
wide; the same-CPU check is what catches real regressions early.

Throughput metrics are "min" gates (fail when below backstop / median
divided by the allowance); the RTT metric is a "max" gate. The https/http
keep-alive ratio is recorded as a diagnostic signal, not gated.

Usage:
https_gates_eval.py RESULT.json [HISTORY.json] # evaluate, exit 1 on fail
https_gates_eval.py --update RESULT.json [HISTORY.json]
"""
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from runner_baseline import runner_key, same_runner_values

HISTORY = Path(__file__).resolve().parent.parent.parent / "benchmarks" / "https_gates.json"

# (metric, direction, absolute backstop, same-CPU allowance)
# Backstops are calibrated so a healthy tree passes on the noisiest shared
# CI runner we have seen, with real margin for run-to-run variance:
# local Ryzen 5600X (12-core): churn ~1650/s, keep-alive ~151-190k/s,
# 1MiB ~4.1GB/s (https) / ~12-14GB/s (http), RTT p50 ~0.09ms.
# shared EPYC 9V45 CI runner (run 37201724744): churn 2013/s https /
# 15793/s http, keep-alive 135570/s https / 206650/s http,
# 1MiB 4.03GB/s https / 7.8GB/s http.
# The pre-#307 tree measured ~600/s churn, ~43ms RTT p50, so these
# backstops still fail it on any hardware. The same-CPU relative check
# (runner_baseline.py convention) is what catches smaller regressions once
# enough history accumulates for a runner CPU.
GATES = [
("tls_rtt_p50_ms", "max", 5.0, 2.0),
("https_churn_rps", "min", 1000.0, 1.25),
("https_keepalive_rps", "min", 110000.0, 1.25),
("https_big_gbps", "min", 2.5, 1.30),
# Plaintext controls: collateral damage to the plain path fails the gate.
("http_churn_rps", "min", 6000.0, 1.25),
("http_keepalive_rps", "min", 150000.0, 1.25),
("http_big_gbps", "min", 5.5, 1.30),
]


def evaluate(history, row):
failures = []
details = []
for metric, direction, backstop, allowance in GATES:
value = row.get(metric)
if not isinstance(value, (int, float)) or value <= 0:
failures.append(f"{metric}: missing or non-positive in result row")
continue

if direction == "max":
if value > backstop:
failures.append(f"{metric}: {value:.3f} over the {backstop}ms "
f"absolute backstop")
continue
else:
if value < backstop:
failures.append(f"{metric}: {value:.1f} under the {backstop} "
f"absolute backstop")
continue

key = runner_key(row)
if key is None:
details.append(f"{metric}: {value:.3f}, runner not recorded, "
f"backstop only")
continue
past = same_runner_values(history, key, metric)
if len(past) < 5:
details.append(f"{metric}: {value:.3f} on {key}, only "
f"{len(past)} earlier run(s), backstop only")
continue
from statistics import median
base = median(past)
if direction == "max":
limit = base * allowance
if value > limit:
failures.append(f"{metric}: {value:.3f} on {key} over {limit:.3f} "
f"({allowance}x the {base:.3f} median of "
f"{len(past)} earlier runs)")
continue
else:
limit = base / allowance
if value < limit:
failures.append(f"{metric}: {value:.1f} on {key} under {limit:.1f} "
f"(median {base:.1f} of {len(past)} earlier "
f"runs / {allowance})")
continue
details.append(f"{metric}: {value:.3f} on {key}, within same-CPU gate")

ratio = row.get("https_keepalive_ratio")
if isinstance(ratio, (int, float)):
details.append(f"https_keepalive_ratio: {ratio:.3f} (signal only, "
f"~0.65 expected; large drops hint at TLS-path damage "
f"even when both absolute gates pass)")
return failures, details


def main():
args = sys.argv[1:]
update = args and args[0] == "--update"
if update:
args = args[1:]
result_path = Path(args[0]) if args else Path("https-gates-result.json")
history_path = Path(args[1]) if len(args) > 1 else HISTORY

row = json.loads(result_path.read_text())
try:
history = json.loads(history_path.read_text())
except (OSError, ValueError):
history = []

if update:
history = [r for r in history
if r.get("timestamp") != row.get("timestamp")]
history.append(row)
history_path.write_text(json.dumps(history[-100:], indent=2) + "\n")
print(f"updated {history_path} ({len(history)} rows)")

failures, details = evaluate(history, row)
for line in details:
print(f"ok: {line}")
for line in failures:
print(f"FAIL: {line}")
print(f"https gates: {len(GATES) - len(failures)}/{len(GATES)} passed")
sys.exit(1 if failures else 0)


if __name__ == "__main__":
main()
Loading
Loading