~/satyajit

Laya on Apple silicon: 1.39×, not 50×, and K is a literal again

mdjsonmcp

2026-09-20 · 24 min · explainer · llm · architecture · on-device-inference · on-device · benchmarks

Yesterday's piece put a System One scorer in the reader's tab and then had to publish a hole in the middle of it. There is no GPU in the machine I write these on — /dev/dri does not exist — so Chrome handed back a SwiftShader adapter, every WebGPU number was a software rasterizer's number, and I refused all of them. What I could measure was WASM on four contended vCPUs: 72.8 / 128.3 / 214.1 ms at two options, 437.1 / 472.8 / 675.9 ms at sixteen, and 11.4 to 32.6 seconds for one sixteen-option decision on a 4-bit ModernBERT-large.

Somebody made the measurement I could not. mizorewww shipped two independent ports of Laya to Apple silicon on 19 and 20 September, benchmarked them on an M3 Max, and committed every timing sample, every rejected run and every export manifest alongside the prose.

I still cannot run them. There is no Apple silicon here either, so every performance figure in this article is Reported — read out of the repositories' own committed JSON, never reproduced. What I can do is read the conversion path, check the arithmetic, and find out what the three claims that reached me are actually claims about. All three turn out to be missing a denominator, and the repositories are not the ones who dropped it.

Two repos, not one, and both answers are yes

The name says Core ML and the pitch says MLX, so: both, in that order, by the same author, hours apart.

Both are Apache-2.0 ports of NandhaKishorM/laya at 6a58191 — the same upstream commit the relational-choice piece quoted build_sequence from. Neither ships weights; both download the original checkpoints and convert them.

convaiinnovations/laya@c5d7873 · snapshot 2026-09-19
parameters
421.3M
repo size
2.37 GB
task
text-classification
library
transformers
license
apache-2.0
safetensors
3 shards
largest file
842.6 MB
files
38
downloads
0
likes
499
parameters by dtype
F16421.3MF323
layasystem-onecalibrated-decisionsrlcdclassificationroutingscoringguardrails

The upstream checkpoint both ports convert. A third family, not a per-option scorer and not a letter readout: every option is a [MASK] marker inside one shared 512-token sequence, a bidirectional encoder runs once, and a shared scalar head reads each marker position. Everything below is about what happens to that shape when you export it.

repo last modified 2026-09-19

The Core ML repo is the more interesting one, because it did the thing nobody else in this story has done: it wrote a transformer graph specifically to land on the Neural Engine, and then integrated the machine's own power sensor over the run to find out whether it was worth it.

A two-panel bar chart titled Laya on Apple M3 Max, 40-core GPU, 128 GB. The left panel, one question per request, shows end-to-end latency for three checkpoints under PyTorch MPS FP32, MLX FP32 and MLX FP16: English about 25, 16 and 13.4 milliseconds; multilingual about 19.4, 8.0 and 7.4; typed decisions about 24.8, 15.9 and 13.7. The right panel, ten questions per request, shows English 95, 99 and 71 milliseconds; multilingual 43, 32 and 27; typed decisions 101, 94, 76. Bars are P50 with P95 whiskers over fifty timed calls.
The MLX port's own latency comparison, and the source of the 13.42 ms and 7.39 ms figures quoted everywhere downstream. Note what is being compared: three local runtimes against each other on one machine. There is no API in this chart, and the repository never puts one in. (laya-mlx, benchmarks/latency.png, Apache-2.0, commit fc1df62.)

Claim one: 50× faster than Jev

The first thing I did was grep for it.

$ git -C laya-coreml ls-files | wc -l
155
$ git -C laya-mlx ls-files | wc -l
130
$ git -C laya-coreml grep -Ili jev | wc -l
0
$ git -C laya-mlx  grep -Ili jev | wc -l
0
$ git -C laya-coreml grep -Ili typesafe | wc -l
0
$ git -C laya-mlx  grep -Ili typesafe | wc -l
0

