No BIOS image, so I diffed the NVRAM
Contents
flashrom got two attempts at the GEEKOM A9 Mega's SPI flash. Both were read-only: -r, run twice, so the two dumps could be hashed against each other before anyone trusted them. The package was Fedora 45's flashrom-1.6.0-6.fc45.
Attempt one, Secure Boot on. The kernel is in lockdown, and lockdown takes raw port I/O away from userspace: ioperm and iopl are on its refusal list. flashrom never reaches the chip.
Attempt two, Secure Boot off. flashrom runs, and names the chipset "AMD FP4". The CPU is a Ryzen AI Max+ 395, family 0x1A, model 0x70: Strix Halo. Whatever flashrom thinks it is talking to, it is not this. It fails to lift the ROM protection, changes nothing, and segfaults.
Score: zero bytes. The plan to hash two dumps against each other never got a first dump to hash.
As a tool review, that's a bug report waiting to be filed, and possibly a stale one: flashrom's own docs are already past v1.8.0, and I haven't checked whether anything newer than Fedora's 1.6.0 knows Strix Halo. But flashrom was only ever plan B. Plan A was downloading the BIOS, and there's nothing to download.
No BIOS image on the vendor page, only the EC#
GEEKOM's support page for the A9 Mega offers one firmware package: EC 0.17, for the ITE IT5571 embedded controller on board NUCSH01. It's a 128 KiB image for the chip that runs the fans, among other things. This unit already ran 0.17 out of the box. No BIOS 0.16 file, and no older one either.
On the AE8, GEEKOM published the ROM, and a ROM is a complete map: unpack it, extract the IFR, and you get all 876 knobs with their options, defaults and the byte offset each one writes to. With two ROMs you can diff BIOS versions knob by knob. IFR is the schema. Without the ROM there's no schema, and the setup screen is the only documentation left.
Filming the hidden menus#
The hidden menus open close to the way GEEKOM documents for other models. Hold Ctrl from power-on, tap H, spam Del. It works only at power-on: Ctrl+H pressed inside setup, after systemctl reboot --firmware-setup, unhides nothing. You get a second Advanced tab with AMD CBS, PBS and PMF. AMD Overclocking (PBO, Curve Optimizer, Curve Shaper) sits behind AMD's warning screen in the regular Advanced tab.
GEEKOM's own instructions for this cover the A8, AX8 and AE8 Max. The A9 Mega isn't on its model list.
So the menus got filmed with a phone: three videos, frames pulled at 2 fps. The first video alone gave 387 distinct frames, OCR'd in four chunks into a 51-page text tree; the other two added the AMD Overclocking page, the CBS top level and the manual PBO and SMU fields it had missed. That tree tells you what each setting is called and what it offers:
- System Configuration: Auto / 45W / 55W / 85W / 120W
- PPT Control: Auto / Manual, and under Manual: Fast PPT Limit [mW], Slow PPT Limit [mW], Slow PPT Time Constant [s]
- Dedicated Graphics Memory: 0.5G up to 96G
It doesn't tell you where any of them are stored, or whether they do anything.
AmdSetup in efivarfs: the settings were readable all along#
Setup values live in NVRAM as UEFI variables, and Linux exposes those as files under /sys/firmware/efi/efivars. On this box the AMD CBS settings sit in one variable, AmdSetup, a 1,526-byte file. The first four bytes are the attribute word (07 00 00 00), and the setting data follows.
That gives a method that doesn't need a ROM. Snapshot the variables, change one setting in the BIOS, boot, snapshot again, diff. The byte that moved is the setting.
#!/usr/bin/env bash
# snap.sh <dir>: copy the AMD setup variables out of efivarfs
set -euo pipefail
mkdir -p "$1"
for v in /sys/firmware/efi/efivars/{AmdSetup,AodSetupStxh,AMD_PBS_SETUP}-*; do
[ -e "$v" ] || continue
cat "$v" > "$1/${v##*/}" # cat, not cp
done
Boiled down to the three variables that matter here, that's the method; the committed snapshots hold every variable. The comment is there for a reason. On the A9, cp from efivarfs produced empty files, while cat from the same paths gave full copies. I didn't chase why. Every snapshot in this post was taken with cat.
Reading cmp -l: decimal offsets, octal bytes#
Here is the diff that decoded the power limit, run against two committed snapshots: one after Optimized Defaults, one after setting PPT by hand.
$ cmp -l 20261010-0041-f3-85w/AmdSetup-* 20261010-0048-ppt85-uma05/AmdSetup-*
249 377 3
262 2 0
322 0 1
323 0 10
324 0 114
325 0 1
327 0 10
328 0 114
329 0 1
331 0 5
The AodSetupStxh and AMD_PBS_SETUP copies compared equal, so everything is in this one variable.
cmp -l prints the byte number in decimal, counting from 1, then the old and new values in octal. So 114 is not 114. It's 0o114 = 76 = 0x4C. And to get the data offset, subtract 1 for cmp's counting and 4 for the attribute word. File byte 322 is data+317.
Translated:
| cmp byte | data offset | old → new | meaning |
|---|---|---|---|
| 322 | +317 | 0 → 1 | PPT Control: Auto → Manual |
| 323–325 | +318..321 | → 08 4C 01 | Fast PPT Limit, u32 little-endian |
| 327–329 | +322..325 | → 08 4C 01 | Slow PPT Limit, same encoding |
| 331 | +326 | 0 → 5 | Slow PPT Time Constant, seconds |
Each limit field shows only three changed bytes because the fourth was zero before and is still zero. cmp lists changes, not fields. xxd shows the whole run, from data+317 to data+326:
$ xxd -s 321 -l 10 20261010-0048-ppt85-uma05/AmdSetup-*
00000141: 0108 4c01 0008 4c01 0005 ..L...L...
08 4C 01 00, read little-endian, is 0x00014C08 = 85,000. Milliwatts. That's the number I typed into the menu.
The snapshot that changed two settings#
The method says one setting at a time. This snapshot changed two. Optimized Defaults had put Dedicated Graphics Memory back to 64 G, so the same BIOS visit set it to 0.5 G as well as the PPT fields. That's the top two lines of the diff:
- data+244,
0xFF→ 3: likely the Dedicated Graphics Memory selector. It was 3 in the first snapshot, when the box ran at 0.5 G, went to0xFFwhen Optimized Defaults restored the 64 G default, and came back to 3 with 0.5 G selected again. - data+257, 2 → 0: it moved at the same time, and I don't know what it is.
The PPT fields were safe because 85,000 identifies itself: type 85000 into a menu, and a four-byte field that becomes 85,000 isn't hard to name. A one-byte enum doesn't have that luxury. So "likely" stays on +244 until it gets a diff of its own.
The diff before this one was worse. Optimized Defaults, with the 85W preset re-selected afterwards, moved eight bytes in AmdSetup at once, most of them to 0xFF. Apart from +244, none of them is decoded. Changing one setting per diff is the whole method. A BIOS reset changes everything that wasn't at its default, all in one diff, and tells you almost nothing.
The 85W preset that changes nothing#
System Configuration is the obvious power knob. It sits in AMD CBS → SMU Common Options and comes with a warning in its help text, as transcribed from the filmed setup screen:
Warning: Select System Configuration may cause the system to hang, as some System Configuration may not be supported by your OPN.
I selected 85W. The system did not hang. Nothing else happened either: the quick benchmark's sustained CPU stress after that boot averaged 120.7 W package power, and a later 60 s stress with 85W still selected gave 120.1 W, Tctl peaking at 80.4 °C. That's the uncapped number.
Where the preset stores itself, I can't tell you. The first snapshot was taken after it was already set, so there's no before to diff against. All I know is what it does, and it does nothing.
PPT Control is the knob that works. Manual, Fast and Slow PPT at 85000 mW, time constant 5 s, then the same 60 s stress:
- package power: 85.0 W mean (was ~120)
- Tctl max: 65.5 °C (was ~80)
- CPU clock: 4084 MHz average (was ~4540, about −10 %)
Linux doesn't offer a softer route here: there's no ACPI platform_profile on this box, likely because PMF Device Support is Disabled in the hidden menus.
What 85 W costs llama.cpp: mostly prefill#
The same quick benchmark ran with PPT at 85 W, against two baselines with no power limit. One ran at 20:44. The other ran at 23:37 with the 85W preset selected, and since the preset is a no-op, that's a second control for free. Vulkan rows, tok/s:
| Row | 20:44 | 23:37 | PPT 85 W | Δ vs 20:44 | Δ vs 23:37 |
|---|---|---|---|---|---|
| Qwen3-30B-A3B pp512 | 1656.57 | 1660.64 | 1472.61 | −11.1 % | −11.3 % |
| Qwen3-30B-A3B tg128 | 89.49 | 92.84 | 88.84 | −0.7 % | −4.3 % |
| gpt-oss-120b pp512 | 1065.34 | 1070.92 | 923.35 | −13.3 % | −13.8 % |
| gpt-oss-120b tg128 | 54.30 | 55.55 | 53.59 | −1.3 % | −3.5 % |
| gpt-oss-20b pp512 | 2261.46 | 2258.72 | 1920.79 | −15.1 % | −15.0 % |
| gpt-oss-20b tg128 | 75.61 | 78.40 | 74.97 | −0.8 % | −4.4 % |
| llama-2-7b pp512 | 1722.04 | 1700.01 | 1488.91 | −13.5 % | −12.4 % |
| llama-2-7b tg128 | 53.10 | 54.00 | 52.50 | −1.1 % | −2.8 % |
Prefill loses 11–15 % on Vulkan, and 15–18 % on ROCm. It's the same against either baseline.
One caveat on the capped run: the harness logged WARN: 1-min loadavg 15.93 > 1 before start: something else is running, numbers will be low. Both baselines started below 0.2. The "something else" was most likely the 60 s PPT stress test run just before it. Idle power at the start read 7.4 W against 6.7–6.8 W for the baselines, and the single-thread 7z score didn't move (7347 against 7323 and 7321). The llama.cpp rows didn't start until 00:55:56, nearly six minutes after the warning, so a leftover one-minute load average had long decayed by then. Still, it's a reason to read the smaller deltas below with care. The prefill loss is far larger than anything that leftover explains.
Generation is murkier. Against 20:44, Vulkan tg loses about 1 %. Against 23:37, it loses 3–4 %. The disagreement is the finding: the two controls disagree with each other by up to 3.7 % (Qwen3-30B tg, 89.49 vs 92.84) with no setting changed between them. On Vulkan, the tg "cost" of 85 W is about the size of the run-to-run noise. ROCm is the exception: its two baselines agree within 0.4 % on tg, and at 85 W its tg drops 2.0–6.6 %. Small, and likely real, with the load-average caveat above.
That split fits the usual reading of LLM inference: prefill is compute-bound and feels the clock cut, decode is memory-bound and mostly doesn't. The 35 W saving from the stress test doesn't carry over either. What the cap clips is the peaks: 76–95 W at 20:44, 67–81 W under the cap. For long-context work, prefill is the wait. PPT went back to Auto at 01:08 on 2026-10-10, before the long-context model runs.
The F3 trap: Optimized Defaults restores 64 G of graphics memory#
One more trap turned up along the way: F3, Optimized Defaults, puts Dedicated Graphics Memory back to 64 G, quietly undoing the previous part's setup. In the byte diff, +244, the likely selector, went to 0xFF.
Ten bytes with names#
Curve Optimizer is next, and it isn't tried yet. It's an AMD Overclocking setting, and those are stored in AodSetupStxh, a separate 805-byte variable. The plan is the same loop: one setting, one snapshot, one cmp -l.
AmdSetup carries 1,522 bytes of settings. So far, ten of them have names.
Filed under: amd, bios, uefi, firmware, linux
Read next#
- The previous part: why Linux saw only 31 GiB of a 96 GiB carve-out, the graphics-memory setup that F3 silently undoes.
- The next part: a benchmark contaminated by its own downloads, on what a measurement looks like when something else is running underneath it.
Sources#
- kernel_lockdown(7): lockdown blocks "the use of the ioperm and iopl instructions on x86", which is what stopped flashrom with Secure Boot on.
- flashrom: the tool. Its docs were on v1.8.0 plus development commits when fetched on 2026-10-10; the version tested was Fedora's
flashrom-1.6.0-6.fc45. - GEEKOM A9 Mega support page: the downloads section, which offered only the EC 0.17 package when checked on 2026-10-09 (project notes). The static HTML fetched on 2026-10-10 holds no download list, so that day's state is unconfirmed.
- GEEKOM: instructions for opening advanced BIOS options (PDF): the Ctrl+H + Del method, documented for A8 Max, AX8 Max and AE8 Max.
- efivarfs, Linux kernel documentation: the filesystem behind
/sys/firmware/efi/efivars. - GNU diffutils manual, cmp Options:
-loutputs "the (decimal) byte numbers and (octal) values of all differing bytes"; "Byte numbers start at 1."