實際體驗Span 的驚人表現


前言

最近做了一個過濾代碼塊功能的接口。就是獲取一些博客文章做文本處理,然后這些博客文章的代碼塊太多了,很多重復的代碼關鍵詞如果被拿過來處理,那么會對文本的特征表示已經特征選擇會有很大的影響。所以需要將這些代碼塊的部分給過濾掉。過濾起來很簡單,就是找代碼塊的html 標記,然后將html標記之間的內容給刪除就可以了。代碼塊的html標記一般都是<pre></pre>

我使用了String,Regex,StringBuilder,Span<T>這些不同的方法來實現這個功能,利用BenchMarks比較它們之間的性能差距。

BenchMarks

要對比不同代碼之間的性能差距,還是不用StopWatch來計算消耗時間,這樣簡單的方法,而是使用BenchMarksDotNet包:一個專業的.net core下測試程序性能的工具包。

BenchMarksDotNetgithub地址

這里簡短介紹下BenchMarksDotNet的使用:

首先新建一個需要測試的類:FilterCodeBlocks ,並在類中寫上被測試的方法:FilterCodeBlockByString

 public class FilterCodeBlocks
 {

        public string FilterCodeBlockByString(string content)
        {
                return content;
        }
 }

然后新建一個類: FilterCodeBlocksBenchMark

using System;
using System.IO;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;

namespace QuickSortBenchMarks
{
    [RankColumn]
    [Orderer(SummaryOrderPolicy.FastestToSlowest)]
    [MemoryDiagnoser]
    public class FilterCodeBlocksBenchmarks
    {
            FilterCodeBlocks FilterCodeBlocks = new FilterCodeBlocks();
            [Benchmark]
           public void FilterByString()
           {

               FilterCodeBlocks.FilterCodeBlockByString(s);
           }
    }
}

最后在入口Progam.cs中 寫上

    class Program
    {
        static void Main(string[] args)
         {
                var summary = BenchmarkRunner.Run<FilterCodeBlocksBenchmarks>();
         }
    }

執行dotnet build -c Release 然后 dotnet yourproject.dll 就可以看見BenchMarks測試效果.

鋪墊好東西,現在開始進入正題。

使用 string

首先,直接用string 操作。由於測試博文可能會比較長,會有比較多的代碼塊。所以我的思路是,while(true) 去尋找代碼塊標記,並使用string 的尋址: indexOf() , 拼接:+= 和 剪切:Substring() 完成代碼塊的過濾。過程也很簡單。 這只是解決問題的一種方法,這篇文章的目的不是尋找最優解決方法,而是比較發現使用不同的 "工具" 之間的巨大性能差距。

        private static string _startTag = "<pre";
        private static string _endTag = "</pre>";

        private static int _startTagLength => _startTag.Length;
        private static int _endTagLength => _endTag.Length;
        public FilterCodeBlocks()
        {

        }

        public string FilterCodeBlockByString(string content)
        {
            string result = "";
            while (true)
            {
                var startPos = content.IndexOf(_startTag, StringComparison.CurrentCulture);
                if (startPos == -1)
                    break;

                var content2 = content.Substring(startPos + _startTagLength, content.Length - startPos - _startTagLength);
                var endPos = content2.IndexOf(_endTag, StringComparison.CurrentCulture);
                result += content.Substring(0, startPos);
                content = content2.Substring(endPos + _endTagLength, content2.Length - endPos - _endTagLength);
            }
            result += content;
            return result;
        }

一開始選取了比較短的文本進行測試 ,可以直接寫在程序中:

[RankColumn]
    [Orderer(SummaryOrderPolicy.FastestToSlowest)]
    [MemoryDiagnoser]
    public class FilterCodeBlocksBenchmarks
    {
        FilterCodeBlocks FilterCodeBlocks = new FilterCodeBlocks();
        public static string s = "<p>我們通過IndexWriterConfig 可以設置IndexWriter的屬性," +
                            "已達到我們希望構建索引的需求,這里舉一些屬性,這些屬性可以影響到IndexWriter寫入索引的速度:" +
                            "</p>\n<div class=\"cnblogs_code\">\n<pre>IndexWriterConfig.setRAMBufferSizeMB" +
                            "(<span style=\"color: #0000ff;\">double</span><span style=\"color: #000000;\">);" +
                            "\nIndexWriterConfig.setMaxBufferedDocs(</span><span style=\"color: #0000ff;\">int</span><span " +
                            "style=\"color: #000000;\">);\nIndexWriterConfig.setMergePolicy(MergePolicy)</span></pre>\n</div>\n<p>" +
                            "setRAMBufferSizeMB()&nbsp;是設置";
        [Benchmark]
        public void FilterByString()
        {

            FilterCodeBlocks.FilterCodeBlockByString(s);
        }
   }

