😽

UE5:Unreal EngineのStructUtilについてまとめた 中編

に公開

はじめに

StructUtilシリーズ第二弾です。
本稿は中編です。

StructUtilの主要型

再掲。

  • FInstancedStruct
  • TInstancedStruct
  • FStructView
  • FConstStructView
  • FSharedStruct
  • FConstSharedStruct
  • TSharedStruct<T>
  • TConstSharedStruct<T>
  • FStructArrayView
  • FConstStructArrayView

ひとつずつ触れていきましょう。

FSharedStructFConstSharedStruct

FInstancedStruct の メモリ領域を共有するバージョンです。

FSharedStructTSharedPtr<FInstancedStruct>のような機能を持つ構造体です。FStructSharedMemoryを利用して、共有メモリを直接保持しています。FInstancedStructとは結構異なる実装になっています。

FSharedStruct は 同一のメモリ領域を参照カウント方式で共有しています。FInstancedStruct をコピーするとメモリがDeepCopyされていましたが、こちらは同一の領域を共有します。BP型のように事前に型を決定できない状況でインスタンスを共有できます。

FSharedStructFConstSharedStructの違いはメモリ領域を書き換えられるかどうかだけです。
以降は FSharedStruct について解説します。

FSharedStructの作成

Make関数を使います。

FSharedStruct SharedStruct = FSharedStruct::Make<FFoo>(42);

InitializeAs関数を使ってもいいです。

FSharedStruct SharedStruct;
SharedStruct.InitializeAs<FFoo>();

どちらも、内部で MakeSharableを利用しています。

FSharedStructの破棄

Resetで明示的に破棄するか、デストラクタで破棄されます。operator=でも元の共有参照は破棄されます。破棄されると参照カウントが減ります。参照カウントが0になった時点で内部型T のデストラクタ~T()が実行されてFreeされます。

void Main()
{
    // 参照カウント1
    FSharedStruct SharedStruct = FSharedStruct::Make<FFoo>(42);

    // 参照カウントが2に増える
    FSharedStruct Shared2 = SharedStruct;

    {
        // 参照カウントが3に増える
        FSharedStruct Shared3 = SharedStruct;
        // 参照カウントが4に増える
        FSharedStruct Shared4 = Shared3;

        Shared4 = {}; // operator= により参照カウントが3に減る
    } // デストラクタで参照カウントが2に減る

    Shared2.Reset(); // 参照カウントが1に減る

}// 参照カウントが0になり~FFoo()を呼び出す&Freeされる

FSharedStructの読み書き

Get<T>GetPtr<T> で得ます。

TSharedPtr<T>と異なりポインタのように振る舞う型ではないので、operator->はありません。

void Main()
{
    FSharedStruct SharedStruct = FSharedStruct::Make<FFoo>(42);
    FFoo& Foo = SharedStruct.Get<FFoo>();
    Foo.Value = 43;

    FFoo* Foo = SharedStruct.GetPtr<FFoo>();
    Foo->Value = 44;
}

FSharedStructの比較

operator==でアドレス比較します。メモリ領域のデータ比較は行いません。nullptr含め同じアドレスを指していたら一致します。内部ではTSharedPtrの形で持っておりますが、参照カウンタ等は比較しませんし、TSharedPtroperator==も使いません。

疑似コード
TSharedPtr<FStructSharedMemory> Ptr;

bool operator==(const FSharedStruct& Other)
{
    // ポインタの比較
    return (Ptr->ScriptStruct == Other.Ptr->ScriptStruct)
        && (Ptr->Memory == Other.Ptr->Memory));
}

内部データの値が同一であることを期待するような稀有な場面では注意してください。例えば、TMapのキーやTSetに入れる場合、IDの照合に使う場合などです。どこのメモリ領域にあるかよりも、内容が同じであれば振る舞いとしては同値であってほしい場面も存在します。

USTRUCT() 
struct FFString
{
    GENERATED_BODY()
    FString Value;

    bool operator==(const FFString& Other) const
    {
        return Value == Other.Value;
    }
}

static bool CheckPassword(FSharedStruct& Lhs, FSharedStruct& Rhs)
{
    // アドレス比較だよ. パスワードをアドレスで比較しちゃダメだよ
    return Lhs == Rsh;
}

FSharedStruct SharedData1 = Make<FFString>(TEXT("Foobar"));
FSharedStruct SharedData2 = Make<FFString>(TEXT("Foobar"));

void Case0()
{
    bool Result = CheckPassword(SharedData1, SharedData2);

    // どちらも値は"Foobar"で合っているがメモリは異なる場所に確保されているのでfalse
    ensure(Result == false); 

    // ちゃんと共有していたらtrue
    FSharedStruct SharedData3 = SharedData;
    ensure(CheckPassword(SharedData3) == true);
}

void Case1()
{
    // Arrayから "Foobar"な値を探したい
    TArray<FSharedStruct> Datas;
    Datas.Emplace(SharedData1);
    Datas.Emplace(SharedData2);
    auto* Found = Datas.FindByPredicate(
        [Data4=FSharedStruct::Make<FFString>(TEXT("Foobar"))](FSharedStruct& Data)
    {
        return Data4 == Data;
    });

    //この実装では何も見つからないよ
    ensure(Found == nullptr);
}

この仕様は汎用共有参照型であるという観点から妥当なものだと思います。同一のメモリ領域を指しているのであれば、ちゃんと共有できており2つのインスタンスは同一であるとみなせますからね。

中身を比較したいなら中身の型を取り出して比較するべきでしょう。

bool CheckValueEqual(const FSharedStruct& Lhs, const FSharedStruct& Rhs)
{
    // FFString::operator== を明示的に利用
    bool ValueEqual = Lhs.Get<FFString>() == Rhs.Get<FFString>();
    return ValueEqual;
}

FSharedStructのハンドリング

共有参照が必要なときはconst&渡しがお勧めです。値渡しの場合は一時オブジェクトにより参照カウントが増えるので、無駄なオーバーヘッドが発生します。

const&ハンドリング
FSharedStruct SharedData;

// const& で受け取る
void ReceiveData_Good(const FSharedStruct& InSharedData) //ここで参照カウントは増えない
{
    this->SharedData = InSharedData;
}

// 値渡しでは引数に積まれるときに参照カウントが1増える
void ReceiveData_Bad(FSharedStruct InSharedData) //ここで参照カウント+1
{
    this->SharedData = InSharedData;
}// ここでInSharedDataのデストラクタが発動して参照カウント-1

参照カウントを+1して-1するだけの無駄なオーバーヘッドがいますね。

FSharedStructから Viewへの変換

FSharedStructFStructView/ FConstStructViewに変換して渡せます。implicit変換は無いので明示的にコンストラクタに渡します。

Viewへ変換
FSharedStruct SharedData;

void SimpleUseData(FStructView View)
{
    T& Data = View.Get<T>();
}

void Main()
{
    SimpleUseData(FStructView(SharedData));
}

値を使いたいだけならば、こちらでよいです。Viewを引数にとるシグネチャを持つ 関数やDelegate型に渡せて便利です。
逆にFStructViewから FSharedStructへは変換できませんから、所有権に興味がない場面においてはFStructViewの値渡しが適切です。

FSharedStructは UPROPERTY対応

FSharedStructUPROEPRTY対応です。TSharedPtr<T>UPROPERTYダメなので助かります。

UCLASS()
class UHoge : public UObject
{
    GENERATED_BODY()

private:
    UPROPERTY() FSharedStruct SharedData;
    UPROPERTY() FConstSharedStruct ConstSharedData;
}

なおEditAnywhereを付けても Detailsビューでは編集できませんでした。

FSharedStructBlueprint 非対応

BPダメです😿

Error : Type 'FSharedStruct' is not supported by blueprint.

// ダメ
UFUNCTION(BlueprintCallable)
void SetSharedData(const FSharedStruct& InSharedData);

// ダメ
UFUNCTION(BlueprintCallable)
void SetSharedData(FSharedStruct InSharedData);

FSharedStruct 詳解

本題です。FSharedStructの謎に迫ります。

FSharedStructは 共有ポインタを持つラッパーです。単純にTSharedPtr<FInstancedStruct>をラップしてしまうとダブルポインタ操作になってしまい、キャッシュミスが増えます。これを避けるための工夫が施されています。

色々省略すると次の通りになります。

疑似コード
struct FSharedStruct
{
    TSharedPtr<FStructSharedMemory> Memory;
}

本体はFStructSharedMemoryですね。

以下FStructSharedMemoryの疑似コードです。

疑似コード
struct FStructSharedMemory
{
    TObjectPtr<const UScriptStruct> ScriptStruct;
    uint8 Memory[0];
}

型情報とメモリ領域をもっており、FInstancedStructと同じ感じです。
データ領域はuint8*ポインタかと思いきや長さ0の配列を持っていますね。キモイですね。

こちらはFlexible array memberパターンというものです。C言語ではC99でサポートされていますが、C++では正式サポートされてたかよくわかりませんでした。でもまぁ動いているんでサポートされているんでしょう!(しらね)

フレキシブル配列ですが、次のような雰囲気でメモリ確保します。
理解を助けるために簡単化した疑似コードです。

疑似コード
//1. メモリ確保
const int32 ToalMemSize = sizeof(FStructSharedMemory) + sizeof(T);
uint8* AllocatedMemory = FMemory::Malloc(ToalMemSize);

//2. 制御領域のオブジェクト構築
new (AllocatedMemory) FStructSharedMemory();
FStructSharedMemory* SharedMem = reinterpret_cast<FStructSharedMemory>(AllocatedMemory);

//3. データ領域のオブジェクト構築
uint8* StructAreaMemory = SharedMem->Memory;
new (StructAreaMemory) T();

//4. SharedPtrを作る
TSharedPtr<FStructSharedMemory> Ptr = MakeSharable<FStructSharedMemory>(AllocatedMemory, CustomDeleter);
return FSharedStruct(Ptr); //インスタンスできた

制御ブロックも含めてメモリ確保

順番に解説します。
重要な点は どかんと連続した領域にメモリ確保していることです。

// 1.メモリ確保
const int32 ToalMemSize = sizeof(FStructSharedMemory) + sizeof(T);
uint8* AllocatedMemory = FMemory::Malloc(ToalMemSize);

まず、TotalMemSizeを求めましょう。
sizeof(TObjectPtr<T>)は8byteです。TObjectPtrは生ポとサイズが一致するように厳密に実装されているからです。次に、長さ0の配列uint8 Memory[0];は0byteです。
よってsizeof(FStructSharedMemory) は8+0=8byteです。

sizeof(T)の部分はアラインメントも考慮されて定まります。実際はUScriptStructから得られます。説明のために、仮に24byteであると仮定して話を進めます。TotalMemSizeは8+24=32byteとなりました。

以下に32byte確保した様子を図示します。
(パケット図は本当はbit表記だけどbyteで読んでください)

FMemory::Mallocで初期化なしに確保します。

制御領域のオブジェクト構築

次に配置newことplacement new でメモリ領域にオブジェクトを構築していきます。
構築は2回行います。最初は制御領域であるFStructSharedMemory部分を構築します。先頭アドレスから sizeof(FStructSharedMemory) 分構築します。sizeof(FStructSharedMemory) は8byteでしたね。

//2. 制御領域のオブジェクト構築
new (AllocatedMemory) FStructSharedMemory();
FStructSharedMemory* SharedMem = reinterpret_cast<FStructSharedMemory>(AllocatedMemory);

placement new によって下図のように8byte構築されました。

データ領域のオブジェクト構築

次にデータ領域を構築していきます。フレキシブル配列メンバーの場合、FStructSharedMemory::Memory[0]は8byteだけoffsetされた位置を差しています。
そこを T型で構築します。

//3. データ領域のオブジェクト構築
uint8* StructAreaMemory = SharedMem->Memory;
new (StructAreaMemory) T();

上記手順により AllcatedMemoryは無事構築されました。

TSharedPtr作って終わり

あとはTSharedPtrに渡すだけです。今回は特殊な初期化をしてしまったので、単純なdelete では解放できません。正しく実装したカスタムデリータを渡します。

//4. SharedPtrを作る
TSharedPtr<FStructSharedMemory> Ptr =
             MakeSharable<FStructSharedMemory>(AllocatedMemory, CustomDeleter);
return FSharedStruct(Ptr); //インスタンスできた

上図のFSharedStruct::TSharedPtr<T>::Ptrの部分が AllocatedMemoryの先頭を指しています。

カスタムデリータは本質ではないので、疑似コードです。Mallocの返り値をつかってFreeします。実際はラムダ式ではありませんが、雰囲気が分かればいいでしょう。

疑似コード
auto CustomDeleter = [=AllocatedMemroy]()
{
    FMemory::Free(AllocatedMemory);
};

なぜこんな面倒くさいことをしているのか

Flexible array memberパターンを利用することで、制御ブロック含め連続したメモリ領域に確保することでキャッシュヒット率をあげたいからです。素直に TSharedPtr<FInstancedStruct>を使ってしまうと、ダブルポインタのダブルデリファレンスにより2連続でload命令が発生する可能性がとても高いです。

  1. TSharedPtr<FInstancedStruct>::Ptr のデリファレンスとload命令
  2. FInstancedStruct::Memoryのデリファレンスとload命令

具体的にTSharedPtr<FInstancedStruct>を使ったときを考えます。

ダブルデリファレンス
TSharedPtr<FInstancedStruct> SharedPtr;
// 1回目のデリファレンス
FFoo* FooPtr = SharedPtr->GetPtr<FFoo>();
// 2回目のデリファレンス
(*FooPtr) = FFoo(100);

上記はそれぞれがMallocしているため、別々のヒープに確保される可能性が高いです。
SharedPtr::Ptrの指すアドレスが0x12345678_00000000だとしたらFooPtr の指すアドレスは0xdeadbeaf_ffffffffぐらい離れているかもわかりません。
L1キャッシュサイズよりも離れていたらL2キャッシュから、そこよりも離れていたらL3キャッシュ... とloadされるでしょう。間に4kテクスチャやvoiceデータが挟まっていたらどれだけ離れるか見当もつきません。

TSharedPtr<FInstancedStruct>
│
└── Ptr (0x12345678_00000000) ──▶ [FInstancedStruct]
                                     │
                                     └── Memory (0xdeadbeaf_ffffffff) [FFoo]

では FSharedStructの場合はどうでしょうか?

FSharedStruct SharedData = FSharedStruct::Make<FFoo>();
// 1回目のデリファレンス
FFoo* FooPtr = SharedData.GetPtr<FFoo>();
// 2回目のデリファレンス
(*FooPtr) = FFoo(100);

デリファレンス自体は2回発生していますね。ただし、2回目のデリファレンスでは、すぐ近くを指します。FSharedStruct::Ptrの指すアドレスが0x12345678_00000000だとしたらFooPtr の指すアドレスは0x12345678_00000008です。
これだけ近いとキャッシュラインにのっており、data prefetchにより一緒にload済みであろうから、L1キャッシュに高確率でキャッシュヒットするはず。あっという間にFFoo本体へ書き込みが実行されるでしょう。

FSharedStruct
│
└── Ptr (0x12345678_00000000) ──▶ [AllocatedMemory]
                                     │
                                     └── ScriptStruct (0x12345678_00000000) 
                                     └── Memory       (0x12345678_00000008) [FFoo]

TSharedStruct

TSharedStruct<T>FSharedStruct の型付け版です。
TInstancedStruct<T>の メモリ共有版とも言えます。

型付けの有無と, メモリ所有権の2軸で表にするとこんな感じ。

汎用 型付け
所有 FInstancedStruct TInstancedStruct<T>
共有 FSharedStruct TSharedStruct<T>

TSharedStruct<T>の作成

FSharedStructと全く一緒です。
新たな共有メモリを確保して、その領域を与えられた引数で構築します。引数は捨てて構いません。

初期化
TSharedStruct<FFoo> SharedData = TSharedStruct<FFoo>::Make();

TSharedStruct<FFoo> SharedData;
SharedData.InitializeAs<FFoo>();

APIも性能も全く一緒です。なぜならば、TSharedStruct::MakeTSharedStruct::InitializeAsは内部でFSharedStruct::Make, FSharedStruct::InitializeAsを使っているからです。

TSharedStructの破棄

FSharedStructと全く一緒です。

TSharedStructの読み書き

FSharedStructと一緒です。
静的型付けされているため、templateパラメータは省略可能です。

読み書き
TSharedStruct<FFoo> SharedData = TSharedStruct<FFoo>::Make();
// templateパラメータを省略するとデフォルトパラメータでFFooが渡される
FFoo& Foo = SharedData.Get(); 

型推論ではなくテンプレートパラメータのデフォルト引数機能です。

TSharedStructの比較

FSharedStructと全く一緒です。アドレス比較です。
固定のID型は共有しがちなので、やりがち。

怪しい例
USTRUCT() struct FFGuid{ GENERATED_BODY() FGuid Guid; }

// どちらも同じIDだけどfalseだよ
TSharedStruct<FFGuid> VenderID1 = TSharedStruct<FFGuid>::Make(1,2,3,4);
TSharedStruct<FFGuid> VenderID2 = TSharedStruct<FFGuid>::Make(1,2,3,4);
ensure(VenderID1 != VenderID2);

型付けされている分やりがちかも???こういうコードをAIに書かれたときに気づけるかどうか自信ないです。

怪しい例
using FStatusCode = TSharedStruct<FHttpStatus>;
static const FStatusCode NotFound = FStatusCode::Make(404);
void OnReceive(const FStatusCode& StatusCode)
{
    if(StatusCode == NotFound) // 論理的にbug
    {
        // 値が404でもアドレスが違うのでここには来ない...
    }
}

void Publish404()
{
    OnReceive(FStatusCode::Make(404));
}

TSharedStruct<T> 詳解

本題その2です。

FSharedStruct の説明と大体同じです。

TSharedStruct<T>FSharedStructとして扱われる

疑似コード
template<class T>
struct TSharedStruct
{
    FSharedStruct Data;
}

リフレクション層では、TSharedStructFSharedStructとして扱われます。
シリアライズなどもsupported です。

その他テンプレート的な特徴は TInstancedStruct<T> で説明したことと同じです。

TSharedStruct<T> は 標準レイアウト型

standard_layoutです。メモリレイアウトははっきりしています。すごい。

疑似コード
USTRUCT() struct FFoo{ GENERATED_BODY() int Value;}

// 全部OK
static_assert(std::is_standard_layout_v<FStructSharedMemory>);
static_assert(std::is_standard_layout_v<TSharedPtr<FStructSharedMemory>>);
static_assert(std::is_standard_layout_v<FSharedStruct>);
static_assert(std::is_standard_layout_v<TSharedStruct<FFoo>>);

standard_layoutだと何がうれしいのかというと、

  1. EBO:Empty base optimizationの条件を1つ満たす
  2. thisreinterpret_castで最初の非静的メンバーを指すポインタへ合法的に変換できる
  3. offsetof が合法的に使える
  4. ABI:Application Binary Interfaceを満たす

TSharedStruct<T>は なぜFSharedStructとして扱っていいのかという答えがここにあります。

reinterpret_castで最初の非静的メンバーを指すポインタへ合法的に変換できる

first non-static data member
struct TSharedStruct
{
    FSharedStruct Struct;
}

void Main()
{
    TSharedStruct<FFoo> Data;

    //これは当然
    FSharedStruct* Ptr0 = &Data.Struct;

    // standard_layoutなら合法
    // オブジェクトのfirst non-static data memberへはreinterpret_castできる
    FSharedStruct* Ptr1 = reinterpret_cast<FSharedStruct*>(&Data);
    ensure(Ptr0 == Ptr1);
}

共有参照カウント式スマートポインタ対応表

FSharedStructの登場により対応表が出揃いました。

ベース型 共有コンテナ型
UObject* TStrongObjectPtr<UObject>
USTRUCT* FSharedStruct
Native* TSharedPtr<FNative>

UObject* に対する参照カウント方式共有参照は TStrongObjectPtr<T>
USTRUCT* に対する参照カウント方式共有参照は FSharedStruct
Native型に対する参照カウント方式共有参照は TSharedPtr<T>です。


つづく

GitHubで編集を提案

Discussion