Zero, across 285 tracked files. Two READMEs, two Chinese READMEs, sixteen engineering documents, seventy-one benchmark result files and nine commit messages: neither repository mentions Jev once, or TypeSafe once, or compares itself to a hosted API anywhere. The only thing a case-insensitive sweep turns up is three video containers and two git packfiles, which is what happens when you grep binaries for a three-letter string.

So the 50× is not a claim the repositories make, retracted or otherwise. It is a claim about them. And it is arithmetically reachable — you just have to pick the right denominator.

seven real ratios from published numbers · log scale · the denominator is the whole story
1×2×5×10×20×50×100×the circulating claimmeasured on one machine, same tasklocal compute ÷ a hosted round tripMLX compiled + prefix cache÷ MLX eager · same paired Snake run, 2,400 moves1.065×Core ML ANE FP16÷ compiled MLX FP16 · same M3 Max, same question, alternating blocks1.394×Core ML ANE W8 palette÷ compiled MLX FP16 · same M3 Max, same question1.423×Laya's own comparison sheetJev p50 ÷ Laya 32.8 ms on a Tesla T47.2–10.3×laya-mlx English 421MJev p50 ÷ 13.42 ms local warm p5017.6–25.2×laya-mlx multilingual 322MJev p50 ÷ 7.39 ms local warm p5031.9–45.8×Core ML ANE multilingualJev p50 ÷ 4.976 ms local warm p5047.4–68.0×
The three filled dots are the only ratios either repository publishes about itself, and all three compare the same workload on the same M3 Max. The four bars divide a hosted API p50 — 236 ms and 276 ms from two independent benchmark repos, 338.6 ms from a third — by a warm local single-question p50 on hardware the API never touched. Both kinds of number are real. Only one of them is a comparison.

The three filled dots are the only speed ratios either repository publishes about itself, and all three compare the same workload on the same machine. The headline one is 1.394×, the Neural Engine graph against compiled MLX FP16 across three balanced cycles of alternating 20-second blocks, 65,598 completed predictions in total. The README states it without decoration — | Speed gain | 1× | **1.39×** | **1.42×** |, a row in a table — and then writes its own least flattering sentence, which is about the demo everyone is sharing:

The short ANE result does not establish a long-context advantage. A 600-step paired Snake check matches 600/600 actions, with zero deaths and zero shield interventions; the current ANE adapter's three sequential calls do not establish a consistent full-game speedup over compiled MLX.

The fastest path is the one the repository declines to claim a Snake win for.

The bars are what you get when you change the denominator from the same task on the same machine to an HTTPS round trip to somebody else's datacentre. Divide the published third-party Jev p50s — 236 ms and 276 ms from two independent benchmark repos, 338.6 ms from a third — by the Core ML port's 4.976 ms warm local single-question p50, and you get 47.4× to 68.0×. Fifty sits comfortably inside that. It is a real division of two real numbers, and it measures the Atlantic.

The browser piece refused exactly this comparison in its own favour, and the reason has not changed: one side is compute, the other side is compute plus TLS plus queueing plus the speed of light, and stripping the network from one side makes the ratio evaporate. The honest version is the weak one. A 322M encoder, warm, on a Neural Engine, answers a 91-token four-option question in about 5 ms, and a hosted decision API answers over the wire in about 250. That tells you the hosted figure is dominated by something other than arithmetic. It does not rank two models.

Claim two: 60 decisions per second, playing Snake

Sixty is in the repository. It is the row that fails.

receiptscaptured 2026-09-20

60 decisions per second appears exactly once in the laya-coreml repository: as the paced-sweep row that fails, at 100% deadline misses. The rate the Core ML runtime actually sustains on an M3 Max is 49.1-50.0 fresh decisions per second, and each of those decisions is three sequential model questions, not one.

