UnityGC优化 -Unsafe和非托管内存

 

10 | Unsafe和非托管内存

Unsafe适用于对于性能有较高要求或者需要做某些特殊操作的时候,它把类似C++的指针操作暴露出来,让开发时具有很大的灵活性。Unity 2018以后又提供了UnsafeUtility工具类,让指针操作更加便捷。



10.1 Unsafe优化字符串操作的GC


比较简单的也比较常用的是ToLower:

 public static void ToLower(string str)
    {
        fixed (char* c = str)
        {
            int length = str.Length;
            for (int i = 0; i < length; ++i)
            {
                c[i] = char.ToLower(c[i]);
            }
        }
    }

这样直接修改了原字符串,将其所有字符全都改成小写。

还有一个比较常用的操作是split,通过分隔符生成一个字符串数组,虽然数组中每个字符串要缓存起来比较麻烦,但是数组本身是可以缓存出来的:

  public static int Split(string str, char split, string[] toFill)
    {
        if (str.Length == 0)
        {
            toFill[0] = string.Empty;
            return 1;
        }
        var length = str.Length;
        int ret = 0;
        fixed (char* p = str)
        {
            var start = 0;
            for (int i = 0; i < length; ++i)
            {
                if (p[i] == split)
                {
                    toFill[ret++] = (new string(p, start, i - start));
                    start = i + 1;
                    if (i == length - 1)
                        toFill[ret++] = string.Empty;
                }
            }
            if (start < length)
            {
                toFill[ret++] = (new string(p, start, length - start));
            }
        }
        return ret;
    }

方法传入了一个缓存的string[],然后根据split遍历分割字符串。但如果是根据split遍历操作每一个字符串,也可以缓存一个单独的长度比较大的字符串,每次分割后的字符都复制进这个缓存里,当然,这就需要动态修改字符串的长度了,字符串的长度修改方法如下:

  public static void SetLength(this string str, int length)
    {
        fixed (char* s = str)
        {
            int* ptr = (int*)s;
            ptr[-1] = length;
            s[length] = '\0';
        }
    }

字符串最后一个字符后面的字符必须是‘\0’,这个和C++是一样的,字符串的首字母地址之前的一个int代表了字符串的长度。

可以设置字符串长度以后,也就可以实现Substring了:

  public static void Substring(string str, int start, int length = 0)
    {
        if (length <= 0)
        {
            length = str.Length - start;
        }
        if (length > str.Length - start)
        {
            throw new IndexOutOfRangeException($"{length} > {str.Length} - {start}");
        }
        fixed (char* c = str)
        {
            UnsafeUtility.MemMove(c, c + start, sizeof(char) * length);
        }
        SetLength(str, length);
    }

此外还可以实现字符串拼接、Path.Combine、string.Format等方法。

使用Unsafe操作字符串可以不必生成新的字符串,从而减少GC Alloc,不过需要注意几点:

  • 指针操作没有越界检查,如果修改字符串的长度,要确保长度小于等于字符串的原始长度。
  • 谨慎修改intern字符串的内容。
  • 修改字符串内容会使字符串的hashcode发生改变,如果修改的字符串是某个字典的Key,需要将其从字典中移除,修改后再放进去。



10.2 使用Unsafe优化反射


反射中一个比较常用的东西是fieldInfo.SetValue,或fieldInfo.GetValue,如果字段是值类型的,就会有一次装箱或拆箱,使用Unsafe可以通过字段在内存中的偏移量来赋值,如下给一个int字段赋值:

    var offset = (int)Marshal.OffsetOf<T>(fieldName) + IntPtr.Size * 2;
    var address = (byte*)UnsafeUtility.PinGCObjectAndGetAddress(obj, out var gcHandle);
    *(int*)(address + offset) = value;
    UnsafeUtility.ReleaseGCObject(gcHandle);

首先取得字段的偏移量,因为是对象,所以偏移量要加IntPtr.Size * 2(也可以直接使用UnsafeUtility.GetFieldOffset(fieldInfo),不用修改偏移量,但是有GetFieldInfo的开销),然后,PinGCObjectAndGetAddress将对象的地址固定住,通过偏移量来赋值,最后释放对象句柄。

取字段偏移量是一个非常耗时的操作,最好可以提前缓存这个偏移量,再调用时速度就会变得非常快了。
下面是三种方式的对比(设置对象中一个整数的值,10000次迭代):

其中fieldInfo.SetValue用到的fieldInfo是提前缓存了的,可以看出来,Marshal.OffsetOf的开销是非常大的(UnsafeUtility.GetFieldOffset开销也很大),而如果缓存了offset,速度会比fieldInfo.SetValue提升十倍。



10.3 非托管堆


相对于托管堆,非托管堆有一个好处,就是可以手动申请和释放,此外,Unity的DOTS大量使用Native容器也是为了能保证尽量使用连续内存。UnsafeUtility提供了方便的接口手动管理非托管内存,下面是一个使用非托管堆的UnsafeList示例。

可以使用非托管堆的类型必须是Blittable,也就是必须是结构体,而且里面的字段只包含基本值类型和Blittable结构体。所以,声明可以写成:

  public unsafe struct UnsafeList<T> where T : unmanaged
    {
        static int alignment = UnsafeUtility.AlignOf<T>();
        static int elementSize = UnsafeUtility.SizeOf<T>();
        const int MIN_SIZE = 4;
    
        ArrayInfo* array;
        ...
    }

