将 C++ 移植到 WASM 时,字符串处理有哪些最佳实践?
可以把 ONNX 推理后的文本处理迁移到 C++/WASM,是否更快还需要通过端到端测量确认。可以先使用一份只读文本缓冲区,用偏移量关联 token 和 POS 标签,再批量执行匹配与统计,以便控制复制次数、内存生命周期和并发访问。
原问题没有说明运行环境和工具链。这里以浏览器中的 JavaScript + Emscripten 为例;使用其他宿主环境时,字符串接口和线程部署条件需要另行确认。
统一编码,并明确偏移量的单位
可以约定传入 WASM 的文本使用 UTF-8,接口接收“指针 + 字节长度”。C++ 内部用 std::string 持有数据,用 C++17 的 std::string_view 表示只读片段。
需要区分三个概念:
- JavaScript 字符串的索引按 UTF-16 code unit 计算。
- UTF-8 缓冲区的偏移量按字节计算。
- 用户看到的一个字符,可能由多个 Unicode code point 组成。
JavaScript 字符串索引不能直接当作 C++ UTF-8 缓冲区的字节偏移量,中文和 emoji 等输入就会暴露这个问题。
还要核对 tokenizer 返回的位置使用什么单位,以及它是否进行了大小写转换、Unicode normalization 或其他预处理。如果 token 位置对应处理后的文本,就需要保留到原始文本的映射,不能直接将位置用于原文。
保存一份原文,用位置引用片段
“固定缓冲区”可以作为内存复用策略,但使用 WASM 不要求预设固定容量。
可以按下面的方式组织数据:
UTF-8 原文缓冲区
+ token 的起止字节偏移量
+ 与 token 对齐的 POS 标签数组
+ 匹配结果或计数
只需查询相邻 POS 标签的规则,可以直接遍历标签数组;需要检查原文的规则,再通过偏移量访问对应片段,避免为每个 token、每个窗口重复构造字符串。
std::string_view 不拥有底层内存,处理期间原文必须保持有效。如果持有原文的 std::string 被销毁,或因修改而重新分配内存,已有视图可能失效。字符串视图也不保证以 \0 结尾,因此不要直接传给要求 NUL 终止字符串的接口。
复用缓冲区时,应记录容量和实际长度,并在上一次处理完全结束后再覆盖内容。对用户输入要先检查大小,不能依赖缓冲区“应该够用”。
减少跨边界调用和复制
如果文本最初来自 JavaScript,可以先编码一次、复制到 WASM 内存一次,再通过一次调用完成多条规则的计算,最后返回计数或偏移量等紧凑结果。
尽量避免逐个 token 调用 WASM,也不要让每条规则都重新传入整段文本。Emscripten 可以通过 _malloc 分配内存,通过 HEAPU8.set() 写入字节,调用 C/C++ 函数后再释放内存。参见 Emscripten 的 JavaScript/C++ 交互文档。
ONNX 推理和后处理即使都使用 WASM,也不一定共用同一块线性内存。只有运行时确实提供可共享的内存和接口,才能进一步讨论直接复用数据。
允许 WASM 内存增长时,不要长期缓存基于旧内存建立的 JavaScript typed array 视图。在可能触发增长的调用后,应重新从当前的 Module.HEAPU8 获取视图;相关限制也见上述交互文档。
单独验证正则表达式的语义和成本
把正则表达式搬到 C++ 不会自动带来性能提升。可以先做以下检查:
- 对固定规则复用已编译的正则对象,避免每次请求重复编译。
- 如果规则只检查 POS 标签序列,优先考虑直接比较标签,避免先拼接字符串再做正则匹配。
- 如果规则只需固定文本查找或简单扫描,比较专门实现与正则实现的成本。
- 验证换行、大小写、重叠匹配和 Unicode 行为,不要假定 JavaScript
RegExp与 C++std::regex完全一致。
特别是对 UTF-8 的 std::string 使用 std::regex 时,不应默认 .、字符类别或词边界具备完整的 Unicode 文本处理能力。涉及自然语言词边界时,需要确认具体引擎的语义。
对用户可控文本,还要关注规则的最坏情况。带有复杂回溯的模式可能在某些输入上耗时很长,迁移到 WASM 并不能消除这种风险。
先并行分配规则,再考虑切分文本
如果多条规则都需要扫描完整文本,可以让不同任务读取同一份不可变原文,各自保存匹配状态和结果,结束后再汇总。
“只读”必须覆盖整个任务周期:所有任务完成前,都不能修改、释放或复用原文缓冲区。匹配结果容器和计数器也需要独立存储,或采用适当同步。
把文本切成几段分别匹配更复杂。匹配可能跨越分段边界,锚点、上下文和重叠计数也可能发生变化。除非能明确规则所需的上下文范围,否则不要直接按字节均分文本。
使用 Emscripten pthreads 时,需要在编译和链接阶段启用 -pthread。浏览器共享内存线程依赖 SharedArrayBuffer,部署时还需要满足 COOP/COEP 等跨源隔离条件。具体要求见 Emscripten pthreads 文档。线程调度也有成本,短文本或少量规则未必适合并行。
一个显式传递 UTF-8 缓冲区的示例
下面只演示字符串传递、只读访问和内存释放。示例统计连续 ASCII 字母片段,不实现 POS 规则,也不能作为自然语言分词器。
C++ 文件 text.cpp:
#include <cstddef>
#include <cstdint>
#include <regex>
#include <string_view>
extern "C" std::uint32_t count_ascii_words(
const char* data,
std::size_t byte_length
) {
if (data == nullptr || byte_length == 0) {
return 0;
}
const std::string_view text(data, byte_length);
static const std::regex pattern(R"([A-Za-z]+)");
using Iterator = std::string_view::const_iterator;
using Matches = std::regex_iterator<Iterator>;
std::uint32_t count = 0;
for (Matches it(text.begin(), text.end(), pattern), end;
it != end; ++it) {
++count;
}
return count;
}
编译为可从 JavaScript 导入的模块:
em++ text.cpp -std=c++17 -O3 --no-entry \
-sMODULARIZE=1 \
-sEXPORT_ES6=1 \
-sEXPORTED_FUNCTIONS='["_count_ascii_words","_malloc","_free"]' \
-o text.mjs
JavaScript 调用示例,假设使用默认的 wasm32 目标,并且模块资源能正常加载:
import createModule from './text.mjs';
const module = await createModule();
const encoder = new TextEncoder();
function countAsciiWords(text) {
const bytes = encoder.encode(text);
if (bytes.length === 0) return 0;
const ptr = module._malloc(bytes.length);
if (ptr === 0) {
throw new Error('WASM allocation failed');
}
try {
module.HEAPU8.set(bytes, ptr);
return module._count_ascii_words(ptr, bytes.length);
} finally {
module._free(ptr);
}
}
这里无需额外写入 \0,因为 C++ 函数始终使用显式长度。该函数同步完成处理,且不保留输入指针,所以可以在 finally 中释放内存。如果改为后台异步处理,必须等所有任务结束后再释放。
用端到端结果决定是否迁移
建议比较现有实现、单线程 WASM,以及确有必要时的多线程 WASM。分别记录编码、复制、规则处理和结果返回的耗时,同时单独观察模块首次加载及规则初始化成本。
输入应覆盖短文本、长文本、中文、emoji、空文本,以及容易触发正则最坏情况的样本。除了耗时,还要核对匹配位置、POS 对齐和计数是否一致。
如果主要成本来自字符串转换和复制,优先调整接口;如果主要成本来自规则扫描,再优化算法和并行方式。只有端到端耗时下降且结果保持一致,才能确认这次迁移带来了收益。
备注:内容仅供参考。