moderequested /sobserved /s3-question API P50 mstick P50 msdeadline missesresult
paced sweep, seed 7, 120 moves2018.5528.5431.640.83%pass
paced sweep, seed 7, 120 moves3028.6724.5727.910.83%fail
paced sweep, seed 7, 120 moves4037.2220.1923.5937.50%fail
paced sweep, seed 7, 120 moves5044.2117.2920.4553.33%fail
paced sweep, seed 7, 120 moves6050.6816.7919.5100.00%fail
uncapped soak, seed 101, 600 moves49.116.5319.340pass
uncapped soak, seed 102, 600 moves49.8916.0518.760pass
uncapped soak, seed 103, 600 moves49.9916.3319.080pass

The paced and uncapped rows are not the same experiment run at different speeds. The repository notes that the three-question API measured about 26.3 ms P50 while paced against 16.3 ms while uncapped, on the same machine and the same board, and declines to explain why — scheduling and device power-state transitions are named as candidates, not as findings. That is why the fastest passing paced setting (20/s) sits so far below the sustained uncapped rate (49.7/s).

method Core ML ANE FP16 bundle at B1/L96/K32, compact prompt, 24x16 board, twenty warmup decisions discarded. A tick includes planner feature construction, three sequential predict calls, Rich composition, truecolor ANSI serialization into memory and the game update; it excludes model loading, warmup and the terminal emulator's own painting. Paced rows add real sleep calls and pass only if at most 1% of active ticks exceed the requested budget. Uncapped rows wait for a fresh prediction on every move and have no deadline. 'API P50' is the three-question predict call; 'tick P50' is the whole active tick.
data /articles/laya-mlx/data/snake-rates.json (8 rows, 3.3 KB)

The paced sweep asks the demo to hold a rate and counts how often a tick blows its budget; 60 requested decisions per second misses the deadline on 100% of ticks and settles at 50.68/s. What the Core ML runtime actually sustains, with no deadline at all, is 49.10, 49.89 and 49.99 decisions per second over three 600-step episodes — pooled 49.66/s, which is 20.14 ms per decision, not 16.7.

The arithmetic of why 60 fails is right there. Sixty decisions per second is a 16.67 ms budget. The three model calls alone are 16.32 ms at p50, so the budget leaves 0.35 ms for the planner, the Rich composition, the truecolor ANSI serialization and the game update — and in the same episodes those account for the 2.75 ms gap between the three-question p50 and the whole-tick p50 of 19.07 ms (25.96 ms at p95). You cannot fit that in 16.67 ms, and the benchmark says so by failing every tick rather than by rounding.

"Roughly 60" is defensible, but only on the other runtime. The MLX port sustains 63.61 moves/s over 2,400 uncapped steps (per-seed range 46.32 to 76.37), 75.40 moves/s on its opt-in compiled path, and 64.77 moves/s in a 20.01-second real-terminal recording. The reason for the gap is structural and the repository names it: Core ML executes questions sequentially, while MLX batches them. MLX runs the move's three questions as one B=3 forward pass. The Neural Engine bundle is exported at B=1 and has to call three times.

Which brings us to the part of the claim that was never specified.

How many options is a decision?

one Snake decision · three sequential calls · aac6fef/laya-multilingual-coreml-ane
a decision is three questions, and the options are 4, 2 and 2the bundle exports 32 marker slots and a 96-token window for every one of themmovechoice · UP · DOWN · LEFT · RIGHTmarker slots — 4 of 32 occupiedone forward pass · B = 1 · L = 96risknoul · false · truemarker slots — 2 of 32 occupiedone forward pass · B = 1 · L = 96foodnoul · false · truemarker slots — 2 of 32 occupiedone forward pass · B = 1 · L = 96token positions attended over, all three calls — 152 real of 288 exportedpadding8 of 96 marker slots carry an option — 8.3%47.2% of the attended positions are padding
The occupancy is not waste the author overlooked — it is the price of a fixed graph, and the repository says so, noting in its own design notes that “Snake does not need 96 tokens” and that a dedicated B3/L64 Neural Engine export would be separate work. The token total is the median of input_tokens across every benchmark decision, which ranges 150-155 and never moves; the per-question split is not published, so the bar shows the sum against the sum.

