首页 / 文章 / 无需 Node 或 V8 的 TypeScript:scriptc 原生二进制的基准测试

无需 Node 或 V8 的 TypeScript:scriptc 原生二进制的基准测试

当相同的 TypeScript HTTP 服务器以 ScriptC 原生二进制形式运行,而非基于 V8 的 Node.js 时,在冷启动、RSS、吞吐量以及 CPU 使用效率方面的权衡。

975 词

通过以原生二进制形式运行 TypeScript HTTP 服务,而非在基于 V8 的 Node.js 环境下运行的基准测试结果。

后端 JavaScript 几乎总是需要宿主运行时环境。在 Node、Deno 或 Bun 中,这种宿主运行时通常是 Google 的 V8:它能够进行即时编译生成机器码、实现垃圾回收,并拥有为并发处理优化的事件循环。

另一项实验旨在探讨 TypeScript 服务器是否可以跳过 Node、V8 以及所有 JS 引擎,直接编译为独立的原生二进制文件。

Vercel Labs 的实验性编译器 scriptc 正在探索这一路径。它先将 TypeScript 转换为 LLVM IR 或中间 C 代码,再由 clang 等常规编译器完成最终编译。

同一款 TypeScript HTTP 服务在两种环境下进行了性能测试:

  1. Node.js v22.21.1,启用内置类型剥离功能运行
  • scriptc v0.0.32,可生成原生 ELF x86-64 二进制文件
  • 下方的结果总结了从该服务器中移除 V8 后所发生的变化。

    1. 对比的工作负载

    为保证公平性,该服务器使用 Node 自带的 http 模块,不添加任何额外依赖。源代码、业务逻辑以及 HTTP 接口定义在两台主机上完全一致。

    覆盖的路由包括:

    • /health — 简单的可用性检测接口
    • /json — 结构化的 JSON 数据体
    • /compute — 高频数值计算循环
    • /hash — 通过 node:crypto 实现 SHA-256 哈希计算
    import { createServer } from "node:http";
    import { createHash } from "node:crypto";
    
    const server = createServer((req, res) => {
      const url = new URL(req.url ?? "/", "http://localhost");
    
      if (req.method === "GET" && url.pathname === "/health") {
        res.writeHead(200, { "content-type": "application/json" });
        return res.end(JSON.stringify({ status: "ok" }));
      }
    
      // Additional routes (/json, /compute, /hash)
    });
    
    server.listen(process.env.PORT || 3000);
    

    2. 冷启动延迟

    那些可扩展至零规模的无服务器平台、边缘函数及容器并不关心进程的启动时间。测试从进程创建开始,直到/health成功响应为止,共进行了十次重复测试。

    • Node.js的平均时间约为232.16 毫秒
    • scriptc的平均时间约为14.23 毫秒(速度大约快16.3倍)

    由于无需经历V8的启动、解析成本以及类型剥离设置,原生二进制文件几乎可以立即响应。

    3. 空闲状态下的RSS与虚拟内存大小

    V8会预先预留堆内存和JIT缓冲区。那么在进程等待期间会有多少内存被占用呢?

    • Node的物理RSS值大约在70.40 MB左右
    • scriptc的物理RSS值大约在2.55 MB左右(内存占用程度约为其1/27.6)

    虚拟内存大小展现了更明显的差异:由于 V8 的映射策略,Node 的虚拟内存大小约为 21.5 GB,而 scriptc 则保持在 4.09 MB 左右。

    4. 请求吞吐量与延迟

    oha 在 10、100 和 500 个并发客户端的情况下分别加载了 10 秒。

    轻量级探测(/health)

    • 在 500 个并发客户端的情况下,scriptc 的响应速率约为 29,828 RPS,而 Node 为 9,966 RPS(约为 2.99 倍)。
    • 在 100 个并发客户端的情况下,scriptc 的 p50 延迟约为 2.78 ms,而 Node 为 8.19 ms。

    JSON 响应(/json)

    包含序列化处理后,100 个并发客户端时 scriptc 的响应速率约为 24,209 RPS,而 Node 为 10,012 RPS(约为 2.42 倍)。

    5. CPU路径:提前编译与即时编译

    以CPU性能为目标的路由展示了AOT与JIT之间的差异。

    在/compute上的算术循环

    处理程序会重复执行(result + i * 31) % 1_000_000_007。

    • Node的处理能力约为2,835次/秒(中位数响应时间约27.38毫秒)
    • scriptc的处理能力约为2,620次/秒(中位数响应时间约32.21毫秒)

    在此场景下,**Node的JIT性能大约高出15%**。查看scriptc生成的C代码可知,局部变量以C语言的double类型存储,取模操作则通过fmod()函数实现:

    double sc_t11 = fmod(sc_t9, sc_t10);
    

    fmod()函数的调用会带来动态库相关的开销。V8在运行时会检测到类似整数的行为,从而能够采用更高效的32位整数除法方式。

    在/hash上的SHA-256加密

    哈希计算是通过node:crypto模块来完成的。

    • Node:约9,873 RPS(p50约为8.45毫秒)
    • scriptc:约28,849 RPS(p50约为2.97毫秒)

    scriptc的性能大约是Node的2.9倍。Node在调用.update()和.digest()时需要转译为C++代码,每次都会产生OpenSSL绑定的额外开销。而scriptc则可将整个操作合并为一次原生调用:

    ScrStr *sc_t212 = scr_crypto_hash_digest_str(sc_t209, sc_t210, sc_t211);
    

    直接在原生内存中运行可避免JS与原生代码之间的来回转换。

    6. 容器镜像大小

    Kubernetes风格部署的镜像打包情况如下:

    • 基于node:22-slim的镜像大小约为120 MB
    • 基于debian:bookworm-slim且仅包含动态链接的scriptc二进制的镜像大小约为80 MB;采用静态打包或scratch风格打包时大小可降至接近25 MB

    7. 当前 ScriptC 的局限

    该编译器仍处于实验阶段,功能较为有限:

    动态模式:大量使用 any 或高度动态的 JavaScript 代码时,不得不嵌入 QuickJS(约620KB),从而丧失了 AOT 编译的速度优势。

    包生态系统:许多 npm 模块依赖于 Node 的内部机制或反射功能,这些都无法进行静态编译。

    缺乏 JIT:如 /compute 示例所示,无法通过 JIT 实现运行时的类型优化。

    8. 实际应用建议

    以下情况请继续使用 Node:

    • 需要处理庞大的 npm 依赖关系图
    • 代码具有较高的动态性或弱类型特征
    • 在动态类型的数值循环中需要 JIT 的优化支持

    以下情况可考虑使用 ScriptC:

    • 针对零扩展或边缘节点,要求启动时间低于15毫秒,且冷态RSS内存占用低于3MB
    • 该服务通常会将原生库(加密、网络相关)封装为轻量级API

    复现相关的示例代码保存在此GitHub仓库中。