124
94

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Bun】JavaScriptをC言語で書けるようになったよ

Last updated at Posted at 2024-11-04

ちょっと言ってる意味がよくわからない。

以下は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言語をそのままコンパイル・実行する機能を実験的にサポートしました。

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からシステムライブラリを呼び出す方法は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-gypPython 3C++コンパイラに依存しています。
多くの人にとって、フロントエンドJavaScriptアプリを開発するだけのためにPython 3C++コンパイラをインストールすることは、理解しがたい挙動です

01.png

Compiling native addons is complicated for maintainers

この問題に対応するため、一部のライブラリはpackage.jsonoscpuフィールドを駆使してパッケージの事前ビルドを行っています。
ビルドの複雑さをユーザから取り上げたのはよいことですが、大量のビルドターゲットを正確に維持し続けることは、メンテナにとっても容易なことではありません。

@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 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から利用するサンプルです。

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

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

これをコンパイルして実行する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

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でコンパイルにかかる時間を計測してみましょう。

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 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倍高速にしてみましょう。

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が存在します。
これはJavaScriptから直接使うことができないため、N-API等で回避する必要がありました。
これを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;
}

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の制限突破してますよね。
どこまでできるのかよくわかりませんが、やろうと思えば四聖龍神録がブラウザで動いたりするのでしょうか?
今後、いったいどんなライブラリが出てくるか楽しみですね。

124
94
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
124
94

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?