按照上述的方法,運行dll 得出 使用string 相關方法的性能。

平均處理時間 48微秒 分配內存 1.41kb,看來效果也是不錯的,我感覺上面的代碼中方法也是大家都會經常使用的方法。

接下來 .NET Core 2.1的新特性: Span 隆重登場!

Span< T >

What is a Span< T >?

Span< T > : 結構體值類型 。相當於C++ 中的指針,它是一段連續內存的引用,也就是一段連續內存的首地址。有了Span< T >,我們就可以不在unsafe的代碼塊中寫指針了。Span< char > 相對於 string 也就具有很大的性能優勢。

舉個栗子: string.Substring() 函數,實際上是在堆中額外創建了一個新的 string 對象,把字符 copy 過去,再返回這個對象的引用。而相對應的 Span< T > 的Slice() 函數則是直接在內存中返回子串的首地址引用,此過過程幾乎不分配內存,並且十分高效。

后面的優化也是使用Span< T > 的Slice() 代替了 string 的SubString()

簡單看下 Span< T > 的源碼,就可以窺見 Span< T > 的奧秘:

 public readonly ref partial struct Span<T>
    {
        /// <summary>A byref or a native ptr.</summary>
        internal readonly ByReference<T> _pointer;
        /// <summary>The number of elements this Span contains.</summary>

        private readonly int _length;
         
        ....
        
        public Span(T[] array)
        {
            if (array == null)
            {
                this = default;
                return; // returns default
            }
            if (default(T) == null && array.GetType() != typeof(T[]))
                ThrowHelper.ThrowArrayTypeMismatchException();

            _pointer = new ByReference<T>(ref Unsafe.As<byte, T>(ref array.GetRawSzArrayData()));
            _length = array.Length;
         }
     }

Span< T > 內部主要就是一個ByReference< T > 類型的對象,實際上就是ref T: 一個類型的引用,它和C 的int* char* 如出一折。 Span < T > 也就是建立 ref 的基礎上。

限定長度: _length ,就像 C 中定義指針,在使用前需要 malloc 或者 alloc 分配固定長度的內存。關於Span< T > 更多詳細知識:

https://msdn.microsoft.com/en-us/magazine/mt814808.aspx

使用 Span< T > 優化

將上述 string 代碼使用 Span< char > 優化一下

public string FilterCodeBlockBySpanAndToString(ReadOnlySpan<char> content)
        {
            string result = "";
            ReadOnlySpan<char> contentSpan2 = new ReadOnlySpan<char>();
            int startPos = 0;
            int endPos = 0;

            ReadOnlySpan<char> startTagSpan = _startTag.AsSpan();
            ReadOnlySpan<char> endTagSpan = _endTag.AsSpan();
            while (true)
            {
                startPos = content.IndexOf(startTagSpan);
                if (startPos == -1)
                    break;

                contentSpan2 = content.Slice(startPos + _startTagLength, content.Length - startPos - _startTagLength);
                endPos = contentSpan2.IndexOf(endTagSpan);
                result += content.Slice(0, startPos).ToString();
                content = contentSpan2.Slice(endPos + _endTagLength, contentSpan2.Length - endPos - _endTagLength);
            }
            result += content.ToString();
            return result;

        }

這里 ReadOnlySpan<char> 是 Span< char > 的只讀類型。使用Slice 代替SubString 。上述代碼我依然返回的是 string。為了得到 string,我不惜使用Span< T > 的ToString() 函數,在我印象中,這個操作會把Span 的優勢給拉回起跑線。

接下來看測試結果:

真是大吃一驚,平均消耗時間,居然少了 48000 納秒,Span< T > 只是 string 的不到百分之一消耗。內存消耗減少了一半

Span< T >果然名不虛傳,正如前面所說的SubStringSlice 之間的性能差距。

Span< T > 的特色

雖然Span< T > 的性能十分出色 ,但是 string 有太多完善的接口,string 是為了簡化你的代碼讓你更加舒服的使用字符串,所以犧牲了性能。因此 在對計算機消耗要求十分的嚴苛的情況下,嘗試使用Span< T > ,大多數情況下,簡短的string 已經能滿足需求。我的認知下的Span< T >的特色:

  • Span< T >的定義方法多種多樣,可以直接 ( i ) 像定義數組那樣 : Span<int> a = new int[10]; ( ii ) 在構造函數中直接傳入 數組(指針+長度)Span<T> a = new Span<T>(T[]),Span<T> a = new Span<T>(void*,length) ; ( iii )可以直接在棧中分配內存:Span<char> a = stackalloc char[10]; 在C# 8.0中才可以,這樣的寫法真是高大上。

  • Span< T > 只能存在於棧中,而不能放在堆中。因為 ( i ) GC 在堆中很難跟蹤這些指針, ( ii ) 在堆中會出現多線程, 如果兩個線程的兩個Span< T >指向了同一個地址,那就糟了。

  • 可以使用 Memory< T > 代替 Span< T >在堆中使用。

  • 所有 string 的接口都可以用 Span< char > 來實現,這似乎又回到了原始的C語言時代。

  • Span < T > 有個兄弟叫 ReadOnlySpan< T > 。

