歡迎光臨
每天分享高質量文章

實際體驗Span 的驚人表現

作者:Shendu.CC

連結:http://www.cnblogs.com/dacc123/p/10644816.html

前言

最近做了一個過濾程式碼塊功能的介面。就是獲取一些部落格文章做文字處理,然後這些部落格文章的程式碼塊太多了,很多重覆的程式碼關鍵詞如果被拿過來處理,那麼會對文字的特徵表示已經特徵選擇會有很大的影響。

所以需要將這些程式碼塊的部分給過濾掉。過濾起來很簡單,就是找程式碼塊的html 標記,然後將html標記之間的內容給刪除就可以了。

程式碼塊的html標記一般都是


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

BenchMarks

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

BenchMarksDotNet的github地址https://github.com/stevejgordon/BenchmarkAndSpanExample/tree/Benchmarks

這裡簡短介紹下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();
     }
}

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

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

使用 string

首先,直接用string 操作。由於測試博文可能會比較長,會有比較多的程式碼塊。所以我的思路是,while(true) 去尋找程式碼塊標記,並使用string 的定址: indexOf() , 拼接:+= 和 剪下:Substring() 完成程式碼塊的過濾。過程也很簡單。 

這隻是解決問題的一種方法,這篇文章的目的不是尋找最優解決方法,而是比較發現使用不同的 “工具” 之間的巨大效能差距。

private static string _startTag = "
;
private static string _endTag = "

 

“;

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>

<div class=”cnblogs_code“>
<pre>IndexWriterConfig.setRAMBufferSizeMB” +
“(<span style=”color: #0000ff;”>doublespan><span style=”color: #000000;”>);” +

IndexWriterConfig.setMaxBufferedDocs(span><span style=”color: #0000ff;”>intspan><span ” +
style=”color: #000000;”>);
IndexWriterConfig.setMergePolicy(MergePolicy)span>pre>
div>
<p>” +
“setRAMBufferSizeMB() 是設定”;
[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
{
    /// A byref or a native ptr.

internal readonly ByReference _pointer;
/// The number of elements this Span contains.
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(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 是 Span< char > 的只讀型別。

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

接下來看測試結果:

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

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

Span< T > 的特色

雖然Span< T > 的效能十分出色 ,但是 string 有太多完善的介面,string 是為了簡化你的程式碼讓你更加舒服的使用字串,所以犧牲了效能。

因此 在對計算機消耗要求十分的嚴苛的情況下,嘗試使用Span< T > ,大多數情況下,簡短的string 已經能滿足需求。我的認知下的Span< T >的特色:

  • Span< T >的定義方法多種多樣,可以直接 ( i ) 像定義陣列那樣 : Span a = new int[10]; ( ii ) 在建構式中直接傳入 陣列(指標+長度)Span a = new Span(T[]),Span a = new Span(void*,length) ; ( iii )可以直接在棧中分配記憶體:Span 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("(
)(.|
)*?(

 

)”, 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很優秀,但是為瞭解決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的引數。接下來看看武裝到牙齒的Span效能如何。

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的驚人表現。同時 .NET Core 在Span加入之後,各個地方都有效能的提升,比如說Regex。 真是讓開發者何其幸哉。

在Regex中的原始碼,我看到了一個ValueStringBuilder一個內部的結構體,只能在System/Text 的內部中使用。

它是一個結構體!它的建構式可以直接傳入Span,我將它copy出來,代替StringBuilder , 時間消耗不分伯仲,但是記憶體消耗又減少了一半!。

這應該是極致的效能表現。鑒於篇幅原因就不展開了。

可以在看到ValueStringBuilder

https://github.com/SilentCC/MyTestBenchMarks)以及完整的程式碼。

已同步到看一看
贊(0)

分享創造快樂