My Surface Laptop Ultra decodes short code replies from qwen3-coder:30b at 90.9 tokens a second, twice the 44.7 that Microsoft's Lenovo Yoga 9n review measured for Qwen3.8-27B on the same chip. Both laptops use NVIDIA's RTX Spark N1X with 64 GB of LPDDR5X-9400. I repeated that post's two benchmarks with Ollama on Windows: no Docker, no vLLM, no speculative decoding.
The long prompt tells a different story. With a 100,589-token prompt, qwen3-coder took 146 seconds to its first token and then decoded at 19.7 tokens a second, where the Lenovo setup held 44.3. Over the whole run the GPU averaged 33 W and peaked at 75 °C.
At a glance
Verdict: with Ollama alone, the Surface Laptop Ultra is a fast machine for short, interactive coding requests with a local model. For a coding agent that resends a 100K-token conversation on every turn, the Lenovo post's vLLM setup is the better pattern.
What I liked:
- Setup is one signed installer and one
ollama pull. Nothing runs in WSL or Docker. - qwen3-coder:30b (30.5B parameters, 8 of 128 experts active per token) and a 102,400-token context fit entirely on the GPU: 26.7 GiB.
- 90.9 tokens a second on short code prompts, steady across three runs (88–92).
- qwen3:8b, the smaller model, decodes at 44–50 tokens a second in every run I made.
Good to know:
- Decode speed falls about 4.6 times between a 20-token and a 100K-token prompt, because the attention cache grows with every token.
- Prompt processing ran at 687 tokens a second at 100K tokens, two-thirds of the Lenovo setup's 1,033.
- Ollama silently cuts a prompt longer than its context window to about half. Only its server log says so.
Who it's for: developers who want a local coding model for chat, edits and short agent turns, with no per-token bill and no container stack.
The machine
The Surface Laptop Ultra has the same chip and memory as the Lenovo in Microsoft's post, so the hardware comparison is like for like.
| Microsoft Surface Laptop Ultra, as tested | |
|---|---|
| Chip | NVIDIA RTX Spark N1X: 20-core Arm CPU, 6,144-core Blackwell GPU, plus an NVIDIA NPU |
| Memory | 64 GB of Samsung LPDDR5X-9400, shared by the CPU and the GPU |
| GPU's share | 31,232 MiB dedicated to the GPU; Windows sees the other 32.2 GB |
| Software | Windows 11 Pro on Arm (build 28000), NVIDIA driver 617.42, Ollama 0.40.11 (native Windows ARM64, per-user install) |
| Power | On AC power, Windows Balanced power plan |
| Models | qwen3-coder:30b and qwen3:8b, both Q4_K_M, stored on the internal SSD |
The GPU's share is the same 32 GB split the Lenovo post used. With qwen3-coder and its 102,400-token cache loaded, GPU memory peaked at 29,952 of 31,232 MiB.
How I measured
I copied the Lenovo post's two single-request benchmarks as closely as Ollama allows; the agent session and Azure Monitor parts I left out.
| Code benchmark | Long-prompt benchmark | |
|---|---|---|
| Task | A thread-safe LRU cache in Python with tests, a token-bucket rate limiter in TypeScript, Dijkstra's algorithm in Go | One question after a long prompt cut from Windows PowerShell's own format and module files |
| Prompt | 18–21 tokens | 100,589 tokens (qwen3-coder) or 37,151 (qwen3:8b, whose limit is 40,960), with a random first line so nothing came from the prompt cache |
| Reply | 512 tokens each, temperature 0, seed 42, thinking off | 384 tokens, temperature 0, thinking off |
| Context | 102,400 tokens (qwen3-coder), 40,960 (qwen3:8b) | Same |
| How it was sent | One request at a time to Ollama's /api/generate, after a warm-up load |
One request, after a calibration prompt that measured characters per token |
The script, speed-bench.ps1, unloads every model first, records Ollama's own token timings and samples GPU power, temperature, clock and memory with nvidia-smi once a second. Decode speed is Ollama's eval_count / eval_duration; prompt speed is prompt_eval_count / prompt_eval_duration.
One lesson from the first attempt: my first long prompt for qwen3-coder came out at 105,530 tokens, over its 102,400-token window, and Ollama kept only the last 51,202 without returning an error. The script now calibrates on a large slice of the text and flags any prompt that comes back short.
Results
On short prompts, qwen3-coder:30b is the fastest setup on either laptop; at 100K tokens it is the slowest.
The three code prompts landed close together: 92.1, 90.2 and 90.5 tokens a second for qwen3-coder, and 46.5, 45.1 and 43.8 for qwen3:8b. The first token of a short reply arrived in 0.08–0.19 seconds once the model was loaded.
Reading the long prompt is where the wait comes from:
| Setup | Prompt | Prompt processing | First token |
|---|---|---|---|
| qwen3-coder:30b, Ollama | 100,589 tokens | 687 tok/s | 146 s |
| qwen3:8b, Ollama | 37,151 tokens | 1,492 tok/s | 25 s |
| Qwen3.8-27B, vLLM on the Lenovo | 97,880 tokens | 1,033 tok/s | about 95 s, worked out from its prompt speed |
For a chat that grows turn by turn, Ollama reuses the cached start of the conversation, so only the new part is read. The 146 seconds is the cost of a brand-new 100K-token prompt.
Power, heat and clocks
The GPU slows its clock while it reads a long prompt and speeds back up to reply, so the lower clock tracks the work, not heat building up.
Reading a prompt is compute-bound: after a short burst to 60 W, the GPU settled near 34 W and dropped its clock to about 1.65 GHz for the whole 146 seconds. Replying is memory-bound: the clock went back up to about 2.25 GHz at 30 W, and the temperature fell from 71 to 64 °C. That pattern looks like a power limit, not thermal throttling.
The 75 °C peak is 8 °C above the Lenovo's three-hour peak, reached in a run of about four minutes. I haven't tested sustained load yet, so treat this as a first look at the Surface's cooling, not a verdict.
Why long context costs so much here
Decode on this chip is a question of bytes, as the Lenovo post showed: every token reads the active weights plus the whole attention cache, at roughly 235 GB/s.
tokens per second ≈ memory bandwidth ÷ bytes read per token
qwen3-coder's mixture of experts makes the weights cheap: only about 3.3B of its 30.5B parameters run per token, about 2 GB at Q4_K_M. Its cache is the expensive part. Each token keeps keys and values for 48 layers × 4 heads × 128 dimensions in 16-bit floats, 96 KiB a token, so 100,000 tokens of cache is about 9.9 GB.
| Run | Weights read per token | Cache read per token | Measured decode | Implied bandwidth |
|---|---|---|---|---|
| qwen3-coder:30b, short prompt | ~2.0 GB | ~0.05 GB | 90.9 tok/s | ~185 GB/s |
| qwen3-coder:30b, 100,589-token prompt | ~2.0 GB | ~9.9 GB | 19.7 tok/s | ~235 GB/s |
| qwen3:8b, short prompt | ~5.1 GB | ~0.08 GB | 45.1 tok/s | ~235 GB/s |
| qwen3:8b, 37,151-token prompt | ~5.1 GB | ~5.5 GB | 22.3 tok/s | ~235 GB/s |
Three of the four runs imply the same 235 GB/s, close to the 229 GB/s the Lenovo post measured with a read test. The short qwen3-coder run falls short of it because routing tokens between experts adds work that isn't memory reads.
The weights and cache sizes are my estimates from the model files and their architecture, not measurements. They explain the gap with the Lenovo setup: Qwen3.8-27B keeps a fixed-size state in 48 of its 64 layers, so its cache barely grows, and speculative decoding produces about five tokens per read of the weights.
Laptop vs laptop
The hardware matches, so the differences come from the software stack and the model. Read this as two working setups, not a controlled benchmark.
| Surface Laptop Ultra (this post) | Lenovo Yoga 9n 2-in-1 (Microsoft's post) | |
|---|---|---|
| Chip and memory | RTX Spark N1X, 64 GB LPDDR5X-9400, 32 GB to the GPU | Same |
| Model | qwen3-coder:30b, mixture of experts, ~3.3B active | Qwen3.8-27B, dense, hybrid attention |
| Weights | Q4_K_M, 17.3 GiB | NVFP4, 19.1 GB |
| Server | Ollama 0.40.11, native on Windows | vLLM 0.31 in Docker Desktop on WSL 2 |
| Speculative decoding | None | DFlash 2 drafter, 7 tokens |
| Context used | 102,400 tokens (the model supports 262,144) | 262,144 tokens |
| Setup effort | One installer, one model pull | Docker, a custom image, WSL memory caps, LiteLLM |
| Monitoring | nvidia-smi samples to CSV | Azure Monitor, Managed Grafana, alert rules |
| Best for | Chat, edits and short agent turns | Long agent sessions with 100K+ token conversations |
The Lenovo post also ran three hours of OpenCode traffic at about 27 tokens a second per request. I haven't run an agent session on the Surface yet, so there is no matching number here.
What runs where, and how to set it up
The whole stack is one Windows service in front of the GPU; there is no container, gateway, or collector to run.
Ollama starts a llama.cpp server per loaded model and offloads every layer to the GPU. The GPU already had a 32 GB share of the unified memory; Microsoft's post says the NVIDIA app's Dedicated GPU Memory setting controls it.
- Check the GPU:
nvidia-smishould list the RTX Spark N1X and its memory. - Install Ollama with its signed script:
irm https://ollama.com/install.ps1 | iex. It installs per user, with no admin rights needed. - Pull the models:
ollama pull qwen3-coder:30b(17.3 GiB) andollama pull qwen3:8b(4.9 GiB). Keep them on the internal SSD. - Set a default context for editors: the user variable
OLLAMA_CONTEXT_LENGTH=65536, then restart Ollama. The benchmark sets its own context per request. - Run the benchmark from the folder that holds it:
powershell -ExecutionPolicy Bypass -File .\speed-bench.ps1(see PowerShell at bottom of the post), or add-Models qwen3-coder:30bfor one model. Running both models takes about seven minutes. - Point VS Code at it: Continue's
config.yamlwithprovider: ollama, or the Ollama provider in Copilot Chat's language models.
Rules of thumb
- Count bytes per token. On RTX Spark, decode speed is about 235 GB/s divided by the weights and cache each token reads.
- Pick a mixture-of-experts model for short turns. Reading 2 GB of weights per token instead of 5 GB is what doubles qwen3-coder's speed over qwen3:8b.
- Budget for the cache at long context. At 100K tokens, qwen3-coder's 16-bit cache is five times the size of its active weights.
-
Check the prompt count. If
prompt_eval_countcomes back far below what you sent, Ollama cut your prompt to fit the context window. - Watch the clock, not just the temperature. During prompt processing, the GPU held about 1.65 GHz at 34 W; during replies, it ran at 2.25 GHz.
The bottom line
The Surface Laptop Ultra has the same chip and memory as the Lenovo in Microsoft's post, so the same models and limits apply. With nothing more than Ollama, qwen3-coder:30b answers short coding requests at 91 tokens a second, with the first token in under 0.2 seconds.
For 100K-token agent sessions, Ollama on this machine is the weak spot: a fresh prompt waits more than two minutes and replies drop to 20 tokens a second. Next, I want to try an 8-bit KV cache (OLLAMA_KV_CACHE_TYPE=q8_0), which should roughly halve the cache's bytes, and a three-hour agent session to compare with the Lenovo's 27 tokens a second.
Powershell:
# Ollama version of the speed benchmark from Microsoft's "Qwen3.8-27B on a Lenovo laptop" post:
# - code benchmark: three code-generation prompts, 512-token replies, temperature 0, thinking off, one at a time
# - long-prompt benchmark: one question after a long prompt whose random first line defeats the prefix cache, 384-token reply
# GPU power, temperature, clock and memory are sampled every second with nvidia-smi during the whole run.
# Writes speed-bench-<COMPUTER>-<stamp>.json and .gpu.csv in the current directory.
param(
[string[]]$Models = @('qwen3:8b', 'qwen3-coder:30b'),
[string]$Api = 'http://localhost:11434'
)
$ErrorActionPreference = 'Stop'
# Context per model: big enough for the long prompt plus reply, within the model's maximum and the GPU's memory
$plan = @{
'qwen3:8b' = @{ num_ctx = 40960; long_tokens = 36000 } # qwen3:8b's trained maximum is 40,960
'qwen3-coder:30b' = @{ num_ctx = 102400; long_tokens = 97880 } # the article's long prompt was 97,880 tokens
}
$codePrompts = @(
'Write a thread-safe LRU cache in Python with unit tests.',
'Write a token-bucket rate limiter in TypeScript.',
"Implement Dijkstra's shortest-path algorithm in Go."
)
function Post-Ollama($path, $body) {
Invoke-RestMethod -Method Post -Uri ($Api + $path) -ContentType 'application/json' -Body ($body | ConvertTo-Json -Depth 8) -TimeoutSec 1800
}
function Unload-All {
foreach ($m in @((Invoke-RestMethod ($Api + '/api/ps')).models)) { Post-Ollama '/api/generate' @{ model = $m.name; keep_alive = 0 } | Out-Null }
Start-Sleep 3
}
function Measure-Reply($r, $wall) {
[ordered]@{
prompt_tokens = $r.prompt_eval_count
prefill_tok_s = if ($r.prompt_eval_duration) { [math]::Round($r.prompt_eval_count / ($r.prompt_eval_duration / 1e9), 1) } else { $null }
output_tokens = $r.eval_count
decode_tok_s = if ($r.eval_duration) { [math]::Round($r.eval_count / ($r.eval_duration / 1e9), 2) } else { $null }
first_token_s = [math]::Round(($r.load_duration + $r.prompt_eval_duration) / 1e9, 2)
wall_s = [math]::Round($wall, 2)
done_reason = $r.done_reason
}
}
function Invoke-Timed($body) {
$sw = [Diagnostics.Stopwatch]::StartNew(); $r = Post-Ollama '/api/generate' $body; $sw.Stop()
return $r, $sw.Elapsed.TotalSeconds
}
# Text for the long prompt: PowerShell's own format/type files and module sources, present on every Windows machine
$corpus = (Get-ChildItem "$env:windir\System32\WindowsPowerShell\v1.0" -Recurse -File -Include *.ps1xml, *.psm1, *.psd1 -ErrorAction SilentlyContinue |
Sort-Object FullName | ForEach-Object { "`n### FILE: $($_.Name)`n" + [IO.File]::ReadAllText($_.FullName) }) -join ''
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$base = Join-Path (Get-Location) "speed-bench-$env:COMPUTERNAME-$stamp"
$gpuCsv = "$base.gpu.csv"
$gpuInfo = (& nvidia-smi -i 0 --query-gpu=name,driver_version,memory.total --format=csv,noheader).Trim()
$sampler = Start-Process nvidia-smi -ArgumentList '-i 0 --query-gpu=timestamp,power.draw,temperature.gpu,clocks.gr,memory.used --format=csv,noheader,nounits -l 1 -f', "`"$gpuCsv`"" -WindowStyle Hidden -PassThru
$phases = @()
$results = @()
try {
foreach ($model in $Models) {
$p = $plan[$model]
if (-not $p) { $p = @{ num_ctx = 32768; long_tokens = 30000 } }
$caps = @((Post-Ollama '/api/show' @{ model = $model }).capabilities)
$opts = { param($n) @{ num_ctx = $p.num_ctx; num_predict = $n; temperature = 0; seed = 42 } }
function New-Body($prompt, $n) {
$b = @{ model = $model; prompt = $prompt; stream = $false; keep_alive = '15m'; options = (& $opts $n) }
if ($caps -contains 'thinking') { $b.think = $false } # thinking off, as in the article; non-thinking models reject the flag
$b
}
Write-Host "=== $model (num_ctx $($p.num_ctx)) ==="
Unload-All
$t0 = Get-Date
$r, $w = Invoke-Timed (New-Body 'Reply with exactly: warm' 8)
$loaded = @((Invoke-RestMethod ($Api + '/api/ps')).models | Where-Object name -eq $model)[0]
$phases += [pscustomobject]@{ model = $model; phase = 'load'; start = $t0; end = Get-Date }
$modelInfo = [ordered]@{
model = $model; num_ctx = $p.num_ctx; loaded_context = $loaded.context_length
load_s = [math]::Round($r.load_duration / 1e9, 1)
size_gib = [math]::Round($loaded.size / 1GB, 1)
gpu_percent = if ($loaded.size) { [math]::Round(100 * $loaded.size_vram / $loaded.size) } else { $null }
}
# Code benchmark
$t0 = Get-Date
$code = foreach ($cp in $codePrompts) {
$r, $w = Invoke-Timed (New-Body $cp 512)
$m = Measure-Reply $r $w; $m.prompt = $cp
Write-Host (" code: {0} tok/s decode, {1} tokens" -f $m.decode_tok_s, $m.output_tokens)
[pscustomobject]$m
}
$phases += [pscustomobject]@{ model = $model; phase = 'code'; start = $t0; end = Get-Date }
# Long-prompt benchmark: calibrate characters per token, then build the prompt just under the target
# A large slice: token density varies a lot between files, and Ollama silently cuts prompts longer than num_ctx
$sample = $corpus.Substring(0, [math]::Min($p.num_ctx * 3, $corpus.Length))
$r, $w = Invoke-Timed (New-Body ("Calibration $(Get-Random)`n" + $sample) 1)
$charsPerToken = $sample.Length / $r.prompt_eval_count
$chars = [int]($p.long_tokens * $charsPerToken * 0.97)
$text = $corpus
while ($text.Length -lt $chars) { $text += $corpus }
$question = "`n`nQuestion: Describe what the files above define, as a numbered list covering every file."
$longPrompt = "Session $([guid]::NewGuid())`n" + $text.Substring(0, $chars) + $question
Start-Sleep 5
$t0 = Get-Date
$r, $w = Invoke-Timed (New-Body $longPrompt 384)
$long = Measure-Reply $r $w
$long.chars_per_token = [math]::Round($charsPerToken, 2)
# Ollama keeps only the end of an over-long prompt (about half the context); a short count means that happened
$long.truncated = $r.prompt_eval_count -lt 0.8 * $p.long_tokens
if ($long.truncated) { Write-Warning "$model long prompt was truncated to $($r.prompt_eval_count) tokens" }
$phases += [pscustomobject]@{ model = $model; phase = 'long'; start = $t0; end = Get-Date }
Write-Host (" long: {0} prompt tokens, {1} tok/s prefill, {2} tok/s decode, {3} s to first token" -f $long.prompt_tokens, $long.prefill_tok_s, $long.decode_tok_s, $long.first_token_s)
$modelInfo.code = $code
$modelInfo.code_mean_decode_tok_s = [math]::Round(($code | Measure-Object decode_tok_s -Average).Average, 2)
$modelInfo.long = [pscustomobject]$long
$results += [pscustomobject]$modelInfo
Start-Sleep 5
}
}
finally {
Stop-Process -Id $sampler.Id -ErrorAction SilentlyContinue
Unload-All
}
# GPU summary per phase from the nvidia-smi samples
$samples = Get-Content $gpuCsv | Where-Object { $_ -match '^\d{4}/' } | ForEach-Object {
$f = $_.Split(',') | ForEach-Object { $_.Trim() }
[pscustomobject]@{
time = [datetime]::ParseExact($f[0], 'yyyy/MM/dd HH:mm:ss.fff', $null)
power_w = [double]$f[1]; temp_c = [int]$f[2]; clock_mhz = [int]$f[3]; mem_mib = [int]$f[4]
}
}
$gpuPhases = foreach ($ph in $phases) {
$s = @($samples | Where-Object { $_.time -ge $ph.start -and $_.time -le $ph.end })
if (-not $s.Count) { continue }
[pscustomobject][ordered]@{
model = $ph.model; phase = $ph.phase; seconds = [math]::Round(($ph.end - $ph.start).TotalSeconds, 1)
avg_power_w = [math]::Round(($s | Measure-Object power_w -Average).Average, 1)
max_power_w = ($s | Measure-Object power_w -Maximum).Maximum
max_temp_c = ($s | Measure-Object temp_c -Maximum).Maximum
avg_clock_mhz = [math]::Round(($s | Measure-Object clock_mhz -Average).Average)
max_mem_mib = ($s | Measure-Object mem_mib -Maximum).Maximum
}
}
$battery = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1
$out = [ordered]@{
computer = $env:COMPUTERNAME; date = (Get-Date).ToString('s')
ollama_version = (Invoke-RestMethod ($Api + '/api/version')).version
gpu = $gpuInfo
power_scheme = (powercfg /getactivescheme) -replace '^.*\((.*)\)\s*$', '$1'
on_ac_power = if ($battery) { $battery.BatteryStatus -eq 2 } else { $null }
models = $results
gpu_phases = $gpuPhases
}
$json = $out | ConvertTo-Json -Depth 8
[IO.File]::WriteAllText("$base.json", $json, (New-Object Text.UTF8Encoding $false))
$gpuPhases | Format-Table -AutoSize | Out-String | Write-Host
Write-Host "Results: $base.json"
Write-Host "GPU samples: $gpuCsv"



Top comments (0)