C# 內(nèi)存與性能優(yōu)化

圖片來自于網(wǎng)絡(luò)

前兩周分享了資源配置與資源管理,今天分享一種特殊的資源腳本數(shù)據(jù)。在Unity項目中,我們通常使用C#編寫腳本,C#是一門非常方便的語言可以幫助我們快速開發(fā)。不過也有一些要點需要關(guān)注,影響內(nèi)存與性能。

字符串String

首先要關(guān)注String,String沒有看起來那么簡單,什么是String呢?

  • String是一個UTF-16編碼的文本
  • String是一個引用類型
  • String是不可變的

在C#里面,字符串是一個引用類型而不是一個值類型,即使看起來像是持有一個值類型對象并可以方便的修改,這里修改字符串是創(chuàng)建一個新的字符串。通常建議使用StringBuilder來拼接字符串,下面看看不同行為的拼接字符串帶來的性能差異吧。

public class MonoTest : MonoBehaviour {
    const int SIZE = 1024;
    void Update () {
        _UpdateStringAppend();
        _UpdateStringFormat();
        _UpdateStringBuild();
    }
    string _UpdateStringAppend() {
        string str = string.Empty;
        for (int i = 0; i < SIZE; ++i) {
            str += i;
        }
        return str;
    }
    string _UpdateStringFormat() {
        string str = string.Empty;
        for (int i = 0; i < SIZE; ++i) {
            str += string.Format("{0}", i);
        }
        return str;
    }
    string _UpdateStringBuild() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < SIZE; ++i) {
            sb.Append(i);
        }
        return sb.ToString();
    }
}
Func Time ms GC Alloc
StringAppend 9.09ms 2.9M
StringFormat 20.97ms 3.0M
StringBuilder 4.76ms 48.0KB

觀察數(shù)據(jù)可以發(fā)現(xiàn)StringBuilder在性能上和GC上都有極大的提升,每次創(chuàng)建一個新的字符串,字符串長度從0增長到n,這是一個O(n^2)的操作。而StringBuilder則只會在長度不夠的時候重新申請并賦值,如果內(nèi)部的長度是按2遞增的話,這里的復(fù)雜度是O(nlogn)。如果設(shè)定一個足夠大的預(yù)初始值,那這里的復(fù)雜度則可以降低到O(n)。

這里的內(nèi)存申請的量級和運算復(fù)雜度也是一樣的,關(guān)注大小與分配次數(shù)。過多的分配次數(shù)會導(dǎo)致堆碎片變多,過多的內(nèi)存分配則會導(dǎo)致觸發(fā)內(nèi)存清理GC,這是額外的無必要的開銷。所以推薦盡可能的使用StringBuilder來優(yōu)化這個操作,同時StringBuilder本身也會在內(nèi)部申請內(nèi)存,復(fù)用StringBuilder能進一步優(yōu)化內(nèi)存。

再來看看之前討論里面被忽略的Format拼接,F(xiàn)ormat表現(xiàn)最差有點出人意外又在情理之中。我們在平時被建議使用Format來拼接字符串,但在有些情況下Format的表現(xiàn)非常差。這里就是一個不恰當(dāng)?shù)腇ormat使用案例,這是一個冗余的Format操作,同時還多了一次int轉(zhuǎn)object的GC。如果還難以理解,則可以看看下面的Format操作得到了一個更差的結(jié)果。

string _UpdateStringFormatEx() {
    string str = string.Empty;
    for (int i = 0; i < SIZE; ++i) {
        str = string.Format("{0}{1}", str, i);
    }
    return str;
}
Func Time ms GC Alloc
StringAppend 9.09ms 2.9M
StringFormat 20.97ms 3.0M
StringBuilder 4.76ms 48.0KB
StringFormatEx 40.13ms 8.6M

其實一個正確的Format用法是下面這樣的

string str = string.Format("{0}{1}....{n}", 0, 1, ..., n);

通過把多次拼接操作合并成一次來達到減少GC提高效率,實際Format的內(nèi)部使用了StringBuilder來拼接字符串。N次使用StringBuilder來拼接字符串的性能與1次的操作性能有較大差異,這是平時使用中也需要注意的。

優(yōu)化字符串?dāng)?shù)量

字符串是不可變,每次修改字符串都會生成一個新的字符串,那創(chuàng)建的字符串呢?盡管實驗得到,每次創(chuàng)建字符串都會得到一個新串,即使已經(jīng)存在一個相同的字符串。這里有一篇顧露分享的《Unity 游戲的 string interning 優(yōu)化》已經(jīng)做了這塊內(nèi)容詳細描述。這里通過string.Intern來減少字符串?dāng)?shù)量達到優(yōu)化內(nèi)存的效果,同時讓我發(fā)現(xiàn)了項目中存在著大量的字符串使用。如何更進一步的減少字符串?dāng)?shù)量是個有趣的問題。

