Быстродействие, или Смотря как измерять
- вторник, 1 сентября 2026 г. в 00:00:07
Всем привет! Меня зовут Владимир, я разработчик СберБанк Онлайн в канале «web». Наверняка вам встречалось мнение, что один и тот же язык должен показывать примерно одинаковую скорость: если код написан на JavaScript, место его запуска – браузер, Node.js, Deno или Bun – кажется второстепенной деталью, а результаты должны быть близкими. Но есть одна проблема: язык сам по себе мы почти никогда не запускаем. Например, в браузере JavaScript исполняется внутри процесса Google Chrome, а Node.js, Deno, Bun и CPython запускаются как отдельные процессы операционной системы; как только программа добирается до файлов, изображений и памяти, в эксперимент вступает уже вся платформа или среда исполнения. Тогда корректнее будет спросить не «что быстрее, JavaScript или Python», а «как одна и та же задача ведёт себя в разных средах исполнения?»

В этом эксперименте меня интересует обычный локальный сценарий: что произойдёт, если разработчик запустит уже установленные среды без предварительного выравнивания версий движков, архитектур и параметров сборки. Поэтому среды запускаются as is, а полученные цифры описывают конкретную конфигурацию, но не претендуют на универсальный рейтинг рантаймов. Чтобы это проверить, я взял четыре сценария: подсчёт слов, агрегацию JSON, пересечение массивов и работу с изображением. Решения в них идейно одинаковы, входные данные тоже, а стандартные инструменты среды используются там, где ими воспользовался бы обычный разработчик: Map и Set в JS, Counter, defaultdict и set в Python. Кроме браузера и Node.js в сравнении участвуют Deno и Bun. Deno создал Райан Дал, автор оригинального Node.js, чтобы пересмотреть некоторые архитектурные решения и подход к безопасности своего предыдущего проекта; Bun, в свою очередь, объединяет среду исполнения программы, пакетный менеджер и набор инструментов. Поэтому сравнение получается любопытнее: теперь можно посмотреть не только на JavaScript рядом с Python, но и на то, насколько по-разному ведёт себя сам JavaScript. Все исходники, входные данные и инструкции по запуску опубликованы в репозитории на GitHub.
Представим простой сценарий: программа читает JSON, превращает его в массив объектов, считает итог и выводит результат. Где начинается полезная работа? Можно измерить только агрегацию уже готового массива, а можно включить чтение и JSON.parse() — оба числа будут правильными, хотя ответят на разные вопросы. Поэтому перед каждым тестом я фиксировал границу замера и разделял:
подготовку входных данных;
чтение файла или загрузку по сети;
парсинг;
вычисление;
запись результата, если он является частью задачи.
Для оценки времени использовала медиану (срединное значение в отсортированном наборе измерений), потому что один неудачный запуск легко портит среднее. Перед основной серией несколько раз прогревал функцию (запускал её без учёта полученного времени), чтобы среда исполнения успела подготовить внутренние структуры и первый запуск не определял весь результат. В браузере измерял время через performance.now(), а память фиксировал вручную в Chrome DevTools Performance monitor по метрике JS heap-size — объёму памяти, занятому JavaScript‑объектами страницы. В CLI‑средах (запускаемых из командной строки) использовал RSS (объём физической памяти, занятый процессом). Это разные метрики: JS heap-size видит управляемую кучу JavaScript, тогда как RSS охватывает саму среду исполнения, нативные библиотеки и другие области процесса. Node.js, Bun и CPython запускались как arm64, тогда как установленная сборка Deno имела архитектуру x86_64 и работала через Rosetta. Поэтому цифры Deno характеризуют именно эту комбинацию, а не производительность нативной arm64-сборки.
Для браузера достаточно функции, которая прогревает сценарий, собирает несколько измерений и возвращает медиану вместе с минимальным и максимальным временем:
async function bench(name, fn, { warmup = 3, runs = 20 } = {}) { for (let i = 0; i < warmup; i++) await fn(); const samples = []; for (let i = 0; i < runs; i++) { const t0 = performance.now(); await fn(); samples.push(performance.now() - t0); } samples.sort((a, b) => a - b); return { name, runs, minMs: samples[0], medianMs: samples[Math.floor(samples.length / 2)], maxMs: samples[samples.length - 1], samples, }; }
В Node.js, Deno и Bun форма остаётся той же, но после каждого прогона дополнительно проверяю RSS. Для Node.js это выглядит так:
import { performance } from 'node:perf_hooks'; async function bench(name, fn, { warmup = 3, runs = 20 } = {}) { for (let i = 0; i < warmup; i++) await fn(); const samples = []; let peakRss = process.memoryUsage.rss(); for (let i = 0; i < runs; i++) { const t0 = performance.now(); await fn(); samples.push(performance.now() - t0); peakRss = Math.max(peakRss, process.memoryUsage.rss()); } samples.sort((a, b) => a - b); return { name, runs, minMs: samples[0], medianMs: samples[Math.floor(samples.length / 2)], maxMs: samples[samples.length - 1], peakRssMb: Math.round(peakRss / 1024 / 1024), samples, }; }
И Python-вариант:
import resource import sys import time def bench(name, fn, warmup=3, runs=20): for _ in range(warmup): fn() samples = [] for _ in range(runs): t0 = time.perf_counter() fn() samples.append((time.perf_counter() - t0) * 1000) samples.sort() usage = resource.getrusage(resource.RUSAGE_SELF) rss_mb = ( usage.ru_maxrss / 1024 / 1024 if sys.platform == "darwin" else usage.ru_maxrss / 1024 ) return { "name": name, "runs": runs, "min_ms": samples[0], "median_ms": samples[len(samples) // 2], "max_ms": samples[-1], "max_rss_mb": round(rss_mb), "samples": samples, }
Конечно, такой замер не претендует на лабораторную точность, однако для домашнего эксперимента его достаточно: он покажет разброс результатов и не позволит принять случайный минимум за стабильную скорость.
В первой задаче дан большой текстовый файл: нужно выделить из него слова без учёта регистра, посчитать количество вхождений каждого и вернуть двадцать самых частых. Такой подсчёт используют как первый срез данных: по самым частым словам можно быстро заметить повторяющиеся темы, служебный шум или аномальные сообщения, и решить, что делать дальше.
Файл читаем до запуска таймера, внутри замера остаются приведение строки к нижнему регистру, регулярное выражение (шаблон поиска фрагментов строки), подсчёт и сортировка. В JavaScript для этого достаточно обычного Map:
function topWords(text, limit = 20) { const words = text.toLowerCase().match(/[a-zа-яё0-9]+/giu) ?? []; const freq = new Map(); for (const word of words) { freq.set(word, (freq.get(word) ?? 0) + 1); } return [...freq.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, limit); } await bench('top words', () => topWords(bigText));
Python-решение строится вокруг Counter, стандартного инструмента для подсчёта повторений:
import re from collections import Counter WORD_RE = re.compile(r"[0-9A-Za-zА-Яа-яЁё]+") def top_words(text, limit=20): words = WORD_RE.findall(text.lower()) return Counter(words).most_common(limit) print(bench("top words", lambda: top_words(text)))
На первый взгляд, вся работа здесь сводится к одному циклу, однако большая часть стоимости скрывается в обработке строки и промежуточных аллокациях (временных выделениях памяти под строки, массивы и объекты). Полученные медианы это хорошо показывают: браузер выполнил задачу за 13,0 мс, Node.js за 14,2 мс, Bun за 14,3 мс, Python за 16,9 мс, а Deno за 22,0 мс. Браузер, Node.js и Bun оказались почти рядом, Python отстал совсем немного, зато Deno на том же алгоритме потребовалось примерно в полтора раза больше времени, чем Node.js. Значит ли это, что Deno всегда медленнее? В этой конфигурации Deno потребовалось 22,0 мс против 14,2 мс у Node.js, однако напрямую относить разницу к устройству рантаймов нельзя: Deno запускался как x86_64 через Rosetta, а Node.js – нативно как arm64. Поэтому результат показывает поведение конкретной локальной установки, а для отдельного сравнения Node.js и Deno нужен контрольный прогон на одной архитектуре.
Память разошлась ещё сильнее: браузерный heap вырос на 35,7 Мб, Node.js занял около 100 Мб RSS, Bun — 120 Мб, Deno — 161 Мб, а Python — 38 Мб. Сравнивать 35,7 Мб heap напрямую со 100 Мб RSS не совсем корректно, а вот значения RSS у Node.js, Deno, Bun и Python показывают заметный разброс — от 38 до 161 Мб: в них входит не только память входного текста, временных строк и коллекций, созданных алгоритмом, но и память самого процесса — движка, сборщика мусора, загруженных библиотек и служебных структур среды исполнения.
Теперь возьмём массив заказов интернет-магазина, по которому нужно подготовить региональный отчёт о выручке. В исходном JSON у каждого заказа есть статус, признак отмены, регион и сумма: неоплаченные и отменённые записи нужно исключить, выручку по оставшимся заказам сложить по регионам, а результаты расположить по убыванию.
Первое решение часто записывают цепочкой filter().reduce(), но filter() создаёт ещё один массив, который нам на самом деле не нужен. На большом входе проще сделать один проход:
function revenueByRegion(orders) { const totals = new Map(); for (const order of orders) { if (order.status !== 'paid' || order.cancelled) continue; totals.set(order.region, (totals.get(order.region) ?? 0) + order.total); } return [...totals.entries()].sort((a, b) => b[1] - a[1]); }
В Python использован тот же проход и defaultdict, который подставляет начальное значение для нового региона:
from collections import defaultdict def revenue_by_region(orders): totals = defaultdict(float) for order in orders: if order["status"] != "paid" or order["cancelled"]: continue totals[order["region"]] += order["total"] return sorted(totals.items(), key=lambda item: item[1], reverse=True)
Но что именно мы хотим узнать: скорость цикла или стоимость всего пути от файла до результата? Я сделал два замера. В варианте pure (только вычисления) файл читал и разбирал заранее, поэтому таймер охватывает только агрегацию:
const orders = JSON.parse(readFileSync(inputUrl, 'utf8')); await bench('revenue by region', () => { revenueByRegion(orders); });
В варианте full (полный сценарий) чтение и JSON.parse() находятся уже внутри измеряемой функции:
await bench('revenue by region end-to-end', () => { const orders = JSON.parse(readFileSync(inputUrl, 'utf8')); revenueByRegion(orders); });
Для Python граница меняется точно так же:
def run_end_to_end(): text = input_path.read_text(encoding="utf-8") revenue_by_region(json.loads(text)) print(bench("revenue by region end-to-end", run_end_to_end))
На уже готовом массиве Node.js показал 2,6 мс, Bun — 3,0 мс, браузер — 3,1 мс, Deno — 3,6 мс, Python — 9,8 мс. Здесь JavaScript‑среды идут плотной группой: цикл простой, итоговый Map маленький, а большая коллекция объектов уже создана. Python выполняет тот же обход заметно дольше, поскольку основная работа остаётся в интерпретируемом цикле по словарям. JS heap выросла только на 15,1 Мб, то есть меньше, чем при разборе текста. Это не удивительно: массив заказов появился до таймера, а во время агрегации росла в основном небольшая таблица итогов. Уже на этом месте видно, насколько показатель памяти зависит от выбранной границы.
Добавим чтение и парсинг, и порядок изменится: Bun справился за 32.0 мс, Node.js за 50.5 мс, Deno за 56,4 мс, браузер за 59,3 мс, Python за 96,7 мс. В pure между четырьмя JS‑средами было около одной миллисекунды, тогда как в полном сценарии Bun опередил Node.js почти на 19 мс. Никакого противоречия здесь нет: к циклу добавились файловый или сетевой API, декодирование JSON и создание большого количества объектов, а значит мы начали измерять не только язык, но и платформу вокруг него.
Представим, что у нас есть две выгрузки: в первой содержатся идентификаторы пользователей из CRM, во второй – идентификаторы пользователей из журнала событий. В CRM‑выгрузке нужно посчитать уникальные значения и дубли, а затем найти пересечение с журналом событий, чтобы оценить качество данных и понять, сколько пользователей присутствует в обоих источниках. Можно сравнивать каждый элемент первого массива с каждым элементом второго, но количество операций будет расти слишком быстро, поэтому сначала построим множества: это потребует дополнительной памяти, зато проверка наличия элемента станет более дешёвой:
function analyzeIds(a, b) { const seen = new Set(); const duplicates = new Set(); for (const id of a) { if (seen.has(id)) duplicates.add(id); else seen.add(id); } const bSet = new Set(b); let intersection = 0; for (const id of seen) { if (bSet.has(id)) intersection++; } return { uniqueA: seen.size, duplicates: duplicates.size, intersection, }; }
Python-версия повторяет тот же алгоритм:
def analyze_ids(a, b): seen = set() duplicates = set() for item in a: if item in seen: duplicates.add(item) else: seen.add(item) b_set = set(b) intersection = sum(item in b_set for item in seen) return { "unique_a": len(seen), "duplicates": len(duplicates), "intersection": intersection, }
В этой задаче результат прежде всего определяет структура данных: множество позволяет проверять наличие идентификатора без повторного перебора массива. Но даже при одинаковой схеме вычислений числа заметно разошлись: Bun выполнил задачу за 37,3 мс, браузер за 42,4 мс, Node.js за 44,9 мс, Deno за 56,3 мс, Python за 60,7 мс. Bun выиграл у Node.js около 17%. Но за скорость пришлось заплатить памятью: браузерный heap вырос на 78,9 Мб, Node.js занял 173 Мб RSS, Python — 167 Мб, Bun — 201 Мб, Deno — 308 Мб.
Выходит привычный компромисс между скоростью и памятью: set‑ы резко сокращают число проверок, но требуют дополнительной памяти для хранения хеш‑таблиц. В браузере это может привести к более частой сборке мусора и паузам интерфейса, а на сервере — увеличить RSS процесса и приблизить его к лимиту контейнера, поэтому результат 37,3 мс выглядит привлекательно, но без соседнего числа 201 Мб.
В последней задаче пользователь загружает четыре JPEG-фотографии, а приложение перед сохранением или показом должно последовательно подготовить для них превью размером до 320 пикселей, сохранив пропорции.
До сих пор мы работали со строками, объектами и числами, поэтому могли написать почти одинаковый код. С изображениями всё иначе: каждый файл нужно декодировать во внутреннее представление, уменьшить и снова закодировать в JPEG, причём каждая платформа делает это через собственный стек. В браузере используются createImageBitmap и OffscreenCanvas:
async function resizeOne(file, size = 320) { const bitmap = await createImageBitmap(file.blob); const ratio = Math.min(size / bitmap.width, size / bitmap.height, 1); const width = Math.round(bitmap.width * ratio); const height = Math.round(bitmap.height * ratio); const canvas = new OffscreenCanvas(width, height); const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0, width, height); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82, }); bitmap.close?.(); return { file: file.name, width, height, bytes: blob.size }; }
В интерфейсе такую работу имеет смысл перенести в Web Worker (механизм выполнения кода вне главного потока страницы), чтобы длинная обработка не блокировала интерфейс. В текущем эксперименте исходные Blob загружаются заранее, а внутри таймера остаётся последовательная цепочка decode → resize → encode. Node.js, Deno и Bun используют sharp:
async function resizeOne(file, size = 320) { const info = await sharp(file) .resize({ width: size, height: size, fit: 'inside' }) .jpeg({ quality: 82 }) .toFile(join(taskDir, 'thumbs', basename(file))); return { file, width: info.width, height: info.height, bytes: info.size, }; }
Python выполняет тот же сценарий через Pillow:
def resize_one(file, size=320): src = Path(file) dst = Path("data/task-4/thumbs") / src.name with Image.open(src) as image: thumb = ImageOps.contain(image, (size, size)) thumb.save(dst, "JPEG", quality=82) return { "file": src.name, "width": thumb.width, "height": thumb.height, "bytes": dst.stat().st_size, }
В этой задаче результат во многом определяют инструменты обработки изображений, доступные в каждой среде: sharp передаёт основную работу библиотеке libvips, Pillow использует собственные механизмы обработки, а браузер – встроенные графические API. Но именно поэтому задача интересна: в реальной разработке пользователь ждёт готовую картинку, а не результат абстрактного цикла.
Браузер завершил обработку за 13,2 мс, Bun за 25,7 мс, Python за 26,1 мс, Node.js за 27,7 мс, Deno за 41,8 мс. Быстрый браузерный результат не означает, что Canvas вообще быстрее sharp: браузер получает исходные Blob до старта таймера и возвращает готовый Blob в памяти, тогда как CLI‑версии читают изображения и записывают файлы внутри измеряемого вызова. Поэтому строка показывает скорость конкретных платформенных сценариев, а не отдельного алгоритма ресайза. Performance monitor не показал заметного роста JS heap size, хотя изображения, конечно, не обрабатывались бесплатно: декодированные bitmap (растровые представления изображений) и canvas‑буферы могут находиться вне JS heap, поэтому выбранная метрика их просто не раскрывает. У CLI‑сред доступен RSS: Node.js занял около 76 Мб, Bun — 82 Мб, Deno — 99 Мб, Python — 31 Мб. В этой задаче расход памяти описывает уже не столько объекты языка, сколько устройство библиотек и графических буферов.
Я выполнял замеры на одной машине последовательно: Google Chrome 148.0.7778.178, Node.js 20.11.1, Deno 2.8.1, Bun 1.3.14, Python 3.13.9. В браузерной колонке память указана как дельта JS heap size, в остальных – как максимальный RSS процесса, поэтому напрямую сравнивать значения памяти по горизонтали некорректно.
Платформа | Частоты слов (текст в памяти) | JSON, pure (массив распарсен) | JSON, full (чтение и парсинг) | Пересечения ID (массивы в памяти) | Превью (декодирование и кодирование) |
Browser JS | 13,0 мс 35.7 Мб JS heap | 3,1 мс 15,1 Мб JS heap | 59,3 мс не измерялось | 42,4 мс 78,9 Мб JS heap | 13,2 мс рост JS heap не зафиксирован |
Node.js | 14,2 мс 100 Мб RSS | 2,6 мс 98 Мб RSS | 50,5 мс 141 Мб RSS | 44,9 мс 173 Мб RSS | 27,7 мс 76 Мб RSS |
Deno | 22,0 мс 161 Мб RSS | 3,6 мс 141 Мб RSS | 56,4 мс 250 Мб RSS | 56,3 мс 308 Мб RSS | 41,8 мс 99 Мб RSS |
Bun | 14,3 мс 120 Мб RSS | 3,0 мс 59 Мб RSS | 32,0 мс 112 Мб RSS | 37,3 мс 201 Мб RSS | 25,7 мс 82 Мб RSS |
Python | 16,9 мс 38 Мб RSS | 9,8 мс 77 Мб RSS | 96,7 мс 77 Мб RSS | 60,7 мс 167 Мб RSS | 26,1 мс 31 Мб RSS |
По времени единого победителя не получилось. Браузер показал лучшую медиану в обработке текста, Node.js – в агрегации уже распарсенного массива, а Bun – в полном JSON‑сценарии и задаче с множествами. В задаче с изображениями минимальное время также осталось за браузером, однако этот результат нельзя напрямую сравнивать с остальными: исходные Blob загружались до таймера, а итоговый Blob не записывался на диск. Среди реализаций, которые читали и записывали файлы, Bun, Python и Node.js уложились в близкий диапазон от 25,7 до 27,7 мс, тогда как Deno потребовалось 41,8 мс. У каждой среды проявился свой профиль. Node.js оказался быстрее остальных в коротком цикле по готовым объектам; Bun заметно оторвался, когда в замер вошли чтение и парсинг JSON, а также показал лучшее время при работе с множествами. Python проиграл на больших циклах по объектам, зато при обработке изображений приблизился к Bun и опередил Node.js, поскольку основную работу выполняла библиотека Pillow. Deno не показал лучшего времени ни в одной задаче: он оказался последним в тексте и изображениях, а в остальных сценариях также не вошёл в число лидеров.
По RSS результат определённее: Python использовал меньше всего памяти в четырёх задачах из пяти, а в чистой JSON‑агрегации минимум показал Bun. Deno, напротив, занял больше всего памяти во всех пяти запусках (очевидно из-за запуска через Rosetta.), где измерялся RSS. Node.js в большинстве случаев расположился между Python и Deno, а профиль Bun зависел от задачи: в двух JSON‑сценариях он использовал меньше памяти, чем Node.js, но в тексте, множествах и изображениях – больше.
Браузерную память нужно рассматривать отдельно. Наименьший прирост JS heap size получился в чистой агрегации — 15,1 Мб, обработка текста добавила 35,7 Мб, а множества — 78,9 Мб. При работе с изображениями заметного роста JS heap size не было, поскольку bitmap и canvas‑буферы могут находиться вне JS‑heap. Эти значения объясняют поведение браузерных задач, но не позволяют определить, использовал ли браузер меньше памяти, чем Node.js, Deno, Bun или Python.
Какой вывод из этого можно сделать? Определить единственного победителя по этим замерам не получилось: эти замеры не отвечают на вопрос, какая среда исполнения быстрее вообще, они скорее показывают, как несколько прикладных сценариев ведут себя в конкретной локальной конфигурации. Но если посмотреть на цифры таблицы, то лидер менялся от задачи к задаче, а стоило включить в измерение чтение файла, парсинг или кодирование изображения, как менялся и порядок результатов; при этом самый быстрый вариант не обязательно оказывался самым экономным по памяти. Поэтому строго сравнивать языки и среды исполнения можно только при одинаковых входных данных, границах задачи и метриках, а также на одной архитектуре, с зафиксированными версиями и в сопоставимых условиях запуска; иначе стоящие рядом цифры описывают не только разные среды, но и разные конфигурации. Место запуска – браузер, Node.js, Deno, Bun или CPython – оказалось не второстепенной деталью, поскольку итоговое быстродействие зависит не только от алгоритма, но и от того, как конкретная среда получает и разбирает данные, управляет памятью и использует системные API и библиотеки.