62

There is a paradox about floating point that I'm trying to understand.

Floating point is an eternal struggle with the problem that real numbers happen to be both essential and incomputable. It's the best solution we have for most calculations involving physical quantities, but has the perennial problems of limited precision and range; many volumes have been written about how to deal with these problems, even down to hardware engineers getting headaches implementing support for subnormal numbers, only to have programmers promptly turning this off because it kills performance on workloads where many numbers iterate to zero. (The usual reference here is the introductory document What every computer scientist should know about floating-point arithmetic, but for a more in-depth discussion, it's worth reading the writings of William Kahan, one of the world's foremost experts on the topic, and a very clear writer.)

The usual standard for floating point where substantial precision is required is IEEE-754 double precision, 64 bits. It's the best most hardware provides; doing even slightly better typically requires switching to a software solution for a dramatic slowdown.

The x87 went one better and provided extended precision, 80 bits. A Google search finds many articles about this, and almost all of them lament the problem that when compilers spill temporaries from registers to memory, they round to 64 bits, so the exact results vary quasi-randomly depending on the behavior of the optimizer, which admittedly is a problem indeed.

The obvious solution is for the in-memory format to be also 80 bits, so that you get both extended precision and consistency. But I have not encountered any mention, ever, of this being used. It's moot now that one uses SSE2 which doesn't provide extended precision, but I would expect it to have been used in the days when x87 was the available floating-point instruction set.

The paradox is this: on the one hand, there is much discussion of limited precision being a big problem. On the other hand, Intel provided a solution with an extra eleven bits of precision and five bits of exponent, that would cost very little performance to use (since the hardware implemented it whether you used it or not), and 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.

So my question is:

Did any compilers ever make full use of extended precision (i.e. 80 bits in memory as well as in registers)? If not, why not?

4
  • Comments are not for extended discussion; this conversation has been moved to chat.
    – Chenmunka
    Commented Apr 22, 2019 at 17:37
  • 3
    Can the title be edited to make it clear we're talking about a particular 80-bit implementation? Would 'x87' or 'Intel' be the best word to add?
    – dave
    Commented Apr 23, 2019 at 15:24
  • 1
    "the exact results very quasi-randomly depending on the behavior of the optimizer," Think this should read "vary".
    – ecm
    Commented Dec 15, 2021 at 17:54
  • 2
    The successor of Turbo Pascal, Delphi, fully supports the 80-bit extended float on windows 32 platforms.
    – LU RD
    Commented Sep 12, 2022 at 19:05

7 Answers 7

61

Yes. For example, the C math library has had full support for long double, which on x87 was 80 bits wide, since C99. Previous versions of the standard library supported only the double type. Conforming C and C++ compilers also perform long double math if you give the operations a long double argument. (Recall that, in C, 1.0/3.0 divides a double by another double, producing a double-precision result, and to get long double precision, you would write 1.0L/3.0L.)

GCC, in particular, even has options such as -ffloat-store to turn off computing intermediate results to a higher precision than a double is supposed to have. That is, on some architectures, the fastest way to perform some operations on double arguments is to use extra precision, but that might produce a non-portable result, so GCC has an option to always round double intermediate values off.

Testing with godbolt.org, GCC, Clang and ICC in x87 mode all perform 80-bit computations and memory stores with long double variables—except that they will optimize constants such as 0.5L to double when that will save memory at no loss of precision. MSVC 2017, however, only supports 64-bit long double.

Although you asked specifically about x87, the 68K architecture also had 80-bit FP hardware, and GCC made long double 80 bits wide on that target.

Fortran 90 finally provided a reasonably-portable way to specify a type with at least the precision of an 80-bit float (with kind and SELECTED_REAL_KIND()). These might give you double-double or 128-bit math on other implementations. Even before then, some Fortran compilers provided extensions such as REAL*10. Ada was another language that allowed the programmer to specify a minimum number of DIGITS of precision. There were other compilers that supported 80-bit math to some degree as well. For example, Turbo Pascal had an Extended type, although its math library supported only Real arguments.

