Floating Point: The xmm Registers
Where float and double live: the xmm registers, the scalar SSE instructions like movss and addsd, and how floating-point arguments and return values are passed.
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:
ssmeans scalar single: onefloat(single precision).sdmeans scalar double: onedouble(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 anintbecomes adouble, whether from an explicitstatic_cast<double>or an implicit conversion liken / 2.0.cvttsd2si: convert scalar double to signed integer, truncating. The extratmatters: 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
floatanddoubletravel in a separate register file: the 128-bit SSE registersxmm0-xmm15, never in the general-purpose registers.- Scalar floating-point instructions carry a suffix:
ss= scalar single (float),sd= scalar double (double); hencemovsd,addsd,subsd,mulsd,divsd. - Floating-point arguments go in
xmm0-xmm7; a floating-point return value comes back inxmm0(theraxof the floating-point world). - Integer and floating-point arguments use their two register sequences independently: in
f(int a, double b, int c),ais inedi,binxmm0, andcinesi. cvtsi2sdconvertsinttodouble;cvttsd2siconvertsdoubletointby truncation; acvtinstruction marks a type conversion in the source.comisd/ucomisdcompare 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
xmmregisters; packed SIMD instructions that use all lanes return in the vectorisation lesson.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Floating Point: The xmm Registers - Quiz
Test your understanding of the lesson.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!