~/satyajit

Husky's 4.5× over MLX: the method is published, and it is speculative decoding

mdjsonmcp

2026-09-22 · 18 min · explainer · llm · on-device-inference · on-device · benchmarks · inference

Two days ago a 50× turned out to be 1.39×, and the reason was not dishonesty — it was a denominator. Somebody divided a local warm inference by a hosted HTTPS round trip and the Atlantic did the work.

Husky makes a claim of the same shape, on the same kind of hardware, in the same week: a model-specific inference engine up to 4.5× faster than Apple's MLX, running Underdog's Woof at up to 730 tokens a second on a MacBook. Same-machine, same-weights, so it is checkable in principle.

It turns out to be checkable in practice, which I did not expect. The page ships a sixteen-row table with both engines' medians, a method paragraph naming the chip, the OS build, both library versions and the run protocol, and a Cite block. That is more denominator than most benchmark posts carry, so the interesting question is no longer is this a real number but what is it a number about.

Short answer: it is a speculative-decoding result, the page says so, and the baseline was not given the speculative decoding it already has.

What is being made faster

Before the ratio, the denominator's denominator: what Woof is.

ConwayResearch/Underdog-Woof-4B-1.1@cf5f8db · snapshot 2026-09-22
parameters
4.21B
repo size
2.39 GB
architecture
Qwen3_5ForConditionalGeneration
task
text-generation
library
mlx
license
apache-2.0
safetensors
1 shard
largest file
2.37 GB
files
11
downloads
815
likes
15
languages
en
parameters by dtype
BF16961.5KU324.20B
mlx4bit

The model both engines run, and the one place this story has an artefact I can inspect. Architecture Qwen3_5ForConditionalGeneration — so Woof is a Qwen3.5-4B derivative, not a from-scratch family. 4-bit affine MLX quantisation at group size 64. Apache-2.0.

repo last modified 2026-09-12

The config settles several things the blog post states without support, and they all hold:

$ curl -s https://huggingface.co/ConwayResearch/Underdog-Woof-4B-1.1/raw/main/config.json \
  | python3 -c "import sys,json,collections as c; t=json.load(sys.stdin)['text_config']; \
      print(len(t['layer_types']), c.Counter(t['layer_types']))"
32 Counter({'linear_attention': 24, 'full_attention': 8})

Twenty-four linear-attention layers, which is exactly the number Husky's resident grid for the recurrence section says it runs on a persistent threadgroup grid, and 32 layers total, which is the number its whole-step-megakernel paragraph names. Hidden size 2560, intermediate 9216, full_attention_interval 4, vocab 248,320. The quantisation is 4-bit affine at group size 64, so each weight costs 4 bits plus an fp16 scale and bias per group of 64 — 4.5 bits effective:

4,205,751,296×12  +  4,205,751,29664×4=2.3657 GB4{,}205{,}751{,}296 \times \tfrac{1}{2} \;+\; \frac{4{,}205{,}751{,}296}{64}\times 4 = 2.3657\ \text{GB}

against a published model.safetensors of 2,367,237,149 bytes. That is 0.06% apart, which for a formula with no fitted terms means the layout is exactly what the config says. Husky's "all 2.4 GB of Woof" is the real file, rounded up by 33 MB.

That matters because the whole argument turns on it.

The bus, and why a kernel cannot win this

At batch 1 a decode step reads every weight. There is no way around it: the matmuls are tall-skinny, arithmetic intensity is about one, and the step is a streaming read with a little multiply attached. Husky states the consequence in its own second paragraph, and it is the most useful sentence on the page:

Producing a token means streaming all 2.4 GB of Woof from unified memory through the 40 GPU cores, about 6 ms on the M5 Max, and no engine reads faster than the bus. What Husky changes is how many tokens come out of each read.

Take that seriously and the headline becomes arithmetic. MLX's median across the sixteen rows is 159 tok/s — 6.29 ms a token, an effective 377 GB/s on a 2.367 GB read. Husky puts the floor for a plain streaming read at 4.5 ms, which is 526 GB/s. So the entire headroom available to any engine that emits one token per read is

6.29 ms4.5 ms=1.40×\frac{6.29\ \text{ms}}{4.5\ \text{ms}} = 1.40\times

and no amount of kernel work can go past it.

