Главная / Статьи / TypeScript без Node или V8: сравнение производительности нативных бинарников ScriptC

TypeScript без Node или V8: сравнение производительности нативных бинарников ScriptC

Проблемы холодного запуска, RSS, пропускной способности и нагрузки на CPU при работе одного и того же HTTP-сервера на TypeScript в качестве нативного бинарника ScriptC вместо Node.js с V8.

975 слов

Заметки по тестированию при работе HTTP-сервиса на TypeScript в виде нативного бинарника вместо его запуска под Node.js с движком V8.

Для работы JavaScript-бэкенда практически всегда требуется среда выполнения. В Node, Deno или Bun этой средой обычно является V8 от Google: JIT-компиляция в машинный код, сборка мусора и цикл событий, настроенный для параллельной обработки задач.

В рамках альтернативного эксперимента исследуется возможность того, чтобы серверы на TypeScript могли обойтись без Node, V8 и любых других движков JavaScript, компилируясь в один самодостаточный нативный бинарник.

Экспериментальный компилятор от Vercel Labs scriptc следует именно этому пути. TypeScript сначала преобразуется в IR LLVM или промежуточный код на C, а затем обрабатывается стандартными компиляторами, такими как clang.

Тот же HTTP-сервис на TypeScript тестировался в двух конфигурациях:

  1. Node.js v22.21.1, работающий с встроенной функцией удаления типов
  • scriptc v0.0.32 — генерация нативного бинарника ELF x86-64
  • Приведённые ниже результаты кратко описывают изменения после удаления V8 с этого сервера.

    1. Сравниваемые нагрузки

    Важна была справедливость, поэтому сервер использует стандартный модуль http от Node без дополнительных зависимостей. Исходный текст, бизнес-логика и границы HTTP остаются одинаковыми на обоих хостах.

    Обрабатываемые маршруты:

    • /health — простой тест готовности
    • /json — структурированный JSON-тело
    • /compute — цикл с числовыми операциями
    • /hash — SHA-256 с использованием node:crypto
    import { createServer } from "node:http";
    import { createHash } from "node:crypto";
    
    const server = createServer((req, res) => {
      const url = new URL(req.url ?? "/", "http://localhost");
    
      if (req.method === "GET" && url.pathname === "/health") {
        res.writeHead(200, { "content-type": "application/json" });
        return res.end(JSON.stringify({ status: "ok" }));
      }
    
      // Additional routes (/json, /compute, /hash)
    });
    
    server.listen(process.env.PORT || 3000);
    

    2. Время запуска при первом старте

    Платформы без сервера, функции на краю сети и контейнеры, способные масштабироваться до нуля, не учитывают время запуска процесса. Измерялось время от создания процесса до успешного ответа на запрос /health; измерения повторялись десять раз.

    • Node.js показал среднее значение примерно 232,16 мс
    • scriptc показал среднее значение примерно 14,23 мс (примерно в 16,3 раза быстрее)

    Отсутствие этапов загрузки движка V8, обработки кода и настройки удаления типов позволяет нативному бинарнику отвечать почти мгновенно.

    3. Размер RSS в состоянии покоя и виртуальный размер

    V8 заранее выделяет память хеш-таблиц и буферы JIT. Сколько памяти остается зарезервированным во время ожидания процессом?

    • Физический размер RSS для Node составлял примерно 70,40 МБ
    • Физический размер RSS для scriptc составлял примерно 2,55 МБ (примерно в 27,6 раза меньше)

    Виртуальный размер показал более ясную картину: Node демонстрировал примерно 21,5 ГБ VSZ благодаря стратегии маппинга V8, в то время как scriptc оставался на уровне примерно 4,09 МБ.

    4. Пропускная способность запросов и задержки

    oha создавал нагрузку в течение 10 секунд при 10, 100 и 500 одновременных клиентах.

    Легкий тест (/health)

    • При 500 одновременных запросах scriptc достигал примерно 29 828 RPS, тогда как у Node этот показатель составлял 9 966 RPS (~2,99×).
    • При 100 одновременных запросах задержка p50 для scriptc составляла примерно 2,78 мс, в то время как у Node — 8,19 мс.

    Ответы в формате JSON (/json)

    С учетом сериализации scriptc достигал примерно 24 209 RPS, тогда как у Node при 100 одновременных запросах этот показатель составлял 10 012 RPS (~2,42×).

    5. Пути обработки CPU: заранее vs в реальном времени

    Маршруты, ориентированные на CPU, показывают моменты различий между подходами AOT и JIT.

    Арифметический цикл в /compute

    Обработчик повторяет выражение (result + i * 31) % 1_000_000_007.

    • Node обеспечивает примерно 2,835 RPS (среднее время выполнения около 27,38 мс)
    • scriptc обеспечивает примерно 2,620 RPS (среднее время выполнения около 32,21 мс)

    Здесь JIT в Node превосходит конкурентов примерно на 15%. Анализ кода, сгенерированного scriptc, показывает, что локальные переменные представлены как значения типа C double, а операция остатка выполняется с помощью функции fmod():

    double sc_t11 = fmod(sc_t9, sc_t10);
    

    Вызов функции fmod() влечет за собой затраты, связанные с использованием динамической библиотеки. V8 на этапе выполнения наблюдает поведение, похожее на работу с целыми числами, и может использовать специализированный метод деления на 32-битные целые числа, что делает процесс более эффективным.

    SHA-256 в /hash

    Функция хеширования реализуется с использованием модуля node:crypto.

    • Node: примерно 9,873 RPS (p50 около 8.45 мс)
    • scriptc: примерно 28,849 RPS (p50 около 2.97 мс)

    scriptc превосходит Node примерно в 2.9×. Node дважды переходит на C++ для выполнения методов .update() и .digest(), что каждый раз приводит к дополнительным затратам из-за интеграции с OpenSSL. scriptc объединяет всю работу в один вызов на нативном уровне:

    ScrStr *sc_t212 = scr_crypto_hash_digest_str(sc_t209, sc_t210, sc_t211);
    

    Работа непосредственно в памяти ядра исключает необходимость обмена данными между JavaScript и нативным кодом.

    6. Размер образа контейнера

    Пакетирование для развертывания в стиле Kubernetes:

    • Образы, основанные на node:22-slim, занимают примерно 120 MB
    • Образы, основанные на debian:bookworm-slim и содержащие только динамически связанную библиотеку scriptc, занимают примерно 80 MB; статическое или упрощенное пакетирование может сократить размер до 25 MB

    7. Критические ограничения scriptc сегодня

    Компилятор остается экспериментальным и имеет узкие возможности:

    Динамические паттерны: чрезмерное использование any или высокодинамичного JavaScript приводит к необходимости встраивания QuickJS (~620KB), что лишает код преимуществ скорости AOT-компиляции.

    Экосистема пакетов: многие модули npm зависят от внутренних компонентов Node или механизмов рефлексии, которые невозможно скомпилировать статически.

    Отсутствие JIT: как показало пример /compute, специализация типов во время выполнения с помощью JIT отсутствует.

    8. Практические выводы

    Используйте Node, когда:

    • Необходимы крупные диаграммы зависимостей npm
    • Код имеет динамическую или слабо типизированную структуру
    • Важна поддержка JIT для циклов с числовыми данными динамического типа

    Рассмотрите scriptc, когда:

    • У хостов, работающих в режиме масштабирования до нуля или на краю сети, требуется время запуска менее 15 мс и объем холодного RSS менее 3 МБ
    • Сервис в основном оборачивает нативные библиотеки (шифрование, сетевые операции) в простой API

    Примеры артефактов при воспроизведении находятся в этом репозитории GitHub.