图形引擎实战:Unity性能分析工具原理介绍

最近在维护一个Unity性能分析工具,类似UPR,客户端采集信息,WEB端显示数据。下面简单介绍下原理。

数据来源

  • Profiler数据 熟悉Unity的同学对Profiler一定不会陌生,我们的性能数据主要来源于它,主要包含函数耗时,GC等等等等
  • 自定义数据 除了Profiler数据外,我们还需要一些其他数据,比如手机温度,PSS大小,lua GC,缓存池的使用情况,等等

一系列你想要,而profiler没有的

数据传输

Unity构建的时候勾选Development Build,在设备上启动后,Profiler就已经启动,并在端口进行监听,我们只需要连接上这个端口就能够和Profiler进行通信,从而获得性能数据,就是平时在Profiler Window看到的那些。

之所以说某个端口,是因为这个端口并不是固定的,在Android和iOS平台,端口以55000开始,55495结束。

下图就是Unity对端口进行扫描,通过建立连接并设置连接超时时间特别短(10ms)来快速判断该端口是否在监听。

虽然端口设置的范围比较大,但一般情况下前面十多个就够用了,从55000开始listen,bind失败换下一个端口。

根据上面的介绍,我们连接Unity Profiler的思路就是,通过短超时连接扫描端口,在能够连接的情况下既确定了监听端口,也连接了Profiler。

连接上Profiler之后,需要给Profiler发送消息,告诉它你要采集信息,它就开始发送茫茫多的消息过来。

把这些消息处理或者直接转发给Web端(根据不同需求定夺)。直接转发数据量巨大,一个正常的游戏峰值可以达到10+M/s的传输。优点是不需要客户端进行处理,不会给CPU带来额外负担,缺点是对发热,耗电,都带来不小的影响,对公司网速也带来不小的影响,单个服务器能够承受的客户端连接也少的可怜。

消息解析

消息头(伪代码),消息头我们都不陌生,一般都包含消息id,消息体大小,这个只是比平常的多了一个magicnumber

struct MessageHeader
{
  UINT32 MagicNumber
  UGUID MessageId
  UINT32 Size
}

MagicNumber 值为 0x67A54E8F

MesasgeId 是一个结构体, 4个INT长,可转化为字符串 ,举个例子 c58d77184f4b4b59b3fffc6f800ae10e

struct UGUID
{
  UINT32 data[4]
}

Size 接下来消息体的大小

下面是一个简单的消息体的结构

LogMessage MessageId "394ada038ba04f26b0011a6cdeb05a62"

struct LogMessage
{
  UINT32 logType
  BYTE[] message
}

Log的消息体比较简单,只包含了log类型,和字符串数组,这两部分就组成了一个完整的消息,log并不是我们关心的消息,接下来就是一个比较复杂的消息了

ProfilerDataMessage  MessageId "c58d77184f4b4b59b3fffc6f800ae10e"

[Header] [[BlockHeader[Message ... Message]BlockFooter] ... [BlockHeader[Message ... Message]BlockFooter]]

这是一个消息体的结构,比较复杂,对涉及到的结构进行简要说明

struct Header
{
  UINT32 signature
  UINT8 isLittleEndian
  UINT8 isAlignedMemoryAccess
  UINT16 platform
  UINT32 version
  UINT64 timeNumerator
  UINT64 timeDenominator
  UINT64 mainThreadId
}

该Head不要和MessgeHead搞混淆,它是属于 ProfilerDataMessage的,并且它在一次连接中,只出现一次,附带了一些初始的信息,其中isLittleEndian与isAlignedMemoryAccess比较重要,对我们后续内容的解析影响比较大。

接下来是Block,Block里又包含了BlockHeader,BlockFooter,在中间又包含了多个Message,这些Message就不包含头和尾了,只包含了消息类型和内容 而处理这些message就是我们的重点了

struct BlockHeader
{
  UINT32 signature
  UINT32 blockId
  UINT64 threadId
  UINT32 length
}
struct BlockFooter
{
  UINT32 nextBlockId
  UINT32 signature
}

这些结构的成员通过命名我们就能理解,这里提一个比较有意思的地方,就是BlockHeader和BlockFooter的signature

BlockHeader 的 signature为0xB10C7EAD

BlockFooter的signature为0xB10CF007

不知道大家看出来了么

threadId,用于标识这个消息是关于哪个线程的,blockid和nextBlockid,是为了判断是否出错的,比如说某一个特定线程上次处理的blockdi 为1,这次处理blockid为3,那说明中间有丢失,就无法继续处理了。

Block中的Message

enum MessageType
{
    ...
    MarkerInfo = 1,
    ...
    GlobalMessagesCount = 32,
    ThreadInfo = GlobalMessagesCount + 1,
    Frame = GlobalMessagesCount + 2,
    ...
    BeginSample = GlobalMessagesCount + 4,
    EndSample = GlobalMessagesCount + 5,
    Sample = GlobalMessagesCount + 6,
    ...
    GCAlloc = GlobalMessagesCount + 20,
    ...

}

由于消息数量比较多,列举了一些比较重要的,其他的就省略了,真实的情况要比下面讲的要复杂的多

  • Session数据解析

BlockHeader的threadId表明了该消息所属的线程,其中有一个GlobalThread,id为-1,它并不是真实纯在的线程,而是标识该数据包含了全局数据。 这些数据对应的消息主要类型有 MarkerInfo , ThreadInfo 等

struct MarkerInfo
  {
    UINT16 messageType
    UINT32 samplerId;
    ...
    STRING name;
    ...
  }

name该采样的名字,一般情况下就是函数名,每个名字对应一个id,方便后面只发送id,不发送名字来减少包体大小。

struct ThreadInfo
  {
    UINT16 messageType
    UINT32 flags;
    STRING group;
    STRING name;
    ULONG startTime;
    ULONG threadID;
  }

线程的信息,主要有Main Thread, Render Thread 等,这些都是全局信息,解析后保存在字典中,方便后面的检索

  • Thread数据解析

全局信息发送完之后就是真实线程的消息了,主要讲解BeginSample和EndSample

struct Sample
  {
    UINT16 messageType
    INT8 flags;
    UINT32 id;
    ULONG Time;
  }

BeginSample和EndSample的结构是一样的,通过messageType来判断是Begin还是End,id就是MarkerInfo的sampleId,可以通过该id查找到函数名,Time是该sample采样的时间。

举一个简单的例子,假设我们有如下函数调用

fun A                // sampleId 100
    B()
  end

  fun B               // sampleId 101
  end

  A()               // main Thread id 1000000

收到BlockerHeader threadid 为 -1的消息,进入Session数据处理阶段,其中包含MarkerInfo, ThreadInfo

struct MarkerInfo
  {
    UINT16 messageType    1
    UINT32 samplerId;     100
    ...
    STRING name;          A
    ...
  }

  struct MarkerInfo
  {
    UINT16 messageType    1
    UINT32 samplerId;     101
    ...
    STRING name;          B
    ...
  }

  struct ThreadInfo
  {
    UINT16 messageType    33
    UINT32 flags;
    STRING group;
    STRING name;          Main Thread
    ULONG startTime;
    ULONG threadID;       1000000
  }

收到BlockHeader threadid 为 1000000的消息,这是一个主线程消息进入线程消息处理阶段,其中包含BeginSample,EndSample

struct Sample
  {
    UINT16 messageType   36
    INT8 flags;
    UINT32 id;           100
    ULONG Time;          0 
  }

  struct Sample
  {
    UINT16 messageType   36
    INT8 flags;
    UINT32 id;           101
    ULONG Time;          10 
  }

  struct Sample
  {
    UINT16 messageType   37
    INT8 flags;
    UINT32 id;           101
    ULONG Time;          20
  }

  struct Sample
  {
    UINT16 messageType   37
    INT8 flags;
    UINT32 id;           100
    ULONG Time;          30 
  }

上面消息的意思是 A 开始,时间0,B开始,时间10, B结束,时间20,A结束 时间30

那我们就可以计算函数的调用时间了,B耗时10 = 20 -10, A耗时 30 = 30-0, A self 耗时 20 = 30(A耗时) - 10(B耗时)

总结

上面简单介绍下性能工具如何通过Profiler获取数据,希望起到抛砖引砖的作用。 通过该工具,在测试人员跑游戏的同时,无感的上传性能数据,

并通过Web的形式展现出来,使得我们能够实时了解游戏的性能状态,及时发现问题,及时解决问题。

欢迎加入我们!

感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com

#2025届秋招##校招##求职##游戏引擎#
全部评论

相关推荐

5 3 评论
分享
牛客网
牛客企业服务