cortex-cycle-budget 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cortex_cycle_budget/__init__.py +87 -0
- cortex_cycle_budget/__main__.py +10 -0
- cortex_cycle_budget/analysis.py +203 -0
- cortex_cycle_budget/cli_multi.py +331 -0
- cortex_cycle_budget/cli_single.py +232 -0
- cortex_cycle_budget/config.py +95 -0
- cortex_cycle_budget/models.py +61 -0
- cortex_cycle_budget/parser.py +97 -0
- cortex_cycle_budget/reports.py +382 -0
- cortex_cycle_budget/timing_model.py +315 -0
- cortex_cycle_budget/tooling.py +27 -0
- cortex_cycle_budget-0.2.0.dist-info/METADATA +190 -0
- cortex_cycle_budget-0.2.0.dist-info/RECORD +16 -0
- cortex_cycle_budget-0.2.0.dist-info/WHEEL +4 -0
- cortex_cycle_budget-0.2.0.dist-info/entry_points.txt +3 -0
- cortex_cycle_budget-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cortex_cycle_budget — Static cycle-count estimation for ARM Cortex-M binaries.
|
|
3
|
+
|
|
4
|
+
Supports Cortex-M0, M4, M7, and M33 instruction timing models. Estimates
|
|
5
|
+
cycle consumption for user-defined critical paths through an ELF binary by
|
|
6
|
+
parsing ``arm-none-eabi-objdump`` output.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
parse_disassembly — disassembly → Function list
|
|
10
|
+
analyze_stages — Function list + config → PathStage list
|
|
11
|
+
walk_call_graph — recursive cycle attribution
|
|
12
|
+
generate_report — full markdown report
|
|
13
|
+
generate_pr_comment — concise PR comment markdown
|
|
14
|
+
generate_cpu_budget_table — budget table only (markdown)
|
|
15
|
+
generate_annotated_disasm — annotated disassembly markdown
|
|
16
|
+
generate_json_metrics — machine-readable metrics dict
|
|
17
|
+
validate_config — raise ConfigError on invalid configs
|
|
18
|
+
EXCEPTION_OVERHEAD — per-variant ISR entry/exit cycles
|
|
19
|
+
TIMING — resolved timing table for the default variant
|
|
20
|
+
BASE_TIMING — variant-agnostic timing table
|
|
21
|
+
VARIANT_OVERRIDES — per-variant overrides applied on top of BASE_TIMING
|
|
22
|
+
SUPPORTED_VARIANTS — frozenset of supported Cortex-M variants
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from cortex_cycle_budget.analysis import analyze_stages, walk_call_graph
|
|
28
|
+
from cortex_cycle_budget.config import validate_config
|
|
29
|
+
from cortex_cycle_budget.models import (
|
|
30
|
+
ConfigError,
|
|
31
|
+
Function,
|
|
32
|
+
Instruction,
|
|
33
|
+
PathStage,
|
|
34
|
+
StageMatchError,
|
|
35
|
+
)
|
|
36
|
+
from cortex_cycle_budget.parser import parse_disassembly
|
|
37
|
+
from cortex_cycle_budget.reports import (
|
|
38
|
+
generate_annotated_disasm,
|
|
39
|
+
generate_cpu_budget_table,
|
|
40
|
+
generate_json_metrics,
|
|
41
|
+
generate_pr_comment,
|
|
42
|
+
generate_report,
|
|
43
|
+
)
|
|
44
|
+
from cortex_cycle_budget.timing_model import (
|
|
45
|
+
BASE_TIMING,
|
|
46
|
+
EXCEPTION_OVERHEAD,
|
|
47
|
+
SUPPORTED_VARIANTS,
|
|
48
|
+
TIMING,
|
|
49
|
+
VARIANT_OVERRIDES,
|
|
50
|
+
classify_instruction,
|
|
51
|
+
get_timing,
|
|
52
|
+
normalize_mnemonic,
|
|
53
|
+
resolve_timing_table,
|
|
54
|
+
)
|
|
55
|
+
from cortex_cycle_budget.tooling import log, run_objdump, run_size
|
|
56
|
+
|
|
57
|
+
__version__ = "0.2.0"
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"BASE_TIMING",
|
|
61
|
+
"EXCEPTION_OVERHEAD",
|
|
62
|
+
"SUPPORTED_VARIANTS",
|
|
63
|
+
"TIMING",
|
|
64
|
+
"VARIANT_OVERRIDES",
|
|
65
|
+
"ConfigError",
|
|
66
|
+
"Function",
|
|
67
|
+
"Instruction",
|
|
68
|
+
"PathStage",
|
|
69
|
+
"StageMatchError",
|
|
70
|
+
"__version__",
|
|
71
|
+
"analyze_stages",
|
|
72
|
+
"classify_instruction",
|
|
73
|
+
"generate_annotated_disasm",
|
|
74
|
+
"generate_cpu_budget_table",
|
|
75
|
+
"generate_json_metrics",
|
|
76
|
+
"generate_pr_comment",
|
|
77
|
+
"generate_report",
|
|
78
|
+
"get_timing",
|
|
79
|
+
"log",
|
|
80
|
+
"normalize_mnemonic",
|
|
81
|
+
"parse_disassembly",
|
|
82
|
+
"resolve_timing_table",
|
|
83
|
+
"run_objdump",
|
|
84
|
+
"run_size",
|
|
85
|
+
"validate_config",
|
|
86
|
+
"walk_call_graph",
|
|
87
|
+
]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Call-graph walker and stage analysis (config → :class:`PathStage` list)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cortex_cycle_budget.models import (
|
|
9
|
+
Function,
|
|
10
|
+
PathStage,
|
|
11
|
+
StageMatchError,
|
|
12
|
+
)
|
|
13
|
+
from cortex_cycle_budget.timing_model import EXCEPTION_OVERHEAD
|
|
14
|
+
from cortex_cycle_budget.tooling import log
|
|
15
|
+
|
|
16
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
# Call-graph walker
|
|
18
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
def _resolve_fn(name: str, lookup: dict[str, Function]) -> Function | None:
|
|
21
|
+
fn = lookup.get(name)
|
|
22
|
+
if fn is not None:
|
|
23
|
+
return fn
|
|
24
|
+
for k, f in lookup.items():
|
|
25
|
+
if name in k:
|
|
26
|
+
return f
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def walk_call_graph(
|
|
31
|
+
entry_fn: Function,
|
|
32
|
+
lookup: dict[str, Function],
|
|
33
|
+
) -> dict[str, dict[str, int]]:
|
|
34
|
+
"""Walk the call graph from *entry_fn*, counting each callee per invocation.
|
|
35
|
+
|
|
36
|
+
Returns a mapping of demangled function name → aggregate metrics
|
|
37
|
+
(``min``, ``max``, ``instructions``, ``code_size``, instruction-mix
|
|
38
|
+
counters, and ``call_count``).
|
|
39
|
+
"""
|
|
40
|
+
breakdown: dict[str, dict[str, int]] = {}
|
|
41
|
+
|
|
42
|
+
def _walk(fn_name: str, visited_stack: frozenset[str]) -> None:
|
|
43
|
+
fn = _resolve_fn(fn_name, lookup)
|
|
44
|
+
if fn is None:
|
|
45
|
+
return
|
|
46
|
+
canonical = fn.demangled
|
|
47
|
+
if canonical in visited_stack:
|
|
48
|
+
return
|
|
49
|
+
visited_stack = visited_stack | {canonical}
|
|
50
|
+
|
|
51
|
+
if canonical not in breakdown:
|
|
52
|
+
breakdown[canonical] = {
|
|
53
|
+
"min": 0,
|
|
54
|
+
"max": 0,
|
|
55
|
+
"instructions": fn.instruction_count,
|
|
56
|
+
"code_size": fn.code_size,
|
|
57
|
+
"fpu_ops": fn.fpu_ops,
|
|
58
|
+
"load_store": fn.load_store_ops,
|
|
59
|
+
"branch": fn.branch_ops,
|
|
60
|
+
"alu": fn.alu_ops,
|
|
61
|
+
"mul_div": fn.mul_div_ops,
|
|
62
|
+
"call_count": 0,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
breakdown[canonical]["call_count"] += 1
|
|
66
|
+
breakdown[canonical]["min"] += fn.total_min_cycles
|
|
67
|
+
breakdown[canonical]["max"] += fn.total_max_cycles
|
|
68
|
+
|
|
69
|
+
for callee in fn.calls:
|
|
70
|
+
_walk(callee, visited_stack)
|
|
71
|
+
|
|
72
|
+
_walk(entry_fn.demangled, frozenset())
|
|
73
|
+
return breakdown
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
# Stage analysis
|
|
78
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def analyze_stages(
|
|
81
|
+
config: dict[str, Any],
|
|
82
|
+
all_fns: list[Function],
|
|
83
|
+
lookup: dict[str, Function],
|
|
84
|
+
*,
|
|
85
|
+
strict: bool = True,
|
|
86
|
+
) -> list[PathStage]:
|
|
87
|
+
"""Resolve the configured ``path_stages`` against the disassembled binary.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
config: Parsed JSON configuration (must include ``path_stages``).
|
|
91
|
+
all_fns: Output of :func:`parse_disassembly`.
|
|
92
|
+
lookup: ``{fn.demangled: fn}`` mapping over *all_fns*.
|
|
93
|
+
strict: When ``True``, raise :class:`StageMatchError` if any required
|
|
94
|
+
stage matches no functions.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
StageMatchError: in strict mode, when at least one required stage
|
|
98
|
+
fails to match.
|
|
99
|
+
"""
|
|
100
|
+
cortex = config.get("cortex", "m4")
|
|
101
|
+
overhead = EXCEPTION_OVERHEAD.get(cortex, EXCEPTION_OVERHEAD["m4"])
|
|
102
|
+
stages: list[PathStage] = []
|
|
103
|
+
errors: list[str] = []
|
|
104
|
+
|
|
105
|
+
for stage_cfg in config["path_stages"]:
|
|
106
|
+
stage_type = stage_cfg.get("type")
|
|
107
|
+
required = stage_cfg.get("required", True)
|
|
108
|
+
|
|
109
|
+
if stage_type == "exception_entry":
|
|
110
|
+
stages.append(PathStage(
|
|
111
|
+
label=stage_cfg["label"],
|
|
112
|
+
min_cycles=overhead["entry_min"],
|
|
113
|
+
max_cycles=overhead["entry_max"],
|
|
114
|
+
is_overhead=True,
|
|
115
|
+
))
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if stage_type == "exception_exit":
|
|
119
|
+
stages.append(PathStage(
|
|
120
|
+
label=stage_cfg["label"],
|
|
121
|
+
min_cycles=overhead["exit_min"],
|
|
122
|
+
max_cycles=overhead["exit_max"],
|
|
123
|
+
is_overhead=True,
|
|
124
|
+
))
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
patterns = stage_cfg.get("patterns", [])
|
|
128
|
+
entry_pattern = stage_cfg.get("entry")
|
|
129
|
+
|
|
130
|
+
matched: dict[str, Function] = {}
|
|
131
|
+
for fn in all_fns:
|
|
132
|
+
for pat in patterns:
|
|
133
|
+
try:
|
|
134
|
+
if re.search(pat, fn.demangled):
|
|
135
|
+
matched[fn.demangled] = fn
|
|
136
|
+
break
|
|
137
|
+
except re.error as exc:
|
|
138
|
+
msg = (
|
|
139
|
+
f"Stage '{stage_cfg['label']}': invalid regex "
|
|
140
|
+
f"pattern '{pat}': {exc}"
|
|
141
|
+
)
|
|
142
|
+
errors.append(msg)
|
|
143
|
+
log(f" ERROR: {msg}")
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
stage = PathStage(label=stage_cfg["label"])
|
|
147
|
+
|
|
148
|
+
if entry_pattern:
|
|
149
|
+
entry_fn = None
|
|
150
|
+
for name, fn in matched.items():
|
|
151
|
+
if re.search(entry_pattern, name):
|
|
152
|
+
entry_fn = fn
|
|
153
|
+
break
|
|
154
|
+
if entry_fn is not None:
|
|
155
|
+
breakdown = walk_call_graph(entry_fn, lookup)
|
|
156
|
+
stage.min_cycles = sum(v["min"] for v in breakdown.values())
|
|
157
|
+
stage.max_cycles = sum(v["max"] for v in breakdown.values())
|
|
158
|
+
stage.instruction_count = sum(v["instructions"] for v in breakdown.values())
|
|
159
|
+
stage.code_size = sum(v["code_size"] for v in breakdown.values())
|
|
160
|
+
stage.fpu_ops = sum(v["fpu_ops"] for v in breakdown.values())
|
|
161
|
+
stage.functions = sorted(breakdown.keys())
|
|
162
|
+
else:
|
|
163
|
+
msg = (
|
|
164
|
+
f"Stage '{stage_cfg['label']}': entry function matching "
|
|
165
|
+
f"'{entry_pattern}' not found in binary"
|
|
166
|
+
)
|
|
167
|
+
if required:
|
|
168
|
+
errors.append(msg)
|
|
169
|
+
log(f" ERROR: {msg}")
|
|
170
|
+
else:
|
|
171
|
+
if not matched and required:
|
|
172
|
+
msg = (
|
|
173
|
+
f"Stage '{stage_cfg['label']}': no functions matched any "
|
|
174
|
+
f"of the {len(patterns)} configured patterns"
|
|
175
|
+
)
|
|
176
|
+
errors.append(msg)
|
|
177
|
+
log(f" ERROR: {msg}")
|
|
178
|
+
for fn in matched.values():
|
|
179
|
+
stage.min_cycles += fn.total_min_cycles
|
|
180
|
+
stage.max_cycles += fn.total_max_cycles
|
|
181
|
+
stage.instruction_count += fn.instruction_count
|
|
182
|
+
stage.code_size += fn.code_size
|
|
183
|
+
stage.fpu_ops += fn.fpu_ops
|
|
184
|
+
stage.functions = sorted(matched.keys())
|
|
185
|
+
|
|
186
|
+
stages.append(stage)
|
|
187
|
+
|
|
188
|
+
if strict and errors:
|
|
189
|
+
log("")
|
|
190
|
+
log("Stage matching failed — the configuration does not align with the binary.")
|
|
191
|
+
log("Unmatched stages:")
|
|
192
|
+
for e in errors:
|
|
193
|
+
log(f" • {e}")
|
|
194
|
+
log("")
|
|
195
|
+
log("Ensure the path_stages patterns in your config JSON match the ")
|
|
196
|
+
log("demangled symbol names in the ELF. You can inspect them with:")
|
|
197
|
+
log(" arm-none-eabi-objdump -d -C <elf> | grep '^[0-9a-f].*<.*>:$'")
|
|
198
|
+
raise StageMatchError(
|
|
199
|
+
f"{len(errors)} path stage(s) matched no functions in the binary. "
|
|
200
|
+
f"See stderr for details."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return stages
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Multi-mode CLI: ``cortex-cycle-budget-multi``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cortex_cycle_budget.analysis import analyze_stages
|
|
13
|
+
from cortex_cycle_budget.config import validate_config
|
|
14
|
+
from cortex_cycle_budget.models import ConfigError, StageMatchError
|
|
15
|
+
from cortex_cycle_budget.parser import parse_disassembly
|
|
16
|
+
from cortex_cycle_budget.reports import (
|
|
17
|
+
generate_annotated_disasm,
|
|
18
|
+
generate_cpu_budget_table,
|
|
19
|
+
generate_json_metrics,
|
|
20
|
+
generate_report,
|
|
21
|
+
)
|
|
22
|
+
from cortex_cycle_budget.timing_model import (
|
|
23
|
+
EXCEPTION_OVERHEAD,
|
|
24
|
+
SUPPORTED_VARIANTS,
|
|
25
|
+
resolve_timing_table,
|
|
26
|
+
)
|
|
27
|
+
from cortex_cycle_budget.tooling import log, run_objdump, run_size
|
|
28
|
+
|
|
29
|
+
PR_COMMENT_MARKER = "<!-- cortex-cycle-budget-comment -->"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="cortex-cycle-budget-multi",
|
|
35
|
+
description="Multi-configuration ARM Cortex-M cycle analysis.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"analyses_config",
|
|
39
|
+
help="JSON file containing an array of analysis entries",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--target", required=True, help="Target board identifier")
|
|
42
|
+
parser.add_argument("--build-config", required=True, help="Build configuration")
|
|
43
|
+
parser.add_argument("--clock-mhz", required=True, type=int, help="CPU clock in MHz")
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--cortex",
|
|
46
|
+
default="m4",
|
|
47
|
+
choices=sorted(SUPPORTED_VARIANTS),
|
|
48
|
+
help="Cortex-M variant (default: m4)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument("--objdump", default="arm-none-eabi-objdump")
|
|
51
|
+
parser.add_argument("--size-tool", default="arm-none-eabi-size")
|
|
52
|
+
parser.add_argument("--output-dir", default="cycle-analysis-combined")
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--no-strict",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Do not fail when path stages match no functions",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--fail-over-max-cycles",
|
|
60
|
+
type=int,
|
|
61
|
+
default=None,
|
|
62
|
+
help="Exit with non-zero status if any mode's max cycles exceeds this threshold.",
|
|
63
|
+
)
|
|
64
|
+
return parser
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run_single_analysis(
|
|
68
|
+
entry: dict[str, Any],
|
|
69
|
+
shared_args: argparse.Namespace,
|
|
70
|
+
) -> tuple[str, str, int]:
|
|
71
|
+
"""Run analysis for one entry. Returns (cpu_budget_md, full_report_md, max_cycles)."""
|
|
72
|
+
label = entry["label"]
|
|
73
|
+
elf_path = entry["elf_path"]
|
|
74
|
+
config_path = entry["config_path"]
|
|
75
|
+
output_dir = entry["output_dir"]
|
|
76
|
+
|
|
77
|
+
log(f"\n--- Analyzing: {label} ---")
|
|
78
|
+
|
|
79
|
+
if not os.path.isfile(elf_path):
|
|
80
|
+
log(f"ERROR: ELF not found: {elf_path}")
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
if not os.path.isfile(config_path):
|
|
84
|
+
log(f"ERROR: Config not found: {config_path}")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
with open(config_path) as f:
|
|
89
|
+
config = json.load(f)
|
|
90
|
+
except json.JSONDecodeError as exc:
|
|
91
|
+
log(f"ERROR: Failed to parse config JSON '{config_path}': {exc}")
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
config["target"] = shared_args.target
|
|
95
|
+
config["build_config"] = shared_args.build_config
|
|
96
|
+
config["clock_mhz"] = shared_args.clock_mhz
|
|
97
|
+
config["cortex"] = shared_args.cortex
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
validate_config(config)
|
|
101
|
+
except ConfigError as exc:
|
|
102
|
+
log(f"ERROR: {exc}")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
disasm = run_objdump(elf_path, shared_args.objdump)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
log(f"ERROR: objdump tool not found: '{shared_args.objdump}'")
|
|
111
|
+
log("Install the ARM toolchain or pass --objdump with the correct path.")
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
except subprocess.CalledProcessError as exc:
|
|
114
|
+
log(f"ERROR: objdump failed (exit {exc.returncode}): {exc.stderr}")
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
with open(os.path.join(output_dir, "full_disassembly.txt"), "w") as f:
|
|
118
|
+
f.write(disasm)
|
|
119
|
+
|
|
120
|
+
size_out = run_size(elf_path, shared_args.size_tool)
|
|
121
|
+
if size_out:
|
|
122
|
+
with open(os.path.join(output_dir, "size_report.txt"), "w") as f:
|
|
123
|
+
f.write(size_out)
|
|
124
|
+
|
|
125
|
+
timing = resolve_timing_table(shared_args.cortex)
|
|
126
|
+
all_fns = parse_disassembly(disasm, timing=timing)
|
|
127
|
+
lookup = {fn.demangled: fn for fn in all_fns}
|
|
128
|
+
log(f"Parsed {len(all_fns)} functions")
|
|
129
|
+
|
|
130
|
+
if len(all_fns) == 0:
|
|
131
|
+
log("ERROR: No functions found in the disassembly.")
|
|
132
|
+
log("The ELF may be stripped, empty, or not an ARM binary.")
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
stages = analyze_stages(
|
|
137
|
+
config, all_fns, lookup, strict=not getattr(shared_args, "no_strict", False),
|
|
138
|
+
)
|
|
139
|
+
except StageMatchError as exc:
|
|
140
|
+
log(f"ERROR: {exc}")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
report = generate_report(config, stages, all_fns, lookup)
|
|
144
|
+
with open(os.path.join(output_dir, "cycle_estimation_report.md"), "w") as f:
|
|
145
|
+
f.write(report)
|
|
146
|
+
|
|
147
|
+
budget = generate_cpu_budget_table(config, stages)
|
|
148
|
+
with open(os.path.join(output_dir, "cpu_budget.md"), "w") as f:
|
|
149
|
+
f.write(budget)
|
|
150
|
+
|
|
151
|
+
annotated = generate_annotated_disasm(config, all_fns, stages)
|
|
152
|
+
with open(os.path.join(output_dir, "annotated_disassembly.md"), "w") as f:
|
|
153
|
+
f.write(annotated)
|
|
154
|
+
|
|
155
|
+
metrics = generate_json_metrics(config, stages)
|
|
156
|
+
with open(os.path.join(output_dir, "cycle_metrics.json"), "w") as f:
|
|
157
|
+
json.dump(metrics, f, indent=2)
|
|
158
|
+
|
|
159
|
+
total_min = sum(s.min_cycles for s in stages)
|
|
160
|
+
total_max = sum(s.max_cycles for s in stages)
|
|
161
|
+
log(f"{label}: {total_min}–{total_max} cycles, {len(stages)} stages")
|
|
162
|
+
|
|
163
|
+
return budget, report, total_max
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _build_collapsible_sections(
|
|
167
|
+
analyses: list[dict[str, Any]],
|
|
168
|
+
budgets: list[str],
|
|
169
|
+
reports: list[str],
|
|
170
|
+
target: str,
|
|
171
|
+
) -> list[str]:
|
|
172
|
+
lines: list[str] = []
|
|
173
|
+
for i, (entry, budget, report) in enumerate(zip(analyses, budgets, reports, strict=False)):
|
|
174
|
+
label = entry["label"]
|
|
175
|
+
lines.extend((
|
|
176
|
+
f"### {label} | {target}",
|
|
177
|
+
"",
|
|
178
|
+
"**CPU Utilization Budget**",
|
|
179
|
+
"",
|
|
180
|
+
budget,
|
|
181
|
+
"",
|
|
182
|
+
"<details>",
|
|
183
|
+
"<summary>Click to expand full details</summary>",
|
|
184
|
+
"",
|
|
185
|
+
report,
|
|
186
|
+
"",
|
|
187
|
+
"</details>",
|
|
188
|
+
"",
|
|
189
|
+
))
|
|
190
|
+
if i < len(analyses) - 1:
|
|
191
|
+
lines.extend(("---", ""))
|
|
192
|
+
return lines
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def generate_combined_pr_comment(
|
|
196
|
+
analyses: list[dict[str, Any]],
|
|
197
|
+
budgets: list[str],
|
|
198
|
+
reports: list[str],
|
|
199
|
+
target: str,
|
|
200
|
+
) -> str:
|
|
201
|
+
lines = [PR_COMMENT_MARKER, "## 🏎️ Cycle Analysis Results", ""]
|
|
202
|
+
lines.extend(_build_collapsible_sections(analyses, budgets, reports, target))
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def generate_combined_step_summary(
|
|
207
|
+
analyses: list[dict[str, Any]],
|
|
208
|
+
budgets: list[str],
|
|
209
|
+
reports: list[str],
|
|
210
|
+
target: str,
|
|
211
|
+
) -> str:
|
|
212
|
+
lines = ["## 🏎️ Cycle Analysis Results", ""]
|
|
213
|
+
lines.extend(_build_collapsible_sections(analyses, budgets, reports, target))
|
|
214
|
+
return "\n".join(lines)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def generate_combined_metrics(analyses: list[dict[str, Any]]) -> dict[str, Any]:
|
|
218
|
+
modes: list[dict[str, Any]] = []
|
|
219
|
+
overall_max = 0
|
|
220
|
+
|
|
221
|
+
for entry in analyses:
|
|
222
|
+
metrics_path = os.path.join(entry["output_dir"], "cycle_metrics.json")
|
|
223
|
+
if not os.path.isfile(metrics_path):
|
|
224
|
+
continue
|
|
225
|
+
with open(metrics_path) as f:
|
|
226
|
+
m = json.load(f)
|
|
227
|
+
m["mode_label"] = entry["label"]
|
|
228
|
+
modes.append(m)
|
|
229
|
+
overall_max = max(overall_max, m["summary"]["estimated_max_cycles"])
|
|
230
|
+
|
|
231
|
+
return {"modes": modes, "overall_max_cycles": overall_max}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main(argv: list[str] | None = None) -> int:
|
|
235
|
+
args = build_parser().parse_args(argv)
|
|
236
|
+
|
|
237
|
+
if not os.path.isfile(args.analyses_config):
|
|
238
|
+
log(f"ERROR: Analyses config not found: {args.analyses_config}")
|
|
239
|
+
return 1
|
|
240
|
+
|
|
241
|
+
if args.clock_mhz <= 0:
|
|
242
|
+
log(f"ERROR: --clock-mhz must be a positive integer, got {args.clock_mhz}")
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
if args.cortex not in EXCEPTION_OVERHEAD:
|
|
246
|
+
valid = ", ".join(sorted(EXCEPTION_OVERHEAD))
|
|
247
|
+
log(f"ERROR: --cortex '{args.cortex}' is not supported. Valid values: {valid}")
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
with open(args.analyses_config) as f:
|
|
252
|
+
analyses = json.load(f)
|
|
253
|
+
except json.JSONDecodeError as exc:
|
|
254
|
+
log(f"ERROR: Failed to parse analyses config JSON: {exc}")
|
|
255
|
+
return 1
|
|
256
|
+
|
|
257
|
+
if not isinstance(analyses, list) or len(analyses) == 0:
|
|
258
|
+
log("ERROR: Analyses config must be a non-empty JSON array")
|
|
259
|
+
return 1
|
|
260
|
+
|
|
261
|
+
os.makedirs(args.output_dir, exist_ok=True)
|
|
262
|
+
|
|
263
|
+
budgets: list[str] = []
|
|
264
|
+
reports: list[str] = []
|
|
265
|
+
per_mode_max: list[tuple[str, int]] = []
|
|
266
|
+
|
|
267
|
+
for entry in analyses:
|
|
268
|
+
budget, report, max_cycles = _run_single_analysis(entry, args)
|
|
269
|
+
budgets.append(budget)
|
|
270
|
+
reports.append(report)
|
|
271
|
+
per_mode_max.append((entry["label"], max_cycles))
|
|
272
|
+
|
|
273
|
+
comment = generate_combined_pr_comment(analyses, budgets, reports, args.target)
|
|
274
|
+
comment_path = os.path.join(args.output_dir, "combined_pr_comment.md")
|
|
275
|
+
with open(comment_path, "w") as f:
|
|
276
|
+
f.write(comment)
|
|
277
|
+
log(f"\nCombined PR comment: {comment_path}")
|
|
278
|
+
|
|
279
|
+
summary = generate_combined_step_summary(analyses, budgets, reports, args.target)
|
|
280
|
+
summary_path = os.path.join(args.output_dir, "combined_step_summary.md")
|
|
281
|
+
with open(summary_path, "w") as f:
|
|
282
|
+
f.write(summary)
|
|
283
|
+
log(f"Combined step summary: {summary_path}")
|
|
284
|
+
|
|
285
|
+
combined_metrics = generate_combined_metrics(analyses)
|
|
286
|
+
metrics_path = os.path.join(args.output_dir, "combined_metrics.json")
|
|
287
|
+
with open(metrics_path, "w") as f:
|
|
288
|
+
json.dump(combined_metrics, f, indent=2)
|
|
289
|
+
log(f"Combined metrics: {metrics_path}")
|
|
290
|
+
|
|
291
|
+
clock = args.clock_mhz
|
|
292
|
+
avail_20k = clock * 1_000_000 // 20_000
|
|
293
|
+
print(f"\n{'=' * 65}")
|
|
294
|
+
print(f" Multi-Config Cycle Analysis ({args.target} {args.build_config}, "
|
|
295
|
+
f"Cortex-{args.cortex.upper()})")
|
|
296
|
+
print(f"{'=' * 65}")
|
|
297
|
+
for entry in analyses:
|
|
298
|
+
mp = os.path.join(entry["output_dir"], "cycle_metrics.json")
|
|
299
|
+
if not os.path.isfile(mp):
|
|
300
|
+
continue
|
|
301
|
+
with open(mp) as f:
|
|
302
|
+
m = json.load(f)
|
|
303
|
+
total_min = m["summary"]["estimated_min_cycles"]
|
|
304
|
+
total_max = m["summary"]["estimated_max_cycles"]
|
|
305
|
+
pct = total_max / avail_20k * 100 if avail_20k > 0 else 0.0
|
|
306
|
+
print(
|
|
307
|
+
f" {entry['label']:<22s} {total_min:>4d}–{total_max:<4d} cycles "
|
|
308
|
+
f"({pct:.1f}% max @ 20 kHz)"
|
|
309
|
+
)
|
|
310
|
+
print(f"{'=' * 65}")
|
|
311
|
+
|
|
312
|
+
if args.fail_over_max_cycles is not None:
|
|
313
|
+
violators = [
|
|
314
|
+
(label, mx)
|
|
315
|
+
for label, mx in per_mode_max
|
|
316
|
+
if mx > args.fail_over_max_cycles
|
|
317
|
+
]
|
|
318
|
+
if violators:
|
|
319
|
+
log(
|
|
320
|
+
f"ERROR: {len(violators)} mode(s) exceed the cycle budget "
|
|
321
|
+
f"({args.fail_over_max_cycles}):"
|
|
322
|
+
)
|
|
323
|
+
for label, mx in violators:
|
|
324
|
+
log(f" • {label}: {mx} cycles")
|
|
325
|
+
return 2
|
|
326
|
+
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
sys.exit(main())
|