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
2 changes: 1 addition & 1 deletion benchmarking/nighthawk-ingress/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ COPY --from=protogen /out/nighthawk.desc /app/nighthawk.desc
COPY benchmarking/locust/common/__init__.py /app/common/__init__.py
COPY benchmarking/locust/common/ateapi_channel.py /app/common/ateapi_channel.py
COPY --from=ateprotos /out/ /app/common/
COPY benchmarking/nighthawk_common /app/nighthawk_common

COPY benchmarking/nighthawk-ingress/runner.py \
benchmarking/nighthawk-ingress/spec.py \
benchmarking/nighthawk-ingress/output.py \
benchmarking/nighthawk-ingress/actors.py \
/app/
COPY benchmarking/nighthawk-ingress/tests /app/tests
Expand Down
1 change: 0 additions & 1 deletion benchmarking/nighthawk-ingress/actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
from common import ateapi_pb2_grpc
from common.ateapi_channel import CA_FILE, CRED_BUNDLE, SERVER_NAME, ateapi_channel

import spec as spec_mod

ATEAPI_HOST = "api.ate-system.svc.cluster.local:443"
TOKEN_FILE = "/run/ateapi-token/token"
Expand Down
5 changes: 3 additions & 2 deletions benchmarking/nighthawk-ingress/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@
from typing import IO, TextIO

import actors
import output as output_mod
from nighthawk_common import output as output_mod
import spec as spec_mod
from nighthawk_common import spec as common_spec

NIGHTHAWK_SERVICE = "nighthawk_service"
ADAPTIVE_CLIENT = "nighthawk_adaptive_load_client"
Expand Down Expand Up @@ -293,7 +294,7 @@ def main() -> None:
),
)
spec_path.write_text(
spec_mod.spec_dict_to_textproto(spec_dict, DESC_PATH)
common_spec.spec_dict_to_textproto(spec_dict, DESC_PATH)
)
tee(logs, f"Wrote spec to {spec_path}")

Expand Down
170 changes: 22 additions & 148 deletions benchmarking/nighthawk-ingress/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,80 +12,24 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""AdaptiveLoadSessionSpec builder.
"""AdaptiveLoadSessionSpec builder for the ingress router capacity benchmark.

Specs are plain dicts, validated and rendered to textproto through the
FileDescriptorSet built by protogen/generate_descriptor.sh — field names
are checked against the real Nighthawk protos at spec-build time.
"""

SPEC_MESSAGE = "nighthawk.adaptive_load.AdaptiveLoadSessionSpec"
OUTPUT_MESSAGE = "nighthawk.adaptive_load.AdaptiveLoadSessionOutput"

