Bun

在 JavaScript 中編譯並執行 C


Jarred Sumner · 2024 年 9 月 18 日

從壓縮到密碼學,從網路到您正在閱讀此文的網頁瀏覽器,世界都仰賴 C 語言運作。如果程式不是用 C 寫成的,它也會遵循 C ABI(C++、Rust、Zig 等),並以 C 語言函式庫的形式提供。C 語言和 C ABI 是系統程式設計的過去、現在和未來。

這就是為什麼在 Bun v1.1.28 中,我們引入了從 JavaScript 編譯和執行原生 C 語言的實驗性支援

hello.c
hello.ts
hello.c
#include <stdio.h>

void hello() {
  printf("You can now compile & run C in Bun!\n");
}
hello.ts
import { cc } from "bun:ffi";

export const {
  symbols: { hello },
} = cc({
  source: "./hello.c",
  symbols: {
    hello: {
      returns: "void",
      args: [],
    },
  },
});

hello();

在 Twitter 上,許多人問了相同的問題

「我為什麼要從 JavaScript 編譯和執行 C 程式?」

先前,您有兩種從 JavaScript 使用系統函式庫的選項

  1. 編寫 N-API (napi) 附加元件或 V8 C++ API 函式庫附加元件
  2. 透過 emscripten 或 wasm-pack 編譯為 WASM/WASI

N-API (napi) 有什麼問題?

N-API (napi) 是一個與執行階段無關的 C API,用於將原生函式庫公開給 JavaScript。Bun 和 Node.js 都有實作它。在 napi 之前,原生附加元件大多使用 V8 C++ API,這表示每次 Node.js 更新 V8 時都可能發生重大變更。

編譯原生附加元件會破壞 CI

原生附加元件通常依賴 "postinstall" 指令碼,以使用 node-gyp 編譯 N-API 附加元件。node-gyp 依賴 Python 3 和最新的 C++ 編譯器。

對於許多人來說,需要在 CI 中安裝 Python 3 和 C++ 編譯器來建置前端 JavaScript 應用程式,是一個令人不快的意外。

編譯原生附加元件對維護者來說很複雜

為了處理這個問題,有些函式庫會預先建置它們的套件,利用 package.json 中的 "os""cpu" 欄位。將複雜性從使用者轉移到維護者對生態系統有益,但維護 10 個不同建置目標的建置矩陣並不容易。

@napi-rs/canvas/package.json
"optionalDependencies": {
  "@napi-rs/canvas-win32-x64-msvc": "0.1.55",
  "@napi-rs/canvas-darwin-x64": "0.1.55",
  "@napi-rs/canvas-linux-x64-gnu": "0.1.55",
  "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.55",
  "@napi-rs/canvas-linux-x64-musl": "0.1.55",
  "@napi-rs/canvas-linux-arm64-gnu": "0.1.55",
  "@napi-rs/canvas-linux-arm64-musl": "0.1.55",
  "@napi-rs/canvas-darwin-arm64": "0.1.55",
  "@napi-rs/canvas-android-arm64": "0.1.55"
}

JavaScript → N-API 函式呼叫:3 倍的額外負擔

為了換取更複雜的建置,我們得到了什麼?

JavaScript → 原生呼叫額外負擔機制
7ns - 15nsN-API
2nsJavaScriptCore C++ API(下限)

使用 JavaScriptCore C++ API,一個簡單的 noop 函式每次呼叫的成本為 2ns。使用 N-API,noop 函式每次呼叫的成本為 7ns。

我們為什麼要為此付出 3 倍的效能損失?

不幸的是,這是 napi 中的 API 設計問題。為了使 napi 與執行階段無關,從 JavaScript 值讀取整數等簡單操作涉及動態函式庫呼叫。為了使 napi 與語言無關,引數的執行階段類型檢查發生在每個動態函式庫呼叫中。更複雜的操作涉及許多記憶體配置(或 GC 的物件配置)和多層指標間接引用。N-API 從未被設計為快速。

JavaScript 是世界上最流行的程式設計語言。我們可以做得更好嗎?

WebAssembly 呢?

為了規避 N-API 建置的複雜性和效能問題,有些專案選擇將其原生附加元件編譯為 WebAssembly,並在 JavaScript 中匯入它。

由於 JavaScript 引擎可以內聯跨越 WebAssembly <> JavaScript 邊界的函式呼叫,因此這可以奏效。

但是,對於系統函式庫來說,WebAssembly 的隔離記憶體模型帶來了嚴重的權衡

隔離表示沒有系統呼叫

