Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

What is the modern, correct way to do type punning in C++?

It seems like there are two types of C++. The practical C++ and the language lawyer C++. In certain situations, it can be useful to be able to interpret a bit pattern of one type as if it were a different type. Floating-point tricks are a notable example. Let's take the famous fast inverse square root (taken from Wikipedia, which was in turn taken from here):

float Q_rsqrt( float number )
{
    long i;
    float x2, y;
    const float threehalfs = 1.5F;

    x2 = number * 0.5F;
    y  = number;
    i  = * ( long * ) &y;                       // evil floating point bit level hacking
    i  = 0x5f3759df - ( i >> 1 );               // what the
    y  = * ( float * ) &i;
    y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration
//  y  = y * ( threehalfs - ( x2 * y * y ) );   // 2nd iteration, this can be removed

    return y;
}

Setting aside details, it uses certain properties of the IEEE-754 floating-point bit representation. The interesting part here is the *(long*) cast from float* to long*. There are differences between C and C++ about which types of such reinterpreting casts are defined behavior, however in practice such techniques are used often in both languages.

The thing is that for such a simple problem there are a lot of pitfalls that can occur with the approach presented above and different others. To name some:

  • unspecified behavior
  • strict aliasing
  • lifetime issues
  • endianness
  • alignment

At the same time, there are a lot of ways of performing type punning and a lot of mechanisms related to it. These are all that I could find:

  • reinterpret_cast and c-style cast

    [[nodiscard]] float int_to_float1(int x) noexcept
    {
        return *reinterpret_cast<float*>(&x);
    }
    [[nodiscard]] float int_to_float2(int x) noexcept
    {
        return *(float*)(&x);
    }
    
  • static_cast and void*

    [[nodiscard]] float int_to_float3(int x) noexcept
    {
        return *static_cast<float*>(static_cast<void*>(&x));
    }
    
  • std::bit_cast

    [[nodiscard]] constexpr float int_to_float4(int x) noexcept
    {
        return std::bit_cast<float>(x);
    }
    
  • memcpy

    [[nodiscard]] float int_to_float5(int x) noexcept
    {
        float destination;
        memcpy(&destination, &x, sizeof(x));
        return destination;
    }
    
  • union

    [[nodiscard]] float int_to_float6(int x) noexcept
    {
        union {
            int as_int;
            float as_float;
        } destination{x};
        return destination.as_float;
    }
    
  • placement new and std::launder

    [[nodiscard]] float int_to_float7(int x) noexcept
    {
        new(&x) float;
        return *std::launder(reinterpret_cast<float*>(&x));
    }
    
  • std::byte

    [[nodiscard]] float int_to_float8(int x) noexcept
    {
        return *reinterpret_cast<float*>(reinterpret_cast<std::byte*>(&x));
    }
    

The question is which of these ways are safe, which are unsafe, and which are damned forever. Which one should be used and why? Is there a canonical one accepted by the C++ community? Why are new versions of C++ introducing even more mechanisms std::launder in C++17 or std::byte, std::bit_cast in C++20?

To give a concrete problem: what would be the safest, most performant, and best way to rewrite the fast inverse square root function? (Yes, I know that there is a suggestion of one way on Wikipedia).

Edit: To add to the confusion, it seems that there is a proposal that suggests adding yet another type punning mechanism: std::start_lifetime_as, which is also discussed in another question.

(godbolt)

like image 921
janekb04 Avatar asked May 21 '21 11:05

janekb04


People also ask

What is type punning in C?

A form of pointer aliasing where two pointers and refer to the same location in memory but represent that location as different types. The compiler will treat both "puns" as unrelated pointers. Type punning has the potential to cause dependency problems for any data accessed through both pointers.

What is the strict aliasing rule?

The strict aliasing rule dictates that pointers are assumed not to alias if they point to fundamentally different types, except for char* and void* which can alias to any other data type.


2 Answers

First of all, you assume that sizeof(long) == sizeof(int) == sizeof(float). This is not always true, and totally unspecified (platform dependent). Actually, this is true on my Windows using clang-cl and wrong on my Linux using the same 64-bit machine. Different compilers on the same OS/machine can give different results. A static assert is at least required to avoid sneaky bugs.

Plain C casts, reinterpret casts and static casts are invalid here because of the strict aliasing rule (to be pedantic, the program is ill-formed in this case regarding the C++ standard). The union solution is not valid too (it is only valid in C, not in C++). Only the std::bit_cast and the std::memcpy solution are "safe" (assuming the size of the types are matching on the target plateform). Using std::memcpy is often fast as it is optimized by most mainstream compiler (when optimizations are enabled, like with -O3 for GCC/Clang): the std::memcpy call can be inlined and replaced by faster instructions. std::bit_cast is the new way of doing this (only since C++20). The last solution is cleaner for a C++ code as std::memcpy use unsafe void* types and thus by-pass the type system.

like image 56
Jérôme Richard Avatar answered Oct 19 '22 08:10

Jérôme Richard


This is what I get from gcc 11.1 with -O3:

int_to_float4(int):
        movd    xmm0, edi
        ret
int_to_float1(int):
        movd    xmm0, edi
        ret
int_to_float2(int):
        movd    xmm0, edi
        ret
int_to_float3(int):
        movd    xmm0, edi
        ret
int_to_float5(int):
        movd    xmm0, edi
        ret
int_to_float6(int):
        movd    xmm0, edi
        ret
int_to_float7(int):
        mov     DWORD PTR [rsp-4], edi
        movss   xmm0, DWORD PTR [rsp-4]
        ret
int_to_float8(int):
        movd    xmm0, edi
        ret

I had to add a auto x = &int_to_float4; to force gcc to actually emit anything for int_to_float4, I guess thats the reason it appears first.

Live Example

I am not that familiar with std::launder so I cannot tell why it is different. Otherwise they are identical. This is what gcc has to say about it (in this context, with that flags). What the standard says is different story. Though, memcpy(&destination, &x, sizeof(x)); is well defined and most compilers know how to optimize it. std::bit_cast was introduced in C++20 to make such casts more explicit. Note that in the possible implementation on cppreference they use std::memcpy ;).


TL;DR

what would be the safest, most performant and best way to rewrite the fast inverse square root function?

std::memcpy and in C++20 and beyond std::bit_cast.

like image 41
463035818_is_not_a_number Avatar answered Oct 19 '22 09:10

463035818_is_not_a_number