A Snake move is not one question with four options. It is three questions, and you can read them off laya_coreml/snake/policy.py directly:

# laya_coreml/snake/policy.py — one decision, three typed questions
questions = {
    "move": {"type": "choice", "instructions": "Choose the best safe move toward food.",
             "criteria": {m.direction: ... for m in moves}},   # 4 options, always
    "risk": {"type": "noul", "instructions": "Is a safe route available?"},   # 2
    "food": {"type": "noul", "instructions": "Is food reachable through empty cells?"},  # 2
}
output = self.agent.predict(state, questions)

moves is the full DIRECTIONS tuple, so the choice question always carries four options even when three of them are walls, and render_options in common.py expands every noul into a fixed [false, true] pair. Eight option markers per decision, spread over three forward passes. Per decision the runtime reports 150 to 155 input tokens, median 152, and that range is identical in every one of the twelve benchmark episodes the repository commits traces for.

That matters because a per-decision latency without an option count is not a latency. On the shared-sequence shape, cost is roughly linear in the total length of the packed sequence, so four short options and twenty-five long ones are not the same request at all. The single-question figure everybody quotes — 4.976 ms p50, 5.307 ms p95 — is one four-option question, 91 real tokens padded to 96. Three of those end-to-end calls is 14.93 ms; the measured three-question Snake API is 16.32 ms. The Snake questions are shorter than the benchmark's — about 51 tokens each against 91 — but both pad to the same fixed 96-token window, so the graph does identical work either way and only the host-side cost moves.

The repository draws the line itself, in bold, so that nobody downstream has to:

The single-question 4.98 ms result must not be advertised as the full Snake frame time.

A terminal screenshot of the laya-coreml Snake demo. A green snake and orange food sit on a dotted board; the right panel is headed Laya Core ML, M3 Max, Local. Under NEXT MOVE, four model probabilities are listed with bars: UP 0.07, DOWN 0.05, LEFT 0.72 highlighted and marked as executing, RIGHT 0.16. Below that, DEAD-END RISK 0.07 and FOOD REACHABLE 0.93. A statistics block reads INFERENCE 27.9 ms, DECISIONS 11.4 per second, OUTPUT TOKENS 0, NETWORK OFFLINE, ENGINE CPU+ANE FP16, and shield interventions 0000. Score 019, length 025.
The shipped demo, and the three questions on screen at once: four move probabilities from the choice question, then dead-end risk and food reachable from the two noul questions. The rate it prints is 11.4 decisions per second, because the recording targets 12 for legibility. Zero output tokens is the real headline and nobody repeats it. (laya-coreml, docs/assets/snake-preview.png, Apache-2.0, commit 12b7501.)

Claim three: at most 1 GB

This one is nearly true, and the interesting part is which quantity it is true of. It is not the weights, it is not process RSS, and it is not a budget the runtime enforces. It is MLX's peak allocator high-water mark for one short question, and it comes from a table the MLX repo publishes:

checkpointparametersFP16 weightspeak, 1 short questionpeak, 10 full-context questions
laya (English 421M)421,293,827803.6 MiB943.6 MiB1,833.0 MiB
laya-multilingual (322M)321,908,995614.0 MiB687.6 MiB1,501.7 MiB
laya-typed-decisions (421M)421,293,827803.6 MiB943.6 MiB1,643.7 MiB

Check the first two columns against each other. 421,293,827 × 2 bytes = 842,587,654 = 803.55 MiB, which is the published 803.6 exactly; 321,908,995 × 2 = 613.99 MiB, which is the published 614.0. The weights are plain FP16, nothing else. So the answer to at what quantisation does 421M land under 1 GB is: no quantisation at all. The port is explicit — this is a parameter-name/dtype conversion, not quantization or retraining — and the source checkpoints already store FP16, so even asking for FP32 buys arithmetic precision, not weight precision.

