3

Say you have an array of floats representing raw data, and several types representing 2D shapes that only have floats as members, like this:

#include <iostream>
#include <array>

struct point_t {
    float x, y;
    void print() const {
        std::cout << "point " << x
            << "," << y << std::endl;
    };
};
struct circle_t {
    float x, y, r;
    void print() const {
        std::cout << "circle " << x  << ","
            << y << "," << r << std::endl;
    };
};
struct rectangle_t {
    float x1, y1, x2, y2;
    void print() const {
        std::cout << "rectangle " << x1 << "," << y1
            << "," << x2 << "," << y2 << std::endl;
    };
};

int main() {

    std::array<float, 50> data{6.1f, 0.3f, 15.4f, 23.2f, 6.1f, 30.f, 35.f, 40.f, 40.f};
    
    //casting objects from the array:
    point_t& point = *(point_t*)&data[0];
    circle_t& circle = *(circle_t*)&data[2];
    rectangle_t& rectangle = *(rectangle_t*)&data[5];

    point.print();
    circle.print();
    rectangle.print();
}

//output:
//point 6.1,0.3
//circle 15.4,23.2,6.1
//rectangle 30,35,40,40

This compiles and appears to work. Is it undefined behavior or dangerous, and if so how can the approach be improved?

For context, this is meant for intense simulations and games where performance is critical. The goal is to manually control where objects of different types and sizes are created and moved in memory, to pack them together with very good cache locality. This includes updating and changing the data as objects are created and destroyed or moved in physical space (they may be periodically moved in memory to respect a certain space filling curve, like a Morton curve, which tends to reduce cache misses when performing spatial queries).

The data will change chaotically and unexpectedly and keeping objects which need to interact with each other as close together as possible in memory is critical.

From what I understand, std::memcpy and std::bit_cast both involve copying data to create a new object, which would be a performance hit. Putting the objects into std::variant or union reduces performance too; std::variant does type safety checking and it uses extra memory to keep track of the type. The size of std::variant also bloats to the largest type stored, which is a problem that unions have as well. The memory bloat makes everything slightly further apart which increases cache misses. Apparently unions may be able to negatively affect the ability to store values in CPU registers, hurting performance. I've benchmarked accessing a member normally VS through a union in a loop and noticed that unions are slightly slower on my system.

I am aware of entity component systems as a solution to cache locality for these kinds of applications but I'm curious about a more traditional object-oriented approach that still achieves good locality - but without running into strange problems due to undefined behavior.

13
  • 3
    I'm pretty sure that is UB and that you have to go through something like assigning individual members or using memcpy or similar, for guaranteed behaviour. Commented Dec 28, 2024 at 2:18
  • 2
    point_t& point = *(point_t*)&data[0]; -- Just seeing a reference to something you claim is a point but really isn't one doesn't seem to be a good idea. I would think a reference to an object has to bind to an actual object, not a fake one. Commented Dec 28, 2024 at 2:30
  • 1
    I believe this is safe, so long as it is only done with types that std::is_standard_layout_v<Type> is true and making sure that alignment is honored (shouldn't be a problem with a float array and all float members). Commented Dec 28, 2024 at 2:34
  • 1
    @greenlagoon -- If I create an object properly with new -- The usage of new is a signal to C++ that the object is what you claim it to be, and thus the compiler will treat those entities as real objects. Once that happens, then all the "normal" usage (and misusage) of objects will apply. I think the other comments were related to the fact that your code pretended to point to point_t, circle_t, etc., when in fact they didn't. Commented Dec 28, 2024 at 14:08
  • 1
    @greenlagoon - I was just mentioning one reason for why it doesn't work - a struct with two floats doesn't have to be the same size as two floats (in fact the language doesn't say what size a struct should have, just that padding is allowed). There are other rules that also apply. So no, a static_cast on size is not enough. Commented Dec 28, 2024 at 14:20

2 Answers 2

2

The goal is to manually control where objects of different types and sizes are created and moved in memory, to pack them together with very good cache locality.

This is what placement new is created for. You can do it like this:

int main() {

    alignas(float) char data[50*sizeof(float)];
    
    auto point = new(&data[0*sizeof(float)]) point_t{6.1f, 0.3f};
    auto circle = new(&data[2*sizeof(float)]) circle_t{15.4f, 23.2f, 6.1f};
    auto rectangle = new(&data[5*sizeof(float)]) rectangle_t{30.f, 35.f, 40.f, 40.f};

    point->print();
    circle->print();
    rectangle->print();
}
Sign up to request clarification or add additional context in comments.

5 Comments

Thanks, I'm assuming you used a char array to place the objects because of the fact that any object can be represented as a sequence of char bytes? Does this also mean that the objects can have whatever member types they want (not just floats) and have padding too or not necessarily be POD? If all of these things are true this is probably the best solution for my application.
@TedLyngmo If the buffer is aligned for 4 byte floats, does that mean you cannot put 8 byte members in objects that you create in the buffer with placement new? And does it mean that all objects must be created at positions that are multiples of 4 bytes from the start of the buffer?
@greenlagoon An alignment of 4 means that it's unsuitable for an object which requires an alignment of more than 4 - unless you do padding manually. The alignment on data in this case means that &data[0] will have an alignment of 4 and so will &data[3] etc. if alignof(float) is 4, so if you stick with types that have an alignment of 4 (and sizes multiples of 4), you can pack objects next to each other. You could create a helper for the management, like this, but that takes you closer to creating a custom Allocator, which may be an alternative solution
@TedLyngmo Very helpful thank you. I will have to study that code more but I can see it's using a template to handle any shape type and variadic to adapt to the number of arguments passed into construct. Since these objects will now be residing in a 4 byte aligned array of chars, and not in an array of any particular shape type - what would be the best way to traverse them (for example to do collision detection)? I understand that a linked list with pointers would work but it wouldn't be as fast to traverse as a simple for loop over contiguous objects in an array.
@greenlagoon You'd need to keep track of the types you've stored somehow. Here's one way but it's not very elegant.
1

You can do this, it will not even copy the data out of the array but keep the data there. It is still raw pointer arithmetic which is not recommended, but it will be safe as long as you don't go out of bounds.

#include <array>
#include <iostream>

struct point_t
{
    float& x, y;    // reference if no copy of array data should be made

    point_t(float* p) :
        x{ p[0] }, y{ p[1] }
    {
    }

    void print() const
    {
        std::cout << "point " << x
            << "," << y << std::endl;
    };
};
struct circle_t
{
    float& x, y, r;

    circle_t(float* p) :
        x{ p[0] }, y{ p[1] }, r{ p[2] }
    {
    }


    void print() const
    {
        std::cout << "circle " << x << ","
            << y << "," << r << std::endl;
    };
};

struct rectangle_t
{
    float& x1, y1, x2, y2;

    rectangle_t(float* p) :
        x1{ p[0] }, y1{ p[1] }, x2{ p[2] }, y2{ p[3] }
    {
    }


    void print() const {
        std::cout << "rectangle " << x1 << "," << y1
            << "," << x2 << "," << y2 << std::endl;
    };
};

int main() {

    std::array<float, 50> data{ 6.1f, 0.3f, 15.4f, 23.2f, 6.1f, 30.f, 35.f, 40.f, 40.f };

    //making objects views on the data
    point_t point{&data[0]};
    circle_t circle{&data[2]};
    rectangle_t rectangle {&data[5]};

    point.print();
    circle.print();
    rectangle.print();
}

4 Comments

Thanks, I did not know you could build objects with members that are references like that. If the objects were created in a different function that gets called in main, would they still exist after the function that creates them returns? They're not being created with new so I'm wondering about object lifetime and scope. But all of their members are references to an array that does still exist.
Like this it is a "view" on the data, that means you will have to keep the original data in scope. So you probably have to do some extra memory management yourself to keep both the array and objects alive.
In a declaration like float& x1, y1, x2, y2, is not only the 1st variable a reference?
Eugene, mmh... I always forget to check that because personally I never declare multiple variables on one line. Haven't done so for over 30 years ;)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.