Partial Register Overhead on x86-64 CPUs
So the other day i was in the midst of my weekly reading of the Dr Agner Fog's CPU performance manuals. This time the instruction latency tables, and I came across this.
MOV r16, m16 latency= 4
MOV r32, m32 latency= 3
So, 4 cycles on 16 bit register write and 3 cycles on 32-bit register writes?? I found this interesting, so i decided to investigate.
Well this begins with the very nature of the instruction operand widths. On x86-64, Writing to a 32-bit (EAX) register automatically zero-extends the upper 32-bits of the full 64-bit register (RAX) on modern CPUs. The execution unit does not care about the previous contents of RAX, meaning it breaks the dependency chain completely and write the value straight to the internal physical register tag. And yes, to those who don't know, the 16 general purpose registers (RAX to R15) and 16 Vector/SIMD registers on x86-64 are just architectural registers exposed to the poor programmer, the microarchitectural implementation actually has hundreds more, and their purpose is to remove false dependencies (WAR/WAW) via register renaming. And here's a shameless rant, i really wish the ISA gave us more than 16 registers like RISC-V, we could do some interesting things with them in compilers. Anyway...
Writing to a 16-bit register preserves the upper 16 bits of EAX or upper 48 bits of RAX. Becuase AX, which is the lower 16 bit portion of RAX, only mutates bits 15-0 while keeping bits 31-16 intact. If RAX contained the value 0x1122334455667788 occupying full 64 bits, writing a partial 16-bit value such as 0xBEEF will result in the full RAX register containing the value 0x112233445566BEEF.
Here's a simple try-at-home program
```
; Build with:
; nasm -f elf64 partial_reg.asm -o partial_reg.o
; gcc -no-pie partial_reg.o -o partial_reg
; Run with:
; ./partial_reg
global main
extern printf
section .data
; Print format strings
fmt_before: db "Before (RAX) : 0x%016ZX", 10, 0
fmt_mem: db "Mem Payload : 0x%04X", 10, 0
fmt_after: db "After (RAX) : 0x%016ZX", 10, 0
; 16-bit memory payload to load
new_val: dw 0xBEEF
section .text
main:
push rbp
mov rbp, rsp
; STEP 1: Fill RAX with a recognizable 64-bit pattern
mov rax, 0x1122334455667788
; Print full initial RAX state
push rax ; Save RAX across printf call
mov rdi, fmt_before
mov rsi, rax
xor eax, eax ; RAX=0 indicates 0 vector registers for printf
call printf
pop rax ; Restore RAX = 0x1122334455667788
; Print the 16-bit memory payload we are about to load
push rax
mov rdi, fmt_mem
movzx rsi, word [rel new_val]
xor eax, eax
call printf
pop rax
; STEP 2: Execute MOV r16, m16
; This modifies ONLY bits [15:0] of RAX (AX), preserving [63:16]
mov ax, word [rel new_val]
; STEP 3: Print modified RAX state
mov rdi, fmt_after
mov rsi, rax
xor eax, eax
call printf
; Cleanup and exit
mov eax, 0
leave
ret
Build and Run!
nasm -f elf64 partial_reg.asm -o partial_reg.o
gcc -no-pie partial_reg.o -o partial_reg
./partial_reg
The register renaming unit must construct a dependency on the previous state of the destination register. Here's a microarchitectural pipleline flow and example
The register alias table (RAT), which acts as a real-time directory for mapping architectural register names to physical silicon registers allocates physical register tags to architectural registers. Suppose before the 16-bit write instruction, RAX points to a physical register P42, which holds the value 0x1122334455667788. When the RAT sees MOV AX, [RSI], it sees that the target is AX, a sub-field of RAX, then it allocates a new physical register tag, e.g P43 because partial writes to physical registers aren't allowed, each destination register of the partial write gets it's own physical register tag. But note, the hardware still needs a full single physical register that contains both the old upper bits value and new 16-bit value, meaning it needs to perform a merge of both into a new physical register, so it creates a dependency where we can't begin the merge if the previous value with the upper bits is not ready. This affects instruction scheduling because the merge micro-op now has a real data dependency on the physical register, meaning the out-of-order engine can not schedule the partial write instruction to execute before the previous value is ready, these steps must occur in the correct sequential steps, just as if you written a normal dependent arithmetic instruction.
Is it over? are we cooked? c'est fini?
Well we have instructions that avoid this partial-register problem. I introduce you to the move-with-extend instructions, MOVZX and MOVSX / MOVSXD and i encourage you to go and see what they do for yourself :)
Thank you.
Chrinovic Mukanya