Unity WebGL开发中的内存分配问题(分析Unity堆数据)

Posted 天生爱赞美

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity WebGL开发中的内存分配问题(分析Unity堆数据)相关的知识,希望对你有一定的参考价值。

对比了WebGL及其它平台内存的工作方式。我们给出的建议是Unity堆应越小越好,同时也强调了一个事实,即浏览器中还存在其他类型的内存开销。

本文将深入探讨Unity堆,并根据实际数据来减少Unity堆的大小,而不再是通过不停地调试和试错来达到这一目的。下面就来看看Unity堆的定义、原理以及如何进行Unity堆内存分析。

Unity堆是什么?

首先要明白,Unity堆和浏览器堆是不同的概念。Unity堆实际上只是浏览器堆中的一块内存。这方面的讲解可在之前的 WebGL内存详解 中查看。

大体上说,所谓堆就是一块用于动态分配的内存,允许应用程序使用malloc/free或new/delete对内存进行操作。

Unity有自己的内存分配系统,以便提高内存的利用率,同时更加方便地进行分析与调试。但在底层仍然使用malloc/free。

在Unity WebGL中,将含有所有运行时Unity引擎对象的这块内存称为Unity堆。Unity堆中的内存分配是通过 dlmalloc 完成的。
在主机平台上,这个堆的大小由硬件规格和操作系统保留内存的大小所决定,因此应用程序应保证其申请的内存不会超过运行时可用内存。
在WebGL平台上同理,我们需要预先定义Unity堆的大小(即在应用程序构造时)。这就是说一旦初始化,Unity堆的大小就不会再发生变化。

Unity堆中有什么?

在Unity WebGL平台中,将Unity堆分配类型分类如下:
  • 静态内存区域
  • 动态内存
  • 未分配内存




被分配的第一块区域用于栈和存放所有静态对象。栈的大小通常是5MB,而静态区域的大小取决于编译的代码,即通常受Unity和工程版本所影响。

上面这些区域分配好之后,剩余的所有内存即可供运行时动态内存分配使用。

当代码开始执行后,动态区域就会占据越来越多的Unity堆空间。如果这片区域占据的空间过多,最终就会导致没有内存可供Unity使用。



随着时间的推移,即便会有一些对象被释放或者其他对象的内存再分配,动态内存区的大小是不会减少的,因为没有对应的压缩机制。而且这类操作会使动态内存区产生碎片。



所以要知道,内存中是会有碎片产生的。

那托管内存哪去了?动态区域中有一个或多个运行时托管堆,程序创建的所有对象都在其中。因此,托管堆是Unity堆的一部分,而Unity堆又是浏览器JS VM堆的一部分。听起来可能有些复杂,如果看过《盗梦空间》或《黑客帝国》,那这里就好理解多了,一个堆在另一个堆中,另一个堆又在另外一个堆中,以此类推……



托管内存

所有脚本对象都存放于此。之所以叫它“托管”是因为每当一个对象不再被引用时,垃圾回收器( Boehm )就会自动回收这部分内存。

首先需要了解的一个重点是:这部分内存是从Unity堆中分配的 (或从其他平台上的操作系统分配)。其次,这部分内存不会再归还给操作系统,因此托管堆的大小只增不减。实际上,当一个对象被回收后,它原本占用的内存仍旧被保存在托管堆中以供将来使用。

就Unity WebGL而言,当我们说“内存不会被归还给操作系统”时,实际上说的是这部分内存不会再归还给Unity堆中的可用内存块池。

还有一点需要强调的是,与Unity堆不同(Unity堆是单个一整块内存),Boehm垃圾回收器有分配多重缓存的能力。另外,每一块缓存都可以按需被分割为更小的块。不过当创建新的脚本对象时,需要一块足够容纳这个对象的毗邻内存空间,如果Boehm垃圾回收器托管的可用块不足以满足需求,则会创建一个新的内存块(从Unity堆中划取)。

更多关于托管内存的信息,请查阅 Unity手册

托管内存用尽后会如何?

如果Boehm垃圾回收器没能找到用于创建新对象的空闲内存,则从Unity堆请求分配失败,Unity WebGL将停止执行,同时抛出内存不足的错误并建议增加WebGLMemorySize的大小。

System.GC.Collect()在WebGL上不起作用吗?

Unity WebGL平台上调用 GC.Collect() 是没有效果的。因为调用栈在不为空的时候是无法进行垃圾回收操作的。更多有关该限制的内容可查阅 Unity手册

