Skip to content
BAEM1N.DEV
Go back

One Model, Four Machines — Benchmarking Qwen3.8-27B Inference

TL;DR: I ran one model, Qwen3.8-27B, on four machines. The RTX 3090 dominates decode (38 t/s, twice the runner-up). GB10 wins prefill (1,467 t/s). On Apple Silicon, MLX beats llama.cpp Metal by 1.4–1.7x. And I had to fix the benchmark code three times — trusting the first run would have led me to publish the opposite conclusion.

Table of contents

Open Table of contents

What you’ll get from this post

Why run this comparison

Qwen3.8-27B is a 27B dense model with an unusual architecture. Of its 64 layers, only 16 use full attention — the other 48 are linear attention (full_attention_interval: 4). That shrinks the KV cache enough that long contexts fit even on a 24GB card.

The open question was which machine to put it on. The four I had on hand belong to entirely different architecture families.

MachineAcceleratorMemoryEngine
DesktopRTX 3090 (Ampere)24GB dedicated VRAMllama.cpp CUDA
MacBookApple M5 Max128GB unifiedMLX / llama.cpp Metal
DGX SparkGB10 (Blackwell)128GB unifiedvLLM
Mini workstationRyzen AI Max 395 (Radeon 8060S)~96GB UMAllama.cpp Vulkan

How fairness was established

This is the core of the post. A “four machine comparison” turns meaningless very easily.

The same GGUF file — verified by checksum

The RTX 3090, M5 Max, and Ryzen AI Max all ran byte-identical files.

sha256sum Qwen3.8-27B-UD-Q4_K_XL.gguf
# bee238bbeb3dc0a34bde4d0dedbaee1f98c009e8bb4226f03070054c12fb1372  (all three)

I hit a trap here. The file on Hugging Face had been replaced. What I downloaded on August 16 was 17,923,394,624 bytes; the same URL on August 23 gave 17,559,178,144 bytes. It had been re-quantized and re-uploaded.

Since content-length matches the new size, the download looks perfectly complete. Checking size alone won’t catch it. I ended up copying directly from the original node to match checksums.

Different quantization is disclosed as different

MLX and vLLM can’t read GGUF, so those two use different quantization schemes.

Stack4-bit methodSize
llama.cppGGUF UD-Q4_K_XL (per-layer mixed bits)16.69 GiB
MLXuniform quantization (group size 64)14.0 GiB
vLLMNVFP4 (native Blackwell FP4)~15 GiB

Different sizes mean different memory-bandwidth load. So this comparison measures “the speed each stack actually delivers,” not “which hardware is faster.” The only pure hardware comparison is the three GGUF runs.

The MLX model was converted from official weights

Converted builds already exist under mlx-community and lmstudio-community, but all of them are third-party conversions. I once used a third-party GGUF reranker conversion whose scores collapsed into e-14 noise and destroyed ranking entirely, so since then I stick to official or unsloth releases only.

MLX has neither, so I converted it myself.

mlx_lm.convert --hf-path Qwen/Qwen3.8-27B -q --q-bits 4 \
               --mlx-path ~/.cache/mlx-models/Qwen3.8-27B-4bit
# downloads 56GB BF16 → 14GiB 4-bit (3 shards)

This keeps the sourcing rule and, as a bonus, puts MLX on the same original weights as every other machine.

Method — after three fixes

My first harness produced plausible-looking nonsense. It claimed the RTX 3090 could prefill 64K tokens at 112,865 t/s. That’s physically impossible, so I suspected the code — and found three separate problems.

1. Prefix cache hits

Measuring the same prompt three times means runs 2 and 3 skip prefill entirely. The response usage gives it away.

"prompt_tokens_details": { "cached_tokens": 42 }

I fixed it by prepending a random nonce at the very front of every call. Putting it at the end leaves the prefix cacheable, so position matters. I also print cached_tokens alongside results to verify invalidation on every run.

2. The differential method poisoning itself

To separate prefill from decode, you diff a max_tokens=1 call against a max_tokens=N call. But calling twice with the same prompt means the second one hits the cache the first one just populated.

The symptom was distinctive: decode got faster as context grew, and at 64K the denominator went negative and produced nan.

Giving each call its own nonce fixes it. The lengths stay equal, so prefill cost is effectively unchanged.

3. Streaming shapes differ per engine

I initially used streaming to measure TTFT, but delta shapes varied by engine.

EngineDelta shape
llama.cppincremental (38 reasoning + 1 content)
vLLM + reasoning parser50 tokens in a single chunk

For vLLM this made ttft ≈ total, dividing decode by roughly zero and yielding values like 255,000,000 t/s. I dropped streaming and standardized on the differential method.

A sanity rule

The experience produced one rule worth keeping.

Decode must get slower as context grows. If it doesn’t, your cache is polluted.

In the final run, all three Linux machines passed this check cleanly.

Results

Median of 3 runs per point, -c 32768 -ctk q8_0 -ctv q8_0 -fa on --parallel 1.

prefill (t/s) — input processing

Machine / stack0.5K2K8K32K
GB10 / vLLM NVFP41,3271,6192,2221,467
RTX 3090 / CUDA5958471,1351,218
M5 Max / MLX335435556519
M5 Max / Metal306281376314
Radeon 8060S / Vulkan80143228271

decode (t/s) — generation

