DEV Community

Cover image for Unplug the Surface Laptop Ultra and qwen3-coder drops from 94 to 73 tokens a second: RTX Spark on battery
Steef-Jan Wiggers
Steef-Jan Wiggers

Posted on

Unplug the Surface Laptop Ultra and qwen3-coder drops from 94 to 73 tokens a second: RTX Spark on battery

Plugged in, my Surface Laptop Ultra answers short coding prompts from qwen3-coder:30b at 94 tokens a second. On battery, it manages 73, and the way it gets there is the interesting part: the GPU runs at full clock for about ten seconds and then the power budget drops from 33 W to 17 W. The first prompt of three lands at 78 tokens per second, the third at 67.

The 100K-token prompt from the first post takes 167 seconds to its first token on battery against 139 plugged in, and replies at 18.7 tokens a second instead of 20.5. One run of the benchmark, about five minutes of GPU work, used 6% of the battery.

The Windows power plan changed almost nothing on battery, and the Power mode slider's Best performance setting bought 12% on prompt processing when plugged in. The charger is the variable that matters.

Four conditions, one benchmark

Each condition is one run of speed-bench.ps1 from the first post, for qwen3-coder:30b only: three 512-token code replies, then one question after a 100K-token prompt. A wrapper sets the power plan, waits for the charger to be unplugged, reads the battery before and after, and lets the settings settle for 30 seconds before the benchmark starts.

AC, Balanced charger in Balanced plan, Power mode slider on Balanced the first post's setup
AC, Best performance charger in Balanced plan, Power mode slider on Best performance
Battery, Balanced unplugged Balanced plan, slider on Balanced 88% → 82%
Battery, Power saver unplugged Power saver plan 82% → 77%

This machine ships with only the Balanced and Power saver plans; powercfg can neither activate nor duplicate High performance, and powercfg /getactiveoverlayscheme isn't available on this build. So Best performance was set by hand in Settings, which the script notes but cannot read. Model: qwen3-coder:30b, Q4_K_M, Ollama 0.40.11, f16 cache, 102,400-token context, all on the GPU. Surface Laptop Ultra, RTX Spark N1X, 64 GB, 27 Oct 2026.

Three panels: short-prompt decode 94, 96, 73, 78 tokens a second for AC Balanced, AC Best performance, Battery Balanced, Battery Power saver; 100K prompt processing 722, 813, 602, 574; 100K decode 20.5, 21.0, 18.7, 17.9

AC, Balanced AC, Best performance Battery, Balanced Battery, Power saver
Code prompt 1 / 2 / 3, decode 96.1 / 93.4 / 93.5 tok/s 98.4 / 94.3 / 94.4 78.5 / 73.3 / 67.0 77.4 / 79.6 / 75.7
100K prompt, prompt processing 722 tok/s 813 602 574
100K prompt, first token 139 s 124 s 167 s 175 s
100K prompt, decode 20.5 tok/s 21.0 18.7 17.9
GPU power, code prompts 42.6 W avg, 49.5 peak 46.2 avg, 49.6 peak 23.4 avg, 37.1 peak 27.6 avg, 41.3 peak
GPU power, long prompt 35.9 W avg, 61.4 peak 41.9 avg, 73.6 peak 28.5 avg, 49.2 peak 26.4 avg, 47.1 peak
GPU peak temperature 75 °C 76 °C 66 °C 65 °C
Model load from SSD 9.5 s 8.2 s 27.7 s 60.2 s
Battery used 0 0 6% in 5.3 min 5% in 6.2 min

Two earlier battery runs, lost to a script bug after their measurements were printed, gave 82.6 / 68.5 / 62.8 and 76.8 / 66.0 / 55.4 on the code prompts and 604 and 610 tokens a second on the long prompt, with decode at 20.1 and 20.0. So the short-prompt and prompt-processing drops are consistent across four battery runs; the long-prompt decode drop is somewhere between nothing and 13%.

Ten seconds of boost

The per-second GPU samples show what the battery does to a short burst of work.

GPU power and clock over the first 40 seconds, AC vs battery: on AC, 40–50 W at 2.2–2.3 GHz for the whole 17 seconds of code prompts; on battery, 33 W at 2.3 GHz for ten seconds, then 16–20 W at 1.75 GHz

Plugged in, the GPU holds 38 to 50 W at 2.2 to 2.3 GHz for all three code prompts. On battery, it starts the same way at 33 W, and after ten seconds the budget falls to 16 to 20 W and the clock to about 1.75 GHz. The first prompt gets most of its 512 tokens inside that window; the third gets none. That is why the three battery runs under the Balanced plan all show the same descending pattern, 78 → 73 → 67, where the AC runs are flat.

The long prompt sits under a different cap. Reading 100K tokens is compute-bound, and the battery holds it at 28 W instead of 36 W, which costs 17% of the prompt-processing speed and adds 28 seconds to the first token. Decode at 100K is bound by memory bandwidth, and that is barely touched: the reply arrives at 18.7 tokens a second instead of 20.5. At long context the bytes-per-token model from the earlier posts still rules, and the battery's cap on the compute clock matters less than the cache it has to read.

