【翻译】第二部分:用更高效的数据结构让 JSI 提速

0 阅读14分钟

第二部分:用更高效的数据结构让 JSI 提速

在 JSI 模块中选择错误的数据表示形式可能会使您的代码慢 30 倍 — 没有算法错误,只是形状错误。

第一部分中,我们介绍了 HostFunction 与 HostObject、NativeState 和堆栈分配。这篇文章深入了一层:数据如何从 C++ 跨越到 JS,各层之间的 API 契约是如何设计的,以及字符串是如何在原生端构建的。每个部分都有基准。 📊

所有基准测试均在 Hermes 的发布版本中运行。每个测试执行该函数 100,000 次。结果是 5 次运行的平均值。

数据形状:从对象到原始内存

本机代码通常需要将数据返回给 JavaScript。有多种方法可以做到这一点,并且慢速表示和快速表示之间的差异可能很大。

想象一个针对每个相机帧运行的函数,检测图像中的某些内容,并返回点列表。例如,这些可能是我们稍后在 JS 端渲染的面部标志。 C++

struct Point {
 int32_t x, y;
};

由于可以有很多点,我们将它们表示为向量:C++

std::vector<Point> points;

让我们编写一个生成测试点的小助手:C++

constexpr size_t pointsCount = 50;

auto createPoints = []() -> std::vector<Point> {
 std::vector<Point> points;
 points.reserve(pointsCount);

 for (int i = 0; i < pointsCount; ++i) {
 int v = i * 2;
 points.emplace_back(v, v + 1);
 }
 return points;
};

现在我们需要将这些数据返回给JS。通常想到的第一个解决方案是对象数组,因此 JS 接收 Array<{x:number,y:number}>

对象数组

Function process = Function::createFromHostFunction(..., [](...) {
 std::vector<Point> points = createPoints();
 auto size = points.size();

 jsi::Array jsPoints(rt, size);

 int index = 0;
 for (auto& [x, y]: points) {
 jsi::Object obj(rt);

 obj.setProperty(rt, "x", x);
 obj.setProperty(rt, "y", y);

 jsPoints.setValueAtIndex(rt, index, std::move(obj));

 index++;
 }

 return jsPoints;
});

这看起来不错。在 JS 方面,数据非常容易使用:JSX

points.forEach(({ x, y }) => {
  // 做点什么
});

让我们对这段代码进行基准测试。

基准(100,000 次调用)C++

Array of Objects: 1036.50ms

这不是最佳的。每个 jsi::Object都是 JS 堆上的单独分配,并且每个 setProperty调用都会跨入 JSI 层。 50 个点意味着 100 次属性写入,再加上 GC 必须跟踪的 50 次对象分配。让我们消除这个开销。

🧠 这里的主要成本不是循环本身,而是多次跨越 JSI 边界并分配 GC 必须跟踪的许多小 JS 对象。

展平数组

我们可以展平数组,而不是创建对象。

数据将如下所示:C++

[x, y, x, y, x, y, ...]

为了产生这个形状,我们只需要稍微改变一下JSI函数:C++

std::vector<Point> points = createPoints();
auto size = points.size();

jsi::Array jsPoints(rt, size * 2);

int index = 0;
for (auto& [x, y]: points) {
 jsPoints.setValueAtIndex(rt, index, x);
 jsPoints.setValueAtIndex(rt, index + 1, y);
 index += 2;
}

return jsPoints;

在JS端,访问变得稍微不太方便:

for (let i = 0; i < points.length / 2; i++) {
  const index = i * 2;

  const x = points[index];
  const y = points[index + 1];
}

让我们看看这快了多少:

基准(100,000 次调用)C++

Flat Array: 311.78ms // 速度提高 3.3 倍

这已经是一个很好的结果:该函数速度提高了 3.3 倍。由于我们只返回数字,因此我们可以进一步推动这一点。

数组缓冲区

我们可以完全避免创建 JS 数组和 JS 对象。我们不是从 C++ 中逐个元素构建 JavaScript 结构,而是为 JavaScript 提供一个指向Native内存的直接指针。没有每个元素的 JSI 调用。没有来自单个值的 GC 压力。没有第二次分配。两个运行时共享的只是一个连续的字节块。

⚡ JSI 的MutableBuffer是一个 C++ 接口,可让本机代码拥有底层内存。 JS 端生成的ArrayBuffer只是同一分配的视图——数据根本不会被复制。

