微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

C# 中的硬件 SIMD 解析性能提升

如何解决C# 中的硬件 SIMD 解析性能提升

我已经实现了一种使用 .NET 中可用的 SIMD 内在函数解析长度

public unsafe static uint ParseUint(string text)
{
  fixed (char* c = text)
  {
    var parsed = Sse3.LoadDquVector128((byte*) c);
    var shift = (8 - text.Length) * 2;
    var shifted = Sse2.ShiftLeftLogical128BitLane(parsed,(byte) (shift));

    Vector128<byte> digit0 = Vector128.Create((byte) '0');
    var reduced = Sse2.SubtractSaturate(shifted,digit0);

    var shortMult = Vector128.Create(10,1,10,1);
    var collapsed2 = Sse2.MultiplyAddAdjacent(reduced.As<byte,short>(),shortMult);

    var repack = Sse41.PackUnsignedSaturate(collapsed2,collapsed2);
    var intMult = Vector128.Create((short)0,100,1);
    var collapsed3 = Sse2.MultiplyAddAdjacent(repack.As<ushort,intMult);

    var e1 = collapsed3.GetElement(2);
    var e2 = collapsed3.GetElement(3);
    return (uint) (e1 * 10000 + e2);
  }
}

遗憾的是,与基线 uint.Parse() 的比较给出了以下令人印象深刻的结果:

方法 平均 错误 StdDev
基线 15.157 ns 0.0325 ns 0.0304 ns
解析模拟 3.269 ns 0.0115 ns 0.0102 ns

上面的代码有哪些可以改进的方法?我特别关注的领域是:

  • SIMD 寄存器发生位移的方式涉及 text.Length 的计算
  • ~~使用包含MultiplyAddAdjacent0的向量的1解包UTF-16数据~~
  • 使用 GetElement() 提取元素的方式——也许在某个地方可能会发生一些 ToScalar() 调用

解决方法

我做了一些优化。

public unsafe uint ParseUint2(string text)
{
    fixed (char* c = text)
    {
        Vector128<ushort> raw = Sse3.LoadDquVector128((ushort*)c);
        raw = Sse2.ShiftLeftLogical128BitLane(raw,(byte)(8 - text.Length << 1));
        Vector128<ushort> digit0 = Vector128.Create('0');
        raw = Sse2.SubtractSaturate(raw,digit0);
        Vector128<short> mul0 = Vector128.Create(10,1,10,1);
        Vector128<int> res = Sse2.MultiplyAddAdjacent(raw.AsInt16(),mul0);
        Vector128<int> mul1 = Vector128.Create(1000000,10000,100,1);
        res = Sse41.MultiplyLow(res,mul1);
        res = Ssse3.HorizontalAdd(res,res);
        res = Ssse3.HorizontalAdd(res,res);
        return (uint)res.GetElement(0);
    }
}

使用 vphaddd 减少了类型转换和最终计算的数量。因此,它的速度提高了约 10%。

但是...imm8 必须是编译时常量。这意味着您不能使用 imm8 为参数的变量。否则 JIT 编译器不会产生操作的内在指令。它会在这个地方创建一个外部方法 call(也许有一些解决方法)。感谢@PeterCordes 的帮助。

无论text.Length如何,这个怪物并不明显,但比上面的怪物更快。

public unsafe uint ParseUint3(string text)
{
    fixed (char* c = text)
    {
        Vector128<ushort> raw = Sse3.LoadDquVector128((ushort*)c);
        switch (text.Length)
        {
            case 0: raw = Vector128<ushort>.Zero; break;
            case 1: raw = Sse2.ShiftLeftLogical128BitLane(raw,14); break;
            case 2: raw = Sse2.ShiftLeftLogical128BitLane(raw,12); break;
            case 3: raw = Sse2.ShiftLeftLogical128BitLane(raw,10); break;
            case 4: raw = Sse2.ShiftLeftLogical128BitLane(raw,8); break;
            case 5: raw = Sse2.ShiftLeftLogical128BitLane(raw,6); break;
            case 6: raw = Sse2.ShiftLeftLogical128BitLane(raw,4); break;
            case 7: raw = Sse2.ShiftLeftLogical128BitLane(raw,2); break;
        };
        Vector128<ushort> digit0 = Vector128.Create('0');
        raw = Sse2.SubtractSaturate(raw,res);
        return (uint)res.GetElement(0);
    }
}

再说一次,@PeterCordes 不允许我写慢代码。以下版本有 2 个改进。现在加载的字符串已经移位,然后通过相同的偏移量减去移位的掩码。这避免了 ShiftLeftLogical128BitLane 使用可变计数的缓慢回退。
第二个改进是用 vphaddd + pshufd 替换 paddd

// Note that this loads up to 14 bytes before the data part of the string.  (Or 16 for an empty string)
// This might or might not make it possible to read from an unmapped page and fault,beware.
public unsafe uint ParseUint4(string text)
{
    const string mask = "\xffff\xffff\xffff\xffff\xffff\xffff\xffff\xffff00000000";
    fixed (char* c = text,m = mask)
    {
        Vector128<ushort> raw = Sse3.LoadDquVector128((ushort*)c - 8 + text.Length);
        Vector128<ushort> mask0 = Sse3.LoadDquVector128((ushort*)m + text.Length);
        raw = Sse2.SubtractSaturate(raw,mask0);
        Vector128<short> mul0 = Vector128.Create(10,mul1);
        Vector128<int> shuf = Sse2.Shuffle(res,0x1b); // 0 1 2 3 => 3 2 1 0
        res = Sse2.Add(shuf,res);
        shuf = Sse2.Shuffle(res,0x41); // 0 1 2 3 => 1 0 3 2
        res = Sse2.Add(shuf,res);
        return (uint)res.GetElement(0);
    }
}

~比初始解决方案快两倍。 (o_O) 至少在我的 Haswell i7 上是这样。

,

C#(感谢@aepot)

public unsafe uint ParseUint(string text)
{
    fixed (char* c = text)
    {
        Vector128<byte> mul1 = Vector128.Create(0x14C814C8,0x010A0A64,0).AsByte();
        Vector128<short> mul2 = Vector128.Create(0x00FA61A8,0x0001000A,0).AsInt16();
        Vector128<long> shift_amount = Sse2.ConvertScalarToVector128Int32(8 - text.Length << 3).AsInt64();

        Vector128<short> vs = Sse2.LoadVector128((short*)c);
        Vector128<byte> vb = Sse2.PackUnsignedSaturate(vs,vs);
        vb = Sse2.SubtractSaturate(vb,Vector128.Create((byte)'0'));
        vb = Sse2.ShiftLeftLogical(vb.AsInt64(),shift_amount).AsByte();

        Vector128<int> v = Sse2.MultiplyAddAdjacent(Ssse3.MultiplyAddAdjacent(mul1,vb.AsSByte()),mul2);
        v = Sse2.Add(Sse2.Add(v,v),Sse2.Shuffle(v,1));
        return (uint)v.GetElement(0);
    }
}

使用 SSSE3 的 C 解决方案:

#include <uchar.h> // char16_t
#include <tmmintrin.h> // pmaddubsw

unsigned ParseUint(char16_t* ptr,size_t len) {
    const __m128i mul1 = _mm_set_epi32(0,0x14C814C8);
    const __m128i mul2 = _mm_set_epi32(0,0x00FA61A8);
    const __m128i shift_amount = _mm_cvtsi32_si128((8 - len) * 8);

    __m128i v = _mm_loadu_si128((__m128i*)ptr); // unsafe chunking
    v = _mm_packus_epi16(v,v); // convert digits from UTF16-LE to ASCII
    v = _mm_subs_epu8(v,_mm_set1_epi8('0'));
    v = _mm_sll_epi64(v,shift_amount); // shift off non-digit trash

    // convert
    v = _mm_madd_epi16(_mm_maddubs_epi16(mul1,mul2);
    v = _mm_add_epi32(_mm_add_epi32(v,_mm_shuffle_epi32(v,1));
    
    return (unsigned)_mm_cvtsi128_si32(v);
}

无论如何移动/对齐字符串(参见 aepot's anwser),我们都希望远离 pmulld。 SSE 基本上具有 16 位整数乘法,而 32 位乘法具有双倍的延迟和 uops。但是,必须注意 pmaddubswpmaddwd 的符号扩展行为。


使用标量 x64

// untested && I don't know C#
public unsafe static uint ParseUint(string text)
{
  fixed (char* c = text)
  {
    var xmm = Sse2.LoadVector128((ushort*)c); // unsafe chunking
    var packed = Sse2.PackSignedSaturate(xmm,xmm); // convert digits from UTF16-LE to ASCII
    ulong val = Sse2.X64.ConvertToUInt64(packed); // extract to scalar

    val -= 0x3030303030303030; // subtract '0' from each digit
    val <<= ((8 - text.Length) * 8); // shift off non-digit trash

    // convert
    const ulong mask = 0x000000FF000000FF;
    const ulong mul1 = 0x000F424000000064; // 100 + (1000000ULL << 32)
    const ulong mul2 = 0x0000271000000001; // 1 + (10000ULL << 32)
    val = (val * 10) + (val >> 8);
    val = (((val & mask) * mul1) + (((val >> 16) & mask) * mul2)) >> 32;
    return (uint)val;
  }
}
,

首先,5 倍的改进并不是“令人印象深刻”。

我不会用标量代码做最后一步,这里有一个替代方案:

// _mm_shuffle_epi32( x,_MM_SHUFFLE( 3,3,2,2 ) )
collapsed3 = Sse2.Shuffle( collapsed3,0xFA );
// _mm_mul_epu32
var collapsed4 = Sse2.Multiply( collapsed3.As<int,uint>(),Vector128.Create( 10000u,0 ) ).As<ulong,uint>();
// _mm_add_epi32( x,_mm_srli_si128( x,8 ) )
collapsed4 = Sse2.Add( collapsed4,Sse2.ShiftRightLogical128BitLane( collapsed4,8 ) );
return collapsed4.GetElement( 0 );

C++ 版本将比我的 PC (.NET Core 3.1) 上发生的更快。生成的代码不好。他们像这样初始化常量:

00007FFAD10B11B6  xor         ecx,ecx  
00007FFAD10B11B8  mov         dword ptr [rsp+20h],ecx  
00007FFAD10B11BC  mov         dword ptr [rsp+28h],64h  
00007FFAD10B11C4  mov         dword ptr [rsp+30h],1  
00007FFAD10B11CC  mov         dword ptr [rsp+38h],64h  
00007FFAD10B11D4  mov         dword ptr [rsp+40h],1  

他们使用堆栈内存而不是另一个向量寄存器。貌似JIT开发者忘记了那里有16个向量寄存器,完整的函数只用了xmm0

00007FFAD10B1230  vmovapd     xmmword ptr [rbp-0C0h],xmm0  
00007FFAD10B1238  vmovapd     xmm0,xmmword ptr [rbp-0C0h]  
00007FFAD10B1240  vpsrldq     xmm0,xmm0,8  
00007FFAD10B1245  vpaddd      xmm0,xmmword ptr [rbp-0C0h]  

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。