Add negative_cases_from_universe to expand an explicit file-by-rule universe into labeled negatives (enabling TP/FP/TN/FN, not just TP/FP), plus project-eval harness updates in run_project_eval and the score/score_fp/quality-gate helpers.
761 lines
29 KiB
Python
Executable file
761 lines
29 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Micro-benchmarks for fold speed/FP optimization work.
|
|
|
|
This harness generates tiny synthetic C++ projects that stress the specific
|
|
surfaces identified in docs/internal/perf-audit.md, runs fold with --profile, and writes a
|
|
stable JSON/Markdown summary. It is intended for A/B validation before and after
|
|
an optimization patch, not for broad corpus scoring.
|
|
|
|
Examples:
|
|
python3 bench/run_micro_opt.py --case all --repeats 3
|
|
python3 bench/run_micro_opt.py --case branch-merge --size 200 --repeats 5
|
|
python3 bench/run_micro_opt.py --fold build/release/src/cli/fold --out-dir /tmp/fold-micro
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import statistics
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_FOLD = ROOT / "build" / "release" / "src" / "cli" / "fold"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CaseSpec:
|
|
name: str
|
|
detectors: list[str]
|
|
description: str
|
|
size_default: int
|
|
expect_actionable: bool = True
|
|
extra_args: tuple[str, ...] = ()
|
|
uses_config_profile: bool = False
|
|
|
|
|
|
CASES: dict[str, CaseSpec] = {
|
|
"branch-merge": CaseSpec(
|
|
name="branch-merge",
|
|
detectors=["null-deref"],
|
|
size_default=160,
|
|
description=(
|
|
"Stresses SecurityWalker::walkIf and SymbolicEnv branch merge cost. "
|
|
"A dirty-binding optimization should reduce null-deref wall_ms per state merge "
|
|
"without changing findings."
|
|
),
|
|
),
|
|
"defuse-null": CaseSpec(
|
|
name="defuse-null",
|
|
detectors=["null-deref"],
|
|
size_default=180,
|
|
description=(
|
|
"Stresses NullDerefObserver::shouldQuery and DefUseChains::reachingDefs. "
|
|
"A direct (var, use_loc) lookup should reduce wall_ms without changing "
|
|
"query/finding counts."
|
|
),
|
|
),
|
|
"cross-tu-prime": CaseSpec(
|
|
name="cross-tu-prime",
|
|
detectors=["null-deref", "div-by-zero"],
|
|
size_default=48,
|
|
description=(
|
|
"Stresses global and local cross-TU property priming. A skip/dedup of redundant "
|
|
"per-TU priming should reduce serial_dispatch_ms / total wall while preserving "
|
|
"cross-TU callsite findings."
|
|
),
|
|
),
|
|
"precision-fp": CaseSpec(
|
|
name="precision-fp",
|
|
detectors=["null-deref", "div-by-zero", "int-overflow"],
|
|
size_default=80,
|
|
description=(
|
|
"Tracks confidence distribution for mixed exact, heuristic, guarded, and "
|
|
"over-approximate-looking security findings. A precision-evidence change should "
|
|
"demote imprecise SATs without changing exact findings."
|
|
),
|
|
extra_args=("--nullable-allocations",),
|
|
),
|
|
"expr-cache": CaseSpec(
|
|
name="expr-cache",
|
|
detectors=["div-by-zero", "int-overflow"],
|
|
size_default=220,
|
|
description=(
|
|
"Stresses repeated equivalent solver queries and hash/cache overhead. ExprCache "
|
|
"changes should reduce detector wall/z3 query overhead without changing findings."
|
|
),
|
|
),
|
|
"overflow-cache": CaseSpec(
|
|
name="overflow-cache",
|
|
detectors=["int-overflow"],
|
|
size_default=120,
|
|
description=(
|
|
"Isolates repeated integer-overflow queries in one symbolic function. Abstract "
|
|
"range or dominance reuse should reduce solver queries, conflicts, wall time, and "
|
|
"peak RSS without changing findings."
|
|
),
|
|
),
|
|
"curl-str-bound": CaseSpec(
|
|
name="curl-str-bound",
|
|
detectors=["oob"],
|
|
size_default=128,
|
|
expect_actionable=False,
|
|
description=(
|
|
"Regression gate for Curl_str parser bounds. curlx_str_* records an output length "
|
|
"upper bound, and curlx_strlen(&out) used as the index into a max+1 stack buffer "
|
|
"must not produce an OOB false positive."
|
|
),
|
|
),
|
|
"control-nesting": CaseSpec(
|
|
name="control-nesting",
|
|
detectors=["null-deref"],
|
|
size_default=80,
|
|
description=(
|
|
"Stresses nested control-flow recursion and the sink precheck counters. "
|
|
"Precision-preserving walker optimizations should reduce control_walk_ms or "
|
|
"functions_prechecked_safe without changing findings."
|
|
),
|
|
),
|
|
"clone-t3": CaseSpec(
|
|
name="clone-t3",
|
|
detectors=["clone"],
|
|
size_default=160,
|
|
description=(
|
|
"Stresses Type-3 clone scoring: many same-shape functions with literal "
|
|
"edits avoid exact/rename buckets, then form a large literal-insensitive "
|
|
"bucket. The baseline scalar DP still pays full edit-distance cost on the "
|
|
"normalized bodies; bit-parallel distance should reduce clone wall_ms "
|
|
"without changing finding counts or scores."
|
|
),
|
|
extra_args=("--clone-type", "3", "--clone-similarity", "0.70"),
|
|
),
|
|
"call-graph": CaseSpec(
|
|
name="call-graph",
|
|
detectors=["production-reachability"],
|
|
size_default=1200,
|
|
expect_actionable=False,
|
|
description=(
|
|
"Stresses cold incremental call-graph contribution collection, merge/SCC work, "
|
|
"index persistence, and graph storage overhead. Graph representation changes "
|
|
"should reduce graph subphase time or peak RSS without changing node/edge counts."
|
|
),
|
|
extra_args=("--incremental",),
|
|
),
|
|
"guarded-call-graph": CaseSpec(
|
|
name="guarded-call-graph",
|
|
detectors=["production-reachability"],
|
|
size_default=1200,
|
|
description=(
|
|
"Stresses guarded production reachability with one profile-controlled frontier "
|
|
"followed by a long unconditional chain. Equivalent downstream reachability proofs "
|
|
"should be reused without changing classifications or witnesses."
|
|
),
|
|
extra_args=(
|
|
"--incremental",
|
|
"--budget-seconds",
|
|
"60",
|
|
"--per-query-ms",
|
|
"2000",
|
|
),
|
|
uses_config_profile=True,
|
|
),
|
|
"guarded-call-graph-active": CaseSpec(
|
|
name="guarded-call-graph-active",
|
|
detectors=["production-reachability"],
|
|
size_default=1200,
|
|
expect_actionable=False,
|
|
description=(
|
|
"Uses the guarded-call-graph shape with its frontier enabled. Comparing this "
|
|
"zero-finding lane against the inactive lane attributes retained report and "
|
|
"witness memory separately from graph and solver memory."
|
|
),
|
|
extra_args=(
|
|
"--incremental",
|
|
"--budget-seconds",
|
|
"60",
|
|
"--per-query-ms",
|
|
"2000",
|
|
),
|
|
uses_config_profile=True,
|
|
),
|
|
"guarded-call-graph-infeasible": CaseSpec(
|
|
name="guarded-call-graph-infeasible",
|
|
detectors=["production-reachability"],
|
|
size_default=1200,
|
|
description=(
|
|
"Uses a contradictory guarded frontier so all downstream findings are infeasible "
|
|
"and carry no SAT witness. Comparing it with the inactive lane isolates witness "
|
|
"retention from general finding/report memory."
|
|
),
|
|
extra_args=(
|
|
"--incremental",
|
|
"--budget-seconds",
|
|
"60",
|
|
"--per-query-ms",
|
|
"2000",
|
|
),
|
|
uses_config_profile=True,
|
|
),
|
|
}
|
|
|
|
|
|
def write_compile_db(project: Path, files: list[Path], std: str = "c++20") -> None:
|
|
commands = []
|
|
for src in files:
|
|
commands.append(
|
|
{
|
|
"directory": str(project),
|
|
"command": f"clang++ -std={std} -c {src.name}",
|
|
"file": str(src),
|
|
}
|
|
)
|
|
(project / "compile_commands.json").write_text(json.dumps(commands, indent=2) + "\n")
|
|
|
|
|
|
def gen_branch_merge(project: Path, size: int) -> None:
|
|
lines = ["int sink(int);", "int f(int x, int* p) {", " int acc = 0;"]
|
|
for i in range(size):
|
|
lines.append(f" int v{i} = x + {i};")
|
|
for i in range(size):
|
|
lines.append(f" if ((x + {i}) & 1) {{")
|
|
lines.append(f" v{i} = v{i} + x;")
|
|
lines.append(" } else {")
|
|
lines.append(f" v{i} = v{i} - x;")
|
|
lines.append(" }")
|
|
lines.append(f" acc += v{i};")
|
|
lines.append(" int* q = nullptr;")
|
|
lines.append(" return acc + *q;")
|
|
lines.append("}")
|
|
src = project / "branch_merge.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_defuse_null(project: Path, size: int) -> None:
|
|
lines = ["int g = 1;", "int f(int selector) {", " int acc = 0;"]
|
|
for i in range(size):
|
|
lines.append(f" int* p{i} = nullptr;")
|
|
# Make half definitely safe after a nullable def. The final deref still
|
|
# makes shouldQuery inspect reaching defs for many uses.
|
|
if i % 2:
|
|
lines.append(f" p{i} = &g;")
|
|
lines.append(f" if (selector == {i}) acc += *p{i};")
|
|
lines.append(" return acc;")
|
|
lines.append("}")
|
|
src = project / "defuse_null.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_cross_tu_prime(project: Path, size: int) -> None:
|
|
files: list[Path] = []
|
|
for i in range(size):
|
|
src = project / f"callee_{i}.cpp"
|
|
if i % 2 == 0:
|
|
src.write_text(f"int null_sink_{i}(int* p) {{ return *p; }}\n")
|
|
else:
|
|
src.write_text(f"int div_sink_{i}(int x) {{ return 100 / x; }}\n")
|
|
files.append(src)
|
|
caller = project / "caller.cpp"
|
|
lines: list[str] = []
|
|
for i in range(size):
|
|
if i % 2 == 0:
|
|
lines.append(f"int null_sink_{i}(int*);")
|
|
else:
|
|
lines.append(f"int div_sink_{i}(int);")
|
|
lines.append("int caller(int x) {")
|
|
lines.append(" int* p = nullptr;")
|
|
lines.append(" int acc = 0;")
|
|
for i in range(size):
|
|
if i % 2 == 0:
|
|
lines.append(f" acc += null_sink_{i}(p);")
|
|
else:
|
|
lines.append(f" acc += div_sink_{i}(x - x);")
|
|
lines.append(" return acc;")
|
|
lines.append("}")
|
|
caller.write_text("\n".join(lines) + "\n")
|
|
files.append(caller)
|
|
write_compile_db(project, files)
|
|
|
|
|
|
def gen_precision_fp(project: Path, size: int) -> None:
|
|
lines = [
|
|
"extern \"C\" void* malloc(unsigned long);",
|
|
"[[noreturn]] extern \"C\" void abort(void);",
|
|
"int global;",
|
|
"int f(int* p, int x) {",
|
|
" int acc = 0;",
|
|
" if (!p) return 0;",
|
|
" acc += *p;", # guard should suppress
|
|
" int* q = nullptr;",
|
|
" acc += *q;", # exact positive
|
|
" int* h = (int*)malloc(sizeof(int));",
|
|
" acc += *h;", # heuristic when nullable allocations enabled
|
|
" if (x == 0) abort();",
|
|
" acc += 10 / x;", # guard should suppress div-by-zero
|
|
]
|
|
for i in range(size):
|
|
lines.append(f" int y{i} = x + {i};")
|
|
lines.append(f" if (y{i} > 0) acc += y{i}; else acc -= y{i};")
|
|
lines.append(" return acc;")
|
|
lines.append("}")
|
|
src = project / "precision_fp.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_expr_cache(project: Path, size: int) -> None:
|
|
lines = ["int f(int x, int y) {", " int acc = 0;"]
|
|
# Repeated same-shape hazards in one function. This tracks local query cache
|
|
# and canonical ExprCache behavior under large repeated predicates.
|
|
for i in range(size):
|
|
lines.append(f" if ((y + {i}) >= 0) acc += 100 / x;")
|
|
lines.append(f" acc += x + {i};")
|
|
lines.append(" return acc;")
|
|
lines.append("}")
|
|
src = project / "expr_cache.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_control_nesting(project: Path, size: int) -> None:
|
|
lines = ["int f(int x, int* p) {", " int acc = 0;"]
|
|
for i in range(size):
|
|
lines.append(f" if ((x + {i}) > 0) {{")
|
|
lines.append(f" for (int j{i} = 0; j{i} < 2; ++j{i}) {{")
|
|
lines.append(f" if ((j{i} + x) & 1) acc += j{i}; else acc -= j{i};")
|
|
lines.append(" }")
|
|
lines.append(" } else {")
|
|
lines.append(f" if (x == {i}) acc += {i};")
|
|
lines.append(" }")
|
|
lines.append(" int* q = nullptr;")
|
|
lines.append(" return acc + *q;")
|
|
lines.append("}")
|
|
src = project / "control_nesting.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_curl_str_bound(project: Path, size: int) -> None:
|
|
lines = [
|
|
"struct Curl_str { const char* str; unsigned long len; };",
|
|
"extern int curlx_str_quotedword(const char**, Curl_str*, unsigned long);",
|
|
"#define curlx_strlen(x) ((x)->len)",
|
|
"int f(const char* line_in) {",
|
|
" const char* line = line_in;",
|
|
" int acc = 0;",
|
|
]
|
|
for i in range(size):
|
|
lines.append(f" Curl_str date{i}{{}};")
|
|
lines.append(f" if(curlx_str_quotedword(&line, &date{i}, 17)) return acc;")
|
|
lines.append(f" char dbuf{i}[18];")
|
|
lines.append(f" dbuf{i}[curlx_strlen(&date{i})] = 0;")
|
|
lines.append(f" acc += dbuf{i}[0];")
|
|
lines.append(" return acc;")
|
|
lines.append("}")
|
|
src = project / "curl_str_bound.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_clone_t3(project: Path, size: int) -> None:
|
|
lines = ["int sink(int);", "namespace clone_t3 {"]
|
|
for i in range(size):
|
|
# Same AST shape across functions, but enough literal changes to avoid
|
|
# Exact/RenameInsensitive buckets and force Type-3 scoring inside the
|
|
# LiteralInsensitive bucket.
|
|
op = "+" if i % 3 else "-"
|
|
scale = 2 + (i % 5)
|
|
lines.append(f"int f_{i}(int x, int y) {{")
|
|
lines.append(" int acc = 0;")
|
|
for j in range(8):
|
|
literal = (i + j) % 11
|
|
lines.append(f" int v{j} = (x {op} {literal}) * {scale};")
|
|
lines.append(f" if ((v{j} + y) & 1) {{")
|
|
lines.append(f" acc += sink(v{j});")
|
|
lines.append(" } else {")
|
|
lines.append(f" acc -= v{j};")
|
|
lines.append(" }")
|
|
lines.append(" return acc;")
|
|
lines.append("}")
|
|
lines.append("}")
|
|
src = project / "clone_t3.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_call_graph(project: Path, size: int) -> None:
|
|
lines = [f"static int node_{i}(int);" for i in range(size)]
|
|
for i in range(size):
|
|
callees = []
|
|
if i + 1 < size:
|
|
callees.append(f"node_{i + 1}(value)")
|
|
if i + 2 < size:
|
|
callees.append(f"node_{i + 2}(value)")
|
|
expression = " + ".join(callees) if callees else "value"
|
|
lines.append(f"static int node_{i}(int value) {{ return {expression}; }}")
|
|
lines.append("int main() { return node_0(1); }")
|
|
src = project / "call_graph.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_guarded_call_graph_case(
|
|
project: Path, size: int, condition: str, profile_value: bool
|
|
) -> None:
|
|
lines = ["bool feature;", *[f"static int node_{i}(int);" for i in range(size)]]
|
|
for i in range(size):
|
|
expression = f"node_{i + 1}(value)" if i + 1 < size else "value"
|
|
lines.append(f"static int node_{i}(int value) {{ return {expression}; }}")
|
|
lines.append(f"int main() {{ return {condition} ? node_0(1) : 0; }}")
|
|
src = project / "guarded_call_graph.cpp"
|
|
src.write_text("\n".join(lines) + "\n")
|
|
(project / "config_profile.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"entrypoints": ["main"],
|
|
"assumptions": [
|
|
{
|
|
"kind": "global",
|
|
"selector": "feature",
|
|
"value": profile_value,
|
|
}
|
|
],
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n"
|
|
)
|
|
write_compile_db(project, [src])
|
|
|
|
|
|
def gen_guarded_call_graph(project: Path, size: int) -> None:
|
|
gen_guarded_call_graph_case(project, size, "feature", False)
|
|
|
|
|
|
def gen_guarded_call_graph_active(project: Path, size: int) -> None:
|
|
gen_guarded_call_graph_case(project, size, "feature", True)
|
|
|
|
|
|
def gen_guarded_call_graph_infeasible(project: Path, size: int) -> None:
|
|
gen_guarded_call_graph_case(project, size, "feature && !feature", False)
|
|
|
|
|
|
GENERATORS = {
|
|
"branch-merge": gen_branch_merge,
|
|
"defuse-null": gen_defuse_null,
|
|
"cross-tu-prime": gen_cross_tu_prime,
|
|
"precision-fp": gen_precision_fp,
|
|
"expr-cache": gen_expr_cache,
|
|
"overflow-cache": gen_expr_cache,
|
|
"curl-str-bound": gen_curl_str_bound,
|
|
"control-nesting": gen_control_nesting,
|
|
"clone-t3": gen_clone_t3,
|
|
"call-graph": gen_call_graph,
|
|
"guarded-call-graph": gen_guarded_call_graph,
|
|
"guarded-call-graph-active": gen_guarded_call_graph_active,
|
|
"guarded-call-graph-infeasible": gen_guarded_call_graph_infeasible,
|
|
}
|
|
|
|
|
|
def find_fold(path: str | None) -> Path:
|
|
if path:
|
|
return Path(path).resolve()
|
|
env = os.environ.get("FOLD_BINARY")
|
|
if env:
|
|
return Path(env).resolve()
|
|
if DEFAULT_FOLD.exists():
|
|
return DEFAULT_FOLD
|
|
found = shutil.which("fold")
|
|
if found:
|
|
return Path(found).resolve()
|
|
raise SystemExit("fold binary not found; pass --fold or set FOLD_BINARY")
|
|
|
|
|
|
def detector_profile(profile: dict[str, Any], name: str) -> dict[str, Any]:
|
|
for det in profile.get("detectors", []):
|
|
if det.get("name") == name:
|
|
return det
|
|
return {}
|
|
|
|
|
|
def sarif_counts(path: Path) -> dict[str, Any]:
|
|
try:
|
|
doc = json.loads(path.read_text())
|
|
except Exception:
|
|
return {"finding_count": 0, "by_rule": {}, "by_confidence": {}, "levels": {}}
|
|
results = doc.get("runs", [{}])[0].get("results", [])
|
|
by_rule: dict[str, int] = {}
|
|
by_conf: dict[str, int] = {}
|
|
levels: dict[str, int] = {}
|
|
for r in results:
|
|
by_rule[r.get("ruleId", "")] = by_rule.get(r.get("ruleId", ""), 0) + 1
|
|
props = r.get("properties", {})
|
|
conf = props.get("confidence", "")
|
|
by_conf[conf] = by_conf.get(conf, 0) + 1
|
|
lvl = r.get("level", "")
|
|
levels[lvl] = levels.get(lvl, 0) + 1
|
|
return {
|
|
"finding_count": len(results),
|
|
"by_rule": dict(sorted(by_rule.items())),
|
|
"by_confidence": dict(sorted(by_conf.items())),
|
|
"levels": dict(sorted(levels.items())),
|
|
}
|
|
|
|
|
|
def run_one(fold: Path, case: str, size: int, repeat_index: int, out_dir: Path, keep_workdir: bool) -> dict[str, Any]:
|
|
spec = CASES[case]
|
|
if keep_workdir:
|
|
project = out_dir / "work" / f"{case}-{size}-r{repeat_index}"
|
|
project.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
project = Path(tempfile.mkdtemp(prefix=f"fold-micro-{case}-"))
|
|
try:
|
|
GENERATORS[case](project, size)
|
|
report = out_dir / f"{case}-{size}-r{repeat_index}.sarif"
|
|
profile_path = out_dir / f"{case}-{size}-r{repeat_index}.profile.json"
|
|
stdout_path = out_dir / f"{case}-{size}-r{repeat_index}.stdout.log"
|
|
stderr_path = out_dir / f"{case}-{size}-r{repeat_index}.stderr.log"
|
|
cmd = [
|
|
str(fold),
|
|
"--quiet",
|
|
"scan",
|
|
"--compile-db",
|
|
str(project / "compile_commands.json"),
|
|
"--processes",
|
|
"1",
|
|
"--profile",
|
|
"--profile-output",
|
|
str(profile_path),
|
|
"--format",
|
|
"json",
|
|
"--output",
|
|
str(report),
|
|
]
|
|
for d in spec.detectors:
|
|
cmd += ["--detectors", d]
|
|
if spec.uses_config_profile:
|
|
cmd += ["--config-profile", str(project / "config_profile.json")]
|
|
cmd += list(spec.extra_args)
|
|
t0 = time.perf_counter()
|
|
proc = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True)
|
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
|
stdout_path.write_text(proc.stdout)
|
|
stderr_path.write_text(proc.stderr)
|
|
if proc.returncode not in (0, 1):
|
|
raise RuntimeError(
|
|
f"{case} failed with rc={proc.returncode}; see {stderr_path}\ncmd: {' '.join(cmd)}"
|
|
)
|
|
profile = json.loads(profile_path.read_text())
|
|
scan = profile.get("scan", {})
|
|
findings = sarif_counts(report)
|
|
detectors = {d: detector_profile(profile, d) for d in spec.detectors}
|
|
return {
|
|
"case": case,
|
|
"size": size,
|
|
"repeat": repeat_index,
|
|
"returncode": proc.returncode,
|
|
"elapsed_ms_external": elapsed_ms,
|
|
"scan": scan,
|
|
"detectors": detectors,
|
|
"findings": findings,
|
|
"artifacts": {
|
|
"project": str(project),
|
|
"report": str(report),
|
|
"profile": str(profile_path),
|
|
"stdout": str(stdout_path),
|
|
"stderr": str(stderr_path),
|
|
},
|
|
}
|
|
finally:
|
|
if not keep_workdir:
|
|
shutil.rmtree(project, ignore_errors=True)
|
|
|
|
|
|
def summarize_case(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
def nums(path: tuple[str, ...]) -> list[float]:
|
|
out = []
|
|
for r in rows:
|
|
cur: Any = r
|
|
ok = True
|
|
for p in path:
|
|
if not isinstance(cur, dict) or p not in cur:
|
|
ok = False
|
|
break
|
|
cur = cur[p]
|
|
if ok and isinstance(cur, (int, float)):
|
|
out.append(float(cur))
|
|
return out
|
|
|
|
def stat(values: list[float]) -> dict[str, float | int | None]:
|
|
if not values:
|
|
return {"n": 0, "min": None, "median": None, "mean": None, "max": None}
|
|
return {
|
|
"n": len(values),
|
|
"min": min(values),
|
|
"median": statistics.median(values),
|
|
"mean": statistics.mean(values),
|
|
"max": max(values),
|
|
}
|
|
|
|
case = rows[0]["case"] if rows else ""
|
|
detector_stats: dict[str, Any] = {}
|
|
for det in CASES[case].detectors:
|
|
detector_stats[det] = {
|
|
"wall_ms": stat(nums(("detectors", det, "wall_ms"))),
|
|
"z3_queries": stat(nums(("detectors", det, "z3_queries"))),
|
|
"z3_timeouts": stat(nums(("detectors", det, "z3_timeouts"))),
|
|
"z3_elapsed_ms": stat(nums(("detectors", det, "z3_elapsed_ms"))),
|
|
"findings_emitted": stat(nums(("detectors", det, "findings_emitted"))),
|
|
"states_merged": stat(nums(("detectors", det, "states_merged"))),
|
|
"states_kept_separate": stat(nums(("detectors", det, "states_kept_separate"))),
|
|
"walker_ms": stat(nums(("detectors", det, "walker_ms"))),
|
|
"control_walk_ms": stat(nums(("detectors", det, "control_walk_ms"))),
|
|
"condition_fact_ms": stat(nums(("detectors", det, "condition_fact_ms"))),
|
|
"branch_feasibility_checks": stat(
|
|
nums(("detectors", det, "branch_feasibility_checks"))
|
|
),
|
|
"branch_feasibility_fast_paths": stat(
|
|
nums(("detectors", det, "branch_feasibility_fast_paths"))
|
|
),
|
|
"env_clone_ms": stat(nums(("detectors", det, "env_clone_ms"))),
|
|
"env_clone_count": stat(nums(("detectors", det, "env_clone_count"))),
|
|
"encode_ms": stat(nums(("detectors", det, "encode_ms"))),
|
|
"hook_ms": stat(nums(("detectors", det, "hook_ms"))),
|
|
"state_merge_ms": stat(nums(("detectors", det, "state_merge_ms"))),
|
|
"stmt_count": stat(nums(("detectors", det, "stmt_count"))),
|
|
"control_stmt_count": stat(nums(("detectors", det, "control_stmt_count"))),
|
|
"candidate_sink_count": stat(nums(("detectors", det, "candidate_sink_count"))),
|
|
"functions_prechecked_safe": stat(nums(("detectors", det, "functions_prechecked_safe"))),
|
|
"expr_cache_hits": stat(nums(("detectors", det, "expr_cache_hits"))),
|
|
"expr_cache_misses": stat(nums(("detectors", det, "expr_cache_misses"))),
|
|
}
|
|
return {
|
|
"case": case,
|
|
"size": rows[0]["size"] if rows else None,
|
|
"description": CASES[case].description if case in CASES else "",
|
|
"elapsed_ms_external": stat(nums(("elapsed_ms_external",))),
|
|
"total_wall_ms": stat(nums(("scan", "total_wall_ms"))),
|
|
"serial_dispatch_ms": stat(nums(("scan", "serial_dispatch_ms"))),
|
|
"call_graph_ms": stat(nums(("scan", "call_graph_ms"))),
|
|
"project_graph_collect_ms": stat(nums(("scan", "project_graph_collect_ms"))),
|
|
"project_graph_merge_ms": stat(nums(("scan", "project_graph_merge_ms"))),
|
|
"project_graph_fallback_ms": stat(nums(("scan", "project_graph_fallback_ms"))),
|
|
"project_index_load_ms": stat(nums(("scan", "project_index_load_ms"))),
|
|
"project_index_update_ms": stat(nums(("scan", "project_index_update_ms"))),
|
|
"security_candidate_ms": stat(nums(("scan", "security_candidate_ms"))),
|
|
"call_graph_nodes": stat(nums(("scan", "call_graph_nodes"))),
|
|
"call_graph_edges": stat(nums(("scan", "call_graph_edges"))),
|
|
"peak_rss_kb": stat(nums(("scan", "peak_rss_kb"))),
|
|
"num_tus": stat(nums(("scan", "num_tus"))),
|
|
"num_functions": stat(nums(("scan", "num_functions"))),
|
|
"detectors": detector_stats,
|
|
"finding_counts": rows[-1]["findings"] if rows else {},
|
|
}
|
|
|
|
|
|
def write_markdown(summary: dict[str, Any], path: Path) -> None:
|
|
lines = ["# fold optimization micro-benchmarks", ""]
|
|
lines.append("These benchmarks are synthetic A/B gates for speed and FP optimization patches.")
|
|
lines.append("Run before and after a patch and compare `summary.json` medians.")
|
|
lines.append("")
|
|
for case in summary["cases"]:
|
|
lines.append(f"## {case['case']} size={case['size']}")
|
|
lines.append("")
|
|
lines.append(case["description"])
|
|
lines.append("")
|
|
lines.append("| metric | median | mean | min | max |")
|
|
lines.append("|---|---:|---:|---:|---:|")
|
|
for key in [
|
|
"elapsed_ms_external",
|
|
"total_wall_ms",
|
|
"serial_dispatch_ms",
|
|
"call_graph_ms",
|
|
"project_graph_collect_ms",
|
|
"project_graph_merge_ms",
|
|
"project_graph_fallback_ms",
|
|
"project_index_load_ms",
|
|
"project_index_update_ms",
|
|
"security_candidate_ms",
|
|
"call_graph_nodes",
|
|
"call_graph_edges",
|
|
"peak_rss_kb",
|
|
"num_tus",
|
|
"num_functions",
|
|
]:
|
|
s = case[key]
|
|
lines.append(f"| {key} | {s['median']} | {s['mean']} | {s['min']} | {s['max']} |")
|
|
for det, stats in case["detectors"].items():
|
|
for metric, s in stats.items():
|
|
lines.append(
|
|
f"| {det}.{metric} | {s['median']} | {s['mean']} | {s['min']} | {s['max']} |"
|
|
)
|
|
lines.append("")
|
|
lines.append("Finding distribution from last repeat:")
|
|
lines.append("```json")
|
|
lines.append(json.dumps(case["finding_counts"], indent=2, sort_keys=True))
|
|
lines.append("```")
|
|
lines.append("")
|
|
path.write_text("\n".join(lines) + "\n")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--fold", help="Path to fold binary; defaults to build/release/src/cli/fold or $FOLD_BINARY")
|
|
parser.add_argument("--case", choices=["all", *CASES.keys()], default="all")
|
|
parser.add_argument("--size", type=int, help="Override synthetic workload size for selected case(s)")
|
|
parser.add_argument("--repeats", type=int, default=1)
|
|
parser.add_argument("--out-dir", default=None, help="Output directory; default bench/results/<timestamp>_micro-opt")
|
|
parser.add_argument("--keep-workdir", action="store_true", help="Keep generated synthetic projects under out-dir/work")
|
|
args = parser.parse_args()
|
|
|
|
fold = find_fold(args.fold)
|
|
if not fold.exists():
|
|
raise SystemExit(f"fold binary does not exist: {fold}")
|
|
|
|
stamp = time.strftime("%Y-%m-%dT%H-%M-%SZ", time.gmtime())
|
|
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "bench" / "results" / f"{stamp}_micro-opt"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
selected = list(CASES.keys()) if args.case == "all" else [args.case]
|
|
raw_rows: list[dict[str, Any]] = []
|
|
cases_summary: list[dict[str, Any]] = []
|
|
for case in selected:
|
|
size = args.size if args.size is not None else CASES[case].size_default
|
|
rows = []
|
|
for repeat in range(args.repeats):
|
|
row = run_one(fold, case, size, repeat, out_dir, args.keep_workdir)
|
|
rows.append(row)
|
|
raw_rows.append(row)
|
|
print(
|
|
f"{case} r{repeat}: rc={row['returncode']} "
|
|
f"wall={row['scan'].get('total_wall_ms')}ms findings={row['findings'].get('finding_count')}",
|
|
flush=True,
|
|
)
|
|
cases_summary.append(summarize_case(rows))
|
|
|
|
summary = {
|
|
"tool": "fold-micro-opt",
|
|
"fold_binary": str(fold),
|
|
"repeats": args.repeats,
|
|
"cases": cases_summary,
|
|
"raw": raw_rows,
|
|
}
|
|
(out_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
|
write_markdown(summary, out_dir / "summary.md")
|
|
print(f"wrote {out_dir / 'summary.json'}")
|
|
print(f"wrote {out_dir / 'summary.md'}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|