23

Google's Protocol Buffers allows you to store floats and doubles in messages. I looked through the implementation source code wondering how they managed to do this in a cross-platform manner, and what I stumbled upon was:

inline uint32 WireFormatLite::EncodeFloat(float value) {
  union {float f; uint32 i;};
  f = value;
  return i;
}

inline float WireFormatLite::DecodeFloat(uint32 value) {
  union {float f; uint32 i;};
  i = value;
  return f;
}

inline uint64 WireFormatLite::EncodeDouble(double value) {
  union {double f; uint64 i;};
  f = value;
  return i;
}

inline double WireFormatLite::DecodeDouble(uint64 value) {
  union {double f; uint64 i;};
  i = value;
  return f;
}

Now, an important additional piece of information is that these routines are not the end of the process but rather the result of them is post-processed to put the bytes in little-endian order:

inline void WireFormatLite::WriteFloatNoTag(float value,
                                        io::CodedOutputStream* output) {
  output->WriteLittleEndian32(EncodeFloat(value));
}

inline void WireFormatLite::WriteDoubleNoTag(double value,
                                         io::CodedOutputStream* output) {
  output->WriteLittleEndian64(EncodeDouble(value));
}

template <>
inline bool WireFormatLite::ReadPrimitive<float, WireFormatLite::TYPE_FLOAT>(
    io::CodedInputStream* input,
    float* value) {
  uint32 temp;
  if (!input->ReadLittleEndian32(&temp)) return false;
  *value = DecodeFloat(temp);
  return true;
}

template <>
inline bool WireFormatLite::ReadPrimitive<double, WireFormatLite::TYPE_DOUBLE>(
    io::CodedInputStream* input,
    double* value) {
  uint64 temp;
  if (!input->ReadLittleEndian64(&temp)) return false;
  *value = DecodeDouble(temp);
  return true;
}

So my question is: is this really good enough in practice to ensure that the serialization of floats and doubles in C++ will be transportable across platforms?

I am explicitly inserting the words "in practice" in my question because I am aware that in theory one cannot make any assumptions about how floats and doubles are actually formatted in C++, but I don't have a sense of whether this theoretical danger is actually something I should be very worried about in practice.

UPDATE

It now looks to me like the approach PB takes might be broken on SPARC. If I understand this page by Oracle describing the format used for number on SPARC correctly, the SPARC uses the opposite endian as x86 for integers but the same endian as x86 for floats and doubles. However, PB encodes floats/doubles by first casting them directly to an integer type of the appropriate size (via means of a union; see the snippets of code quoted in my question above), and then reversing the order of the bytes on platforms with big-endian integers:

void CodedOutputStream::WriteLittleEndian64(uint64 value) {
  uint8 bytes[sizeof(value)];

  bool use_fast = buffer_size_ >= sizeof(value);
  uint8* ptr = use_fast ? buffer_ : bytes;

  WriteLittleEndian64ToArray(value, ptr);

  if (use_fast) {
    Advance(sizeof(value));
  } else {
    WriteRaw(bytes, sizeof(value));
  }
}

inline uint8* CodedOutputStream::WriteLittleEndian64ToArray(uint64 value,
                                                            uint8* target) {
#if defined(PROTOBUF_LITTLE_ENDIAN)
  memcpy(target, &value, sizeof(value));
#else
  uint32 part0 = static_cast<uint32>(value);
  uint32 part1 = static_cast<uint32>(value >> 32);

  target[0] = static_cast<uint8>(part0);
  target[1] = static_cast<uint8>(part0 >>  8);
  target[2] = static_cast<uint8>(part0 >> 16);
  target[3] = static_cast<uint8>(part0 >> 24);
  target[4] = static_cast<uint8>(part1);
  target[5] = static_cast<uint8>(part1 >>  8);
  target[6] = static_cast<uint8>(part1 >> 16);
  target[7] = static_cast<uint8>(part1 >> 24);
#endif
  return target + sizeof(value);
}

This, however, is exactly the wrong thing for it to be doing in the case of floats/doubles on SPARC since the bytes are already in the "correct" order.

So in conclusion, if my understanding is correct then floating point numbers are not transportable between SPARC and x86 using PB, because essentially PB assumes that all numbers are stored with the same endianess (relative to other platforms) as the integers on a given platform, which is an incorrect assumption to make on SPARC.

UPDATE 2

As Lyke pointed out, IEEE 64-bit floating points are stored in big-endian order on SPARC, in contrast to x86. However, only the two 32-bit words are in reverse order, not all 8 of the bytes, and in particular IEEE 32-bit floating points look like they are stored in the same order as on x86.

Gregory Crosswhite
  • 1,457
  • 8
  • 17
  • P.S.: Thanks to Lyke for supplying the link to the Oracle page! :-) – Gregory Crosswhite Aug 30 '11 at 20:43
  • That oracle page does look a bit ambiguous regarding endian representation on SPARC vs x86, and I do suspect Jon Skeet is right. Though, given the code above, it'd work nice if you're reading/writing them on the same host. I'd say we need soneone with a SPARC machine to generate output with doubles/float and read them back in on an x86 with the above code to settle this :) – nos Aug 30 '11 at 20:56
  • I thought using unions like that resulted in undefined behaviour. –  Aug 30 '11 at 21:06
  • @ Mike: It theory yes, but in practice it seems to work for such purposes even though it is not guaranteed to. In a sense your comment has restated the essence of my question. :-) – Gregory Crosswhite Aug 30 '11 at 21:56
  • 2
    @ nos: "I'd say we need soneone with a SPARC machine to generate output with doubles/float and read them back in on an x86 with the above code to settle this :)" I don't have a SPARC machine, but (after a surprising amount of blood, sweat, and tears :-) ) I managed to get a qemu emulated SPARC system running with NetBSD, and sure enough the generated output on the SPARCH got the correct result when read back in on x86. – Gregory Crosswhite Sep 01 '11 at 03:55