WebAssembly 只能存取執行階段公開給它的函式。通常,那是 JavaScript。

那麼,依賴系統 API 的函式庫呢?例如 macOS Keychain API(用於安全地儲存/擷取密碼)或 錄音?如果您的 CLI 想要使用 Windows 登錄檔呢?

隔離表示複製所有內容

現代處理器支援約 280 TB 的可定址記憶體(48 位元)。WebAssembly 是 32 位元,只能存取自己的記憶體。

這表示預設情況下,傳遞字串和二進位資料 JavaScript <=> WebAssembly 每次都必須複製。對於許多專案來說,這抵消了利用 WebAssembly 帶來的任何效能提升。


如果 N-API 和 WebAssembly 不是伺服器端 JavaScript 的唯一選項呢?如果我們可以從 JavaScript 編譯和執行原生 C 語言,並具有共享記憶體和近乎零的呼叫額外負擔呢?

從 JavaScript 編譯並執行原生 C

以下是一個快速範例,它在 C 語言中編譯一個隨機數字產生器,並在 JavaScript 中執行它。

myRandom.c
#include <stdio.h>
#include <stdlib.h>

int myRandom() {
    return rand() + 42;
}

編譯並執行 C 語言的 JavaScript 程式碼

main.js
import { cc } from "bun:ffi";

export const {
  symbols: { myRandom },
} = cc({
  source: "./myRandom.c",
  symbols: {
    myRandom: {
      returns: "int",
      args: [],
    },
  },
});

console.log("myRandom() =", myRandom());

最後,輸出結果

bun ./main.js
myRandom() = 43

這是如何運作的?

bun:ffi 使用 TinyCC 在記憶體中編譯、連結和重新定位 C 程式。從那裡,它產生內聯函式包裝器,將 JavaScript 原始類型 <=> C 原始類型轉換。

例如,為了將 C 語言中的 int 轉換為 JavaScriptCore 的 EncodedJSValue 表示法,程式碼基本上會執行以下操作

static int64_t int32_to_js(int32_t input) {
  return 0xfffe000000000000ll | (uint32_t)input;
}

與 N-API 不同,這些類型轉換會自動發生,且沒有動態調度額外負擔。由於這些包裝器是在 C 編譯時產生的,因此我們可以安全地內聯類型轉換,而無需擔心相容性問題,也不會犧牲效能。

bun:ffi 編譯速度很快

如果您之前使用過 clanggcc,您可能會想

clang/gcc 使用者:「太好了 🙄 現在我每次執行這個 JS 都必須等待 10 秒來編譯 C 語言。」

讓我們測量一下使用 bun:ffi 編譯需要多長時間

main.js
import { cc } from "bun:ffi";

 console.time("Compile ./myRandom.c");
export const {
  symbols: { myRandom },
} = cc({
  source: "./myRandom.c",
  symbols: {
    myRandom: {
      returns: "int",
      args: [],
    },
  },
});
 console.timeEnd("Compile ./myRandom.c");

以及輸出結果

bun ./main.js
[5.16ms] Compile ./myRandom.c
myRandom() = 43

那是 5.16 毫秒。感謝 TinyCC,在 Bun 中編譯 C 語言速度很快。如果編譯需要 10 秒,我們就不會安心地發布它。

bun:ffi 具有低額外負擔

外部函式介面 (FFI) 因速度慢而聞名。但在 Bun 中,情況有所不同。

在我們在 Bun 中測量它之前,讓我們了解一下它可以達到的最快速度上限。為了簡單起見,讓我們使用 Google 的基準測試函式庫(需要 .cpp 檔案)

bench.cpp
#include <stdio.h>
#include <stdlib.h>
#include <benchmark/benchmark.h>

int myRandom() {
    return rand() + 42;
}

static void BM_MyRandom(benchmark::State& state) {
  for (auto _ : state) {
    benchmark::DoNotOptimize(myRandom());
  }
}
BENCHMARK(BM_MyRandom);

BENCHMARK_MAIN();

以及輸出結果

clang++ ./bench.cpp -L/opt/homebrew/lib -l benchmark -O3 -I/opt/homebrew/include -o bench
./bench
------------------------------------------------------
Benchmark            Time             CPU   Iterations
------------------------------------------------------
BM_MyRandom       4.67 ns         4.66 ns    150144353

因此,在 C/C++ 中,每次呼叫為 4 奈秒。這表示它可能達到的最快速度上限。

使用 bun:ffi 需要多長時間?

bench.js
import { bench, run } from 'mitata';
import { myRandom } from './main';

