Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
50e7007
feat: JEV-9B with vision on Core ML (JevManager) + Mario demo
Alex-Wengg Oct 10, 2026
aa8e9eb
JevMarioDemo: Tiny (0.46M-param pixel student) brain alongside JEV-9B
Alex-Wengg Oct 10, 2026
8013d5b
JevMarioDemo: pixel-student brain (~9M params), lazy JEV download, le…
Alex-Wengg Oct 12, 2026
f2ae591
JevMarioDemo: show the student's Core ML op placement (MLComputePlan)
Alex-Wengg Oct 12, 2026
f482e7b
demo.sh / bars.py: no clipped load bars
Alex-Wengg Oct 12, 2026
a91d1a2
demo.sh: MONITOR=asitop runs sudo asitop in the top pane (ANE power)
Alex-Wengg Oct 12, 2026
cad09b6
bars.py --ane: Neural Engine power via sudo powermetrics; demo.sh MON…
Alex-Wengg Oct 12, 2026
9dd12cb
JevMarioDemo: T layout, ANE busy bar, max-speed mode
Alex-Wengg Oct 12, 2026
9753dc4
JevMarioDemo: level playlist (MARIO_PLAYLIST=level[:idle],...)
Alex-Wengg Oct 12, 2026
28b3c0f
JevMarioDemo: level picker grid and resizable window
Alex-Wengg Oct 12, 2026
2eb2872
JevMarioDemo: one level menu + Play instead of the level grid
Alex-Wengg Oct 12, 2026
94fb19d
JevMarioDemo: nothing plays until Play; one level per press
Alex-Wengg Oct 12, 2026
9411800
JevMarioDemo: stable-retro bridge, solver-style student, batched max …
Alex-Wengg Oct 12, 2026
16038bb
JevMarioDemo: fast mode capped at 10x real time (MARIO_SPEED_CAP)
Alex-Wengg Oct 12, 2026
72714fa
JevMarioDemo: fast mode cap 5x by default
Alex-Wengg Oct 12, 2026
cb91e11
JevMarioDemo: student lookahead on emulator save states (78/96, 30/32…
Alex-Wengg Oct 12, 2026
3911030
JevMarioDemo: Play all 23 (levels the student + lookahead clears from…
Alex-Wengg Oct 12, 2026
6a8f75d
JevMarioDemo: compact student panel (2-column move bars), demo.sh lay…
Alex-Wengg Oct 12, 2026
db24ce3
JevMarioDemo: swift-format line lengths
Alex-Wengg Oct 12, 2026
6a8e6ee
JevMarioDemo: @preconcurrency import CoreML (Swift 6.1 CI sendability)
Alex-Wengg Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,13 @@ let package = Package(
.executableTarget(name: "ImageSortDemo", dependencies: ["ImageSort"], exclude: ["README.md"]),
.executableTarget(name: "KevCheck", dependencies: ["FluidUse"]),
.executableTarget(name: "ClefVisionCheck", dependencies: ["FluidUse"]),
.executableTarget(name: "ClefFlashDemo", dependencies: ["FluidUse"], exclude: ["README.md", "demo.sh", "bars.py"]),
.executableTarget(
name: "ClefFlashDemo", dependencies: ["FluidUse"], exclude: ["README.md", "demo.sh", "bars.py"]),
.executableTarget(name: "ClefTextCheck", dependencies: ["FluidUse"]),
.executableTarget(name: "JevCheck", dependencies: ["FluidUse"]),
.executableTarget(
name: "JevMarioDemo", dependencies: ["FluidUse"],
exclude: ["README.md", "demo.sh", "nes_bridge.py", "retro_bridge.py"]),
.executableTarget(name: "ClefCompareDemo", dependencies: ["FluidUse"], exclude: ["README.md", "demo.sh"]),
.executableTarget(name: "InternDecisionCheck", dependencies: ["FluidUse"]),
.executableTarget(name: "Decision2Check", dependencies: ["FluidUse"]),
Expand All @@ -79,7 +84,8 @@ let package = Package(
.executableTarget(name: "GuardrailDemo", dependencies: ["FluidUse"], exclude: ["README.md"]),
.executableTarget(name: "FrontDoorDemo", dependencies: ["FluidUse"], exclude: ["README.md"]),
.executableTarget(name: "ShortReplyCheck", dependencies: ["FluidUse"]),
.executableTarget(name: "ShortReplyDemo", dependencies: ["FluidUse"], exclude: ["README.md", "demo.sh", "mock-feed"]),
.executableTarget(
name: "ShortReplyDemo", dependencies: ["FluidUse"], exclude: ["README.md", "demo.sh", "mock-feed"]),
.executableTarget(name: "GLiClassServe", dependencies: ["FluidUse"]),
.executableTarget(
name: "KevGuessWhoDemo", dependencies: ["FluidUse", "SortAnything"], exclude: ["README.md", "demo.sh"]),
Expand Down
115 changes: 112 additions & 3 deletions Sources/ClefFlashDemo/bars.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
#!/usr/bin/env python3
"""Live bar charts for the demo's top pane: CPU / GPU utilisation and GPU power from `macmon pipe` (no sudo).
"""Live bar charts for the demo's top pane: CPU / GPU utilisation and GPU power from `macmon pipe` (no sudo); with
--ane also Neural Engine power from `sudo powermetrics` (asks for the password once).

On M5 Pro / macOS 27, macmon's CPU and ANE power read 0 W and powermetrics' GPU power reads milliwatts while the GPU
is busy, so this shows the counters that do track load: P-/E-cluster usage, GPU usage and GPU watts.
"""
from __future__ import annotations

import json
import plistlib
import shutil
import subprocess
import sys
import threading
import time

BAR = "█"
COLORS = {"P-CPU": "\033[38;5;75m", "E-CPU": "\033[38;5;111m", "GPU": "\033[38;5;214m", "GPU W": "\033[38;5;203m"}
COLORS = {"P-CPU": "\033[38;5;75m", "E-CPU": "\033[38;5;111m", "GPU": "\033[38;5;214m", "GPU W": "\033[38;5;203m",
"ANE W": "\033[38;5;120m", "ANE": "\033[38;5;84m"}
RESET, DIM, BOLD = "\033[0m", "\033[2m", "\033[1m"


Expand All @@ -21,7 +28,93 @@ def bar(label: str, fraction: float, text: str, width: int) -> str:
f"{text}")


def find_ane_watts(node) -> float | None:
"""ANE power from a powermetrics plist sample (key names vary by macOS / chip: ane_power in mW, ane_energy ...)."""
if isinstance(node, dict):
for key, value in node.items():
k = key.lower()
if "ane" in k and "power" in k and isinstance(value, (int, float)):
return float(value) / 1000 # mW
for value in node.values():
found = find_ane_watts(value)
if found is not None:
return found
elif isinstance(node, list):
for value in node:
found = find_ane_watts(value)
if found is not None:
return found
return None


class AneMeter:
"""`sudo powermetrics --samplers ane_power` (macmon's ANE power reads 0 W on macOS 27). Latest watts + peak."""

def __init__(self):
self.watts, self.peak, self.error = 0.0, 0.0, None
self.process = subprocess.Popen(
["sudo", "powermetrics", "--samplers", "ane_power", "-i", "500", "-f", "plist"], stdout=subprocess.PIPE)
threading.Thread(target=self.read, daemon=True).start()

def read(self):
buffer = b""
for chunk in iter(lambda: self.process.stdout.read(4096), b""):
buffer += chunk
while b"\0" in buffer:
sample, buffer = buffer.split(b"\0", 1)
try:
watts = find_ane_watts(plistlib.loads(sample.strip()))
except Exception as e: # noqa: BLE001
self.error = str(e)[:60]
continue
if watts is None:
self.error = "no ANE power key in powermetrics output"
continue
self.watts, self.peak = watts, max(self.peak, watts)


class AneBusy:
"""Neural Engine busy time from the demo's own decision log (`... 0.55 ms` per line): the model's ops all run on
the ANE (MLComputePlan), so model milliseconds over the last second = ANE busy %. No sudo, no hardware counter."""

def __init__(self, path: str, window: float = 1.0):
self.window, self.events, self.placement = window, [], ""
try: # the placement line is printed once at load, possibly before this pane started
self.placement = next((l.split(",", 1)[-1].strip() for l in open(path) if "Neural Engine" in l), "")
except OSError:
pass
self.process = subprocess.Popen(["tail", "-n", "0", "-F", path], stdout=subprocess.PIPE, text=True,
stderr=subprocess.DEVNULL)
threading.Thread(target=self.read, daemon=True).start()

def read(self):
for line in self.process.stdout:
parts = line.split()
if "Neural Engine" in line and "ops" in line:
self.placement = line.split(",", 1)[-1].strip()
if len(parts) >= 2 and parts[-1] == "ms":
try:
self.events.append((time.time(), float(parts[-2])))
except ValueError:
pass

def stats(self) -> tuple[float, float, float]:
"""(busy fraction, median ms per decision, decisions per second) over the window."""
now = time.time()
self.events = [e for e in self.events if now - e[0] <= max(self.window, 5.0)]
recent = [ms for t, ms in self.events if now - t <= self.window]
busy = sum(recent) / (self.window * 1000)
ms = sorted(recent)[len(recent) // 2] if recent else 0.0
return busy, ms, len(recent) / self.window


def main() -> None:
busy = AneBusy(sys.argv[sys.argv.index("--log") + 1]) if "--log" in sys.argv else None
ane = None
if "--ane" in sys.argv:
print("ANE power needs `sudo powermetrics`:", flush=True)
subprocess.run(["sudo", "-v"]) # password prompt here, before the screen starts redrawing
ane = AneMeter()
process = subprocess.Popen(["macmon", "pipe", "-i", "500"], stdout=subprocess.PIPE, text=True)
gpu_watts_max = 30.0
sys.stdout.write("\033[?25l") # hide cursor
Expand All @@ -31,7 +124,8 @@ def main() -> None:
d = json.loads(line)
except json.JSONDecodeError:
continue
width = max(10, shutil.get_terminal_size().columns - 30)
# label (9) + space + bar + space + the widest right-hand text, so nothing clips at any window width
width = max(10, shutil.get_terminal_size().columns - 9 - 2 - (24 if busy else 15) - 2)
p_mhz, p_use = d["pcpu_usage"]
e_mhz, e_use = d["ecpu_usage"]
g_mhz, g_use = d["gpu_usage"]
Expand All @@ -46,6 +140,21 @@ def main() -> None:
bar("GPU", g_use, f"{g_use * 100:5.1f}% {g_mhz:4d} MHz", width),
bar("GPU W", g_watts / gpu_watts_max, f"{g_watts:5.1f} W", width),
]
if busy is not None:
fraction, ms, rate = busy.stats()
# the bar is drawn at least one cell while the model runs: busy time is a fraction of a percent
cells = max(1 if rate else 0, int(round(fraction * width)))
rows.append(f" {BOLD}{'ANE':<6}{RESET} {COLORS['ANE']}{BAR * cells}{RESET}{DIM}{'·' * (width - cells)}{RESET} "
f"{fraction * 100:5.2f}% {ms:4.2f} ms × {rate:.0f}/s")
if busy.placement:
ops = busy.placement.split("·")[-1].strip()
rows.append(f" {DIM}ANE % = model time per second · {ops} on the Neural Engine{RESET}")
if ane is not None:
# no utilisation counter exists for the ANE; power vs. a 2 W scale (or the session peak) stands in
scale = max(2.0, ane.peak)
rows.append(bar("ANE W", ane.watts / scale, f"{ane.watts * 1000:5.0f} mW", width))
if ane.error:
rows.append(f" {DIM}ANE: {ane.error}{RESET}")
if ram:
rows += ["", f" {DIM}RAM {ram.get('ram_usage', 0) / 2**30:.1f} / {ram.get('ram_total', 0) / 2**30:.0f} GB"
f" swap {ram.get('swap_usage', 0) / 2**30:.1f} GB{RESET}"]
Expand Down
Loading
Loading