🎧 Listen to this article

Mainline Linux on the SpacemiT K3 shows eight cores. The vendor kernel showed all sixteen and could not load KVM. Ubuntu's kernel loads KVM and pretends the eight A100 cores do not exist. For a month I have had to pick one.

Now I do not. The board is running a patched kernel.org Linux 7.3 build with all sixteen cores online. The twelve Debian guests from my load harness still run under KVM on the eight X100 cores, and the eight A100 cores take any host program that asks for them. I did not flash any firmware.

It took two kernel builds, one firmware query, a device-tree correction, and a test that had to be fixed before it could test anything. It also turned up a panic in the first custom kernel, on its way down rather than up.

The A100 cores have no hypervisor extension, so none of this adds a virtual machine. In the October piece on renting these boards I called sixteen-core support the gate to watch and said it would roughly double capacity on a software update. For the product I was planning to sell, that was wrong. The last section of this piece works out what it changes instead.

The K3's sixteen cores form two separate CPU groups: eight X100 cores, logical CPUs 0–7, run ordinary host tasks and KVM with 256-bit vector registers; eight A100 cores, CPUs 8–15, run explicitly launched host workers with 1,024-bit vector registers and no hardware virtualization extension. The register-width bars show a four-to-one size ratio, not a throughput comparison.

Why build a kernel at all

The board has hung twice under my sustained-load harness, once on Ubuntu's 7.3.0-5 kernel and once on 7.3.0-8. The second time, the last telemetry row showed a cool, nearly idle board with nothing blocked. It simply stopped, and stayed down until I power-cycled it. It has not hung in about thirteen days on 7.3.0-9. Two events on two builds cannot separate a kernel bug from a firmware or hardware one, and a newer kernel was the cheapest next experiment.

It is not an explanation, though. Watching a new kernel boot says nothing about a hang that took days to appear, so I kept the old telemetry and recorded this run separately. Nothing in this piece claims the hangs are fixed.

Ubuntu's 7.3 is not 7.3

Ubuntu calls its kernel 7.3.0-9-generic. Its /proc/version_signature says the source underneath is Linux 7.3.0-rc6. Kernel.org has since released 7.3 final, so I started from that tarball, checked its SHA-256, and verified the detached signature against Greg Kroah-Hartman's published key.

Upstream 7.3 cannot boot this board the way I run it. It has no K3 PCIe support, and my root filesystem lives on a 2 TB WD_BLACK SN850X behind that controller, because the onboard UFS still does not work on mainline. So I carried the PCIe driver, its PHY dependencies and the clock fixes over from the exact Ubuntu source package behind the installed kernel. The shared DesignWare PCIe code was identical in both trees. The first build used Ubuntu's device tree unchanged, because the upstream one has no PCIe nodes. Display support stayed out; this machine is managed over SSH and serial.

The release is 7.3.0-tinymachines-k3-1, and "patched kernel.org build" is the accurate name for it. The board-specific code I carried over is exactly where a K3 bug would most likely live, so changing the base version cannot rule it out.

For the configuration I started from Ubuntu's, trimmed it with localmodconfig against a snapshot of the loaded modules, and then checked by hand that NVMe, LVM, ext4, networking, containers and KVM had survived the trim. An AMD GPU driver had survived it too, through graphics-helper dependencies, on a board with no AMD GPU, so I disabled it explicitly. The native build on the K3 took under an hour and produced the EFI boot image and 1,446 modules. Four GCC warnings about stack-frame size came from InfiniBand management code this board never loads. There were no errors.

Making a bad kernel cheap to lose

Before the first reboot I set things up so that a bad kernel would cost me a power cycle rather than a reinstall.

Ubuntu's kernel, initrd and device tree stayed installed, and I recorded their hashes so the install script could prove it had not touched them. GRUB kept Ubuntu as the default, and the custom kernel got exactly one boot through grub-reboot. The K3's serial console runs over a PL2303 USB cable (a CP2102 adapter is the better buy today) to a Raspberry Pi 5 that records it to disk, and the board's power comes through a Shelly Plug US Gen4 I can switch from another machine. If the new kernel died before SSH came back, the Pi would have its last words and the plug would put me back in Ubuntu.

That does not make a bad kernel harmless. kernel.panic stays at zero, so a dead kernel stays dead with its trace on the console, and getting out still means cutting power. What it buys is a failure I can read and a known place to land afterwards.

