在 C# 中将字符串转换为 byte[] 数组的最快方法是什么?我正在通过套接字发送大量字符串数据,并且需要优化每一个操作。目前,我在发送之前将字符串转换为 byte[] 数组:
private static readonly Encoding encoding = new ASCIIEncoding();
//...
byte[] bytes = encoding.GetBytes(someString);
socket.Send(bytes);
//...
在 C# 中将字符串转换为 byte[] 数组的最快方法是什么?我正在通过套接字发送大量字符串数据,并且需要优化每一个操作。目前,我在发送之前将字符串转换为 byte[] 数组:
private static readonly Encoding encoding = new ASCIIEncoding();
//...
byte[] bytes = encoding.GetBytes(someString);
socket.Send(bytes);
//...
如果你所有的数据真的都是 ASCII,那么你可以比 稍微快一点ASCIIEncoding
,它有各种(完全合理的)错误处理位等。你也可以通过避免创建新字节来加速它一直都是数组。假设你有一个上限,你的所有消息都在:
void QuickAndDirtyAsciiEncode(string chars, byte[] buffer)
{
int length = chars.Length;
for (int i = 0; i < length; i++)
{
buffer[i] = (byte) (chars[i] & 0x7f);
}
}
然后你会做类似的事情:
readonly byte[] Buffer = new byte[8192]; // Reuse this repeatedly
...
QuickAndDirtyAsciiEncode(text, Buffer);
// We know ASCII takes one byte per character
socket.Send(Buffer, text.Length, SocketFlags.None);
这是非常绝望的优化。我会坚持下去,ASCIIEncoding
直到我证明这是瓶颈(或者至少这种糟糕的黑客没有帮助)。
我会说你现在做的很好。如果你真的很关心这样的低级优化,我能做的最好的建议是使用 Reflector。使用反射器,您可以(大部分时间)自己查看代码,并查看算法是什么。如果反射器没有显示,您可以随时下载 Microsoft 的 SSCLI(共享源公共语言基础结构)以查看 MethodImplOptions.InternalCall 方法背后的 C++ 代码。
作为参考,这里是 Encoding.ASCII.GetBytes 的实际实现:
public override int GetBytes(string chars, int charIndex, int charCount, byte[] bytes, int byteIndex)
{
if ((chars == null) || (bytes == null))
{
throw new ArgumentNullException();
}
if ((charIndex < 0) || (charCount < 0))
{
throw new ArgumentOutOfRangeException();
}
if ((chars.Length - charIndex) < charCount)
{
throw new ArgumentOutOfRangeException();
}
if ((byteIndex < 0) || (byteIndex > bytes.Length))
{
throw new ArgumentOutOfRangeException();
}
if ((bytes.Length - byteIndex) < charCount)
{
throw new ArgumentException();
}
int num = charIndex + charCount;
while (charIndex < num)
{
char ch = chars[charIndex++];
if (ch >= '\x0080')
{
ch = '?';
}
bytes[byteIndex++] = (byte) ch;
}
return charCount;
}
使用 SIMD 寄存器实现通用 memcpy 库函数的性能特征明显比使用通用寄存器的等效实现更加丰富多彩......
-英特尔 64 和 IA-32 架构优化参考手册 (2018 年 4 月)§3.7 .6.1
为了在 8 位和“宽”(16 位,Unicode)文本之间转换中型到大型数据块的速度极快byte[]
,您需要考虑部署SIMD指令PUNPCKLBW
+ PUNPCKHBW
(加宽)和PACKUSWB
(收窄)的解决方案)。在.NET中,这些可用作 x64 JIT 内在函数,用于硬件加速System.Numerics
类型Vector
和Vector<T>
(有关更多信息,请参见此处)。通用版本Vector<T>
在System.Numerics.Vectors
包中定义,目前仍在相当积极的开发中。如下图所示,您可能还希望包含该System.Runtime.CompilerServices.Unsafe
包,因为这是Vector<T>
作者推荐的首选 SIMD 加载/存储技术。
相关的 SIMD 加速仅在x64 模式下为有能力的 CPU 启用,但 .NET 为System.Numerics.Vectors
库中的模拟代码提供透明的回退,因此此处演示的代码确实在更广泛的 .NET 生态系统中可靠地运行,可能会降低性能。为了测试下面显示的代码,我在完整的.NET Framework 4.8(“桌面”)上以x64(SIMD)和x86(模拟)模式使用了控制台应用程序。
由于我不想剥夺任何人学习相关技术的机会,我将用C# 7Vector.Widen
来说明byte[]
tochar[]
方向。从这个例子中,做相反的事情——即,使用来实现缩小方向——很简单,留给读者作为练习。Vector.Narrow
警告:
这里建议的方法完全不知道编码,它们只是从原始字节中剥离/扩展——或缩小/扩大——原始字节,而不考虑字符映射、文本编码或其他语言属性。扩大时,多余的字节被设置为零,而缩小时,多余的字节被丢弃。
其他人已经在此页面和其他地方讨论了与此做法相关的n̲u̲m̲e̲r̲o̲u̲s̲ h̲a̲z̲a̲r̲d̲s̲ ,因此请在考虑是否适合您的情况之前仔细查看并了解此操作的性质。为清楚起见,下面显示的代码示例中省略了内联验证,但可以将其添加到最内层循环中,对 SIMD 优势的影响最小。
你已经被警告了。尽管不是 SIMD 加速的,但推荐使用合适Encoding
实例的规范技术用于几乎所有实际应用场景。尽管 OP 特别要求提供最高性能的解决方案,但首先我将总结通常应该使用的适当认可的技术。
要将字节数组扩展为 .NET ,请在合适的面向字节的编码实例上
String
调用GetString()方法:
String Encoding.ASCII.GetString(byte[] bytes)
要将 .NET 缩小为
String
(例如,Ascii)字节数组,请在合适的面向字节的编码实例上调用GetBytes()方法:
byte[] Encoding.ASCII.GetBytes(char[] chars)
好的,现在进入有趣的部分——用于“哑”扩展字节数组的极快的启用 SIMD(“矢量化”)的C#代码。提醒一下,这里有一些应该引用的依赖项:
// ...
using System.Numerics; // nuget: System.Numerics.Vectors
using System.Runtime.CompilerServices; // nuget: System.Runtime.CompilerServices.Unsafe
// ...
这是公共入口点包装函数。如果您更喜欢返回char[]
而不是 的版本String
,则在本文末尾提供。
/// <summary>
/// 'Widen' each byte in 'bytes' to 16-bits with no consideration for
/// character mapping or encoding.
/// </summary>
public static unsafe String ByteArrayToString(byte[] bytes)
{
// note: possible zeroing penalty; consider buffer pooling or
// other ways to allocate target?
var s = new String('\0', bytes.Length);
if (s.Length > 0)
fixed (char* dst = s)
fixed (byte* src = bytes)
widen_bytes_simd(dst, src, s.Length);
return s;
}
接下来是主工作循环体。请注意序言循环将目标与 16 字节内存边界对齐,如有必要,通过最多 15 个源字节的字节复制。这确保了主“ quad-quadwise ”循环的最有效操作,该循环通过一对 SIMDPUNPCKLBW/PUNPCKHBW
指令一次写入 32 个字节(取出 16 个源字节,然后存储为 16 个宽字符,占用 32 个字节)。预对齐以及选择dst对齐(与src相对)是上述英特尔手册中的官方建议。同样,对齐操作需要当主循环完成时,源可能有多达 15 个剩余尾随字节;这些由一个简短的结语循环完成。
static unsafe void widen_bytes_simd(char* dst, byte* src, int c)
{
for (; c > 0 && ((long)dst & 0xF) != 0; c--)
*dst++ = (char)*src++;
for (; (c -= 0x10) >= 0; src += 0x10, dst += 0x10)
Vector.Widen(Unsafe.AsRef<Vector<byte>>(src),
out Unsafe.AsRef<Vector<ushort>>(dst + 0),
out Unsafe.AsRef<Vector<ushort>>(dst + 8));
for (c += 0x10; c > 0; c--)
*dst++ = (char)*src++;
}
这实际上就是它的全部!它就像一种魅力,正如您将在下面看到的那样,它确实像广告一样“尖叫” 。
但首先,通过关闭 vs2017 调试器选项“禁用 JIT 优化”,我们可以检查x64 JIT 为.NET 4.7.2上的“发布”构建生成的本机 SIMD 指令流。这是主内部循环的相关部分,它一次遍历 32 个字节的数据。请注意,JIT 已设法发出理论上最小的获取/存储模式。
L_4223 mov rax,rbx
L_4226 movups xmm0,xmmword ptr [rax] ; fetch 16 bytes
L_4229 mov rax,rdi
L_422C lea rdx,[rdi+10h]
L_4230 movaps xmm2,xmm0
L_4233 pxor xmm1,xmm1
L_4237 punpcklbw xmm2,xmm1 ; interleave 8-to-16 bits (lo)
L_423B movups xmmword ptr [rax],xmm2 ; store 8 bytes (lo) to 8 wide chars (16 bytes)
L_423E pxor xmm1,xmm1
L_4242 punpckhbw xmm0,xmm1 ; interleave 8-to-16 bits (hi)
L_4246 movups xmmword ptr [rdx],xmm0 ; store 8 bytes (hi) to 8 wide chars (16 bytes)
L_4249 add rbx,10h
L_424D add rdi,20h
L_4251 add esi,0FFFFFFF0h
L_4254 test esi,esi
L_4256 jge L_4223
L_4258 ...
性能测试结果:
我针对执行相同功能的其他四种技术测试了 SIMD 代码。对于下面列出的 .NET 编码器,这是对该GetChars(byte[], int, int)
方法的调用。
测试包括对所有人的相同工作以及对所有被测单元的相同结果的验证。测试字节是随机的且仅 ASCII ( [0x01 - 0x7F]
) 以确保所有测试单元的结果相同。输入大小是随机的,最大 1MB,对较小的大小有 log 2的偏差,因此平均大小约为 80K。
为了公平起见,每次迭代的执行顺序系统地轮换通过 5 个单元。对于预热,在第 100 次迭代时,计时被丢弃并重置为零一次。测试工具在测试阶段不执行任何分配,并且每 10000 次迭代强制并等待一次完整的 GC。
相对刻度,标准化为最佳结果 .NET Framework 4.7.3056.0 x64(发布) iter naive win-1252 ascii utf-8 simd -------- ----------- ------------ ------------ -------- ---- ----------- 10000 | 131.5 294.5 186.2 145.6 100.0 20000 | 137.7 305.3 191.9 149.4 100.0 30000 | 139.2 308.5 195.8 151.5 100.0 40000 | 141.8 312.1 198.5 153.2 100.0 50000 | 142.0 313.8 199.1 154.1 100.0 60000 | 140.5 310.6 196.7 153.0 100.0 70000 | 141.1 312.9 197.3 153.6 100.0 80000 | 141.6 313.7 197.8 154.1 100.0 90000 | 141.3 313.7 197.9 154.3 100.0 100000 | 141.1 313.3 196.9 153.7 100.0 gcServer=假;LatencyMode.Interactive;Vector.IsHardwareAccelerated=True
在启用 JIT 优化且 SIMD 可用时首选的x64平台上,没有竞争。SIMD 代码的运行速度比下一个竞争者快 150%。,Encoding.Default
通常是“Windows-1252”代码页,性能特别差,比 SIMD 代码慢大约 3 倍。
之前我提到过测试数据大小的分布强烈地向零倾斜。如果没有这一步——意味着大小从 0 到 1,048,576 字节的均匀分布(平均测试大小为 512K)——SIMD 继续领先于其他所有单元,与上面显示的代码相比,它的表现相对较差。
天真 153.45% 赢1252 358.84% ASCII 221.38% UTF-8 161.62% 模拟 100.00%
至于非 SIMD(仿真)情况,UTF-8 和 SIMD 非常接近——通常彼此相差 3-4% 之内——而且比其他情况要好得多。我发现这个结果非常令人惊讶:UTF8Encoding 源代码如此之快(大量快速路径优化),而且通用 SIMD 仿真代码能够匹配该专用调整代码。
/// <summary>
/// 'Widen' each byte in 'bytes' to 16-bits with no consideration for
/// character mapping or encoding
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static unsafe char[] WidenByteArray(byte[] bytes)
{
var rgch = new char[bytes.Length];
if (rgch.Length > 0)
fixed (char* dst = rgch)
fixed (byte* src = bytes)
widen_bytes_simd(dst, src, rgch.Length);
return rgch;
}
我想 GetBytes() 函数已经为此进行了很好的优化。我想不出任何提高现有代码速度的建议。
编辑——你知道,我不知道这是否更快。但这是使用 BinaryFormatter 的另一种方法:
BinaryFormatter bf = new BinaryFormatter();
MemoryStream ms = new MemoryStream();
bf.Serialize(ms, someString);
byte[] bytes = ms.ToArray();
ms.Close();
socket.Send(bytes);
我认为这可能更快的原因是它跳过了编码步骤。我也不完全确定这会正常工作。但是你可以试试看。当然,如果您需要 ascii 编码,那么这将无济于事。
我只是有了另一个想法。我相信这段代码返回的字节数是使用带 ASCII 编码的 GetBytes 的两倍。原因是 .NET 中的所有字符串都在幕后使用 unicode。当然,Unicode 每个字符使用 2 个字节,而 ASCII 只使用 1 个字节。因此,在这种情况下,BinaryFormatter 可能不适合使用,因为您将通过套接字发送的数据量增加一倍。
你想优化什么?中央处理器?带宽?
如果要优化带宽,可以尝试预先压缩字符串数据。
首先,在您尝试在如此低的级别进行优化之前,分析您的代码,找出慢速位是什么。
在不知道您的并发要求(或其他任何东西)的情况下:您能否在 ThreadPool 上生成一些线程,将字符串转换为字节数组并将它们放入队列中,然后再让一个线程监视队列并发送数据?
正如其他人所说,Encoding 类已经针对该任务进行了优化,因此可能很难使其更快。您可以进行一项微优化:使用Encoding.ASCII
而不是new ASCIIEncoding()
. 但众所周知,微优化很糟糕;)
我建议分析你在做什么。我怀疑将字符串转换为字节数组的速度比套接字本身的速度在吞吐量方面的问题更大。
另一个提示:我不知道您是如何创建初始字符串的,但请记住 StringBuilder.Append("something") 确实比 myString += "something" 之类的更快。
在创建字符串并通过套接字连接发送它们的整个过程中,如果瓶颈是字符串到字节数组的转换,我会感到惊讶。但我很感兴趣是否有人会用探查器对此进行测试。