bench('myRandom', () => {
  myRandom();
});

run();

在我的機器上,結果是

bun ./bench.js
cpu: Apple M3 Max
runtime: bun 1.1.28 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
myRandom     6.26 ns/iter    (6.16 ns … 17.68 ns)   6.23 ns   7.67 ns  10.17 ns

6 奈秒。因此,bun:ffi 的每次呼叫額外負擔僅為 6ns - 4ns = 2ns。

您可以使用這個建置什麼?

bun:ffi 可以使用動態連結的共享函式庫。

使用 ffmpeg 將短片轉換速度提高 3 倍

透過避免產生新程序和為每個影片分配大量記憶體的額外負擔,您可以將短片轉換速度提高 3 倍。

ffmpeg.js
mp4.c
ffmpeg.js
import { cc, ptr } from "bun:ffi";
import source from "./mp4.c" with {type: 'file'};
import { basename, extname, join } from "path";

console.time(`Compile ./mp4.c`);
const {
  symbols: { convert_file_to_mp4 },
} = cc({
  source,
  library: ["c", "avcodec", "swscale", "avformat"],
  symbols: {
    convert_file_to_mp4: {
      returns: "int",
      args: ["cstring", "cstring"],
    },
  },
});
console.timeEnd(`Compile ./mp4.c`);
const outname = join(
  process.cwd(),
  basename(process.argv.at(2), extname(process.argv.at(2))) + ".mp4"
);
const input = Buffer.from(process.argv.at(2) + "\0");
const output = Buffer.from(outname + "\0");
for (let i = 0; i < 10; i++) {
  console.time(`Convert ${process.argv.at(2)} to ${outname}`);
  const result = convert_file_to_mp4(ptr(input), ptr(output));
  if (result == 0) {
    console.timeEnd(`Convert ${process.argv.at(2)} to ${outname}`);
  }
}

mp4.c
#include <dlfcn.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
#include <libavutil/imgutils.h>
#include <libavutil/opt.h>
#include <libswscale/swscale.h>
#include <stdio.h>
#include <stdlib.h>

int to_mp4(void *buf, size_t buflen, void **out, size_t *outlen) {
  AVFormatContext *input_ctx = NULL, *output_ctx = NULL;
  AVIOContext *input_io_ctx = NULL, *output_io_ctx = NULL;
  uint8_t *output_buffer = NULL;
  int ret = 0;
  int64_t *last_dts = NULL;

  // Register all codecs and formats

  // Create input IO context
  input_io_ctx = avio_alloc_context(buf, buflen, 0, NULL, NULL, NULL, NULL);
  if (!input_io_ctx) {
    ret = AVERROR(ENOMEM);
    goto end;
  }

  // Allocate input format context
  input_ctx = avformat_alloc_context();
  if (!input_ctx) {
    ret = AVERROR(ENOMEM);
    goto end;
  }

  input_ctx->pb = input_io_ctx;

  // Open input
  if ((ret = avformat_open_input(&input_ctx, NULL, NULL, NULL)) < 0) {
    goto end;
  }

  // Retrieve stream information
  if ((ret = avformat_find_stream_info(input_ctx, NULL)) < 0) {
    goto end;
  }

  // Allocate output format context
  avformat_alloc_output_context2(&output_ctx, NULL, "mp4", NULL);
  if (!output_ctx) {
    ret = AVERROR(ENOMEM);
    goto end;
  }

  // Create output IO context
  ret = avio_open_dyn_buf(&output_ctx->pb);
  if (ret < 0) {
    goto end;
  }

  // Copy streams
  for (int i = 0; i < input_ctx->nb_streams; i++) {
    AVStream *in_stream = input_ctx->streams[i];
    AVStream *out_stream = avformat_new_stream(output_ctx, NULL);
    if (!out_stream) {
      ret = AVERROR(ENOMEM);
      goto end;
    }

    ret = avcodec_parameters_copy(out_stream->codecpar, in_stream->codecpar);
    if (ret < 0) {
      goto end;
    }
    out_stream->codecpar->codec_tag = 0;
  }

  // Write header
  ret = avformat_write_header(output_ctx, NULL);
  if (ret < 0) {
    goto end;
  }

  // Allocate last_dts array
  last_dts = calloc(input_ctx->nb_streams, sizeof(int64_t));
  if (!last_dts) {
    ret = AVERROR(ENOMEM);
    goto end;
  }

  // Copy packets
  AVPacket pkt;
  while (1) {
    ret = av_read_frame(input_ctx, &pkt);
    if (ret < 0) {
      break;
    }

    AVStream *in_stream = input_ctx->streams[pkt.stream_index];
    AVStream *out_stream = output_ctx->streams[pkt.stream_index];

    // Convert timestamps
    pkt.pts =
        av_rescale_q_rnd(pkt.pts, in_stream->time_base, out_stream->time_base,
                         AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX);
    pkt.dts =
        av_rescale_q_rnd(pkt.dts, in_stream->time_base, out_stream->time_base,
                         AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX);
    pkt.duration =
        av_rescale_q(pkt.duration, in_stream->time_base, out_stream->time_base);

    // Ensure monotonically increasing DTS
    if (pkt.dts <= last_dts[pkt.stream_index]) {
      pkt.dts = last_dts[pkt.stream_index] + 1;
      pkt.pts = FFMAX(pkt.pts, pkt.dts);
    }
    last_dts[pkt.stream_index] = pkt.dts;

    pkt.pos = -1;

    ret = av_interleaved_write_frame(output_ctx, &pkt);
    if (ret < 0) {
      char errbuf[AV_ERROR_MAX_STRING_SIZE];
      av_strerror(ret, errbuf, AV_ERROR_MAX_STRING_SIZE);
      fprintf(stderr, "Error writing frame: %s\n", errbuf);
      break;
    }
    av_packet_unref(&pkt);
  }

  // Write trailer
  ret = av_write_trailer(output_ctx);
  if (ret < 0) {
    goto end;
  }

  // Get the output buffer
  *outlen = avio_close_dyn_buf(output_ctx->pb, &output_buffer);
  *out = output_buffer;
  output_ctx->pb = NULL; // Set to NULL to prevent double free

  ret = 0; // Success

end:
  if (input_ctx) {
    avformat_close_input(&input_ctx);
  }
  if (output_ctx) {
    avformat_free_context(output_ctx);
  }
  if (input_io_ctx) {
    av_freep(&input_io_ctx->buffer);
    av_freep(&input_io_ctx);
  }

  return ret;
}