I also skipped make install. The generic install path can trigger Ubuntu's board-flashing hooks, and I did not want a kernel experiment anywhere near the firmware. I installed the versioned kernel, modules, config and device tree by hand, built a Dracut initrd that explicitly includes the PCIe, PHY and NVMe drivers, and listed its contents before rebooting. A clean compile means nothing if the next boot cannot find the controller its root filesystem sits behind.

The first custom kernel booted in 21.6 seconds. All twelve guests answered SSH, PostgreSQL accepted connections, Redis returned PONG, and nginx returned HTTP 200. There was no new panic or oops, and the seven error-level boot messages about CMA reservation and legacy PCI interrupts matched the Ubuntu boot exactly.

That proved the kernel could run the workload. It proved nothing about the hangs.

The firmware already knew about the other eight

Linux reported possible, present and online as 0-7, and the device tree described only X100 cores. CONFIG_NR_CPUS was already 512, so raising it would do nothing.

Before spending another build on the A100 port, I asked the firmware a narrower question. Linux on RISC-V starts each hardware thread, or hart, by asking OpenSBI through its Hart State Management extension, and the same extension answers status queries. I wrote a small module that queried all sixteen hart IDs without starting or stopping any of them.

All sixteen calls succeeded. Harts 0 through 7 reported STARTED. Harts 8 through 15 reported STOPPED.

That split one problem into two. Linux had no description of the extra cores, while the firmware knew their hart IDs and their state. No affinity trick in userspace bridges that gap. The work belonged in the kernel and the device tree, and the firmware I already had could stay. A STOPPED answer does not prove a start request will succeed, or that the cores will stay coherent under load. It did mean I could skip flashing firmware, which on this board means holding a button down while applying power.

Loading an out-of-tree module taints the kernel, so I unloaded it after the query and noted the taint against that boot.

Two vector widths on one chip

The X100 and A100 cores have different vector register widths: 256 bits on X100, 1,024 on A100. A task cannot carry live vector state from one to the other and expect its registers to fit. Upstream 7.3 checks for a matching width when it brings up secondary CPUs, and refuses the mismatch.

SpacemiT's current public source handles this under CONFIG_SPACEMIT_HMP. It allocates room for the larger vector state, keeps tasks in separate CPU groups, and provides /proc/set_ai_thread to move a task into the A100 group. Older public discussions said these changes had never been released. They are in the vendor's 6.18 tree now: the CPU grouping, the vector handling, and the KVM exceptions.

The KVM exceptions exist because A100 also lacks the H extension, the hardware support for virtualization. So the target was always the same: KVM stays on X100, and separate host programs get A100. The first review already showed that A100 runs ordinary code, compiling my Rust benchmark in 342.78 seconds against 161.29 on X100. What the extra cores add is host capacity. They do not add a single core to what a guest can use.

Porting the vendor code meant more than relaxing the startup check. A signal frame has to carry the right amount of vector state. A debugger has to read the right register size. KVM has to keep describing X100 registers to its guests even though the host now allocates room for A100's. The scheduler's fallback paths have to keep a task inside its own group when its affinity changes.

The sizes make it concrete. Thirty-two X100 vector registers occupy 1 KiB; the A100 set occupies 4 KiB. The port reserves 4 KiB per task but tracks how much each task actually owns, and signal delivery and ptrace copy that task's width. A signal frame that tries to change the width is rejected. KVM keeps the 1 KiB allocation and the X100 width, and KVM ioctls from an A100 task return EOPNOTSUPP. I also turned off preemptible in-kernel vector support for this build, so that fewer mechanisms changed at once. Userspace vectors stay on.

The first compile failed on something the vendor code never had to handle: Linux 7.3 requires a task_cpu_fallback_mask hook alongside an architecture's task CPU mask. The port now confines that fallback to the task's group as well.

The launcher needed care too. The C library's startup code can execute vector instructions before a program reaches main, and a task that already owns vector state cannot switch groups. So a100-run is a small assembly program that opts itself into the A100 group with raw system calls and then executes the requested program. The kernel accepts the request only from the calling thread, before it has used vectors and before it shares its address space with another thread. Nothing can move someone else's process.

A device tree can renumber your CPUs

The first device-tree merge had a quieter problem. fdtoverlay prepends the child nodes it adds, and RISC-V Linux numbers logical CPUs in the order it walks CPU nodes, after giving the boot CPU its special place. A naive overlay would have put most of the A100 cores ahead of the X100s. A command pinned to CPU 1 would have landed on a different physical core after the upgrade, and nothing would have told me.