2 Answers2

10

I think it should be fine so long as your target C++ platform uses IEEE-754 and the library handles the endianness properly. Basically the code you've shown is assuming that if you've got the right bits in the right order and an IEEE-754 implementation, you'll get the right value. The endianness is handled by protocol buffers, and the IEEE-754-ness is assumed - but pretty universal.

Jon Skeet
  • 1,421,763
  • 867
  • 9,128
  • 9,194
  • 1
    Im likely reading it wrong, but at http://download.oracle.com/docs/cd/E18659_01/html/821-1384/bjbds.html (the table at F.2.4 ) it seems there's a bit difference in representing doubles on x86 vs sparc. – Lyke Aug 30 '11 at 20:03
  • 4
    The table indicates differences in long doubles. Long Doubles on a SPARC are a 128 bit floating point, while Intel uses an 80 bit floating point. – Dave S Aug 30 '11 at 20:09
  • 1
    Actually, it looks to me on that page as if floats and doubles are the only data types that are stored exactly the same on x86 and SPARC. – Gregory Crosswhite Aug 30 '11 at 20:10
  • 1
    @Gregory Crosswhite well, the table mentions for +3.0, 0000000040080000 on x86 and 4008000000000000 on sparc – Lyke Aug 30 '11 at 20:15
  • 1
    @Lyke: That's an endianness difference though, not a size difference. I'd expect the endianness difference to be handled by the compiler + protobuf library. – Jon Skeet Aug 30 '11 at 20:18
  • I agree with Gregory here. SPARC follows the IEEE754 spec, as does intel, so they shouldn't run into problems there. I somehow doubt that any hardware out there doesn't follow IEEE-754 at least insofar as that it uses the same bit representations (I'd assume that hardware not following the spec would probably be more lenient in terms of handling denormals or rounding modes, etc. ie the stuff that's complex to implement and most of the time not that important). – Voo Aug 30 '11 at 20:22
  • 1
    Actually, ironically this looks to me like it could mean that Protocol Buffers is broken on SPARC, since on SPARC floating point numbers are are the *same* as x86, but PB will reverse the order of the bytes anyway because SPARC is a big-endian platform and PB assumes that if integers are big-endian and need to have their bytes reversed then the same is true for floats and doubles. – Gregory Crosswhite Aug 30 '11 at 20:23
  • @Voo: I suspect there's still *some* hardware out there which doesn't conform with 754, but it's rare enough to be ignored :) – Jon Skeet Aug 30 '11 at 20:23
  • @Gregory: I don't know - I suspect that the integer layout ends up working the right way automatically. One of the nice things about IEEE-754 is that it's sortable if you view the values as integers (IIRC) and I doubt that they'd sacrifice that. I suspect it's just a case of integers having an "odd" representation if you look at the *exact byte pattern* - but if you view it as big-endian with two words, it's okay. Aside from anything else, IIRC the unit tests do test floating point, so they'd go bang pretty quickly if this failed :) – Jon Skeet Aug 30 '11 at 20:25
  • @Jon Skeet True enough ;) I should've said "hardware that you'll see in the wild and which could run protobuf", after all I'm sure there are still enough PDP-10's around in museums ;-) – Voo Aug 30 '11 at 20:42
  • @ Lyke, you're right! Thank you for pointing it out. Interestingly though, only the words are swapped, not the bytes. – Gregory Crosswhite Aug 30 '11 at 20:52
  • 1
    ARM has an ABI and float hardware that stores doubles in "middle-endian" order, on little-endian FPA systems IIRC. Take that, you fiend. As Jon says, of course, a unit test will catch it quickly. Unless the test values are astonishingly badly chosen, byte order isn't something that will appear to work when it doesn't. By the looks of the code quoted in the question I doubt that it's all that difficult to fork Protocol Buffers for funny architectures and just replace `WriteDoubleNoTag` etc, as long as storage format is close to IEEE. – Steve Jessop Aug 30 '11 at 22:48
  • @ Steve: "As Jon says, of course, a unit test will catch it quickly." Yes, but the problem is that writing a unit test where something is created on on architecture and then read in on another is a pain... nonetheless, if I get the time I think I'll use this as an excuse to play with running a SPARC virtual machine to see for myself whether their code works. Though, honestly, I am starting to reach the conclusion that my best bet is just to write out the floating-point values I want to store as text rather than using a binary format, since it looks so easy to get them wrong. :-) – Gregory Crosswhite Aug 30 '11 at 23:19
  • 2
    @Gregory: In protocol buffers there are unit tests with "golden" binary files and also text files. So that's how the unit tests work in this case at least. – Jon Skeet Aug 31 '11 at 01:10
4

In practice, the fact that they are writing and reading with the endianness enforced is enough to maintain portability. This is fairly evident, considering the widespread use of Protocol Buffers across many platforms (and even languages).

Reed Copsey
  • 554,122
  • 78
  • 1,158
  • 1,373