ちょっと言ってる意味がよくわからない。
以下はBun公式ブログより、Compile and run C in JavaScriptの紹介です。
Compile and run C in JavaScript
圧縮、暗号化、ネットワーク、そしてこの記事を読んでいるブラウザまで、この世の全てはCで動いています。
Cそのものではなかったとしても、C++・Rust・ZigなどC ABIとしてCライブラリで使用できるものも多数です。
CとC ABIは、システムプログラミングの過去であり、現在であり、そして未来です。
そのため、Bun v1.1.28では、C言語をそのままコンパイル・実行する機能を実験的にサポートしました。
#include <stdio.h>
void hello() {
printf("You can now compile & run C in Bun!\n");
}
import { cc } from "bun:ffi";
export const {
symbols: { hello },
} = cc({
source: "./hello.c",
symbols: {
hello: {
returns: "void",
args: [],
},
},
});
hello();
Twitterでは多くの質問が殺到しました。
「どうしてJavaScriptでC言語を書きたいんだ?」
これまで、JavaScriptからシステムライブラリを呼び出す方法は2種類がありました。
・N-API
アドオンもしくはV8 C++ API
アドオンの利用。
・emscripten
もしくはwasm-pack
でWASMにコンパイル。
What's wrong with N-API (napi)?
どうしてN-API
ではだめなのか。
N-API
はネイティブライブラリをJavaScriptから使えるようにする、ランタイムに依存しないC APIです。
BunとNode.jsはこれを実装しています。
N-API
以前はV8 C++ API
を使っていましたが、Node.jsがV8を更新するたびに互換性の問題が発生する可能性がありました。
Compiling native addons breaks CI
ネイティブアドオンは通常、postinstall
スクリプトでnode-gyp
を使ってN-API
アドオンをコンパイルします。
node-gyp
はPython 3
とC++
コンパイラに依存しています。
多くの人にとって、フロントエンドJavaScriptアプリを開発するだけのためにPython 3
やC++
コンパイラをインストールすることは、理解しがたい挙動です。
Compiling native addons is complicated for maintainers
この問題に対応するため、一部のライブラリはpackage.json
のos・cpuフィールドを駆使してパッケージの事前ビルドを行っています。
ビルドの複雑さをユーザから取り上げたのはよいことですが、大量のビルドターゲットを正確に維持し続けることは、メンテナにとっても容易なことではありません。
"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 function calls: 3x overhead
より複雑なビルドと引き換えに、我々は何を得られるでしょうか。
JavaScript → Native call overhead | Mechanism |
---|---|
7ns - 15ns | N-API |
2ns | JavaScriptCore C++ API (lower bound) |
単純なループ関数の呼び出しコストを調べてみると、JavaScriptCore C++ API
を使えば2nsであるところ、N-API
を使うと7nsもかかってしまいます。
いったい何が3倍ものパフォーマンス低下を招いているのでしょうか?
残念なことに、これはN-API
のAPI設計の問題です。
N-API
はランタイムに依存しないようにするため、JavaScriptから値を読み取るなどの単純な作業にも動的ライブラリ関数を呼び出して型チェックを行っています。
より複雑な操作には、より多くのメモリ割り当てと、複数階層のポインタ参照が入り込みます。
すなわち、N-API
は高速化を目指して作られたものではありません。
JavaScriptは世界で最も人気のあるプログラミング言語のはずですk
もっといいものはないのでしょうか?
What about WebAssembly?
WebAssemblyはどうでしょうか?
N-API
の複雑さやパフォーマンスを気にする一部のプロジェクトは、ネイティブアドオンをWebAssemblyにコンパイルしてJavaScriptにインポートする道を選びました。
JavaScriptはJavaScriptとWebAssemblyをまたぐ関数呼び出しをインライン化できるので、これは有効な手段です。
しかしながら、システムライブラリにおいてはWebAssemblyの分離メモリモデルには重大なトレードオフが存在します。
分離とはすなわち、システムコールが存在しないことを意味します。
WebAssemblyは、ランタイムが公開する関数にしかアクセスすることができません。
通常はこれがJavaScriptです。
MacOSのKeychain APIや、オーディオ録音APIにアクセスしたい場合はどうすればいいでしょう。
CLIからWindowsレジストリにアクセスしたい場合は?
また分離は、全てをcloneすることを意味します。
最近のプロセッサは48ビット280TBのアドレス空間を利用可能ですが、WebAssemblyは32ビットの独自メモリにしかアクセスすることができません。
まとめると、WebAssemblyとJavaScript間でデータをやりとりする場合、テキストやバイナリデータを毎回全てcloneしなければなりません。
多くのプロジェクトでは、これはWebAssemblyを採用したことによるパフォーマンス向上が打ち消されてしまいます。
N-API
やWebAssemblyによらない、サーバサイドJavaScriptのオプションはないのでしょうか。
共有メモリを持ち、呼び出しのオーバーヘッドが極小である言語、すなわちネイティブC言語をJavaScriptから実行するのです。
Compile and run native C from JavaScript
簡単な例として、C言語の乱数ジェネレータをJavaScriptから利用するサンプルです。
#include <stdio.h>
#include <stdlib.h>
int myRandom() {
return rand() + 42;
}
これをコンパイルして実行するJavaScriptコードです。
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
How does this work?
これはどうやって動いているのか。
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
と異なり、この型変換は自動的に行われるため、動的呼び出しのオーバーヘッドは発生しません。
またラッパーはコンパイル時に自動生成されるため、互換性の問題を気にすることもなく、パフォーマンスを犠牲にすることもなく、安全にインライン化できます。
bun:ffi compiles quickly
clangやgccを使ったことのある人は、こう思うかもしれません。
素晴らしい!
これでJavaScriptを実行するたびに10秒かかるようになるんだね!
bun:ffi
でコンパイルにかかる時間を計測してみましょう。
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 is low-overhead
FFIは遅いという流言があります。
Bunではそんなことはありません。
まずは上限を確認しておきましょう。
楽をするためにGoogleのベンチマークライブラリを使います。
#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
So that's 4 nanoseconds per call in C/C++. This represents the ceiling of how fast it could possibly get.
プレーンなC/CPPでは4ナノ秒でした。
すなわち、これが実現可能な速度の上限です。
bun:ffi
はどれくらいでしょうか。
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ナノ秒であり、すなわち2ナノ秒が呼び出しにかかるオーバーヘッドということになります。
What can you build with this?
結局これで何ができるの?
bun:ffi
は、動的にリンクされた共有ライブラリを使用可能です。
ffmpegの変換を3倍高速にしてみましょう。
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}`);
}
}
#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が存在します。
これはJavaScriptから直接使うことができないため、N-API
等で回避する必要がありました。
これをC言語をすこしばかり記述するだけで済むとしたらどうでしょう。
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);
#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;
}
What is this good for?
これは何の役に立つ?
これは、JavaScriptからCライブラリやシステムライブラリを利用する容易な手段です。
JavaScriptを実行するのと同じ方法で、余計なステップを経ずにCを実行できます。
これは、C言語やC言語っぽいものをJavaScriptにバインドするグルーコードとして役立ちます。
JavaScriptから利用したいCライブラリやシステムライブラリがあったとして、普通はそのライブラリはJavaScriptからの利用を想定して書かれたものではありません。
そのようなコードをJavaScriptから使うためには、Cでラップするのが今のところ最も簡単な方法です。
What is this not for?
この機能は何に向いていないでしょうか?
あらゆるものにはトレードオフが存在します。
PostgresSQLやSQLiteなど大規模なCプロジェクトを使用することには向いていないでしょう。
TinyCCはそれなりにいいかんじにコンパイルしてはくれますが、自動ベクトル化や特殊なCPU命令など、C言語やGCCが行っているような高度な最適化までは行いません。
また逆に、非常に小さなコードをCで最適化したとしても、パフォーマンスはあまり向上しないでしょう。
感想
これは便利!
……か?
正直なところ、ほとんどの一般開発者には縁のない機能だと思います。
PHPなどのFFIと同様、高度な機能を使いたいライブラリの開発者が主なターゲットとして使われる機能になることでしょう。
というか普通にJavaScriptの制限突破してますよね。
どこまでできるのかよくわかりませんが、やろうと思えば四聖龍神録がブラウザで動いたりするのでしょうか?
今後、いったいどんなライブラリが出てくるか楽しみですね。