在C#中使用Struct代替Class

最近在尝试优化Spine的渲染器,中途发现自己手上的Unity版本其实已经支持ref return了,故有此文。

一般优化性能都会想到Native化,但这种想法其实是不对的。Cpp效率高,其实并不是因为它本身语言效率高,而是它提供的一些独有的语言特性可以获得更高的效率。也就是说,如果你直接把一个C#的代码翻译成Cpp编译,获得的效率提升其实并不显著。而Unity本来也是在用il2cpp,本体本来也就是Cpp,直接翻译成CppNative化作用就更不明显了。

而所谓的高性能编程,基本可以分为“内存连续”,“SIMD”,“多线程”三个最重要的部分。内存连续是为了减少cachemiss提高数据吞吐,SIMD是减少指令数,多线程是为了避免核心空载。

后面两个Burst都能解决。

而内存连续并不限于Burst方案。大部分情况,只要把数据对象的Class替换成Struct就可以。这就是本文要讨论的内容。


首先,Class替换成Struct,有一些问题是无法处理的。比如说,数组扩容。

数组在扩容时实际上是新分配一块内存然后把原内存里的数据复制过去,Struct会导致复制成本过高。

所以要尽可能在初始化时就指定好数组的长度。

此外,Struct在临时变量中存在的时候每次变化也需要复制一次Struct,具有比较高的成本,而且在对数组修改时也和class的做法不同。

struct Item
{
    public int v1;
    public int v2;
}

Item[] arr = new Item[] { new Item() { v1 = 1, v2 = 1 } };
arr[0].v1 = 2;
arr[0].v2 = 2;

对数组这样写是没问题的。但是很显然这里会多出多次数组下标操作,在不确定编译器是否会对其优化的情况下,我们通常会将它复制到一个临时变量上。

Item tmp = arr[0];
tmp.v1 = 2;
tmp.v2 = 2;

但这样就不对了。因为Struct经过等号赋值操作,就会复制一份变成另外一个对象,之后对它的修改都不会影响原对象,所以上面的写法只能写成

Item tmp = arr[0];
tmp.v1 = 2;
tmp.v2 = 2;
arr[0] = tmp;

看上就就很蠢而且性能不好。

C#提供了Struct,但Struct在没有指针的情况下操作,就容易出现复制的性能问题。C#不默认提供指针的目的是为了安全,但并不是所有的指针操作都是不安全的。所以在C#7.0后就提供了ref return语法来解决这个问题(7.3后提供了local ref)。

比如像上面的情况就可以写成。

ref Item tmp = ref arr[0];
tmp.v1 = 2;
tmp.v2 = 2;

标记成ref的部分本质是个指针,指向了原数据,也就不存在数据复制。

此外,为了支持垮函数体支持。我们还需要ref return。

private ref Item GetItem()
{
    return ref arr[0];
}

ref Item tmp = GetItem();
tmp.v1 = 2;
tmp.v2 = 2;

向内传引用,用以前的ref参数就行了

private SetItem(ref tmp)
{
    tmp.v1 = 2;
    tmp.v2 = 2;
}
ref Item tmp = ref arr[0];
SetItem(ref tmp);

这样整个环路就打通了,而且不存在安全问题。

只要不出现数组扩容操作,以及插入,删除等需要移动数据的操作,就可以用Struct代替Class。最典型的情况是静态数据表,使用的时候也不会有任何不自由的地方。

减少对象数量还有助于提高GC速度。


而ref实际上就是指针,只要开了unsafe用指针其实什么都能实现。

在不使用unsafe的情况下,也可以用TypedReference,__makeref,__refvalue来实现以上功能。TypedReference就相当于无类型指针,剩下两个是转换方法。

TypedReference t = __makeref(list[0]);
__refvalue(t, Struct).v = 2;

这东西虽然看上去和裸指针没什么区别,但因为严格限制了TypeReference的使用环境,无法用在字段属性,返回值,ref/out上面,也无法和其他类型强制转换,实际上杜绝了向栈外传递的可能。所以是安全的。

而local ref是在栈上的当然不存在安全问题,ref return则要求右值必须也是ref,并且通过编译器追踪引用确保不会出现栈对象引用被上层使用的问题(这个就不展开了,Cpp的一千个陷阱之一,总之ref的使用错误都会在编译期间被检查出来,有点类似Rust)。


但我们有个需求就无法解决了。一组数据,诸如Bone,往往需要在每个上面储存一个parent指针方便使用(可以用index但是麻烦)。

要做到这点好像只能用unsafe的指针了。也就是给class标记上unsafe,并且用传统C指针的用法,给parent一个Bone*类型。

能用,但这就是个典型的裸指针,必须靠人力来保证它不会出问题。否则你的代码会退化到和C艹一样的危险级别上。

但至少其他地方没事。


系统库的List不实现ref return其实也是出于这个理由,因为List在扩容的时候会重置内部数组,之前对外的ref其实就已经失效了。

static ref T GetListData<T>(List<T> list, int index)
{
        FieldInfo fi = typeof(List<T>).GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance);
        return ref ((T[])fi.GetValue(list))[0];
}

static void main()
{
        var list = new List<Item> { new Item() { v = 1 } };
        ref Item t = ref GetListData(list, 0);
        list.Capacity = 100;//扩容
        t.v = 2;
        Debug.Log(t.v);////2
        Debug.Log(list[0].v);////1
}

这个例子中,虽然获得了List内数组值的引用,但一旦发生了扩容,引用就失效了。C#自己也没法处理这种情况,自然不可能提供这样一个API出来。但你自己使用的时候,倒确实可以保证在获取引用后数组不变。但既然都这样了,干嘛不直接使用原始数组呢?

如果是需要字典,则可以建立一个只包含键值对的字典,获得数组索引后再取值。

发布于 2019-12-30