TypeScript без Node или V8: сравнение производительности нативных бинарников ScriptC
Проблемы холодного запуска, RSS, пропускной способности и нагрузки на CPU при работе одного и того же HTTP-сервера на TypeScript в качестве нативного бинарника ScriptC вместо Node.js с V8.
Заметки по тестированию при работе 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 тестировался в двух конфигурациях:
- Node.js v22.21.1, работающий с встроенной функцией удаления типов
Приведённые ниже результаты кратко описывают изменения после удаления 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.