可可西

UE4之FMemStack内存管理机制

示例代码:

{
FMemMark
Mark(FMemStack::Get()); TArray
<int32, SceneRenderingAllocator> Values; Values.Add(10); Values.Add(20); ... // 期间可能触发扩容 // 作用域结束,Mark 析构
}

 

大体流程如下:

image

偏移指针,即可从chunk上分配得到内存

image

 

完整的时序图如下:

image

时序图详解

假设这个数组在 Mark 作用域里经历了:

  • 第一次 Add
  • 一次扩容
  • 最后 Mark.Pop()

阶段 0:刚进入作用域

MemStack 当前 Chunk:

| 之前已有临时数据 |.......................空闲.......................|
                 ^
                Top

Mark 记录:
- SavedChunk = 当前 Chunk
- SavedTop   = 当前 Top

阶段 1:第一次 Add,容量不够,分配首块数组内存

Values.Add(10)

| 之前已有临时数据 | A0[capacity=1 或更大] |........空闲........|
                                      ^
                                     Top

A0:
Data ---> [10]
Num = 1
Max = 初始增长后的容量

 

阶段 2:继续 Add,暂时不扩容

Values.Add(20)

| 之前已有临时数据 | A0[capacity=4 假设] |........空闲........|
                                      ^
                                     Top

A0:
Data ---> [10][20][?][?]
Num = 2
Max = 4

这时:

  • 没有新申请
  • 只是把新元素直接构造到 A0 后面

阶段 3:再次 Add,A0 容量耗尽,触发扩容

Values.Add(30)  // 假设这次触发 grow

扩容前:
| 之前已有临时数据 | A0[10][20] |........空闲........|
                               ^
                              Top

扩容后:
| 之前已有临时数据 | A0旧块 | A1新块(更大容量) |....空闲....|
                                                ^
                                               Top

 

注意这里最关键的点:

  • A1 是重新从 MemStack 申请的新块
  • 然后把 A0 里的旧数据 Memcpy 到 A1
  • Values.Data 改为指向 A1
  • A0 旧块不会立即 free

所以扩容后的真实情况是:

A0: 旧数据块,已经废弃,但还占着 MemStack 空间
A1: 当前正在使用的新数据块

Values.Data ---> A1

 

阶段 4:如果又发生一次扩容

那会变成:

| 之前已有临时数据 | A0旧 | A1旧 | A2当前新块 |....空闲....|
                                                   ^
                                                  Top

 

也就是说,SceneRenderingAllocator 的扩容路径不是:

  • 原地扩
  • 或释放旧块再换新块

而是:

  • 一路往前“堆”新块
  • 等 FMemMark 统一回收

阶段 5:作用域结束,FMemMark::Pop()

Pop() 前:
| 之前已有临时数据 | A0旧 | A1旧 | A2当前 |....空闲....|
                                                   ^
                                                  Top

Pop() 后:
| 之前已有临时数据 |.......................空闲.......................|
                 ^
                Top(回到 SavedTop)

结果:

  • A0 / A1 / A2 这几块全都一起失效
  • Values.Data 成为悬空指针
  • 所以这种数组只能在 Mark 作用域内使用

 

对应到源码调用链

1. SceneRenderingAllocator 其实就是 TMemStackAllocator<>

/*UnrealEngine\Engine\Source\Runtime\RenderCore\Public\RendererInterface.h*/
// Shortcut for the allocator used by scene rendering.
typedef TMemStackAllocator<> SceneRenderingAllocator;

 

2. Add() 实际走到 Emplace()

// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h
FORCEINLINE SizeType Add(ElementType&& Item)
{
    CheckAddress(&Item);
    return Emplace(MoveTempIfPossible(Item));
}

/**
 * Adds a new item to the end of the array, possibly reallocating the whole array to fit.
 *
 * @param Item The item to add
 * @return Index to the new item
 * @see AddDefaulted, AddUnique, AddZeroed, Append, Insert
 */
FORCEINLINE SizeType Add(const ElementType& Item)
{
    CheckAddress(&Item);
    return Emplace(Item);
}

3. Emplace() 会先 AddUninitialized(1)

// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h
template <typename... ArgsType>
FORCEINLINE SizeType Emplace(ArgsType&&... Args)
{
    const SizeType Index = AddUninitialized(1);
    new(GetData() + Index) ElementType(Forward<ArgsType>(Args)...);
    return Index;
}

对 int32 来说,这一步可以理解成“在目标位置直接构造一个整数值”。

4. AddUninitialized() 里判断是否需要扩容

// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h
FORCEINLINE SizeType AddUninitialized(SizeType Count = 1)
{
    CheckInvariants();
    checkSlow(Count >= 0);

    const SizeType OldNum = ArrayNum;
    if ((ArrayNum += Count) > ArrayMax)
    {
        ResizeGrow(OldNum);
    }
    return OldNum;
}

 

5. 触发扩容时,TArray 调用 allocator 的 ResizeAllocation

// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h
FORCENOINLINE void ResizeGrow(SizeType OldNum)
{
    ArrayMax = AllocatorInstance.CalculateSlackGrow(ArrayNum, ArrayMax, sizeof(ElementType));
    AllocatorInstance.ResizeAllocation(OldNum, ArrayMax, sizeof(ElementType));
}
FORCENOINLINE void ResizeShrink()
{
    const SizeType NewArrayMax = AllocatorInstance.CalculateSlackShrink(ArrayNum, ArrayMax, sizeof(ElementType));
    if (NewArrayMax != ArrayMax)
    {
        ArrayMax = NewArrayMax;
        check(ArrayMax >= ArrayNum);
        AllocatorInstance.ResizeAllocation(ArrayNum, ArrayMax, sizeof(ElementType));
    }
}
FORCENOINLINE void ResizeTo(SizeType NewMax)
{
    if (NewMax)
    {
        NewMax = AllocatorInstance.CalculateSlackReserve(NewMax, sizeof(ElementType));
    }
    if (NewMax != ArrayMax)
    {
        ArrayMax = NewMax;
        AllocatorInstance.ResizeAllocation(ArrayNum, ArrayMax, sizeof(ElementType));
    }
}

 

6. TMemStackAllocator 真正向 FMemStack 申请内存

// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h
void ResizeAllocation(SizeType PreviousNumElements, SizeType NumElements,SIZE_T NumBytesPerElement)
{
    void* OldData = Data;
    if( NumElements )
    {
        // Allocate memory from the stack.
        Data = (ElementType*)FMemStack::Get().PushBytes(
            (int32)(NumElements * NumBytesPerElement),
            FMath::Max(Alignment,(uint32)alignof(ElementType))
            );

        // If the container previously held elements, copy them into the new allocation.
        if(OldData && PreviousNumElements)
        {
            const SizeType NumCopiedElements = FMath::Min(NumElements,PreviousNumElements);
            FMemory::Memcpy(Data,OldData,NumCopiedElements * NumBytesPerElement);
        }
    }
}

 

这段代码直接说明:

  • 新内存来自 FMemStack::Get()
  • 旧数据靠 Memcpy
  • 没有单独 free OldData

7. FMemStack 是当前线程的栈式分配器

// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h
class CORE_API FMemStack : public TThreadSingleton<FMemStack>, public FMemStackBase
{
};

它不是“全局共享堆”,而是每个线程一个栈式分配器。

 

8. FMemStack 的分配就是“Top 指针前移”

// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h
FORCEINLINE uint8* PushBytes(int32 AllocSize, int32 Alignment)
{
    return (uint8*)Alloc(AllocSize, FMath::Max(AllocSize >= 16 ? (int32)16 : (int32)8, Alignment));
}

FORCEINLINE void* Alloc(int32 AllocSize, int32 Alignment)
{
    // Debug checks.
    checkSlow(AllocSize>=0);
    checkSlow((Alignment&(Alignment-1))==0);
    checkSlow(Top<=End);
    checkSlow(NumMarks >= MinMarksToAlloc);

    // Try to get memory from the current chunk.
    uint8* Result = Align( Top, Alignment );
    uint8* NewTop = Result + AllocSize;

    // Make sure we didn't overflow.
    if ( NewTop <= End )
    {
        Top    = NewTop;
    }
    else
    {
        // We'd pass the end of the current chunk, so allocate a new one.
        AllocateNewChunk( AllocSize + Alignment );
        Result = Align( Top, Alignment );
        NewTop = Result + AllocSize;
        Top = NewTop;
    }
    return Result;
}

 

当当前 chunk 放不下时,会新建 chunk:

// UnrealEngine\Engine\Source\Runtime\Core\Private\Misc\MemStack.cpp
void FMemStackBase::AllocateNewChunk(int32 MinSize)
{
    FTaggedMemory* Chunk=nullptr;
    // Create new chunk.
    int32 TotalSize = MinSize + (int32)sizeof(FTaggedMemory);
    uint32 AllocSize;
    if (TopChunk || TotalSize > FPageAllocator::SmallPageSize)
    {
        AllocSize = AlignArbitrary<int32>(TotalSize, FPageAllocator::PageSize);
        if (AllocSize == FPageAllocator::PageSize)
        {
            Chunk = (FTaggedMemory*)FPageAllocator::Get().Alloc();
        }
        else
        {
            Chunk = (FTaggedMemory*)FMemory::Malloc(AllocSize);
            INC_MEMORY_STAT_BY(STAT_MemStackLargeBLock, AllocSize);
        }
        check(AllocSize != FPageAllocator::SmallPageSize);
    }
    else
    {
        AllocSize = FPageAllocator::SmallPageSize;
        Chunk = (FTaggedMemory*)FPageAllocator::Get().AllocSmall();
    }
    Chunk->DataSize = AllocSize - sizeof(FTaggedMemory);

    Chunk->Next = TopChunk;
    TopChunk    = Chunk;
    Top         = Chunk->Data();
    End         = Top + Chunk->DataSize;
}

 

所以可以把 MemStack 理解成:

  • 不是一块无限连续内存
  • 而是由多个 chunk 串起来
  • 每个 chunk 内部按 Top -> End 线性消费

9. FMemMark 负责批量回收

// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h
class FMemMark
{
public:
    // Constructors.
    FMemMark(FMemStackBase& InMem)
    :    Mem(InMem)
    ,    Top(InMem.Top)
    ,    SavedChunk(InMem.TopChunk)
    ,    bPopped(false)
    ,    NextTopmostMark(InMem.TopMark)
    {
        Mem.TopMark = this;

        // Track the number of outstanding marks on the stack.
        Mem.NumMarks++;
    }

    /** Destructor. */
    ~FMemMark()
    {
        Pop();
    }

    /** Free the memory allocated after the mark was created. */
    void Pop()
    {
        if(!bPopped)
        {
            check(Mem.TopMark == this);
            bPopped = true;

            // Track the number of outstanding marks on the stack.
            --Mem.NumMarks;

            // Unlock any new chunks that were allocated.
            if( SavedChunk != Mem.TopChunk )
            {
                Mem.FreeChunks( SavedChunk );
            }

            // Restore the memory stack's state.
            Mem.Top = Top;
            Mem.TopMark = NextTopmostMark;

            // Ensure that the mark is only popped once by clearing the top pointer.
            Top = nullptr;
        }
    }

 

时序图总结

1. Add() 本身不神秘

就是:

  • 增 ArrayNum
  • 不够就扩容
  • 在尾部构造新元素

2. SceneRenderingAllocator 扩容不是 realloc

而是:

  • 从 FMemStack 再切一块新内存
  • Memcpy 旧数据
  • 指针改到新块

3. 旧块不会立即释放

所以多次扩容会出现:

旧块1 + 旧块2 + 当前块

 

都同时挂在 MemStack 里。

4. 真正回收发生在 FMemMark::Pop()

一旦 Mark 弹栈:

  • 当前数组内存失效
  • 同一作用域里所有 memstack 临时分配一起失效

 

5. 归纳说明

 SceneRenderingAllocator(TMemStackAllocator)是建立在 FMemStack(FMemStackBase)之上的TArray / TSet / TMap 等容器分配器策略;

它自己不管理内存池,只把容器的内存申请转发给当前线程的 FMemStack(FMemStackBase)

FMemStackBase作为底层实现者,负责:

  • chunk 管理
  • Top/End 指针推进
  • 不够时分配新 chunk
  • 回退时释放多余 chunk

FMemStack是FMemStackBase的线程局部版本。

  • 每个线程有自己的 FMemStack
  • FMemStack::Get() 取到的是当前线程的 memstack

生命周期上,谁负责释放?回收则依赖 FMemMark 批量完成。

 

代码里经常先 Reserve()

因为如果不先预估容量,后面每次扩容都会:

  • 申请新块
  • Memcpy
  • 把旧块留在 memstack 里直到作用域结束

这在渲染阶段会额外浪费临时内存,还可能引入并行阶段的重分配风险。

你当前打开的 SceneVisibility.cpp 就有很典型的注释:

// The dirty list allocation must take into account the max possible size because when GILCUpdatePrimTaskEnabled is true,
// the indirect lighting cache will be update on by threaded job, which can not do reallocs on the buffer (since it uses the SceneRenderingAllocator).
View.DirtyIndirectLightingCacheBufferPrimitives.Reserve(Scene->Primitives.Num());

所以这类数组的最佳实践是:

  1. 先估最大数量
  2. 一次 Reserve 到位
  3. 后面只做 Add/Push,尽量不触发扩容

 

优点

1. 分配非常快

本质是“指针前移”,开销远小于通用堆分配。

2. 几乎没有传统意义上的堆碎片

因为不是到处 malloc/free,而是线性推进。

3. 批量释放极高效

FMemMark::Pop() 一次恢复指针/回收 chunk。

4. 很适合“一帧临时数据”

比如:

  • 可见性结果
  • pass 构建中间数组
  • 临时 draw command 列表
  • transient primitive/light list

 

代价

1. 扩容成本偏高

因为每次扩容都是:

  • 新申请
  • Memcpy
  • 旧块挂着不释放

2. Shrink() 不是真正回收

它只是给数组换一块更小的新内存; 旧块仍然占用到当前 mark 结束。

3. 不能把它当长期容器

如果对象活得太久,就会让 MemStack 内存长期滞留。

4. 跨线程 realloc 风险大

因为 FMemStack 是线程局部。 如果某个 TArray<..., SceneRenderingAllocator> 在别的线程触发重新分配, 会落到另一个线程的 memstack 上,生命周期/线程归属都会出问题。

 

其他示例代码

/** UnrealEngine\Engine\Source\Runtime\RHI\Public\RHICommandList.h */
// Stack allocate the transition
FMemStack& MemStack = FMemStack::Get();
FMemMark Mark(MemStack);
FRHITransition* Transition = new (MemStack.Alloc(FRHITransition::GetTotalAllocationSize(), FRHITransition::GetAlignment())) FRHITransition(Pipeline, Pipeline);
GDynamicRHI->RHICreateTransition(Transition, Pipeline, Pipeline, ERHICreateTransitionFlags::NoSplit, Infos);
 

 

posted on 2026-03-20 11:37  可可西  阅读(1)  评论(0)    收藏  举报

导航