Two smaller observations. The GPU peaked at 66 °C on battery against 75 °C plugged in, so an unplugged laptop is also a cooler and quieter one. And loading the 17 GiB model from the SSD took 9.5 seconds on AC, 27.7 on battery with the Balanced plan and 60.2 with Power saver; the storage power states on battery slow the one step that happens before the GPU does anything.

Plans and sliders

On battery, switching from the Balanced plan to Power saver did nothing I can separate from run-to-run noise: the code prompts were a little faster (77.5 against 73.0 mean), the long prompt a little slower (574 against 602 prompt tokens a second), and the battery drain was the same within a percent. The firmware's battery budget sets the GPU's share, and the plan sits above it.

Plugged in, Best performance made a measurable difference to the long prompt only: prompt processing rose from 722 to 813 tokens a second, the first token arrived 15 seconds sooner, and the GPU averaged 42 W during the prompt instead of 36, with a 74 W peak. The short prompts were within 2% of Balanced. Best performance is worth switching on for a cold 100K prompt and makes no difference to a chat.

What it means

  • On battery, plan for 75 tokens a second on short replies, not 94. And expect the first reply after a pause to be faster than the ones that follow it; the boost window is about ten seconds.
  • A cold 100K-token prompt costs 28 seconds more on battery, and roughly 3 to 5% of the charge on its own. A full benchmark run, 5 minutes of GPU work, took 5 to 6%; call it 1% a minute while the GPU is busy.
  • Decode at long context is nearly immune. 18.7 against 20.5 tokens a second at 100K. If your session is long conversations rather than short bursts, the battery costs you less than the short-prompt number suggests.
  • Don't bother with the power plan on battery. Power saver and Balanced gave the same speed and the same drain. Plugged in, set Best performance before a big prompt; it is 12% faster at reading it.
  • The first post's "power limit, not thermal" reading holds. The battery lowers the limit, the temperature drops with it, and the clock follows the limit on both sides of the charger.

Script notes

run-power-bench.ps1 wraps the unchanged speed-bench.ps1. It records the active plan, the battery percentage and charger state before and after (from Win32_Battery), the nvidia-smi -q -d POWER output, which on this chip reports every power limit as N/A, and a console transcript. It tries to activate the plan for the requested mode and, when the plan doesn't exist on the machine, pauses so the Power mode slider can be set by hand.

Two bugs cost runs. speed-bench.ps1 parsed the nvidia-smi CSV with [datetime]::ParseExact and died on the half-written last line that stopping the sampler leaves behind, taking the wrapper and its battery-after reading with it; the parser now skips lines it can't read, and the wrapper catches a benchmark error and still writes its own results. And the wrapper's first version used powercfg /getactiveoverlayscheme, which this build doesn't have.

Sources

run-power-bench.ps1

# Battery vs AC: runs speed-bench.ps1 under one power condition and records what changed around it.
# Run it once per condition, e.g.
#   .\run-power-bench.ps1 -Condition ac-balanced     -Mode balanced
#   .\run-power-bench.ps1 -Condition ac-best         -Mode max
#   .\run-power-bench.ps1 -Condition battery-balanced -Mode balanced -Battery
#   .\run-power-bench.ps1 -Condition battery-saver   -Mode min      -Battery
# -Battery makes the script wait until the charger is unplugged (and refuses to start while it is in).
# Records the active power plan, battery percentage before and after, the GPU power limit
# from nvidia-smi, and the speed-bench JSON it produced. Output goes to C:\Dev\power-bench\.
param(
    [Parameter(Mandatory)][string]$Condition,
    [ValidateSet('balanced', 'max', 'min')][string]$Mode = 'balanced',   # power plan: Balanced, High performance, Power saver
    [switch]$Battery,
    [string[]]$Models = @('qwen3-coder:30b'),
    [string]$Bench = 'C:\Dev\speed-bench.ps1'
)
$ErrorActionPreference = 'Stop'
$outDir = 'C:\Dev\power-bench'
New-Item -ItemType Directory -Force $outDir | Out-Null
Set-Location $outDir

