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
152 lines
6.5 KiB
Python
152 lines
6.5 KiB
Python
"""Run the task suite and score it, so an agent change can be compared.
|
|
|
|
Success is decided by the phone's own settings and screen, not by the
|
|
transcript: an agent that says it turned Wi-Fi on and left it off fails here,
|
|
which is exactly the failure this model exhibits.
|
|
|
|
Two things changed after the plan review:
|
|
|
|
* ``--repeat N`` now walks the task's **variants** rather than re-running one
|
|
fixture. At temperature 0 against fixed fixtures, repeats measure
|
|
repeatability; they only become trials if the phone differs between them.
|
|
The summary reports a pass rate per task with the variants named, so a build
|
|
comparison rests on more than one cell.
|
|
* ``--device adb`` scores a real phone. Each run resets the device to the
|
|
task's declared starting state first, and a task that cannot be scored from
|
|
a phone (because its predicate needs flip history) is skipped and reported
|
|
as skipped, rather than silently scored from final state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import statistics
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
from agent import AgentConfig, run_episode, wait_for_server
|
|
from phone_state import read_state
|
|
from tasks import SUITE, SUITE_BY_ID
|
|
|
|
|
|
def _reset_real_device(device, task) -> None:
|
|
if not task.reset_adb:
|
|
raise SystemExit(
|
|
f"task {task.task_id!r} declares no reset_adb, so a phone run would "
|
|
f"be scored against an unknown starting state. Add one or exclude "
|
|
f"the task with --tasks."
|
|
)
|
|
for cmd in task.reset_adb:
|
|
device._run(*cmd)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--base-url", default="http://127.0.0.1:8077/v1")
|
|
ap.add_argument("--model", default="spark")
|
|
ap.add_argument("--tasks", default="all",
|
|
help="comma-separated task ids, or 'all'")
|
|
ap.add_argument("--repeat", type=int, default=1,
|
|
help="runs per task; each walks the next variant")
|
|
ap.add_argument("--device", choices=("sim", "adb"), default="sim")
|
|
ap.add_argument("--serial", default=None, help="adb device serial")
|
|
ap.add_argument("--no-think", action="store_true")
|
|
ap.add_argument("--max-tokens", type=int, default=1024)
|
|
ap.add_argument("--label", default="")
|
|
ap.add_argument("--out", default=None)
|
|
a = ap.parse_args()
|
|
|
|
tasks = SUITE if a.tasks == "all" else [SUITE_BY_ID[t] for t in a.tasks.split(",")]
|
|
cfg = AgentConfig(base_url=a.base_url, model=a.model,
|
|
think=not a.no_think, max_tokens=a.max_tokens)
|
|
|
|
phone = None
|
|
if a.device == "adb":
|
|
from device import AdbDevice
|
|
phone = AdbDevice(serial=a.serial)
|
|
skipped = [t.task_id for t in tasks if t.needs]
|
|
if skipped:
|
|
print(f"skipping on a real phone (needs flip history): "
|
|
f"{', '.join(skipped)}")
|
|
tasks = [t for t in tasks if not t.needs]
|
|
|
|
if not wait_for_server(cfg, timeout_s=600):
|
|
raise SystemExit(f"no model server at {a.base_url}")
|
|
|
|
rows, t0 = [], time.perf_counter()
|
|
for task in tasks:
|
|
for run in range(a.repeat):
|
|
if phone is not None:
|
|
_reset_real_device(phone, task)
|
|
device = phone
|
|
else:
|
|
device = task.build(run)
|
|
cfg.max_steps = task.max_steps
|
|
episode = run_episode(device, task.prompt, cfg)
|
|
score = task.score(read_state(device), episode.done, run)
|
|
score.update(
|
|
run=run + 1, steps=episode.n_steps,
|
|
stop_reason=episode.stop_reason,
|
|
recovered_steps=sum(1 for s in episode.steps
|
|
if s.parsed_via not in ("tool_calls", "none")),
|
|
median_latency_s=round(
|
|
sorted(s.latency_s for s in episode.steps)[episode.n_steps // 2], 2)
|
|
if episode.n_steps else None,
|
|
)
|
|
rows.append(score)
|
|
mark = "PASS" if score["success"] else "fail"
|
|
detail = []
|
|
if not score.get("scorable", True):
|
|
mark, detail = "skip", [score.get("reason", "")]
|
|
else:
|
|
if score["reached_goal"] and not score["declared_done"]:
|
|
detail.append("goal reached but never stopped")
|
|
if score["declared_done"] and not score["reached_goal"]:
|
|
detail.append("claimed done, goal NOT reached")
|
|
if score["collateral_damage"]:
|
|
detail.append("collateral damage")
|
|
print(f" {mark} {task.task_id:18s} {score['variant']:10s} "
|
|
f"steps={score['steps']:2d} "
|
|
f"{score['stop_reason'][:34]:36s} {'; '.join(detail)}", flush=True)
|
|
|
|
wall = time.perf_counter() - t0
|
|
scored = [r for r in rows if r.get("scorable", True)]
|
|
n_pass = sum(1 for r in scored if r["success"])
|
|
reached = sum(1 for r in scored if r["reached_goal"])
|
|
claimed = sum(1 for r in scored if r["declared_done"] and not r["reached_goal"])
|
|
|
|
by_task: dict[str, list[dict]] = defaultdict(list)
|
|
for r in scored:
|
|
by_task[r["task_id"]].append(r)
|
|
print("\npass rate per task")
|
|
for task_id, rs in by_task.items():
|
|
passes = sum(1 for r in rs if r["success"])
|
|
failed = [r["variant"] for r in rs if not r["success"]]
|
|
print(f" {task_id:18s} {passes}/{len(rs)}"
|
|
+ (f" failed on: {', '.join(failed)}" if failed else ""))
|
|
|
|
rates = [sum(1 for r in rs if r["success"]) / len(rs) for rs in by_task.values()]
|
|
spread = (f"{min(rates):.0%}-{max(rates):.0%}" if rates else "-")
|
|
print(f"\n{n_pass}/{len(scored)} passed in {wall:.0f}s"
|
|
f" (goal reached {reached}/{len(scored)}; "
|
|
f"falsely claimed done {claimed}; per-task rate {spread}"
|
|
+ (f"; median {statistics.median(rates):.0%}" if rates else "") + ")")
|
|
if len(scored) and len(by_task) and max(len(rs) for rs in by_task.values()) < 3:
|
|
print(" NOTE: fewer than 3 runs per task -- enough to show a failure "
|
|
"reproduces, not to rank builds.")
|
|
|
|
if a.out:
|
|
with open(a.out, "w") as f:
|
|
json.dump({"label": a.label, "base_url": a.base_url,
|
|
"model": a.model, "think": cfg.think,
|
|
"device": a.device, "repeat": a.repeat,
|
|
"passed": n_pass, "total": len(scored),
|
|
"skipped": len(rows) - len(scored),
|
|
"wall_s": round(wall, 1), "rows": rows}, f, indent=2)
|
|
print(f"wrote {a.out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|