虽然我们的大多数软件都依赖于 Unicode 字符串,但我们仍然经常遇到遗留编码,例如 Latin 1。在将 Latin 1 字符串转换为 Unicode(例如 UTF-8)之前,我们必须计算 UTF-8 字符串的大小。这相当简单:所有 ASCII 字符将 1 个字节映射到 1 个字节,而其他字符(代码点值从 128 到 256)将 1 个拉丁字节映射到 2 个 Unicode 字节(在 UTF-8 中)。
计算机使用字节表示字符串。大多数情况下,我们使用 Unicode 标准以字节表示字符。在线交换字符串的通用格式称为 UTF-8。它可以表示超过一百万个字符,同时保持与古老的 ASCII 格式的兼容性。
尽管我们的大部分软件堆栈已转移到 Unicode 字符串,但仍有较旧的标准,如用于欧洲字符串的 Latin 1。因此我们经常需要将 Latin 1 字符串转换为 UTF-8。首先计算最终 UTF-8 字符串的大小(以字节为单位)很有用。您可以编写一个简单的 C 函数来计算 Latin 1 输入的 UTF-8 大小,如下所示:
size_t scalar_utf8_length ( const char * c , size_t len ) { size_t答案= 0 ; 对于( size_t i = 0 ; i < len ; i + + ) { 如果( ( c [ i ] > > 7 ) ) {回答+ + ; } } 返回答案+ len ; }
在Computing the UTF-8 size of a Latin 1 string quickly (AVX edition)中,我回顾了在 x64 处理器上解决此问题的更快技术。
ARM 处理器(如最近的 MacBook)怎么样?
Keyhan Vakil 提出了一个很好的解决方案,它依赖于 ARM 处理器中“缩小指令”的可用性。基本上,您可以获取一个 16 字节的向量寄存器,并通过截断或舍入结果来(虚拟地)创建一个 8 字节的寄存器。方便的是,您还可以将位移与缩小结合起来。
将成对的连续 8 位字视为 16 位字。例如,如果 16 位以aaaaaaaabbbbbbbbb开头,则四位移位和窄位创建字节值aaaabbbb 。事实上,如果将一个 16 位字移动 4 位并只保留结果的最低有效 8 位,那么
- 第二个 8 位字的最高 4 位成为结果中的最低 4 位
- 并且第一个 8 位字的最低有效 4 位成为最高有效 4 位。
这很方便,因为当比较为真(全为 1)时,向量化比较函数通常会生成填充字节。 C 中的最终算法如下所示:
uint64_t utf8_length_kvakil ( const uint8_t * data , uint32_t length ) { uint64_t 结果= 0 ; 常量int车道= sizeof ( uint8x16_t ) ; uint8_t rem = length %车道; const uint8_t * simd_end = data + ( length / lanes ) * lanes ; const uint8x16_t threshold = vdupq_n_u8 ( 0x80 ) ; 对于( ;数据< simd_end ;数据+ =车道) { // 加载 16 位 uint8x16_t 输入= vld1q_u8 (数据) ; // 与阈值 (0x80) 比较 uint8x16_t withhighbit = vcgeq_u8 (输入,阈值) ; // 移动和缩小 uint8x8_t highbits = vshrn_n_u16 ( vreinterpretq_u16_u8 ( withhighbit ) , 4 ) ; // 每个字节有 0、4 或 8 位 uint8x8_t bitsperbyte = vcnt_u8 ( highbits ) ; // 将字节垂直求和到 uint16_t 结果+ = vaddlv_u8 ( bitsperbyte ) ; } 结果/ = 4 ; // 我们多算了 4 倍 // 标量尾 对于( uint8_t j = 0 ; j < rem ; j + + ) { 结果+ = ( simd_end [ j ] > > 7 ) ; } 返回结果+长度; }
快吗?
在我的 Apple M2 上,它的速度是编译器在足够大的输入下从标量代码生成的速度的三倍。观察编译器已经依赖向量指令。
标量码 | ~6 GB/秒 |
霓虹灯代码 | ~20 GB/秒 |
我的源代码可用。您的结果会有所不同。
你能打败瓦基尔吗?您当然可以减少指令数,但是一旦达到 20 GB/s 的速度,就很难在不达到内存和缓存速度限制的情况下更快地运行。