That leaves 140.0 MiB of headroom between the weights and the peak, which is what one 93-token forward pass costs in activations. Ten questions at full context cost 1,029.4 MiB of activations on top of the same weights, and the total is 1,833.0 MiB — 1.79 GiB, comfortably over any reading of "1 GB". The claim is true of exactly one cell in that table and false of the cell two columns to its right.

Two more things the phrase quietly flattens. Peak MLX allocation is not process RSS, and the Core ML repo says so in as many words: Process RSS is recorded, including framework caches; it is not interchangeable with MLX active-memory statistics. And the download is a separate number nobody quotes: the published Core ML bundles are 557 to 848 MB each, because the host-side embedding table has to ship even though it never goes near the Neural Engine. Of the 679.9 MB ANE bundle only 251.91 MB is the Core ML body; the W8 variant shrinks that body to 129.29 MB and the bundle still only falls to 557.3 MB. Roughly 428 MB of both is embeddings and tokenizer, which is the clearest possible demonstration of where the bytes actually are.

The finding: K is a literal again

Here is the thing I would keep if I could keep only one, and it is the second independent time it has turned up.

The browser piece closed on rlcd-modernbert-151m's ONNX export, whose graph metadata reads logits[batch_size, 25] — batch symbolic, sequence symbolic, 25 a literal. The option count that is a loop bound in PyTorch had become a tensor shape at the edge. I flagged it as a small, checkable way in which the architecture does not survive the trip intact, and attached a falsifier.

It happens again here, on a different runtime, in a different framework, by a different author, against a different model. And it is worse.

which tensor dimensions survive the export as data, and which become literals
N is a loop bound until somebody exports ita frozen dimension is a re-export, not a longer arraybatchsequenceoptionsoutput shapePyTorch, upstream Layatorch.gather over marker_possymbolicsymbolicsymboliclogits[B, N]ONNX, rlcd-modernbert-151mthe browser export, measured yesterdaybatch_sizesequence_length25logits[batch_size, 25]Core ML, ordinary exportlaya-coreml, enumerated lengths19-11 buckets32logits[1, 32]Core ML, Neural Enginelaya-coreml ANE, B1 / L96 / K3219632logits[1, 32]Core ML, Snake bundlelaya-coreml, B3 / L64 / K43644logits[3, 4]
Solid boxes are literals in the exported graph; dashed boxes are dimensions the runtime still chooses per call. The browser export froze one of the three. The Neural Engine export freezes all three, and the Snake bundle freezes the option count at 4 because a snake has four directions to choose between. Sources: laya_coreml/convert.py, laya_coreml/ane.py, and the shape dictionary each bundle carries in its own coreml_config.json.

In laya_coreml/convert.py, only two of the five model inputs get flexible shapes:

# laya_coreml/convert.py — max_options defaults to 32
inputs = {
    "input_ids":     torch.zeros((batch_size, length), dtype=torch.int32),
    "attention_mask": torch.ones((batch_size, length), dtype=torch.int32),
    "marker_pos":    torch.zeros((batch_size, max_options), dtype=torch.int32),
    "marker_mask":    torch.ones((batch_size, max_options), dtype=torch.int32),
    "qtype":         torch.zeros((batch_size,), dtype=torch.int32),
}
specs = [
    ct.TensorType(
        name=name, dtype=np.int32,
        shape=sequence_shape if name in ("input_ids", "attention_mask") else tuple(value.shape),
    )
    for name, value in inputs.items()
]

sequence_shape is a ct.EnumeratedShapes over a bucket list. Everything else is tuple(value.shape) — a literal. The runtime then enforces it with the politest possible error, which even tells you the remedy:

# laya_coreml/inputs.py
if any(len(item["markers"]) > shape["max_options"] for item in items):
    raise ValueError("Question exceeds the exported max_options; convert with a larger value")

That is the same class of thrown error the browser worker raised past its 25th candidate, stated as a limit instead of advertised as a feature. And on the Neural Engine path it hardens twice over: the adapter refuses to load a bundle that is not exactly K=32, asserts the graph's full input signature including a dense marker_map (1, L, 1, 32), and reshapes the output to (1, 32) unconditionally.

# laya_coreml/ane.py
if shape["batch_size"] != 1 or shape["max_options"] != 32 or shape["flexible"]:
    raise ValueError("ANE runtime requires a fixed B1/K32 bundle")
...
expected_shapes = {"embeddings": (1, width, 1, length), "full_mask": (1, length, 1, length),
                   "local_mask": (1, length, 1, length), "type_vectors": (1, width, 1, 1),
                   "marker_map": (1, length, 1, 32)}

And every published bundle carries its frozen shape in its own manifest, so this is not an inference about one export. It is an inventory.

receiptscaptured 2026-09-20

Every published laya-coreml bundle freezes its option capacity at export time. K is never flexible in any of the six: it is 32 in five of them and 4 in the Snake bundle, whose four slots exist because Snake has four directions. The sequence length is flexible only in the three ordinary Core ML exports, and the Neural Engine bundles freeze the batch size and the sequence length too.

hugging face bundleBLKlength bucketsbundle MB
aac6fef/laya-coreml116-512329847.2
aac6fef/laya-multilingual-coreml116-10243211679.9
aac6fef/laya-typed-decisions-coreml116-10243211848.2
aac6fef/laya-multilingual-coreml-ane19632679.9
aac6fef/laya-multilingual-coreml-ane-w819632557.3
aac6fef/laya-multilingual-coreml-snake3644678.5

Nothing here is quantised except the W8 bundle, which is weight-only K-means palettisation with FP16 compute. The 122 MB it saves against the FP16 ANE bundle is the body package shrinking from 251.91 to 129.29 MB; the other ~428 MB of both bundles is the host embedding table and tokenizer, which never reach the Neural Engine.

method Read out of the repository's own release inventory, which records the exported shape dictionary written into each bundle's coreml_config.json at conversion time. B is the exported batch size (questions per forward pass), L the sequence capacity in tokens, K the max_options marker-slot count. 'length buckets' is the number of enumerated sequence shapes the export carries; a dash means the length is a single literal. Bundle size is the whole downloadable repository in decimal MB, including the tokenizer and, for the Neural Engine bundles, the host-side embedding and action-head tensors.
data /articles/laya-mlx/data/exported-shapes.json (6 rows, 2.6 KB)

K is never flexible in any of the six. Five are 32 and one is 4, and the one that is 4 is the Snake bundle, whose option capacity is four because a snake has four directions to choose between. That is the constraint in its purest form: a general-purpose decision model, exported into a graph that can physically only answer questions about a game with at most four answers.

The difference from the browser case is the direction of travel. In ONNX, batch and sequence stayed symbolic and only N froze. On the Neural Engine, all three freezeB=1, L=96, K=32 — because the layout rewrite that makes the graph an ANE target (channel-first B,C,1,L activations, 1×1 convolutions for every projection, per-head attention, static masks) is a rewrite into fixed buffers. Going closer to the metal costs more of the shape, not less.

What the Snake decision actually is

Before anyone reads 49.66 decisions per second as a model playing Snake: read what the model is given.

# laya_coreml/snake/policy.py — the planner runs first, and it already knows
moves = game.moves()                                    # legality, safety, advance, eats
safe = [m for m in moves if m.safe]
preferred = max(safe, key=lambda m: m.advance).direction   # the answer
...
questions["move"]["criteria"] = {
    m.direction: ("Blocked. Collision."       if not m.legal
                  else "Unsafe. Traps the snake." if not m.safe
                  else "Safe. Eat food now. Best." if m.eats
                  else "Safe. Best route to food." if m.direction == preferred
                  else "Safe. Slower route.")
    for m in moves
}