Machine / stack0.5K2K8K32K
RTX 3090 / CUDA40.4040.1339.7238.11
M5 Max / MLX25.4524.8524.5119.44
GB10 / vLLM NVFP417.0617.6817.3516.90
M5 Max / Metal17.2314.7714.7013.26
Radeon 8060S / Vulkan11.6311.6311.4611.42

What the numbers say

Prefill and decode split in opposite directions

GB10 delivers 1.8x the 3090’s prefill but less than half its decode. The reason is clear.

Working out the crossover gives a usable rule.

input tokens P > roughly 79 × output tokens O  →  GB10 wins
otherwise                                      →  RTX 3090 wins
ScenarioRTX 3090GB10Winner
12K in + 256 out17.2s20.2s3090
100K in + 256 out90.5s59.6sGB10
4K in + 1000 out25.8s56.4s3090

Long input with short output (RAG, summarization) goes to GB10; chat and code generation go to the 3090.

The controlled 3-way comparison — CUDA dominates

Same GGUF, same flags, same engine. The only pure comparison here, varying hardware and backend alone.

Backendprefill (32K)decode (32K)
CUDA (RTX 3090)1,21838.11
Metal (M5 Max)31413.26
Vulkan (Radeon 8060S)27111.42

The 3090 runs 3.9x the prefill and 2.9x the decode of Metal, and 4.5x / 3.3x of Vulkan.

What’s interesting is that Metal and Vulkan land in the same class (314 vs 271, 13.26 vs 11.42). The M5 Max with 128GB unified memory and Strix Halo with 96GB UMA perform equivalently under llama.cpp. That looks like a shared ceiling of unified-memory architectures.

On Apple Silicon, use MLX

Same hardware, same model, engine swapped.

decode (32K)prefill (32K)
MLX19.44519
llama.cpp Metal13.26314

MLX is 1.4–1.7x faster on decode and 1.1–1.5x on prefill. I measured each engine twice and the ranges don’t overlap, so the advantage is real.

MLX’s weights being 2.7GiB smaller (14.0 vs 16.69GiB) contributes to decode, but it doesn’t explain the prefill advantage.

Laptop benchmarks need at least two full runs

I got an interpretation wrong here.

In the first run, MLX prefill was nearly flat: 403 → 470 → 432 → 446. Every other machine increased, so I concluded that mlx_lm.server must be skipping batch optimization.

The second run demolished that hypothesis.

MLX0.5K2K8K32K
Run 1 prefill403470432446
Run 2 prefill335435556519
Run 1 decode27.0219.1720.7918.57
Run 2 decode25.4524.8524.5119.44

Run 2 goes 335 → 435 → 556 → 519 — the same growth pattern as every other machine. The flatness wasn’t real. Decode differed by 30% at the 2K point (19.17 vs 24.85).

The harness takes 3 samples per point and uses the median. But that only captures within-point variance, not run-to-run variance. The three Linux servers were monotonic enough that this trap stayed hidden; the laptop exposed it immediately.

Repeat the entire run at least twice on a laptop.

Worth adding: Metal was actually more reproducible than MLX here (decode spread around 2 t/s).

GB10’s prefill dips at 32K

It’s the only non-monotonic curve: 2,222 at 8K but 1,467 at 32K.

I attribute this to --max-num-batched-tokens 8192. An 8K prompt fits one chunk; 32K splits into four and pays the overhead. If you frequently feed long inputs, that’s a reason to raise the value.

Side findings

MTP is either usable or discarded, depending on the engine

Qwen3.8 ships a Multi-Token Prediction layer (mtp_num_hidden_layers: 1), present in the GGUF as blk.64.nextn.* tensors.

llama.cpp ignores them as unused tensor. vLLM uses them.

Enabling it in vLLM gave an 80% acceptance rate and pushed decode from 11.36 to 17.15 t/s — 51% faster. The cost was a 30% prefill hit, because reserving draft slots forces max_num_scheduled_tokens down to 2048. Pairing it with --max-num-batched-tokens 8192 shrinks that loss to 8%.

Directory permissions can cause a crash in a surprising place

With the shell’s cwd sitting in a directory I couldn’t read, llama-server died like this:

libc++abi: terminating due to uncaught exception of type
std::filesystem::filesystem_error: in current_path: call to getcwd failed

Not during GGUF loading, not during Metal init — it blew up in getcwd() inside backend discovery. From the log alone it looks like a llama.cpp bug. Spelling out cd "$HOME" in automation scripts is the safe move.

References

This post follows an earlier series that measured the same four machines with Qwen3.5. That series compared five engines; this one moves to Qwen3.8 and focuses on a controlled setup with checksum-identical GGUF files.

The desktop differs between the two. The earlier series used a 5950X with two RTX 3090s; this one uses a 9600X with a single 3090. Under single-user, no-concurrency conditions the second card goes unused, so decode comparisons are unaffected.

Key takeaways


All numbers are measured single-user with no concurrency (--parallel 1). Comparisons across stacks with different quantization should be read as effective per-stack speed, not as hardware comparison.


AI-assisted content
Share this post on:

Previous Post
176B on a Single 128GB Box — Serving Qwen3.8-Flash-Next on GB10, Down to the CUDA Kernel
Next Post
Building PhoenixCallbackHandler: Wrapping OpenInference Tracing as a LangChain Callback