TL:DR: Yes, most C/C++ compilers other than MSVC expose an 80-bit IEEE754 Extended Precision format (x87, 68881) as
long double, and some implementations of other languages have it as REAL10 or whatever name.
But no, none of the major C compilers had an option to force promoting
double locals/temporaries to 80-bit even across spill/reload, only keeping them as 80-bit when it was convenient to keep them in registers anyway.
So code had to be written specifically to use it, in a way that often is incompatible with auto-vectorization with SSE/SSE2 because it forces 80-bit x87 even when SSE2 for SIMD
double is available. (And most existing code is written to use
Bruce Dawson's Intermediate Floating-Point Precision article is essential reading if you're wondering about whether extra precision for temporaries is helpful or harmful. He has examples that demonstrate both, and links to articles that conclude one way and the other.
Also very importantly, he has lots of specific details about what Visual Studio / MSVC actually does, and what gcc actually does, with x87 and with SSE/SSE2. Fun fact: MSVC before VS2012 used
float * float even when using SSE/SSE2 instructions! (Presumably to match the numerical behaviour of x87 with its precision set to 53-bit significand; which is what MSVC does without SSE/SSE2.)
His whole series of FP articles is excellent; index in this one.
that would cost very little performance to use (since the hardware implemented it whether you used it or not)
This is an overstatement. Working with 80-bit
long double in x87 registers has zero extra cost, but as memory operands they are definitely 2nd-class citizens in both ISA design and performance. Most x87 code involves a significant amount of loading and storing, something like Mandelbrot iterations being a rare exception at the upper end of computational intensity. Some round constants can be stored as
float without precision loss, but runtime variables usually can't make any assumptions.
Compilers that always promoted temporaries / local variables to 80-bit even when they needed to be spilled/reloaded would create slower code (as @Davislor's answer seems to suggest would have been an option for gcc to implement). See below about when compilers actually round C
double temporaries and locals to IEEE binary64: any time they store/reload.
- 80-bit REAL10 /
long double can't be a memory operand for
fdiv / etc. Those only support using 32 or 64-bit float/double memory operands.
So to work with an 80-bit value from memory, you need an extra
fld instruction. (Unless you want it in a register separately anyway, then the separate
fld isn't "extra"). On P5 Pentium, memory operands for instructions like
fadd have no extra cost, so if you already had to spill a value earlier, adding it from memory is efficient for
And you need an extra x87 stack register to load it into.
fadd st5, qword [mem] isn't available (only memory source with the top of the register stack
st0 as an implicit destination), so memory operands didn't help much to avoid
fxch, but if you were close to filling up all 8
st0..7 stack slots then having to load might require you to spill something else.
fst to store
st0 to memory without popping the x87 stack is only available for
m64 operands (IEEE binary32
float / IEEE binary64
fstp m32/m64/m80 to store-and-pop is used more often, but there are some use-cases where you want to store and keep using a value. Like in a computation where one result is also part of a later expression, or an array calc where
x[i] depends on
If you want to store 80-bit
fstp is your only option. You might need use
fld st0 to duplicate it, then
fstp to pop that copy off. (You can
fstp with a register operand instead of memory, as well as
fxch to swap a register to the top of the stack.)
80-bit FP load/store is significantly slower than 32-bit or 64-bit, and not (just) because of larger cache footprint. On original Pentium, it's close to what you might expect from 32/64-bit load/store being a single cache access, vs. 80-bit taking 2 accesses (presumably 64 + 16 bit), but on later CPUs it's even worse.
Some perf numbers from Agner Fog's instruction tables for some 32-bit-only CPUs that were relevant in the era before SSE2 and x86-64. I don't have 486 numbers; Agner Fog only covers Pentium and later, and http://instlatx64.atw.hu/ only has CPUID from a 486, not instruction latencies. And its PPro / PIII latency/throughput numbers don't cover fld/fstp. It does show
fdiv performance being slower for full 80-bit precision, though.
(Update: found https://www2.math.uni-wuppertal.de/~fpf/Uebungen/GdR-SS02/opcode_f.html has integer and x87 instructions from 8087 to Pentium (not counting instruction-fetch bottlenecks).
fld m32/m64 (load float/double into 80-bit x87 ST0): 3 cycles.
fld m80: 6 cycles (Also slower on 8087, 287, 387, but not by as large a ratio)
fst(p) m32/m64: 7 cycles / 8 cycles
fstp m80: 6 cycles!
(Faster than rounding to a narrower type, like on 8087 and 287, but not 387)
P5 Pentium (in-order pipelined dual issue superscalar):
fld m32/m64 (load float/double into 80-bit x87 ST0): 1 cycle, pairable with fxchg.
fld m80 : 3 cycles, not pairable, and (unlike
fmul which are pipelined), not overlapable with later FP or integer instructions.
fst(p) m32/m64 (round 80-bit ST0 to float/double and store): 2 cycles, not pairable or overlapable
fstp m80: (note only available in
pop version that frees the x87 register): 3 cycles, not pairable
P6 Pentium Pro / Pentium II / Pentium III. (out-of-order 3-wide superscalar, decodes to 1 or more RISC-like micro-ops that can be scheduled independently)
(Agner Fog doesn't have useful latency numbers for FP load/store on this uarch)
fld m32/m64 is 1 uop for the load port.
fld m80 : 4 uops total: 2 ALU p0, 2 load port
fst(p) m32/m64 2 uops (store-address + store-data, not micro-fused because that only existed on P-M and later)
fstp m80: 6 uops total: 2 ALU p0, 2x store-address, 2x store-data. I guess ALU extract into 64-bit and 16-bit chunks, as inputs for 2 stores.
Multi-uop instructions can only be decoded by the "complex" decoder on Intel CPUs (while simple instructions can decode in parallel, in patterns like 1-1-1 up to 4-1-1), so 4-uop
fld m80 can lead to the previous cycle only producing 1 uop in the worst case. 6 uops for
fstp m80 is more than 4, so decoding it requires the microcode sequencer. These decode bottlenecks could lead to bubbles in the front-end, as well as / instead of possible back-end bottlenecks. (P6-family CPUs, especially later ones with better back-end throughput, can bottleneck on instruction fetch/decode in the front-end if you aren't careful; see Agner Fog's microarch pdf. Keeping the issue/rename stage fed with 3 uops / clock can be hard, or 4 on Core2 and later.)
Agner doesn't have latencies or throughputs for FP loads/stores on original P6 (the "1 cycle" latency in a couple columns appears bogus). But it's probably similar to later CPUs, where
m80 has worse throughput than you'd expect from the uop counts / ports.
- Pentium-M: 1 per 3 cycle throughput for
fstp m80 6 uops. vs. 1 uop / 1-per-clock for
fst(p) m32/m64, with micro-fusion of the store-address and store-data uops into a single fused-domain uop that can decode in any slot on the simple decoders.
- Core 2 (Merom) / Nehalem:
fld m80: 1 per 3 cycles (4 uops)
fstp m80 1 per 5 cycles (7 uops: 3 ALU + 2x each store-address and store-data). Agner's latency numbers show 1 extra cycle for both load and store.
- Pentium 4 (pre-Prescott):
fld m80 3+4 uops, 1 per 6 cycles vs. 1-uop pipelined.
fstp m80: 3+8 uops, 1 per 8 cycles vs. 2+0 uops with 2 to 3c throughput. Prescott is similar
fld m80: 1 per 2 cycles (4 uops) vs. 1 per 0.5 cycles for m32/m64.
fstp m80: Still 7 uops, 1 per 5 cycles vs. 1 per clock for normal stores.
fld m80: 7 m-ops, 1 per 4-cycle throughput (vs. 1 per 0.5c for 1 m-op
fstp m80: 10 m-ops, 1 per 5-cycle throughput. (vs. 1 m-op fully pipelined
fst(p) m32/m64). The latency penalty on these is much higher than on Intel, e.g. 16 cycle m80 loads vs. 4-cycle m32/m64.
fld m80: 8 ops/14c lat/4c tput. (vs. 1 op/8c lat/1c tput for m32/m64). Interesting that even regular
double x87 loads have half throughput of SSE2 / AVX loads.
fstp m80: 13 ops/9c lat/20c tput. (vs. 1 op/8c lat/1c tput). Piledriver/Steamroller are similar, that catastrophic store throughput of one per 20 or 19 cycles is real.
(Bulldozer-family's high load/store latencies for regular m32/m64 operands is related to having a "cluster" of 2 weak integer cores sharing a single FPU/SIMD unit. Ryzen abandoned this in favour of SMT in the style of Intel's Hyperthreading.)
There's definitely a chicken/egg effect here; if compilers did make code that regularly used stored/reloaded 80-bit temporaries in memory, CPU designers would spend some more transistors to make it more efficient at least on later CPUs. Maybe doing a single 16-byte unaligned cache access when possible, and grabbing the required 10 bytes from that.
fld m32/m64 can raise / flag an FP exception (
#IA) if the source operand is SNaN, but Intel's manual says this can't happen if the source operand is in double extended-precision floating-point format. So it can just stuff the bits into an x87 register without looking at them, unlike fld m32 / m64 where it has to expand the significand/exponent fields.
So ironically, on recent CPUs where the main use-case for x87 is for 80-bit, 80-bit float support is relatively even worse than on older CPUs. Obviously CPU designers don't put much weight on that and assume it's mostly used by old 32-bit binaries.
x87 and MMX are de-prioritized, though, e.g. Haswell made
fxch a 2-uop instruction, up from 1 in previous uarches. (Still 0 latency using register renaming, though. See Why is XCHG reg, reg a 3 micro-op instruction on modern Intel architectures? for some thoughts on that and
fadd throughputs are only 1 per clock on Skylake, vs. 2 per clock for SSE/AVX vector or scalar add/mul/fma. On Skylake even some MMX integer SIMD instructions run on fewer execution ports than their XMM equivalents.
(If you're looking at the tables yourself,
fbstp m80bcd are insanely slow because they convert from/to BCD, thus requiring conversion from binary to decimal with division by 10. Nevermind those, they're always microcoded).
yet everyone seemed to behave as though this had no value, and to positively celebrate the move to SSE2 where extended precision is no longer available.
No, what people celebrated was that FP became more deterministic. When and where you got 80-bit temporaries depended on compiler optimization decisions. You still can't compile most code on different platforms and get bitwise-identical results, but 80-bit x87 was one major source of difference between x86 and some other platforms.
Some people (e.g. writing unit tests) would rather have the same numbers everywhere than have more accurate results on x86. Often
double is more than enough, and/or the benefit was relatively small. In other cases, not so much, and extra temporary precision might help significantly.
Deterministic FP is a hard problem, but sought after by people for various reasons. e.g. trying to make multi-player games that don't need to send the whole state of the world over the network every simulation step, but instead can have everyone's simulation run in lockstep without drifting out of sync.
x87 (thus C
FLT_EVAL_METHOD == 2) isn't the only thing that was / is problematic. C compilers that can contract
x*y + z into
fma(x,y,z) also avoid that intermediate rounding step.
For algorithms that didn't try to account for rounding at all, increased temporary precision usually only helped. But numerical techniques like Kahan summation that compensate for FP rounding errors can be defeated by extra temporary precision. So yes, there are definitely people that are happy that extra temporary precision went away, so their code works the way they designed it on more compilers.
When do compilers round:
Any time they need to pass a
double to a non-inline function, obviously they store it in memory as a
double. (32-bit calling conventions pass FP args on the stack, not in x87 registers unfortunately. They do return FP values in
st0. I think some more recent 32-bit conventions on Windows use XMM registers for FP pass/return like in 64-bit mode. Other OSes care less about 32-bit code and still just use the inefficient i386 System V ABI which is stack args all the way even for integer.)
So you can use
sinl(x) instead of
sin(x) to call the
long double version of the library function. But all your other variables and internal temporaries get rounded to their declared precision (normally
float) around that function call, because the whole x87 stack is call-clobbered.
When compilers spill/reload variables and optimization-created temporaries, they do so with the precision of the C variable. So unless you actually declared
long double a,b,c, your
double a,b,c all get rounded to
double when you do
x = sinl(y). That's somewhat predictable.
But even less predictable is when the compiler decides to spill something because it's running out of registers. Or when you compile with/without optimization.
gcc -ffloat-store does this store/reload variables to the declared precision between statements even when optimization is enabled. (Not temporaries within the evaluation of one expression.) So for FP variables, kind of like debug-mode code-gen where vars are treated similar to
But of course this is crippling for performance unless your code is bottlenecked on something like cache misses for an array.
long double is still an option
long double will prevent auto-vectorization, so only use it if you need it when writing modern code.)
Nobody was celebrating removing the possibility of extended precision, because that didn't happen (except with MSVC which didn't give access to it even for 32-bit code where SSE wasn't part of the standard calling convention).
Extended precision is rarely used, and not supported by MSVC, but on other compilers targeting x86 and x86-64,
long double is the 80-bit x87 type. Apparently even when compiling for Windows, gcc and clang use 80-bit
long double is an ABI difference between MSVC and other x86 compilers. Usually gcc and clang are careful to match the calling convention, type widths, and struct layout rules of the platform. But they chose to make
long double a 10-byte type despite MSVC making it the same as 8-byte
GCC has a
-mlong-double-64/80/128 x86 option to set the width of
long double, and the docs warn that it changes the ABI.
ICC has a
/Qlong-double option that makes
long double an 80-bit type even on Windows.
So functions that interact with any kind of
long double are not ABI compatible between MSVC and other compilers (except GCC or ICC with special options); they're expecting a different sized object, so not even a single
long double works, except as a function return value in
st0 where it's in a register already.
If you need more precision than IEEE binary64
double, your options include so-called double-double (using a pair of
double values to get twice the significand width but the same exponent range), or taking advantage of x87 80-bit hardware. If 80-bit is enough, it's a useful option, and gives you extra range as well as significand precision, and only requires 1 instruction per computation).
(On CPUs with AVX, especially with AVX2 + FMA, for some loops double-double might outperform x87, being able to compute 4x
double in parallel. e.g. https://stackoverflow.com/questions/30573443/optimize-for-fast-multiplication-but-slow-addition-fma-and-doubledouble shows that
double * double => double_double (53x53 => 106-bit significand) multiplication can be as simple as
high = a * b;
low = fma(a, b, -high); and Haswell/Skylake can do that for 4 elements at once in 2 instructions (with 2-per-clock throughput for FP mul/FMA). But with
double_double inputs, it's obviously less cheap.)
Further fun facts:
The x87 FPU has precision-control bits that let you set how results in registers are rounded after any/every computation and load:
- to 80-bit long double: 64-bit significand precision. The
finit default, and normal setting except with MSVC.
- to 64-bit double: 53-bit significand precision. 32-bit MSVC sets this.
- to 24-bit float: 24-bit significand precision.
Apparently Visual C++'s CRT startup code (that calls
main) reduces x87 precision from 64-bit significand down to 53-bit (64-bit double). Apparently x86 (32-bit) VS2012 and later still does this, if I'm reading Bruce Dawson's article correctly.
So as well as not having an 80-bit FP type, 32-bit MSVC changes the FPU setting so even if you used hand-written asm, you'd still only have 53-bit significand precision, with only the wider range from having more exponent bits. (
fstp m80 would still store in the same format, but the low 11 bits of the significand would always be zero. And I guess loading would have to round to nearest. Supporting this stuff might be why
fld decodes to multiple ALU uops on modern CPUs.)
I don't know if the motivation was to speed up
fsqrt (which it does for inputs that don't have a lot of trailing zeros in the significand), or if it's to avoid extra temporary precision. But it has the huge downside that it makes using extended precision impossible (or useless). It's interesting that GNU/Linux and MSVC made opposite decisions here.
Apparently the D3D9 library init function sets x87 precision to 24-bit significand single-precision float, making everything less precise for a speed gain on fdiv/fsqrt (and maybe
fsin and other slow microcoded instructions, too.) But x87 precision settings are per-thread, so it matters which thread you call the init function from! (The x87 control word is part of the architectural state that context switches save/restore.)
Of course you can set it back to 64-bit significand with
_controlfp_s, so you could useful use asm, or call a function using
long double compiled by GCC, clang, or ICC. But beware the ABI differences: you can only pass it inputs as
double, or integer, because MSVC won't ever create objects in memory in the 80-bit x87 format.