Floating Point: The xmm Registers

Everything you have read so far has travelled through the general-purpose registers: integers in eax and edi, pointers in rax and rsi. But float and double never touch those registers at all. Floating-point values live in a completely separate register file: xmm0 through xmm15, sixteen 128-bit registers that arrived with the SSE (Streaming SIMD Extensions) instruction set. On x86-64, SSE is not an optional extra; it is the standard way compilers handle floating-point arithmetic, and the System V convention builds it directly into how functions communicate.

A function that works with double therefore looks different at first glance: a new register family, a new set of instruction names. The good news is that the patterns are the same ones you already know; only the spelling changes.

A New Instruction Family: ss and sd

The xmm registers are 128 bits wide, but for ordinary C++ arithmetic the compiler uses only the low part of each one: the low 32 bits for a float, the low 64 bits for a double. Instructions that work this way are called scalar instructions, and you can spot them by a two-letter suffix:

  • ss means scalar single: one float (single precision).
  • sd means scalar double: one double (double precision).

Attach those suffixes to familiar operation names and you have most of the floating-point vocabulary:

Instruction Meaning
movss / movsd move a float / double (register to register, or load/store)
addss / addsd add
subss / subsd subtract
mulss / mulsd multiply
divss / divsd divide

So addsd xmm0, xmm1 reads as "add the double in xmm1 into xmm0", the exact shape of add eax, edx, just in the other register file. Once the suffix decodes itself, a wall of mulsd and movsd reads as easily as the integer code from earlier chapters. (Even division behaves: divsd is a plain two-operand instruction, with none of the awkwardness of integer div.)

Arguments in xmm0-xmm7, Results in xmm0

The System V convention gives floating point its own copy of the rules you already know. The first eight float or double arguments are passed in xmm0 through xmm7, in order. A floating-point return value comes back in xmm0, playing exactly the role rax plays for integers.

Here is the pattern end to end:

double scale(double value, double factor)
{
    return value * factor;
}

At -O0:

scale(double, double):
    push  rbp
    mov   rbp, rsp
    movsd QWORD PTR -8[rbp], xmm0
    movsd QWORD PTR -16[rbp], xmm1
    movsd xmm0, QWORD PTR -8[rbp]
    mulsd xmm0, QWORD PTR -16[rbp]
    pop   rbp
    ret

Every move here is familiar. The first argument, value, arrives in xmm0; the second, factor, in xmm1. Unoptimized code spills both into stack slots (movsd doing the store, QWORD PTR because a double is 8 bytes), reloads value, and multiplies by factor. Because the product lands in xmm0, the return value is already in the right place when ret runs. It is the spill-reload-compute rhythm from the integer lessons, transposed into the xmm register file.

At -O2 the function collapses just as getAnswer did:

scale(double, double):
    mulsd xmm0, xmm1
    ret

One instruction: multiply the two argument registers, leave the result where returns live.

Mixed Signatures: Two Independent Sequences

What about a function that takes both integers and doubles? The two register sequences are counted independently: integer and pointer arguments consume rdi, rsi, rdx, ... in order, floating-point arguments consume xmm0, xmm1, ... in order, and neither sequence skips a register because of the other. So for:

void f(int a, double b, int c);

the arguments land in edi (first integer), xmm0 (first floating-point), and esi (second integer). Note that c is not in edx: it is the third parameter but only the second integer, and the integer count is what matters. When the argument registers at a call site seem "out of order" relative to the source, check the types; a double in the middle of the list quietly steps aside into the xmm sequence.

Crossing Between the Worlds: Conversions

C++ freely mixes integers and floating point, so the compiler needs instructions that convert between the two register files. The two you will meet constantly are:

  • cvtsi2sd: convert signed integer to scalar double. This appears wherever an int becomes a double, whether from an explicit static_cast<double> or an implicit conversion like n / 2.0.
  • cvttsd2si: convert scalar double to signed integer, truncating. The extra t matters: C++ defines double-to-int conversion as truncation toward zero, and this instruction does exactly that.

So double half{n / 2.0}; compiles to a cvtsi2sd moving n from a general-purpose register into an xmm register, followed by a divsd. Spotting a cvt instruction is spotting a type conversion in the source.

Comparing Doubles: comisd

Floating-point comparison gets its own instruction, but it plugs into machinery you already know. comisd (and its sibling ucomisd, which differs only in how it reports invalid values like NaN) compares two doubles and sets the same flags that cmp sets, so the same conditional jumps follow it:

void report(double reading)
{
    if (reading > 100.0)
    {
        std::cout << "high\n";
    }
}

The heart of the -O0 output:

    movsd  xmm0, QWORD PTR -8[rbp]
    comisd xmm0, QWORD PTR .LC0[rip]
    jbe    .L3
    ; ... print "high" ...
.L3:

Two details are worth noticing. First, the constant 100.0 is not written into the instruction; floating-point constants cannot be immediates, so the compiler places them in read-only data and loads them through a rip-relative label like .LC0. Second, the jump is jbe, one of the unsigned-style jumps: floating-point comparisons pair comisd with ja, jb, jae, jbe rather than the signed jg/jl family. The shape, though, is the inverted-condition pattern you know by heart: compare, then jump over the body when the condition fails.

A Promise of Things to Come

Why are these registers 128 bits wide when a double only needs 64? That is the "SIMD" in SSE: each xmm register can hold two doubles or four floats at once, and a parallel family of packed instructions (addpd, mulps, ...) operates on every value simultaneously. Scalar code uses one lane of a wide machine. In the vectorisation lesson at the end of the course, these same registers reappear with all their lanes full, doing four additions per instruction.

Key Takeaways

  • float and double travel in a separate register file: the 128-bit SSE registers xmm0-xmm15, never in the general-purpose registers.
  • Scalar floating-point instructions carry a suffix: ss = scalar single (float), sd = scalar double (double); hence movsd, addsd, subsd, mulsd, divsd.
  • Floating-point arguments go in xmm0-xmm7; a floating-point return value comes back in xmm0 (the rax of the floating-point world).
  • Integer and floating-point arguments use their two register sequences independently: in f(int a, double b, int c), a is in edi, b in xmm0, and c in esi.
  • cvtsi2sd converts int to double; cvttsd2si converts double to int by truncation; a cvt instruction marks a type conversion in the source.
  • comisd/ucomisd compare doubles and set the ordinary flags, feeding the unsigned-style jumps (ja, jbe, ...); floating-point constants are loaded from rip-relative labels, not immediates.
  • Scalar code uses one lane of the xmm registers; packed SIMD instructions that use all lanes return in the vectorisation lesson.