A deterministic Hamiltonian-cycle planner computes legality, safety, progress and whether a move eats, picks the best one, and then writes that verdict into the option text. The model's job is to read four short labels, one of which says Safe. Best route to food., and put its mass there. A shield then clamps execution to the planner's safe set anyway.

The repository is completely straight about this. From its own benchmark page:

The model still receives the same exact planner features, so these runs do not test reasoning from an unprocessed board.

With the shield disabled, raw top-1 over 200 moves on three seeds scored 7, 6 and 7 with zero deaths. Across every mode: 4,920 decisions, zero deaths, four safety interventions. All of that is a legitimate demonstration of a fast local classifier in a loop, which is what it says it is. None of it is a demonstration of a model playing Snake, and the difference is the planner.

What I could not check, and what the repo could not either

The honest inventory of holes, mine first.

I did not run any of this. No Apple silicon, so every millisecond, watt and joule above is read out of committed JSON. I checked what is checkable from source: the export shapes, the option counts, the token totals, the parameter arithmetic, the grep.

The repository's own holes are more interesting, because it publishes them. CPU_AND_NE did not make the ordinary Core ML export use the Neural Engine at all — the compute plan put all 1,318 assigned operations on the CPU and the result was 81.34 ms p50, seven times slower than the CPU+GPU plan. The rewrite is what moved 6,390 nonconstant operations onto MLNeuralEngineComputeDevice, and even then the repo grades its own evidence: an MLComputePlan is anticipated placement, so it went and took a 15.97-second Instruments trace and found 3,124 Neural Engine prediction intervals, then noted that the table is global and cannot attach a PID to any of them.

RangeDim with CPU_AND_GPU produced wrong and non-repeatable answers — 47 of 63 on the SDPA graph, 20 of 63 with explicit attention — and FP32 did not fix it. Enumerated shapes did, and then tripped an MPSGraph compiler SIGTRAP on a sliced constant boolean mask. All of that is committed with "passed": false rather than deleted.

And the energy measurement: an IOReport counter returned roughly 38,021 W for the CPU and 2,068 W for the Neural Engine in a single sample, which propagated into a 40,089 W system reading. The entire affected run was rejected whole, without clipping the bad sample, and its raw record is still in the tree marked unusable. That is the behaviour that makes me believe the 2.784× energy number more than I believe most benchmark tables.

The gap the whole project leaves open, stated in its own words: the short-input advantage does not generalise. A real 1,024-token request through the Neural Engine graph takes 91.703 ms p50 against the MLX port's 51.98 ms. The 4.98 ms figure describes a 96-token window and nothing else.

What I would actually ship

The answer to what does Jev-shaped inference cost on real Apple silicon is: about 5 ms for a small short question on a Neural Engine, about 7 to 14 ms in MLX, 1.394× rather than the 10× the project set out to find against a well-tuned GPU path, and roughly 800 MiB to 1.8 GiB of unified memory depending entirely on how many questions you ask at once. Every one of those is a smaller number than the one that travelled, and all of them come with a denominator.

What would change my mind