The device-tree build script now sorts CPU nodes by hart ID after merging, checks that 0 through 7 are X100 and 8 through 15 are A100, and extends the interrupt-controller descriptions to all sixteen harts. The extra harts need their interrupt connections described, and the supervisor interrupt controller's region has to cover them. Everything else (storage, networking, clocks) had to stay exactly as it was, so the script compares the compiled tree against the pinned working one and allows only the intended interrupt changes. The result passes dtc with no warnings.

None of that can be caught by a kernel that compiles. The device tree is a separate boot artifact, and a wrong one fails at boot or, worse, does not fail at all.

My first test was wrong

The first vector-state test filled all 32 vector registers with known patterns, called sched_yield, and checked that the patterns survived. It failed on the unchanged eight-core kernel, before any A100 code existed. Every register came back filled with ones.

That was my mistake, not the kernel's. The Linux RISC-V vector ABI allows system calls to clobber the vector registers, and the syscall entry code deliberately discards them. I had written a test for a guarantee Linux does not make.

The corrected test keeps the registers live inside a userspace loop with no system calls in it. Timer interrupts and competing tasks preempt it. A signal handler fires every 500 microseconds and overwrites every vector register, so returning from the signal has to restore the interrupted state. An ebreak gives a debugger a ptrace stop without a system call inside the live-vector stretch.

It passed on the first custom kernel: 464 signals during 500 million loop iterations, a debugger read of the saved registers, and a rejected attempt to change the reported vector width. That baseline mattered. If A100 failed later, I would be debugging the kernel and not the test.

The rest of the test is built around specific ways the port could be wrong. The signal handler runs on an alternate stack between inaccessible guard pages. The arithmetic check uses 4,099 elements, so the final vector operation is a partial one. The negative tests try forbidden CPU affinities and hand the kernel a signal frame with an invalid vector width. Every check had an expected result before the new kernel ran, so a rejection could be a pass and a silent acceptance would be a bug.

The old kernel panicked on the way out

Once the A100 build finished, I installed it alongside both fallback kernels, checked their hashes again, and added a separate GRUB entry with maxcpus=9: eight X100 cores and one A100 for the first boot, with the load harness held off by a marker on the kernel command line.

The reboot never reached GRUB. The eight-core custom kernel unmounted the root filesystem, called its final reset, and panicked:

Comm: systemd-shutdow
7.3.0-tinymachines-k3-1
epc : 0x2009aba6
Kernel panic - not syncing: Fatal exception

This was not an A100 failure, because that kernel had not started yet. On RISC-V the reset path calls the EFI runtime ResetSystem service before it tries the SBI restart handler, which makes EFI a lead. The trace does not establish a cause. That boot also carried the out-of-tree taint from the HSM probe, which belongs in the record next to the trace.

EFI pstore failed to save the panic as well, with error -22. The Pi's serial recording is the only reason I have it. After copying the trace I cut power with the Shelly plug and turned it back on. The one-time GRUB selection was still waiting, because the failed reset had never reached the bootloader to use it up.

UTC bring-up timeline for October 26–27, 2026: the first eight-core custom kernel boots at 23:19; firmware recognizes all sixteen harts at 00:06; the older kernel panics during reset at 01:01:52; after power cycling, the A100 kernel boots with nine CPUs at 01:03:28. The first telemetry sample with all sixteen online is at 01:04:45. A four-minute concurrent validation finishes at 01:11:56, with 32 workers passing and twelve KVM guests answering SSH. This is initial validation, not a long-term stability result.

Sixteen cores, with the guests still on X100

After the power cycle, 7.3.0-tinymachines-k3-a100-1 booted with nine CPUs. NVMe, networking and SSH worked. A test process launched onto CPU 8 measured its vector registers at 128 bytes each; X100 still measured 32.

Both core types passed the register, signal, arithmetic, debugger and affinity tests, and A100 refused KVM ioctls as designed. Then I brought CPUs 9 through 15 online one at a time and checked that every logical CPU number matched its hart ID.

Keeping the harness paused gave that first boot one job. A single A100 core was enough to exercise the different vector width and the virtualization restriction, and bringing up the other seven one at a time tested each startup on its own. Only then did I restart the guests. Had the board stopped at any step, the serial log and the last passing check would have told me where to look.