unmanaged约束可以看作是Blittable,但是有个问题就是不包含泛型结构体,如果不需要泛型结构体可以忽略,或者使用struct约束,但是struct约束就不能用指针T * 来存取数据了,需要换一种方式。这里还是使用unmanaged约束。

几个静态变量缓存了申请内存所需要的信息,数据信息存在ArrayInfo的指针中:

    unsafe struct ArrayInfo
    {
        public int count;
        public int capacity;
        public void* ptr;
    }

信息包含长度、容量,真正的数据保存在ptr中。

首先是构造函数:

  public UnsafeList(int capacity)
    {
        capacity = Mathf.Max(MIN_SIZE, capacity);
        array = (ArrayInfo*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<ArrayInfo>(), UnsafeUtility.AlignOf<ArrayInfo>(), Allocator.Persistent);
        array->capacity = capacity;
        array->count = 0;
        array->ptr = UnsafeUtility.Malloc(elementSize * capacity, alignment, Allocator.Persistent);
    }

UnsafeUtility提供了多种Allocator,生命周期和性能都不相同,具体可以参见官方文档。 然后如果不使用这个List,需要手动将其释放:

   public void Dispose()
    {
        UnsafeUtility.Free(array->ptr, Unity.Collections.Allocator.Persistent);
        UnsafeUtility.Free(array, Unity.Collections.Allocator.Persistent);
    }

当容量不够时,可以像List一样扩容:

 void EnsureCapacity(int newCapacity)
    {
        if (newCapacity > array->capacity)
        {
            newCapacity = Mathf.Max(newCapacity, array->count * 2);
            var newPtr = UnsafeUtility.Malloc(elementSize * newCapacity, alignment, Allocator.Persistent);
            UnsafeUtility.MemCpy(newPtr, array->ptr, elementSize * array->count);
            UnsafeUtility.Free(array->ptr, Allocator.Persistent);
            array->ptr = newPtr;
            array->capacity = newCapacity;
        }
    }

申请新内存,将旧的数据复制到新的内存中,再释放旧内存。有了这个就可以添加数据了:

   public void Add(T t)
    {
        EnsureCapacity(array->count + 1);
        *((T*)array->ptr + array->count) = t;
        ++array->count;
    }
    public void Insert(int index, T t)
    {
        EnsureCapacity(array->count + 1);
        if (0 <= index && index <= array->count)
        {
            UnsafeUtility.MemMove((T*)array->ptr + index + 1, (T*)array->ptr + index, (array->count - index) * elementSize);
            *((T*)array->ptr + index) = t;
            ++array->count;
        }
        else
        {
            throw new IndexOutOfRangeException();
        }
    }

如果复制的内存区域重叠,不管是向前还是向后,最好都使用memmove,内部会决定要不要考虑重叠区域。AddRange和Remove也是类似的实现方法。

在List中,Clear方法因为要考虑元素是引用类型的情况,为了能让GC正常回收List中的对象,必须把所有数据全都归零,但是这里因为不存在这种情况,所以Clear方法很简单:

   public void Clear()
    {
        array->length = 0;
    }

最后是读写,因为索引器的set方法不支持ref参数,所以可以直接用指针:

 public T* this[int index]
    {
        get
        {
            if (0 <= index && index < array->count)
            {
                return ((T*)array->ptr + index);
            }
            throw new IndexOutOfRangeException();
        }
        set
        {
            if (0 <= index && index < array->count)
            {
                *((T*)array->ptr + index) = *value;
            }
            throw new IndexOutOfRangeException();
        }
    }

然后也可以提供一个单独的ref return方法:

  public ref T Get(int index)
    {
        return ref *this[index];
    }

这样一个使用非托管堆的容器就诞生了。



10.4 stackalloc、Span<T>和Memory<T>


stackalloc关键词,可以申请栈内存:

   void Calculate()
    {
        Vector3* s = stackalloc Vector3[10];
        ...
    }

如果计算只需要一组占用比较小的临时数据,使用stackalloc是一个很好的选择,因为它的申请速度非常快,而且不需要手动管理,作用域一结束就会自动释放。

Span<T>Memory<T>这两个类型需要额外的DLL支持。它们可以管理存放在托管堆,非托管堆和栈内存的数据,因为提供了Slice方法分割内存,还提供了各种Copy方法可以在各种类型内存中互相拷贝,比直接用指针来方便一些,Span<T>Memory<T>的区别是,Span<T>是ref类型的,不能用作字段,也不能跨越yield和await使用。一般Span<T>Memory<T>的执行效率比直接使用指针要低。有兴趣可以看一下 KCP的一个实现



10.5 总结


本文包含了一些在我们项目实际开发过程当中用到过的和优化方法,内容概括起来有三点:

  1. 使用结构代替类
  2. 缓存对象
  3. 使用非托管堆

虽然在游戏运行过程当中完全没有GC是非常难的,但是至少在一场战斗过程中,最好可以确保不会出现一次GC。此外,对于低端设备,1GB内存以下的设备要尽量保证堆内存大小控制在一定范围内,这也是非常重要的。希望本文可以对大家进行内存优化方面的工作有一定的帮助。

 

猜你喜欢

转载自www.cnblogs.com/chenggg/p/12533168.html
今日推荐