通過顧露的自制工具PA_ResourceTracker采集的數(shù)據(jù),分析數(shù)據(jù)發(fā)現(xiàn)字符串?dāng)?shù)據(jù)里面存在較多的資源加載路徑。這些路徑數(shù)據(jù)非常的長,而且數(shù)量也非常的多。字符串路徑的作用是標(biāo)識資源,考慮使用Hash來標(biāo)識資源也可以做到相同的事情。

Resources.Load(string path, Type type);

Resources.Load(ulong pathHash, Type type);
Resources.PathToHash(string path);

在資源管理上實現(xiàn)兩個新增的接口,支持按Hash加載資源,然后提供一個字符串路徑轉(zhuǎn)Hash的接口,來實現(xiàn)這一目標(biāo)。

public class Template
{
   public int id = -1;
   public string name;
   public string path;
}
public class Template
{
   public int id = -1;
   public string name;
   public ulong pathHash;
}

然后替換結(jié)構(gòu)體里面的變量為Hash,在第一次得到這個字符串后立刻調(diào)用Resources.PathToHash計算Hash值并存儲。

計算路徑的Hash還需要考慮路徑的大小寫、斜杠與放斜杠。

public ulong PathToHash(string str) {
    ulong hashCode = 0;
    for (int i = 0; i < str.Length; ++i) {
        char ch = Char.ToUpperInvariant(str[i]);
        if (str[i] == '\\')  { ch = '/'; }
        hashCode = (hashCode << 7) + (hashCode << 1) + hashCode + ch;
    }
    return hashCode;
}

使用ulong降低Hash的沖突,由于存在沖突的可能,這里在日常構(gòu)建的時候?qū)λ械馁Y源路徑計算Hash判斷是否有沖突。這里路徑Hash不僅減少了對象數(shù)量,也減少了一些字符串修改操作導(dǎo)致的GC。下面舉個降低的例子,得到唯一字符串路徑。

public string GetUniString(string str) {
    return str.Replace('\\', '/').ToUpperInvariant();
}

這實在是一個低效的行為,所以即使你不需要縮減字符串個數(shù),還是強烈推薦使用Hash來做唯一標(biāo)識符。

由于Unity提供的Resources接口需要使用路徑字符串來加載資源,所以之前說了那么多還沒有解釋為什么可以減少字符串對象這個問題。這里我們項目能使用主要是由于使用了AssetBundle。只需要先存Hash對應(yīng)的AssetBundle ID然后加載這個AssetBundle的時候加載Hash對應(yīng)Name即可。AssetBundle支持直接使用Name加載,也可以使用Asset Path加載。這里的AssetPath是相對于Assets目錄的路徑與Resources的相對于Resources目錄還是有差異的,所以使用Name來加載。AssetBundle本身就有一個接口AssetBundle.GetAllAssetNames()獲取所有資源路徑。不過這里會包含被依賴的所有資源路徑,所以一般自己存這個數(shù)據(jù)。

細心的人也注意到了上面提到的AssetBundle ID,由于AssetBundle打包是可以完全控制的。所以給AssetBundle命名一個數(shù)字ID,也是有效的減少字符串?dāng)?shù)量的方法。這對使用AssetBundle打包加載資源的項目是一個不錯的參考。我們實現(xiàn)自己的AssetBundleManifest維護AssetBundle之間的依賴關(guān)系。

Unity的Animator類提供了StringToHash接口來幫助消除字符串,同時配套提供兩套接口可以調(diào)用,和這里消除字符串路徑的思路是一致的。相信還有其他地方也可以通過這個思路來消除字符串優(yōu)化性能。

最后這里做字符串轉(zhuǎn)路徑這個實現(xiàn)是由于游戲在開始的時候就加載了大部分配置表,表現(xiàn)里面有著大量路徑字符串。在工具里面發(fā)現(xiàn)路徑字符串的比重大概在20%,所以來做這項工作優(yōu)化對象數(shù)量。帶來了不錯的性能收益,不過如果出錯只能看到hash而不能看到實際字符串。不過可以通過區(qū)分DEBUG與RELEASE版本來決定是否保留這些字符串。

優(yōu)化字符串比較

默認的字符串比較操作是非常低效的,《Best Practices for Using Strings in .NET》這篇文章講了這方面的大部分細節(jié)。這里主要展示一些實踐測試數(shù)據(jù),讓我們對性能有一個認識。

