Skip to content

Commit b81d03b

Browse files
committed
ci: update workflows
1 parent 4b52dc7 commit b81d03b

File tree

10 files changed

+972
-0
lines changed

10 files changed

+972
-0
lines changed
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import pathlib, re, sys
2+
3+
try:
4+
p = pathlib.Path("comparison.md")
5+
if not p.exists():
6+
print("comparison.md not found, skipping post-processing.")
7+
sys.exit(0)
8+
9+
lines = p.read_text(encoding="utf-8").splitlines()
10+
processed_lines = []
11+
in_code = False
12+
13+
def strip_worker_suffix(text: str) -> str:
14+
# Also strip "Benchmark" prefix if it somehow sneaked in
15+
text = re.sub(r"^Benchmark", "", text)
16+
return re.sub(r"(\S+?)-\d+(\s|$)", r"\1\2", text)
17+
18+
def get_icon(diff_val: float) -> str:
19+
if diff_val > 10:
20+
return "🐌"
21+
if diff_val < -10:
22+
return "🚀"
23+
return "➡️"
24+
25+
def clean_superscripts(text: str) -> str:
26+
return re.sub(r"[¹²³⁴⁵⁶⁷⁸⁹⁰]", "", text)
27+
28+
def parse_val(token: str):
29+
if "%" in token or "=" in token:
30+
return None
31+
token = clean_superscripts(token)
32+
token = token.split("±")[0].strip()
33+
token = token.split("(")[0].strip()
34+
if not token:
35+
return None
36+
37+
m = re.match(r"^([-+]?\d*\.?\d+)([a-zA-Zµ]+)?$", token)
38+
if not m:
39+
return None
40+
try:
41+
val = float(m.group(1))
42+
except ValueError:
43+
return None
44+
suffix = (m.group(2) or "").replace("µ", "u")
45+
46+
if suffix in ["n", "ns"]: return val * 1e-9
47+
if suffix in ["u", "us"]: return val * 1e-6
48+
if suffix in ["m", "ms"]: return val * 1e-3
49+
if suffix == "s": return val
50+
if suffix == "": return val # Handle unitless numbers (e.g. counts) if any
51+
52+
# If we reach here, it's an unexpected unit
53+
raise ValueError(f"Unexpected unit: {suffix}")
54+
55+
def extract_two_numbers(tokens):
56+
found = []
57+
for t in tokens[1:]: # skip name
58+
if t in {"±", "∞", "~", "│", "|"}:
59+
continue
60+
if "%" in t or "=" in t:
61+
continue
62+
# Skip p=... n=...
63+
if t.startswith("p=") or t.startswith("n="):
64+
continue
65+
66+
val = parse_val(t)
67+
if val is not None:
68+
found.append(val)
69+
if len(found) == 2:
70+
break
71+
return found
72+
73+
# Pass 0: Calculate widths
74+
max_content_width = 0
75+
76+
for line in lines:
77+
if line.strip().startswith("```"):
78+
in_code = not in_code
79+
continue
80+
if not in_code:
81+
continue
82+
83+
if re.match(r"^\s*[¹²³⁴⁵⁶⁷⁸⁹⁰]", line) or re.search(r"need\s*>?=\s*\d+\s+samples", line):
84+
continue
85+
if not line.strip() or line.strip().startswith(("goos:", "goarch:", "pkg:", "cpu:")):
86+
continue
87+
if "│" in line and ("vs base" in line or "old" in line or "new" in line):
88+
continue
89+
90+
curr_line = strip_worker_suffix(line).rstrip()
91+
w = len(curr_line)
92+
if w > max_content_width:
93+
max_content_width = w
94+
95+
diff_col_start = max_content_width - 13
96+
# User asked to shift first column by 8 chars.
97+
# This means the whole table shifts right? Or just the text inside?
98+
# I'll prepend 8 spaces to the final output line.
99+
INDENT = " "
100+
101+
for line in lines:
102+
if line.strip().startswith("```"):
103+
in_code = not in_code
104+
processed_lines.append(line)
105+
continue
106+
107+
if not in_code:
108+
processed_lines.append(line)
109+
continue
110+
111+
# Footnotes
112+
if re.match(r"^\s*[¹²³⁴⁵⁶⁷⁸⁹⁰]", line) or re.search(r"need\s*>?=\s*\d+\s+samples", line):
113+
processed_lines.append(INDENT + line)
114+
continue
115+
116+
# Header
117+
if "│" in line and ("vs base" in line or "old" in line or "new" in line):
118+
stripped_header = line.rstrip().rstrip("│").rstrip()
119+
stripped_header = re.sub(r"\s+Diff\s*$", "", stripped_header, flags=re.IGNORECASE)
120+
121+
if len(stripped_header) < diff_col_start:
122+
new_header = stripped_header + " " * (diff_col_start - len(stripped_header))
123+
else:
124+
new_header = stripped_header + " "
125+
126+
if "vs base" in line:
127+
new_header += "Diff"
128+
129+
new_header += "│"
130+
processed_lines.append(INDENT + new_header)
131+
continue
132+
133+
# Metadata
134+
if not line.strip() or line.strip().startswith(("goos:", "goarch:", "pkg:", "cpu:")):
135+
processed_lines.append(INDENT + line)
136+
continue
137+
138+
# Data Lines
139+
line = strip_worker_suffix(line)
140+
tokens = line.split()
141+
if not tokens:
142+
processed_lines.append(INDENT + line)
143+
continue
144+
145+
numbers = extract_two_numbers(tokens)
146+
147+
def append_aligned(left_part, content):
148+
if len(left_part) < diff_col_start:
149+
aligned = left_part + " " * (diff_col_start - len(left_part))
150+
else:
151+
aligned = left_part + " "
152+
return f"{INDENT}{aligned}{content}"
153+
154+
# Geomean
155+
156+
if len(numbers) == 2 and numbers[0] != 0:
157+
diff_val = (numbers[1] - numbers[0]) / numbers[0] * 100
158+
icon = get_icon(diff_val)
159+
left = line.rstrip()
160+
processed_lines.append(append_aligned(left, f"{diff_val:+.2f}% {icon}"))
161+
continue
162+
163+
processed_lines.append(INDENT + line)
164+
165+
p.write_text("\n".join(processed_lines) + "\n", encoding="utf-8")
166+
167+
except Exception as e:
168+
print(f"Error post-processing comparison.md: {e}")
169+
sys.exit(1)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import json
2+
import sys
3+
import math
4+
import re
5+
import platform
6+
import subprocess
7+
8+
# Force UTF-8 output
9+
sys.stdout.reconfigure(encoding="utf-8")
10+
11+
def load_json(path):
12+
try:
13+
with open(path, "r", encoding="utf-8") as f:
14+
return json.load(f)
15+
except Exception as e:
16+
print(f"Error loading {path}: {e}", file=sys.stderr)
17+
return None
18+
19+
def format_val(val):
20+
if val is None:
21+
return "N/A"
22+
# val is in ns
23+
if val < 1e3:
24+
return f"{val:.2f}n" # ns
25+
if val < 1e6:
26+
return f"{val/1e3:.2f}µ" # us
27+
if val < 1e9:
28+
return f"{val/1e6:.2f}m" # ms
29+
return f"{val/1e9:.2f}s" # s
30+
31+
def main():
32+
if len(sys.argv) < 3:
33+
print("Usage: python benchstat_simulator.py base.json pr.json")
34+
sys.exit(1)
35+
36+
base_data = load_json(sys.argv[1])
37+
pr_data = load_json(sys.argv[2])
38+
39+
if not base_data or not pr_data:
40+
sys.exit(1)
41+
42+
base_map = {b["name"]: b["value"] for b in base_data.get("benches", [])}
43+
pr_map = {b["name"]: b["value"] for b in pr_data.get("benches", [])}
44+
45+
all_names = sorted(set(base_map.keys()) | set(pr_map.keys()))
46+
47+
print("Comparison:")
48+
print("```")
49+
print("goos: linux")
50+
print("goarch: amd64")
51+
print("pkg: github.com/casbin/node-casbin")
52+
53+
cpu_info = "GitHub Actions Runner"
54+
try:
55+
if platform.system() == "Windows":
56+
cpu_info = platform.processor()
57+
elif platform.system() == "Linux":
58+
try:
59+
# Try lscpu first
60+
command = "lscpu"
61+
output = subprocess.check_output(command, shell=True).decode()
62+
for line in output.splitlines():
63+
if "Model name" in line:
64+
cpu_info = line.split(":")[1].strip()
65+
break
66+
except:
67+
# Fallback to /proc/cpuinfo
68+
with open("/proc/cpuinfo", "r") as f:
69+
for line in f:
70+
if "model name" in line:
71+
cpu_info = line.split(":")[1].strip()
72+
break
73+
except:
74+
pass
75+
print(f"cpu: {cpu_info}")
76+
77+
# Reduced padding: 52 instead of 50
78+
# Header
79+
print(f"{'':<52}{'base':<19}{'pr':<19} │")
80+
print(f"{'':<52}│ sec/op │ sec/op vs base │ Diff")
81+
82+
base_values = []
83+
pr_values = []
84+
85+
for name in all_names:
86+
base_val = base_map.get(name, 0)
87+
pr_val = pr_map.get(name, 0)
88+
89+
if base_val > 0: base_values.append(base_val)
90+
if pr_val > 0: pr_values.append(pr_val)
91+
92+
def format_cell(val):
93+
if val == 0: return "N/A"
94+
return f"{format_val(val)} ± ∞ ¹"
95+
96+
base_str = format_cell(base_val)
97+
pr_str = format_cell(pr_val)
98+
99+
comp_str = ""
100+
if base_val > 0 and pr_val > 0:
101+
comp_str = "~ (p=1.000 n=1) ²"
102+
103+
print(f"{name:<52}{base_str:<22}{pr_str:<22}{comp_str}")
104+
105+
if base_values and pr_values:
106+
def calc_geo(vals):
107+
return math.exp(sum(math.log(x) for x in vals) / len(vals))
108+
g_base = calc_geo(base_values)
109+
g_pr = calc_geo(pr_values)
110+
print(f"{'geomean':<52}{format_val(g_base):<22}{format_val(g_pr):<22}")
111+
112+
print("¹ need >= 6 samples for confidence interval at level 0.95")
113+
print("² all samples are equal")
114+
print("³ need >= 4 samples to detect a difference at alpha level 0.05")
115+
print("⁴ summaries must be >0 to compute geomean")
116+
print("```")
117+
118+
if __name__ == "__main__":
119+
main()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/* eslint-disable @typescript-eslint/no-var-requires */
2+
module.exports = async ({ github, context, core }) => {
3+
try {
4+
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
5+
owner: context.repo.owner,
6+
repo: context.repo.repo,
7+
run_id: context.payload.workflow_run.id,
8+
});
9+
10+
const matchArtifact = artifacts.data.artifacts.find((artifact) => {
11+
return artifact.name === 'benchmark-results';
12+
});
13+
14+
if (!matchArtifact) {
15+
core.setFailed("No artifact named 'benchmark-results' found.");
16+
return;
17+
}
18+
19+
const download = await github.rest.actions.downloadArtifact({
20+
owner: context.repo.owner,
21+
repo: context.repo.repo,
22+
artifact_id: matchArtifact.id,
23+
archive_format: 'zip',
24+
});
25+
26+
const fs = require('fs');
27+
const path = require('path');
28+
const workspace = process.env.GITHUB_WORKSPACE;
29+
fs.writeFileSync(path.join(workspace, 'benchmark-results.zip'), Buffer.from(download.data));
30+
} catch (error) {
31+
core.setFailed(`Failed to download artifact: ${error.message}`);
32+
}
33+
};

0 commit comments

Comments
 (0)