7 claims above, and what would falsify each

  1. The word Jev appears nowhere in either repository, so the 50x is not the author's claim.

    Two lines settle it: git -C laya-coreml grep -Ili jev and the same for laya-mlx, on the commits named at the foot of this page. I get 0 and 0 across 285 tracked files. If a later commit, a release note, a PyPI description or the author's own announcement thread makes the comparison, the framing above is wrong about who said it — though not about the arithmetic.

  2. 50x is a hosted round trip divided by a warm local call, not a compute ratio.

    Run Jev and Laya on the same machine, or run both over the same network path, and report the ratio. If it stays near 50 once the network is on both sides or off both sides, then the round trip was not carrying the ratio and I have attributed it wrongly. The weaker check: subtract a measured TLS-plus-RTT baseline to the API's region from the 236-338.6 ms figures and re-divide. If what is left is still 50x the 4.976 ms, I am wrong.

  3. The sustained Core ML Snake rate is 49.1 to 50.0 decisions per second, and 60 is the setting that fails.

    laya-coreml-snake benchmark --rates 20,30,40,50,60 --soak-steps 600 --seeds 101,102,103 on any M3-class Mac reproduces the table above. If a quieter machine, a newer macOS or a B3/L64 Neural Engine export — which the repo names as unbuilt future work — passes the 60/s deadline criterion, the ceiling is this build and this machine rather than the method, and "roughly 60" becomes fair for Core ML too. It already is for MLX.

  4. A Snake decision is three model calls carrying 4, 2 and 2 options.

    Read laya_coreml/snake/policy.py and render_options in common.py, or instrument Agent.system_one and count the rows in items. If the three questions are batched into one call on the ANE path, or if noul renders as anything other than a fixed false/true pair, the per-decision arithmetic in this piece is wrong. On the MLX path they are batched, into one B=3 forward pass, which is the whole reason its moves-per-second is higher.

  5. At most 1 GB describes MLX peak allocation for one short question, and the port quantises nothing.

    Parameters times two bytes reproduces the published FP16 weight sizes to the first decimal in both checkpoints, which is only possible if the weights are unquantised FP16. The peak claim is falsified by the repository's own next column: 1,833.0 MiB for ten full-context questions. If somebody has a real RSS measurement under 1 GB for a live process serving this model, that is a different and better number than either of us has, and I would rather have it.

  6. The option count is welded into the exported graph here, as it was in the browser ONNX export.

    Convert with laya-coreml convert laya-multilingual out --max-options 8 and then send a nine-option question. If it answers, max_options is advisory and I have misread the export. If it raises, ask the ANE bundle a 33-option question and watch it refuse before it ever reaches the graph. The stronger version: load the original safetensors in PyTorch and pass 64 markers. It should work, because upstream's torch.gather takes whatever width it is handed — and if it works there and fails here, the constraint arrived with the export, exactly as it did in ONNX.

  7. The Snake demo measures a classifier reading planner labels, not a model playing Snake.

    Replace the criteria dictionary in LayaPolicy.decide with bare direction names carrying no verdict — {"UP": None, "DOWN": None, ...} — keep the shield off, and re-run the 200-move raw top-1 benchmark. If the score holds near 7, the labels were not doing the work and I have understated the model. If it dies inside twenty moves, the planner was the player.


Nothing here was executed. There is no Apple silicon in the machine this was written on, so every latency, watt and joule above is Reported: read out of the committed results in mizorewww/laya-coreml at 12b7501 and mizorewww/laya-mlx at fc1df62, both cloned rather than summarised, both Apache-2.0 ports of NandhaKishorM/laya at 6a58191. The shape inventory comes from benchmarks/results/hub-release.json; the Snake rates from benchmarks/results/coreml-snake.json; the memory and parameter figures from laya-mlx's BENCHMARKS.md; the energy and Neural Engine placement figures from docs/ANE_BENCHMARKS.md. Star counts are from the GitHub API on 20 September 2026. The "1 GB max memory" and "roughly 60 decisions per second" phrasings reached me through a third-party summary, not from either repository. Third-party Jev latencies are the ones collected in Jev scores zero. Companion pieces: Jev in the browser for the export constraint this one confirms, and Jev scores zero for what Laya's marker-token architecture is and is not.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Laya on Apple silicon: 1.39×, not 50×, and K is a literal again", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026layamlx,
  author = {Satyajit Ghana},
  title  = {Laya on Apple silicon: 1.39×, not 50×, and K is a literal again},
  url    = {https://ai.thesatyajit.com/articles/laya-mlx},
  year   = {2026}
}
share