Another possible example is Haskell, which provided both exact Rational types and arbitrary-precision floating-point through Data.Number.CReal. So far as I know, no implementation used x87 80-bit hardware, but it might still be an answer to your question.

7
  • Comments are not for extended discussion; this conversation has been moved to chat.
    – Chenmunka
    Commented Apr 22, 2019 at 17:39
  • Can you clarify what you mean by "the" C math library? Commented Dec 17, 2021 at 13:21
  • @TobySpeight In this context, I was thinking of how ANSI specified long double versions of all the library functions in <math.h>. On x87 implementations of the time, these used 80-bit math.
    – Davislor
    Commented Dec 17, 2021 at 16:33
  • 1
    I see. Of course, actual implementations are quite able to make long double be the same type as double; I don't know which choices particular platforms made. Commented Dec 17, 2021 at 16:43
  • @TobySpeight As I mentioned in the post, MSVC is the only major compiler with 64-bit long double, although the x87 opcodes are now considered obsolescent.
    – Davislor
    Commented Dec 17, 2021 at 16:52
57

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. (Strict ISO C / IEEE FP semantics require rounding to the actual type on assignment. So not keeping extra precision across statements, only within expressions, even for FLT_EVAL_METHOD == 2. What real compilers do for x87 is already bending that rule, unless you use -std=c11 instead of -std=gnu11, or gcc -ffloat-store)

So code had to be written specifically to use it (by using long double explicitly), which is incompatible with auto-vectorization with SSE/SSE2 because it forces 80-bit x87 even when SSE2 for SIMD float/double is available. (And most existing code is written to use double, not long double.)

Note: on x86 Windows, long double is the same as 64-bit double; GCC targeting Windows can use -mlong-double-64/80/128, but that's not ABI-compatible with other code using long double. The i386 and AMD64 System V ABIs have 80-bit long double.


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 double for 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 fadd / fsub / fmul / 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 float/double.

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 m32 / m64 operands (IEEE binary32 float / IEEE binary64 double).

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 x[i-1].

If you want to store 80-bit long double, fstp is your only option. You might need use fld st0 to duplicate it, then fstp to pop that copy off. (You can fld / 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 fsqrt and 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).

  • 486

    • 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 fadd / 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
  • Skylake: 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.

  • AMD K7/K8: fld m80: 7 m-ops, 1 per 4-cycle throughput (vs. 1 per 0.5c for 1 m-op fld m32/m64).
    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.

  • AMD Bulldozer: fld m80: 8 ops/14c lat/4c tput. (vs. 1 op/8c lat/1c tput for m32/m64). Interesting that even regular float/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.

Fun fact: 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 fxch.) And fmul / 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, fbld and 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 double or 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 volatile.

But of course this is crippling for performance unless your code is bottlenecked on something like cache misses for an array.


Extended precision long double is still an option

