SSE2 scalar and packed arithmetic

SSE2 scalar and packed arithmetic

x86-64 guarantees the SSE2 instruction set. In 64-bit mode it provides sixteen registers named xmm0 through xmm15, each 128 bits wide. These registers hold bits; an instruction decides whether those bits represent one floating-point value or several values side by side.

This lesson draws every XMM register from its low bits to its high bits, left to right:

             low bits                                      high bits
xmm0  [ lane 0: bits 0..63       | lane 1: bits 64..127        ]

Each 64-bit region above is a lane when an instruction treats the register as two binary64 values. Lane 0 is the low lane and lane 1 is the high lane.

Reading the instruction suffixes

In the instruction families used here, the final letters say how many values the instruction uses and which format it gives those bits:

suffixmeaninglanes used in one XMM register
ssscalar binary32one 32-bit value in the low bits
sdscalar binary64one 64-bit value in the low lane
pspacked binary32four 32-bit lanes
pdpacked binary64two 64-bit lanes

Scalar means one value. Packed means several independent lanes. For example, addsd adds the low binary64 values, while addpd adds both pairs of binary64 lanes.

The examples begin with binary64, so their arithmetic mnemonics end in sd or pd.

Scalar arithmetic and the high half

The two-operand scalar SSE2 instructions used here read the low lane of each operand, write their answer to the destination's low lane, and preserve the destination's high 64 bits. This first calculation starts with memory loads whose high halves become zero:

default rel
global _start

section .data
a:      dq 1.5
b:      dq 2.25
two:    dq 2.0

section .text
_start:
    movsd xmm0, [a]         ; [low 1.5  | high 0]
    movsd xmm1, [b]         ; [low 2.25 | high 0]
    addsd xmm0, xmm1        ; [low 3.75 | high 0]
    mulsd xmm0, [two]       ; [low 7.5  | high 0]

    mov rax, 60
    xor rdi, rdi
    syscall

Here is the complete addsd change:

before: xmm0 = [ low 1.5  | high 0 ]
        xmm1 = [ low 2.25 | high 0 ]
after:  xmm0 = [ low 3.75 | high 0 ]

Only the low lanes take part in the addition. The high lane shown in the result is the old high lane of xmm0.

The source form of movsd determines what happens to that high half:

  • movsd xmm0, [value] loads eight bytes into the low lane and clears the high 64 bits.
  • movsd xmm0, xmm1 copies the low lane and preserves the high 64 bits already in xmm0.

This example gives the preserved half a visible value:

default rel
global _start

section .data
destination:    dq 1.0, 99.0
source:         dq 4.0

section .text
_start:
    movupd xmm0, [destination]  ; [low 1.0 | high 99.0]
    movsd xmm1, [source]        ; [low 4.0 | high 0]
    movsd xmm0, xmm1            ; [low 4.0 | high 99.0]

    mov rax, 60
    xor rdi, rdi
    syscall

The register-to-register move changes xmm0 like this:

before: xmm0 = [ low 1.0 | high 99.0 ]
        xmm1 = [ low 4.0 | high 0    ]
after:  xmm0 = [ low 4.0 | high 99.0 ]

subsd, mulsd, divsd, and sqrtsd follow the same destination rule: they replace the low binary64 lane and preserve the destination's high 64 bits.

Packed loads and arithmetic

Packed binary64 instructions use both 64-bit lanes. With dq 1.0, 2.0, the first qword is at the lower address and becomes low lane 0. The next qword becomes high lane 1:

increasing address  ---->
memory left:  [ qword 1.0 | qword 2.0 ]
xmm0:         [ low 1.0   | high 2.0  ]

movupd loads or stores all 128 bits and has no alignment requirement. addpd then adds corresponding lanes independently:

default rel
global _start

section .data
left:   dq 1.0, 2.0
right:  dq 10.0, 20.0
out:    dq 0.0, 0.0

section .text
_start:
    movupd xmm0, [left]     ; [low 1.0  | high 2.0]
    movupd xmm1, [right]    ; [low 10.0 | high 20.0]
    addpd xmm0, xmm1        ; [low 11.0 | high 22.0]
    movupd [out], xmm0

    mov rax, 60
    xor rdi, rdi
    syscall
lanexmm0 beforexmm1xmm0 after
low lane 01.010.011.0
high lane 12.020.022.0

mulpd has the same lane-by-lane shape, with multiplication in place of addition. Neither packed operation carries a value from one lane into the other.

Converting signed integers and binary64 values

Conversion instructions change the representation of a value:

instructionoperation
cvtsi2sd xmm0, raxconvert the signed qword in rax to binary64 in the low lane
cvttsd2si rax, xmm0convert the low binary64 value to a signed qword, truncating toward zero
cvtsd2si rax, xmm0convert the low binary64 value to a signed qword using MXCSR rounding control
movq rax, xmm0copy only the low 64 raw bits, with no numerical conversion

The usual MXCSR rounding mode is nearest, with halfway cases going to the result whose low bit is even. In that mode, cvtsd2si rounds 2.7 to 3. cvttsd2si always truncates toward zero, so it converts 2.7 to 2 regardless of that rounding setting.

Like scalar arithmetic, cvtsi2sd xmm, r64 replaces the low lane and preserves the destination's high 64 bits:

default rel
global _start

section .data
seed:   dq 1.0, 99.0
value:  dq 2.7

section .text
_start:
    movupd xmm0, [seed]     ; [low 1.0 | high 99.0]
    mov rcx, 7
    cvtsi2sd xmm0, rcx      ; [low 7.0 | high 99.0]

    movsd xmm1, [value]     ; [low nearest binary64 to 2.7 | high 0]
    cvttsd2si r8, xmm1      ; 2: always truncate toward zero
    cvtsd2si r9, xmm1       ; 3 in the usual nearest-even mode
    movq r10, xmm1          ; low 64 raw bits: 0x400599999999999A

    mov rax, 60
    xor rdi, rdi
    syscall

The conversion of 7 changes xmm0 as follows:

before: xmm0 = [ low 1.0 | high 99.0 ]
after:  xmm0 = [ low 7.0 | high 99.0 ]

Decimal 2.7 has an infinite repeating expansion in base two. The assembler stores the nearest binary64 encoding, 0x400599999999999A, under its normal rounding rule. Its exponent makes the normalized value approximately 1.35 x 2^1; the stored fraction bits describe the digits after the leading 1 in that significand. movq lets r10 expose those low-lane bits without treating them as an integer value to convert.

MXCSR in brief

mxcsr holds control and status for SSE floating-point work, including its rounding-control field, exception masks, and recorded exception conditions. The playground begins in the usual default state: nearest-even rounding with the floating-point exceptions masked.

A masked exception records its condition and lets the instruction produce its defined floating-point result. For example, a nonzero finite value divided by zero produces a signed infinity when divide-by-zero is masked. 0.0 / 0.0 is an invalid operation and produces a NaN when invalid operation is masked. The result for a masked exceptional operation depends on the operation and condition; masking does not turn every invalid calculation into the same result.

Comparing low lanes

ucomisd xmm0, xmm1 compares only the low binary64 lanes. It ignores both high lanes and records one of four outcomes in ZF, PF, and CF:

relation of low xmm0 to low xmm1ZFPFCF
greater000
less001
equal100
unordered111

Unordered means at least one operand is a NaN. Test it first with jp. Its row also satisfies the flag conditions used by jb and je, so either of those jumps would misclassify a NaN if it ran first.

default rel
global _start

section .data
left:   dq 0x7FF8000000000000    ; a quiet NaN
right:  dq 2.0

section .text
_start:
    movsd xmm0, [left]       ; [low NaN | high 0]
    movsd xmm1, [right]      ; [low 2.0 | high 0]
    ucomisd xmm0, xmm1       ; reads the low lanes only

    jp unordered_path        ; PF first
    jb less_path
    je equal_path
    mov r12, 1               ; greater
    jmp compare_done

less_path:
    mov r12, -1
    jmp compare_done

equal_path:
    mov r12, 0
    jmp compare_done

unordered_path:
    mov r12, 2

compare_done:
    mov rax, 60
    xor rdi, rdi
    syscall

This input leaves 2 in r12.

Packed comparisons produce masks

A packed comparison writes a result into every lane. cmppd takes an immediate predicate; the value 1 selects ordered signaling less-than: “left lane is less than right lane.” If that relation is true, the result lane is all one bits. If it is false, the result lane is all zero bits. A quiet NaN makes the lane false and all-zero, and it raises the invalid-operation condition. With the masked default taught above, MXCSR records that condition and execution continues.

default rel
global _start

section .data
left:   dq 1.0, 5.0
right:  dq 2.0, 4.0
mask:   dq 0, 0

section .text
_start:
    movupd xmm0, [left]      ; [low 1.0 | high 5.0]
    movupd xmm1, [right]     ; [low 2.0 | high 4.0]
    cmppd xmm0, xmm1, 1      ; each lane asks: left < right?
    movupd [mask], xmm0

    mov rax, 60
    xor rdi, rdi
    syscall
lanecomparisonresult bits
low lane 01.0 < 2.0, true0xFFFFFFFFFFFFFFFF
high lane 15.0 < 4.0, false0x0000000000000000

Those all-one and all-zero lanes form a mask. They are bit patterns for selecting later data, so the floating-point display of the result is not useful here.

