Unity 游戏的 string interning 优化

问题描述

在开始之前,先说一下这个问题为什么很容易被忽视吧。

通常情况下,我们难以注意到运行着的 Unity 程序内 string 的实例化情况。这些字符串的创建,销毁的时机是否合理,是否存在有重复 (相同内容的字符串),冗余 (存有已不再有意义的垃圾字符),低效 (capacity 远大于 length),以及泄漏 (没有在期望的时机及时销毁) 的情况就更容易被忽视了。由于 string 没法随时像普通的 Unity 对象那样通过调用 Object.GetInstanceID() 来查看实例id,我们不太容易感知字符串对象的实际内存开销。其实要不是偶然在工具里发现了大量的此类情况,俺也没想到看起来颇单纯的 immutable string 里居然隐藏着这么多秘密。

一次只说一件事,这次我们只讨论重复字符串的问题。

使用自制工具 ResourceTracker,可以发现 Unity 游戏运行时 mono(il2cpp) 内有大量重复的字符串,如下所示:

before_intern

手动 Intern()

对 .Net 特性有了解的同学,应该知道 C# 同 Java 一样,提供了一套内建的 string interning 机制,能够在后台维护一个字符串池,从而保证让同样内容的字符串始终复用同一个对象。这么做有两个好处,一个是节省了内存 (重复字符串越多,内存节省量越大),另一个好处是降低了字符串比较的开销 (如果两个字符串引用一致,就不用逐字符比较内容了)

但是为什么上面的 Unity 程序内仍然有大量的重复字符串呢?

查看他们的地址,发现彼此各不相同,说明的确没有引用到同一块内存区域。由于 C# 语言实现以静态的特性为主,俺推测,也许只有编译期可以捕捉到的字符串 (也就是通常用字面字符串 literal string 来构建时) 才会 interning。

做个实验吧:

string foobar = "foobar";
string foobar2 = new StringBuilder().Append("foo").Append("bar").ToString();

Debug.Log(foobar == foobar2); 
Debug.Log(System.Object.ReferenceEquals(foobar, foobar2)); 

运行上面的代码,输出结果分别是 TrueFalse。嗯,也就是说,即使运行时内容一样 (== 返回 True),手动在运行时拼出来的字符串也不会自动复用已有的对象。查看游戏代码,发现很多重复字符串是通过解析 binary stream 或 text stream 构造出来的,这样就解释得通了。(String literals get interned automatically)

手动 Intern 一下试试吧。

string foobar0 = "foobar";
string foobar1 = new StringBuilder().Append("foo").Append("bar").ToString();
string foobar2 = string.Intern(foobar1);
string foobar3 = new StringBuilder().Append("f").Append("oo").Append("b").Append("ar").ToString();
string foobar4 = string.Intern(foobar3);

Debug.Log(foobar0 == foobar1);   // True
Debug.Log(foobar0 == foobar2);   // True
Debug.Log(foobar0 == foobar3);   // True
Debug.Log(foobar0 == foobar4);   // True
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar1)); // False
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar2)); // True
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar3)); // False
Debug.Log(System.Object.ReferenceEquals(foobar0, foobar4)); // True

注意,C# 并没有提供“清除已经 Intern 的字符串”的接口。也就是说,如果不由分说地把产生的字符串都扔进去,会造成大量短生命期字符串 (如某个地图上特有的特效名) 在全局池内的堆积。

解决这个问题并不难,手写一个可清除的版本就可以了。

可清除的 Interning - UniqueString

下面的 UniqueString 类除了提供两个与 string.Intern()string.IsInterned() 一致的接口外,还提供了 Clear() 接口用于周期性地释放整个字符串池,可在地图切换等时机调用。这个类通过判断参数来确认,是将字符串放入全局的系统池,还是支持周期性清理的用户池。

public class UniqueString
{
    // 'removable = false' means the string would be added to the global string pool
    //   which would stay in memory in the rest of the whole execution period.
    public static string Intern(string str, bool removable = true)  
                                                                    
