You can read x86-64 compiler output (Levels 11–13). This skill puts the same C++ through GCC 16 for both machines and looks at where AArch64 does it differently — usually in fewer, more regular instructions. Use the compiler pane's Compare mode (left x86-64, right AArch64) on every snippet here.
The big structural differences:
| x86-64 | AArch64 | |
|---|---|---|
| instruction length | 1–15 bytes | always 4 bytes |
| operands | 2 (add rax, rdx: rax += rdx) |
3 (add x0, x1, x2) |
| memory operands | most ALU ops can read memory | only loads/stores touch memory |
| registers | 16 GPRs | 31 GPRs + zero register |
| flags | almost every ALU op sets them | only s-suffixed ops (adds, subs, cmp) |
| immediates | up to 32 bits (64 with movabs) |
small, encoded — big ones are built |
1. Multiply-add in one instruction
long fma1(long a, long b, long c) { return a * b + c; }
long fms (long a, long b, long c) { return c - a * b; }
long wide(int a, int b, long c) { return (long)a * b + c; }x86-64 AArch64
imul rdi, rsi madd x0, x0, x1, x2 // x0*x1 + x2
lea rax, [rdi+rdx] msub x0, x0, x1, x2 // x2 - x0*x1
smaddl x0, w0, w1, x2 // sign-extend, 32×32→64, addmadd/msub take four registers. smaddl/umaddl multiply two 32-bit
values into a 64-bit result and do the widening, where x86 needs two
movsxd first. A remainder x % y becomes udiv + msub
(q = x / y; r = x − q·y).
2. Conditional select instead of cmov
int max2(int a, int b) { return a > b ? a : b; }
int inc_if(int a, int b, int x) { return a < b ? x + 1 : x; }x86-64 AArch64
cmp edi, esi cmp w0, w1
mov eax, esi csel w0, w0, w1, ge
cmovge eax, edi // w0 = ge ? w0 : w1
cmp w0, w1
xor eax, eax cinc w0, w2, lt // w0 = lt ? w2+1 : w2
cmp edi, esi
setl al
add eax, edxThe csel family has a destination and two sources, so no mov
beforehand: csel (select), csinc (select or increment), csinv
(invert), csneg (negate), and aliases cset/csetm (0/1, 0/−1),
cinc, cneg. Very often a ?: that x86 spends three instructions on is
one csel here.
3. Shifted and extended operands
Most arithmetic instructions can shift or extend their last register operand for free:
a + (b << 3) add x0, x0, x1, lsl 3 (x86: lea rax, [rdi+rsi*8])
p[i] (8-byte elements) ldr x0, [x0, x1, lsl 3] (x86: mov rax, [rdi+rsi*8])
a + (long)int_index add x0, x0, w1, sxtw (x86: movsxd + add)Unlike lea, the shift can be any amount (0–63), and it works on and,
orr, eor, sub, cmp too: bic x0, x0, x1, lsr 4 clears the bits of
x1 >> 4.
4. Bitfields: ubfx, sbfx, bfi, ubfiz
unsigned field(unsigned x) { return (x >> 5) & 0x3f; }
unsigned ins(unsigned x, unsigned y) { return (x & ~0x7e0u) | ((y & 0x3f) << 5); }x86-64 AArch64
mov eax, edi ubfx x0, x0, 5, 6 // extract 6 bits from bit 5
shr eax, 5
and eax, 63
mov eax, esi bfi w0, w1, 5, 6 // insert w1's low 6 bits at bit 5
and edi, -2017
sal eax, 5
and eax, 2016
or eax, ediShift-and-mask patterns over a contiguous field become one instruction.
sbfx sign-extends the field; ubfiz places low bits at a position
((x & 7) << 2, used for t % 8 * 4).
5. Immediates: encodable or built
Every instruction is 32 bits, so a 64-bit constant can't be inline. Two encodings cover most constants:
- Arithmetic (
add,sub,cmp): a 12-bit value, optionally shifted left by 12.add x0, x0, 4095fits;add x0, x0, 4097doesn't. - Logical (
and,orr,eor,tst): a repeating bit pattern — a run of ones, rotated, repeated every 2, 4, 8, 16, 32 or 64 bits.0x00ff00ff00ff00ffand0x5555555555555555are encodable;0x123456789isn't.
x & 0x00ff00ff00ff00ff and x0, x0, 71777214294589695 // one instruction
x + 0x123456789 mov x1, 26505 // 0x6789
movk x1, 0x2345, lsl 16
movk x1, 0x1, lsl 32
add x0, x0, x1and encodes it directly. 0x123456789 has no such period: it takes mov + two movk.movz (shown as mov) writes 16 bits and zeroes the rest; each movk
keeps the register and replaces one 16-bit chunk. A full 64-bit constant
is up to four instructions (x86: one 10-byte movabs). Compilers put big
constants used many times in a literal pool and load them with ldr.
6. Division by constants, and udiv
x / 10 (unsigned) mov w1, 52429 // 0xcccd
movk w1, 0xcccc, lsl 16 // magic = 0xcccccccd
umull x0, w0, w1 // 32×32→64
lsr x0, x0, 35
x / y udiv w0, w0, w1udiv.The magic-number trick is the same as on x86 (Level 12). The difference
is runtime division: udiv/sdiv are ordinary instructions with no
fixed registers (x86's div needs edx:eax), never trap on overflow and
return 0 for division by zero. They're still slow (roughly 7–20
cycles), so compilers still avoid them for constants.
7. Bit counting
std::countl_zero(x) clz x0, x0
std::countr_zero(x) rbit x0, x0 // reverse the bits…
clz x0, x0 // …and count leading zeros
std::popcount(x) fmov d31, x0 // move to a SIMD register
cnt v31.8b, v31.8b // popcount of each byte
addv b31, v31.8b // add the 8 bytes
fmov w0, s31
std::byteswap(x) rev w0, w0fmov copies x0 into d31, cnt counts the bits of every byte at once, addv sums the bytes: 1 + 2 + 8 = 11. Byte 0 is on the right.Baseline ARMv8 has no scalar popcount or ctz, but NEON's cnt is always
there, so GCC never calls __popcountdi2 the way baseline x86-64 does.
(ARMv8.9's CSSC extension adds scalar cnt and ctz.)
8. Conditional compare chains
bool both(int a, int b) { return a == 3 && b > 7; }x86-64 AArch64
cmp edi, 3 cmp w0, 3
sete al ccmp w1, 7, 4, eq // if eq: flags = cmp w1, 7
cmp esi, 7 // else: flags = 0b0100 (Z set)
setg dl cset w0, gt
and eax, edxccmp does the second comparison only if the first condition holds;
otherwise it loads the flags from an immediate (nzcv), chosen so the final
test fails. A chain of &&/|| of simple comparisons becomes
cmp, ccmp, ccmp, …, cset/csel — no branches. Write conditions so
GCC can see this: plain comparisons combined with &&/|| in one
expression; nested ifs often become real branches.
9. Pairs, addresses, tail calls
struct {long a, b;} *p: p->a + p->b ldp x1, x0, [x0] // one load, two registers
copy two longs ldp x2, x3, [x1]
stp x2, x3, [x0]
read a global adrp x0, sym // 4 KiB page of sym
ldr x0, [x0, #:lo12:sym]
tail call b callee // x86: jmpldp/stp move two registers at once — also how every prologue saves the
frame pointer and link register. Globals take two instructions: adrp
gives the page address (±4 GiB around pc), and the low 12 bits go into the
load. x86 does it in one mov rax, [rip+sym], because its instructions are
long enough to hold a 32-bit offset.
#include <bit>
#include <cstdio>
unsigned field(unsigned x) { return (x >> 5) & 0x3f; }
int ctz(unsigned long x) { return std::countr_zero(x); }
bool both(int a, int b) { return a == 3 && b > 7; }
int main() {
std::printf("%u %d %d %d\n", field(0x7e0), ctz(0x80), both(3, 8), both(3, 7));
}