这时,Unity WebGL会在每帧开始时尝试进行一些垃圾回收操作。之后在载入新场景时,系统会进行一次完全垃圾回收操作。

System.GC.GetTotalMemory()具体会做什么事情?

在Unity WebGL平台上,该函数的作用与在其他平台上比是相同的,同时也提供了垃圾回收异常机制: System.GC.GetTotalMemory()   返回当前使用的所有托管内存,正如 Profiler.GetMonoUsedSize() 一样。如需了解托管堆的总大小(已使用+空闲),可以使用 Profiler.GetMonoHeapSize()

如何在托管堆中保留一定数量的内存?

如果曾用过C++ std容器(例如string,vector等等),应该已经了解在向容器中追加或插入新元素时,它们的大小会发生变化。在需要将使用内存控制在一定范围内的游戏和一些其他应用程序中,这可能是个问题,不过可以使用预留内存方法(例如: std::string::reserve std::vector::reserve )来解决这一问题。

与C++ std容器不同,Unity中没有为托管堆提供类似的内存保留API。不过此前也曾提到,可以另辟蹊径来达成这一点。

假设已经预先知道程序内容的托管堆占用大小,就可以预先创建一个大小相同的数组,然后手动运行垃圾回收器。这样就“隐式”地为托管堆保留了这一块内存,从而托管堆也不再需要扩容了。

听着是个相当不错的思路,但正如我们之前提到的,调用GC.Collect()函数不会有任何效果,且完全垃圾回收机制仅在场景载入时被激活。当然,这个问题还是比较容易解决的,可以设置一个预加载场景,其中仅有一个游戏对象,将分配数组的脚本附加在对象上。

void Prealloc(int sizeInMB)

    byte[] buf = new byte[sizeInMB * 1024 * 1024];
    buf = null;

然后,将这个场景设置为工程的第一个场景。现在万事俱备只欠东风,我们需要知道预分配托管堆的大小:可供内容从头至尾运行,然后使用 Profiler.GetMonoHeapSize() 函数获取保留内存的总大小。

最后记住一点,使用该方法的代价是,程序的托管内存永远都是最大值。

设置Unity堆大小

解释过Unity堆与内存管理脚本之后,回到最开始的问题:选择Unity堆大小的最佳策略是什么?

基本思路是要知道运行内容所需的最大内存占用量,然后将WebGLMemorySize设置为一个稍大的值(下一个16的倍数)。

具体做法是完整测试WebGL程序的所有内容,记录所占内存的峰值大小。然后将最终大小再稍加扩大以防万一,将其调整为16MB的倍数。

好在Profiler API提供了获取总内存大小(Total Reserved Memory)的函数。

Profiler.GetTotalReservedMemory() ,对应Unity Profiler窗口中的ReservedTotal。



但是这种方法存在两个问题。第一个问题和内存峰值有关:如果临时内存的分配和释放发生在同一帧时,这部分开销就不会被计算在ReservedTotal中。第二个问题是Unity Profiler不会记录所有的内存分配。

未跟踪的内存分配

从Profiler中获取的信息可以发挥巨大的作用,然而还需要考虑到一些事情:显而易见,Profiler会告诉您它知道的一切,但它无法告诉您它不知道的东西!

Unity之所以能够追踪内存的使用情况,是因为内存的分配都是通过MemoryManager::Allocate()完成的,而该函数会存储有关内存分配名称和大小的额外信息。不过出于某些原因,还有一些其他的内存分配操作不会被追踪,因此如果想要确切地知道Unity堆里占用了多少内存,这一点就会成为问题。

这通常是因为某些内部子系统和第三方库在操作内存时使用的是malloc/free函数,而不是Unity自身的MemoryManager。除此之外, 用户的插件 也有可能产生这样的情况,例如C和C++代码中的mallc()函数,或javascript中的_malloc()函数(例如JS样例插件中的StringReturnValueFunction函数),或者是文件的写入操作(这也会导致内存的写入)。

为了能给出清晰的概念来表明未能追踪的内存究竟有多大,就拿Unity 5.5中的一个简易工程来说,这个数字大概是7MB。好消息是,我们正在着手解决这个问题,将来这个数字只会减少。

真的无法知道内存的准确用量?

实际上还是有方法的。我们再次回顾最开始的图片,很容易就能发现总内存占用(Total Reserved Memory) = 静态内存 + 栈 + 动态内存。