    // Why return a ref rather than a bool? 
    //   return-val is the ref to the unique interned one, which should be tested against `null`
    public static string IsInterned(string str)     

    // should be called on a regular basis
    public static void Clear();
}

通过参数 removable 我们可以指定使用默认 intern 还是 removable-intern。显式地指定后者的字符串将可被随后的 UniqueString.Clear() 清理。

UniqueString 的实现 (及更新) 在这里

效果

使用上面的机制在关键点加了几行代码简单地优化后,内存中的字符串从 88000 条降低到 34000 条左右 (仍有很多重复存在)。

after_intern

小结

  1. 直接写在代码里的常量字符串 (即所谓的 literal string) 会在启动时被系统自动 Intern 到系统字符串池;而通过拼接,解析,转换等方式在运行时动态产生的字符串则不会。
  2. 避免在 C# 代码里写多行的巨型 literal string,避免无谓的内存浪费。常见的情况是很大的 Lua 代码块,很密集的生成路径,大块 xml/json 等等,见下面的例子。
  3. 已经被自动或手动 Intern 的字符串在之后的整个生命期中常驻内存无法移除,但可以使用上面提供的 UniqueString 类实现周期性的清理。

下面是一些不合理的常见的代码内的常量字符串的情况 (都是常驻内存无法释放的)

string query = @"SELECT foo, bar
    FROM table
    WHERE id = 42";

string lua_code_block = @"
    local ns = foo.bar(self.nID)
        for i,v in ipairs(self.imgs) do
        if (i - 1) < ns then
            Obj.SetActive(self.imgs[i], true)
        else
            Obj.SetActive(self.imgs[i], false)
        end
    end
";

string[] resFiles = new string[] { 
    "Assets/Scenes/scene_01.unity", 
    "Assets/Scenes/scene_02.unity", 
    "Assets/Scenes/scene_03.unity", 
    "Assets/Scenes/scene_04.unity", 
    "Assets/Scenes/scene_05.unity"
};

附:

Gu Lu
2016-11-22

Comments
Write a Comment
  • JingFengJi reply

    下载ResourceTracker工具之后,并没有System.String的显示,使用的是ResourceTracker_Demo\main,并且再DemoMain.cs的Start方法中加入了string query = @"SELECT foo, bar

    FROM table

    WHERE id = 42";

    TypeName中只有[native]并没有看到[Managed]。

    请问是如何查看string 的消耗呢

    • Gemfeeling reply

      @JingFengJi 显示 managed 需要 il2cpp 支持,请参考这个 blog 上其他相关文章及评论

Tags

游戏开发   随笔   Programming   C/C++   优化   Unity   C++   知乎   游戏设计   比特币   Unity3D   区块链   软件开发   引擎设计   系统架构   Production   idtech   中国文化   加密货币   项目管理   Bitcoin   游戏评论   资源管理   资源流水线   效率   道德经   网络   方法论   模板编程   Lua   Oculus   GDC   渲染   VR   PerfAssist   Unity MemoryProfiler   BCH   经济学   信息过载   行业报告   字体   Productivity   图形   网络编程   Dice   协程   EMC   Premake   测试   中间件   Game Engine   新手引导   Methodology   CI   命令行解析   goroutine   ndk   Ethereum   nanomsg   自动化   Scripting   摘录   Debugging   同步技术   cppcon   C++模板   DOOM3   技术评估   Unity GC   C++11   学习方法   Surface Pro 3   Engine Evaluation   CRT   文化   Blockchain   笔记   golang   图形编程   多线程   ETH   Blockchain Computing   Bitcoin Cash   cppcon14   Bitcoin SV   Visual Studio   Unity Coroutine   跨语言可变参数列表   团队协作   货币   Deployment   Visual Assist   工程改进   Michael Abrash   exp   开放世界   域名   虚拟现实   系统重构   slua   遮挡剔除   完美转发   协作式调度   Modern C++   类型推导   Memory Debugging   个人成长   小故事   BTC   暴雪   产品   历史   错误处理   Unity Profiler   MOD  

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。