laya-apple switchyard is an open-loop benchmark shown as a rail junction. Trains arrive
on a seeded timetable. Each train needs one Laya decision: which platform it goes to.
Long requests arrive in the background at the same time and load the GPU. The benchmark
runs the same timetable twice, once on the GPU only and once on the GPU plus the Neural
Engine (ANE). It then reports how many trains got their decision late.
The benchmark runs headless. The browser only shows a replay of the recorded run, never a live measurement.
laya-apple switchyard [--seed N] [--duration S] [--out DIR] [--no-open] [--setup-ane] [--replay DIR]
The first run downloads the pinned laya-typed-decisions checkpoint (about 800 MB) and runs
offline after that. A standard run (60 s timed window, plus a 5 s warmup, for one or two
rounds) takes about 2–3 minutes once the checkpoint is cached. Without a built Neural Engine
artifact it runs the GPU-only round and prints the setup command (ANE states).
This document states the rules and the measurement method before any number is published. It contains no performance results.
headless benchmark → result.json + trace.jsonl → replay.html in the browser
(measures, no UI) (every number, every request) (draws the recorded requests)
- Measure. The CLI runs every round headless. No browser, server or page is open while it measures.
- Record. It writes
result.json(the summary, schema-validated) andtrace.jsonl(one line per request). Every published number comes from these two files. - Replay. It then builds
replay.htmlfrom those files and opens it. The page only draws what was recorded. It is not part of the measurement, and--no-openskips it.
- A train reaches the back of its line's queue at its recorded
arrival_msand stands at the red signal until its recordedresponse_ms. The time a train spends at the signal is its measured decision latency. - At
response_msthe signal turns green, the switch throws to the recordedanswerand the train runs into that platform. The signal ring shows the device that answered: amber for the MLX GPU, teal for the Neural Engine. An on-time train takes the same colour. - A train still waiting after the deadline turns red, is marked "!" and stays so; only its nose takes the answering device's colour. A misrouted train enters an occupied or closed platform and is marked "×".
- Playback is slowed so the motion can be followed. The page defaults to 0.25× real time and always shows the speed. Data time maps linearly to screen time, and no event is reordered. Travel distances and animation lengths are presentation only.
The launch animation in the README
(media/switchyard-launch.gif) is a capture of such a replay;
its sources are in media/switchyard-launch.md.
- Train. One
choicequestion:ROUTE A / B / C. Exactly one platform is clear. Each of the other two is either occupied by another train or closed for maintenance. The clear platform is the correct answer (the oracle). - Late. The decision latency is over the deadline. The deadline is 100 ms, preregistered here before any Switchyard measurement.
- Misrouted. The decision is on time, but the answer is not the oracle.
- Delivered. The decision is on time and correct.
"Late" wins over "misrouted": a late train counts as late whatever its answer. Every run
also reports the miss rate at 25, 50, 100, 250 and 500 ms, so the result never
depends on the 100 ms choice alone. The deadline and these thresholds are fixed for
workload switchyard-v1. Changing them needs a new workload version.
| Parameter | Value |
|---|---|
| Model | laya-typed-decisions, pinned revision (laya_apple/registry.py) |
| Arrivals | bursty: 1 s at 3x the rate, then 2 s off. Nominal mean rate 40 req/s. laya_apple.workload.arrivals, bit-identical to scripts/bench_concurrency.arrivals |
| Timed window | 60 s per round |
| Warmup | 5 s, with its own schedule (seed + 100), not measured |
| Mix | 60% train (1 question, ≤ 128 tokens) · 20% medium_1q (L512) · 10% long_1q (L1024) · 10% short_4q (L128, 4 questions) |
| Background requests | laya_apple.workload.make_request with seeds 2 / 3 / 4, as in v1.0 part B. No deadline |
| Seeds | default 11. Arrivals use Random(seed), class picks Random(seed + 1) and train contents Random(seed + 2) |
| Deadline | 100 ms |
| Drain | each round waits for every request to finish, with a 600 s timeout |
A run with any seed or duration other than the default is recorded as
"standard": false. mode is a separate field: "standard" is the only value in v1,
and "live" is reserved for a future showcase mode. standard is the comparability
flag. mode says how the run was made.
submission_eligible is true for every standard run, whether or not the GPU + ANE round
ran, so GPU-only Macs can submit too.
With the default seed, the 60 s schedule has 2410 requests, 1422 of them trains. Its
schedule_sha256 is bf84ed5699072c8d561ae8c65b61bd6fd71b89c2d467f2b2c5686caad0ccf99d
(world.schedule_sha256(world.schedule())).
context: Line 3 inbound. Train 4127 is approaching the junction. Platform A is occupied by
train 2290. Platform B is closed for maintenance. Platform C is clear and open.
question: {"platform": {"type": "choice",
"instructions": "Train 4127 must be routed to the clear platform. Which platform is clear?",
"criteria": ["A", "B", "C"]}}
The line (1–6), the train id and the ids of the occupying trains vary with the seed. The
platform sentences always come in the order A, B, C. With one clear platform and two
platforms that are each occupied or closed, there are 3 × 2 × 2 = 12 core patterns.
prompt_template_sha256 in result.json fingerprints this wording
(a7569c1f9b8e5592b7fc1e7a14f78d099ba26cee64037fa596998db45084e7fe).
The gate is 12 patterns × 20 surface variants (world.oracle_cases(), seed 0). The
argmax must equal the oracle for 100% of cases, on the GPU and on the ANE. Every train
prompt must also be ≤ 128 tokens, so that auto can route it to the ANE. The check is
tests/integration/test_switchyard.py. With the frozen wording, train prompts are 61–67
tokens.
The wording was the only thing allowed to change to pass the gate. The checkpoint was not changed. Every wording tried is listed below. Each row changes only the listed sentence(s) of the original contract wording and keeps the rest.
The original contract wording:
- Header: "Line {line} inbound. Train {train} is approaching the junction."
- Occupied platform: "Platform {p} is occupied by train {other}."
- Closed platform: "Platform {p} is closed for maintenance."
- Clear platform: "Platform {p} is clear."
- Question: "Which platform should train {train} be routed to?"
Accuracy is the share of cases where the argmax matched the oracle, on the 240-case gate set. The minimum margin is the smallest gap between the oracle's probability and the runner-up's, over the correctly answered cases. The held-out set is 12 patterns × 50 variants from seed 1 (600 cases). It was run only for wordings that passed the gate.
| # | Wording change | GPU gate | ANE gate | Min margin (gate) | GPU held-out (min margin) |
|---|---|---|---|---|---|
| 1 | none (original contract wording) | 160/240 | 160/240 | 0.018 | — |
| 2 | question: "Which platform is clear for train {train}?" | 238/240 | — | 0.025 | — |
| 3 | question: "Which platform is clear?" | 231/240 | — | 0.002 | — |
| 4 | question: "Train {train} must be routed to the clear platform. Which platform is clear?" | 237/240 | — | 0.032 | — |
| 5 | question: "Which platform is free for train {train} to enter?" | 230/240 | — | 0.013 | — |
| 6 | clear: "Platform {p} is clear and open." | 190/240 | — | 0.017 | — |
| 7 | clear: "Platform {p} is clear and available for train arrivals." | 189/240 | — | 0.003 | — |
| 8 | closed: "Platform {p} is closed." | 163/240 | — | 0.001 | — |
| 9 | occupied: "Platform {p} is blocked by train {other}.", closed: "Platform {p} is closed." | 215/240 | — | 0.001 | — |
| 10 | criteria with descriptions {"A": "platform A", "B": "platform B", "C": "platform C"} |
184/240 | — | 0.023 | — |
| 11 | #2 + #6 | 240/240 | — | 0.102 | 600/600 (0.048) |
| 12 | #2 + #6 + occupied "blocked by train {other}" | 240/240 | — | 0.112 | 600/600 (0.029) |
| 13 | question "Which platform is clear and open for train {train}?" + #6 | 240/240 | — | 0.076 | 600/600 (0.071) |
| 14 | #4 + #6 (frozen) | 240/240 | 240/240 | 0.129 (ANE 0.128) | 600/600 (0.116); ANE 600/600 (0.123) |
Rule used to choose: among the wordings at 100% on the gate set, take the one with the largest minimum margin on the held-out set. This makes a near-tie flip between GPU FP16 and ANE FP16 as unlikely as possible. Row 14 was then checked on the ANE.
Measured on an Apple M4 Max, macOS 26.6.2, laya-typed-decisions at f9ab0b2. The GPU
column uses MLX FP16 and the ANE column uses the validated ANE artifacts, both run
inline. The wording is now frozen. Any change to it is a new workload version.
Source: benchmarks/switchyard/oracle/gpu.json (every wording) and
benchmarks/switchyard/oracle/ane.json (wordings 1 and 14), written by
scripts/switchyard_oracle.py. Each file keeps every case: pattern, line, train id,
oracle, answer, margin and token count, plus the platform profile and model revision.
The margins in the table are rounded to three decimals.
All timestamps are time.monotonic_ns(), the same clock axis as
laya_apple.trace.RequestTrace.
| Quantity | Definition |
|---|---|
| arrival | arrival_ns = round_start_ns + scheduled offset |
| decision latency | response_ns − arrival_ns. Includes submit lag, queueing, inference and formatting |
| queue wait | RequestTrace.queue_ms (dispatch_ns − queue_enter_ns) |
| inference | RequestTrace.service_ms (service_end_ns − service_start_ns) |
| submit lag | submit_ns − arrival_ns |
| overhead | decision latency − queue wait − inference |
The replay timeline and the queue-wait metrics use different starting points. On the
timeline, a request counts as waiting from its arrival until its inference starts
(service_start_ns − arrival_ns), so the drawn wait includes the submit lag. The "queue
wait" metrics in result.json and on the result card are queue_ms = dispatch_ns − queue_enter_ns, the time spent in the device's FIFO only.
- Driver. Open-loop, modelled on part B of
scripts/bench_concurrency.py. One submitter thread waits for each scheduled arrival with the same loop as that script (time.sleep(0.0002)until the arrival time). A spin loop would hold the GIL against the thread-placed ANE dispatcher in the same process and slow it down. It then callsLaya.submitand never waits for a response before the next arrival.Laya.submittokenises the request on the submitter thread, as the product does; that time is part of the submit lag.secondary.submit_lag_all_requests(every request, background included) shows how closely the open loop kept to the timetable. - Configurations. Both run through
Laya(execution="workers").gpu_onlyusesdevice="gpu".hybridusesdevice="auto"with the measured ANE placement. - Tracing. The trace callback only puts each
RequestTraceon aSimpleQueue. After the drain, every trace is joined to its result onrequest_id. A request that fails, times out or has no trace fails the round. Nothing is dropped silently. - Percentiles. Computed with
laya_apple.benchmark.stats, per round. - Which requests count. Train metrics use trains only; every scheduled arrival falls
inside the timed window. Background classes are reported separately. Pooled summaries
(
configs.<label>.summary) concatenate the samples of every round with that configuration. They never average percentiles. - Separate results. Late trains, decision latency, queue wait, correctness
(misrouted,
route_accuracy,decision_disagreements) and throughput (decisions_per_s,completed_req_s) are reported as separate results. They are never merged into one pass/fail. - Conditions. Each round records its start and end conditions: the load average, the
five busiest processes (CPU %, executable name only) and the
pmset -g thermnotes. It also records other running laya-apple processes (thelaya-appleconsole script,python -m laya_apple…or ascripts/bench_*.pyin the first two arguments), by pid and executable name only. Command lines are never recorded. - Duration.
--durationmust be a whole number of burst cycles (workload.burst_period_s, 3 s): 3, 6, 9, … s. Any other value is refused before anything loads.workload.offered.req_sis the rate actually offered over the window. It equals the nominal mean (nominal_rate_req_s, 40 req/s) only over whole burst cycles: a 1 s window, for example, is all burst and would offer about 115 req/s.
With an even seed, gpu_only runs first. With an odd seed, hybrid runs first. The
default seed, 11, therefore runs hybrid first. There is no counterbalancing within a
run. The order is recorded in design
({"ordering": "seed_parity", "counterbalance": "none", "sequence": [...]}). One Laya
is closed before the next is loaded, so the two configurations never share the machine.
Both rounds use the same timetable. comparison.same_schedule_sha256 confirms that from
what each round actually submitted. It is null when fewer than two rounds ran.
The v1.0 open-loop benchmark (benchmarks/v1.0/concurrency-laya-typed-decisions.json,
part B) reports a GPU + ANE short-request P50 of 11.1 ms. Switchyard's GPU + ANE decision
latency is not comparable with it, and must not be compared without saying so:
| v1.0 part B | Switchyard v1 | |
|---|---|---|
| Rate | bursty at 35 req/s (its rates were 20 and 35) | bursty at 40 req/s |
| Latency starts at | Laya.submit (runtime.latency_ms) |
the scheduled arrival (includes submit lag) |
| Window | 20 s | 60 s |
| Class sequence | Random(7) picks |
Random(seed + 1) picks, seed 11 |
| Short request | exactly 128 tokens (ANE bucket 128) | 61–67 tokens (ANE bucket 64) |
At 40 req/s bursty, about 70 trains arrive in each burst second. At about 11 ms of ANE time each, the ANE is busy about three quarters of every burst, so trains queue behind each other on the ANE. Late trains in the GPU + ANE round are an honest result of the preregistered workload. The rate was not changed after seeing them.
The ANE state is decided from the runtime's own checks only: _coremltools_available(),
platform_validated(), a local calibrated profile and artifacts.load_verified for the
auto buckets.
| State | Condition | Behaviour |
|---|---|---|
ready |
coremltools, verified auto-bucket artifacts and a validated or calibrated profile. Also, the warmup routed at least 1 train to the ANE | runs gpu_only and hybrid |
setup_available |
Apple silicon, but coremltools, the artifacts or the calibration are missing. Also used when the warmup sent no train to the ANE | runs gpu_only only. Reports the reason and the setup command |
unavailable |
an artifact failed parity on this Mac, or Core ML cannot run the ANE path | runs gpu_only only. Reports the reason, with no command |
unavailable (after the round) |
the GPU + ANE round failed, or its trains stopped reaching the ANE | see below |
After the GPU + ANE round, the trains are checked. The round is demoted when no train ran
on the ANE, or when any train was routed with ane_runtime_unavailable (the ANE worker
died). A demoted round keeps its data and has ane_verified: false. The ANE state
becomes unavailable with reason ane_lost_during_round, and comparison.available is
false. Neither the terminal nor the result card presents it as a comparison. If loading,
warming up or running the GPU + ANE round raises, that round is dropped. The state
becomes unavailable with reason ane_round_failed and the error in ane.detail, and
the GPU-only round still runs. GPU-only rounds have ane_verified: null.
result.json records the raw reason code as ane.reason and a plain-language sentence
as ane.reason_text (laya_apple/demos/switchyard/messages.py). When there is an
underlying error, ane.detail holds it.
ane.reason |
ane.reason_text |
|---|---|
ane_runtime_unavailable |
The Neural Engine runtime (Core ML Tools) is not installed |
ane_artifact_unavailable |
The Neural Engine model is not built on this Mac yet |
ane_artifact_stale |
The Neural Engine model needs to be rebuilt on this Mac |
platform_not_validated |
This Mac has not been calibrated for the Neural Engine yet |
ane_unused_in_warmup |
The router sent no trains to the Neural Engine during warmup |
ane_parity_failed |
A Neural Engine model failed the correctness check on this Mac |
ane_placement_failed |
Core ML could not run the model on the Neural Engine |
not_apple_silicon |
This machine is not an Apple silicon Mac |
ane_lost_during_round |
The Neural Engine stopped being used during the round |
ane_round_failed |
The GPU + ANE round failed on this Mac |
ane_convert_unavailable |
The Neural Engine build tools (laya-apple[convert]) are not installed |
ane_calibration_failed |
Measuring this Mac for the Neural Engine failed |
ane_runtime_unavailable, ane_artifact_unavailable, ane_artifact_stale,
platform_not_validated, ane_unused_in_warmup and ane_convert_unavailable are
setup_available. All the others are unavailable.
The setup command is exactly:
uvx --from "laya-apple[convert]" laya-apple switchyard --setup-ane
--setup-ane first builds every offered bucket that does not verify on this Mac, with
the parity gate (laya-apple artifacts build). calibrate needs all of them. It then runs
laya-apple calibrate if no shipped profile matches this Mac. Last, it runs the
benchmark. If a build or the calibration fails, the failure becomes the ANE state (a
parity failure is unavailable) and the GPU-only round still runs.
Exit codes:
- 0: the run finished, whatever the ANE state. A missing or failed ANE never makes the command exit with a non-zero code.
- 1: the GPU-only round failed. Any rounds that finished are still written.
- 2: a usage problem. The machine cannot run MLX (not Apple silicon macOS),
--durationis not a whole number of 3 s burst cycles, or--replay DIRdoes not point to a folder a Switchyard run wrote (a missing or unreadableresult.jsonortrace.jsonl). In both cases the command prints one line saying why.--replayworks on any machine.
Each run writes three files to --out. The default is
./switchyard-results/<UTC YYYYmmddTHHMMSSZ>-<soc>/.
result.json:schema: "laya-apple/switchyard-result",schema_version: 1, validated againstlaya_apple/data/switchyard-result.schema.json. Machine paths are written as<LAYA_APPLE_CACHE>,<HF_HOME>or~.workload.burst_period_sandworkload.burst_on_sgive the burst shape (3 s and 1 s), from the same constantslaya_apple.workload.arrivalsuses.configs.<label>.summaryhas the shape of a round'ssystems, andconfigs.<label>.gamethe shape of itsgame, both pooled over that configuration's rounds. Each round also records itsround_start_nsand atimetable_sha256of what it submitted.trace.jsonl: one line per request. Each line holdsRequestTrace.to_dict()plusround,index(the position in the schedule),classandarrival_ns. Train lines also haveline,train_id,pattern,answer,probabilities,oracleandoutcome. Every number inresult.jsoncan be recomputed from this file withlaya_apple.demos.switchyard.metrics.replay.html: a self-contained page. It isstatic/index.htmlwith the stylesheet, the script and the replay data inlined. It needs no network and opens fromfile://.--replay DIRrebuilds it fromDIR'sresult.jsonandtrace.jsonl.
replay.html embeds one JSON object, built by
laya_apple.demos.switchyard.replay.replay_data. It is the only interface between the
benchmark and the page. replay.bundle fills three markers in static/index.html in a
single pass:
/*__SWITCHYARD_CSS__*/in a<style>, withstatic/style.css;/*__SWITCHYARD_JS__*/in a<script>, withstatic/app.js;__SWITCHYARD_DATA__in<script type="application/json" id="switchyard-data">, with the JSON. Every</is escaped as<\/.
{
"format": "switchyard-replay", "format_version": 1,
"result": { the full result.json object },
"rounds": [{
"index", "config", "duration_s", "deadline_ms",
"trains": [{
"id", "line", "platforms": {"A": ..., "B": ..., "C": ...}, "oracle", "answer",
"device", "outcome", "arrival_ms", "queue_enter_ms", "dispatch_ms",
"service_start_ms", "service_end_ms", "response_ms", "latency_ms", "queue_ms",
"service_ms"
}],
"background": [{
"class", "device", "arrival_ms", "queue_enter_ms", "dispatch_ms",
"service_start_ms", "service_end_ms", "response_ms", "latency_ms"
}]
}]
}
- Times. Every
*_mstime is relative to that round'sround_start_ns, rounded to 3 decimals. Trains and background requests are sorted by arrival. - Values.
outcomeisdelivered,lateormisrouted.deviceisgpuorane.lineis 1–6. Platform states areclear,occupiedorclosed. - Recomputing. The counts and percentiles on the result card can be recomputed from this data, within the 3-decimal rounding.
- Synthetic data. Synthetic test data under
tests/fixtures/switchyard/is labelled"synthetic": true. It is never used for documentation.