UE UnrealInsights 使用与实现
Unreal Insights使用UnrealInsights是UE的新一代Profile东西,用于替代原先的Stats,比如Frontend。它可以Profile CPU,GPU,Network,Animation,Slate,功能更强大,界面也更友好易用。斗劲常用的是CPU Profile,界面如下:
前两个函数用于更新持续状态等内容,可不管,重点看后两个函数。于浏览profile数据。
关于使用方式,官方文档中有详细的介绍
命令行启动
添加-trace= -statnamedevents -tracehost=127.0.0.1
当地先启动UnrealInsights处事器,然后启动游戏就能连上
一个例子如下,这几乎是最详细的标签了
-trace=CPU,Log,Bookmark,Frame,GPU,LoadTime,File,Net,Stats,Counters -statnamedevents -tracehost=127.0.0.1
UE有快速Prfile方式,Windows环境下,只需先打开UnrealInsights.exe,再打开standalone游戏,就可以进行profile,不需要额外命令行。
标识表记标帜
Unreal Insight可以使用以下标识表记标帜
TRACE_CPUPROFILER_EVENT_SCOPE(xxx)
TRACE_BOOKMARK(TEXT(”xxx %s ”), string)
也撑持现有stats标识表记标帜
SCOPE_CYCLE_COUNTER(xxxxx)
Channel
Channel暗示要Profile哪些类目,比如CpuChannel对应RACE_CPUPROFILER_EVENT_SCOPE标签,CpuChannel打开后该标签才会起效。
Channel通过命令行参数-trace配置,比如-trace=channel1,channel2…
XXXEngine.ini可以配置默认Channel,如果-trace没有跟Channel,就用Presets
Default=cpu,frame,log,bookmark
Rendering=gpu,cpu,frame,log,bookmark
其他Insights
Unreal Insights主要用于Profile CPU,UE还提供了其他几种Insights东西,用来Profile分歧部门。
Networking Insights
用于Profile网络数据流。可以看到网络流量变化以及数据包具体内容,比如Actor同步数据,同步了哪个Actor,属性同步数据,同步了哪些属性。
使用方式
-NetTrace=1 -tracehost=localhost -trace=net
其他还有Animation Insights和Slate Insights,就不外多介绍了,官方文档有更详细的说明。
Unreal Insights道理
开启流程
开启Unreal Insights主要使用FTraceAuxiliaryImpl类,该类负责解析命令行,控制连接处事器和打开insight文件,以及Unreal Insights的开启/封锁。
FTraceAuxiliaryImpl
主要属性
Tmap<uint32, FChannel> Channels 当前要profile的channel,比如cpu,frame,network等等,key为channel name的hash。注意此处的Channel只是真正Profile所使用Trace::Channel的handler,它们是两个类型,改削它们的Active属性后,要同步到“真正”的Channel实例。
TraceDest 连接的处事器地址或写入的文件名
初始开启
FEngineLoop::PreInitPreStartupScreen函数调用FTraceAuxiliary::Initialize函数,传入CmdLine,开启UnrealInsight。
FTraceAuxiliary::Initialize()
首先,把法式简要信息写入Buffer,包罗Platform,AppName等。
UE_TRACE_LOG(Diagnostics, Session2, Trace::TraceLogChannel)
<< Session2.Platform(PREPROCESSOR_TO_STRING(UBT_COMPILED_PLATFORM))
<< Session2.AppName(UE_APP_NAME)
<< Session2.CommandLine(CommandLine)
<< Session2.ConfigurationType(uint8(FApp::GetBuildConfiguration()))
<< Session2.TargetType(uint8(FApp::GetBuildTargetType()));接着解析-trace=channel1,channel2…参数中指定的Channel,把它们插手到Channels map中。每个Channel都有Active状态,开启后才会收集该Channel的数据,默认开启。如果用户没有提供Channel,UE会去Engine.ini获取Trace.ChannelPresets指定的预置Channel,默认是cpu,frame,log,bookmark。
之后解析-tracehost和-tracehost,判断要把profile数据发到某个insight处事器还是写入某个文件。
[*]连接处事器会创建一条TCP连接,过程是阻塞的,可指定对方ip:port,如不指定port就用默认的1980端口。
[*]写入文件可指定path和文件名,如果是相对路径,会写入Profiling目录,如果没特殊需求,相对路径就够了。写文件之前要把得到的/Game/….路径转换为各平台的路径,比如安卓就是/sdcard/…开头的路径,之后以write模式打开文件,筹备写入。
到这里初始化unreal insights读取命令行初始化就完成了,我们已配置channel,成立处事器连接或打开了文件。
windows缺省开启
我们大部门情况使用windows开发调试,因此UE为我们提供了windows缺省开启模式,只要当地打开insight客户端,再开游戏,就会使用默认Channel,自动连接,便利了profile。
Channel定义
Channel使用UE_TRACE_CHANNEL_DEFINE定义,例如CpuChannel
UE_TRACE_CHANNEL_DEFINE(CpuChannel)
创建名为CpuChannelObject的FChannel实例,作为静态变量,然后创建名为CpuChannel的引用对象。之后创建名为FCpuChannelRegister的布局体和实例,主要感化为调用CpuChannelObject.Setup函数进行注册。Channel新建后会保留在GNewChannelList链表中,Channel信息被WriterBuffer发送之后,链表被清空。
FChanne::Toggle函数的坑
不雅察看FChannel,发现有Toggle函数可以开启/封锁某个Channel,但其底层使用了原子P/V操作来改削Enabled,Enabled是int类型而不是bool。
bool FChannel::Toggle(bool bEnabled)
{
using namespace Private;
int64 OldRefCnt = AtomicAddRelaxed(&Enabled, bEnabled ? 1 : -1);
UE_TRACE_LOG(Trace, ChannelToggle, TraceLogChannel)
<< ChannelToggle.Id(Name.Hash)
<< ChannelToggle.IsEnabled(IsEnabled());
return IsEnabled();
}意味着要小心Toggle()函数执行的次数,如果执行序列未Toggle(false), Toggle(false), Toggle(true),那么值还是false。
收集Profile数据
收集Profile数据是Unreal Insights最重要的部门。
创建profile标签
我们可以使用宏创建新的profile标签,常用的为TRACE_CPUPROFILER_EVENT_SCOPE(Name),可以在函数的第一行添加它,这样就记录了函数的总执行时间。
宏最终展开内容如下:
#define TRACE_CPUPROFILER_EVENT_SCOPE_ON_CHANNEL_STR(NameStr, Channel) \
static uint32 PREPROCESSOR_JOIN(__CpuProfilerEventSpecId, __LINE__); \
if (bool(Channel|CpuChannel) && PREPROCESSOR_JOIN(__CpuProfilerEventSpecId, __LINE__) == 0) { \
PREPROCESSOR_JOIN(__CpuProfilerEventSpecId, __LINE__) = FCpuProfilerTrace::OutputEventType(NameStr); \
} \
FCpuProfilerTrace::FEventScope PREPROCESSOR_JOIN(__CpuProfilerEventScope, __LINE__)(PREPROCESSOR_JOIN(__CpuProfilerEventSpecId, __LINE__), Channel);PREPROCESSOR_JOIN宏把两个参数拼接到一起,因此创建了名为__CpuProfilerEventSpecIdXXX的uint32变量,注意这是一个static变量,每个标签都对应一个,可以称为SpecId。之后判断该变量是否为0,即我们是否第一次进到这个标签,如果是第一次,要使用FCpuProfilerTrace::OutputEventType()函数获取新的Id,Id是自增的且可多线程访谒。期间还有UE_TRACE_LOG写入操作,这个下文再介绍。
最后创建FCuProfilerTrace::FEventScope对象,该对象的生命周期就是我们的profile周期,构造函数开始profile,析构函数结束profile。
FEventScope
struct FEventScope
{
FEventScope(uint32 InSpecId, const Trace::FChannel& Channel)
: bEnabled(Channel | CpuChannel)
{
if (bEnabled)
{
OutputBeginEvent(InSpecId);
}
}
~FEventScope()
{
if (bEnabled)
{
OutputEndEvent();
}
}
bool bEnabled;
};注意到无论构造函数还是析构函数,城市先判断bEnabled,而bEnabled由Chennel值决定。我们可以半途通过Trace.Start和Trace.Stop来动态改变Channel值,使后面代码不走,这样就能按需开启/封锁Profile数据收集了,但打开的TCP连接和文件描述符,还保留着。
构造函数
先看构造函数中的OutputBeginEvent,InSpecId就是我们之前创建的静态变量SpecId,函数内部又是两个宏。
#define CPUPROFILERTRACE_OUTPUTBEGINEVENT_PROLOGUE() \
++FCpuProfilerTraceInternal::ThreadDepth; \
FCpuProfilerTraceInternal::FThreadBuffer* ThreadBuffer = FCpuProfilerTraceInternal::ThreadBuffer; \
if (!ThreadBuffer) \
{ \
ThreadBuffer = FCpuProfilerTraceInternal::CreateThreadBuffer(); \
} \
#define CPUPROFILERTRACE_OUTPUTBEGINEVENT_EPILOGUE() \
uint64 Cycle = FPlatformTime::Cycles64(); \
uint64 CycleDiff = Cycle - ThreadBuffer->LastCycle; \
ThreadBuffer->LastCycle = Cycle; \
uint8* BufferPtr = ThreadBuffer->Buffer + ThreadBuffer->BufferSize; \
FTraceUtils::Encode7bit((CycleDiff << 1) | 1ull, BufferPtr); \
FTraceUtils::Encode7bit(SpecId, BufferPtr); \
ThreadBuffer->BufferSize = (uint16)(BufferPtr - ThreadBuffer->Buffer); \
if (ThreadBuffer->BufferSize >= FCpuProfilerTraceInternal::FullBufferThreshold) \
{ \
FCpuProfilerTraceInternal::FlushThreadBuffer(ThreadBuffer); \
}
void FCpuProfilerTrace::OutputBeginEvent(uint32 SpecId)
{
CPUPROFILERTRACE_OUTPUTBEGINEVENT_PROLOGUE();
CPUPROFILERTRACE_OUTPUTBEGINEVENT_EPILOGUE();
}CPUPROFILERTRACE_OUTPUTBEGINEVENT_PROLOGUE
首先递增ThreadDepth,它是ThreadLocal变量,用于暗示当前线程的CPUPROFILETRACE深度。之后按需创建同样是ThreadLocal变量的ThreadBuffer,用于记录Profile数据。
FThreadBuffer
FThreadBuffer可以理解为一块内存,用于存储多个EventScope的相关数据。
主要属性:
uint64 LastCycle 最后一次记录的时钟周期
uint8 Buffer 存储数据的Buffer,MaxBufferSize默认256
uint16 BufferSize 当前存储数据的size
类FCpuProfilerTraceInternal的静态成员ThreadBuffer就是当前线程所使用的ThreadBuffer。
CPUPROFILERTRACE_OUTPUTBEGINEVENT_EPILOGUE
之后获取当前CPU时钟tick计数,需要高精度,各平台实现分歧,比如Windows使用QueryPerformanceCounter,精度高于1微妙,ios平台则使用mach_absolute_time内核接口。得到tick计数后,只要计算与上次记录计数的Diff,然后把Diff和SpecId写入ThreadBuffer即可,因为后面计数会重置,以减小内存消耗。
注意到写入使用了Encode7bit接口,这是一种变长编码实现,可以节省内存。一个Byte只有7为存储数据,最高位暗示当前值是否还有后续部门待读取。举个例子:
存储127需要一个Byte
01111 1111
存储128需要两个Byte
1111 1111 0000 0001
这种变长编码被广泛应用于Protobuf等序列化框架中。
当ThreadBuffer长度超过了FullBufferThreshold,默认256-15,需要执行一次flush,使用UE_TRACE_LOG写入另一个区域,然后重置ThreadBuffer的Buffer和LastCycle。
析构函数
再看析构函数,其与构造函数对应,得到当前tick计数的Diff,然后写入ThreadBuffer,注意此次不需要写入id了。同样的,Buffer超阈值,或者Depth退回到0,要flush。
UE_TRACE_LOG
Profile数据最终会通过UE_TRACE_LOG进行写入,此刻分析它是如何工作的。
首先看生成SpecId时写入的例子:
UE_TRACE_LOG(CpuProfiler, EventSpec, CpuChannel, NameSize)
<< EventSpec.Id(SpecId)
<< EventSpec.CharSize(uint8(sizeof(TCHAR)))
<< EventSpec.Attachment(Name, NameSize);不是很直不雅观。。
还是需要先把宏都展开
第一步
#define UE_TRACE_LOG(LoggerName, EventName, ChannelsExpr, ...) TRACE_PRIVATE_LOG(LoggerName, EventName, ChannelsExpr, ##__VA_ARGS__)UE_TRACE_LOG的前3个参数为Loggername,EventName,ChannelsExpr,最后一个参数为额外内存大小,这里是NameSize,用于之后存储Name。
Loggername不是一个独立的类型名或变量名,重点存眷EventSpec和ChannlExpr,对应上面例子,就是EventSpec,CpuChannel,看下它们是怎么来的。
EventSpec定义
UE_TRACE_EVENT_BEGIN(CpuProfiler, EventSpec, Important)
UE_TRACE_EVENT_FIELD(uint32, Id)
UE_TRACE_EVENT_FIELD(uint8, CharSize)
UE_TRACE_EVENT_END()这会创建一个名为CpuProfilerEventSpecEvent的FEventNode实例,以及名为FCpuProfilerEventSpecFields的布局体。
第二步
#define TRACE_PRIVATE_LOG(LoggerName, EventName, ChannelsExpr, ...) \
TRACE_PRIVATE_LOG_PRELUDE(Enter, LoggerName, EventName, ChannelsExpr, ##__VA_ARGS__) \
TRACE_PRIVATE_LOG_EPILOG()
#define TRACE_PRIVATE_LOG_PRELUDE(EnterFunc, LoggerName, EventName, ChannelsExpr, ...) \
if (TRACE_PRIVATE_CHANNELEXPR_IS_ENABLED(ChannelsExpr)) \
if (auto LogScope = Trace::Private::TLogScope<F##LoggerName##EventName##Fields>::EnterFunc(__VA_ARGS__)) \
if (const auto& __restrict EventName = *(F##LoggerName##EventName##Fields*)LogScope.GetPointer())这里会调用TLogScope<FCpuProfilerEventSpecFields>::Enter函数,创建一个FLogScope实例。
template <class T>
auto TLogScope<T>::Enter(uint32 ExtraSize)
{
uint32 Size = T::GetSize() + ExtraSize;
uint32 Uid = T::GetUid();
using LogScopeType = typename TLogScopeSelector<T::bIsImportant>::Type;
return LogScopeType::template Enter<T::EventFlags>(Uid, Size);
}Size包罗了成员变量大小,例子中为Id和CharSize的大小,之后调用Enter函数返回FLogScope实例或FImportantLogScope实例,后者只是简单担任了一下。
class FLogScope
{
public:
template <uint32 Flags>
static FLogScope Enter(uint32 Uid, uint32 Size);
uint8* GetPointer() const;
void Commit() const;
void operator += (const FLogScope&) const;
const FLogScope& operator << (bool) const { return *this; }
constexpr explicit operator bool () const { return true; }
private:
template <class T> void EnterPrelude(uint32 Size, bool bMaybeHasAux);
void Enter(uint32 Uid, uint32 Size, bool bMaybeHasAux);
void EnterNoSync(uint32 Uid, uint32 Size, bool bMaybeHasAux);
struct
{
uint8* Ptr;
FWriteBuffer* Buffer;
} Instance;
};最后进入到FLogScope::Enter函数,这里简单起见,只看FEventHeader版本。EnterPrelude函数中会获取当前线程的GTlsWriteBuffer,该Buffer用于实际数据写入,Buffer会预留FCpuProfilerEventSpecFields大小+Fields大小+NameSize(UE_TRACE_LOG最后一个参数)的内存,然后把Size和Uid写入Buffer。
inline void FLogScope::EnterNoSync(uint32 Uid, uint32 Size, bool bMaybeHasAux)
{
EnterPrelude<FEventHeader>(Size, bMaybeHasAux);
// Event header
auto* Header = (uint16*)(Instance.Ptr);
Header[-1] = uint16(Size);
Header[-2] = uint16(Uid)|int(EKnownEventUids::Flag_TwoByteUid);
}
template <class HeaderType>
inline void FLogScope::EnterPrelude(uint32 Size, bool bMaybeHasAux)
{
uint32 AllocSize = sizeof(HeaderType) + Size + int(bMaybeHasAux);
FWriteBuffer* Buffer = Writer_GetBuffer();
Buffer->Cursor += AllocSize;
if (UNLIKELY(Buffer->Cursor > (uint8*)Buffer))
{
Buffer = Writer_NextBuffer(AllocSize);
}
// The auxilary data null terminator.
if (bMaybeHasAux)
{
Buffer->Cursor[-1] = 0;
}
uint8* Cursor = Buffer->Cursor - Size - int(bMaybeHasAux);
Instance = {Cursor, Buffer};
}接着执行下面的宏,FLogScope重载了+=操作符,会执行FLogScope::Commit函数,感化为把Buffer当前Cursor更新到Buffer.Commit,因此称为“提交”。
#define TRACE_PRIVATE_LOG_EPILOG() \
LogScope += LogScope
inline void FLogScope::Commit() const
{
FWriteBuffer* Buffer = Instance.Buffer;
AtomicStoreRelease((uint8**) &(Buffer->Committed), Buffer->Cursor);
}到这里,我们看完了第一个UE_TRACE_LOG执行的操作,最后会得到一个FLogScope对象,该对象关联了Buffer,以及一个FCpuProfilerEventSpecFields实例指针,名为EventSpec。
下面是有些怪异的<<操作符,它并不会真正输出什么,只是逐个初始化EventSpec的属性,内存地址就是Buffer中预留的FCpuProfilerEventSpecFields。最后的Attachment函数用于外挂一些未声明的其他属性。
一番操作后,Buffer内容如下:
不妨称一次UE_TRACE_LOG写入Buffer的内容称为一个“EventBlock”,可以认为是最小写入单元。
接下来看FlushThreadBuffer中的UE_TRACE_LOG使用
UE_TRACE_LOG(CpuProfiler, EventBatch, true, InThreadBuffer->BufferSize)
<< EventBatch.Attachment(InThreadBuffer->Buffer, InThreadBuffer->BufferSize);与之前例子类似,但这里EventBatch没有成员变量,只申请了BufferSize,然后把Buffer作为attachment数据写入。
此时写入的EventBlock内容:
ThreadBuffer很小,因此要多次写入才能填满一个TlsBuffer,写入操作其实就是memorycpy了ThreadBuffer的内存。
Uid
注意到每个Block开头都是Uid,它是每个FXXXFields布局比如FCpuProfilerEventSpecFields的标识符,一个FXXXFields由LoggerName和EventName独一确定。在使用UE_TRACE_EVENT_BEGIN创建FXXXFields时,会同步创建一个对应的FEventNode对象,它会以单调递增方式创建新的Uid,而且内部记录了LoggerName和EventName。所有创建的FEventNode城市添加到GNewEventList链表中进行打点,类似GNewChannelList,这个链表只会存储新建EventNode,当EventNode信息被WriterBuffer发送之后,GNewEventList会被清空。
FWriteBuffer
前面介绍了UnrealInsight写入TlsBuffer数据的布局,那TlsBuffer有多大?写满了怎么办?因此这里看下UE如何打点TlsBuffer。
TlsBuffer全称为GTlsWriteBuffer,类型是FWriteBuffer,通过thread_local描述符,声明为每个线程独有。每个FWriteBuffer的BufferSize,由GPollBlockSize指定,默认4KB,但不都能存储数据,这块内存头部要存储FWriteBuffer本身,然后要再空一个uint32,还不确定用途。UE把这个Buffer称为Block。然后Block由更大的Page进行打点,类型为FPoolPage,申请内存的单元是Page。Page大小可以分歧,初始化的Page包含64个Block,半途扩容的Page包含16个Block,这点很像UE的mallocbinned内存分配器了。不外不雅察看426引擎代码,发现初始Page大小并没有被使用过,统一用到扩容Page大小。
数据布局
FWriteBuffer
uint16Size 可用空间
FWriteBuffer* NextBuffer 指向的下个FWriteBuffer(Block)节点
Uint8* Cursor 存储数据的游标
Uint8* Commited 提交数据的游标
uint16 ThreadId 对应线程id
FPoolPage
FPoolPage* NextPage 指向的下个FPoolPage节点
Uint32 AllocSize Page大小
Block和Page的布局图如下:
有两点值得存眷
[*]Page中第一个Block头部存储了FPoolPage布局体,对比其他BlockBuffer会小一些
[*]每个Block的FWriteBuffer在内存块尾部,Cursor初始为Buffer头部,往后增长,因此判断Buffer满的条件为Cursor>FWriteBuffer
UE维护一个GPoolFreeList指针,当申请完一个Page后,会取走第一个Block去使用,然后把最后一个Block的NextBuffer指向第二个Block,再把GPoolPreeList指向第二个Block,这样当下次需要Block时,直接取GPoolFreeList即可。
回到UE_TRACE_LOG写入Buffer代码,如果此时Buffer已满,就会执行Writer_NextBufferInternal()函数,获取下一个可用Buffer,而且把当前Buffer的NextBuffer指向新获取的Buffer。
被写入数据的Buffer由另一个链表打点,称为GNewThreadList。
因此,一个“满”的Buffer可能存在空隙,比如Buffer还有10Byte空闲,我们要写入12Byte,那么该Buffer就被判定为“满”了,12byte会被写入下个Buffer。
发送Profile数据
我们已经记录了Profile数据,而且写入了Buffer,之后这些数据如何被传到处事器,或写入文件?
回到最开始的UnrealInsight初始化部门,会执行Trace::Initialize()函数,如果平台撑持多线程(一般都撑持),就启动独立的WriterThread,并以17ms为间隔进行tick。
tick执行函数为Writer_WorkerUpdate()
static void Writer_WorkerUpdate()
{
Writer_UpdateControl();
Writer_UpdateData();
Writer_DescribeAnnounce();
Writer_DrainBuffers();
}前两个函数用于更新持续状态等内容,可以不管,重点看后两个函数。
Writer_DescribeAnnounce
我们每次用UE_TRACE_LOG写入Profile信息,城市用Uid开头作为标识表记标帜,Uid由Loggername(如CpuProfiler)和EventName(如Scope)确定,还可能包含成员变量,因此要把这个对应关系也发到UnrealInsight处事器,这样在收到Uid之后才知道要如何措置后面的数据。UE_TRACE_LOG会生成FEventNode对象,里面包含了Uid对应的LoggerName,EventName,以及Field描述信息,相当于反射数据,指导UnrealInsight处事器如何解析数据。
举个例子:
使用标识表记标帜TRACE_CPUPROFILER_EVENT_SCOPE(Name)来profile一个函数,首先要给这一行的标识表记标帜生成一个SpecId,并把Name和SpecId的关系通过UE_TRACE_LOG写入TlsBuffer,这个Event称为EventSpec:
UE_TRACE_LOG(CpuProfiler, EventSpec, CpuChannel, NameSize)
<< EventSpec.Id(SpecId)
<< EventSpec.CharSize(uint8(sizeof(TCHAR)))
<< EventSpec.Attachment(Name, NameSize);此处的Uid对应CpuProfiler和EventSpec,处事器需要知道数据中包含了成员变量SpecId和CharSize,以及Attachment包含了Name数据。
再到FlushThreadBuffer
UE_TRACE_LOG(CpuProfiler, EventBatch, true, InThreadBuffer->BufferSize)
<< EventBatch.Attachment(InThreadBuffer->Buffer, InThreadBuffer->BufferSize);这里的Uid对应CpuProfiler和EventBatch,处事器需要解析该Buffer,获取SpecId和时钟tick计数。
Writer_DescribeAnnounce函数会不竭查找新创建的EventNode和Channel,并清空GNewEventList和GNewChannelList,把Uid对应的这些反射信息,都发给处事器。
Writer_DrainBuffers
最后终于到了发送FWriteBuffer部门。
UE会遍历GNewThreadList,取出已经写入数据的Buffer,然后按照Buffer.Commited指针获取到要发送的数据,之后交给底层的IoWrite接口进行发送。IoWrite接口屏蔽了网络发送和当地写文件,上层无感知,只管写入即可。
措置完的FWriteBuffer又变成空闲可用状态,UE把它们从头插手到GPoolFreeList链表中,等待下次使用。
总体流程图
页:
[1]