Chrinovic Mukanya

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