幸运的是,我们能够实时地获取到这些内存区域的大小,使用emscripten生成的变量和常量即可。该插件的代码如下:
vAR MemoryStatsPlugin = 
 
    GetTotalMemorySize: function() 
        return TOTAL_MEMORY; // WebGLMemorySize in bytes
    ,
 
    GetTotalStackSize: function() 
        return TOTAL_STACK;
    ,
 
    GetStaticMemorySize: function() 
        return STATICTOP - STATIC_BASE;
    ,
 
    GetDynamicMemorySize: function() 
        if (typeof DYNAMICTOP !== 'undefined') 
            return DYNAMICTOP - DYNAMIC_BASE;
        
        else 
            // Unity 5.6+
            return HEAP32[DYNAMICTOP_PTR >> 2] - DYNAMIC_BASE;
        
    
;
 
mergeInto(LibraryManager.library, MemoryStatsPlugin);
 
var MemoryStatsPlugin = 
  
    GetTotalMemorySize: function() 
        return TOTAL_MEMORY; // WebGLMemorySize in bytes
    ,
  
    GetTotalStackSize: function() 
        return TOTAL_STACK;
    ,
  
    GetStaticMemorySize: function() 
        return STATICTOP - STATIC_BASE;
    ,
  
    GetDynamicMemorySize: function() 
        if (typeof DYNAMICTOP !== 'undefined') 
            return DYNAMICTOP - DYNAMIC_BASE;
        
        else 
            // Unity 5.6+
            return HEAP32[DYNAMICTOP_PTR >> 2] - DYNAMIC_BASE;
        
    
;
  
mergeInto(LibraryManager.library, MemoryStatsPlugin);

之后仅需存入jslib文件并存储在工程里,然后创建对应的C#代码即可:

using UnityEngine;
using System.Runtime.InteropServices;
 
public class WebGLMemoryStats : MonoBehaviour 
    [DllImport("__Internal")]
    private static extern uint GetTotalMemorySize();
 
    [DllImport("__Internal")]
    private static extern uint GetTotalStackSize();
 
    [DllImport("__Internal")]
    private static extern uint GetStaticMemorySize();
 
    [DllImport("__Internal")]
    private static extern uint GetDynamicMemorySize();

 
using UnityEngine;
using System.Runtime.InteropServices;
  
public class WebGLMemoryStats : MonoBehaviour 
    [DllImport("__Internal")]
    private static extern uint GetTotalMemorySize();
  
    [DllImport("__Internal")]
    private static extern uint GetTotalStackSize();
  
    [DllImport("__Internal")]
    private static extern uint GetStaticMemorySize();
  
    [DllImport("__Internal")]
    private static extern uint GetDynamicMemorySize();

使用这种方式还有一个好处,与Profiler API不同,这个插件可以在发布版本中使用。

需要注意的是,上述的jslib代码依赖于emscripten生成的JS代码,因此在将来的Unity版本中,这个插件可能需要更新。不过既然已经发现了这个问题,我们可能会为其添加Unity WebGL专用API来避免这样的问题。

如何分析Unity堆的数据?

首先可以使用 Unity Memory Profiler内存分析器 ,该分析器可以提供内存数据的总览,以及所有内存分配类型的详细信息。

如果需要排查内存泄露,可以参考 CPU分析器中的GC Alloc 一栏。这一栏可以清楚表明在某一帧分配了多少内存。



顺便一提,如果在使用分析器的过程中遇到问题,有可能是5.3中的bug。我们已经在 5.3.6 Patch 8 中修复了这个问题。

如果想获得更底层的数据,可以尝试新的内存分析器(Unity 5.3中提供):



可以在此了解更多信息: https://bitbucket.org/Unity-Technologies/memoryprofiler

Memory Profiler是个非常实用的工具,但还是要注意一点:它只适用于il2cpp(当然这不是问题,因为我们只在Unity WebGL上使用),并且它还只是一个预览版,因此可能在使用时会产生各种各样的问题。

以上是关于Unity WebGL开发中的内存分配问题(分析Unity堆数据)的主要内容,如果未能解决你的问题,请参考以下文章

理解Unity WebGL中的内存模型

深入理解Unity WebGL内存

教程Unity WebGL 内存优化:续篇

[蛮牛教程] Unity WebGL内存优化:Deux部分

unity webgl内存最多多大

unityecs能不能webgl