Skip to content

Preserve dormant SME ZA across the AArch64 stack switch. - #25

Draft
kristjanvalur wants to merge 7 commits into
stackless-dev:masterfrom
kristjanvalur:sme-za
Draft

kristjanvalur wants to merge 7 commits into
stackless-dev:masterfrom
kristjanvalur:sme-za

Conversation

@kristjanvalur

@kristjanvalur kristjanvalur commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Status: open, dormant. Off the main line for the foreseeable future. See "Backwards compatibility" below.

aarch64 and darwin_arm64 keep a dormant or off SME ZA state across stackman_switch. win_arm64 does not. stackman_call does not touch ZA.

On entry, ZA is off or dormant. stackman_sme_suspend runs before the save callback. It asks __arm_sme_state for the TPIDR2 block pointer and does not read TPIDR2_EL0 itself. A null pointer stays null. A dormant pointer is committed with __arm_za_disable, and the pointer is stored in the stack image next to FPCR and FPSR. After the new stack is installed, stackman_sme_resume turns ZA back on, copies the rows with __arm_tpidr2_restore, and points TPIDR2_EL0 at the same block. The helpers are weak. A toolchain without them stores a null pointer and skips both calls.

The published stack pointer is still the FPCR word. The slot is 32 bytes: FPCR, FPSR, the block pointer, and padding. The unwind CFA is sp+192 until that slot is popped.

When this thread is not using ZA, the added work is the suspend call, a null pointer stored in the slot, and a load-and-branch that skips the resume. The suspend call returns immediately if the helpers are missing. If they are present, it calls __arm_sme_state, which reads a startup flag and returns zeros when SME is unavailable. The 16 extra bytes sit below the frame the callback already receives.

ZT0 is caller-saved and is not part of this state. A callback that copies a stack to a new base has to relocate the saved block pointer and za_save_buffer.

The checked-in assembly reloads x2 from sp after the resume call. That call can destroy x2, and the restore callback takes the installed stack pointer in that register. Clang assembled the Linux and Darwin objects. Neither was executed. There is no SME test here: the stock qemu-aarch64 model does not provide ZA.

Where the hardware actually is

FEAT_SME is optional. Arm published it in 2021 with Armv9.2-A. It is not part of baseline AArch64, and plenty of Armv9-A cores leave it out.

Machines that have it are mostly recent clients. Apple M4 was the first widely shipped SME core; M1, M2, and M3 do not have it. Later Apple phone and desktop chips do (A18 / iPhone 16 and M4 onward). A few 2025–2026 phone SoCs do too. The server cores this library is usually built for do not. Graviton2 through Graviton5, Grace, Cobalt 100, and AmpereOne do not advertise SME. A Graviton5 (Neoverse V3) /proc/cpuinfo feature list has SVE2 and does not have sme. The stock qemu-aarch64 CPU does not have ZA. GitHub's macos-latest arm64 runners are M1-class and do not have it either. Windows on ARM is a different runtime and is left out of this change.

So the extension matters for a program that was built with an SME-aware compiler and enters stackman_switch with dormant ZA, on one of those client CPUs. Current cloud AArch64, qemu, and the machines CI runs on are outside that set.

What CI showed

