从CPU缓存出发对引用池进行优化
背景
语言C#,开发工具Unity(不重要)。将从用例一步步进行分析
内容
用例基础
开发过程中,会碰到一种情况,函数传参为非固定数量,固定类型的值,这里以打开UI界面为例,不同的界面需要的参数是不同的,常用的有两种。
方法一(param object[] 方式)
参数使用param限制,这里参数有四个,类型分别为【整型,整型,字符串,浮点】,在调用时,直接传入需要的值即可。
pubic void OpenPanel(string mame,params object[] obj)
{}OpenPanel("main",1,2,"haha",2.3f)
方法二(UIParam方式)
自定义新类型,通过继承以及类型转换的方式,传入以及读取参数。
public void OpenPanel(string name,UIParam param)
{}public class UIParam
{}public class MainParam : UIParam
{public int month;public int day;public string tip;public float value;
}OpenPanel("main",new MainParam()
{month = 1,day = 2,tip = "haha",value = 2.3f
})var mParam = param as MainParam
对比
方法1优点
灵活性高,不需要再单独为每个界面定义参数类型,实现起来比较快速,代码量很少。
方法1缺点
类型不安全,编译时无法检查类型是否正常,需要运行时才能确定类型;可读性差;顺序固定,在删除或者插入新数据时,旧的所有地方都需要重新修改顺序;传参时参数内容值类型需要先装箱成引用类型,调用时还需要再拆箱,会产生额外的GC,对性能极其不友好。
方法2优点
类型安全,经过编译检查;可读性好,根据参数名推断出参数意义,注释方便阅读;维护方便,新增,删除,修改字段比较简单;没有装箱拆箱(字段内没有object类型),类型转换比较轻量,几乎无性能消耗,也不会有GC产生。
方法2缺点
实现起来比较繁琐,每个界面都需要单独的类型限制参数,代码量较多
总结
对比维度 | param object[] 方式 | UIParam方式 |
---|---|---|
灵活性 | 高 | 低 |
类型安全 | 低 | 高,有编译验证 |
错误率 | 高 | 低,有编译验证 |
可读性 | 差 | 好 |
维护性 | 差,不好插入和删除 | 好,随便修改 |
开发效率 | 快速开发 | 相对繁琐 |
装/拆箱 | 频繁,效率低 | 无装/拆箱 |
GC压力 | 高,object数组以及装箱 | 低,UIParam本身需要分配引用 |
类型转换成本 | 高,需要手动转换 | 低,需要一次向下转型 |
调用效率 | 低,取决于实际调用逻辑 | 高,直接调用 |
总结建议
快速开发及对性能不敏感的时候,可以使用方法1。否则,使用方法2
关于引用的优化
继续深入方法2,在总结表里GC压力这一行,可以看到UIParam本身需要分配引用,因为我们传参的时候需要new一个UIParam派生类,此处需要分配引用。针对这一点,可以使用引用池进行优化,当频繁调用OpenPanel时,去引用池获取UIParam实例,而不是重新new一个。简单的代码示例,基础类型定义
public interface IPoolable
{void Reset();
}public class UIParam : IPoolable
{public virtual void Reset() { }
}
引用池定义
using System;
using System.Collections.Generic;public static class UIParamPool
{private static readonly Dictionary<Type, Stack<UIParam>> poolDict = new();/// <summary>/// 获取一个参数实例/// </summary>public static T Get<T>() where T : UIParam, new(){Type type = typeof(T);if (poolDict.TryGetValue(type, out var stack) && stack.Count > 0){return (T)stack.Pop();}return new T();}/// <summary>/// 回收参数实例/// </summary>public static void Recycle(UIParam param){if (param == null) return;param.Reset();Type type = param.GetType();if (!poolDict.TryGetValue(type, out var stack)){stack = new Stack<UIParam>();poolDict[type] = stack;}stack.Push(param);}/// <summary>/// 清空所有缓存(例如场景切换时)/// </summary>public static void Clear(){poolDict.Clear();}
}
这样当我们调用OpenPanel时,每次都从引用池获取对象,不需要每次都new,降低了GC的产生。
关于引用池Stack类型的使用
注意,上面引用池中,字典内部嵌套的类型是栈,这里为什么使用栈,而不是使用队列,或者列表?因为引用池的主要作用可以理解为复用,而栈在复用性上,是最优解。
这里结合CPU的缓存机制和.Net中的对象复用,就很容易明白原因。
CPU缓存机制
CPU缓存命中(Cache Hit)
当 CPU 访问内存时,如果所需数据已存在于 L1/L2/L3 缓存中,访问速度会极快;否则就需要从主内存加载,速度慢很多倍。
层级 | 访问速度(大致) | 缓存大小 |
---|---|---|
L1 Cache | 非常快(1-4 cycles) | 小(32KB ~ 128KB) |
L2 Cache | 快(4-14 cycles) | 中(256KB ~ 1MB) |
L3 Cache | 较慢(几十 cycles) | 大(几 MB) |
RAM | 慢(100+ cycles) | 非常大 |
即当访问某个变量时,CPU 会先在缓存里查找,如果找到了,就是 Cache Hit;否则就是 Cache Miss,需要去主内存里慢慢找。
这里拓展下L1/L2/L3的几种模式,不同的CPU采取的缓存模式不同
CPUL1/2/3缓存模式
包容式缓存(Inclusive Cache)
定义
高层缓存(如 L2/L3)必须包含低层缓存(如 L1)中的所有数据副本,即
L1 中的数据一定也存在于 L2
L2 中的数据一定也存在于 L3
优点
当 L1 中数据被逐出后,L2/L3 一定还保留副本,读取更快,命中率高。
方便调试和一致性管理(Cache coherence):L3 缓存可看作所有核心共享的总副本。
缺点
会浪费缓存空间。例如 L1 的 32KB + L2 的 256KB,会重复占用部分空间。
更难扩展缓存容量,冗余增多。
非包容式缓存(Non-Inclusive Cache)
定义
高层缓存不保证包含低层缓存的数据,可以重叠也可以不重叠。即
L1 的数据,L2 可能有也可能没有。
L2 和 L3 之间也可能不包含彼此数据。
优点
每一级缓存都能最大化自己的使用空间,不做重复缓存,空间利用率更高。
更灵活,可以为高并发、专核任务做更好的定制。
缺点
如果某一级被驱逐数据,下一层不一定能命中 ⇒ 可能直接退到 RAM ⇒ 性能损失。
一致性维护相对复杂。
排斥式缓存(Exclusive Cache)
定义
高层缓存(如 L2)与低层缓存(如 L1)完全不允许有重复数据。即
如果一块数据从 L2 移动到了 L1,就会从 L2 删除。
如果从 L1 逐出,就重新放回 L2。
是不是感觉似曾相识,跟C# GC的Generate维护机制很类似
优点
各层缓存利用率最大。
缺点
操作复杂,维护代价高。
.Net中的对象复用
在 C#中,对象是分配在托管堆上的(heap),它们的引用保存在栈上或其他对象的字段中。
引用池复用对象,实际是在避免重复分配/GC,而复用已有对象的内存地址。举例,如下对象
class MyClass
{public int a = 123;public int b = 456;
}
在内存中是如何存放的,以及CPU是如何缓存的,如下
[ obj 引用(8字节) ] --> 指向 --> [ MyClass 实体:a=123 | b=456 ]↑栈或字段 堆
CPU缓存的是后面那一块“a=123, b=456” 的实际字段值,而不是 obj 本身这个 8 字节引用值。
合并理解
结合CPU缓存机制以及.Net对象复用机制,我们能很清晰的理解引用池中为什么要用栈结构,因为栈是后进先出,而后进的更有可能存放在CPU的缓存中,CPU能更快的命中这部分内存,如果是使用队列,先进先出,引用池复用的是很久之前的对象,而对象对应的内存数据,可能早就被抛弃到RAM中,触发CPU的Cache Miss,降低执行效率。
拓展
根据上面的.Net的对象内存存储规则,可以看到内存堆上存放的对象的实际内容,而不是引用,所以我们应该避免在存放数据的对象中使用其他内存量占用较高的数据,比如游戏开发中的贴图,模型等,因为这些的对象整体过大,是无法放到CPU缓存内的,必须要抛弃在RAM中,导致Cache Miss。所以应该对对象进行归类,单纯存放数据,或单纯存放资源,以及资源管理必需的数据,以此来避免资源导致的Cache Miss。