MutableBuffer 合约

jsi::MutableBuffer 是一个纯虚拟基类,具有两种方法:

class MutableBuffer {
public:
 virtual size_t size() const = 0;
 virtual uint8_t* data() = 0;
};

你可以对它进行子类化,用任何你想要的存储来支持它——std::vectormalloc、内存映射文件——JSI 将该内存公开为 JavaScript 中的ArrayBuffer,中间副本为零。

这是我们的实现:

class PointsBuffer : public jsi::MutableBuffer {
 std::vector<Point> points_;
public:
 explicit PointsBuffer(std::vector<Point>&& points)
 : points_(std::move(points)) {}

 size_t size() const override {
 return points_.size() * sizeof(Point);
 }

 uint8_t* data() override {
 return reinterpret_cast<uint8_t*>(points_.data());
 }
};

关键细节:points\_移入,而不是复制。该向量在createPoints()期间分配一次。 data() 返回相同的指针——不会发生第二次分配。

JSI 函数本身变得微不足道:

auto process = Function::createFromHostFunction(..., [](...) {
 std::vector<Point> points = createPoints();
 auto buffer = std::make_shared<PointsBuffer>(std::move(points));
 return jsi::ArrayBuffer(rt, std::move(buffer));
});

在JS端读取

我们的Point结构体是两个int32_t字段——每个点 8 个字节,紧密包装。我们使用Int32Array读取它,它是原始字节的类型化视图,无需额外的装箱或复制:

const buffer = process(); // 返回数组缓冲区
const view = new Int32Array(buffer);

for (let i = 0; i < view.length / 2; i++) {
  const x = view[i * 2];
  const y = view[i * 2 + 1];
}

🧠 类型化数组类型必须与 C++ 布局完全匹配。 int32_tInt32ArrayfloatFloat32ArraydoubleFloat64Array。如果您的结构具有填充或混合类型,请在字节偏移量中考虑它或使用DataView来提高精度。

基准(100,000 次调用)

ArrayBuffer: 34.81ms // 比对象数组快 29 倍

这是一个巨大的改进:比 Array<Object>~30x,比 Flat Array~9x

🚀 在相机处理等工作负载中,这种表示变化可能是保持在帧预算内和丢帧之间的区别。

数据形态总结

方法之间的差异不是渐进的——而是一个数量级。

benchmark-data-shape-summary.png


API 形状:字符串与数字

现在让我们讨论将参数传递到本机函数并根据这些参数进行分支。

假设我们有一个函数接受表示数据格式的type参数。我在实际的生产存储库中看到过这样的代码。为了清晰起见,下面的示例进行了简化并略有更改,但其思想是相同的:

enum class NativeType {
 NONE = 0,
 ST_1 = 101,
 ST_2 = 151,
 ST_3 = 180,
 ST_4 = 190,
 ST_5 = 221,
 ST_6 = 230,
 ST_7 = 252
};
...
const std::string type = args[0].asString(rt).utf8(rt);

auto code = NativeType::NONE;

if (type == "uint8") {
 code = NativeType::ST_1;
} else if (type == "uint16") {
 code = NativeType::ST_2;
} else if (type == "int8") {
 code = NativeType::ST_3;
} else if (type == "int16") {
 code = NativeType::ST_4;
} else if (type == "int32") {
 code = NativeType::ST_5;
} else if (type == "float32") {
 code = NativeType::ST_6;
} else if (type == "float64") {
 code = NativeType::ST_7;
}
...

此函数接收 JS 字符串形式的type,并将其映射到 JSI 端的本机数字类型。这种方法有两个开销来源:asString(rt).utf8(rt)在堆上分配并通过 JSI 边界复制数据,并且以下字符串比较会在每次调用时增加更多工作。

让我们对其进行基准测试:

基准(100,000 次调用)

Branch string: 12.88ms

从绝对值来看,这里的收益并不大,但该模式对于每秒调用数千次的路径很重要 - 例如,处理传感器事件流或来自本机模块的高频回调。

查看代码,我们可以将一个数值从 JS 传递到 JSI 并保持相同的行为。

JS:

enum Types {
 uint8 = 0,
 uint16 = 1,
 int8 = 2,
 int16 = 3,
 int32 = 4,
 float32 = 5,
 float64 = 6,
}

JSI:

...
const int type = args[0].asNumber();
auto code = NativeType::NONE;

