[Перевод] Компилируем и выполняем C на JavaScript
Мир работает на C. Этот язык лежит в основе сжатия файлов, сетевых взаимодействий и даже браузера, в котором вы читаете эту статью. Если код не написан на C, он всё равно взаимодействует с ABI, написанном на C (речь о C++, Rust, Zig, т.д.) и доступен в виде библиотеки на C. Язык C и C ABI — это прошлое, настоящее и будущее системного программирования.
Вот почему мы разработали Bun v1.1.28, в которой предлагаем экспериментальную поддержку компиляции и выполнения нативного C из JavaScript
hello.c
#include
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();
В Твиттере многие задавали нам один и тот же вопрос:
«А зачем мне вообще может потребоваться компилировать программы на C и потом выполнять их из JavaScript?»
Ранее существовало две возможности использовать системные библиотеки из JavaScript:
- Написать аддон N-API (napi) или аддон для API V8 C++ API
- Скомпилировать код в WASM/WASI при помощи emscripten или wasm-pack
Что не так с N-API (napi)?
N-API (napi) — это API языка C, не зависящий от среды выполнения. Предназначен для предоставления нативных библиотек в JavaScript. Его реализуют Bun и Node.js. До появления napi с нативными аддонами обычно использовался API V8 C++, работа с которым могла приводить к разрушительным изменениям всякий раз, когда Node.js обновлял V8.
Компиляция нативных аддонов ломает CI
В основе работы нативных аддонов обычно лежит скрипт addons "postinstall"
script, позволяющий компилировать аддоны N-API при помощи node-gyp
. node-gyp
зависит от Python 3 и свежего компилятора C++.
Многие неприятно удивятся тому, что в конвейере CI, оказывается, нужно установить Python 3 и компилятор C++ только для того, чтобы собрать клиентское приложение JavaScript.
Компиляция нативных аддонов — это сложная работа (с точки зрения поддержки)
Чтобы справиться с этой проблемой, в некоторых библиотеках предварительно собирают пакеты, для этого задействуются поля "os"
и "cpu"
в package.json. Экосистеме пойдёт на пользу, если эта часть сложности делегируется от пользователей к специалистам по поддержке, но не так просто поддерживать матрицу сборки на 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-x кратные издержки
Что же мы получаем в обмен на усложнение сборок?
При использовании JavaScriptCore C++ API тратим 2 нс на вызов простой noop-функции. При использовании N-API на вызов noop-функции уходит 7 нс.
Почему же мы расплачиваемся за это трёхкратным снижением производительности?
К сожалению, проблема в том, как именно спроектирован данный API. Чтобы napi одинаково работал независимо от среды выполнения, приходится динамически вызывать библиотечные функции при простых операциях, например, при считывании целого числа из значения JavaScript. Также именно для этого при каждом динамическом вызове библиотечной функции прямо во время выполнения проверяются типы аргументов. При более сложных операциях многократно выделяется память (или объекты, позже попадающие под сборку мусора), возникает многоуровеновая опосредованность указателей. Никогда и не предполагалось, что N-API будет работать быстро.
JavaScript — самый популярный язык программирования в мире. Можно ли его превзойти?
Что насчёт WebAssembly?
Чтобы обогнуть характерную для N-API сложность при сборке и проблемы с производительностью, на некоторых проектах нативный аддон сначала компилируется на WebAssembly, а затем импортируется в JavaScript.
Поскольку движки JavaScript сами могут втягивать вызовы функций, проникающие на сторону WebAssembly, такой подход может сработать.
Но системные библиотеки плохо приспособлены к работе с предусмотренной в WebAssembly изолированной моделью памяти, и в данном случае приходится идти на серьёзные компромиссы.
При изоляции — никаких системных вызовов
WebAssembly получает доступ только к тем функциям, которые ему предоставляет среда выполнения. Обычно в таком качестве выступает JavaScript.
Что насчёт библиотек, зависящих от системных API, например, macOS Keychain API (для безопасного хранения и извлечения паролей) или аудиозаписи? Что, если ваш CLI захочет воспользоваться реестром Windows?
При изоляции всё приходится клонировать
Современные процессоры поддерживают около 280 ТБ адресуемой памяти (48 разрядов). Язык WebAssembly 32-разрядный и может обращаться только к собственной памяти.
Таким образом, по умолчанию, при передаче строк и двоичных данных в направлении JavaScript <=> WebAssembly данные при каждой операции нужно клонировать. Во многих проектах из-за этого нивелируется всяческий выигрыш в производительности, приобретаемый благодаря WebAssembly.
Что, если бы возможности использования JavaScript на сервере не ограничивались N-API и WebAssembly? Если бы нам удавалось компилировать нативный C, а затем выполнять его из JavaScript, пользуясь при этом разделяемой памятью при практически нулевых издержках на вызовы?
Компилируем и выполняем нативный C из JavaScript
Давайте по-быстрому разберём пример, в котором генератор случайных чисел компилируется на C, а затем выполняется на JavaScript.
myRandom.c
#include
#include
int myRandom() {
return rand() + 42;
}
Код JavaScript, компилирующий и выполняющий C:
main.js
import { cc } from "bun:ffi";
export const {
symbols: { myRandom },
} = cc({
source: "./myRandom.c",
symbols: {
myRandom: {
Наконец, вывод:
bun ./main.js
myRandom() = 43
Как это работает?
При помощи TinyCC bun:ffi
компилирует, связывает и перемещает программы на C в оперативной памяти. Опираясь на эти данные, он генерирует обёртки для инлайнинга функций, которые преобразуют примитивные типы JavaScript в примитивные типы на C и обратно.
Например, чтобы преобразовать целое число(int)
из в C в представление EncodedJSValue для JavaScriptCore, код выполняет следующую операцию:
static int64_t int32_to_js(int32_t input) {
return 0xfffe000000000000ll | (uint32_t)input;
}
В отличие от N-API, такие преобразования типов происходят автоматически, при этом издержки на динамическую диспетчеризацию равны нулю. Поскольку эти обёртки генерируются во время компиляции C, можно без опаски втягивать преобразования типов, при этом не волноваться о проблемах с совместимостью и не жертвовать производительностью.
bun:ffi
быстро компилируется
Если ранее вы работали с clang
или gcc
, то, возможно, думаете:
Пользователь clang/gcc: «Отлично. Теперь нужно каждый раз ждать по 10 секунд, пока скомпилируется C, если мне нужно запустить этот JS.»
Давайте измерим, сколько времени уходит на такую компиляцию при использовании 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, компиляция C в Bun идёт быстро. У нас возникли бы проблемы с отправкой этого кода, если бы на его компиляцию уходило 10 секунд.
bun:ffi
— издержки невелики
Интерфейс внешних функций (FFI) известен своей медлительностью. В Bun всё иначе.
Прежде, чем перейти к замерам в Bun, давайте определимся, насколько он может разогнаться — очертим верхнюю границу. Для простоты давайте воспользуемся библиотекой контрольных точек от Google (ей требуется файл в формате .cpp):
bench.cpp
#include
#include
#include
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
составляют всего 6 нс–4 нс = 2 нс.
Что можно собрать при помощи этого инструмента?
bun: ffi может работать с динамически связываемыми разделяемыми библиотеками.
Троекратный выигрыш в скорости при преобразовании коротких видео с применением ffmpeg
Если избавиться от издержек на порождение нового процесса, и если не требуется выделять много памяти на каждое новое видео, то преобразование коротких видеороликов идёт втрое быстрее обычного.
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
#include
#include
#include
#include
#include
#include
#include
#include
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 \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. Давайте не будем пытаться обернуть его в N-API и сконфигурировать CMake с применением node-gyp, а просто напишем несколько строк на C в проекте JS — и удовлетворимся этим?
keychain.js
keychain.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);
keychain.c
#include
#include
#include
// 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 библиотеку или системный API из языка C, причём, такая библиотека исходно совершенно не предназначалась для использования из JavaScript.
Обычно бывает проще всего написать на C небольшую обёртку, чтобы упаковать код такого рода в API, приспособленный к работе с JavaScript. Вот почему:
- Примеры пишутся на C, а не на JavaScript через интерфейс внешних функций.
- При работе с интерфейсом внешних функций вам придётся постоянно мысленно переключаться между JavaScript и C. Указатели проще использовать именно в C, чем через интерфейс FFI, так, чтобы в JavaScript они представляли собой типизированные массивы. Так не лучше ли самостоятельно это упростить?
Для чего такой метод не подойдёт?
При работе с любым инструментом от чего-то приходится отказываться.
- Вероятно, этот метод не подойдёт для компиляции больших проектов на C, например, PostgresSQL или SQLite. TinyCC компилируется во вполне производительный C, но он не приспособлен к продвинутым оптимизациям, применяемым в Clang или GCC, например, к автовекторизации или узкоспециализированным инструкциям ЦП.
- Вероятно, вы не сможете серьёзно выиграть в производительности, если займётесь микрооптимизацией отдельных элементов вашей базы кода на C. Рад буду ошибиться!
P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.