Turns a branch of accumulated edge/CPU work into something someone else can
read, reproduce and continue.
README.md states what the fork is for (multimodal models on edge CPUs rather
than servers), the headline memory and speed results with their costs, and why
the numbers are worth reading -- five retracted conclusions, all measurement
errors rather than coding errors. Upstream's README is preserved at
docs/README.vllm-omni.md and NOTICE records the Apache-2.0 derivation and the
files this fork changes.
docs/edge/:
methodology.md the measurement discipline, which is the most transferable
thing here: what each of the five retractions was, and the
rule it left behind. Includes the two traps specific to this
stack -- vLLM's AOT compile cache keying on config so two
arms sharing an artifact load the wrong graph, and allocated
not being resident until mincore(2) says so.
results.md every measured figure with its spread and baseline, and an
explicit note that the four memory levers overlap and must
not be added.
cpu-4bit.md the two 4-bit paths, the packed layout as measured rather
than assumed, why the path choice is a config field, and the
GPTQ output-head trap that invalidated a whole round of W4A8
numbers.
harness.md how to run a comparison, and the two arguments that matter
more than they look (--context-len, --prompt-len), each of
which has produced a wrong conclusion by being left at its
default.
ROADMAP.md orders by whether the verification method is correct rather than by
expected size, lists what is done with results, what is next with expected
gains and how each would be verified, and -- at equal length -- what was
considered and rejected, including the CPU megakernel with the measurements
that rule it out for now.
benchmarks/edge_harness/ brings the tooling into the repo so the numbers are
reproducible: the grid runner, the single-run benchmarks, the memory ledger,
the fidelity metric, the Android agent suite, the quantizers, and the tests
that pin the harness's judgement. 82 tests pass from the new location.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""Where the agent's actions actually land.
|
|
|
|
Two backends behind one interface. `AdbDevice` talks to a phone over `adb`.
|
|
`SimulatedDevice` runs a small scripted phone in-process, and exists for a
|
|
reason beyond convenience: no Android device, emulator or `/dev/kvm` is
|
|
reachable from the host this was built on, so without it the loop, the tool
|
|
executor and the stopping rules would all be untested code shipped on a
|
|
promise.
|
|
|
|
The simulator emits **real uiautomator XML**, not pre-rendered text, so the
|
|
parser, the index-to-coordinate mapping and the screen-change detection are
|
|
exercised on the same path a phone would drive. What it cannot test is
|
|
whether a real Settings app is laid out the way the fixtures assume; that is
|
|
stated in the report rather than hidden.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
from xml.sax.saxutils import escape as _xml_escape
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, Protocol
|
|
|
|
KEYCODES = {
|
|
"back": "KEYCODE_BACK",
|
|
"home": "KEYCODE_HOME",
|
|
"enter": "KEYCODE_ENTER",
|
|
"delete": "KEYCODE_DEL",
|
|
"recent": "KEYCODE_APP_SWITCH",
|
|
}
|
|
|
|
|
|
class Device(Protocol):
|
|
def dump(self) -> str: ...
|
|
def tap(self, x: int, y: int) -> None: ...
|
|
def input_text(self, text: str) -> None: ...
|
|
def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> None: ...
|
|
def keyevent(self, key: str) -> None: ...
|
|
def launch(self, app: str) -> None: ...
|
|
def size(self) -> tuple[int, int]: ...
|
|
|
|
|
|
class AdbError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class AdbDevice:
|
|
"""A real phone over `adb`."""
|
|
|
|
def __init__(self, serial: str | None = None, adb: str = "adb",
|
|
timeout: float = 30.0):
|
|
if shutil.which(adb) is None:
|
|
raise AdbError(
|
|
f"{adb!r} is not on PATH. Install platform-tools and connect a "
|
|
"device (`adb devices` should list it)."
|
|
)
|
|
self.adb, self.serial, self.timeout = adb, serial, timeout
|
|
self._size: tuple[int, int] | None = None
|
|
|
|
def _run(self, *args: str, binary: bool = False):
|
|
cmd = [self.adb] + (["-s", self.serial] if self.serial else []) + list(args)
|
|
proc = subprocess.run(cmd, capture_output=True, timeout=self.timeout)
|
|
if proc.returncode != 0:
|
|
raise AdbError(f"{' '.join(cmd)} failed: {proc.stderr.decode(errors='replace')[:400]}")
|
|
return proc.stdout if binary else proc.stdout.decode(errors="replace")
|
|
|
|
def dump(self) -> str:
|
|
# exec-out keeps the XML on stdout instead of round-tripping through
|
|
# /sdcard, which is both faster and avoids a writable-storage
|
|
# assumption that does not hold on every device.
|
|
out = self._run("exec-out", "uiautomator", "dump", "/dev/tty")
|
|
start = out.find("<?xml")
|
|
if start < 0:
|
|
start = out.find("<hierarchy")
|
|
if start < 0:
|
|
raise AdbError(f"no UI hierarchy in dump output: {out[:200]!r}")
|
|
end = out.rfind("</hierarchy>")
|
|
return out[start:end + len("</hierarchy>")] if end > 0 else out[start:]
|
|
|
|
def tap(self, x: int, y: int) -> None:
|
|
self._run("shell", "input", "tap", str(x), str(y))
|
|
|
|
def input_text(self, text: str) -> None:
|
|
# `input text` takes a single token: spaces become %s and the shell
|
|
# metacharacters have to go, or half the string is eaten.
|
|
escaped = text.replace(" ", "%s")
|
|
for ch in "()<>|;&*\\~\"'`$":
|
|
escaped = escaped.replace(ch, "\\" + ch)
|
|
self._run("shell", "input", "text", escaped)
|
|
|
|
def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> None:
|
|
self._run("shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(ms))
|
|
|
|
def keyevent(self, key: str) -> None:
|
|
code = KEYCODES.get(key, key)
|
|
self._run("shell", "input", "keyevent", code)
|
|
|
|
def launch(self, app: str) -> None:
|
|
"""Launch by package name, or by visible label via the launcher."""
|
|
if "." in app:
|
|
self._run("shell", "monkey", "-p", app, "-c",
|
|
"android.intent.category.LAUNCHER", "1")
|
|
return
|
|
raise AdbError(
|
|
f"open_app needs a package name, got {app!r}. Resolve the label to a "
|
|
"package first (`adb shell pm list packages`) or tap its icon."
|
|
)
|
|
|
|
def size(self) -> tuple[int, int]:
|
|
if self._size is None:
|
|
out = self._run("shell", "wm", "size")
|
|
part = out.strip().rsplit(":", 1)[-1].strip()
|
|
w, h = part.split("x")
|
|
self._size = (int(w), int(h))
|
|
return self._size
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Simulated phone
|
|
# --------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class SimElement:
|
|
label: str
|
|
cls: str = "android.widget.TextView"
|
|
clickable: bool = True
|
|
checkable: bool = False
|
|
checked: bool = False
|
|
selected: bool = False
|
|
editable: bool = False
|
|
goto: str | None = None
|
|
"""Screen this element navigates to when tapped."""
|
|
toggles: str | None = None
|
|
"""State key this element flips when tapped."""
|
|
saves: str | None = None
|
|
"""Record key. Tapping commits the current field values into
|
|
``records[saves]`` and clears them -- the difference between typing a time
|
|
and having an alarm, which the scored suite previously could not tell."""
|
|
label_of: str | None = None
|
|
"""State key whose value picks the label from ``labels``."""
|
|
labels: tuple[str, str] = ("", "")
|
|
"""(label when the state key is false, label when true)."""
|
|
|
|
def rendered_label(self, state: dict) -> str:
|
|
if self.label_of:
|
|
return self.labels[bool(state.get(self.label_of))]
|
|
return self.label
|
|
|
|
|
|
@dataclass
|
|
class SimScreen:
|
|
screen_id: str
|
|
elements: list[SimElement]
|
|
back: str | None = None
|
|
dynamic: "Callable[[SimulatedDevice], list[SimElement]] | None" = None
|
|
"""Rows derived from device state, appended after ``elements``. A saved
|
|
alarm has to appear *on the screen*, or an oracle that reads the screen --
|
|
which is the only kind a real phone can have -- cannot tell a committed
|
|
alarm from a typed one."""
|
|
|
|
|
|
@dataclass
|
|
class SimulatedDevice:
|
|
"""A scripted phone that speaks uiautomator XML.
|
|
|
|
Deliberately small. It is here to prove the loop terminates, recovers and
|
|
stops -- not to model Android.
|
|
"""
|
|
|
|
screens: dict[str, SimScreen]
|
|
current: str
|
|
state: dict[str, bool] = field(default_factory=dict)
|
|
width: int = 1080
|
|
height: int = 2400
|
|
typed: list[str] = field(default_factory=list)
|
|
actions: list[tuple[str, ...]] = field(default_factory=list)
|
|
home: str = "home"
|
|
focus: str | None = None
|
|
"""Label of the focused text field; typing lands here."""
|
|
fields: dict[str, str] = field(default_factory=dict)
|
|
records: dict[str, list[dict]] = field(default_factory=dict)
|
|
toggled: list[tuple[str, bool, bool]] = field(default_factory=list)
|
|
"""(key, before, after) per switch tap. A task whose starting state is
|
|
already correct is failed by a round trip, which final state cannot see."""
|
|
|
|
_ROW_H = 160
|
|
_TOP = 200
|
|
|
|
def _visible(self) -> list[SimElement]:
|
|
screen = self.screens[self.current]
|
|
rows = list(screen.elements)
|
|
if screen.dynamic:
|
|
rows = [r for r in rows if not getattr(r, "_placeholder", False)]
|
|
rows.extend(screen.dynamic(self))
|
|
return rows
|
|
|
|
def _bounds(self, row: int) -> tuple[int, int, int, int]:
|
|
y1 = self._TOP + row * self._ROW_H
|
|
return 0, y1, self.width, y1 + self._ROW_H
|
|
|
|
def dump(self) -> str:
|
|
rows = []
|
|
for i, el in enumerate(self._visible()):
|
|
x1, y1, x2, y2 = self._bounds(i)
|
|
checked = el.checked
|
|
if el.toggles:
|
|
checked = self.state.get(el.toggles, el.checked)
|
|
# uiautomator escapes attribute values, and labels really do
|
|
# contain them -- "Network & internet" is on every Android
|
|
# settings screen. Emitting raw breaks the parser on the second
|
|
# screen of the first task.
|
|
label = _xml_escape(el.rendered_label(self.state), {'"': """})
|
|
rows.append(
|
|
f' <node index="{i}" text="{label}" resource-id="" '
|
|
f'class="{el.cls}" package="com.example.sim" content-desc="" '
|
|
f'checkable="{str(el.checkable).lower()}" '
|
|
f'checked="{str(checked).lower()}" '
|
|
f'clickable="{str(el.clickable).lower()}" enabled="true" '
|
|
f'focusable="{str(el.clickable).lower()}" focused="false" '
|
|
f'scrollable="false" long-clickable="false" password="false" '
|
|
f'selected="{str(el.selected).lower()}" '
|
|
f'bounds="[{x1},{y1}][{x2},{y2}]" />'
|
|
)
|
|
body = "\n".join(rows)
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
f'<hierarchy rotation="0">\n{body}\n</hierarchy>'
|
|
)
|
|
|
|
def _row_at(self, y: int) -> int | None:
|
|
row = (y - self._TOP) // self._ROW_H
|
|
return row if 0 <= row < len(self._visible()) else None
|
|
|
|
def tap(self, x: int, y: int) -> None:
|
|
self.actions.append(("tap", str(x), str(y)))
|
|
row = self._row_at(y)
|
|
if row is None:
|
|
return
|
|
el = self._visible()[row]
|
|
if el.editable:
|
|
self.focus = el.label
|
|
if el.toggles:
|
|
before = bool(self.state.get(el.toggles, el.checked))
|
|
self.state[el.toggles] = not before
|
|
self.toggled.append((el.toggles, before, not before))
|
|
if el.saves:
|
|
self.records.setdefault(el.saves, []).append(
|
|
{**self.fields, **{k: v for k, v in self.state.items()
|
|
if k.startswith(el.saves + "_")}})
|
|
self.fields = {}
|
|
self.focus = None
|
|
if el.goto and el.goto in self.screens:
|
|
self.current = el.goto
|
|
|
|
def input_text(self, text: str) -> None:
|
|
self.actions.append(("text", text))
|
|
self.typed.append(text)
|
|
if self.focus is not None:
|
|
self.fields[self.focus] = self.fields.get(self.focus, "") + text
|
|
|
|
def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> None:
|
|
self.actions.append(("swipe", str(x1), str(y1), str(x2), str(y2)))
|
|
|
|
def keyevent(self, key: str) -> None:
|
|
self.actions.append(("key", key))
|
|
if key == "back":
|
|
self.current = self.screens[self.current].back or self.home
|
|
elif key == "home":
|
|
self.current = self.home
|
|
|
|
def launch(self, app: str) -> None:
|
|
self.actions.append(("launch", app))
|
|
for screen in self.screens.values():
|
|
for el in screen.elements:
|
|
if el.label.lower() == app.lower() and el.goto:
|
|
self.current = el.goto
|
|
return
|
|
|
|
def size(self) -> tuple[int, int]:
|
|
return self.width, self.height
|
|
|
|
|
|
def _saved_alarm_rows(device: "SimulatedDevice") -> list[SimElement]:
|
|
"""Render committed alarms the way a clock app lists them."""
|
|
saved = device.records.get("alarm", [])
|
|
if not saved:
|
|
return [SimElement("No alarms", clickable=False)]
|
|
rows = []
|
|
for rec in saved:
|
|
hour = (rec.get("Hour") or "?").lstrip("0") or "0"
|
|
minute = (rec.get("Minute") or "?").rjust(2, "0")
|
|
meridiem = "PM" if rec.get("alarm_pm") else "AM"
|
|
rows.append(SimElement(f"{hour}:{minute} {meridiem}", clickable=False))
|
|
return rows
|
|
|
|
|
|
def demo_phone() -> SimulatedDevice:
|
|
"""A two-app phone: Settings (Wi-Fi) and Clock (alarms).
|
|
|
|
Enough to exercise a multi-screen task, a stateful toggle -- the case the
|
|
earlier single-step run got wrong, tapping a switch that was already on --
|
|
and a text field.
|
|
"""
|
|
screens = {
|
|
"home": SimScreen("home", [
|
|
SimElement("Phone"),
|
|
SimElement("Messages"),
|
|
SimElement("Clock", goto="clock"),
|
|
SimElement("Settings", goto="settings"),
|
|
SimElement("Camera"),
|
|
]),
|
|
"settings": SimScreen("settings", [
|
|
SimElement("Settings", clickable=False),
|
|
SimElement("Network & internet", goto="network"),
|
|
SimElement("Connected devices"),
|
|
SimElement("Apps"),
|
|
SimElement("Battery"),
|
|
], back="home"),
|
|
"network": SimScreen("network", [
|
|
SimElement("Network & internet", clickable=False),
|
|
SimElement("Wi-Fi", cls="android.widget.Switch", checkable=True,
|
|
toggles="wifi"),
|
|
SimElement("Mobile network"),
|
|
SimElement("Airplane mode", cls="android.widget.Switch",
|
|
checkable=True, toggles="airplane"),
|
|
], back="settings"),
|
|
"clock": SimScreen("clock", [
|
|
SimElement("Alarm", cls="android.widget.Button", goto="alarms"),
|
|
SimElement("Clock", cls="android.widget.Button", selected=True),
|
|
SimElement("Timer", cls="android.widget.Button"),
|
|
SimElement("Stopwatch", cls="android.widget.Button"),
|
|
], back="home"),
|
|
"alarms": SimScreen("alarms", [
|
|
SimElement("Alarm", cls="android.widget.Button", selected=True),
|
|
SimElement("Add alarm", cls="android.widget.ImageButton",
|
|
goto="new_alarm"),
|
|
], back="clock", dynamic=_saved_alarm_rows),
|
|
"new_alarm": SimScreen("new_alarm", [
|
|
SimElement("Set time", clickable=False),
|
|
SimElement("Hour", cls="android.widget.EditText", editable=True),
|
|
SimElement("Minute", cls="android.widget.EditText", editable=True),
|
|
# Android shows one AM/PM control whose label is its value, so
|
|
# "set an alarm for 7:30 AM" has a wrong answer as well as a right
|
|
# one. Without it the task could be passed by typing any digits.
|
|
SimElement("AM", cls="android.widget.Button",
|
|
label_of="alarm_pm", labels=("AM", "PM"),
|
|
toggles="alarm_pm"),
|
|
SimElement("Save", cls="android.widget.Button", goto="alarms",
|
|
saves="alarm"),
|
|
], back="alarms"),
|
|
}
|
|
return SimulatedDevice(screens=screens, current="home", state={"wifi": False})
|