blob: 9de1675f4e20a7d90861f0f889585b230c8a89ff [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.
"""Generate the Java JSON benchmark chart and Markdown report from JMH JSON."""
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.ticker import FuncFormatter
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from plot_style import (
BAR_EDGE_COLOR,
apply_benchmark_style,
format_markdown_with_prettier,
format_throughput_label,
format_throughput_tick,
save_benchmark_figure,
style_throughput_axis,
)
apply_benchmark_style(plt)
DEFAULT_LIBS = "fory-json,jackson,gson"
LIB_ALIASES = {
"fory": "fory",
"fory-json": "fory",
"jackson": "jackson",
"gson": "gson",
"fastjson2": "fastjson2",
}
SERIALIZER_LABELS = {
"fory": "fory-json",
"jackson": "jackson",
"gson": "gson",
"fastjson2": "fastjson2",
}
COLORS = {
"fory": "#FF6F01",
"jackson": "#55BCC2",
"gson": "#8C6D8A",
"fastjson2": (0.90, 0.43, 0.5),
}
OPERATIONS = ("to", "from")
REPRESENTATIONS = ("string", "bytes")
CASE_LABELS = {
"to": "Serialize",
"from": "Deserialize",
}
BENCHMARK_PATTERN = re.compile(
r"(?:^|[.])(?P<serializer>fory|jackson|gson|fastjson2)"
r"(?P<operation>To|From)Json(?P<representation>Bytes|String)$"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate a Java JSON benchmark chart and Markdown report"
)
parser.add_argument(
"--json-file",
default="reports/json/benchmark_results.json",
help="Raw JMH JSON result file",
)
parser.add_argument(
"--output-dir",
default="reports/json",
help="Directory for throughput.png and README.md",
)
parser.add_argument(
"--libs",
default=DEFAULT_LIBS,
help="Comma-separated libraries in bar and table order",
)
parser.add_argument(
"--print-lib-config",
action="store_true",
help=argparse.SUPPRESS,
)
return parser.parse_args()
def parse_libs(value: str) -> tuple[str, ...]:
requested = [name.strip().lower() for name in value.split(",")]
if not requested or any(not name for name in requested):
raise ValueError("--libs requires one or more comma-separated library names")
serializers = []
for name in requested:
serializer = LIB_ALIASES.get(name)
if serializer is None:
choices = "fory-json, jackson, gson, fastjson2"
raise ValueError(f"Unknown JSON library: {name}. Available: {choices}")
if serializer in serializers:
raise ValueError(f"Duplicate JSON library: {SERIALIZER_LABELS[serializer]}")
serializers.append(serializer)
return tuple(serializers)
def load_json(path: Path) -> list[dict[str, Any]]:
with path.open("r", encoding="utf-8") as source:
payload = json.load(source)
benchmarks = payload if isinstance(payload, list) else payload.get("benchmarks", [])
if not isinstance(benchmarks, list):
raise TypeError(f"Expected a JMH benchmark list in {path}")
return benchmarks
def ops_per_second(value: float, unit: str) -> float:
multipliers = {
"ops/s": 1,
"ops/ms": 1_000,
"ops/us": 1_000_000,
"ops/ns": 1_000_000_000,
}
if unit not in multipliers:
raise ValueError(f"Unsupported JMH throughput unit: {unit}")
return value * multipliers[unit]
def collect_results(
benchmarks: list[dict[str, Any]],
serializers: tuple[str, ...],
) -> dict[tuple[str, str], dict[str, tuple[float, float]]]:
results: dict[tuple[str, str], dict[str, tuple[float, float]]] = {
(operation, representation): {}
for representation in REPRESENTATIONS
for operation in OPERATIONS
}
for benchmark in benchmarks:
name = benchmark.get("benchmark", "")
match = BENCHMARK_PATTERN.search(name)
if match is None:
continue
serializer = match.group("serializer")
case = (
match.group("operation").lower(),
match.group("representation").lower(),
)
metric = benchmark.get("primaryMetric", {})
unit = metric.get("scoreUnit", "ops/s")
score = ops_per_second(float(metric["score"]), unit)
error = ops_per_second(float(metric.get("scoreError", 0.0)), unit)
if not math.isfinite(error):
error = 0.0
results[case][serializer] = (score, error)
missing = [
f"{serializer}{operation.title()}Json{representation.title()}"
for representation in REPRESENTATIONS
for operation in OPERATIONS
for serializer in serializers
if serializer not in results[(operation, representation)]
]
if missing:
raise ValueError("Missing JMH benchmark results: " + ", ".join(missing))
return results
def render_plot(
results: dict[tuple[str, str], dict[str, tuple[float, float]]],
serializers: tuple[str, ...],
representation: str,
output: Path,
) -> None:
figure, axes = plt.subplots(1, 2, figsize=(11.5, 5.2))
x = np.arange(len(serializers), dtype=float)
for axis, operation in zip(axes, OPERATIONS):
case = (operation, representation)
values = [results[case][serializer][0] for serializer in serializers]
errors = [results[case][serializer][1] for serializer in serializers]
bars = axis.bar(
x,
values,
width=0.62,
yerr=errors,
capsize=2.5,
color=[COLORS[serializer] for serializer in serializers],
edgecolor=BAR_EDGE_COLOR,
linewidth=0.8,
)
axis.bar_label(
bars,
labels=[format_throughput_label(value) for value in values],
padding=3,
fontsize=8,
)
highest = max(value + error for value, error in zip(values, errors))
axis.set_ylim(0, highest * 1.18)
axis.set_xticks(x)
axis.set_xticklabels([SERIALIZER_LABELS[name] for name in serializers])
axis.set_title(CASE_LABELS[operation], pad=10)
axis.yaxis.set_major_formatter(FuncFormatter(format_throughput_tick))
style_throughput_axis(axis)
axes[0].set_ylabel("Throughput (ops/sec)")
representation_title = "String" if representation == "string" else "UTF-8 Bytes"
figure.suptitle(
f"Java JSON {representation_title} Serialization and Deserialization Throughput",
y=0.98,
)
figure.tight_layout(rect=[0, 0, 1, 0.95], w_pad=2.4)
save_benchmark_figure(figure, output)
plt.close(figure)
def format_score(value: float) -> str:
return f"{value:,.0f}"
def benchmark_metadata(benchmarks: list[dict[str, Any]]) -> list[str]:
if not benchmarks:
return []
first = benchmarks[0]
items = []
if first.get("jdkVersion"):
items.append(f"JDK: `{first['jdkVersion']}`")
if first.get("vmName"):
items.append(f"VM: `{first['vmName']}`")
if first.get("jmhVersion"):
items.append(f"JMH: `{first['jmhVersion']}`")
if first.get("warmupIterations") is not None and first.get("warmupTime"):
items.append(
f"Warmup: {first['warmupIterations']} iterations × `{first['warmupTime']}`"
)
if first.get("measurementIterations") is not None and first.get("measurementTime"):
items.append(
"Measurement: "
f"{first['measurementIterations']} iterations × `{first['measurementTime']}`"
)
if first.get("forks") is not None and first.get("threads") is not None:
items.append(f"Forks: {first['forks']}; threads: {first['threads']}")
items.append("Mode: throughput; higher is better")
return items
def render_report(
benchmarks: list[dict[str, Any]],
results: dict[tuple[str, str], dict[str, tuple[float, float]]],
serializers: tuple[str, ...],
output: Path,
) -> None:
library_names = ", ".join(SERIALIZER_LABELS[name] for name in serializers)
lines = [
"# Java JSON Benchmark Report\n\n",
f"The benchmark compares {library_names} using the same data. "
"The String group excludes UTF-8 conversion. The UTF-8 bytes group uses "
"direct byte-array APIs when available. "
+ (
"Gson includes String-to-UTF-8 encoding and UTF-8-to-String decoding.\n\n"
if "gson" in serializers
else "\n\n"
),
"```bash\n",
"cd benchmarks/java\n",
"./run_json.sh --libs "
+ ",".join(SERIALIZER_LABELS[name] for name in serializers)
+ "\n",
"```\n\n",
]
metadata = benchmark_metadata(benchmarks)
if metadata:
lines.extend(f"- {item}\n" for item in metadata)
lines.append("\n")
lines.extend(
[
"## String\n\n",
"![Java JSON String benchmark throughput](string_throughput.png)\n\n",
"## UTF-8 Bytes\n\n",
"![Java JSON UTF-8 bytes benchmark throughput](utf8_bytes_throughput.png)\n\n",
"## Results\n\n",
"| Representation | Operation | "
+ " | ".join(f"{SERIALIZER_LABELS[name]} ops/sec" for name in serializers)
+ " | Fastest |\n",
"| --- | --- | " + " | ".join("---:" for _ in serializers) + " | --- |\n",
]
)
for representation in REPRESENTATIONS:
for operation in OPERATIONS:
values = results[(operation, representation)]
fastest = max(serializers, key=lambda serializer: values[serializer][0])
lines.append(
f"| {'String' if representation == 'string' else 'UTF-8 bytes'} | "
f"{'Serialize' if operation == 'to' else 'Deserialize'} | "
+ " | ".join(
format_score(values[serializer][0]) for serializer in serializers
)
+ f" | {SERIALIZER_LABELS[fastest]} |\n"
)
output.write_text("".join(lines), encoding="utf-8")
format_markdown_with_prettier(output)
def main() -> None:
args = parse_args()
try:
serializers = parse_libs(args.libs)
except ValueError as error:
raise SystemExit(str(error)) from error
if args.print_lib_config:
labels = ",".join(SERIALIZER_LABELS[name] for name in serializers)
print(labels + "\t" + "|".join(serializers))
return
json_path = Path(args.json_file)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
benchmarks = load_json(json_path)
results = collect_results(benchmarks, serializers)
string_chart_path = output_dir / "string_throughput.png"
bytes_chart_path = output_dir / "utf8_bytes_throughput.png"
report_path = output_dir / "README.md"
render_plot(results, serializers, "string", string_chart_path)
render_plot(results, serializers, "bytes", bytes_chart_path)
render_report(benchmarks, results, serializers, report_path)
print(f"Generated {string_chart_path}")
print(f"Generated {bytes_chart_path}")
print(f"Generated {report_path}")
if __name__ == "__main__":
main()