Then came a longer concurrent run, about four minutes, while the VM fleet was starting:

Check Observed result
CPU topology Eight X100 and eight A100 cores online
Register-state stress Two workers per CPU; all 32 passed 100 billion loop iterations each
Signal handling 5,968,568 signal deliveries across those workers
Shared memory 16,000,000 atomic increments; all sixteen per-CPU totals matched
KVM All twelve guests answered SSH; every QEMU process confined to CPUs 0-7
Guest vector state The same test passed inside a guest with the correct 32-byte register width
Containers PostgreSQL accepting connections, Redis PONG, nginx HTTP 200
Kernel and services Kernel taint zero; no failed systemd units at the end of the run

The loop counts measure context-state handling, not vector throughput. The loop itself is two instructions. What matters is that 32 sets of vector registers stayed intact through two workers per core, timer preemption and almost six million signal deliveries. A separate arithmetic check covers real vector computation with an uneven tail. The deliberately corrupted signal frames produce bad frame messages from the user processes, which is the expected outcome, and no kernel oops.

The shared-memory check made both groups fight over the same data. Sixteen workers, one pinned to each CPU, incremented one counter in a shared mapping while keeping their own per-CPU totals. The combined count came to exactly 16,000,000, one million per worker. A single lost update would have failed it even with every process exiting cleanly. It is a targeted coherence check across the two clusters, not a memory validation.

The guest test answers a different question. A guest can boot and answer SSH while its vector state is wrong underneath. I copied a statically linked build of the test into one of the Debian guests and ran it there: 32-byte registers, 462 signals, pass. Together with every QEMU thread's affinity reading 0-7, that ties the guest's behavior to the cores actually running it.

The load harness, sampler and canary are running again. The harness pulled its container tags on restart and Redis changed versions, so I recorded the image digests. This run shows the kernel is compatible with the workload. It is not a before-and-after performance comparison.

What using the extra cores looks like

An ordinary shell still gets eight CPUs from nproc, because nproc respects the calling process's affinity. The machine-wide online set is 0-15. a100-run opts a fresh process into the A100 group before executing it:

cat /sys/devices/system/cpu/online
# 0-15

a100-run /usr/bin/nproc
# 8, available to this A100 process

The interface is narrow on purpose. A process opts itself in before it has used vectors or started a thread, and nothing moves a running program between register widths. Ordinary tasks and every virtual machine stay on X100. The A100 cores take work that is launched onto them explicitly; they pick up none of the VM load on their own.

That changes monitoring too. With sixteen CPUs online, eight saturated X100 cores read as about 50 percent whole-machine utilization, which makes a full board look half idle. A companion sampler now records X100 and A100 utilization separately, along with online counts, kernel release and boot ID. During the concurrent test both groups hit 100 percent. Intervals where a core was missing or mid-hotplug stay blank instead of becoming a made-up zero.

Timestamps needed the same care. The host clock is stale in early boot until NTP synchronizes, so I kept boot IDs and time since boot next to the serial recorder's timestamps. The harness's phase labels needed context as well: it entered its idle valley phase while my separate vector test was still saturating the board, and the label alone would misdescribe what the machine was doing. The test's start and finish times are saved, so the utilization trace can be explained later without reconstructing it from memory.

Measured X100 and A100 utilization from 01:04:25 to 01:13:06 UTC on October 27, 2026, with online CPU counts above. A100 rises from one to eight online cores. During the shaded 01:07:57–01:11:56 register-state test, both groups reach 100 percent busy; X100 utilization drops earlier, while A100 remains saturated longer. All twelve guests answer SSH at 01:10:41. The missing A100 hotplug interval remains a gap. This is a compatibility and register-state check, not a throughput benchmark.

What I have not tested

Ubuntu is still the default boot, and both older kernels are intact. The sixteen-core state came from a nine-core boot plus seven hotplugged cores. I have not tested a direct sixteen-core cold boot, taking CPUs offline, suspend, or a multi-day run. I have not rebooted this kernel at all, and the last custom kernel I built panicked doing exactly that.

The next evidence has to come from a direct sixteen-core boot and a long run with real A100 work alongside the twelve guests. The reset panic needs its own reproduction, from a clean boot with the same console capture. Keeping those apart is how I will tell whether a failure follows sustained load, CPU startup or the final reset. Source revisions, configurations, artifact hashes and raw results are saved so the next change can be compared against this one.

