第二部分:用更高效的数据结构让 JSI 提速
- 原文链接:blog.margelo.com/make-jsi-ru…
- 原文作者:Alex Shumihin
在 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::vector、malloc、内存映射文件——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_t→Int32Array、float→Float32Array、double→Float64Array。如果您的结构具有填充或混合类型,请在字节偏移量中考虑它或使用DataView来提高精度。
基准(100,000 次调用)
ArrayBuffer: 34.81ms // 比对象数组快 29 倍
这是一个巨大的改进:比 Array<Object> 快 ~30x,比 Flat Array 快 ~9x。
🚀 在相机处理等工作负载中,这种表示变化可能是保持在帧预算内和丢帧之间的区别。
数据形态总结
方法之间的差异不是渐进的——而是一个数量级。
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或查找表而不是字符串。
字符串构建:便利性与性能
假设我们正在编写一个从 GitHub REST API 请求数据的函数。我们需要获取存储库的最新版本标签,因此我们需要两个参数:USER_NAME和REPO_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_name或repo_name可以是任意的,请在复制之前检查总长度或使用有界的API。该基准旨在显示在控制尺寸时的弦乐建造成本。
让我们对这个实现进行基准测试:
基准(100,000 次调用)
char buffer: 7.78ms
此版本比 std::string 连接快 1.6 倍,比 std::format 快 2.7 倍。
⚖️
char缓冲区是最快的,但它们以安全换取性能。在大多数代码路径中,std::string和reserve是更好的平衡。
字符串构建小结
std::format:21.35ms(基线)std::string拼接:12.38ms(1.7x)charbuffer:7.78ms(2.7x)
数字到字符串的转换
在热路径上构建字符串时,还有一个很容易被忽略的小细节:将数字转换为文本。
常见的方法是使用 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)
最后的想法
本文中的主题——数据形状、API 形状、字符串构建和数字转换——在第一部分做出更广泛的架构决策之后,可以产生巨大的实际影响。
主要收获:
-
如果需要将数值数组从 C++ 传递到 JS,请使用
ArrayBuffer或MutableBuffer而不是对象数组 -
如果函数接受类型参数,请传递数字索引而不是字符串,并考虑使用查找表
-
如果您在热路径上构建 URL 或类似字符串,只要控制输入大小,堆栈
char缓冲区可能比std::format快得多 -
如果您在热路径中将数字转换为字符串,请考虑
std::to_chars以避免创建临时字符串
这些优化不需要改变整个模块架构。它们主要来自选择正确的数据表示。
JSI 中最大的性能优势通常不是来自聪明的算法,而是来自选择正确的数据表示形式和最小化边界穿越。在许多情况下,朴素 API 和完善的 API 之间的差异不是 10-20%,而是一个数量级。
术语表(本篇命中)
| 术语 | 英文 | 释义 |
|---|---|---|
| JavaScript Interface | JSI | React Native 中连接 JS 与原生层的低层接口。 |
| Host Function | HostFunction | 在 C++ 侧暴露给 JS 的函数能力。 |
| Host Object | HostObject | 通过属性访问暴露原生对象的桥接结构。 |
| Mutable Buffer | MutableBuffer | 由原生持有内存并映射到 JS ArrayBuffer 的接口。 |
| Typed Array | TypedArray | 基于 ArrayBuffer 的类型化视图。 |
| Lookup Table | lookup table | 通过索引直接查值的数据结构。 |