在C#中,數據類型分為值類型和引用類型兩種。 引用類型變數存儲的是數據的引用,數據存儲在數據堆中,而值類型變數直接存儲數據。對於引用類型,兩個變數可以引用同一個對象。因此,對一個變數的操作可能會影響另一個變數引用的對象。對於值類型,每個變數都有自己的數據副本,並且對一個變數的操作不可能影響另一個變數 ...
作者: zyl910
目錄一、引言
C#沒有直接提供對數據進行重新解釋(C++的 reinterpret_cast)的功能,而在使用向量類型時,經常需要做這種操作。例如 第2篇文章,用了3種辦法——
- 事先將基元類型數組轉為了向量類型數組(SumVectorAvx).
- 使用Span改進數據載入(SumVectorAvxSpan).
- 使用指針改進數據載入(SumVectorAvxPtr).
第1種辦法其實是將全部數據搬運了一遍,開銷大。不適合生產使用,僅能用於教學演示。
剩下2種辦法雖然能用,但還是存在一些缺點的——
- Span:Span的長度(Length)是32位有符號整數(Int32),且索引一般不能為負數,導致它的地址範圍只有31位,難以處理超過2GB的數據。而現在64位平臺已經普及了,有時存在“處理超過2GB的數據”的需求。官方說了“我們決定將其保留為 int”(We decided to keep it as an int),Span的位數升級無望。
- 指針:雖然在C#中能夠使用指針語法,但是只能在用unsafe關鍵字申明的“非安全代碼”里使用,且項目配置里需啟用“允許非安全代碼”(Allow unsafe code),使用比較繁瑣。而且對於開發類庫等嚴格審查的場合,有時會規定不啟用“允許非安全代碼”。其次C#的指針還存在 “不支持泛型”、“有時需要fixed”等缺點。
有沒有更好的辦法呢?
二、辦法說明
最開始毫無頭緒,直到我分析了Span的源碼後,才發現C#如今能用引用來做重新解釋,從而解決上述難題。
而且如今引用的功能非常強大,能完全代替指針操作,且能擺脫unsafe關鍵字。適合不啟用“允許非安全代碼”等嚴格的場合。
2.1 歷史
C# 1.0 就支持了“ref”關鍵字,但僅能用於參數列表,用來表示“引用方式傳參”。
C# 7.0 新增了“局部引用和引用返回”(Ref locals and returns)特性。自此C#的引用(ref)與C++的引用(&),功能很接近了.
C# 7.3 新增了“重新分配局部引用變數”(Reassign ref local variables)特性。C#的引用(ref)與C++的引用(&),幾乎功能一致了.
再加上Unsafe類提供了 引用地址調整、引用地址比較、重新解釋(C++的 reinterpret_cast)、引用取消只讀(類似C++的 const_cast) 等功能. 使得C# 引用(ref)非常強大,能夠完全代替指針(*)操作,徹底擺脫unsafe關鍵字。
引用比指針還多了這些優點——
- 指針不支持泛型,而引用是支持泛型的。
- 指針有時需要fixed,而引用無需使用fixed。
可以簡單理解為這樣——如今C#里的引用,比指針的功能更強大。
於是在一些官方文檔中,將引用(ref)稱呼為“托管指針”(Managed pointer);並將傳統的指針(*),稱呼為“非托管指針”(Unmanaged pointer)。
2.2 局部引用變數與引用所指的值(類似指針的 地址運算符&
、間接運算符*
)
摘自官方文檔。
https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/statements/declarations#reference-variables
Reference 變數
聲明局部變數併在變數類型之前添加 ref 關鍵字時,聲明 reference 變數,或 ref 局部變數:
ref int alias = ref variable;
reference 變數是引用另一個變數(稱為引用)的變數。 也就是說,reference 變數是其引用的別名。 向 reference 變數賦值時,該值將分配給引用。 讀取 reference 變數的值時,將返回引用的值。 以下示例演示了該行為:
int a = 1;
ref int alias = ref a;
Console.WriteLine($"(a, alias) is ({a}, {alias})"); // output: (a, alias) is (1, 1)
a = 2;
Console.WriteLine($"(a, alias) is ({a}, {alias})"); // output: (a, alias) is (2, 2)
alias = 3;
Console.WriteLine($"(a, alias) is ({a}, {alias})"); // output: (a, alias) is (3, 3)
上述代碼對應的C語言版代碼如下。
int a = 1;
int* alias = &a;
printf("(a, alias) is (%d, %d)", a, *alias); // output: (a, alias) is (1, 1)
a = 2;
printf("(a, alias) is (%d, %d)", a, *alias); // output: (a, alias) is (2, 2)
*alias = 3;
printf("(a, alias) is (%d, %d)", a, *alias); // output: (a, alias) is (3, 3)
主要是以下幾點的寫法不同:
ref int alias
與C語言的int* alias
: 聲明變數時,類型的前面可加上“ref”關鍵字,表示它是引用類型。類似C語言的變數類型加“*”,表示它是指針類型。alias = ref a
與C語言的alias = &a
: 將引用指向某個變數時,需要給右式中變數的前面加上“ref”關鍵字。類似指針的 地址運算符&
。- 賦值時的
alias
與C語言的*alias
: 引用在直接賦值時,都是讀取或設置其指向的值(並不是更改引用的指向)。而指針須使用 間接運算符*
。
引用可以直接用“.”運算符來訪問成員,不像指針那樣還需使用“指針成員訪問運算符->
”。
2.3 重新分配局部引用變數(類似指針直接賦值)
上面提到引用在直接賦值時,都是讀取或設置其指向的值。那麼怎樣才能更改引用的指向呢?
C# 7.3 新增了“重新分配局部引用變數”(Reassign ref local variables)特性,能夠實現該功能。
摘自官方文檔:
使用 ref 賦值運算符= ref 更改 reference 變數的引用,如以下示例所示:
void Display(int[] s) => Console.WriteLine(string.Join(" ", s));
int[] xs = { 0, 0, 0 };
Display(xs);
ref int element = ref xs[0];
element = 1;
Display(xs);
element = ref xs[^1];
element = 3;
Display(xs);
// Output:
// 0 0 0
// 1 0 0
// 1 0 3
在前面的示例中,element reference 變數初始化為第一個數組元素的別名。 然後,ref 將被重新分配,以引用最後一個數組元素。
上述代碼對應的C語言版代碼如下。
int[] xs = { 0, 0, 0 };
Display(xs);
int* element = &xs[0];
*element = 1;
Display(xs);
element = &xs[(sizeof(xs)/sizeof(xs[0]))-1];
*element = 3;
Display(xs);
規律是——
=
的右邊若有“ref”關鍵字:更改引用的指向。類似指針直接賦值。=
的右邊若沒有“ref”關鍵字:設置其指向的值。類似指針的 間接運算符*
。
上述示例中,演示了引用對數組的訪問。C#中的指針若想訪問數組,一般得使用fixed關鍵字,而引用不用。
註:上述示例還用到了C# 8.0 的一個特性—— 從末尾開始索引運算符^
。^1
就是指末尾的最後1個元素,於是在C語言中是 (sizeof(xs)/sizeof(xs[0]))-1
.
2.4 引用地址調整(類似指針加減法)
指針能通過加減法來調整地址,從而對一片連續的記憶體進行操作。
C#的引用也能夠調整地址,故也能對一片連續的記憶體進行操作。Unsafe類提供了的Add等方法來調整引用的地址,例如以下方法。
Add<T>(T, Int32) 將偏移量添加到給定的托管指針。
Add<T>(T, IntPtr) 將元素偏移量添加到給定的托管指針。
AddByteOffset<T>(T, IntPtr) 將位元組偏移量添加到給定的托管指針。
Subtract<T>(T, Int32) 從給定的托管指針中減去偏移量。
Subtract<T>(T, IntPtr) 從給定的托管指針中減去元素偏移量。
SubtractByteOffset<T>(T, IntPtr) 從給定的托管指針中減去位元組偏移量。
帶“ByteOffset”尾碼的方法,是以位元組為單位的。而沒有該尾碼的方法,是以元素大小為單位的,類似指針加減法。
2.5 引用地址比較(類似指針比較)
指針能夠進行比較。
引用也能夠進行比較。Unsafe類提供了的AreSame等方法來獲得比較結果,例如以下方法。
AreSame<T>(T, T) 確定指定的托管指針是否指向同一位置。
IsAddressGreaterThan<T>(T, T) 返回一個值,該值指示指定的托管指針是否大於另一個指定的托管指針。
IsAddressLessThan<T>(T, T) 返回一個值,該值指示指定的托管指針是否小於另一個指定的托管指針。
2.6 重新解釋(類似C++的 reinterpret_cast)
C++的 reinterpret_cast運算符能夠對數據進行重新解釋。且在C語言(或C#的非安全代碼)中,可以通過對指針進行強制類型轉換,從而對數據進行重新解釋。
對於C#的引用,可以用Unsafe類的As方法對數據進行重新解釋。
摘自官方文檔。
As<TFrom,TTo>(TFrom)
將給定的托管指針重新解釋為指向 TTo類型的新托管指針。
public static ref TTo As<TFrom,TTo> (ref TFrom source);
類型參數
TFrom 要重新解釋的托管指針的類型。
TTo 托管指針的所需類型。
參數
source TFrom 用於重新解釋的托管指針。
返回
TTo 指向 TTo類型的新托管指針。
註解
此 API 在概念上類似於 C++ 的 `reinterpret_cast<>`。 調用方有責任確保強制轉換是合法的。 不會執行運行時檢查。
僅重新解釋托管指針。 引用的值本身將保持不變。 請考慮以下示例。
int[] intArray = new int[] { 0x1234_5678 }; // a 1-element array
ref int refToInt32 = ref intArray[0]; // managed pointer to first Int32 in array
ref short refToInt16 = ref Unsafe.As<int, short>(ref refToInt32); // reinterpret as managed pointer to Int16
Console.WriteLine($"0x{refToInt16:x4}");
此程式的輸出取決於當前電腦的端序。 在 big-endian 體繫結構中,此代碼輸出 0x1234。 在 little-endian 體繫結構上,此代碼輸出 0x5678。
將托管指針從較窄的類型轉換為較寬的類型時,調用方必須確保取消引用指針不會產生超出邊界的訪問。 調用方還負責確保生成的指針正確對齊所引用的類型。
當將托管指針從較窄類型強制轉換為較寬類型時,調用方必須確保對指針的解引用不會導致越界訪問。調用者還負責確保結果指針為引用類型正確對齊。
有關對齊假設的詳細信息,請參閱 ECMA-335,第 I.12.6.2 (“對齊”) 。
2.7 引用取消只讀(類似C++的 const_cast)
在C#中可以用“ref readonly”定義只讀引用。摘自官方文檔。
可以定義 ref readonly 局部變數。 不能為 ref readonly 變數賦值。 但是,可以 ref 重新分配這樣的 reference 變數,如以下示例所示:
int[] xs = { 1, 2, 3 };
ref readonly int element = ref xs[0];
// element = 100; error CS0131: The left-hand side of an assignment must be a variable, property or indexer
Console.WriteLine(element); // output: 1
element = ref xs[^1];
Console.WriteLine(element); // output: 3
可見,在使用“= ref”做引用賦值時,能方便的將“可變引用(ref)”賦值給“只讀引用(ref readonly)”。因為將“能讀寫變數”限製為“只讀變數”,是安全的。
但反向該如何做呢,即怎樣將“只讀引用(ref readonly)”轉為“可變引用(ref)”?
雖然這個操作是不安全的,但是有些時候必須使用。類似C++提供const_cast運演算法,能夠去掉“const”。
Unsafe類的許多方法是僅支持“可變引用(ref)”的,例如“Add”等。當想對“只讀引用(ref readonly)”使用Unsafe類的方法前,需要將“只讀引用(ref readonly)”轉為“可變引用(ref)”。
解決辦法就是使用 Unsafe的AsRef方法。摘自官方文檔。
AsRef<T>(T)
將給定的只讀引用重新解釋為可變引用。
public static ref T AsRef<T> (scoped in T source);
類型參數
T 引用的基礎類型。
參數
source T 要重新解釋的只讀引用。
返回
T 對類型的 T值的可變引用。
註解
此 API 在概念上類似於 C++的`const_cast<>`。調用方負責確保不會將數據寫入引用的位置。 運行時包含基於只讀引用真正不可變的假設的內部邏輯,違反此固定項的調用方可能會在運行時內觸發未定義的行為。
AsRef 通常用於將只讀引用傳遞給方法,例如 Add,接受可變托管指針作為參數。 請看下麵的示例。
int ComputeSumOfElements(ref int refToFirstElement, nint numElements)
{
int sum = 0;
for (nint i = 0; i < numElements; i++)
{
sum += Unsafe.Add(ref refToFirstElement, i);
}
}
如果輸入參數不是 `ref int refToFirstElement`, 而是 `ref readonly int refToFirstElement`,則上一個示例不會編譯,因為不能將只讀引用用作Add的參數。相反,AsRef 可用於刪除不可變性約束並允許編譯成功,如以下示例所示。
int ComputeSumOfElements(ref readonly int refToFirstElement, nint numElements)
{
int sum = 0;
for (nint i = 0; i < numElements; i++)
{
sum += Unsafe.Add(ref Unsafe.AsRef(ref refToFirstElement), i);
}
}
三、將指針代碼改寫為引用代碼
3.1 代碼編寫
我們先來回顧一下指針版的代碼。
private static float SumVectorAvxPtr(float[] src, int count, int loops) {
#if Allow_Intrinsics && UNSAFE
unsafe {
float rt = 0; // Result.
int VectorWidth = Vector256<float>.Count; // Block width.
int nBlockWidth = VectorWidth; // Block width.
int cntBlock = count / nBlockWidth; // Block count.
int cntRem = count % nBlockWidth; // Remainder count.
Vector256<float> vrt = Vector256<float>.Zero; // Vector result.
Vector256<float> vload;
float* p; // Pointer for src data.
int i;
// Body.
fixed(float* p0 = &src[0]) {
for (int j = 0; j < loops; ++j) {
p = p0;
// Vector processs.
for (i = 0; i < cntBlock; ++i) {
vload = Avx.LoadVector256(p); // Load. vload = *(*__m256)p;
vrt = Avx.Add(vrt, vload); // Add. vrt += vsrc[i];
p += nBlockWidth;
}
// Remainder processs.
for (i = 0; i < cntRem; ++i) {
rt += p[i];
}
}
}
// Reduce.
for (i = 0; i < VectorWidth; ++i) {
rt += vrt.GetElement(i);
}
return rt;
}
#else
throw new NotSupportedException();
#endif
}
利用本文上面的知識,可以去掉unsafe關鍵字,將指針改造為引用。代碼如下。
private static float SumVectorAvxRef(float[] src, int count, int loops) {
#if Allow_Intrinsics
float rt = 0; // Result.
int VectorWidth = Vector256<float>.Count; // Block width.
int nBlockWidth = VectorWidth; // Block width.
int cntBlock = count / nBlockWidth; // Block count.
int cntRem = count % nBlockWidth; // Remainder count.
Vector256<float> vrt = Vector256<float>.Zero; // Vector result.
int i;
// Body.
for (int j = 0; j < loops; ++j) {
ref Vector256<float> p0 = ref Unsafe.As<float, Vector256<float>> (ref src[0]); // Pointer for src data.
// Vector processs.
for (i = 0; i < cntBlock; ++i) {
vrt = Avx.Add(vrt, p0); // Add. vrt += vsrc[i];
p0 = ref Unsafe.Add(ref p0, 1);
}
// Remainder processs.
ref float p = ref Unsafe.As<Vector256<float>, float>(ref p0);
for (i = 0; i < cntRem; ++i) {
rt += Unsafe.Add(ref p, i);
}
}
// Reduce.
for (i = 0; i < VectorWidth; ++i) {
rt += vrt.GetElement(i);
}
return rt;
#else
throw new NotSupportedException();
#endif
}
在向量處理(Vector processs)階段,需要向量類型的引用,於是定義了 ref Vector256<float> p0
。因為C#里訪問的引用,會相當於訪問所指向的值,類似自動使用了指針的“間接運算符*
”。即給Avx.Add
傳遞的p0
,是符合參數要求的值。
在餘數處理(Remainder processs)階段,需要基元類型的引用,於是定義了 ref float p
。由於向量處理的迴圈結束時,p0正好移動到首個餘數的位置,所以可以用 Unsafe.As
進行重新解釋,為p賦值(設置引用的指向)。
3.2 測試結果
在我的電腦(lntel(R) Core(TM) i5-8250U CPU @ 1.60GHz
、Windows 10)上運行時,x64、Release版程式的輸出信息為:
BenchmarkVectorCore30
IsRelease: True
EnvironmentVariable(PROCESSOR_IDENTIFIER): Intel64 Family 6 Model 142 Stepping 10, GenuineIntel
Environment.ProcessorCount: 8
Environment.Is64BitOperatingSystem: True
Environment.Is64BitProcess: True
Environment.OSVersion: Microsoft Windows NT 6.2.9200.0
Environment.Version: 3.0.3
RuntimeEnvironment.GetRuntimeDirectory: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\3.0.3\
RuntimeInformation.FrameworkDescription: .NET Core 3.0.3
BitConverter.IsLittleEndian: True
IntPtr.Size: 8
Vector.IsHardwareAccelerated: True
Vector<byte>.Count: 32 # 256bit
Vector<float>.Count: 8 # 256bit
Vector<double>.Count: 4 # 256bit
Vector4.Assembly.CodeBase: file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/3.0.3/System.Numerics.Vectors.dll
Vector<T>.Assembly.CodeBase: file:///C:/Program Files/dotnet/shared/Microsoft.NETCore.App/3.0.3/System.Private.CoreLib.dll
Benchmark: count=4096, loops=1000000, countMFlops=4096
SumBase: 6.871948E+10 # msUsed=4891, MFLOPS/s=837.4565528521774
SumBaseU4: 2.748779E+11 # msUsed=1844, MFLOPS/s=2221.2581344902387, scale=2.65238611713666
SumVector4: 2.748779E+11 # msUsed=1219, MFLOPS/s=3360.1312551271535, scale=4.012305168170632
SumVector4U4: 1.0995116E+12 # msUsed=515, MFLOPS/s=7953.398058252427, scale=9.497087378640778
SumVectorT: 5.497558E+11 # msUsed=610, MFLOPS/s=6714.754098360656, scale=8.018032786885247
SumVectorTU4: 2.1990233E+12 # msUsed=187, MFLOPS/s=21903.74331550802, scale=26.155080213903744
SumVectorAvx: 5.497558E+11 # msUsed=609, MFLOPS/s=6725.7799671592775, scale=8.0311986863711
SumVectorAvxSpan: 5.497558E+11 # msUsed=610, MFLOPS/s=6714.754098360656, scale=8.018032786885247
SumVectorAvxRef: 5.497558E+11 # msUsed=609, MFLOPS/s=6725.7799671592775, scale=8.0311986863711
SumVectorAvxPtr: 5.497558E+11 # msUsed=610, MFLOPS/s=6714.754098360656, scale=8.018032786885247
SumVectorAvxU4: 2.1990233E+12 # msUsed=328, MFLOPS/s=12487.80487804878, scale=14.911585365853659
SumVectorAvxSpanU4: 2.1990233E+12 # msUsed=312, MFLOPS/s=13128.205128205129, scale=15.676282051282053
SumVectorAvxPtrU4: 2.1990233E+12 # msUsed=172, MFLOPS/s=23813.95348837209, scale=28.436046511627907
SumVectorAvxPtrU16: 8.386202E+12 # msUsed=188, MFLOPS/s=21787.23404255319, scale=26.01595744680851
SumVectorAvxPtrU16A: 8.3862026E+12 # msUsed=250, MFLOPS/s=16384, scale=19.564
SumVectorAvxPtrUX[4]: 2.1990233E+12 # msUsed=531, MFLOPS/s=7713.747645951035, scale=9.210922787193974
SumVectorAvxPtrUX[8]: 4.3980465E+12 # msUsed=500, MFLOPS/s=8192, scale=9.782
SumVectorAvxPtrUX[16]: 8.3862026E+12 # msUsed=484, MFLOPS/s=8462.809917355371, scale=10.105371900826446
可以看出 SumVectorAvxRef與SumVectorAvxPtr的性能幾乎一致。引用版演算法有時更快,可能是因為避免了fixed。
而且從“標準庫中的Span是靠引用實現的”來看,.NET運行時
在執行引用相關演算法時,肯定是做了充足的編譯優化的。使其能達到與指針版演算法相當的性能。
四、小結
如今引用的功能非常強大,能完全代替指針操作,且能擺脫unsafe關鍵字。適合不啟用“允許非安全代碼”等嚴格的場合。
但需註意,引用有這些缺點——
- 有些場合的編碼比指針繁瑣。例如用
Unsafe.As
做重新解釋時,還需要寫上源數據的類型。且不支持指針元素訪問運算符 []
,只能用Unsafe.Add
等方法對地址進行調整。 - Unsafe的方法一般是沒有安全檢查的,這樣避免了性能損耗,但會留下一些安全隱患需開發者處理。若開發者處理不慎,會像開髮指針代碼那樣易出現 地址越界、懸空指針 等Bug。故需要開發者有指針開發經驗,細心避免bug。
源碼地址——
https://github.com/zyl910/BenchmarkVector/tree/main/BenchmarkVector
參考文獻
- 《
Span<T> needs to be support long length and indices #896
》. https://github.com/dotnet/corefxlab/issues/896 - Microsoft《unsafe(C# 參考)》. https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/unsafe
- Microsoft《C# 發展歷史》. https://learn.microsoft.com/zh-cn/dotnet/csharp/whats-new/csharp-version-history
- Microsoft《Unsafe 類》. https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.compilerservices.unsafe?view=netcore-3.0
- Microsoft《Span.cs》. https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Span.cs
- zyl910《
C# 使用SIMD向量類型加速浮點數組求和運算(1):使用Vector4、Vector<T>
》. https://www.cnblogs.com/zyl910/p/dotnet_simd_BenchmarkVector1.html - zyl910《
C# 使用SIMD向量類型加速浮點數組求和運算(2):C#通過Intrinsic直接使用AVX指令集操作 Vector256<T>,及C++程式對比
》. https://www.cnblogs.com/zyl910/p/dotnet_simd_BenchmarkVector2.html - zyl910《
C# 使用SIMD向量類型加速浮點數組求和運算(3):迴圈展開
》. https://www.cnblogs.com/zyl910/p/dotnet_simd_BenchmarkVector3.html