c++ - Why are polymorphic objects not trivially relocatable? - Stack Overflow

admin2025-04-20  0

Throughout the battle of P1144 vs P2786 to introduce trivial relocation optimisation for C++, one of the points P1144 has raised against P2786 is that polymorphic objects should not be trivially relocated and P2786 should not allow this.

P2786R13 has been recently voted into C++26 and it does allow polymorphic objects to be trivially relocated.

  1. Why where polymorphic object not trivially relocatable in the first place?
  2. How does P2786R13 overcome this to add support for these objects?
  3. Does trivial relocation of polymorphic object differ from normal objects?

A detailed technical insight into these questions would be helpful.

Throughout the battle of P1144 vs P2786 to introduce trivial relocation optimisation for C++, one of the points P1144 has raised against P2786 is that polymorphic objects should not be trivially relocated and P2786 should not allow this.

P2786R13 has been recently voted into C++26 and it does allow polymorphic objects to be trivially relocated.

  1. Why where polymorphic object not trivially relocatable in the first place?
  2. How does P2786R13 overcome this to add support for these objects?
  3. Does trivial relocation of polymorphic object differ from normal objects?

A detailed technical insight into these questions would be helpful.

Share Improve this question edited Mar 3 at 17:23 Konrad Rudolph 547k140 gold badges960 silver badges1.2k bronze badges asked Mar 3 at 17:20 yosemiteyosemite 1712 bronze badges 4
  • 1 @3CxEZiVlQ Then again std::string is not "dynamically polymorphic", but "statically polymorphic"... so maybe the wording or the question is not quite precise enough? Anyway I'm not enough of a language laywer to give a good answer ;) – Pepijn Kramer Commented Mar 3 at 17:28
  • It looks to me that the argument is just that some polymorphic objects can be relocatable. Not that all of them are. – BoP Commented Mar 3 at 18:18
  • @3CxEZiVlQ It is not trivially relocatable if the data pointer points into the small buffer. This makes for faster access, but it is not a necessary feature for an SBO string. You could alternatively check some "is small" marker on each access. That's how SBO works in Rust, for example. – Sebastian Redl Commented Mar 4 at 9:49
  • 1 @3CxEZiVlQ: You might want to delete your comment, as it's potentially misleading. (1) It's irrelevant: string is not a polymorphic type. (2) It's wrong: libc++ and MSVC both have SSO strings that are trivially relocatable by both P1144 and P2786's definitions. See "What types are trivially relocatable in practice?" (2019-02-20). – Quuxplusone Commented Mar 5 at 15:51
Add a comment  | 

2 Answers 2

Reset to default 15

There isn't any particular reason in general why a type being polymorphic should disqualify it from being trivially relocatable.

After all, this:

struct B {
  virtual ~B() = default;
  virtual int f() = 0;
};

struct D : B {
  int f() override { return 42; }
};

Really is conceptually similar to:

struct __vtable {
  void (*dtor)(B*);
  int (*f)(B*);
};

struct B {
  __vtable* v;
  ~B() { v->dtor(this); }
  int f() { return v->f(this); }
};

struct D : B {
  D()
  : B{.v={
    .dtor=[](B* p) { static_cast<D*>(p)->~D(); },
    .f=[](B* p) { return static_cast<D*>(p)->f(); }
    }}
  { }

   int f() { return 42; }
};

And trivially relocating the latter is totally fine.

The only argument P1144 makes against trivially relocating polymorphic types is linking to this blog, which doesn't make a coherent argument at all.


Now, the interesting part of this discussion isn't polymorphic types in general, but rather one particular situation. Recent ARM hardware (arm64e) permits something called vptr signing — where the vtable isn't just a pointer to bunch of function pointers (like I showed above) but rather also uses some bits of the pointer to point back to the object, for security reasons. This case cannot be memcpy-ied — because now the designation vtable will be invalid (it won't be properly signed).

This case is okay though, since the implementation knows what's going on and can cleanup the vtables after the fact. Making D trivially relocatable still works — it's just that relocation in this case isn't just memcpy.

However, the case that doesn't work is:

union U {
  uint64_t u;
  D d;
};

Here, the normal language rules would say that U is trivially relocatable, but it's impossible to do the vptr fixup in this case — since you don't know if the U in question is holding a uint64_t (so fixup must not be performed) or a D (so fixup must be performed).

That's why the ultimate rule in P2786R13 is:

A class is eligible for trivial relocation unless it [...] except that it is implementation-defined whether an otherwise-eligible union having one or more subobjects of polymorphic class type is eligible for trivial relocation.

For many implementations, U is still trivially relocatable. On Apple hardware, it may not be. Either way, that shouldn't affect any program correctness — just what specific instructions get invoked to do relocation.

(Author of P1144 here.)

Why were polymorphic objects not trivially relocatable in the first place?

P1144 was (and still is, until the National Body Comment period of C++26 ends!) concerned mainly with formalizing existing practice and promoting performance and correctness. These aren't exactly "principles" such that I'll be able to trace each design decision back to exactly one of them, but I think that's useful background to keep in mind as we go.

As Barry said in his answer, a polymorphic object is basically the syntactic-sugar version of the following kind of class. Barry left out the special member functions, though, and the special member functions end up being very important in P1144-world!

struct __vtable {
  void (*dtor)(B*);
  int (*f)(B*);
};
struct __vtable __vtb = {
  .dtor=[](B* p) { ~~~~ },
  .f=[](B* p) { ~~~~ }
};
struct __vtable __vtd = {
  .dtor=[](B* p) { ~~~~ },
  .f=[](B* p) { ~~~~ }
};

struct B {
  __vtable* v;
  int i;
  B() : v(__vtb), i(42) {}
  B(const B& b) : v(__vtb), i(b.i) {}
  B& operator=(const B& b) { i = b.i; return *this; }
  ~B() { v->dtor(this); }
  int f() { return v->f(this); }
};

struct D : B {
  D() : B() { v = __vtd; }
  D(const D& d) : B(d) { v = __vtd; }
  D& operator=(const D& d) { i = d.i; return *this; }
};

Notice that when we copy (or move) a B into another B, we don't just take the v and i members directly over. We take the i over, but we must write a new value into v. This is because of "slicing." And when we copy- (or move-) assign a B into another B, we don't just take the v and i members directly over; we take the i over, but leave the v member untouched.

None of the special member functions of B can be defaulted. Of course they can be defaulted if we use C++'s core-language support (virtual, inheritance, etc.), but if we were just implementing B on our own, we couldn't just "copy over all the members"; that would be wrong.

P1144's trivial relocation (unlike P2786's) is required to be trivial

In P1144-world, we say that a type is trivially relocatable if-and-only-if its relocation operation is tantamount to a bytewise memory copy. This is indispensable for real-world code, because real-world code is going to want to use is_trivially_relocatable to gate optimizations involving memcpy, realloc, compare-and-swap, and other such "bytewise" facilities.

Barry writes:

Recent ARM hardware (arm64e) permits something called vptr signing — where the vtable isn't just a pointer to bunch of function pointers (like I showed above) but rather also uses some bits of the pointer to point back to the object, for security reasons. This case cannot be memcpy-ied — because now the designation vtable will be invalid (it won't be properly signed).

This case is okay though, since the implementation knows what's going on and can cleanup the vtables after the fact.

This is true only for a "sufficiently smart (i.e. non-trivial) implementation." Obviously if you're gating your optimizations today on your::is_trivially_relocatable, and using memcpy today, then you really cannot tolerate your::is_trivially_relocatable returning true for polymorphic types. See for example Google Abseil's internal documentation:

// absl::is_trivially_relocatable<T>
//
// Detects whether a type is known to be "trivially relocatable" -- meaning it
// can be relocated from one place to another as if by memcpy/memmove.
// This implies that its object representation doesn't depend on its address,
// and also none of its special member functions do anything strange.

Notice "its object representation doesn't depend on its address."

So, as it currently stands, Abseil will not be able to use C++26's std::is_trivially_relocatable out of the box. They'll have to rewrite their trait from the-moral-equivalent-of

// if P1144 had been adopted
template <class T>
struct is_trivially_relocatable
  : std::is_trivially_relocatable<T> {};

to:

// because P2786 was adopted
template <class T>
struct is_trivially_relocatable
  : std::bool_constant<
      std::is_trivially_relocatable<T> &&
      std::is_replaceable<T> &&
      !std::is_polymorphic<T>
    > {};

Which is of course doable; there's no physical reason that Abseil, Folly, AMC, etc. shouldn't just update their definitions in this way. It's just a very stupid situation we're in, where the leaders of WG21 are forcing them to do this for... um... reasons.

There's a timeline in P3559R0 "One trait or two?" that shows the adoption of the one-trait approach across Folly, Abseil, AMC, et al., and of course there's the Clang patch that was never adopted despite 22 thumbs-up reactions from the authors of P3236 "Please reject P2786".

How does P2786R13 overcome this to add support for these objects?

Reminder: The three sub-issues we need to overcome here are:

  • B's move-constructor is non-trivial. Relocating into a B needs to pull a pointer to __vtb out of thin air; it can't just copy over the v member from the right-hand object unless that right-hand object is dynamically a B and not, say, a D.

  • Even if the right-hand object is a B and not a D, we still can't just copy over the v member because it might be address-dependent (arm64e "ptrauth" signing).

  • B's move-assignment operator is non-trivial, perhaps even virtual. This means B is not "replaceable," in P2786 terminology.

P2786 solves the third problem by splitting absl::is_trivially_relocatable into two weaker traits: std::is_trivially_relocatable and std::is_replaceable. So they get to claim that B is in fact std::is_trivially_relocatable (by this redefinition of the term) — it's merely not std::is_replaceable. This is on the one hand a perfectly legitimate refactoring, and on the other hand a lot like that joke attributed to Abraham Lincoln about how many legs a horse would have if you called a tail a leg.

P2786 solves the second problem by permitting std::trivially_relocate to perform a non-trivial operation. It doesn't necessarily copy bytewise. It is permitted to do something else in order to make the "type information" come out right. P2786R13's exact wording is:

[trivially_relocate's postcondition is that the destination range] contains objects (including subobjects) whose lifetime has begun and whose object representations are the original object representations of the corresponding objects in [the source range] except for any parts of the object representations used by the implementation to represent type information.

That is, vptrs and vbptrs are allowed to take on new values (or at least new object representations) as a result of the operation.

P2786 solves the first problem by "library UB": Whereas in P1144-world it's fine to use std::relocate_at from a D object into a B object (it simply Does The Right Thing), in P2786-world it is "library UB" to use std::trivially_relocate in the same way. The Godbolt from the blog post demonstrates this "library UB": the call to trivially_relocate will compile, because is_trivially_relocatable<B> is true in P2786-world, but if you actually execute that call at runtime, you get garbage. (And it's your fault for holding it wrong.) P2786R13's exact wording is:

Preconditions: [...] No element in [the source range] is a potentially-overlapping subobject.

Now, my impression is that the first problem is self-caused in the first place by P2786's providing of this preconditionful std::trivially_relocate function. If you go through the high-level library API proposed in P3516, my impression is that you'll get the "it Just Works" behavior anyway. Alternatively, if you adopt within your own codebase the absl::is_trivially_relocatable semantics that rejects polymorphic types, I believe you won't have to think about this stuff at all.

转载请注明原文地址:http://conceptsofalgorithm.com/Algorithm/1745081262a283872.html

最新回复(0)