int convert_file_to_mp4(const char *input_filename,
                        const char *output_filename) {
  FILE *input_file = NULL;
  FILE *output_file = NULL;
  uint8_t *input_buffer = NULL;
  uint8_t *output_buffer = NULL;
  size_t input_size = 0;
  size_t output_size = 0;
  int ret = 0;

  // Open the input file
  input_file = fopen(input_filename, "rb");
  if (!input_file) {
    perror("Could not open input file");
    return -1;
  }

  // Get the size of the input file
  fseek(input_file, 0, SEEK_END);
  input_size = ftell(input_file);
  fseek(input_file, 0, SEEK_SET);

  // Allocate memory for the input buffer
  input_buffer = (uint8_t *)malloc(input_size);
  if (!input_buffer) {
    perror("Could not allocate input buffer");
    ret = -1;
    goto cleanup;
  }

  // Read the input file into the buffer
  if (fread(input_buffer, 1, input_size, input_file) != input_size) {
    perror("Could not read input file");
    ret = -1;
    goto cleanup;
  }

  // Call the to_mp4 function to convert the buffer
  ret = to_mp4(input_buffer, input_size, (void **)&output_buffer, &output_size);
  if (ret < 0) {
    fprintf(stderr, "Error converting to MP4\n");
    goto cleanup;
  }

  // Open the output file
  output_file = fopen(output_filename, "wb");
  if (!output_file) {
    perror("Could not open output file");
    ret = -1;
    goto cleanup;
  }

  // Write the output buffer to the file
  if (fwrite(output_buffer, 1, output_size, output_file) != output_size) {
    perror("Could not write output file");
    ret = -1;
    goto cleanup;
  }

cleanup:

  if (output_buffer) {
    av_free(output_buffer);
  }
  if (input_file) {
    fclose(input_file);
  }
  if (output_file) {
    fclose(output_file);
  }

  return ret;
}

// for running it standalone
int main(const int argc, const char **argv) {

  if (argc != 3) {
    printf("Usage: %s <input_file> <output_file>\n", argv[0]);
    return -1;
  }

  const char *input_filename = argv[1];
  const char *output_filename = argv[2];

  int result = convert_file_to_mp4(input_filename, output_filename);
  if (result == 0) {
    printf("Conversion successful!\n");
  } else {
    printf("Conversion failed!\n");
  }
  return result;
}

使用 macOS Keychain API 安全地儲存和載入密碼

macOS 具有內建的 Keychain API,用於安全地儲存和擷取密碼,但這並未公開給 JavaScript。如果您可以直接在您的 JS 專案中編寫幾行 C 程式碼並完成它,而不是弄清楚如何使用 N-API 包裝它、使用 node-gyp 設定 CMake,那會怎麼樣?

keychain.js
keychain.c
keychain.js
import { cc, ptr, CString } from "bun:ffi";
const {
  symbols: { setPassword, getPassword, deletePassword },
} = cc({
  source: "./keychain.c",
  flags: [
    "-framework",
    "Security",
    "-framework",
    "CoreFoundation",
    "-framework",
    "Foundation",
  ],
  symbols: {
    setPassword: {
      args: ["cstring", "cstring", "cstring"],
      returns: "i32",
    },
    getPassword: {
      args: ["cstring", "cstring", "ptr", "ptr"],
      returns: "i32",
    },
    deletePassword: {
      args: ["cstring", "cstring"],
      returns: "i32",
    },
  },
});

var service = Buffer.from("com.bun.test.keychain\0");

var account = Buffer.from("bun\0");
var password = Buffer.alloc(1024);
password.write("password\0");
var passwordPtr = new BigUint64Array(1);
passwordPtr[0] = BigInt(ptr(password));
var passwordLength = new Uint32Array(1);

setPassword(ptr(service), ptr(account), ptr(password));

passwordLength[0] = 1024;
password.fill(0);
getPassword(ptr(service), ptr(account), ptr(passwordPtr), ptr(passwordLength));
const result = new CString(
  Number(passwordPtr[0]),
  0,
  passwordLength[0]
);
console.log(result);
keychain.c
#include <Security/Security.h>
#include <stdio.h>
#include <string.h>

// Function to set a password in the keychain
OSStatus setPassword(const char* service, const char* account, const char* password) {
    SecKeychainItemRef item = NULL;
    OSStatus status = SecKeychainFindGenericPassword(
        NULL,
        strlen(service), service,
        strlen(account), account,
        NULL, NULL,
        &item
    );

    if (status == errSecSuccess) {
        // Update existing item
        status = SecKeychainItemModifyAttributesAndData(
            item,
            NULL,
            strlen(password),
            password
        );
        CFRelease(item);
    } else if (status == errSecItemNotFound) {
        // Add new item
        status = SecKeychainAddGenericPassword(
            NULL,
            strlen(service), service,
            strlen(account), account,
            strlen(password), password,
            NULL
        );
    }

    return status;
}

// Function to get a password from the keychain
OSStatus getPassword(const char* service, const char* account, char** password, UInt32* passwordLength) {
    return SecKeychainFindGenericPassword(
        NULL,
        strlen(service), service,
        strlen(account), account,
        passwordLength, (void**)password,
        NULL
    );
}

// Function to delete a password from the keychain
OSStatus deletePassword(const char* service, const char* account) {
    SecKeychainItemRef item = NULL;
    OSStatus status = SecKeychainFindGenericPassword(
        NULL,
        strlen(service), service,
        strlen(account), account,
        NULL, NULL,
        &item
    );

    if (status == errSecSuccess) {
        status = SecKeychainItemDelete(item);
        CFRelease(item);
    }

    return status;
}

這有什麼好處?

這是一種低樣板的方式,可從 JavaScript 使用 C 語言函式庫和系統函式庫。執行 JavaScript 的同一個專案也可以執行 C 語言,而無需單獨的建置步驟。

它適用於將 C 或類似 C 語言的函式庫繫結到 JavaScript 的膠水程式碼。有時,您想要從 JavaScript 使用 C 語言函式庫或系統 API,但該函式庫從未打算從 JavaScript 使用。

編寫一些 C 程式碼來將這類程式碼包裝成 JavaScript 友善的 API 通常是最簡單的方法,因為

  • 範例是以 C 語言編寫的,而不是透過 FFI 在 JavaScript 中編寫的。
  • 使用 FFI 表示您必須在 JavaScript 和 C 之間進行心智翻譯。在 C 語言中使用指標比透過 JavaScript 中的類型化陣列在 FFI 中使用指標更容易。那麼,為什麼不讓自己更輕鬆呢?

這不適用於什麼?

每個工具都有其權衡取捨。

  • 您可能不想使用它來編譯大型 C 專案,例如 PostgresSQL 或 SQLite。TinyCC 編譯成效能尚可的 C 語言,但它不會執行 Clang 或 GCC 所做的進階最佳化,例如自動向量化或非常專業的 CPU 指令。
  • 您可能不會透過 C 語言微調程式碼庫的小部分來獲得太多效能提升,但很樂意被證明是錯的!