CI on this branch is red in two jobs, both on machines with no SME:

  • build-linux-gnu (aarch64): bin/test and bin/test_cc pass under qemu-aarch64. bin/test_static prints test_01 and dies with SIGSEGV. That binary is the GCC inline copy. The asm pushes a 32-byte slot with stp [sp, #-32]!, which GCC does not account for, and then reloads the callback pointer from [sp, #160]. After the push that address is the saved d12/d13 slot, and the indirect call faults. Helpers are null on that toolchain and ZA is off. The crash is the compiler's stack model, on the path that has to stay a no-op.
  • build-macos (macos-latest): bin/test prints test_01 and dies with SIGILL. That binary uses the checked-in .S, which is the default build. The .S itself has no SME opcode. The illegal instruction is in stackman_sme_suspend / stackman_sme_resume or in the helper they call. The runner has no SME, so mrs TPIDR2_EL0, mrs SVCR, or smstart on the way into the helper is fatal. macos-15-intel passes. Every other Linux ABI, the BSD jobs, and Windows x86/x64 pass. Windows arm64 is build-only.

Backwards compatibility

Backwards compatible means the aarch64 and darwin_arm64 archives this repo already ships. The same binary keeps running on M1, on Graviton, under qemu, and with a toolchain that has never heard of SME. ZA is preserved only when the CPU and the compiler runtime actually have it.

That version is possible, and it is complex. The source diff stays small. The constraints do not.

The untaken path has to be plain Armv8-A in both copies of the switch. The checked-in .S is the default build. The inline body is what tests/test_static.c compiles, and CI runs it as its own program. The old 16-byte FPCR/FPSR push worked because nothing called out before it, so GCC held the callback in a callee-saved register. Calling stackman_sme_suspend before the push made GCC spill that pointer and reload it at the old sp offset, which is the qemu segfault. The inline restore has the same shape as the x2 bug already fixed in the .S: bl stackman_sme_resume kills caller-saved registers, and the compiler still thinks its copy of the new stack pointer survived. Both copies have to be kept honest by hand. A later call, a memory clobber, or a wider slot brings the spill back.

The instructions that implement the feature are illegal on the machines the binary must still run on. mrs TPIDR2_EL0, mrs SVCR, and smstart za are UNDEFINED without FEAT_SME. They have to sit behind a branch, including the prologue of whatever function contains them. A C helper is a bad place for that. Apple Clang and GCC 14 know the SME procedure call, and a call to __arm_sme_state or __arm_za_disable can make them emit mrs TPIDR2_EL0 before the null check. target("arch=armv8-a") is on the forward declaration of these helpers. Clang takes that attribute from the definition. Ubuntu Clang 18, aimed at arm64-apple-macos11, emitted no such prologue; the macOS 26 runner's Clang is newer, and bin/test died with SIGILL on the first switch. A compatible helper is checked-in assembly, .arch armv8-a, with the SME encodings only on the taken branch.

Finding out whether SME exists has the same trap. EL0 code must not read ID_AA64PFR1_EL1, and it must not read TPIDR2_EL0 to see whether the read faults. __arm_sme_state is the supported probe: a correct copy loads a startup flag and only then touches an SME register. The symbol is weak, and a missing symbol means "no SME", which is the compatible answer. A present symbol is .variant_pcs. A compiler or linker thunk for that convention reads TPIDR2_EL0 before the helper reaches its own flag check, and that thunk is illegal on M1. Qemu leaves the weak symbol null, so it never takes the call and cannot catch the thunk. Showing that a particular call sequence stays clean takes a run on M1 and a run on M4. This CI has the M1, which crashed, and it has no M4.

The half that needs the feature has no test. Stock qemu has no ZA, and the GitHub arm64 runners have no SME. A restore that uses the wrong register, a missed smstart, or a block pointer written onto the wrong stack all come out of make test green.

A compatible change is therefore a second assembler implementation of the helpers, a matching fix to the inline stack picture, a call sequence that cannot grow a ZA prologue or a variant-PCS thunk, and a dormant path that CI will never execute. That remains a constraint on every later edit of the AArch64 switch, while the compilers grow more SME-aware. It is too much to put on master for the hardware that exists today.

This stays off the main line for the foreseeable future. The PR stays open and dormant. If a program on those CPUs needs ZA preserved across a switch, that belongs in a separate library those users build and link, with SME as a requirement of that library. It does not become another ABI in this tree, and it does not go into the default libstackman.

Bump STACKMAN_VERSION and the README banner, and record the changes
since 1.2.5 in the changelog. The release tag is not created here.
The x64 test.exe run exits 1 with no assertion text and no test
names. stdout is fully buffered on that pipe, and PowerShell turns
any native failure into exit code 1. The test now prints each name
unbuffered and logs the exception code. The Windows step runs under
cmd and prints that process exit code.
The stack enlargement itself is on master. This records it with the
other changes since 1.2.5.
The note covers where FEAT_SME shows up, the off, active, and dormant
modes, and the helper sequence for aarch64 and darwin_arm64.
stackman_sme_suspend commits a dormant ZA before the save callback
and parks the TPIDR2 block pointer next to FPCR. stackman_sme_resume
puts that dormant state back after the new stack is installed. Off
stays off. The helpers are weak, and Windows ARM64 is unchanged.
stackman_sme_resume is a normal call, so it can destroy x2. The
restore callback takes the installed stack pointer in that register.
The null path skipped the call and kept the pointer. The dormant
path did not.
@kristjanvalur
kristjanvalur marked this pull request as draft October 15, 2026 22:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant