PAYATHON 2026

将 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 对齐和计数是否一致。

如果主要成本来自字符串转换和复制,优先调整接口;如果主要成本来自规则扫描,再优化算法和并行方式。只有端到端耗时下降且结果保持一致,才能确认这次迁移带来了收益。

备注:内容仅供参考。