# Registered plugin names: adaptive-load plugins use underscores upstream,
# request-source plugins use hyphens.
STEP_CONTROLLER_PLUGIN = "nighthawk.exponential_search"
STEP_CONTROLLER_TYPE_URL = (
"type.googleapis.com/nighthawk.adaptive_load.ExponentialSearchStepControllerConfig"
)
BINARY_SCORING_PLUGIN = "nighthawk.binary_scoring"
BINARY_SCORING_TYPE_URL = (
"type.googleapis.com/nighthawk.adaptive_load.BinaryScoringFunctionConfig"
)
BUILTIN_METRICS_PLUGIN = "nighthawk.builtin"
REQUEST_SOURCE_PLUGIN = "nighthawk.in-line-options-list-request-source-plugin"
REQUEST_SOURCE_TYPE_URL = (
"type.googleapis.com/nighthawk.request_source.InLineOptionsListRequestSourceConfig"
from nighthawk_common.spec import (
PERMISSIVE_FAILURE_PREDICATES,
REQUEST_SOURCE_PLUGIN,
REQUEST_SOURCE_TYPE_URL,
build_session_spec,
)

ROUTER_SNI = "atenet-router.ate-system.svc"
CA_FILE = "/run/servicedns-ca/ca.crt"
CRED_BUNDLE = "/run/podidentity.podcert.ate.dev/credential-bundle.pem"

# The default failure predicates (all 0) abort an execution on the first
# 4xx/5xx; measuring periods must instead run to completion so overload
# scores as degraded success-rate. All five defaults overridden.
PERMISSIVE_FAILURE_PREDICATES = {
"benchmark.http_4xx": "1000000000",
"benchmark.http_5xx": "1000000000",
"benchmark.pool_connection_failure": "1000000000",
"benchmark.stream_resets": "1000000000",
"requestsource.upstream_rq_5xx": "1000000000",
}

INFORMATIONAL_METRICS = (
"attempted-rps",
"achieved-rps",
"send-rate",
"latency-ns-mean",
"latency-ns-mean-plus-2stdev",
)


def _metric_spec(name: str) -> dict:
return {"metric_name": name, "metrics_plugin_name": BUILTIN_METRICS_PLUGIN}


def _binary_threshold(
metric_name: str,
lower_threshold: float | None = None,
upper_threshold: float | None = None,
) -> dict:
config: dict = {"@type": BINARY_SCORING_TYPE_URL}
if lower_threshold is not None:
config["lower_threshold"] = lower_threshold
if upper_threshold is not None:
config["upper_threshold"] = upper_threshold
return {
"metric_spec": _metric_spec(metric_name),
"threshold_spec": {
"scoring_function": {
"name": BINARY_SCORING_PLUGIN,
"typed_config": config,
},
},
}


def build_spec_dict(
*,
Expand All @@ -105,13 +49,10 @@ def build_spec_dict(
tail_latency_slo_ms: float | None = None,
benchmark_cooldown_s: int = 5,
) -> dict:
"""Assemble the AdaptiveLoadSessionSpec as a JSON-shaped dict.
"""Assemble the AdaptiveLoadSessionSpec as a JSON-shaped dict."""
if not uri.startswith("https://"):
raise ValueError(f"router URI must use https://, got {uri!r}")

The controller varies the *per-worker* requests_per_second, so
initial_value = initial_total_rps / client_concurrency. The template
must not set duration (the controller rejects it); open_loop is forced
true by the controller.
"""
request_variants = [
{
"request_method": "POST",
Expand All @@ -127,28 +68,6 @@ def build_spec_dict(
}
for actor_name in actor_names
]
# Threshold roles: tail latency (mean+2stdev, ~p95 proxy — no true
# percentiles in the builtin adaptive metrics) is the SLO bound;
# success-rate catches fast-error saturation (503/504) that latency and
# send-rate miss; send-rate is the open-loop backstop — skipped
# requests are neither failures nor latency samples, so without it the
# search ramps unboundedly.
thresholds = [
_binary_threshold("success-rate", lower_threshold=success_rate_threshold)
]
if send_rate_threshold is not None:
thresholds.append(
_binary_threshold("send-rate", lower_threshold=send_rate_threshold)
)
if tail_latency_slo_ms is not None:
thresholds.append(
_binary_threshold(
"latency-ns-mean-plus-2stdev",
upper_threshold=tail_latency_slo_ms * 1_000_000.0,
)
)
if not uri.startswith("https://"):
raise ValueError(f"router URI must use https://, got {uri!r}")

traffic_template: dict = {
"uri": uri,
Expand Down Expand Up @@ -189,61 +108,16 @@ def build_spec_dict(
},
},
}
return {
"nighthawk_traffic_template": traffic_template,
"metric_thresholds": thresholds,
"informational_metric_specs": [
_metric_spec(m) for m in INFORMATIONAL_METRICS
],
"step_controller_config": {
"name": STEP_CONTROLLER_PLUGIN,
"typed_config": {
"@type": STEP_CONTROLLER_TYPE_URL,
"initial_value": max(1.0, initial_total_rps / client_concurrency),
"exponential_factor": exponential_factor,
},
},
"measuring_period": f"{measuring_period_s}s",
"convergence_deadline": f"{convergence_deadline_s}s",
"testing_stage_duration": f"{testing_stage_duration_s}s",
"benchmark_cooldown_duration": f"{benchmark_cooldown_s}s",
}


# protobuf imports are deferred: the dict builder above must work without
# protobuf installed.


def load_pool(desc_path: str):
from google.protobuf import descriptor_pb2
from google.protobuf import descriptor_pool

pool = descriptor_pool.DescriptorPool()
fds = descriptor_pb2.FileDescriptorSet()
with open(desc_path, "rb") as f:
fds.ParseFromString(f.read())
for file_proto in fds.file:
pool.Add(file_proto)
return pool


def message_class(pool, full_name: str):
from google.protobuf import message_factory

return message_factory.GetMessageClass(pool.FindMessageTypeByName(full_name))


def spec_dict_to_textproto(spec_dict: dict, desc_path: str) -> str:
"""Validate the dict against the real protos and render textproto.

No descriptor_pool for MessageToString: expanded-form Any is not
parseable by nighthawk_adaptive_load_client for plugin types it does
not link (the request-source config); raw type_url/value form is.
"""
from google.protobuf import json_format
from google.protobuf import text_format

pool = load_pool(desc_path)
msg = message_class(pool, SPEC_MESSAGE)()
json_format.ParseDict(spec_dict, msg, descriptor_pool=pool)
return text_format.MessageToString(msg)
return build_session_spec(
traffic_template,
client_concurrency=client_concurrency,
initial_total_rps=initial_total_rps,
exponential_factor=exponential_factor,
measuring_period_s=measuring_period_s,
convergence_deadline_s=convergence_deadline_s,
testing_stage_duration_s=testing_stage_duration_s,
success_rate_threshold=success_rate_threshold,
send_rate_threshold=send_rate_threshold,
tail_latency_slo_ms=tail_latency_slo_ms,
benchmark_cooldown_s=benchmark_cooldown_s,
)
4 changes: 2 additions & 2 deletions benchmarking/nighthawk-ingress/tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

import output as output_mod
from nighthawk_common import output as output_mod


def benchmark_result(
Expand Down
14 changes: 8 additions & 6 deletions benchmarking/nighthawk-ingress/tests/test_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

import spec as spec_mod
from nighthawk_common import spec as common_spec


def build(**overrides):
Expand Down Expand Up @@ -103,7 +105,7 @@ def test_actor_reference_rotation_covers_all_actors():
actor_names = [f"sb-{i}" for i in range(5)]
spec = build(actor_names=actor_names)
plugin = spec["nighthawk_traffic_template"]["request_source_plugin_config"]
assert plugin["name"] == spec_mod.REQUEST_SOURCE_PLUGIN
assert plugin["name"] == common_spec.REQUEST_SOURCE_PLUGIN
options = plugin["typed_config"]["options_list"]["options"]
got = [
{header["header"]["key"]: header["header"]["value"] for header in o["request_headers"]}
Expand All @@ -123,7 +125,7 @@ def test_actor_reference_rotation_covers_all_actors():
def test_initial_rps_is_per_worker():
spec = build(initial_total_rps=1000, client_concurrency=4)
controller = spec["step_controller_config"]
assert controller["name"] == spec_mod.STEP_CONTROLLER_PLUGIN
assert controller["name"] == common_spec.STEP_CONTROLLER_PLUGIN
assert controller["typed_config"]["initial_value"] == 250.0


Expand All @@ -136,7 +138,7 @@ def test_thresholds_and_durations():
]
assert names == ["success-rate", "send-rate"]
scoring = spec["metric_thresholds"][0]["threshold_spec"]["scoring_function"]
assert scoring["name"] == spec_mod.BINARY_SCORING_PLUGIN
assert scoring["name"] == common_spec.BINARY_SCORING_PLUGIN
assert scoring["typed_config"]["lower_threshold"] == 0.999
assert spec["measuring_period"] == "10s"
assert spec["convergence_deadline"] == "600s"
Expand Down Expand Up @@ -165,9 +167,9 @@ def test_spec_round_trips_through_real_protos():
return
from google.protobuf import text_format

text = spec_mod.spec_dict_to_textproto(build(tail_latency_slo_ms=25), desc)
pool = spec_mod.load_pool(desc)
msg = spec_mod.message_class(pool, spec_mod.SPEC_MESSAGE)()
text = common_spec.spec_dict_to_textproto(build(tail_latency_slo_ms=25), desc)
pool = common_spec.load_pool(desc)
msg = common_spec.message_class(pool, common_spec.SPEC_MESSAGE)()
text_format.Parse(text, msg, descriptor_pool=pool)
assert msg.nighthawk_traffic_template.concurrency.value == "4"
assert msg.measuring_period.seconds == 10
Expand Down
13 changes: 13 additions & 0 deletions benchmarking/nighthawk_common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2026 The Agent Substrate Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
import json
import re

from nighthawk_common import spec as spec_mod

# Statistic carrying client-measured end-to-end request latency.
LATENCY_STAT_ID = "benchmark_http_client.request_to_response"

Expand Down Expand Up @@ -70,8 +72,6 @@ def parse_output_textproto(text: str, desc_path: str) -> dict:
from google.protobuf import json_format
from google.protobuf import text_format

import spec as spec_mod

# Drop the client's leading "goo.gle/debugproto" marker line(s).
lines = text.splitlines()
start = 0
Expand Down
Loading
Loading