blob: 07864c5fc922cef054f4e4f844c777d7e948798c [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
import argparse
import base64
import binascii
import json
import math
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from statistics import median
LANES = (
"class-root",
"struct-root",
"holder-field",
"list-field",
"list-root",
"map-field",
"map-root",
)
OPERATIONS = ("serialize", "deserialize")
ROLES = ("apache-main", "ordinary", "external")
WORKER_IMPLEMENTATIONS = {
"apache-main": "ordinary",
"ordinary": "ordinary",
"external": "external",
}
SAMPLE_ORDERS = (
("apache-main", "ordinary", "external"),
("external", "ordinary", "apache-main"),
)
MAX_SLOWDOWN_PERCENT = 1.0
TEXT_METADATA = (
"RuntimeVersion",
"OsDescription",
"OsArchitecture",
"ProcessArchitecture",
)
METADATA_FIELDS = TEXT_METADATA + (
"ProcessorCount",
"WarmupSeconds",
"DurationSeconds",
"AllocationIterations",
)
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="Merge isolated C# external-equivalence benchmark results."
)
parser.add_argument(
"--lane",
action="append",
choices=LANES,
help="Expected lane; repeat as needed. Defaults to all lanes.",
)
parser.add_argument(
"--sample",
action="append",
nargs=6,
required=True,
metavar=(
"LANE",
"INDEX",
"ORDER",
"FIRST_INPUT",
"SECOND_INPUT",
"THIRD_INPUT",
),
help=(
"Balanced apache-main/current-ordinary/current-external sample metadata "
"and worker JSON paths; repeat for each lane and sample."
),
)
parser.add_argument(
"--apache-main-commit",
required=True,
help="Fetched apache/main commit used by baseline workers.",
)
parser.add_argument(
"--current-commit",
required=True,
help="Current worktree HEAD commit used by ordinary/external workers.",
)
parser.add_argument(
"--current-dirty",
required=True,
choices=("true", "false"),
help="Whether the current worktree had changes when workers were launched.",
)
parser.add_argument("--output", required=True, help="Combined JSON output path.")
return parser.parse_args(argv)
def require_object(value, owner: str) -> dict:
if not isinstance(value, dict):
raise TypeError(f"{owner} must be a JSON object")
return value
def require_string(value, owner: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{owner} must be a non-empty string")
return value
def require_int(value, owner: str, minimum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise ValueError(
f"{owner} must be an integer greater than or equal to {minimum}"
)
return value
def require_number(value, owner: str, positive: bool):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{owner} must be a number")
number = float(value)
if not math.isfinite(number) or (number <= 0 if positive else number < 0):
qualifier = "positive" if positive else "non-negative"
raise ValueError(f"{owner} must be a finite {qualifier} number")
return value
def require_commit(value, owner: str) -> str:
commit = require_string(value, owner)
if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
raise ValueError(f"{owner} must be a lowercase 40-character commit id")
return commit
def validate_metadata(document: dict, path: Path) -> dict:
owner = str(path)
require_string(document.get("GeneratedAtUtc"), f"{owner}.GeneratedAtUtc")
for field in TEXT_METADATA:
require_string(document.get(field), f"{owner}.{field}")
require_int(document.get("ProcessorCount"), f"{owner}.ProcessorCount", 1)
require_number(document.get("WarmupSeconds"), f"{owner}.WarmupSeconds", True)
require_number(document.get("DurationSeconds"), f"{owner}.DurationSeconds", True)
require_int(
document.get("AllocationIterations"),
f"{owner}.AllocationIterations",
1,
)
return {field: document[field] for field in METADATA_FIELDS}
def validate_measurement(value, owner: str) -> dict:
measurement = require_object(value, owner)
for field in ("OperationsPerSecond", "AverageNanoseconds", "ElapsedSeconds"):
require_number(measurement.get(field), f"{owner}.{field}", True)
require_int(measurement.get("Iterations"), f"{owner}.Iterations", 1)
return measurement
def decode_frame(value, owner: str, serialized_size: int) -> bytes:
encoded = require_string(value, owner)
try:
frame = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as error:
raise ValueError(f"{owner} is not valid Base64: {error}") from error
if base64.b64encode(frame).decode("ascii") != encoded:
raise ValueError(f"{owner} is not canonical Base64")
if len(frame) != serialized_size:
raise ValueError(
f"{owner} decodes to {len(frame)} bytes, expected {serialized_size}"
)
return frame
def sample_order_name(order) -> str:
return "-".join(order)
def parse_sample_specs(raw_samples, expected_lanes):
expected_lane_set = set(expected_lanes)
expected_specs = {
(lane, index): order
for lane in expected_lanes
for index, order in enumerate(SAMPLE_ORDERS, start=1)
}
specs = {}
input_owners = {}
for raw_sample in raw_samples:
(
lane,
raw_index,
order_name,
first_input,
second_input,
third_input,
) = raw_sample
if lane not in expected_lane_set:
raise ValueError(f"sample lane is not expected: {lane!r}")
try:
sample_index = int(raw_index)
except ValueError as error:
raise ValueError(
f"sample index must be an integer: {raw_index!r}"
) from error
if raw_index != str(sample_index):
raise ValueError(
f"sample index must use canonical decimal form: {raw_index!r}"
)
key = (lane, sample_index)
expected_order = expected_specs.get(key)
if expected_order is None:
raise ValueError(f"unexpected sample index for {lane}: {sample_index}")
if key in specs:
raise ValueError(f"duplicate sample for {lane}/{sample_index}")
expected_order_name = sample_order_name(expected_order)
if order_name != expected_order_name:
raise ValueError(
f"sample {lane}/{sample_index} order must be "
f"{expected_order_name}, got {order_name}"
)
paths = (
Path(
require_string(first_input, f"sample {lane}/{sample_index} first input")
),
Path(
require_string(
second_input,
f"sample {lane}/{sample_index} second input",
)
),
Path(
require_string(
third_input,
f"sample {lane}/{sample_index} third input",
)
),
)
for path in paths:
normalized = path.resolve()
if normalized in input_owners:
raise ValueError(
f"worker input {path} is reused by {input_owners[normalized]} "
f"and {lane}/{sample_index}"
)
input_owners[normalized] = f"{lane}/{sample_index}"
specs[key] = {
"Order": expected_order,
"Paths": paths,
}
missing = set(expected_specs) - set(specs)
if missing:
raise ValueError(
"missing samples: "
+ ", ".join(f"{lane}/{index}" for lane, index in sorted(missing))
)
return specs
def load_worker(path: Path, expected_lane: str, expected_implementation: str):
with path.open("r", encoding="utf-8") as source:
document = require_object(json.load(source), str(path))
metadata = validate_metadata(document, path)
implementation = require_string(
document.get("Implementation"), f"{path}.Implementation"
)
if implementation != expected_implementation:
raise ValueError(
f"{path}.Implementation must be {expected_implementation}, "
f"got {implementation}"
)
raw_results = document.get("Results")
if not isinstance(raw_results, list) or len(raw_results) != len(OPERATIONS):
raise ValueError(f"{path}.Results must contain exactly two operations")
results = {}
for index, raw_result in enumerate(raw_results):
owner = f"{path}.Results[{index}]"
result = require_object(raw_result, owner)
lane = result.get("DataType")
if lane != expected_lane:
raise ValueError(f"{owner}.DataType must be {expected_lane}, got {lane!r}")
operation = result.get("Operation")
if operation not in OPERATIONS:
raise ValueError(f"{owner}.Operation must be serialize or deserialize")
if operation in results:
raise ValueError(
f"duplicate result for {expected_lane}/{operation} in {path}"
)
serialized_size = require_int(
result.get("SerializedSize"), f"{owner}.SerializedSize", 0
)
frame = decode_frame(
result.get("SerializedFrameBase64"),
f"{owner}.SerializedFrameBase64",
serialized_size,
)
measurement = validate_measurement(
result.get("Measurement"), f"{owner}.Measurement"
)
allocated = result.get("AllocatedBytesPerOperation")
if allocated is None:
raise ValueError(f"{owner}.AllocatedBytesPerOperation is required")
require_number(
allocated,
f"{owner}.AllocatedBytesPerOperation",
False,
)
results[operation] = {
"SerializedSize": serialized_size,
"Frame": frame,
"Measurement": measurement,
"AllocatedBytesPerOperation": allocated,
}
missing_operations = set(OPERATIONS) - set(results)
if missing_operations:
raise ValueError(
f"{path} is missing operations: {', '.join(sorted(missing_operations))}"
)
if (
results["serialize"]["SerializedSize"]
!= results["deserialize"]["SerializedSize"]
or results["serialize"]["Frame"] != results["deserialize"]["Frame"]
):
raise ValueError(
f"serialized frame differs between operations for "
f"{expected_implementation}/{expected_lane} in {path}"
)
return metadata, results
def load_samples(specs, expected_lanes):
metadata = None
results = {}
for lane in expected_lanes:
for sample_index, expected_order in enumerate(SAMPLE_ORDERS, start=1):
spec = specs[(lane, sample_index)]
if spec["Order"] != expected_order:
raise ValueError(
f"internal sample order mismatch for {lane}/{sample_index}"
)
for role, path in zip(expected_order, spec["Paths"]):
current_metadata, worker_results = load_worker(
path,
lane,
WORKER_IMPLEMENTATIONS[role],
)
if metadata is None:
metadata = current_metadata
elif current_metadata != metadata:
differences = [
field
for field in METADATA_FIELDS
if current_metadata[field] != metadata[field]
]
raise ValueError(
f"{path} has incompatible metadata fields: "
f"{', '.join(differences)}"
)
for operation, result in worker_results.items():
results[(role, lane, sample_index, operation)] = result
if metadata is None:
raise ValueError("no benchmark samples were loaded")
return metadata, results
def median_summary(samples) -> dict:
return {
"OperationsPerSecond": median(
sample["Measurement"]["OperationsPerSecond"] for sample in samples
),
"AverageNanoseconds": median(
sample["Measurement"]["AverageNanoseconds"] for sample in samples
),
}
def stable_allocation(samples, owner: str):
allocations = {sample["AllocatedBytesPerOperation"] for sample in samples}
if len(allocations) != 1:
raise ValueError(f"allocation mismatch between samples for {owner}")
return next(iter(allocations))
def slowdown_percent(candidate: dict, baseline: dict) -> float:
return (
candidate["AverageNanoseconds"] / baseline["AverageNanoseconds"] - 1.0
) * 100.0
def merge_results(
metadata: dict,
results: dict,
expected_lanes,
apache_main_commit: str,
current_commit: str,
current_dirty: bool,
) -> dict:
expected_keys = {
(role, lane, sample_index, operation)
for role in ROLES
for lane in expected_lanes
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
for operation in OPERATIONS
}
missing = expected_keys - set(results)
if missing:
raise ValueError(
"missing results: "
+ ", ".join(
f"{role}/{lane}/{sample_index}/{operation}"
for role, lane, sample_index, operation in sorted(missing)
)
)
unexpected = set(results) - expected_keys
if unexpected:
raise ValueError(
"unexpected results: "
+ ", ".join(
f"{role}/{lane}/{sample_index}/{operation}"
for role, lane, sample_index, operation in sorted(unexpected)
)
)
combined_results = []
violations = []
for lane in expected_lanes:
proof = results[("apache-main", lane, 1, "serialize")]
lane_samples = [
results[(role, lane, sample_index, operation)]
for role in ROLES
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
for operation in OPERATIONS
]
for sample in lane_samples:
if (
sample["SerializedSize"] != proof["SerializedSize"]
or sample["Frame"] != proof["Frame"]
):
raise ValueError(f"serialized frame differs between samples for {lane}")
for operation in OPERATIONS:
apache_main_samples = [
results[("apache-main", lane, sample_index, operation)]
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
]
ordinary_samples = [
results[("ordinary", lane, sample_index, operation)]
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
]
external_samples = [
results[("external", lane, sample_index, operation)]
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
]
apache_main_allocation = stable_allocation(
apache_main_samples,
f"apache-main/{lane}/{operation}",
)
ordinary_allocation = stable_allocation(
ordinary_samples,
f"ordinary/{lane}/{operation}",
)
external_allocation = stable_allocation(
external_samples,
f"external/{lane}/{operation}",
)
apache_main_median = median_summary(apache_main_samples)
ordinary_median = median_summary(ordinary_samples)
external_median = median_summary(external_samples)
ordinary_slowdown = slowdown_percent(
ordinary_median,
apache_main_median,
)
external_slowdown = slowdown_percent(
external_median,
ordinary_median,
)
external_vs_apache_main_slowdown = slowdown_percent(
external_median,
apache_main_median,
)
if ordinary_slowdown > MAX_SLOWDOWN_PERCENT:
violations.append(
f"{lane}/{operation}: current ordinary is "
f"{ordinary_slowdown:+.2f}% slower than apache/main "
f"(limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
)
if external_slowdown > MAX_SLOWDOWN_PERCENT:
violations.append(
f"{lane}/{operation}: current external is "
f"{external_slowdown:+.2f}% slower than current ordinary "
f"(limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
)
if external_vs_apache_main_slowdown > MAX_SLOWDOWN_PERCENT:
violations.append(
f"{lane}/{operation}: current external is "
f"{external_vs_apache_main_slowdown:+.2f}% slower than "
f"apache/main (limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
)
if ordinary_allocation > apache_main_allocation:
violations.append(
f"{lane}/{operation}: current ordinary allocates "
f"{ordinary_allocation:.2f} B/op, above apache/main "
f"{apache_main_allocation:.2f} B/op"
)
if external_allocation != ordinary_allocation:
violations.append(
f"{lane}/{operation}: current external allocation "
f"{external_allocation:.2f} B/op differs from current ordinary "
f"{ordinary_allocation:.2f} B/op"
)
combined_results.append(
{
"DataType": lane,
"Operation": operation,
"SerializedSize": proof["SerializedSize"],
"ApacheMainMedian": apache_main_median,
"CurrentOrdinaryMedian": ordinary_median,
"CurrentExternalMedian": external_median,
"CurrentOrdinarySlowdownPercent": ordinary_slowdown,
"CurrentExternalSlowdownPercent": external_slowdown,
"ExternalVsApacheMainSlowdownPercent": (
external_vs_apache_main_slowdown
),
"ApacheMainAllocatedBytesPerOperation": apache_main_allocation,
"CurrentOrdinaryAllocatedBytesPerOperation": ordinary_allocation,
"CurrentExternalAllocatedBytesPerOperation": external_allocation,
"CurrentOrdinaryAllocationWithinBaseline": (
ordinary_allocation <= apache_main_allocation
),
"CurrentExternalAllocationEqual": (
external_allocation == ordinary_allocation
),
}
)
return {
"GeneratedAtUtc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"ApacheMainCommit": apache_main_commit,
"CurrentCommit": current_commit,
"CurrentDirty": current_dirty,
**metadata,
"MaximumSlowdownPercent": MAX_SLOWDOWN_PERCENT,
"SamplesPerRole": len(SAMPLE_ORDERS),
"SampleOrders": [sample_order_name(order) for order in SAMPLE_ORDERS],
"Passed": not violations,
"Violations": violations,
"Results": combined_results,
}
def print_summary(output: dict) -> None:
print("=== External-Type Equivalence Median Summary ===")
for result in output["Results"]:
print(
f"{result['DataType']}/{result['Operation']}: "
f"apache-main={result['ApacheMainMedian']['AverageNanoseconds']:.1f} ns/op, "
f"ordinary={result['CurrentOrdinaryMedian']['AverageNanoseconds']:.1f} "
f"ns/op ({result['CurrentOrdinarySlowdownPercent']:+.2f}%), "
f"external={result['CurrentExternalMedian']['AverageNanoseconds']:.1f} "
f"ns/op (vs ordinary "
f"{result['CurrentExternalSlowdownPercent']:+.2f}%, vs apache-main "
f"{result['ExternalVsApacheMainSlowdownPercent']:+.2f}%), "
f"allocated="
f"{result['ApacheMainAllocatedBytesPerOperation']:.2f}/"
f"{result['CurrentOrdinaryAllocatedBytesPerOperation']:.2f}/"
f"{result['CurrentExternalAllocatedBytesPerOperation']:.2f} B/op"
)
def main(argv=None) -> int:
args = parse_args(argv)
lanes = args.lane if args.lane is not None else list(LANES)
if len(lanes) != len(set(lanes)):
raise ValueError("--lane may specify each lane only once")
specs = parse_sample_specs(args.sample, lanes)
metadata, results = load_samples(specs, lanes)
output = merge_results(
metadata,
results,
lanes,
require_commit(args.apache_main_commit, "--apache-main-commit"),
require_commit(args.current_commit, "--current-commit"),
args.current_dirty == "true",
)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as destination:
json.dump(output, destination, indent=2)
destination.write("\n")
print_summary(output)
if not output["Passed"]:
for violation in output["Violations"]:
print(f"regression: {violation}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"error: {error}", file=sys.stderr)
sys.exit(1)