Where this leaves renting RISC-V virtual machines

The plan from the October piece is two K3 boards in a 1U at ServerPronto in Miami: virtual machines for kernel porters, bare metal for firmware developers, and a Raspberry Pi as the management controller, doing the same console capture and power switching this experiment leaned on. That piece put the two-node pilot at \$133.56 a month and listed five gates to re-measure before buying more hardware. One of them was "mainline enumerates all sixteen cores," and I called it the one to watch, because capacity would roughly double on a software update.

That was wrong, and not because the software failed. It worked. The A100 cores have no hypervisor extension, so no kernel will ever schedule a guest's virtual CPU on them. When mainline does land this support, the VM product still gets nothing: the same eight X100 cores, the same aggregate ceiling of about 5,446 sysbench events per second, and the same point at which each tenant's share starts shrinking. Every line of the October arithmetic for the VM tier stands as written. The gate stays on the list, but it is a bare-metal gate now, not a capacity gate.

What the A100 cores do offer is a second, smaller product, and it belongs to bare metal. A tenant who rents a whole board runs their own kernel anyway, so this port becomes something they can boot rather than something I have to support underneath strangers. What they get is unusual. By the first review's compile numbers, the A100 group adds about 47 percent more general-purpose throughput for work that can be split into separate processes. More interesting is that they get two vector widths, 256 and 1,024 bits, in one coherence domain, and the same test binary ran unchanged on both. QEMU will happily emulate a 1,024-bit vector unit and tell you whether your code is correct at that width. It cannot tell you how fast it is. In September the only RISC-V bare metal anyone rented by the hour carried a vector draft that was never ratified. The fidelity argument was always the case for this business, and the A100 cores strengthen it for exactly the customer I already thought was the defensible one.

I would not run this kernel under paying VM tenants. The port touches signal frames, ptrace register access and KVM's CPU hooks, which are the paths where a mistake becomes a guest-isolation bug rather than a crash. It adds a /proc file that any unprivileged process can write. It is not upstream, and nobody reviews it but me. A hypervisor host for strangers should run a kernel that somebody else also maintains, and Ubuntu's does that for free.

The tempting variation is to move the container tier from October's harness onto A100, where it would stop competing with the guests for X100 time. That puts paying tenants directly on the host kernel (containers are already the weaker isolation boundary) running the least reviewed code on the board. Not with my name on the invoice.

Then there is labor, which the October arithmetic left out entirely. A custom host kernel makes every security fix mine to carry: rebuild it on the board, which takes under an hour, rerun the test suite, then reboot the host and take all twelve guests down with it. The last custom kernel panicked on exactly that reboot, and this one has never been rebooted. In a rack the panic is survivable, because recovering a wedged board is precisely what the Pi and its power switch are for. But it turns every security update into a support event, and a one-person business sells its own hours before it sells anything else.

So one part of the plan changes, and two do not.

The VM tier runs Ubuntu's kernel and sells eight cores, as before. The October break-even stands.

The bare-metal tier gets a new line on its spec sheet: sixteen cores and two vector widths, with a documented kernel to boot. The firmware and toolchain people I expected to rent whole boards are the people most likely to want 1,024-bit vectors anyway.

And the things that actually decide whether this business exists have not moved. Two silent hangs in October, none in the thirteen days since on 7.3.0-9, and no idea yet why either one happened. I will not rent out a board that stops for reasons I cannot name, and when I wrote this the A100 kernel had been up for 49 minutes, which tells me nothing about that. Nobody has paid me anything yet, either. Sixteen cores changed neither fact.

Caveats. Everything here comes from one board over one evening. The sixteen-core state was reached through a nine-core boot plus hotplug, not a cold sixteen-core boot, and the concurrent test lasted four minutes. The vector-state loop counts measure whether register state survives preemption and signals, not vector performance, and the shared-counter test is a targeted coherence check, not a memory validation. The reset panic happened on the first custom kernel, which carried an out-of-tree taint from the HSM probe, and its cause is unknown. The October hangs are unexplained, and nothing here bears on them. The 47 percent A100 throughput figure comes from the first review's compile on the vendor kernel, two runs per cluster, not from this kernel. The kernel is a local experimental port of SpacemiT's vendor code to 7.3, not an upstream submission; checkpatch flags inherited style problems and the writable proc interface. The \$133.56 pilot figure and the 5,446 events per second ceiling are from the October piece and were not re-measured here.