(Obviously 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.

Beware 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 double.

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 32-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 fdiv and 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 fcos/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 usefully 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 float, double, or integer, because MSVC won't ever create objects in memory in the 80-bit x87 format.

26
  • 2
    @supercat: right, but if you want deterministic 80-bit, you have to use it at least for some local vars, so when they have to be spilled across other function calls, like for sin(x) + sin(y) + sin(x*y) or whatever, the spill/reload will be in 80-bit precision. And this is why compilers don't default to promoting locals and temporaries to 80-bit by default. (An option to do that would be possible, but GCC (still) doesn't have one. See discussion on Davislor's answer about a quote from the g77 2.95 manual about the possibility. Commented Apr 19, 2019 at 21:53
  • 2
    @supercat: As I mentioned in this answer, the x87 stack is always call-clobbered in all calling conventions. (And must be empty on call, and empty or holding return value on ret). Due to its nature, there's no sane way to make any of it call-preserved; it's a stack, and pushing a new value when the slot is already in use give you a NaN-indefinite. You could in theory make a calling convention that stored the status word and figured out how many regs were in use, so it knew how many slow 80-bit fstps to use on entry, and how many slow 80-bit flds to do before returning, but yuck. Commented Apr 19, 2019 at 22:12
  • 3
    Design defects in the 8087 may have made it impractical to say that st0...2 are call-preserved without introducing weird corner-cases, but were it not for that, doing 80-bit spills in cases where functions use more than 5 registers would be probably cheaper than having to do 64-bit spills for everything. BTW, I vaguely recall some having read of a compiler's directives to indicate that particular functions would leave a certain number of FPU registers alone, but I didn't pay any attention to the details at the time.
    – supercat
    Commented Apr 19, 2019 at 22:35
  • 6
    the option in GCC is -mlong-double-64/80/128. There's also a warning under them saying if you override the default value for your target ABI, this changes the size of structures and arrays containing long double variables, as well as modifying the function calling convention for functions taking long double. Hence they are not binary-compatible with code compiled without that switch.
    – phuclv
    Commented Apr 20, 2019 at 4:19
  • 2
    @supercat: did you mean to reply to me? I think the System V concern (which is being too conservative or stubborn IMO) is that if you can't preserve the whole xmm/ymm/zmm/future whatever width of a vector reg, the whole reg must be call-clobbered. x86 doesn't have any way to write a future-proof function that reserves the right amount of stack space for a full-width vector and stores it. Saving/restoring a ZMM is only possible with code that requires AVX512 and assumes 512 is the max vector width. However, a few call-preserved XMM or low64-XMM would make a lot of sense. Commented Apr 20, 2019 at 16:50
27

I worked for Borland back in the days of the 8086/8087. Back then, both Turbo C and Microsoft C defined long double as an 80-bit type, matching the layout of Intel's 80-bit floating-point type. Some years later, when Microsoft got cross-hardware religion (maybe at the same time as they released Windows NT?) they changed their compiler to make long double a 64-bit type. To the best of my recollection, Borland continued to use 80 bits.

6
  • 8
    Microsoft X86 16 bit tool sets support 80 bit long doubles. This was dropped in their X86 32 bit and X86 64 bit tool sets. Win32s for WIndow 3.1 was released about the same time as NT 3.1. I'm not sure if Windows 3.1 winmem32 was released before or after win32s.
    – rcgldr
    Commented Apr 19, 2019 at 13:46
  • 3
    C89 botched "long double" by violating the fundamental principle that all floating-point values passed to non-prototyped functions get converted to a common type. If it had specified that values of long double get converted to double except when wrapped using a special macro which would pass them in a struct __wrapped_long_double, that would have avoided the need for something like printf("%10.4f", x*y); to care about the types of both x and y [since the value isn't wrapped, the value would get passed to double regardless of the types of x and y].
    – supercat
    Commented Apr 19, 2019 at 21:37
  • 4
    IIRC Delphi 5 (and probably also 3,4,6, and 7) had the "Extended" type which used all 80 bits of the FPU registers. The generic "Real" type could be made an alias of that, of the 64-bit Double, or of a legacy Borland soft float format.
    – cyco130
    Commented Apr 20, 2019 at 11:04
  • 2
    @MichaelKarcher: The introduction of long int came fairly late in the development of C, and caused considerable problems. Nonetheless, I think a fundamental difference between the relationship of long int and int, vs. long double and double, is that every value within the range of int can be represented just as accurately by that type as by any larger type. Thus, if scale_factor will never exceed the range of int, there would generally be no reason for it to be declared as a larger type. On the other hand, if one writes double one_tenth=0.1;, ...
    – supercat
    Commented Apr 20, 2019 at 16:23
  • 4
    ...and then computes double x=one_tenth*y;, the calculation may be less precise than if one had written double x=y/10.0; or used long double scale_factor=0.1lL. If neither wholeQuantity1 nor wholeQuantity2 would need to accommodate values outside the range of int, the expression wholeQuantity1+wholeQuantity2 will likely be of type int or unsigned. But in many cases involving floating-point, there would be some advantage to using longer-precision scale factors.
    – supercat
    Commented Apr 20, 2019 at 16:29
13

The Gnu Ada compiler ("Gnat") has supported 80-bit floating point as a fully-fledged built-in type with its Long_Long_Float type since at least 1998.

Here's a Usenet argument in February of 1999 between Ada compiler vendors and users about whether not supporting 80-bit floats is an Ada LRM violation. This was a huge deal for compiler vendors, as many government contracts can't use your compiler then, and the rest of the Ada userbase at that time viewed the Ada LRM as the next best thing to holy writ.*

To take a simple example, an x86 compiler that does not support 80-bit IEEE extended arithmetic is clearly violates B.2(10):

10 Floating point types corresponding to each floating point format fully supported by the hardware.

and is thus non-conformant. It will still be fully validatable, since this is not the sort of thing the validation can test with automated tests.

...

P.S. Just to ensure that people do not regard the above as special pleading for non-conformances in GNAT, please be sure to realize that GNAT does support 80-bit float on the ia32 (x86).

Since this is a GCC-based compiler, its debatable if this is a revelation over the current top-rated answer, but I didn't see it mentioned.

* - It may look silly, but this user attitude kept Ada source code extremely portable. The only other languages that really can compare are ones that are effectively defined by the behavior of a single reference implementation, or under the control of a single developer.

8

Did any compilers ever make full use of extended precision (i.e. 80 bits in memory as well as in registers)? If not, why not?

Since any calculations inside the x87 fpu have 80bit precision by default, any compiler that's able to generate x87 fpu code, is already using extended precision. I also remember using long double even in 16-bit compilers for real mode.

The very similar situation was in 68k world, with FPUs like 68881 and 68882 supporting 80bit precision by default and any FPU code without special precautions would keep all register values in that precision. There was also long double datatype.

On the other hand, Intel provided a solution with an extra eleven bits of precision and five bits of exponent, that would cost very little performance to use (since the hardware implemented it whether you used it or not), and yet everyone seemed to behave as though this had no value

The usage of long double would prevent contemporary compilers from ever making calculations using SSE/whatever registers and instructions. And SSE is actually a very fast engine, able to fetch data in large chunks and make several computations in parallel, every clock. The x87 fpu now is just a legacy, not being very fast. So the deliberate usage of 80bit precision now would be certainly a huge performance hit.

3
  • 1
    Right, I was talking about the historical context in which x87 was the only FPU on x86, so no performance hit from using it. Good point about 68881 being a very similar architecture.
    – rwallace
    Commented Apr 19, 2019 at 8:12
  • If memory serves, the 68000-family Macintosh could process computations with the 80-bit extended-precision type were faster than computations using 64-bit double-precision type, whether or not an FPU was present. The problem with the 80-bit type is that ANSI broke the C language by making the representation of a floating-point value passed to a non-prototyped function depend upon its precision, rather than specifying that any floating-point value that is passed to a non-prototyped function without being with a certain intrinsic will be passed as double, and any value which is wrapped...
    – supercat
    Commented Nov 12, 2020 at 20:57
  • ...in an _LDBL(x) intrinsic will be passed as long double [the exact behavior of the intrinsic would be up to the compiler, but a typical implementation could have it be a function return a struct of implementation-reserved type containing a long double]. The only convenient way for implementations to be compatible with code that expected to receive 64-bit double values without breaking the language was to interpret both double and long double as synonyms for the same 64-bit type.
    – supercat
    Commented Nov 12, 2020 at 21:00
3

The D language supports the maximum precision a platform provides. It has defined these 3 base floating point type float for 32 bits floats, double for 64 bits floats and real for maximum of platform. In the case of x86, it's the 80 bits x87 type. The unusual feature is that the compiler still supports x87 even when generating x86_64 code. In 64 bit mode, double will generate code via SSE2 instruction set as do the C compilers, but will still maintain x87 code for real. Contrary to popular belief, x87 is still usable even in 64 bit mode of x86 processors. It might one day be deprecated or even get serious performance regressions, but that is not the rationale behind the specific reason of existence of the real type in D. It is to include in the language a way to exploit a higher precision when available on a platform (128 bit float in some POWER or SPARC processor).

2
  • 1
    C works this way, too, on x86-64 other than Windows. In the x86-64 System V ABI, long double is an 80-bit type (stored as an aligned 16 bytes, which is probably too much alignment for most cases...) It's really only MSVC, and compilers aiming to be ABI-compatible with it, i.e. targeting Windows, that lose easy access to an 80-bit FP type. (GCC has a -mlong-double-64/80/128 option to override the ABI (making incompatible code), which you can use to narrow long double on non-Windows, or widen it on Windows. gcc.gnu.org/onlinedocs/gcc/x86-Options.html) Commented Jun 8, 2023 at 23:42
  • 1
    x87 80-bit load/store are low performance on current CPUs, but the actual computation using registers is still fairly fast. See my answer for details. fadd and fmul have lower throughput than scalar SSE2 math (like only 1/clock), but similar latency on Intel, with fmul and fadd using different ports so they can run in parallel. Agner Fog's testing ((agner.org/optimize) found Zen 3 has an fadd throughput of only one per 2 clocks, but fully pipelined fmul. But fiadd throughput of 1/clock? That doesn't make much sense, probably an error in hand-editing his spreadsheet. Commented Jun 8, 2023 at 23:51
-1

I don't think the no value of 80 bit is correct. At the time of SSE however, large computational problems were not solved on x86, but rather on risc platforms that provided 64bit addressing capability, alpha, sparc, IBM power, SGI etc. There was also a vast performance gap between the P3 and the risc platforms of the time.

All of these provided 64bit doubles, and some had support for 128bit floating point registers, but without the math hardware, making it slow.

Having 80bit on x86, and 64bit on everything else was a pain, since then you wouldn't get the same answers on the 2 platforms reliably, there is nothing worse than 2 builds of the same code giving slightly different answers, and even worse since if you wrote the 80 bit x86 representation to memory which occupies 128 bits, the format is different to the 128 bit long double on any other platform making data interchange horrible. With doubles you only have to switch the endianess.

These days when x64 is so dominant, this argument doesn't hold, but at the time SSE was introduced, it was a big deal.

4
  • 2
    The pattern of using higher precision for temporary calculations and rounding values at well-defined points for longer-term storage allows code to be faster and more accurate than code which uses the same precision all the time, but for it to be usable there must be a way of storing temporary values with full precision. The extended-precision double got a bad reputation because C botched the semantics of long-double argument passing, leading compilers to cheat and make "long double" and "double" synonymous, so e.g. long double dsquared = (x-y)*(x-y) wouldn't yield the same result as...
    – supercat
    Commented Sep 30, 2020 at 14:58
  • 2
    ...long double delta=x-y, dsquared=delta*delta;. If ANSI C hadn't botched floating-point argument passing, and had specified that within calculations, float promotes to long float (whose size may be anything from float to long double) and double to ` long double`, that could have allowed greatly improved math performance on 16-bit and 32-bit platforms without hardware floating-point support, for which unpacked 48-bit and 80 (or 96)-bit types would be more efficient than packed 32-bit or 64-bit ones.
    – supercat
    Commented Sep 30, 2020 at 15:01
  • @supercat I am not disputing the semantics of more a accurate calculation. I am addressing the point in the question of sse being well received when sse was brought in, performance critical code couldnt run on the x86 and sameness as risk was far more useful than any minor benefit here
    – camelccc
    Commented Sep 30, 2020 at 17:36
  • The way many compilers treated 80-bit types would throw out the window the "sameness" of any code using them.
    – supercat
    Commented Sep 30, 2020 at 18:09

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .