UnityProfiler的采样原理及理解

5 阅读16分钟

📖前言:这里介绍的是Unity提供了Profiler API和ProfilerDriver API,方便调用Profiler功能进行性能监控。注意,只介绍代码控制内容。

1 ProfilerAPI简介

✈️ Profiler API:Profiling.Profiler - Unity 脚本 API

简单介绍一下使用到的Profiler的录制API:

// 1.logFile:用于指定写入文件的**绝对路径**
Profiler.logFile = CurrentLogPath;
// 2.enableBinaryLog:启用将性能分析数据记录到文件中的功能。
Profiler.enableBinaryLog = true;  
// 3.两种分析器开启,下面会介绍一下两者区别
Profiler.enabled = true;  
ProfilerDriver.enabled = true;

Profiler.enabled -> 决定采样是否发生
ProfilerDriver.enabled -> 决定采样到的帧数据是否进入ProfilerDriver 的“可读范围”

1.1 为什么需要让采样到的帧数据进入ProfilerDriver的可读范围?

❓问:为什么需要让采样到的帧数据进入ProfilerDriver的可读范围?
📌答:

👉需要先了解Profiler采样到的帧数据是什么
一帧 = 一组采样事件(event set)
在这一帧时间窗口内,持续产生一串采样事件(Sample Events)
这些事件 共同构成了这一帧的原始事实

CPU 主线程 为例,一帧里通常会有:

  • Sample Begin / End

    • Unity 内部:PlayerLoop.UpdateCamera.RenderOverhead
    • 你自己的:ProfilerMarkerBeginSample
  • GC Alloc 事件

  • Counter / Marker 事件(内存、渲染计数等)

  • Job / Worker 线程的 Sample 事件(并行)
    所有这些事件,按时间顺序混在一起,共同构成这一帧采样事件集合。

第一层:运行时产生的“采样事件记录”(最原始)

这是采样真正生成时的形态,unity不提供调用接口

更形象一些,可以把每条采样事件理解成一个不可见的记录结构,例如

SampleEvent
{
    EventType        // Begin / End / GCAlloc / Marker / Counter
    SampleId         // Sample 名称或 Marker ID
    ThreadId         // 所在线程
    Timestamp        // 高精度时间戳
    Value            // 可选:分配字节数、计数值等
}

特点:

  • 线性事件流(不是树)
  • 按时间顺序记录
  • 多线程事件混在一起
  • 没有“帧结构”“Hierarchy”“Self Time”

第二层:帧级事件集合(Frame Event Set)

这是 Profiler 内部对事件做的第一次“切片”,unity不提供调用接口

Profiler 会把事件按 Frame Boundary 分组:

Frame N
{
    Thread 0: [Event, Event, Event...]
    Thread 1: [Event, Event...]
    Thread 2: [Event...]
}

注意:

  • 仍然是事件列表
  • 只是多了:
    • frameIndex
    • threadIndex
  • Begin/End 还没“变成树”

在这个阶段:

  • 仍然无法直接显示在 Hierarchy
  • 只能做事件级分析(几乎没人这么干)

第三层:ProfilerDriver 提供的“结构化视图”(真正能用的)

这是通过 API 能接触到的最高层结构

HierarchyFrameDataView(CPU Hierarchy / Timeline)

HierarchyFrameDataView view =
    ProfilerDriver.GetHierarchyFrameDataView(
        frameIndex,
        threadIndex,
        HierarchyFrameDataView.ViewModes.Hierarchy
    );

它已经是:

  • 树结构
  • 已经算好统计
  • 已经合并同名 Sample

能读到的数据类型(核心字段):

HierarchyNode
{
    int id;
    int parentId;
    string name;

    float totalTime;   // Begin → End
    float selfTime;    // 去掉子节点
    int calls;
    long gcAlloc;
}
\ 这个和Profiler窗口上显示的内容一致

📌注意,Unity不会暴露采样事件结构,也没有提供接口。只能通过ProfilerDriver从采样事件处理后的结果中拿到所需要的耗时、调用次数等字段。

ProfilerDriver只能在Editor中使用,那么如果想实时跑真机并且监测数据,就需要
1.用 Development Build 打包(并开启对应 profiling 选项)
2.真机运行后,Editor 里 Profiler 窗口 Attach 到该 Player
这时:

  • 真机负责产出采样事件
  • Editor 负责接收 + 解析 + 显示(也就是你能用 ProfilerDriver.GetHierarchyFrameDataView 的前提)

❓问:Profiler.enabled可以编译进包,ProfilerDriver只能在Editor下使用,那只打开Profiler.enabled是不是毫无意义?
📌答:不能这么说,Profiler.enabled 决定采样是否发生ProfilerDriver.enabled决定是否能拿到unityProfiler的可视化编辑器ui数据。只要能用连接 Unity Profiler 远程查看(Development Build + Autoconnect Profiler / 运行时手动连上 Editor),就可以用。
就比如,在模拟器装一下手机包,再连接editor

1.2 Call Stacks——Unity本身行为如何通过Profiler定位

📌说明:Profiler需要开启Call Stack才能捕捉到Unity自身行为的调用栈,但默认只记录GC.Alloc的托管调用栈

Unity CPU Profiler默认是:
Sampling Profiler

采样器的工作方式是:

  • 每隔一段时间中断线程
  • 记录当前执行函数
  • 累计统计

并不知道完整调用链,只知道“当前在哪个函数”。

❓问:GC.Alloc 为什么可以有完整调用栈?

📌答:因为 GC.Alloc 不是采样得来的,它是插桩事件(Instrumentation Point)

Unity 的设计理念是:GC是需要被消灭的东西,是一种异常行为、性能风险点,因此它属于高价值调试事件。
Unity 认为:

  • 函数耗时 → 采样统计即可
  • GC 分配 → 必须知道是谁造成的

GC 不可控,因此必须精准定位。

因此,当托管分配发生时:

  • Unity 的 C# runtime 已经在内存分配路径上
  • 此时可以主动抓取 managed callstack
  • 然后把它附加到这次分配事件上
    这属于:主动记录,而不是被动采样。

📌总而言之,开启的 CallStacks 本质是:GC Alloc Callstack Capture

❓问:CallStacks实际“钩”在哪里?

📌答:Unity 运行时可以粗略理解成:

[C# 托管层]
    ↑
    │ 绑定层(Bindings)
    ↓
[C++ Native 引擎层]

托管层(你写的代码)

  • Mono / IL2CPP runtime
  • C# 方法
  • new 对象
  • GC
  • LINQ
  • boxing

Native 层(Unity 内部)

  • Transform 更新
  • Physics
  • Animator
  • Rendering
  • PlayerLoop
  • 引擎内部系统

开启CallStacks,实际上是在托管内存分配函数上加钩子

当 C# 执行 new 时
    → runtime 分配托管内存
        → Unity 在这里抓调用栈

❓问:为什么只能抓GC.Alloc的栈?

📌答:因为只有托管内存分配路径是 确定 + 可控 + 集中
托管分配一定会走:runtime allocation function,例如:mono_gc_alloc()
Unity 可以在这里 CaptureManagedStackTrace(),然后把这条栈挂到 GC.Alloc 事件上。

❓那别的行为呢?
比如:

  • Transform.Update
  • Physics.Simulate
  • Animator.Update
    以上这些都是在Native C++层

它们不会触发托管内存分配,也没有统一“入口点”可以抓托管栈。

如果 Unity 想抓,必须:

  • 在所有 native 调用 C# 的地方插栈记录
  • 或者给所有 C# 函数加探针
    这就变成 DeepProfiler。

❓问:mono_gc_alloc()是什么?

📌答:mono_gc_alloc()来自Mono runtime(开源的 .NET 运行时实现)。

Unity 的运行时结构:

你的 C# 代码
    ↓
Mono / IL2CPP runtime
    ↓
Unity C++ 引擎

Mono 模式下:
当你写:var list = new List<int>();
流程大概是:

C# IL
  ↓
Mono JIT 编译
  ↓
调用 Mono runtime 的分配函数
  ↓
mono_gc_alloc()
  ↓
GC 管理器分配托管堆内存

Unity 在 Mono 之上做了一层Unity Profiler Hook

当 Mono 执行分配时,Unity 会:

  • 在 runtime 分配路径上插入 profiler hook
  • 捕捉当前托管调用栈
  • 记录 GC.Alloc 事件
    所以:

    栈是 Mono 提供的
    记录行为是 Unity 加的

如果是 IL2CPP 呢?
IL2CPP 情况不一样,IL2CPP 会把 C# 转成 C++:C# → IL → C++ → 编译成本地代码
这时分配不再走 Mono,而是走:il2cpp::gc::GarbageCollector::Allocate()

Unity 在 IL2CPP runtime 里同样插了 profiler hook。

1.3 Mono 的 Profiler 机制

Mono 有一整套 profiler API,大概结构是这样的:

mono_profiler_install(...)
mono_profiler_install_allocation(...)
mono_profiler_install_enter_leave(...)
mono_profiler_install_gc(...)
mono_profiler_set_events(...)

✈️官方开源源码仓库:Mono Project · GitHub

❗注意:Mono的Profiler 和UnityProfiler体系是两回事!

Mono 提供的是嵌入式运行时级别的profiler hook机制,它不是 Unity Profiler 的 API,而是VM 层的 instrumentation (插桩)机制

📌VM(Virtual Machine 虚拟机),Mono本质上是一个 C# 托管代码的虚拟机,你写的 C# 代码的执行路径为 C# -> IL -> Mono VM -> JIT -> 机器码 -> CPU

其核心能力包括:

  • 方法 enter/leave 事件
  • JIT 编译事件
  • GC begin/end
  • 分配事件
  • 线程创建销毁
  • Domain 生命周期
  • 异常抛出
  • 对象创建

也就是说,Mono提供了能在VM执行流里插桩的API

//伪代码举例 
void mono_execute_method(Method* m) {
    profiler_method_enter(m);  // 插桩点

    real_execute(m);

    profiler_method_leave(m);  // 插桩点
}

而这个插桩UnityProfiling的BeginSample采样不是同一个东西。

例如 Profiler.BeginSample("EnemyAIAction");,这是脚本层插桩
Mono profiler 是VM 执行引擎层插桩

VM层可以看到:

  • 每一个方法调用
  • 每一个对象分配
  • 每一次 GC
  • 每一个异常
  • 每一个线程创建
  • JIT 编译过程
  • Domain 加载卸载
    它看到的是运行时本身的行为,而不是 Unity 系统行为

📌再重复一遍,Call Stacks 会在 Mono runtime 的托管分配路径上启用 allocation hook,并在分配时抓取托管栈信息。属于VM 层局部插桩

如果是全VM插桩,则会包含:

  • 所有方法 enter
  • 所有方法 leave
  • 所有分配
  • 所有异常
  • 所有 GC 事件

1.4 IL2CPP情况

在 IL2CPP 下的代码执行路径为:C# → IL → IL2CPP → C++ → 编译成本地机器码

运行时是Unity 自己的 IL2CPP Runtime ,也叫libil2cpp,它是一整套 C++ 实现的托管运行时支持库,充当了Mono VM的托管运行职责。但是具体负责内容还是有些许不同的。

Mono VM 负责什么?

  • 托管对象分配
  • GC
  • 类型系统
  • 方法调用
  • 反射
  • 异常机制
  • 线程管理
  • 泛型实例化
  • 静态字段管理

IL2CPP Runtime(libil2cpp)同样要负责:

  • 对象分配
  • GC
  • 类型系统
  • 反射
  • 异常
  • 线程
  • 元数据
  • 泛型支持

Mono 是 VM,因为 IL → 运行时 JIT → 机器码,也就是为读取机器码提供了一个 运行时解释 IL / JIT IL环境
IL2CPP是 IL → 构建期转 C++ → 编译成机器码,构建期就翻译好了,运行时并不存在IL解释器。

这么解释感觉还是有一些晦涩,再直白一点,
Mono 是一个托管虚拟机
IL2CPP 是一个提前把托管代码编译成本地代码,然后由 Unity 自己实现托管运行时支持库来维持 C# 语义的系统,即语义支撑库

所以这套机制并不完全依赖 Mono profiler、IL2CPP

1.5 托管栈帧

❓Mono有托管栈帧结构,可以遍历IL栈去捕捉alloc,那IL2CPP没有托管栈怎么捕捉?(有点晦涩)

为什么这对 CallStack 有影响?

Mono:遍历托管 frame 链
IL2CPP:native stack unwind → 地址映射回 MethodInfo

📌举例解释上述内容

假设有代码:

void A() { B(); }
void B() { C(); }
void C() { Debug.Log("hi"); }

当执行到 C() 时,调用关系是:

A
 └─ B
     └─ C

调用关系即:我怎么走到C。
但是此时CPU不认识 A->B->C这个链路的

栈帧是:编译器按照调用约定,在栈上组织出来的一段内存布局。
假设执行 A → B → C。
每进入一个函数,编译器会生成类似这样的机器码:

push rbp
mov rbp, rsp
sub rsp, 32   ; 分配局部变量空间

这几条指令会:

  • 保存上一层的栈基址
  • 为当前函数分配局部变量空间
    这块被分配出来的内存区域,就是当前函数的栈帧

❓为什么我们可以叫它 A/B/C 的栈帧?
因为:

  • 每个函数入口都会建立一段栈空间
  • 每个函数退出都会释放它
  • 这块空间只属于这个函数执行期间
    所以我们把这块内存称为这个函数的栈帧

真实内存里大概是:

[ 栈帧 C ]
  - 局部变量
  - 返回地址 → B

[ 栈帧 B ]
  - 局部变量
  - 返回地址 → A

[ 栈帧 A ]
  - 局部变量
  - 返回地址 → main

stack unwind(栈回溯) 只是提取:

\回溯地址用于显示
返回地址 C
返回地址 B
返回地址 A

于是变成了:

0x104523a
0x1045110
0x10322aa

CPU 层面会有:

\ 这就是所谓的Native栈帧
[A 的栈帧]
[B 的栈帧]
[C 的栈帧]
\

在任意时刻,CPU知道的只有:

  • 当前指令地址(IP / PC)
  • 当前栈顶指针(SP)
  • 栈内存里的原始字节

用大白话来说,CPU只做两件机械的事
1.执行call target(函数调用)

push 返回地址
jump 到 target

2.执行ret,从栈顶取一个地址,并且跳转过去

pop 栈顶地址
jump 到那个地址

CPU 根本不知道

  • 这是 C#
  • 这是 MonoMethod
  • 这是托管对象
  • 哪些变量是 GC 引用
什么叫“托管栈帧”(Managed Frame)?

Mono维护一套栈(托管栈帧) ,是因为它不只是执行函数,它还要:

  • 知道当前执行的是哪个 C# 方法
  • 知道 IL 执行到哪条指令
  • 知道哪些局部变量是引用类型(给 GC 用)
  • 支持异常向上抛
  • 支持反射
  • 支持 profiler
    这些信息,CPU 栈里是没有的,所以 Mono 运行时会为每个方法调用维护一条托管调用链

Mono 在每次进入一个 C# 方法时,都会创建一个“托管执行记录”,也就是托管栈帧

\当 A 被调用时,Mono 逻辑上做
push ManagedFrame(A)

\当 B 被调用时
push ManagedFrame(B)

\当 C 被调用时
push ManagedFrame(C)

\于是当前线程内部就有
A
B
C

每次创建的单个托管栈帧 包含信息:

  • 当前方法是谁(MonoMethod)
  • 局部变量信息
  • 哪些变量是引用
  • 异常处理信息
  • 指向上一个 frame 的指针

托管栈(Managed Stack)就是当前线程上所有托管栈帧组成的结构,如:

\这一整组,就是托管栈
ManagedFrame(C)
ManagedFrame(B)
ManagedFrame(A)

托管调用链(Managed Call Chain)其实就是从逻辑视角描述和托管栈一样的东西,强调的是方法调用关系,即 A → B → C

它其实是 Mono 运行时维护的一份“当前 C# 方法执行列表 。

为什么 Mono 必须有托管栈帧?

因为 Mono 是 VM,运行时执行 IL 指令
执行 IL 时,必须维护:

  • 当前执行到哪条 IL

  • 当前方法是谁

  • 当前局部变量表

  • 当前 evaluation stack

  • GC 可扫描信息

  • 异常处理表
    以上这些信息必须有一个“运行时结构”保存。

    于是就形成了:

    托管栈帧(Managed Frame)

换句话说:

托管栈帧是“执行 IL”所必须的执行上下文结构。

为什么 IL2CPP 没有托管栈帧?

因为 IL2CPP IL → 构建期展开 → C++ → 本地代码,运行时不再执行 IL。
所以:

  • 不再有 IL
  • 不再解释 IL
  • 不再 JIT
  • 不再维护 IL 执行栈
    运行时执行的已经是:纯 native 机器码
那 GC 怎么办?

Mono:

  • 通过托管栈帧知道哪些局部变量是引用

IL2CPP:

  • 在编译期生成 GC 描述信息
  • 在特定 safepoint 扫描 native 栈
  • 根据编译期元数据判断哪些位置是引用

它不依赖运行时“托管栈帧链”。

那异常怎么办?

Mono:

  • 遍历托管栈帧链
  • 找 catch

IL2CPP:

  • 依赖 C++ 异常机制或生成的 unwind 信息
  • 使用 native 栈展开机制
那 CallStack 怎么办?

Mono:
直接遍历托管栈帧
IL2CPP:
native stack unwind → 地址 → 查 MethodInfo

因为:

IL2CPP 运行时只有 native 栈

deepProfiler和callStack,这两者有一定的相似之处,接下来先说明两者区别。

功能做了什么
Call Stack记录某些采样点的调用栈
Deep Profiler给所有方法插桩,记录每一次函数调用

本质差异是:

  • Call Stack 是“栈抓取”
  • Deep Profiler 是“函数级插桩”

2 病灶针对

需要了解,我们做采样统计的时候最需要关注的就是“可读范围”、“读取来源”、“如何读取”。

我们在ProfilerDriver的可读范围,即 ProfilerDriver.GetHierarchyFrameDataView 的层级帧数据中,往往能看到一些Unity引擎自身的行为函数,如GCAlloc,但是该层级数据并没有办法看到具体是哪个业务函数产生了GC分配。

为了看具体这个函数,往往第一个想到的是打开deepProfiler。DeepProfiler的缺点也很明显,过重,容易造成明显卡顿,不适合用于长时间监控开启。好处就是可以得到非常详细的调用细节,快准狠地解决病灶。

此时,可以关注Profiler 中有一个叫做"收集调用栈"(Callstack Collection)的选项,原理已经在第1章节里面有完整的原理解释

📌重复:Unity其实早已自动内置了"插桩",用于分析Unity自身行为(如GC.Alloc
),可以通过 Profiler.ResolveItemCallback 得到该行为的详细调用栈。

并且,开启callstack时,也可以通过 手动插桩(用ProfilferMarker进行标记)去分析自己写的特定函数。

❗需要区分:

  • ProfilerDriver.GetHierarchyFrameDataView 是 “看”数据,它只能展示 Profiler 已经记录下来的样本(Samples)。
  • ProfilerMarker 是 “制造”数据,它主动告诉 Profiler:“请把这段代码的执行情况也记录下来”。

📊 核心区别一览

维度GetHierarchyFrameDataViewProfilerMarker
本质读取接口写入标记
数据来源访问 Profiler 内部已收集的样本数据在代码中主动创建新的样本数据
能看到自己的函数吗?仅能看到 已被标记 的函数(如引擎内置标记、MonoBehaviour 生命周期、或 Deep Profiling 自动注入的标记)可以让原本不可见的函数变为可见,从而出现在调用树中
是否需要修改代码否,只读操作是,需要在目标代码位置添加标记
开销读取数据本身无额外开销标记本身开销极小(Release 下几乎为 0),但会增加 Profiler 的数据量
主要用途分析已有的性能数据,查看调用树、耗时、GC 分配等精细化定位,让 Profiler 能追踪到你关心的代码块
典型场景查看 BehaviourUpdate 下的所有更新,但只能看到聚合项想知道 BehaviourUpdate 内部的具体哪个函数调用了 Instantiate

举个例子:
通过 GetHierarchyFrameDataView,你只能看到:

BehaviourUpdate → XXXAPPMono.Update() → XXXAPPEnv.Update → (这里看不到) → Instantiate

中间的 XXXAPP.Update()XXXComs.Update() 以及各个 ICom.Update 都没有出现在调用树中,因为 Profiler 默认不会记录这些普通的 C# 函数调用。

当你给 XXXCom.Update 加上 ProfilerMarker 后,调用树就会变成:

BehaviourUpdate
  └── XXXAPPMono.Update()
       └── XXXAPPEnv.Update
            └── XXXComs.Update
                 └── ABCManager.Update   ← 现在能看到这个标记了!
                      └── Instantiate

现在面临一个情况,继承 ICom 的组件很多怎么办?总不能一个一个都手动打标记。

这时候可以考虑动态标记(普通采样标记)注意,它只能让你看到你自己标记的这个函数,无法自动展开这个函数内部的调用。

public static void Update()
{
    foreach (var com in components)
    {
        // 用组件的类型名作为标记名,这样每个组件都会有自己的样本
        Profiler.BeginSample($"{com.GetType().Name}.Update");
        try
        {
            com.Update();
        }
        finally
        {
            Profiler.EndSample();
        }
    }
}

说了这么多可能有点绕,来看一下三者区别

🧭 使用场景指南

工具核心用途何时使用示例
Callstacks 收集追踪特定事件(如 GC 分配、Instantiate)的来源当你已经知道发生了某个事件(比如看到大量 GC.Alloc),但不知道是哪里触发的发现 BehaviourUpdate 下有 70MB 的 GC.Alloc,想知道具体是哪个组件里的哪行代码分配的
动态 BeginSample临时展开一组未知函数,让它们出现在调用树中当 callstacks 不够详细(被内联或截断),且你不知道具体是哪个函数时,用于快速定位遍历 ICom 列表调用 Update,想一次性看到所有组件的 Update 样本,找出哪个调用了 Instantiate
ProfilerMarker长期监控特定热点函数当你已经确定某个函数是性能关键点,需要持续关注它的耗时或分配找到是 PoolManager.Update 导致的 Instantiate,决定以后一直监控这个函数的性能

📌 详细说明

2.1 Callstacks 收集

  • 何时用:你看到某个性能事件(如 GC.AllocInstantiate)大量发生,但调用树中只显示 BehaviourUpdate 这种聚合条目,不知道具体是哪个函数触发的。

  • 怎么用

    • UI 方式:Profiler 窗口 → 点击 "Call Stacks" 按钮 → 勾选你想追踪的事件(如 GC.Alloc)。
    • 代码方式:Profiler.enableAllocationCallstacks = true;
  • 效果:当你点击 GC.Alloc 样本时,下方 Details 面板会显示完整的调用栈,告诉你分配发生在哪个函数的哪一行。

  • 优点:零代码修改,只需开启开关。

  • 缺点:只对特定事件有效,且调用栈可能因内联或优化而不完整。

2.2 动态 BeginSample

  • 何时用:callstacks 不够详细(看不到业务函数),且你有一组未知函数需要一次性展开,比如遍历调用 ICom 组件的 Update。
  • 怎么用:在遍历循环中,用 Profiler.BeginSample($"{com.GetType().Name}.Update") 包裹每个组件的调用。
  • 效果:每个组件的 Update 都会作为独立样本出现在调用树中,你可以展开看到它们内部的 Instantiate 或 GC.Alloc。
  • 优点:改一处代码,覆盖所有组件,开销适中,能看到每个组件的性能分布。
  • 缺点:样本名用字符串拼接,可能产生少量 GC(但只在 Profiler 启用时),不适合长期保留。

2.3 ProfilerMarker

  • 何时用:你已经找到了热点函数(比如 PoolManager.Update),需要长期监控它的性能,或者希望它永久出现在调用树中以便日常分析。

  • 怎么用
    csharp static readonly ProfilerMarker k_PoolManagerUpdate = new ProfilerMarker("PoolManager.Update");
    void Update() {
    using (k_PoolManagerUpdate.Auto()) {
    // 业务逻辑
    }
    }

  • 效果ABCManager.Update 会作为一个永久样本出现在 Profiler 中,你可以随时查看它的耗时和分配。

  • 优点:开销极小(Release 下 0 开销),适合长期保留,精确标记热点。

  • 缺点:需要手动添加到每个热点函数。