Floating-point arguments and results

For ordinary non-variadic System V x86-64 calls with simple scalar floating-point arguments, the first floating arguments use xmm0 through xmm7; integer arguments are counted separately in the general-purpose argument registers. A floating-point result is returned in xmm0.

Every XMM register is caller-saved. A called function may change any of them. A caller that needs an XMM value afterward must preserve it in memory, copy its bits elsewhere, or recompute it.

default rel
global _start

section .data
x:      dq 3.0
y:      dq 4.0
out:    dq 0.0

section .text
; sum_of_squares(x in xmm0, y in xmm1) -> x*x + y*y in xmm0
sum_of_squares:
    mulsd xmm0, xmm0        ; changes low lane; preserves xmm0 high half
    mulsd xmm1, xmm1        ; changes low lane; preserves xmm1 high half
    addsd xmm0, xmm1
    xorpd xmm1, xmm1        ; deliberately zero both lanes, all 128 bits
    ret

_start:
    movsd xmm0, [x]         ; [low 3.0 | high 0]
    movsd xmm1, [y]         ; [low 4.0 | high 0]
    call sum_of_squares     ; rsp is 16-byte aligned immediately before call
    movsd [out], xmm0       ; low result is 25.0
    mov r12, [out]          ; raw result bits: 0x4039000000000000

    mov rax, 60
    xor rdi, rdi
    syscall

The playground gives _start a 16-byte-aligned rsp, and this code does not move rsp before the call. call pushes the return address, so rsp is eight bytes away from a 16-byte boundary on entry to sum_of_squares, as required by the convention. The function makes no nested calls. xorpd xmm1, xmm1 clears every bit in xmm1, so both of its 64-bit lanes become zero.

Your turn: scalar sum and average

Use all four binary64 values at values. Calculate their sum in xmm1 and their average in xmm0. The exact sum is 18.0 and the exact average is 4.5; the stored divisor is 4.0, so loading the divisor alone cannot produce either requested answer.

The fixed code stores both results and copies the low 64 raw bits into r12 and r13. The checks assert both memory values and both safe register copies.

default rel
global _start

section .data
values:       dq 1.5, 2.25, 4.0, 10.25
count:        dq 4.0
sum_out:      dq 0.0
average_out:  dq 0.0

section .text
_start:
    ; your code here

    movsd [sum_out], xmm1
    movsd [average_out], xmm0
    movq r12, xmm1            ; copy only the low 64 raw bits
    movq r13, xmm0

    mov rax, 60
    xor rdi, rdi
    syscall
Show solution
default rel
global _start

section .data
values:       dq 1.5, 2.25, 4.0, 10.25
count:        dq 4.0
sum_out:      dq 0.0
average_out:  dq 0.0

section .text
_start:
    movsd xmm0, [values]
    addsd xmm0, [values + 8]
    addsd xmm0, [values + 16]
    addsd xmm0, [values + 24]   ; xmm0 low lane = 18.0
    movsd xmm1, xmm0            ; copy the low sum
    divsd xmm0, [count]         ; xmm0 low lane = 4.5

    movsd [sum_out], xmm1
    movsd [average_out], xmm0
    movq r12, xmm1
    movq r13, xmm0

    mov rax, 60
    xor rdi, rdi
    syscall

Now perform packed addition and multiplication. Load the two lanes at left and right, put their lane-wise sums in xmm0, and put their lane-wise products in xmm2. The fixed code stores all four answers and copies each low/high qword to a safe general-purpose register.

default rel
global _start

section .data
left:         dq 1.5, 2.0
right:        dq 2.5, 4.0
sum_out:      dq 0.0, 0.0
product_out:  dq 0.0, 0.0

section .text
_start:
    ; your code here

    movupd [sum_out], xmm0
    movupd [product_out], xmm2
    mov r12, [sum_out]
    mov r13, [sum_out + 8]
    mov r14, [product_out]
    mov r15, [product_out + 8]

    mov rax, 60
    xor rdi, rdi
    syscall
Show solution
default rel
global _start

section .data
left:         dq 1.5, 2.0
right:        dq 2.5, 4.0
sum_out:      dq 0.0, 0.0
product_out:  dq 0.0, 0.0

section .text
_start:
    movupd xmm0, [left]
    movupd xmm1, [right]
    movupd xmm2, xmm0
    addpd xmm0, xmm1            ; [low 4.0 | high 6.0]
    mulpd xmm2, xmm1            ; [low 3.75 | high 8.0]

    movupd [sum_out], xmm0
    movupd [product_out], xmm2
    mov r12, [sum_out]
    mov r13, [sum_out + 8]
    mov r14, [product_out]
    mov r15, [product_out + 8]

    mov rax, 60
    xor rdi, rdi
    syscall