到這里還不能結束Span< T >的性能評測。因為在大量字符串處理中還有個隱藏的實力派:正則表達式 Regex

正則表達式

如果我們使用正則表達式呢,它的性能會是如何呢?

正則表達式的實現:

  private static Regex _codeTag = new Regex("(<pre(.*?)>)(.|\n)*?(</pre>)", RegexOptions.Compiled);
  public string FilterCodeBlocByRegex(string content)
  {
            return _codeTag.Replace(content, string.Empty);
  }

真是簡短的讓人看着就舒服。正則表達式的長處是在大文本處理,所以我決定直接將字符串變成100篇博客的內容加在一起。下面就是測試結果:

Incredible! 正則表達式 真的是一匹黑馬,直逼Span< T >,時間消耗僅為10.68ms,內存消耗只有7.69MB。難得的是它的內存消耗也比Span< T >低。

為什么Regex會有這么好的表現呢?翻閱一下源碼,原來如此!

private static string Replace(MatchEvaluator evaluator, Regex regex, string input, int count, int startat)
{
    ....
    Span<char> charInitSpan = stackalloc char[ReplaceBufferSize];
    var vsb = new ValueStringBuilder(charInitSpan);
}

.net core 2.2 中,Regex的 Replace 內部用了 Span< char > 重新實現。看來,正則表達式的高性能表現 和 Span 不無關系。

根據園友的評論,Regex 以前的版本,也是通過指針來進行操作,我也實驗了 .net standard的Regex , 二者效率差不多。

Span < T > 很優秀,但是為了解決 string 的性能問題,C# 早早就有了 StringBuilder 。於是我讓了字符串處理界的大師:StringBuilder, 來助 Span< T > 一臂之力。

StringBuilder + Span< T >

 public string FilterCodeBlockBySpanAndStringBuilder(ReadOnlySpan<char> content)
        {
            var result = new StringBuilder(content.Length);

            var contentSpan2 = new ReadOnlySpan<char>();
            var startPos = 0;
            var endPos = 0;

            var startTagSpan = _startTag.AsSpan();
            var endTagSpan = _endTag.AsSpan();
            while (true)
            {
                startPos = content.IndexOf(startTagSpan);
                if (startPos == -1)
                    break;

                contentSpan2 = content.Slice(startPos + _startTagLength, content.Length - startPos - _startTagLength);
                endPos = contentSpan2.IndexOf(endTagSpan);
                result.Append(content.Slice(0, startPos));
                content = contentSpan2.Slice(endPos + _endTagLength, contentSpan2.Length - endPos - _endTagLength);
            }
            result.Append(content);
            return result.ToString();

        }

將原先的 字符串拼接變成了 StringBuilder 的 append函數,而且減少了我心心念念的ToString()次數。在 .net core 2.2 中StringBuilder的內部也有 Span< T >的身影。

Append 函數可以直接接受Span< T >的參數。接下來看看武裝到牙齒的Span< T >性能如何。

unbelievable ! 使用 StringBuilder 的Span< T >時間消耗居然只有 867.1微妙,內存消耗只有1.7MB ,在各個方面都技壓群雄。又是百分之一的消耗。

實際上 StringBuilder的內部操作字符串的 是一個 char 數組,它的 Apend 的性能如此之高,還是因為內部使用了指針。

 			unsafe
                        {
                            fixed (char* valuePtr = value)
                            fixed (char* destPtr = &chunkChars[chunkLength])
                            {
                                string.wstrcpy(destPtr, valuePtr, valueLen);
                            }
		      }

StringBuilder 只能支持字符串,但是Span< T >可是泛型的哦。不過,程序中最消耗CPU的大都是一些字符串的處理。

結語

在實際中體驗了Span< T >的驚人表現。同時 .NET Core 在Span< T >加入之后,各個地方都有性能的提升,比如說Regex。 真是讓開發者何其幸哉。

在Regex 中的源代碼,我看到了一個 ValueStringBuilder 一個內部的結構體,只能在System/Text 的內部中使用。它是一個結構體!它的構造函數可以直接傳入 Span< char >,我將它 copy 出來,代替StringBuilder , 時間消耗不分伯仲,但是內存消耗又減少了一半!。這應該是極致的性能表現。鑒於篇幅原因就不展開了。

可以在 這里 看到ValueStringBuilder,以及完整的代碼。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM