利用SIMD优化UTF-16到UTF-8的转换

巧用 SIMD 加速 UTF-16 到 UTF-8 的转换

一、背景引入

在当今数字化信息爆炸的时代,软件应用需要处理来自全球各地的多语言文本。不同的字符编码标准应运而生,其中 UTF - 16 和 UTF - 8 是 Unicode 编码体系下非常重要的两种编码方式。UTF - 16 以其简单直接的方式对大部分常用的 Unicode 字符进行编码,在一些操作系统和编程语言内部使用较为广泛;而 UTF - 8 由于其对 ASCII 字符的兼容性以及可变长度编码节省存储空间的特性,成为了网络传输和文件存储的首选编码。

然而,在实际的应用场景中,经常需要在这两种编码之间进行转换。传统的逐字符转换方法虽然逻辑简单,但在处理大规模文本时效率低下,会成为系统性能的瓶颈。为了提高转换效率,我们可以借助现代处理器提供的单指令多数据(SIMD,Single Instruction Multiple Data)技术。

二、SIMD 技术简介

SIMD 技术是现代处理器中一项强大的并行计算特性。它允许处理器在单个指令周期内同时对多个数据元素执行相同的操作,就像一个高效的流水线工人,能够同时处理多个任务。例如,我们常见的 SSE(Streaming SIMD Extensions)、AVX(Advanced Vector Extensions)等指令集都是 SIMD 技术的具体实现。

在本文中,我们将使用 AVX2 指令集。AVX2 指令集支持 256 位寄存器,这意味着我们可以在一个寄存器中同时存储 16 个 16 位的数据元素。通过使用 AVX2 指令,我们可以一次性对这 16 个元素进行比较、运算等操作,大大减少了指令执行的次数,从而提高了程序的运行效率。

三、代码实现与详细分析

(一)代码整体功能概述

下面是一个使用 SIMD 优化的 UTF - 16 到 UTF - 8 转换的函数代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#include <string>
#include <immintrin.h>

// 假设这两个辅助函数已经实现
void utf16ToUtf8(char16_t utf16Char, char*& output);
void utf16SurrogatePairToUtf8(char16_t highSurrogate, char16_t lowSurrogate, char*& output);

std::string utf16ToUtf8(const std::u16string &utf16, bool is_little_endian) {
std::string utf8;
// 预先分配足够的空间,避免在转换过程中频繁进行内存重新分配,提高效率
utf8.reserve(utf16.size() * 3);

// 定义一些常量,用于后续的比较操作
const __m256i limit1 = _mm256_set1_epi16(0x80);
const __m256i limit2 = _mm256_set1_epi16(0x800);
const __m256i surrogate_high_start = _mm256_set1_epi16(0xD800);
const __m256i surrogate_high_end = _mm256_set1_epi16(0xDBFF);
const __m256i surrogate_low_start = _mm256_set1_epi16(0xDC00);
const __m256i surrogate_low_end = _mm256_set1_epi16(0xDFFF);

// 临时缓冲区,用于存储转换后的 UTF - 8 字节
char buffer[64];
char *output = buffer;

size_t i = 0;
size_t n = utf16.size();

// 主循环,每次处理 16 个 UTF - 16 字符
while (i + 16 <= n) {
// 从 UTF - 16 字符串中加载 16 个字符到 256 位寄存器中
__m256i in = _mm256_loadu_si256(reinterpret_cast<const __m256i *>(utf16.data() + i));

// 如果输入是大端字节序,需要进行字节交换
if (!is_little_endian) {
in = _mm256_or_si256(
_mm256_slli_epi16(in, 8),
_mm256_srli_epi16(in, 8));
}

// 生成掩码,用于判断每个字符需要多少字节来表示
__m256i mask1 = _mm256_cmpgt_epi16(in, limit1);
__m256i mask2 = _mm256_cmpgt_epi16(in, limit2);
__m256i high_surrogate_mask =
_mm256_and_si256(_mm256_cmpgt_epi16(in, surrogate_high_start),
_mm256_cmpgt_epi16(in, surrogate_high_end));
__m256i low_surrogate_mask =
_mm256_and_si256(_mm256_cmpgt_epi16(in, surrogate_low_start),
_mm256_cmpgt_epi16(in, surrogate_low_end));

// 根据掩码判断情况进行处理
if (_mm256_testz_si256(mask1, mask1)) {
// 所有字符值都小于 0x80,每个字符用 1 字节表示
for (int j = 0; j < 16; ++j) {
*output++ = static_cast<char>(utf16[i + j]);
}
} else if (_mm256_testz_si256(mask2, mask2)) {
// 所有字符值都小于 0x800,每个字符用 2 字节表示
for (int j = 0; j < 16; ++j) {
utf16ToUtf8(utf16[i + j], output);
}
} else {
// 存在 1、2 和 3 字节表示的字符混合情况
for (int j = 0; j < 16; ++j) {
if (_mm256_testz_si256(high_surrogate_mask, high_surrogate_mask) &&
j + 1 < 16 &&
!_mm256_testz_si256(low_surrogate_mask, low_surrogate_mask)) {
// 处理代理对
utf16SurrogatePairToUtf8(utf16[i + j], utf16[i + j + 1], output);
++j;
} else {
utf16ToUtf8(utf16[i + j], output);
}
}
}

// 将临时缓冲区中的转换结果追加到 UTF - 8 字符串中
utf8.append(buffer, output - buffer);
// 重置缓冲区指针
output = buffer;
// 移动到下一组 16 个字符
i += 16;
}

// 处理剩余的字符
while (i < n) {
if (i + 1 < n && utf16[i] >= 0xD800 && utf16[i] <= 0xDBFF &&
utf16[i + 1] >= 0xDC00 && utf16[i + 1] <= 0xDFFF) {
// 处理代理对
utf16SurrogatePairToUtf8(utf16[i], utf16[i + 1], output);
++i;
} else {
utf16ToUtf8(utf16[i], output);
}
++i;
}
// 将最后一次转换结果追加到 UTF - 8 字符串中
utf8.append(buffer, output - buffer);

return utf8;
}

(二)代码详细分析

初始化部分

1
2
std::string utf8;
utf8.reserve(utf16.size() * 3);

这里我们创建了一个 std::string 类型的 utf8 字符串,用于存储最终转换后的 UTF - 8 编码文本。通过调用 reserve 函数,我们预先为 utf8 分配了足够的空间。因为 UTF - 16 字符转换为 UTF - 8 后,每个字符最多可能需要 3 个字节来表示,所以我们根据输入的 UTF - 16 字符串长度乘以 3 来预留空间,这样可以避免在转换过程中频繁进行内存重新分配,从而提高程序的运行效率。

1
2
cppconst __m256i limit1 = _mm256_set1_epi16(0x80);
const __m256i limit2 = _mm256_set1_epi16(0x800);

这些代码使用 _mm256_set1_epi16 函数将常量值广播到 256 位寄存器的每个 16 位元素中。这些常量在后续的比较操作中非常重要,用于判断每个 UTF - 16 字符转换为 UTF - 8 时需要多少个字节来表示。例如,limit1 用于判断字符是否可以用 1 个字节表示(小于 0x80),limit2 用于判断字符是否可以用 2 个字节表示(小于 0x800)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while (i + 16 <= n) {
__m256i in = _mm256_loadu_si256(reinterpret_cast<const __m256i *>(utf16.data() + i));
// 字节序处理
if (!is_little_endian) {
in = _mm256_or_si256(
_mm256_slli_epi16(in, 8),
_mm256_srli_epi16(in, 8));
}

//在主循环中,我们每次从输入的 UTF - 16 字符串中加载 16 个 16 位的字符到 256 位寄存器 __m256i 中。使用 _mm256_loadu_si256 函数进行非对齐加载,因为输入的字符串不一定是按 256 位对齐的。如果输入的 UTF - 16 字符串是大端字节序(is_little_endian 为 false),我们需要进行字节交换操作,通过 _mm256_slli_epi16 和 _mm256_srli_epi16 函数分别进行左移和右移 8 位,然后使用 _mm256_or_si256 函数将结果合并,从而实现字节序的转换。
cpp__m256i mask1 = _mm256_cmpgt_epi16(in, limit1);
__m256i mask2 = _mm256_cmpgt_epi16(in, limit2);
// 其他掩码计算...
//这部分代码使用 _mm256_cmpgt_epi16 函数对寄存器中的每个 16 位元素与常量进行比较。比较结果会生成一个掩码,掩码中的每个位对应寄存器中的一个元素,如果元素大于比较值,则对应位为 1,否则为 0。这些掩码用于后续判断每个字符需要多少字节来表示,以及是否为代理对的一部分。
cpp

主循环部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (_mm256_testz_si256(mask1, mask1)) {
// All values < 0x80, 1 byte per character
for (int j = 0; j < 16; ++j) {
*output++ = static_cast<char>(utf16[i + j]);
}
} else if (_mm256_testz_si256(mask2, mask2)) {
// All values < 0x800, 2 bytes per character
for (int j = 0; j < 16; ++j) {
utf16ToUtf8(utf16[i + j], output);
}
} else {
// Mix of 1, 2, and 3 byte characters
for (int j = 0; j < 16; ++j) {
if (_mm256_testz_si256(high_surrogate_mask, high_surrogate_mask) &&
j + 1 < 16 &&
!_mm256_testz_si256(low_surrogate_mask, low_surrogate_mask)) {
// Surrogate pair
utf16SurrogatePairToUtf8(utf16[i + j], utf16[i + j + 1], output);
++j;
} else {
utf16ToUtf8(utf16[i + j], output);
}
}
}

根据生成的掩码,我们进行不同的处理。如果 mask1 全为 0,说明这 16 个字符的值都小于 0x80,每个字符可以直接用 1 个字节表示,我们将其直接转换为 char 类型并存储到临时缓冲区中。如果 mask1 不全为 0,但 mask2 全为 0,说明这些字符的值都小于 0x800,每个字符需要用 2 个字节表示,我们调用 utf16ToUtf8 函数进行转换。如果 mask2 也不全为 0,说明存在 1、2 和 3 字节表示的字符混合情况,并且可能存在代理对。对于代理对,我们调用 utf16SurrogatePairToUtf8 函数进行处理,同时跳过下一个字符。

1
2
3
utf8.append(buffer, output - buffer);
output = buffer; // Reset output buffer pointer
i += 16;

每次处理完 16 个字符后,我们将临时缓冲区中的转换结果追加到 utf8 字符串中。然后重置临时缓冲区的指针,以便下一次存储新的转换结果。最后,将索引 i 增加 16,移动到下一组 16 个字符进行处理。

处理剩余字符

1
2
3
4
5
6
7
8
9
10
11
12
while (i < n) {
if (i + 1 < n && utf16[i] >= 0xD800 && utf16[i] <= 0xDBFF &&
utf16[i + 1] >= 0xDC00 && utf16[i + 1] <= 0xDFFF) {
// Surrogate pair
utf16SurrogatePairToUtf8(utf16[i], utf16[i + 1], output);
++i;
} else {
utf16ToUtf8(utf16[i], output);
}
++i;
}
utf8.append(buffer, output - buffer);

当主循环处理完能被 16 整除的部分后,可能还会有剩余的字符。这部分代码会逐个处理这些剩余字符。如果遇到代理对,调用 utf16SurrogatePairToUtf8 函数进行处理;否则,调用 utf16ToUtf8 函数进行转换。最后,将临时缓冲区中最后一次转换的结果追加到 utf8 字符串中。

通过使用 SIMD 技术,我们成功地对 UTF - 16 到 UTF - 8 的转换过程进行了优化。利用 AVX2 指令集的并行计算能力,一次性处理 16 个字符,减少了指令执行次数,提高了程序的运行效率。特别是在处理大规模文本时,这种优化效果会更加明显。
然而,需要注意的是,SIMD 编程需要对处理器指令集有一定的了解,并且代码的可移植性可能会受到一定影响。在不同的处理器架构上,可能需要调整指令集的使用。因此,在实际应用中,我们需要根据具体的平台和需求进行权衡和优化,以达到最佳的性能和兼容性。