Avoid multiple copy of data when composing objects at construction without dynamic allocation

Viewed 115

We have hierarchical messages that are represented by classes. They are used to send messages between threads and components by serializing/deserializing. In our use-case, we use std::variant<InnerA, InnherB, ...>, but to simplify, our code is similar to this:

class Inner {
  public:
    Inner(uint8_t* array, uint16_t arrayLength) {
        m_payloadLength = arrayLength; // Let's assume arrayLength is always < 256
        memcpy(m_payload.data(), array, arrayLength));
    }
    std::array<uint8_t, 256> m_payload;
    uint16_t m_payloadLength;
}

class Outer {
  public:
    Outer(const Inner& inner): m_inner(inner){};
    Inner m_inner;
}

class OuterOuter {
  public:
    OuterOuter(const Outer& outer): m_outer(outer){};
    Outer m_outer;
}

Thus to create an OuterOuter object we need to do

int main(int argc, char** argv){
   uint8_t buffer[4]  = {1,2,3,4};
   Inner inner(buffer, 4);
   Outer outer(inner);
   OuterOuter outerOuter(outer);
   addToThreadQueue(outerOuter);
}

Now the thing is, we use an embedded device so we cannot use dynamic memory with malloc and new. As of now, will the payload content will be copied thrice? Once for the creation of inner, once when the copy constructor of Inner is called in Outer, and once when the copy constructor of Outer is called in OuterOuter? If so, is there a way to avoid all this copying without the use of dynamic memory? If there is a way to pass my intent to the compiler, may be an optimization would be possible, if it's not already optimizing it.

Ideally, we would avoid the OuterOuter class taking all the subclasses construction argument since our tree is pretty deep and we use std::variant. In this example it would be OuterOuter(uint8_t* array, uint16_t arrayLength), Outer(uint8_t* array, uint16_t arrayLength) and then Outer would build Inner.

2 Answers

Generally speaking, modern compilers do a good job at optimizing the construction of class hierarchies that have no side-effect to their construction beyond filling a continuous memory layout.

For example, gcc compiles your sample down to essentially a single class:

main:
  sub rsp, 280
  mov eax, 4
  mov rdi, rsp
  mov WORD PTR [rsp+256], ax
  mov DWORD PTR [rsp], 67305985
  call addToThreadQueue(OuterOuter const&)
  xor eax, eax
  add rsp, 280
  ret

see on godbolt

Even beyond that, compilers are allowed to skip some side-effects in certain scenarios. For example, in the following sample, gcc completely gets rid of a heap allocation via a process called "heap elision".

#include <memory>

extern int foo(int);
extern void bar(int);

struct MyStruct {
    int data;

    MyStruct() {
        auto val = std::make_unique<int>(12); 
        data = foo(*val);
    }
};

int main(int argc, char** argv){
   MyStruct x;
   bar(x.data);
}

becomes:

main:
  sub rsp, 8
  mov edi, 12
  call foo(int)
  mov edi, eax
  call bar(int)
  xor eax, eax
  add rsp, 8
  ret

see on godbolt

Obviously, you need to double check in your own code base, but the usual refrain remains: "Write easy to read and maintain code first, and only if the compiler does a bad job with it should you bother jumping through hoops to optimize it."

You can use inplacer (see this post). Your code will look like this:

#include <type_traits>
#include <array>
#include <cstdint>
#include <cstring>


using namespace std;


template<class F>
struct inplacer
{
    F f_;
    operator std::invoke_result_t<F&>() { return f_(); }
};

template<class F> inplacer(F) -> inplacer<F>;


struct Inner
{
    Inner(uint8_t* data, size_t len)
        : len_(len) // Let's assume arrayLength is always < 256
    {
        memcpy(payload_.data(), data, len*sizeof(*data));
    }

    std::array<uint8_t, 256>    payload_;
    size_t                      len_;
};

struct Outer
{
    template<class T>
    Outer(T&& inner): m_inner(std::forward<T>(inner)) {}

    Inner m_inner;
};

struct OuterOuter
{
    template<class T>
    OuterOuter(T&& outer): m_outer(std::forward<T>(outer)) {}

    Outer m_outer;
};


void addToThreadQueue(OuterOuter const&);

int main()
{
    uint8_t buffer[4]  = {1,2,3,4};
    OuterOuter outerOuter{ inplacer{[&]{ return Inner{buffer, size(buffer)}; }} };
    addToThreadQueue(outerOuter);
    return 0;
}

This approach will make you less dependent on compiler optimizations. It will also work if your ctors have side effects (or are unavailable for compiler to analyze in this translation unit).

main:
        sub     rsp, 280
        mov     rdi, rsp
        mov     DWORD PTR [rsp], 67305985
        mov     QWORD PTR [rsp+256], 4
        call    addToThreadQueue(OuterOuter const&)
        xor     eax, eax
        add     rsp, 280
        ret

Edit: here is similar solution (but without inplacer) -- it won't work with aggregates, but I bet in your case it is not a problem.

Related