StringBuilder sBuilder = new StringBuilder();
System.Random random = new System.Random();
for (int i = 0; i < 100; ++i)
{
   sBuilder.Append((char)(random.Next() % 256));
}
string str = sBuilder.ToString();
string preStr = str.Substring(0, 16);
string lastStr = str.Substring(str.Length - 16, 16);
int cnt = 0;
for (int i = 0; i < 100 * 1024; ++i)
{
    if (str.StartsWith(preStr)) ++cnt;
    if (str.EndsWith(lastStr)) ++cnt;
}

測試結(jié)果

Method Time(ms) 100k compares
String.StartsWith,default culture 360ms
String.EndsWith,default culture 12465ms
String.StartsWith,Ordinal 357ms
String.EndsWith,Ordinal 174ms
CustomStartsWith 18ms
CustomEndsWith 17ms
Func Name Default interpretation
String.Compare StringComparison.CurrentCulture
String.CompareTo StringComparison.CurrentCulture
String.Equals StringComparison.Ordinal
String.ToUpper StringComparison.CurrentCulture
Char.ToUpper StringComparison.CurrentCulture
String.StartsWith StringComparison.CurrentCulture
String.IndexOf StringComparison.CurrentCulture

字符串比較接口默認行為

Func Name Default interpretation
String.Compare StringComparison.CurrentCulture
String.CompareTo StringComparison.CurrentCulture
String.Equals StringComparison.Ordinal
String.ToUpper StringComparison.CurrentCulture
Char.ToUpper StringComparison.CurrentCulture
String.StartsWith StringComparison.CurrentCulture
String.IndexOf StringComparison.CurrentCulture

正常情況下使用Ordinal比較即可,自己實現(xiàn)Ordinal行為的比較還可以提高10倍的性能。

從容器談Boxing

泛型容器內(nèi)部實現(xiàn)會調(diào)用一些System.Object接口,如果我們不實現(xiàn)對應(yīng)的泛型接口,在調(diào)用接口的時候就會找到基類Object的接口。而由于Struct是一個值類型,value type轉(zhuǎn)class type會觸發(fā)內(nèi)存分配,定義這種行為為Boxing。《c-performance-tips-for-unity-part-2-structs-and-enums》這篇文章已經(jīng)對這塊做了詳細描述與舉例。我自己也做了一些數(shù)據(jù)測試,分享給大家做參考。

public struct SmallStruct
{   // 2 int fields. Total size: 2 * 4B + 16B = 24B
    public int a, b;
}
public struct LargeStruct
{   // 20 int fields. Total size: 20 * 4B + 16B = 96B
    public int a, b,  /* … */;
}
// Dictionary<Struct, bool> dict
// 1024 calls dict. ContainsKey
Struct GC Alloc Time ms
SmallStruct 72.0KB 2.50ms
LargeStruct 288.0KB 11.05ms
SmallStruct GC Alloc Time ms
None 72.0KB 2.50ms
IEquatable<T> 24.0KB 1.77ms
GetHashCode 48.0KB 2.57ms
GetHashCode,IEquatable<T> 0.0KB 1.81ms

實現(xiàn)了不同接口之后

SmallStruct GC Alloc Time ms
None 72.0KB 2.50ms
IEquatable<T> 24.0KB 1.77ms
GetHashCode 48.0KB 2.57ms
GetHashCode,IEquatable<T> 0.0KB 1.81ms

觀察發(fā)現(xiàn)Dictionary內(nèi)部使用 EqualityComparer

public abstract class EqualityComparer<T>
{
    protected EqualityComparer();
    public static EqualityComparer<T> Default { get; }
    public abstract bool Equals(T x, T y);
    public abstract int GetHashCode(T obj);
}

如果沒有實現(xiàn)還GetHashCode觸發(fā)一次boxing,而Equals則觸發(fā)兩次。實現(xiàn)IEquatable泛型接口,以及override int GetHashCode則可避免觸發(fā)GC。非泛型的HashTable實現(xiàn)和泛型Dictionary基本一致,推薦使用Dictionary泛型版本,提高性能。

其他Tips

void DispatchEvent(string str, params object[] data);

static object[] _default = new object[] {};
void DispatchEvent(string str)  {
    _DispatchEvent(str, _default);
}
void _DispatchEvent(string str, object[] data);

params object每次調(diào)用會申請一個object數(shù)組,對于無參數(shù)的行為,實現(xiàn)一個默認接口減少GC。

一般情況下使用Profile Windows排查不必要的GC Alloc。

Profiler window