# Power plan. powercfg's overlay (Power mode) commands are not available on this build, so this switches the classic
# scheme: Balanced, High performance or Power saver. High performance is hidden on most laptops; it is duplicated from
# the built-in GUID when missing. The scheme is restored at the end.
$builtin = @{ balanced = '381b4222-f694-41f0-9685-ff5bb260df2e'; max = '8c5e7fda-e8bf-4a96-a9c4-0ff2b8d02f6b'; min = 'a1841308-3541-4fab-bc81-f71556f20b4a' }
function Get-ActiveScheme { ((& powercfg /getactivescheme | Out-String).Trim()) }
function Get-SchemeGuid($text) { if ($text -match '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})') { $Matches[1] } }
function Set-Scheme($guid) {
    $r = & cmd /c "powercfg /setactive $guid 2>&1"
    if ($LASTEXITCODE -ne 0) {
        # not present on this machine: create it from the built-in template
        $dup = & cmd /c "powercfg /duplicatescheme $guid 2>&1" | Out-String
        $new = Get-SchemeGuid $dup
        if (-not $new) {
            Write-Warning "This machine has no power scheme for '$Mode' (Modern Standby devices only ship Balanced)."
            Write-Warning "Set Settings > System > Power & battery > Power mode by hand before continuing; the scheme stays Balanced."
            Read-Host "Set the Power mode slider now, then press Enter"
            return
        }
        & cmd /c "powercfg /setactive $new 2>&1" | Out-Null
    }
}
function Get-Battery {
    $b = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1
    if (-not $b) { return $null }
    [pscustomobject]@{ percent = $b.EstimatedChargeRemaining; on_ac = ($b.BatteryStatus -eq 2); status = $b.BatteryStatus; time = (Get-Date).ToString('s') }
}
function Get-GpuPower {
    $q = & cmd /c "nvidia-smi -q -d POWER 2>&1" | Out-String
    $lines = ($q -split "`n") | Where-Object { $_ -match 'Power Limit|Power Draw|Current Power|Requested Power|Enforced' } | ForEach-Object { $_.Trim() }
    $lim = ($lines | Where-Object { $_ -match '^(Current )?Power Limit' } | Select-Object -First 1) -replace '[^\d.]', ''
    [pscustomobject]@{ raw = $lines; limit_w = if ($lim) { [double]$lim } else { $null } }
}

$prevScheme = Get-ActiveScheme
$prevGuid = Get-SchemeGuid $prevScheme
Set-Scheme $builtin[$Mode]
Start-Sleep 2
$activeScheme = Get-ActiveScheme
$activeOverlay = $null
Write-Host "scheme: $activeScheme"
Write-Host "(if Settings > Power shows a Power mode slider, note its position; this script cannot read it)"

# Charger state
$bat = Get-Battery
if ($Battery) {
    while ($bat.on_ac) { Write-Host "Unplug the charger now; waiting... (battery $($bat.percent)%)"; Start-Sleep 5; $bat = Get-Battery }
    if ($bat.percent -lt 40) { Write-Warning "battery at $($bat.percent)%; a full run uses several percent, consider charging first" }
} elseif (-not $bat.on_ac) {
    throw "Charger is not connected; plug it in or pass -Battery"
}
Write-Host ("battery before: {0}% on_ac={1}" -f $bat.percent, $bat.on_ac)
$gpuBefore = Get-GpuPower
Write-Host "GPU power limit before: $($gpuBefore.limit_w) W"
Start-Sleep 30   # let the power mode and the charger change settle before measuring

$t0 = Get-Date
$transcript = Join-Path $outDir "console-$Condition-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
Start-Transcript -Path $transcript -Force | Out-Null
$benchError = $null
try { & $Bench -Models $Models } catch { $benchError = "$_"; Write-Warning "speed-bench.ps1 failed after measuring: $benchError" }
Stop-Transcript | Out-Null
$t1 = Get-Date

$batAfter = Get-Battery
$gpuAfter = Get-GpuPower
Write-Host ("battery after:  {0}% on_ac={1}  ({2:N1} min)" -f $batAfter.percent, $batAfter.on_ac, ($t1 - $t0).TotalMinutes)

# The speed-bench JSON this run just wrote
$benchJson = Get-ChildItem (Join-Path $outDir 'speed-bench-*.json') | Where-Object LastWriteTime -ge $t0 | Sort-Object LastWriteTime | Select-Object -Last 1
$benchData = if ($benchJson) { Get-Content $benchJson.FullName -Raw | ConvertFrom-Json } else { $null }

$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$out = [ordered]@{
    condition      = $Condition
    requested_mode = $Mode
    power_overlay  = $activeOverlay
    power_scheme   = $activeScheme
    battery_before = $bat
    battery_after  = $batAfter
    battery_used_percent = if ($bat -and $batAfter) { $bat.percent - $batAfter.percent } else { $null }
    minutes        = [math]::Round(($t1 - $t0).TotalMinutes, 1)
    gpu_power_before = $gpuBefore
    gpu_power_after  = $gpuAfter
    speed_bench_file = if ($benchJson) { $benchJson.Name } else { $null }
    bench_error      = $benchError
    console_log      = Split-Path $transcript -Leaf
    speed_bench      = $benchData
}
$file = Join-Path $outDir "power-bench-$Condition-$stamp.json"
[IO.File]::WriteAllText($file, ($out | ConvertTo-Json -Depth 12), (New-Object Text.UTF8Encoding $false))
Write-Host "Results: $file"

# Put the power plan back
if ($prevGuid) { & cmd /c "powercfg /setactive $prevGuid 2>&1" | Out-Null; Write-Host "restored: $prevScheme" }
Enter fullscreen mode Exit fullscreen mode

Top comments (0)