speedup over MLX, log scale · the bus floor is 1.39× and everything above it is speculation
one decode step reads 2.367 GB of Woof4,205,751,296 params at 4 bits + fp16 scale and bias per group of 64 = 2.3657 GBMLX at 6.29 ms/token is 376 GB/s effective · the stated floor is 526 GB/s1×1.5×2×3×5×MLX159 tok/s median · 6.29 ms per read1.00×Husky alone, median of sixteenthe engine, host off the critical path1.13×Husky alone, best prose rowQuestion over a document1.21×a perfect one-token-per-read engine4.5 ms bus floor ÷ 6.29 ms1.40×Flash draft ceiling, prose2.0 tokens a step ÷ 1.5× step cost1.33×Flash on, median of sixteenmeasured1.76×Flash draft ceiling, code5.0 tokens a step ÷ 1.5×3.33×prompt-lookup ceiling, edits5.4 tokens a step ÷ 1.5×3.60×Husky alone, function editmeasured — 614 tok/s3.77×Flash on, function editthe headline — 730 tok/s4.48×the lineno engine reads the weights faster than the bus — so nothing that emits one token per read can pass 1.39×diamonds are ceilings derived from published acceptance rates; circles are measured rows

Husky, without its draft model, has a median gain of 1.125× and a best prose row of 1.21×. Both sit under the line, where they have to. The page is candid about where that comes from and it is not a kernel: "Every step used to cost almost half a millisecond of encoding and waiting between GPU commands; Husky now encodes the next step while the current one runs." Half a millisecond off 6.3 is 8%, and the measured prose gains are 2% to 21%. It is a host-latency fix and it reads like one.

Everything above the line is tokens per read, which is speculative decoding. Two kinds of it:

Both ride the same eight-row verify step, which Husky prices at 1.5× the cost of one row. That gives clean ceilings from the published acceptance rates — 5.4 accepted on edits is 5.4 / 1.5 = 3.60×, 2.0 on prose is 1.33×, 5.0 on code from scratch is 3.33× — and the measured rows land just under each, which is what a real system that also pays host costs looks like. The one place the measurement beats its own ceiling is the function-edit row at 3.77×, which is what you get when acceptance on a particular file runs above the 5.4 average.

Sixteen rows, and which one the headline is

decode tokens/s while writing the reply · M5 Max, Woof 4B at 4 bits, same weight file both engines
0200400600730MLX 0.32.2 / mlx-lm 0.31.3Husky — prompt lookup onlyHusky, Flash on — trained draftFunction edit368 tokens in4.48×163 / 614 / 730Add a field to a JSON file477 tokens in4.28×157 / 611 / 672Rename a SQL column240 tokens in3.46×158 / 487 / 547Write a function40 tokens in3.52×155 / 170 / 545Fix typos in a paragraph301 tokens in3.36×159 / 463 / 535Invoice to JSON321 tokens in3.02×164 / 208 / 496CSV to a table303 tokens in2.92×158 / 287 / 462Data to a table247 tokens in1.73×163 / 188 / 282Repeated transcript857 tokens in1.78×153 / 164 / 273Tone rewrite195 tokens in1.66×162 / 168 / 269Short email38 tokens in1.72×151 / 164 / 260Project plan51 tokens in1.56×158 / 171 / 246Meeting notes to to-dos249 tokens in1.44×163 / 175 / 234Reply to an email thread280 tokens in1.33×163 / 170 / 217Call summary657 tokens in1.30×162 / 166 / 211Question over a document297 tokens in1.32×159 / 193 / 210mediansMLX 159 tok/s across all sixteen · Husky alone 1.125× median · Flash on 1.757× median · 4.48× on one rowthe baseline is mlx-lm's plain generate path — its own `--draft-model` speculative decoding was not enabled

Read down the chart rather than at the top of it.

MLX is flat. 151 to 164 tok/s across every task in the suite — a 9% spread over prompts from 38 to 857 tokens. That is exactly what a bandwidth-bound single-token decoder should do, and it is a quiet validation of the whole setup: a baseline that moved around with the prompt would mean something else was wrong.

Husky alone is a tie on prose and a rout on edits. Seven of the sixteen rows are between 1.02× and 1.09×. Four are between 2.91× and 3.89×, and all four are edits — function edit, JSON field, SQL column rename, typo fix — where the reply repeats long runs of the prompt verbatim and prompt lookup has something to copy.

Flash on beats MLX everywhere, by 1.30× to 4.48×, median 1.76×.

The 4.5× is the first row. Function edit, 368 tokens in, Flash on, 730 against 163. It is a real median of three runs. It is also the single most favourable cell in a sixteen-cell table, on the task class the engine was explicitly built for — Husky says so: "Underdog asks Woof for edits like these all day, so this is not a corner case, it is the common one." Fair enough for their product. It is still a maximum, and "up to" is doing the work in the title.

The first-token numbers are the least-quoted and hold up best. 137–177 ms for MLX against 29–39 ms for Husky on a continued chat, a median 4.24× sooner, on a comparison the page sets up to disfavour itself: both engines run from their caches, and it separately reports that MLX's plain generate call — the one without a prompt cache, which is what a naive caller gets — takes 145 to 295 ms. It quotes the cached number in the headline. That is the right choice and almost nobody makes it.

Underdog's own launch animation, clipped to the two evidence-bearing beats and otherwise unaltered. It is a replay at the speed the stream arrived, not a screen recording — the counters are animated from the same measurements as the table. The prompt is the 'Fix typos in a paragraph' row, where the table reads MLX 159 / Husky 463 / Flash on 535. The internal arithmetic is consistent: a reply Husky finishes in 0.53 s at 537 tok/s is about 285 tokens, which at 148 tok/s takes 1.93 s, and the clip ends at 1.70 s with MLX unfinished. (husky.underdog.ai, husky-launch-v15.mp4, 20 September 2026.)

The baseline that was not run

Here is the one thing I would change, and it is the same shape as the question worth asking of any framework comparison: was the baseline given its own best options?

Mostly yes. Both engines get the same weight file — "Husky repacks it without changing a value, and greedy output is compared token by token against MLX after every change". The MLX version is mlx-lm 0.31.3 on MLX 0.32.2, and 0.31.3 is the current release on PyPI, so this is not a stale baseline. MLX gets its prompt cache in the first-token comparison rather than the naive path. Runs that disagreed with their own repeats were discarded. Order was alternated, a canary ran first, repeats had to agree within 12%.

But the headline ratio is a speculative-decoding ratio, and mlx-lm has shipped speculative decoding the whole time:

$ curl -s https://raw.githubusercontent.com/ml-explore/mlx-lm/main/mlx_lm/generate.py \
  | grep -n "draft-model\|num-draft-tokens\|def speculative"
210:        "--draft-model",
216:        "--num-draft-tokens",
476:def speculative_generate_step(

So the comparison in the table is Husky with an eight-token verify step against MLX without one. That is not a small asymmetry — it is the entire mechanism above 1.40×.

Two honest caveats on my own complaint, because they are real:

  1. Flash is not portable to MLX. It is not a standalone language model; it reads Woof's own hidden states from five of its layers. You cannot hand it to --draft-model. A fair MLX speculative baseline would need somebody to train or pick a small standalone draft for Woof, which is work Underdog did for their engine and nobody has done for MLX.
  2. mlx-lm has no prompt-lookup decoding. I grepped for it; there are no hits for lookup or n-gram anywhere in generate.py. So the 2.9–3.9× on the four edit rows is a capability MLX genuinely does not have, draft model or not, and that part of the gap is not a configuration choice.

Which leaves a precise version of the claim. Husky's engine is about 1.1× MLX on prose. Husky's prompt lookup is 2.9–3.9× on edits and MLX cannot do it at all. Husky's trained draft adds 1.3–1.7× on prose, and MLX has a slot for a draft that this benchmark left empty. All three are worth having. Only the first is a claim about kernels.

What 730 tokens a second is, exactly

Since it is the number that will travel, here it is with every qualifier attached:

None of that makes 730 wrong. It makes it one cell.

What is not checkable

The engine. Husky is not open source, has no repository, and runs only inside the Underdog app — "Husky is live in Underdog on the Mac today, with no setup." The method section says "the receipts are kept"; they are not published.

So the reproducibility is one-sided in a specific way. Anyone with a Mac can reproduce the baseline exactly: pip install mlx-lm==0.31.3, pull ConwayResearch/Underdog-Woof-4B-1.1, run the sixteen prompts, and check whether MLX really lands at 151–164 tok/s and 137–177 ms to first token on their machine. If it does, the denominator is sound and the only unverified column is Husky's own. If it does not, nothing else matters.

That is a much better position than the 50× was in. It is still short of the bar Laya on Apple silicon set two days earlier, which committed every timing sample, every rejected run and every export manifest to a public tree — including a run it threw away whole because one power-sensor reading came back as 38 kilowatts.

What I would actually ship

What would change my mind

6 claims above, and what would falsify each

  1. Woof is a Qwen3.5-4B derivative, 4-bit affine at group size 64, and its weight file is 2.367 GB.

    curl -s https://huggingface.co/ConwayResearch/Underdog-Woof-4B-1.1/raw/main/config.json prints "architectures": ["Qwen3_5ForConditionalGeneration"] and "quantization": {"group_size": 64, "bits": 4, "mode": "affine"}; the Hub API gives model.safetensors at 2,367,237,149 bytes and 4,205,751,296 parameters. If a later revision reshapes or requantises the checkpoint, or if the engine ships a different repack whose byte count differs from the Hub file, the 2.367 GB per read is wrong and the 1.40× bus ceiling moves with it.

  2. The memory bus caps any one-token-per-read engine at about 1.40x over MLX on this machine.

    It is one division: MLX's 159 tok/s median is 6.29 ms a token, and Husky's own stated plain-streaming-read floor for the same bytes is 4.5 ms. Measure the floor yourself — a bare mx.eval over a 2.37 GB array on an M5 Max — and if it comes back under 4.5 ms the ceiling rises, or if MLX is leaving more than 40% on the table for a reason other than bandwidth, the whole framing is wrong. The strong version: run Husky with Flash off and prompt lookup disabled on a task whose reply shares nothing with its prompt. It should not beat 1.40x.

  3. Husky without its draft model is a median 1.125x over MLX, and a tie on seven of sixteen tasks.

    Sum the published Husky column against the published MLX column, row by row. I get 1.02, 1.04, 1.04, 1.07, 1.07, 1.08, 1.09, 1.10, 1.15, 1.21, 1.27, 1.82, 2.91, 3.08, 3.77, 3.89 — median 1.125. If the table is revised, or if I have mapped the Husky column to the wrong condition and it already includes a draft, the ladder above is wrong about which mechanism does the work.

  4. The baseline is mlx-lm without its own speculative decoding.

    mlx_lm/generate.py defines speculative_generate_step and the CLI takes --draft-model and --num-draft-tokens; the method paragraph names no draft on the MLX side. If Underdog did run MLX with a draft model and simply did not say so, the asymmetry disappears and I owe them a correction. The test that settles it for everyone: convert any small Qwen3.5-class checkpoint to a 4-bit MLX draft, run mlx_lm.generate --draft-model over the same sixteen prompts, and publish the column. If MLX plus a draft lands near Flash on, most of the 1.76x median was the method rather than the engine.

  5. mlx-lm has no prompt-lookup decoding, so the edit speedup is a capability MLX lacks.

    grep -i "lookup\|ngram" mlx_lm/generate.py returns nothing on main as of 22 September 2026. If it lands, or if it is reachable through a logits processor or a custom sampler in a way I missed, then even the four edit rows become a configuration choice rather than a capability gap, and the honest ratio for Husky drops to something near the bus ceiling on every row.

  6. 730 tok/s is decode-only, Flash on, on the single most favourable of sixteen tasks.

    It is the top-left cell of Table 1 — Function edit, 368 tokens in, Flash-on column — and the table's own header says the column is tokens per second while writing the reply. Second place is 672. If a later run publishes a higher median or a distribution rather than a maximum, quote that instead. If somebody measures 730 sustained across a mixed workload, the headline stops being a maximum and becomes a throughput, which is a different and better claim.


Nothing here was executed. There is no Apple silicon in the machine this was written on, so every latency and throughput figure is Reported: read off husky.underdog.ai on 22 September 2026, by Sigil Wen, Underdog / Conway Research, published 20 September 2026. What I checked independently is the model — ConwayResearch/Underdog-Woof-4B-1.1, Apache-2.0, through the Hugging Face API and its raw config.json — and the baseline's capabilities, from ml-explore/mlx-lm on main and the PyPI release index. Every ratio, median and byte count in this piece is arithmetic over those two sources and is reproducible from them. The clip is Underdog's own launch animation, trimmed to its two evidence-bearing beats and otherwise unaltered. There is no figure here because there is none to take: the launch page publishes its numbers as one HTML table and draws its three charts in the browser from that same table, so there is no image to mirror, and the only image file it serves is icon.png, the product mark. The model card carries no figure either — no chart, no diagram, nothing but the Hub's own avatar — and Underdog publishes no paper or post beyond the page. All sixteen rows of that table are transcribed unaltered into the ladder above instead. Companion piece: Laya on Apple silicon, for the same question asked of a different engine in the same week, and for what a fully published benchmark tree looks like.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Husky's 4.5× over MLX: the method is published, and it is speculative decoding", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026huskymsi,
  author = {Satyajit Ghana},
  title  = {Husky's 4.5× over MLX: the method is published, and it is speculative decoding},
  url    = {https://ai.thesatyajit.com/articles/husky-msi},
  year   = {2026}
}
share