[Перевод] Компилируем и выполняем 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:

  1. Написать аддон N-API (napi) или аддон для API V8 C++ API
  2. Скомпилировать код в 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.

image


Компиляция нативных аддонов — это сложная работа (с точки зрения поддержки)

Чтобы справиться с этой проблемой, в некоторых библиотеках предварительно собирают пакеты, для этого задействуются поля "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 кратные издержки


Что же мы получаем в обмен на усложнение сборок?

image

При использовании 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. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.

© Habrahabr.ru