📖前言:这里介绍的是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.Update、Camera.Render、Overhead… - 你自己的:
ProfilerMarker、BeginSample
- Unity 内部:
-
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:“请把这段代码的执行情况也记录下来”。
📊 核心区别一览
| 维度 | GetHierarchyFrameDataView | ProfilerMarker |
|---|---|---|
| 本质 | 读取接口 | 写入标记 |
| 数据来源 | 访问 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.Alloc、Instantiate)大量发生,但调用树中只显示BehaviourUpdate这种聚合条目,不知道具体是哪个函数触发的。 -
怎么用:
- UI 方式:Profiler 窗口 → 点击 "Call Stacks" 按钮 → 勾选你想追踪的事件(如
GC.Alloc)。 - 代码方式:
Profiler.enableAllocationCallstacks = true;
- UI 方式:Profiler 窗口 → 点击 "Call Stacks" 按钮 → 勾选你想追踪的事件(如
-
效果:当你点击
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 开销),适合长期保留,精确标记热点。
-
缺点:需要手动添加到每个热点函数。