這個工具能幫助我們定位發(fā)生GC Alloc行為的代碼,通常第一步優(yōu)化那些每幀都存在的GC,之后優(yōu)化那些峰值很高的GC。優(yōu)化GC能帶來什么好處呢,假設(shè)當(dāng)前使用了30M內(nèi)存,申請了50M內(nèi)存。這里有20M的空間可以用于日常的GC Alloc。假設(shè)我們每幀的GC Alloc=100K,則20 * 1024 / 100 = 204幀。如果每幀的執(zhí)行時間為33ms(30幀),則6.76S觸發(fā)一次GC.Collect()。這個函數(shù)開銷在100ms以上,當(dāng)前幀的開銷從33ms變成133ms,這會有明顯的卡頓感。更多的GC優(yōu)化可以參考《Structing out code to minimize the impact of garbage collection》

從Struct再談優(yōu)化對象數(shù)量

從Rich Geldreich的《Lessons Learned While Fixing Memory Leaks in our First Unity Title》了解到對象數(shù)量過大造成額外的內(nèi)存使用。這里再次談對象數(shù)量優(yōu)化,優(yōu)化內(nèi)存使用。

The Boehm collector grows its OS memory allocation so it has enough internal heap headroom to avoid collecting too frequently. You must factor this headroom into account when budgeting your C# memory, i.e. if your budget calls for 25MB of C# memory then the actual amount of memory consumed at the OS level will be significantly larger (approximately 40-50MB in our experience).

這里主要討論配置表,配置表一般是一種Key-Value結(jié)構(gòu),同時在運行時我們不需要修改內(nèi)存,最后配置表的總量和數(shù)量會非常多。

public class Dicitonary<TKey, TValue> { 
    private int[] m_buckets;
    private int[] m_entryNext;
    private int[] m_entryHash;
    private TKey[] m_entryKey;
    private TValue[] m_entryValue;
}
public class PlayerTemplate {
    public int id;
    public ulong pathHash;
    public float height;
    /* ... more data */
} // assume size = 128B
Dictionary<int, PlayerTemplate> dict;

一般使用Dictionary存儲配置表數(shù)據(jù),上面定義的配置表數(shù)據(jù)類型為class,則可以得到下面的數(shù)據(jù)。

Set PlayerTemplate Count = 5000;
// 第一個大于Count * 2的素數(shù)
Dictionary ArraySize = 10103; 

ObjectCount = 5000 + 5 + 1 = 5006;
MemorySize = 5000 * 128B + 10103 * 24 = 882472B = 861.8KB 

之后我們把class改struct

public struct PlayerTemplate {/* … */}

ObjectCount = 5 + 1 = 6;
MemorySize = 10103 * 128B + 10103 * 16 = 1454832B = 1420.7KB

對象數(shù)量減少后的代價是內(nèi)存使用的增長,下面來看怎么優(yōu)化內(nèi)存使用。

public interface ITableType<TKey, TValue> {
    TKey GetKey();
}
public class TableOrderList<Tkey, TValue> {
    private bool m_sorted;
    private TValue[] m_data;
    private int m_size;
}
public struct PlayerTemplate : ITableType<int, PlayerTemplate> {
    public int GetKey() {
        return id;
    }
    public int id;
    public ulong pathHash;
    public float height;
    /* ... more data */
}

自定義容器與接口實現(xiàn)線性內(nèi)存空間存儲數(shù)據(jù)。

public int LowerBounder(TKey key) {
    int low = 0, high = m_size;
    while (low < high) {
        int mid = (low + high) >> 1;
        if (m_list[mid].GetKey().CompareTo(key) < 0) {
            low = mid + 1;
        } else { 
            high = mid;
        }
    }
    return low;
}

通過二分查找數(shù)據(jù),再數(shù)據(jù)加載結(jié)束后進行一次排序。最后的數(shù)據(jù)對比如下。

Type Object Count Memory Use Complexity
Class,Dictionary 5006 861.8KB O(1)
Struct,Dictionary 6 1420.7KB O(1)
Struct,TableOrderList 1 625KB O(logn)

新實現(xiàn)的容器再對象數(shù)量與內(nèi)存使用上都有著較大優(yōu)勢,由于一般游戲很難有超過1W以上的數(shù)據(jù)量,這里O(logn)與O(1)的差距較小可以接受,而且一般這里也不是性能瓶頸。

Struct只能整存整取,Class則可以簡易的修改成員變量。但是對于只讀的數(shù)據(jù)來說,使用Struct來存儲數(shù)據(jù)有極大的優(yōu)勢。更多Struct與Class的討論可以參考《What's the difference between struct and class in .NET》

[完 Carber 2017-08-11]

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容