tan-cli 0.7.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.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/core/model_check.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Pure serialisation + text rendering for `tan model check` (tan-cli#782).
|
|
3
|
+
|
|
4
|
+
Mirrors `tan.core.model_doctor`'s split: the engine (`tan.model.check`,
|
|
5
|
+
`tan.model.analyze`) returns `BackendReport`s; this module turns THOSE into
|
|
6
|
+
the wire dict `--format json` emits (camelCase, the house convention every
|
|
7
|
+
other command's `data` payload already follows -- `schemaVersion`,
|
|
8
|
+
`boardYaml`, ...) and the lines `--format text` prints. No IO, no vela/dxcom
|
|
9
|
+
probing, nothing that was not already computed by the caller.
|
|
10
|
+
|
|
11
|
+
`model_cmd.py`'s `finish()` renders `check`'s text mode from `data` -- the
|
|
12
|
+
SAME serialised dict `--format json` emits, not a second dataclass-shaped
|
|
13
|
+
code path -- exactly the pattern `doctor`'s own text branch already uses
|
|
14
|
+
(`data["backends"]`): one source of truth for the values, two renderers for
|
|
15
|
+
the format.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from tan.model.analyze import COVERAGE_WITHHELD, BackendReport, OpVerdict
|
|
20
|
+
|
|
21
|
+
#: Cosmetic backend -> text-mode label. `ethos_u` is special-cased in
|
|
22
|
+
#: `backend_label` below to fold in the resolved variant ("Ethos-U55") --
|
|
23
|
+
#: the other two backends carry no per-instance variant to show.
|
|
24
|
+
_BACKEND_LABELS = {"ethos_u": "Ethos-U", "drpai": "DRP-AI", "deepx_dxm1": "DEEPX DX-M1"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def backend_label(backend: str, variant: str | None) -> str:
|
|
28
|
+
if backend == "ethos_u" and variant and variant.startswith("u"):
|
|
29
|
+
return f"Ethos-U{variant[1:]}"
|
|
30
|
+
return _BACKEND_LABELS.get(backend, backend)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def op_verdict_as_dict(v: OpVerdict) -> dict:
|
|
34
|
+
return {"op": v.op, "status": v.status, "reason": v.reason, "macs": v.macs}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def backend_report_as_dict(report: BackendReport) -> dict:
|
|
38
|
+
"""Every `BackendReport` field, camelCase -- including `basis`,
|
|
39
|
+
`confidence` and `uncostedCpuOpCount` (a structured caveat on
|
|
40
|
+
`computeOnNpuPctMax`, not just prose -- see `analyze.BackendReport`'s own
|
|
41
|
+
comment), so a consumer never needs the text rendering to recover a
|
|
42
|
+
number this dict already carries. `computeOnNpuPctMax` and
|
|
43
|
+
`npuPlacementPctReal` are DIFFERENT units answering different questions
|
|
44
|
+
(MAC-weighted upper bound vs. real op-count placement) and are never
|
|
45
|
+
both non-`None` on the same report -- see `analyze.BackendReport`'s
|
|
46
|
+
field comments.
|
|
47
|
+
|
|
48
|
+
The six measured fields (`arenaBytes` ... `perfRef`) are `null` at
|
|
49
|
+
`basis: "static-screen"` and stay structured rather than prose-only, so a
|
|
50
|
+
consumer sizing a module reads a NUMBER instead of parsing tan's own
|
|
51
|
+
sentence: `null` there means "not measured", never zero."""
|
|
52
|
+
return {
|
|
53
|
+
"backend": report.backend,
|
|
54
|
+
"variant": report.variant,
|
|
55
|
+
"table": report.table,
|
|
56
|
+
"npuCoverage": report.npu_coverage,
|
|
57
|
+
"computeOnNpuPctMax": report.compute_on_npu_pct_max,
|
|
58
|
+
"npuPlacementPctReal": report.npu_placement_pct_real,
|
|
59
|
+
"uncostedCpuOpCount": report.uncosted_cpu_op_count,
|
|
60
|
+
"basis": report.basis,
|
|
61
|
+
"confidence": report.confidence,
|
|
62
|
+
"arenaBytes": report.arena_bytes,
|
|
63
|
+
"reqSramKib": report.req_sram_kib,
|
|
64
|
+
"latencyMsMean": report.latency_ms_mean,
|
|
65
|
+
"latencyMsP95": report.latency_ms_p95,
|
|
66
|
+
"latencyRuns": report.latency_runs,
|
|
67
|
+
"perfRef": report.perf_ref,
|
|
68
|
+
"notes": list(report.notes),
|
|
69
|
+
"ops": [op_verdict_as_dict(o) for o in report.ops],
|
|
70
|
+
# tan-cli#1288: `Sram_Only` arena + SRAM0-residency fit. `null` at
|
|
71
|
+
# `basis: "static-screen"` always (no compiled blob to measure a fit
|
|
72
|
+
# against), and at `basis: "bench"` unless the rebase corroborates an
|
|
73
|
+
# already-`"compiled"` report (`analyze.BackendReport.sram_fit`'s own
|
|
74
|
+
# field comment) -- never a guess.
|
|
75
|
+
"sramFit": None if report.sram_fit is None else report.sram_fit.as_dict(),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _coverage_line(report: dict) -> str | None:
|
|
80
|
+
"""`None` on BOTH of `npu_coverage`'s "we don't actually know" spellings
|
|
81
|
+
-- `"undetermined"` itself, and the more subtle case where every `op`
|
|
82
|
+
entry carries `status: "unknown"` (`format-not-accepted`/`no-table-for-
|
|
83
|
+
backend`: `analyze.py` still emits one placeholder verdict per input op
|
|
84
|
+
so a consumer can see WHICH ops were skipped, but none of them was
|
|
85
|
+
actually screened). Counting `status == "unknown"` entries against
|
|
86
|
+
`len(ops)` reads as "0/N ops are NPU-eligible" -- the exact `cpu-only`
|
|
87
|
+
misreading non-negotiables 1 and 2 forbid, reintroduced by the renderer
|
|
88
|
+
(MAJOR 3 review) even though the JSON underneath was already correct.
|
|
89
|
+
Only ever counts verdicts a real screen actually determined
|
|
90
|
+
(`npu-eligible`/`cpu-certain`). Also `None` for a real (`basis:
|
|
91
|
+
"compiled"`) report: `tan.model.check._report_from_vela_compile` keeps
|
|
92
|
+
the STATIC per-op verdicts alongside a real vela placement result
|
|
93
|
+
(`ops=report.ops`, so the caller can still see which ops the table
|
|
94
|
+
thought were eligible), and those can legitimately disagree with what
|
|
95
|
+
vela actually placed (table membership doesn't check dtype/shape) --
|
|
96
|
+
recomputing an "E/T ops are NPU-eligible" figure from them here would
|
|
97
|
+
read alongside the REAL placement percentage as self-contradictory. The
|
|
98
|
+
real split is already `notes`' whole point for that report
|
|
99
|
+
(`"N/Total operators placed on the NPU (P%)"`)."""
|
|
100
|
+
if report["npuCoverage"] == COVERAGE_WITHHELD or report["basis"] != "static-screen":
|
|
101
|
+
return None
|
|
102
|
+
determined = [o for o in report["ops"] if o["status"] in ("npu-eligible", "cpu-certain")]
|
|
103
|
+
if not determined:
|
|
104
|
+
return None
|
|
105
|
+
eligible = sum(1 for o in determined if o["status"] == "npu-eligible")
|
|
106
|
+
total = len(determined)
|
|
107
|
+
pct = report["computeOnNpuPctMax"]
|
|
108
|
+
if pct is not None:
|
|
109
|
+
return (f" {pct:.0f}% of compute ({eligible}/{total} ops) is NPU-eligible"
|
|
110
|
+
f" [upper bound, static screen]")
|
|
111
|
+
return f" {eligible}/{total} ops are NPU-eligible by name (no MAC-weighted figure)"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _cpu_fallback_line(report: dict) -> str | None:
|
|
115
|
+
"""`None` for a real (`basis: "compiled"`) report -- the SAME gate
|
|
116
|
+
`_coverage_line` carries, for the same reason (MAJOR 3 review: this line
|
|
117
|
+
did NOT get that gate in the commit that added it to `_coverage_line`,
|
|
118
|
+
so a compiled report could still print an op-level "N ops are certain
|
|
119
|
+
CPU fallback" claim built from the kept STATIC verdicts
|
|
120
|
+
(`tan.model.check._report_from_vela_compile`'s `ops=report.ops`) --
|
|
121
|
+
self-contradicting the REAL placement `_coverage_line`/`notes` reports
|
|
122
|
+
alongside it: measured a static screen asserting "2 ops are certain CPU
|
|
123
|
+
fallback" next to a real compile's "1/3 operators placed on the NPU
|
|
124
|
+
(67%)" in the same rendered block, from the same STATIC vs COMPILED
|
|
125
|
+
disagreement `_coverage_line`'s own gate exists to prevent -- table
|
|
126
|
+
membership doesn't check dtype/shape, so the static and compiled
|
|
127
|
+
verdicts can legitimately differ)."""
|
|
128
|
+
if report["basis"] != "static-screen":
|
|
129
|
+
return None
|
|
130
|
+
names = [o["op"] for o in report["ops"] if o["status"] == "cpu-certain"]
|
|
131
|
+
if not names:
|
|
132
|
+
return None
|
|
133
|
+
shown = ", ".join(names[:8])
|
|
134
|
+
if len(names) > 8:
|
|
135
|
+
shown += f", +{len(names) - 8} more"
|
|
136
|
+
noun = "op is" if len(names) == 1 else "ops are"
|
|
137
|
+
return f" {len(names)} {noun} certain CPU fallback: {shown}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _exact_hint_line(report: dict) -> str | None:
|
|
141
|
+
"""The upgrade-path advertisement -- ethos_u only (the sole backend with
|
|
142
|
+
a free, un-gated compiler), and only while the report is STILL a static
|
|
143
|
+
screen. Suppressed when a note already explains an `--exact` attempt
|
|
144
|
+
(`tan.model.check._exact_ethos_u`'s own notes all open with
|
|
145
|
+
"--exact ..."); printing both would repeat the same install command
|
|
146
|
+
twice in one report. Deliberately NOT a substring check on "vela" --
|
|
147
|
+
every ethos_u table is itself named `<variant>@vela-<ver>.json`
|
|
148
|
+
(`tan.model.analyze._resolve_table`), so the static-screen note ALWAYS
|
|
149
|
+
mentions "vela" via the table filename it cites, which would suppress
|
|
150
|
+
this hint on every ordinary run rather than only after a real attempt."""
|
|
151
|
+
if report["backend"] != "ethos_u" or report["basis"] != "static-screen":
|
|
152
|
+
return None
|
|
153
|
+
if any(n.startswith("--exact") for n in report["notes"]):
|
|
154
|
+
return None
|
|
155
|
+
return " Exact: pip install tan-cli[model-compile] && tan model check --exact"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
#: tan-cli#1288 -- text-mode label per `sramFit` axis, matching the JSON
|
|
159
|
+
#: key order (`arena` then `sram0`).
|
|
160
|
+
_SRAM_FIT_AXIS_LABELS = (("arena", "arena"), ("sram0", "SRAM0"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sram_fit_lines(report: dict) -> list[str]:
|
|
164
|
+
"""One line per `sramFit` axis, or `[]` when the report carries none (a
|
|
165
|
+
static screen, or a `compiled` report `check.py` never reached --
|
|
166
|
+
`basis` gates it, `model_check.backend_report_as_dict` already made it
|
|
167
|
+
`null` there). Never renders a bare "fits" -- only `fits-unverified` /
|
|
168
|
+
`no-fit` / `skipped`, the same three words the JSON carries.
|
|
169
|
+
|
|
170
|
+
tan-cli#1288 review round 2, finding 4 checked THIS renderer too for the
|
|
171
|
+
same "borrows the wrong axis's `limitKib`" defect `build.py`'s own
|
|
172
|
+
`_sram_no_fit_message` had -- it does not have it: each axis line reads
|
|
173
|
+
ONLY its own `axis[...]` dict below, never the sibling axis's, so there
|
|
174
|
+
is no `arena`/`sram0` figure to cross up."""
|
|
175
|
+
fit = report.get("sramFit")
|
|
176
|
+
if not fit:
|
|
177
|
+
return []
|
|
178
|
+
lines = []
|
|
179
|
+
for key, label in _SRAM_FIT_AXIS_LABELS:
|
|
180
|
+
axis = fit[key]
|
|
181
|
+
verdict = axis["verdict"]
|
|
182
|
+
if verdict == "skipped":
|
|
183
|
+
lines.append(f" SRAM fit ({label}): skipped -- {axis['reason']}")
|
|
184
|
+
elif verdict == "no-fit":
|
|
185
|
+
lines.append(f" SRAM fit ({label}): NO-FIT -- needs {axis['neededKib']} KiB, "
|
|
186
|
+
f"only {axis['limitKib']} KiB available")
|
|
187
|
+
else:
|
|
188
|
+
caveat = f" ({axis['reason']})" if axis.get("reason") else ""
|
|
189
|
+
lines.append(f" SRAM fit ({label}): fits-unverified -- "
|
|
190
|
+
f"{axis['neededKib']} KiB <= {axis['limitKib']} KiB{caveat}")
|
|
191
|
+
return lines
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def render_backend_report(report: dict, *, sku: str | None, prefix: str = "") -> list[str]:
|
|
195
|
+
"""One backend's block of text-mode lines: the header (`{label} ({sku})
|
|
196
|
+
{coverage}`), the coverage + CPU-fallback summary lines, the SRAM-fit
|
|
197
|
+
lines (tan-cli#1288, absent when `sramFit` is `null`), every note (this
|
|
198
|
+
is where the engine's own silent-CPU-fallback sentence and the
|
|
199
|
+
uncosted-MAC caveat surface), and the exact-upgrade hint."""
|
|
200
|
+
label = backend_label(report["backend"], report["variant"])
|
|
201
|
+
lines = [f"{prefix}{label} ({sku}) {report['npuCoverage']}"]
|
|
202
|
+
for build_line in (_coverage_line, _cpu_fallback_line):
|
|
203
|
+
line = build_line(report)
|
|
204
|
+
if line:
|
|
205
|
+
lines.append(line)
|
|
206
|
+
lines.extend(_sram_fit_lines(report))
|
|
207
|
+
lines.extend(f" {note}" for note in report["notes"])
|
|
208
|
+
hint = _exact_hint_line(report)
|
|
209
|
+
if hint:
|
|
210
|
+
lines.append(hint)
|
|
211
|
+
return lines
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def render_check_text(data: dict) -> list[str]:
|
|
215
|
+
"""Every backend block for every declared model, in order. `[]` for no
|
|
216
|
+
declared models -- the caller (`model_cmd.finish`) is the one that
|
|
217
|
+
decides whether an empty result needs a "nothing to check" filler line,
|
|
218
|
+
the same split `build`'s own text branch already makes for `built`."""
|
|
219
|
+
models = data.get("models") or []
|
|
220
|
+
sku = data.get("sku")
|
|
221
|
+
show_name = len(models) > 1
|
|
222
|
+
lines: list[str] = []
|
|
223
|
+
for m in models:
|
|
224
|
+
prefix = f"{m['name']}: " if show_name else ""
|
|
225
|
+
for backend in m.get("backends", []):
|
|
226
|
+
lines.extend(render_backend_report(backend, sku=sku, prefix=prefix))
|
|
227
|
+
return lines
|
tan/core/model_device.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""On-device tier of `tan model run`/`ab` (tan-cli#1287): parse the console a
|
|
3
|
+
benchmark app printed on the target and turn it into a `RunResult` row.
|
|
4
|
+
|
|
5
|
+
Ported from alp-sdk `integration/model-edge-ai` (`scripts/alp_model/ondevice.py`,
|
|
6
|
+
alp-sdk#933), parser and math only, then hardened: the console protocol is the one
|
|
7
|
+
alp-sdk's `examples/aen/aen-inference-energy` prints (`ENERGY-CFG` / `ENERGY-S` /
|
|
8
|
+
`ENERGY-W` / `ENERGY-WERR` / `ENERGY-WARN` / `ENERGY-PAIR` / `ENERGY-RESULT`; that
|
|
9
|
+
app's `printk` calls are the source of truth). The reference's labgrid /
|
|
10
|
+
`build.sh` / `ram-run.sh` driver is NOT ported -- tan's own flash and monitor paths
|
|
11
|
+
own deployment and capture.
|
|
12
|
+
|
|
13
|
+
**Honest by construction.** A capture is untrusted text: every value is type- and
|
|
14
|
+
range-checked (finite, positive spans and clock, non-negative counts) and anything
|
|
15
|
+
else raises `DeviceCaptureError`, never a plausible number.
|
|
16
|
+
|
|
17
|
+
* `latencyMs` is the active window's measured span divided by the inferences it
|
|
18
|
+
completed. The span is wall time of the app's WHOLE window loop, so it is an
|
|
19
|
+
upper bound on pure inference time (the energy app also polls the monitor IC
|
|
20
|
+
inside it; the latency-only app does not) -- hence the app-neutral
|
|
21
|
+
`LATENCY_SCOPE`.
|
|
22
|
+
When the app also printed `LATENCY-RESULT`, its own `ms_per_inference` wins
|
|
23
|
+
(tan-cli#1404): the app derives it from its raw cycle counts over every good
|
|
24
|
+
window (all inferences, not a median of per-window spans), so it can differ
|
|
25
|
+
slightly from the window-derived figure; the device is the authority on its
|
|
26
|
+
own latency.
|
|
27
|
+
* `peakSramKib` is ENERGY-CFG `sram_peak_bytes` / 1024 when the app reports it,
|
|
28
|
+
else `None`. The model name is ENERGY-CFG `model`.
|
|
29
|
+
* `RunResult.power_mj` stays `None`; energy rides in `RunResult.energy`, a
|
|
30
|
+
labelled board-level carrier-rail delta (`EnergyMeasurement`), only when the
|
|
31
|
+
capture holds usable active+idle sample pairs. Pairs the app itself marked
|
|
32
|
+
`ENERGY-PAIR n SKIPPED` and windows that timed out are excluded from energy
|
|
33
|
+
(timed-out windows from latency too) and listed as degraded evidence.
|
|
34
|
+
* A capture with `ENERGY-W` lines but no `ENERGY-S` stream is a valid
|
|
35
|
+
latency-only capture.
|
|
36
|
+
* `ENERGY-S` cycle stamps are a uint32 counter: one wrap per window is handled
|
|
37
|
+
by the modular delta; a counter that steps BACKWARDS is indistinguishable from
|
|
38
|
+
a wrap, so non-monotonic samples are not detected (a limit of the protocol).
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import math
|
|
44
|
+
import re
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from statistics import mean, median, stdev
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
from tan.model.measure import EnergyMeasurement, RunResult, windowed_delta
|
|
50
|
+
|
|
51
|
+
#: +-5% agreement band between the ENERGY-W-measured clock and the DT constant.
|
|
52
|
+
_CYCLES_PER_S_TOLERANCE = 0.05
|
|
53
|
+
|
|
54
|
+
#: ENERGY-CFG `timestamp_source` whose spans share `cycles_per_s`'s own clock.
|
|
55
|
+
_KERNEL_CLOCK = "k-cycle-get-32"
|
|
56
|
+
|
|
57
|
+
#: What `latencyMs` is, carried in every device row.
|
|
58
|
+
LATENCY_SCOPE = "window-span-per-inference"
|
|
59
|
+
|
|
60
|
+
#: `latencyMs` when the app's own LATENCY-RESULT `ms_per_inference` is used instead.
|
|
61
|
+
#: That value is the app's, computed from the nominal `cycles_per_s`; it does NOT
|
|
62
|
+
#: go through tan's measured-clock reconciliation, and may time a different set of
|
|
63
|
+
#: inferences than the ENERGY-W active windows.
|
|
64
|
+
LATENCY_SCOPE_DEVICE_RESULT = "device-latency-result"
|
|
65
|
+
|
|
66
|
+
_U32 = 1 << 32
|
|
67
|
+
_WERR_RE = re.compile(r"^ENERGY-WERR (\d+) (active|idle) timed_out=(\d)")
|
|
68
|
+
_WARN_WRAP_RE = re.compile(r"^ENERGY-WARN (active|idle) window (\d+) ms exceeds the cycle-counter wrap")
|
|
69
|
+
_PAIR_SKIP_RE = re.compile(r"^ENERGY-PAIR (\d+) SKIPPED")
|
|
70
|
+
|
|
71
|
+
#: The app's own verdict that its energy delta is noise (tan-cli#1387).
|
|
72
|
+
_DEVICE_UNRESOLVED = "RESULT FAIL: delta not resolvable"
|
|
73
|
+
|
|
74
|
+
#: aen-inference-energy's `NOISE_SIGMA`: a pair-to-pair mean must clear this
|
|
75
|
+
#: many standard errors of the mean (`spread / sqrt(pairs)`) to be a result.
|
|
76
|
+
#: Used only when the capture carries no device verdict of its own.
|
|
77
|
+
NOISE_SIGMA = 3.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DeviceCaptureError(Exception):
|
|
81
|
+
"""The console capture is missing pieces, malformed or from an older app."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class ParsedCapture:
|
|
86
|
+
"""A validated console capture. `samples` / `uptime_spans` are keyed
|
|
87
|
+
[window][phase] ("active"/"idle"); spans are `(n_samples, span_cycles,
|
|
88
|
+
span_ms, inferences)`. `werr`/`warn`/`pair_skips` keep the raw evidence
|
|
89
|
+
lines in capture order; `timed_out` is the (window, phase) set the app said
|
|
90
|
+
missed its deadline, `skipped` the windows it excluded itself, `wrapped`
|
|
91
|
+
the (window, phase) spans whose cycle count the app flagged as wrapped."""
|
|
92
|
+
cfg: dict[str, Any]
|
|
93
|
+
samples: dict[int, dict[str, list[tuple[int, int]]]]
|
|
94
|
+
uptime_spans: dict[int, dict[str, tuple[int, int, float, int]]]
|
|
95
|
+
device_result: dict[str, Any] | None
|
|
96
|
+
werr: list[str]
|
|
97
|
+
warn: list[str]
|
|
98
|
+
pair_skips: list[str] = field(default_factory=list)
|
|
99
|
+
timed_out: frozenset = frozenset()
|
|
100
|
+
skipped: frozenset = frozenset()
|
|
101
|
+
wrapped: frozenset = frozenset()
|
|
102
|
+
latency_result: dict[str, Any] | None = None
|
|
103
|
+
#: The app's `RESULT PASS: ... mJ/inference` or `RESULT FAIL: delta not
|
|
104
|
+
#: resolvable` line, whichever came last; `None` when it printed neither.
|
|
105
|
+
energy_verdict: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _json_object(text: str, what: str) -> dict[str, Any]:
|
|
109
|
+
try:
|
|
110
|
+
value = json.loads(text)
|
|
111
|
+
except (ValueError, RecursionError) as exc: # JSONDecodeError, int-digit limit, deep nesting
|
|
112
|
+
raise DeviceCaptureError(f"malformed {what} line") from exc
|
|
113
|
+
if not isinstance(value, dict):
|
|
114
|
+
raise DeviceCaptureError(f"{what} must be a JSON object, got {type(value).__name__}")
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _number(value: Any, what: str, *, positive: bool = True) -> float:
|
|
119
|
+
"""`value` as a finite float > 0 (or >= 0), else `DeviceCaptureError`."""
|
|
120
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
121
|
+
raise DeviceCaptureError(f"{what} must be a number, got {value!r}")
|
|
122
|
+
try:
|
|
123
|
+
number = float(value)
|
|
124
|
+
except OverflowError as exc:
|
|
125
|
+
raise DeviceCaptureError(f"{what} is out of range") from exc
|
|
126
|
+
if not math.isfinite(number) or number < 0 or (positive and number == 0):
|
|
127
|
+
raise DeviceCaptureError(f"{what} must be finite and {'> 0' if positive else '>= 0'}, got {value!r}")
|
|
128
|
+
return number
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _uint(token: str, what: str, line: str, *, limit: int | None = None) -> int:
|
|
132
|
+
try:
|
|
133
|
+
value = int(token)
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise DeviceCaptureError(f"malformed {what} in {line!r}") from exc
|
|
136
|
+
if value < 0 or (limit is not None and value >= limit):
|
|
137
|
+
raise DeviceCaptureError(f"{what} out of range in {line!r}")
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_w(parts: list[str], line: str) -> tuple[int, str, tuple[int, int, float, int]]:
|
|
142
|
+
if len(parts) == 6 and parts[2] in ("active", "idle"):
|
|
143
|
+
raise DeviceCaptureError(
|
|
144
|
+
f"ENERGY-W line has 5 fields, no per-window inference count: {line!r} -- "
|
|
145
|
+
"this capture is from an older firmware image that predates per-window "
|
|
146
|
+
"ENERGY-W inference reporting; re-flash the current aen-inference-energy build")
|
|
147
|
+
if len(parts) != 7 or parts[2] not in ("active", "idle"):
|
|
148
|
+
raise DeviceCaptureError(f"malformed ENERGY-W line: {line!r}")
|
|
149
|
+
window = _uint(parts[1], "window", line)
|
|
150
|
+
n_samples = _uint(parts[3], "sample count", line)
|
|
151
|
+
span_cycles = _uint(parts[4], "span_cycles", line, limit=_U32)
|
|
152
|
+
try:
|
|
153
|
+
span_ms = float(parts[5])
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
raise DeviceCaptureError(f"malformed span_ms in {line!r}") from exc
|
|
156
|
+
if not math.isfinite(span_ms) or span_ms <= 0 or span_cycles <= 0:
|
|
157
|
+
raise DeviceCaptureError(f"span must be finite and > 0 in {line!r}")
|
|
158
|
+
return window, parts[2], (n_samples, span_cycles, span_ms, _uint(parts[6], "inferences", line))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_console(text: str) -> ParsedCapture:
|
|
162
|
+
"""Tolerant of interleaved noise (Zephyr banner, other printk, ENERGY-SCAN);
|
|
163
|
+
strict about every recognised line. Raises `DeviceCaptureError` on a
|
|
164
|
+
missing/malformed/invalid header, a malformed or out-of-range line, a
|
|
165
|
+
duplicate ENERGY-W, a window/phase with fewer than 2 samples, an
|
|
166
|
+
old-firmware ENERGY-W, an ENERGY-WPART partial window, or no ENERGY-W at all."""
|
|
167
|
+
cfg: dict[str, Any] | None = None
|
|
168
|
+
samples: dict[int, dict[str, list[tuple[int, int]]]] = {}
|
|
169
|
+
spans: dict[int, dict[str, tuple[int, int, float, int]]] = {}
|
|
170
|
+
device_result: dict[str, Any] | None = None
|
|
171
|
+
latency_result: dict[str, Any] | None = None
|
|
172
|
+
werr: list[str] = []
|
|
173
|
+
warn: list[str] = []
|
|
174
|
+
pair_skips: list[str] = []
|
|
175
|
+
timed_out: set[tuple[int, str]] = set()
|
|
176
|
+
skipped: set[int] = set()
|
|
177
|
+
wrapped: set[tuple[int, str]] = set()
|
|
178
|
+
energy_verdict: str | None = None
|
|
179
|
+
|
|
180
|
+
for raw in text.splitlines():
|
|
181
|
+
line = raw.strip()
|
|
182
|
+
parts = line.split()
|
|
183
|
+
if line.startswith("ENERGY-CFG "):
|
|
184
|
+
cfg = _json_object(line[len("ENERGY-CFG "):], "ENERGY-CFG")
|
|
185
|
+
elif line.startswith("ENERGY-S "):
|
|
186
|
+
if len(parts) != 5 or parts[2] not in ("active", "idle"):
|
|
187
|
+
raise DeviceCaptureError(f"malformed ENERGY-S line: {line!r}")
|
|
188
|
+
window = _uint(parts[1], "window", line)
|
|
189
|
+
cycles = _uint(parts[3], "cycle stamp", line, limit=_U32)
|
|
190
|
+
power = _uint(parts[4], "power count", line, limit=_U32)
|
|
191
|
+
samples.setdefault(window, {}).setdefault(parts[2], []).append((cycles, power))
|
|
192
|
+
elif line.startswith("ENERGY-W "):
|
|
193
|
+
window, phase, span = _parse_w(parts, line)
|
|
194
|
+
if phase in spans.get(window, {}):
|
|
195
|
+
raise DeviceCaptureError(f"duplicate ENERGY-W for window {window} {phase}: {line!r}")
|
|
196
|
+
spans.setdefault(window, {})[phase] = span
|
|
197
|
+
elif line.startswith("ENERGY-WPART "):
|
|
198
|
+
raise DeviceCaptureError(
|
|
199
|
+
f"a window emitted a partial sample stream ({line!r}) -- the device integral "
|
|
200
|
+
"is authoritative for this build and `--device` cannot re-derive energy from "
|
|
201
|
+
"a partial window")
|
|
202
|
+
elif line.startswith("ENERGY-WERR "):
|
|
203
|
+
werr.append(line)
|
|
204
|
+
m = _WERR_RE.match(line)
|
|
205
|
+
if m and m.group(3) == "1":
|
|
206
|
+
timed_out.add((int(m.group(1)), m.group(2)))
|
|
207
|
+
elif line.startswith("ENERGY-WARN "):
|
|
208
|
+
warn.append(line)
|
|
209
|
+
m = _WARN_WRAP_RE.match(line)
|
|
210
|
+
if m:
|
|
211
|
+
wrapped.add((int(m.group(2)), m.group(1)))
|
|
212
|
+
elif line.startswith("LATENCY-WARN "):
|
|
213
|
+
warn.append(line)
|
|
214
|
+
elif line.startswith("RESULT ") and " WARN: " in line:
|
|
215
|
+
warn.append("WARN: " + line.split(" WARN: ", 1)[1])
|
|
216
|
+
elif line.startswith("RESULT FAIL:"):
|
|
217
|
+
warn.append(line)
|
|
218
|
+
if line.startswith(_DEVICE_UNRESOLVED):
|
|
219
|
+
energy_verdict = line
|
|
220
|
+
elif line.startswith("RESULT PASS:") and "mJ/inference" in line:
|
|
221
|
+
energy_verdict = line
|
|
222
|
+
elif line.startswith("ENERGY-PAIR "):
|
|
223
|
+
m = _PAIR_SKIP_RE.match(line)
|
|
224
|
+
if m:
|
|
225
|
+
pair_skips.append(line)
|
|
226
|
+
skipped.add(int(m.group(1)))
|
|
227
|
+
elif line.startswith("ENERGY-RESULT "):
|
|
228
|
+
device_result = _json_object(line[len("ENERGY-RESULT "):], "ENERGY-RESULT")
|
|
229
|
+
elif line.startswith("LATENCY-RESULT "):
|
|
230
|
+
latency_result = _json_object(line[len("LATENCY-RESULT "):], "LATENCY-RESULT")
|
|
231
|
+
# else: banner / printk noise (incl. ENERGY-SCAN) -- ignored.
|
|
232
|
+
|
|
233
|
+
if cfg is None:
|
|
234
|
+
raise DeviceCaptureError("no ENERGY-CFG header line found in capture")
|
|
235
|
+
_number(cfg.get("cycles_per_s"), "ENERGY-CFG cycles_per_s")
|
|
236
|
+
if "power_lsb_w" in cfg:
|
|
237
|
+
_number(cfg["power_lsb_w"], "ENERGY-CFG power_lsb_w")
|
|
238
|
+
if "rail" in cfg and not isinstance(cfg["rail"], str):
|
|
239
|
+
raise DeviceCaptureError("ENERGY-CFG rail must be a string")
|
|
240
|
+
if not spans:
|
|
241
|
+
raise DeviceCaptureError("no ENERGY-W lines found in capture")
|
|
242
|
+
for window, phases in samples.items():
|
|
243
|
+
for phase, points in phases.items():
|
|
244
|
+
if len(points) < 2:
|
|
245
|
+
raise DeviceCaptureError(f"window {window} phase {phase!r} has {len(points)} sample(s); need >= 2")
|
|
246
|
+
return ParsedCapture(cfg, samples, spans, device_result, werr, warn, pair_skips,
|
|
247
|
+
frozenset(timed_out), frozenset(skipped), frozenset(wrapped), latency_result,
|
|
248
|
+
energy_verdict)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _samples_to_power(points: list[tuple[int, int]], cycles_per_s: float,
|
|
252
|
+
power_lsb_w: float) -> list[tuple[float, float]]:
|
|
253
|
+
"""(cycles, power_raw) -> (t_seconds, power_watts); t is the unsigned delta
|
|
254
|
+
from the window's first stamp, mod 2**32 (one wrap per window is correct; see
|
|
255
|
+
the module docstring for the backwards-counter limit)."""
|
|
256
|
+
if not points:
|
|
257
|
+
return []
|
|
258
|
+
t0 = points[0][0]
|
|
259
|
+
return [(((cycles - t0) % _U32) / cycles_per_s, power * power_lsb_w) for cycles, power in points]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _span_rates(parsed: ParsedCapture) -> list[float]:
|
|
263
|
+
"""Cycles per second implied by every ENERGY-W span, unwrapped and not timed
|
|
264
|
+
out (a window the app reported ENERGY-WERR timed_out=1 for ends early, so its
|
|
265
|
+
span says nothing about the clock; latency already excludes it)."""
|
|
266
|
+
return [
|
|
267
|
+
span[1] / span[2] * 1000.0
|
|
268
|
+
for window, phases in parsed.uptime_spans.items()
|
|
269
|
+
for phase, span in phases.items()
|
|
270
|
+
if (window, phase) not in parsed.wrapped and (window, phase) not in parsed.timed_out
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _cycles_per_s(parsed: ParsedCapture) -> tuple[float, float | None]:
|
|
275
|
+
"""`(used, measured)`: the median span-implied clock when every span agrees
|
|
276
|
+
with the others within `_CYCLES_PER_S_TOLERANCE`, else the DT constant."""
|
|
277
|
+
dt = float(parsed.cfg["cycles_per_s"])
|
|
278
|
+
rates = _span_rates(parsed)
|
|
279
|
+
if not rates:
|
|
280
|
+
return dt, None
|
|
281
|
+
measured = median(rates)
|
|
282
|
+
if parsed.cfg.get("timestamp_source") == _KERNEL_CLOCK:
|
|
283
|
+
# Spans and `cycles_per_s` come from the SAME kernel clock, and the span's
|
|
284
|
+
# ms field is an integer tick, so span_cycles / span_ms is a rounding
|
|
285
|
+
# artefact, not a second opinion on the clock: use the constant, and
|
|
286
|
+
# report the ratio informationally only.
|
|
287
|
+
return dt, measured
|
|
288
|
+
if (max(rates) - min(rates)) / measured > _CYCLES_PER_S_TOLERANCE:
|
|
289
|
+
return dt, measured
|
|
290
|
+
return measured, measured
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _energy_windows(parsed: ParsedCapture) -> list[int]:
|
|
294
|
+
return [
|
|
295
|
+
w for w in sorted(parsed.samples)
|
|
296
|
+
if {"active", "idle"} <= set(parsed.samples[w])
|
|
297
|
+
and w not in parsed.skipped
|
|
298
|
+
and (w, "active") not in parsed.timed_out
|
|
299
|
+
and (w, "idle") not in parsed.timed_out
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def measurement_from_capture(parsed: ParsedCapture) -> EnergyMeasurement:
|
|
304
|
+
"""Per usable (active, idle) pair: `windowed_delta` over that pair's own
|
|
305
|
+
active inference count (ENERGY-W's last field). Excludes pairs the app
|
|
306
|
+
marked SKIPPED and windows that timed out. `value` is the mean across pairs,
|
|
307
|
+
`spread_mj` the sample stdev (None for one pair)."""
|
|
308
|
+
cfg = parsed.cfg
|
|
309
|
+
if not isinstance(cfg.get("rail"), str) or not cfg["rail"]:
|
|
310
|
+
raise DeviceCaptureError("ENERGY-CFG rail must be a non-empty string to derive energy")
|
|
311
|
+
power_lsb_w = _number(cfg.get("power_lsb_w"), "ENERGY-CFG power_lsb_w")
|
|
312
|
+
cycles_per_s, _ = _cycles_per_s(parsed)
|
|
313
|
+
windows = _energy_windows(parsed)
|
|
314
|
+
if not windows:
|
|
315
|
+
raise DeviceCaptureError("no usable (active, idle) sample pair (skipped, timed-out or incomplete)")
|
|
316
|
+
per_window, window_ms, total_samples, total_inferences = [], [], 0, 0
|
|
317
|
+
for w in windows:
|
|
318
|
+
active_span = parsed.uptime_spans.get(w, {}).get("active")
|
|
319
|
+
if active_span is None:
|
|
320
|
+
raise DeviceCaptureError(f"window {w} has no ENERGY-W line for phase 'active'")
|
|
321
|
+
n_inferences = active_span[3]
|
|
322
|
+
if n_inferences < 1:
|
|
323
|
+
raise DeviceCaptureError(f"window {w} active phase completed 0 inferences; no mJ/inference divisor")
|
|
324
|
+
active = _samples_to_power(parsed.samples[w]["active"], cycles_per_s, power_lsb_w)
|
|
325
|
+
idle = _samples_to_power(parsed.samples[w]["idle"], cycles_per_s, power_lsb_w)
|
|
326
|
+
per_window.append(windowed_delta(active, idle, n_inferences))
|
|
327
|
+
window_ms.append((active[-1][0] - active[0][0]) * 1000.0)
|
|
328
|
+
total_samples += len(active) + len(idle)
|
|
329
|
+
total_inferences += n_inferences
|
|
330
|
+
return EnergyMeasurement(
|
|
331
|
+
value_mj_per_inference=per_window[0] if len(per_window) == 1 else mean(per_window),
|
|
332
|
+
rails=[cfg["rail"]],
|
|
333
|
+
n_inferences=total_inferences,
|
|
334
|
+
window_ms=mean(window_ms),
|
|
335
|
+
sample_count=total_samples,
|
|
336
|
+
spread_mj=None if len(per_window) < 2 else stdev(per_window),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def capture_diagnostics(parsed: ParsedCapture, energy: EnergyMeasurement | None) -> dict:
|
|
341
|
+
"""Everything `EnergyMeasurement` can't carry (camelCase, envelope-ready):
|
|
342
|
+
the clock reconciliation, the device's own result as a cross-check against
|
|
343
|
+
the ALREADY-computed `energy` (never recomputed here), and the degraded-run
|
|
344
|
+
evidence."""
|
|
345
|
+
used, measured = _cycles_per_s(parsed)
|
|
346
|
+
device_value = None
|
|
347
|
+
if parsed.device_result is not None:
|
|
348
|
+
raw = parsed.device_result.get("value_mj_per_inference")
|
|
349
|
+
device_value = float(raw) if isinstance(raw, (int, float)) and not isinstance(raw, bool) and math.isfinite(raw) else None
|
|
350
|
+
ratio = round(energy.value_mj_per_inference / device_value, 4) if energy and device_value else None
|
|
351
|
+
return {
|
|
352
|
+
"cyclesPerSUsed": used,
|
|
353
|
+
"cyclesPerSDt": float(parsed.cfg["cycles_per_s"]),
|
|
354
|
+
"cyclesPerSMeasured": measured,
|
|
355
|
+
"deviceValueMjPerInference": device_value,
|
|
356
|
+
"hostVsDeviceRatio": ratio,
|
|
357
|
+
"model": parsed.cfg.get("model") if isinstance(parsed.cfg.get("model"), str) else None,
|
|
358
|
+
"npuDispatched": bool(parsed.cfg.get("npu_dispatched", False)),
|
|
359
|
+
"windows": sorted(parsed.uptime_spans),
|
|
360
|
+
"werrLines": list(parsed.werr),
|
|
361
|
+
"warnLines": list(parsed.warn),
|
|
362
|
+
"skippedPairs": list(parsed.pair_skips),
|
|
363
|
+
"energyWindows": _energy_windows(parsed),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def latency_from_capture(parsed: ParsedCapture) -> tuple[float, int, float]:
|
|
368
|
+
"""`(median ms per inference, total active inferences, median cycles per
|
|
369
|
+
inference)` from the active ENERGY-W spans of windows that did not time out.
|
|
370
|
+
Derived from `span_cycles` over the reconciled clock (the span's own
|
|
371
|
+
millisecond field for a window the app flagged as counter-wrapped), rounded
|
|
372
|
+
to what the clock can resolve. Raises when nothing completed an inference."""
|
|
373
|
+
used, _ = _cycles_per_s(parsed)
|
|
374
|
+
per_ms: list[float] = []
|
|
375
|
+
per_cycles: list[float] = []
|
|
376
|
+
total = 0
|
|
377
|
+
for window, phases in parsed.uptime_spans.items():
|
|
378
|
+
span = phases.get("active")
|
|
379
|
+
if span is None or span[3] < 1 or (window, "active") in parsed.timed_out:
|
|
380
|
+
continue
|
|
381
|
+
_n, span_cycles, span_ms, inferences = span
|
|
382
|
+
wrapped = (window, "active") in parsed.wrapped
|
|
383
|
+
per_ms.append((span_ms if wrapped else span_cycles / used * 1000.0) / inferences)
|
|
384
|
+
per_cycles.append(span_cycles / inferences)
|
|
385
|
+
total += inferences
|
|
386
|
+
if not per_ms:
|
|
387
|
+
raise DeviceCaptureError("no active window completed an inference in time; nothing to time")
|
|
388
|
+
resolution_ms = 1000.0 / (used * total)
|
|
389
|
+
return round(median(per_ms), max(0, math.ceil(-math.log10(resolution_ms)))), total, median(per_cycles)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _unresolved_energy(parsed: ParsedCapture, energy: EnergyMeasurement | None) -> dict | None:
|
|
393
|
+
"""tan-cli#1387: the measurement, kept for diagnostics, when its delta is
|
|
394
|
+
noise; `None` when it is a result (or there is none). The app's own verdict
|
|
395
|
+
line wins when it printed one; otherwise the app's rule is applied here:
|
|
396
|
+
with >= 2 pairs the mean must exceed `NOISE_SIGMA` standard errors of the
|
|
397
|
+
mean, with one pair it must be > 0."""
|
|
398
|
+
if energy is None:
|
|
399
|
+
return None
|
|
400
|
+
pairs = len(_energy_windows(parsed))
|
|
401
|
+
value, spread = energy.value_mj_per_inference, energy.spread_mj
|
|
402
|
+
verdict = parsed.energy_verdict
|
|
403
|
+
if verdict is not None:
|
|
404
|
+
if not verdict.startswith(_DEVICE_UNRESOLVED):
|
|
405
|
+
return None
|
|
406
|
+
source = "device"
|
|
407
|
+
elif value > (NOISE_SIGMA * spread / math.sqrt(pairs) if spread is not None else 0.0):
|
|
408
|
+
return None
|
|
409
|
+
else:
|
|
410
|
+
source = "host"
|
|
411
|
+
return {
|
|
412
|
+
"valueMjPerInference": value, "spreadMj": spread, "rail": energy.rails[0],
|
|
413
|
+
"pairs": pairs, "verdict": source, "deviceLine": verdict,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def run_result_from_capture(parsed: ParsedCapture) -> tuple[RunResult, EnergyMeasurement | None, dict]:
|
|
418
|
+
"""The `RunResult` (tier-device schema), the labelled energy measurement when
|
|
419
|
+
the capture supports one (else `None`, with the reason in
|
|
420
|
+
`diagnostics["energyNote"]` when samples existed), and the diagnostics.
|
|
421
|
+
Energy is computed exactly once."""
|
|
422
|
+
latency_ms, inferences, _ = latency_from_capture(parsed)
|
|
423
|
+
energy: EnergyMeasurement | None = None
|
|
424
|
+
note: str | None = None
|
|
425
|
+
if parsed.samples:
|
|
426
|
+
try:
|
|
427
|
+
energy = measurement_from_capture(parsed)
|
|
428
|
+
except (DeviceCaptureError, ArithmeticError) as err:
|
|
429
|
+
note = f"energy not derived: {err}"
|
|
430
|
+
unresolved = _unresolved_energy(parsed, energy)
|
|
431
|
+
if unresolved is not None:
|
|
432
|
+
energy = None
|
|
433
|
+
diagnostics = capture_diagnostics(parsed, energy)
|
|
434
|
+
diagnostics["energyNote"] = note
|
|
435
|
+
diagnostics["unresolvedEnergy"] = unresolved
|
|
436
|
+
diagnostics["windowLatencyMs"] = latency_ms
|
|
437
|
+
diagnostics["latencyScope"] = LATENCY_SCOPE
|
|
438
|
+
if parsed.latency_result is not None and "ms_per_inference" in parsed.latency_result:
|
|
439
|
+
# Device-reported: nominal cycles_per_s, no measured-clock check (see
|
|
440
|
+
# LATENCY_SCOPE_DEVICE_RESULT); the window-derived value stays in diagnostics.
|
|
441
|
+
latency_ms = _number(parsed.latency_result["ms_per_inference"], "LATENCY-RESULT ms_per_inference")
|
|
442
|
+
diagnostics["latencyScope"] = LATENCY_SCOPE_DEVICE_RESULT
|
|
443
|
+
peak_sram_kib = None
|
|
444
|
+
if "sram_peak_bytes" in parsed.cfg:
|
|
445
|
+
peak_sram_kib = _number(parsed.cfg["sram_peak_bytes"], "ENERGY-CFG sram_peak_bytes",
|
|
446
|
+
positive=False) / 1024
|
|
447
|
+
backend = "ethos-u" if parsed.cfg.get("npu_dispatched") else "cpu-device"
|
|
448
|
+
result = RunResult(backend, latency_ms, None, peak_sram_kib, None, inferences, energy)
|
|
449
|
+
return result, energy, diagnostics
|