switch (type) {
case 0:
 code = NativeType::ST_1;
 break;
case 1:
 code = NativeType::ST_2;
 break;
case 2:
 code = NativeType::ST_3;
 break;
case 3:
 code = NativeType::ST_4;
 break;
case 4:
 code = NativeType::ST_5;
 break;
case 5:
 code = NativeType::ST_6;
 break;
case 6:
 code = NativeType::ST_7;
 break;
default:
 break;
}
...

代码类似,但它已经更高效:我们传递一个数字而不是字符串,并且没有字符串比较。

基准(100,000 次调用)

switch int: 9.13ms

结果好一点了。但如果我们看一下switch,它实际上是一个索引查找。我们可以使代码更简洁并完全删除分支:

static std::array<NativeType, 7> codes{
 NativeType::ST_1,
 NativeType::ST_2,
 NativeType::ST_3,
 NativeType::ST_4,
 NativeType::ST_5,
 NativeType::ST_6,
 NativeType::ST_7
};
...

const int type = args[0].asNumber();
const NativeType code = codes[type];

在此示例中,我们删除了if-else链和switch,创建了std::array,并按索引读取值。

💡 这假设type始终是有效的索引。在生产代码中,在索引到数组之前验证输入。 assert(type >= 0 && type <codes.size()) 在调试版本中很有用,但发布版本仍然应该防止无效的 JS 输入(如果可能发生)。

💡 您可以直接从 JS 传递最终的本机值并避免这种映射。

在实践中,本机端通常需要根据输入(枚举、模式、标志等)初始化多个内部值。使用索引 + 查找表可以最大限度地减少 JS API,同时在本机一侧将所有内容解析为一处。

基准(100,000 次调用)

lookup table: 8.67ms

这个版本是最快且最干净的。

💡 如果您的 API 被频繁调用,甚至像字符串转换 (asString(rt).utf8(rt)) 这样的小成本也会变得可见。更喜欢 JS 和原生之间的数字契约。

API形状总结

如果您的分支仅根据从 JS 传递的类型初始化值,请考虑使用switch + int或查找表而不是字符串。

benchmark-api-shape-summary.png

字符串构建:便利性与性能

假设我们正在编写一个从 GitHub REST API 请求数据的函数。我们需要获取存储库的最新版本标签,因此我们需要两个参数:USER_NAMEREPO_NAME

URL 如下所示:/repos/{USER_NAME}/{REPO_NAME}/releases/latest

现在假设我们存储许多USER_NAME/REPO_NAME对,并向多个存储库发出请求。我们需要根据输入动态构建 URL。

让我们看看几个选项。第一个也是最方便的一个是std::format

std::format

auto url = std::format("/repos/{}/{}/releases/latest", user_name, repo_name);

基准测试显示了这个结果:

基准(100,000 次调用)

std::format: 21.35ms

这个版本是最方便的,但在热路径上并不理想。 std::format 必须处理格式字符串,执行类型分派,并通过比简单串联更繁重的格式化机制构建结果。对于运行非常频繁的代码,更明确的方法可能会更快。

std::string 拼接

std::string url;
url.reserve(7 + user_name.size() + 1 + repo_name.size() + 16);

url += "/repos/";
url += user_name;
url += '/';
url += repo_name;
url += "/releases/latest";

这段代码有点冗长。让我们看看基准:

基准(100,000 次调用)

std::string concatenation: 12.38ms

更好:这个版本速度快 1.7 倍

还有一个值得一看的实现。

字符缓冲区

...
// 缓冲区必须足够大以容纳完整的 URL + null 终止符。
// 验证生产中的总长度以避免溢出。
char buffer[128];

char* ptr = buffer;

auto user_name_size = user_name.size();
auto repo_name_size = repo_name.size();

std::memcpy(ptr, "/repos/", 7);
ptr += 7;
std::memcpy(ptr, user_name.data(), user_name_size);
ptr += user_name_size;
std::memcpy(ptr, "/", 1);
ptr += 1;
std::memcpy(ptr, repo_name.data(), repo_name_size);
ptr += repo_name_size;
std::memcpy(ptr, "/releases/latest", 16);
ptr += 16;
*ptr = '\0';
...

在此版本中,我们在堆栈上分配一个固定大小的缓冲区,将 URL 的每个部分复制到其中,每次复制后移动指针,并以\0结束以标记字符串的结尾。

⚠️ 仅当最大输入大小已知并经过验证时,此方法才是安全的。如果user_namerepo_name可以是任意的,请在复制之前检查总长度或使用有界的API。该基准旨在显示在控制尺寸时的弦乐建造成本。

让我们对这个实现进行基准测试:

基准(100,000 次调用)

char buffer: 7.78ms

此版本比 std::string 连接快 1.6 倍,比 std::format2.7 倍

⚖️ char 缓冲区是最快的,但它们以安全换取性能。在大多数代码路径中,std::stringreserve是更好的平衡。

字符串构建小结

  • std::format:21.35ms(基线)
  • std::string 拼接:12.38ms(1.7x)
  • char buffer:7.78ms(2.7x)

benchmark-string-building-summary.png


数字到字符串的转换

在热路径上构建字符串时,还有一个很容易被忽略的小细节:将数字转换为文本。

常见的方法是使用 std::to_string

const int value = static_cast<int>(args[0].asNumber());

std::string res;
res.reserve(64);

res.append(std::to_string(value));
res.append(":");
res.append(std::to_string(value));

这段代码简单易读,但是 std::to_string 为每次转换返回一个新的 std::string。根据标准库实现和数字大小,这可以分配和创建临时对象,然后将其复制或移动到最终字符串中。在此示例中,我们创建两个临时字符串,然后将它们附加到最终结果中。

基准(100,000 次调用)

std::to_string: 16.27ms

对于大多数代码来说,这完全没问题。但如果运行非常频繁,这些临时分配就会变得可见。

较低级别的替代方案是 std::to_chars (C++17):

const int value = static_cast<int>(args[0].asNumber());

char buffer[64];
char* ptr = buffer;
char* const end = buffer + sizeof(buffer);

auto [p, ec] = std::to_chars(ptr, end, value);
ptr = p;

*ptr++ = ':';

auto [p2, ec2] = std::to_chars(ptr, end, value);
ptr = p2;

*ptr = '\0';

std::to_chars 直接写入提供的缓冲区。它不分配,不创建临时字符串,也不依赖于区域设置格式。这使得它非常适合输出格式简单且最大大小已知的热路径。典型用例包括 ID、计数器、时间戳、偏移量、大小和紧凑协议字符串。

⚠️ 使用std::to_chars时,请务必检查生产代码中返回的错误代码。如果缓冲区太小,ec 将被设置为 std::errc::value_too_large,并且不应该使用输出。

基准(100,000 次调用)

std::to_chars: 9.75ms // 速度提高 1.7 倍

在不改变输出格式的情况下,速度提高了 1.7 倍

它的权衡与手写 char 缓冲区相同:代码更快,但也更显式、更容易出错。对于常规应用程序代码,std::to_string通常是更好的默认值。对于紧密的本机循环,std::to_chars可以避免不必要的分配。

数字转换小结

  • std::to_string:16.27ms(基线)
  • std::to_chars:9.75ms(1.7x)

benchmark-number-conversion-summary.png


最后的想法

本文中的主题——数据形状、API 形状、字符串构建和数字转换——在第一部分做出更广泛的架构决策之后,可以产生巨大的实际影响。

主要收获:

  • 如果需要将数值数组从 C++ 传递到 JS,请使用ArrayBufferMutableBuffer而不是对象数组

  • 如果函数接受类型参数,请传递数字索引而不是字符串,并考虑使用查找表

  • 如果您在热路径上构建 URL 或类似字符串,只要控制输入大小,堆栈char 缓冲区可能比std::format快得多

  • 如果您在热路径中将数字转换为字符串,请考虑std::to_chars以避免创建临时字符串

这些优化不需要改变整个模块架构。它们主要来自选择正确的数据表示。

JSI 中最大的性能优势通常不是来自聪明的算法,而是来自选择正确的数据表示形式和最小化边界穿越。在许多情况下,朴素 API 和完善的 API 之间的差异不是 10-20%,而是一个数量级。

术语表(本篇命中)

术语英文释义
JavaScript InterfaceJSIReact Native 中连接 JS 与原生层的低层接口。
Host FunctionHostFunction在 C++ 侧暴露给 JS 的函数能力。
Host ObjectHostObject通过属性访问暴露原生对象的桥接结构。
Mutable BufferMutableBuffer由原生持有内存并映射到 JS ArrayBuffer 的接口。
Typed ArrayTypedArray基于 ArrayBuffer 的类型化视图。
Lookup Tablelookup table通过索引直接查值的数据结构。