[DESAFIO] NodeJS mais rápido que o Bun v1.0.1
Uma nova runtime de Javascript foi lançada esse mês o Bun e provavelmente tu já sabe. Eu sempre testo performance usando algoritmo basico e assim como fiz pro Mojo vs. Python escolhi fazer Fibonacci.
Eis que o Bun ficou MUITO atras do NodeJS: Video nesse Tweet.
Me ajude a descobrir onde está a perda de performance do Bun.
console.time("Execution Time");
const n = 1000000
let a = BigInt(0), b = BigInt(1); // fixed
for (let i = BigInt(0); i < n; i++) {
[a, b] = [b, a + b];
}
console.log("fib: " + a);
console.timeEnd("Execution Time");
Adicionando misterio a essa desafio:
Se fizer o loop a seguir sem a matematica do Fibonacci o Bun desempenha melhor que o NodeJS.
console.time("Execution Time");
const n = 1000000
for (let i = BigInt(0); i < n; i++) {
console.log("Item: " + i);
}
console.timeEnd("Execution Time");
Ambiente que rodei o teste MacOS
- Apple Chip M1
- Apple Chip M1 Max
Nota: Obviamente rodei o mesmo codigo em ambos e não usei nenhuma biblioteca externa apenas o raw Javacript que o DHH tanto ama.
Fiz alguns testes aqui, só fiz uma pequena modificação no código, inicializando o n
com um BigInt
também:
let n = 1000000n; // sufixo "n" faz com que seja BigInt
let a = BigInt(0), b = BigInt(1);
for (let i = BigInt(0); i < n; i++) {
[a, b] = [b, a + b];
}
console.log(a);
Testei na minha máquina (Ubuntu 22.04.3, processador 11th Gen Intel® Core™ i5-1145G7 2.60GHz, 8 núcleos), primeiro com o teste mais básico, usando o comando time
do Linux. Ou seja, time bun run arquivo.js
e time node arquivo.js
. A versão do Node é v18.17.1, e do Bun é 1.0.1. Os resultados estão abaixo (lembrando que em outros hardwares os tempos poderão ser diferentes, obviamente), só retirei a saída do console.log
para não deixar poluído.
Node:
real 0m10,368s
user 0m8,712s
sys 0m0,037s
Bun:
real 0m9,915s
user 0m9,782s
sys 0m1,833s
O que importa é a primeira linha ("real", o tempo que efetivamente se passou entre eu teclar ENTER e o comando terminar).
Só de curiosidade, "user" é o tempo de CPU em user mode (fora do kernel), e "sys" é o tempo de CPU dentro do kernel (em chamadas de sistema, por exemplo). Mas estes tempos são computados considerando todos os núcleos, então se a máquina tem mais de um, a soma de "user" e "sys" pode ser maior que "real", conforme explicado aqui.
Enfim, no meu caso o Node demorou um pouco mais, mas dado o tempo total de ambos (10,3 segundos versus 9,9 segundos), não acho que foi uma diferença tão significativa (cerca de 4% a mais).
Mas tem um detalhe, operações de I/O (como o console.log
) costumam ser caras e elas geralmente acabam mascarando o resultado (neste caso nem tanto porque só tem uma, mas enfim). Então removi o console.log
para ver apenas o tempo do loop e rodei de novo.
Node:
real 0m8,615s
user 0m8,577s
sys 0m0,033s
Bun:
real 0m9,329s
user 0m9,424s
sys 0m1,710s
Agora o Bun foi ligeiramente mais lento, mas novamente, não acho que a diferença é tão significativa.
Testei mais algumas vezes, e às vezes o Node era mais rápido, às vezes era mais lento, mas sempre com diferenças pequenas. Nada tão gritante quanto o que vc encontrou.
Uma maneira melhor de testar
O problema de fazer um teste isolado é justamente esse: vc roda uma vez um pequeno trecho de código e já acha que é o suficiente, mas conforme já visto aqui, isso pode levar a conclusões precipitadas.
Mesmo que rode várias vezes, podem ter outros fatores que influenciam, como outros processos rodando na mesma máquina (ainda mais se tiver I/O e outras operações bloqueantes), e até mesmo a própria inicialização do runtime (tanto o Node quanto o Bun precisam de uma etapa de inicialização antes de começar a rodar o código propriamente dito).
Sendo assim, uma forma melhor de testar seria usar uma lib específica que desconsidera esses fatores externos. Eu usei o Benchmark.js, o código ficou assim:
var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;
suite.add('test', function () {
let n = 1000000n;
let a = BigInt(0), b = BigInt(1);
for (let i = BigInt(0); i < n; i++) {
[a, b] = [b, a + b];
}
}).on('cycle', function (event) {
console.log(String(event.target));
}).run({
'async': true
});
Obs: o propósito do Benchmark.js na verdade é comparar dois códigos diferentes. Por exemplo, chamo várias vezes add
com algoritmos diferentes, e no final ele mostra qual é mais rápido. Mas aqui eu coloquei apenas um, e rodei este teste no Node e depois no Bun, pois ele também mostra a quantidade de operações por segundo que conseguiu executar. E é exatamente isso que eu quero comparar, pois dá para ter uma ideia melhor do desempenho de cada um.
Resultados com Node:
test x 0.12 ops/sec ±0.24% (5 runs sampled)
E com Bun:
test x 0.11 ops/sec ±0.90% (5 runs sampled)
Ou seja, 0,12 operações por segundo versus 0,11 operações por segundo. Em outras palavras, no Node demoraria cerca de 8,3 segundos para rodar o código uma vez, e no Bun, cerca de 9 segundos. Mais uma vez, uma diferença bem pequena, nada da discrepância que vc encontrou.
Rodei mais algumas vezes e os resultados giraram em torno disso, com pouca variação, e às vezes um era melhor, às vezes o outro - o que mostra que mesmo uma lib que desconsidera fatores externos não consegue eliminar 100% deles.
Quanto ao segundo caso (o console.log
dentro do loop), realmente o Node foi pior em todos os casos. Primeiro com time
, segue abaixo.
Bun:
real 0m2,648s
user 0m0,656s
sys 0m1,416s
Node:
real 0m5,264s
user 0m4,276s
sys 0m0,884s
E com o Benchmark.js:
Bun:
test x 0.38 ops/sec ±2.40% (5 runs sampled)
Node:
test x 0.19 ops/sec ±7.30% (5 runs sampled)
Ou seja, o Bun foi cerca de duas vezes mais rápido, uma diferença maior que no primeiro código.
Pesquisei um pouco a respeito e encontrei isso, que indica que a implementação do console.log
no Node acaba deixando-o pior para este caso específico.
Conclusões (ou não)
No fim das contas, é difícil tirar conclusões definitivas sobre qual deles sempre será mais rápido. Isso depende de tantos fatores que o melhor a fazer é testar em uma situação mais próxima possível do caso concreto: teste o seu sistema, com seu código e seus casos de uso, e veja se faz diferença. Testar rodando um código qualquer uma ou poucas vezes é o teste mais ingênuo e propenso a erros que vc pode fazer (e tirar "verdades" disso é pior ainda). Testar a situação real costuma ser mais efetivo, pois aí vc está considerando o seu contexto específico em vez de só seguir a moda. Mais ainda, como vimos acima, em um caso particular pode fazer mais diferença que em outros, então o melhor é testar com o código que vc efetivamente vai usar.
Se procurar por benchmarks que comparam os dois, vai encontrar vários diferentes, e é importante ver qual código foi usado para testar, já que isso pode fazer diferença. E claro que também precisa considerar outros fatores: se usar A ou B vai facilitar o dia-a-dia da sua equipe, se é estável, se aguenta o tranco, etc etc etc. Vc pode inclusive concluir que não faz diferença, e não tem problema, pois o que importa é que seja uma decisão embasada, em vez de só puro achismo ou "ouvi dizer que é melhor".
Fiz alguns testes também sem bibliotecas em uma máquina linux com fedora 38 (dell Intel® Core™ i5-8350U × 8)
1. Teste de Operações com BigInt (Números de Fibonacci):
console.time("Execution Time");
const n = 1000000;
let a = BigInt(0), b = BigInt(1);
for (let i = BigInt(0); i < n; i++) {
[a, b] = [b, a + b];
}
console.timeEnd("Execution Time");
Resultados: Node.js: 13.770s Bun: 32.01s
2. Teste de Operações de I/O:
console.time("Execution Time");
const n = 1000000;
for (let i = BigInt(0); i < n; i++) {
console.log("Item: " + i);
}
console.timeEnd("Execution Time");
Resultados:
Node.js: 17.787s Bun: 5.27s
3. Teste de Alocação de Memória:
console.time("Memory Allocation Execution Time");
const n = 1000;
for (let i = 0; i < n; i++) {
let arr = new Array(100000).fill(0);
}
console.timeEnd("Memory Allocation Execution Time");
Resultados:
Node.js: 2.162s Bun: 1.78s
4. Teste de Manipulação de Strings:
console.time("String Manipulation Execution Time");
let result = "";
const baseString = "abcdefghijklmnopqrstuvwxyz";
const n = 100000;
for (let i = 0; i < n; i++) {
result += baseString;
}
console.timeEnd("String Manipulation Execution Time");
Resultados:
Node.js: 9.487ms Bun: 4.12ms
5. Teste de Manipulação de Arrays:
console.time("Array Operations Execution Time");
const n = 100000;
let arr = [];
for (let i = 0; i < n; i++) {
arr.push(i);
}
for (let i = 0; i < n; i++) {
let item = arr[i];
}
for (let i = 0; i < n; i++) {
arr.pop();
}
console.timeEnd("Array Operations Execution Time");
Resultados:
Node.js: Média de 8.347ms Bun: Média de 7.91ms
6. Teste de Operações Matemáticas Básicas:
console.time("Basic Math Operations Execution Time");
let result = 0;
const n = 1000000;
for (let i = 1; i <= n; i++) {
result += i;
result -= i;
result *= i;
result /= i;
}
console.timeEnd("Basic Math Operations Execution Time");
Resultados:
Node.js: Média de 11.115ms Bun: Média de 15.423ms
Parece que o bun se destaca em operações de I/O, agora o Node.js tem uma vantagem em operações aritméticas, especialmente com BigInt
Talvez a perda de performance seja por conta do arquivo não ser em typescript, temos esse trecho na página do Bun:
Bun is fast, starting up to 4x faster than Node.js. This difference is only magnified when running a TypeScript file, which requires transpilation before it can be run by Node.js.
Me parece um artifício para que as pessoas tenham algum hype de o Bun ser um Node Killer. Mas lendo o que eles mesmo escreveram da pra perceber que o foco maior não é na performance em si, mas em reduzir a complexidade de projetos que antes precisavam de 5 libs diferentes para rodar um simples arquivo.
Tenho curiosidade também de ver comparações em outros contextos, como operações de IO, com os módulos nativos do Bun e também com algoritmos recursivos. Vou tirar um tempo no próximo fim de semana para fazer algo do tipo.
Depende de que forma mais rapido, o problema do node não é só em velocidade de execução, o tooling é uma merda, transpilar e configurar um projeto ts é chato. Com bun tu tem praticamente tudo isso out of the box. Sendo que o tooling é inclusive mais rapido que o pnpm. E a loucura de esmodule e common js, tudo isso é horrível no node.
Eu fiz alguns benchmarcks hoje nessa mesma pegada, mas ao invés de utilizar só Fibonacci eu fiz juntamente com outros algoritmos e também utilizando os respectivos recursivos nos casos onde se aplicam.
Na minha máquina que é Intel os resultados realmente foram muito discrepantes, o Bun deu de lavada no Node, o que me faz acreditar que nesses casos relatados tem algo a ver com os chips M1 mesmo.
Eu fiz um repositório com todas as implementações e o resultados dos testes. Se quiserem ver é esse aqui: https://github.com/Gabriel-Tapes/bun-vs-node.
Os testes foram até que bem simples, mas vou dar uma resumida aqui:
Eu utilizei o hyperfine para fazer os benchmarcks em uma configuração de 50 testes warmup antes de rodar o benchmarck real, que rodava os arquivos 1000 vezes.
Os meus resultados estão aqui: https://github.com/Gabriel-Tapes/bun-vs-node/blob/main/results.md
Executei uma série de benchmarks na minha máquina com as seguintes especificações:
- WSL 2 Ubuntu 22.04.2 LTS
- AMD Ryzen 7 5700x 4.6Ghz, 8 núcleos
- RAM 16gb 3200Mhz CL18
Utilizei uma suíte de testes fornecida pelo @kht, que incluiu os seguintes casos de teste:
var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;
suite
.add('desestruturação', function () {
const n = 1000;
let a = 0, b = 1;
for (let i = 0; i < n; i++) {
[a, b] = [b, a + b];
}
})
.add('sem desestruturação', function () {
const n = 1000;
let a = 0, b = 1;
for (let i = 0; i < n; i++) {
let tmp = a;
a = b;
b += tmp;
}
})
.add('desestruturação BigInt', function () {
const n = 1000000n;
let a = 0n, b = 1n;
for (let i = 0n; i < n; i++) {
[a, b] = [b, a + b];
}
})
.add('sem desestruturação BigInt', function () {
const n = 1000000n;
let a = 0n, b = 1n;
for (let i = 0n; i < n; i++) {
let tmp = a;
a = b;
b += tmp;
}
})
.on('complete', function () {
console.log('Fastest is ' + this.filter('fastest').map('name'));
})
.on('cycle', function (event) {
console.log(String(event.target));
})
.run({
'async': true
});
Os resultados obtidos foram os seguintes:
Teste | Node | Bun |
---|---|---|
desestruturação | 1.350.540 | 884.238 |
sem desestruturação | 1.392.095 | 1.662.632 |
desestruturação BigInt |
0.16 | 0.10 |
sem desestruturação BigInt |
0.16 | 0.10 |
Só queria agregar aqui tenho um notebook que está usando o POP_OS 22.04 e rodei o bun com o zsh. esse mesmo Fibonacci demorou em média 14s enquanto o node demorou 12s.
tenho um i7 de 8ª geração. Durante a execução estava com o Google Chrome, Egde, VS Code aberto.
Alo pessoal. A galera ja comentou sobre diversos fatores que podem influenciar aqui... so queria compartilhar os resultados de uma maquina mais "humilde" (que nao é humilde pq no fim eh um mac mas vcs entenderam...)
Configuracao
MacBook Air 2020 1,6 GHz Dual-Core Intel Core i5, no macOS Ventura 13.4 (22F66) Node --v 18.17.1 Bun -version 1.0.2
Teste
console.time("execution time")
function fib(n) {
let a = BigInt(0), b = BigInt(1);
for (let i = BigInt(0); i < n; i++) {
[a, b] = [b, a + b]
console.log("a:", a, "b:",b)
}
return a.toString()
}
for(let i = 100; i > 0; i--){
let result = fib(10000)
console.log(result)
}
console.timeEnd("execution time")
Resultado
Runtime | Tempo total(m:ss.mmm) | Média(ss.mmm) |
---|---|---|
Node | 7:06.114 | 04.261 |
Bun | 9:42.06 | 05.821 |
Ao que parece ser realmente é a forma como a compilação do bun em arm64 está funcionando, tem alguns issues acerca da arquitetura, erros que não ocorrem na compilação realizada para amd/intel. Pode ser que seja alguma implementação para arm64 que faz com que seja mais rápido.
Um dos principais core do projeto (Jarred-Sumner), fez a seguinte fala: If you run into performance issues with real code in your application feel free to file an issue
This looks more like a microbenchmark. JS engines are really good at optimizing microbenchmarks with statically known data. It probably isn't meaningful in applications.
logo:
Se você tiver problemas de desempenho com código real em seu aplicativo, sinta-se à vontade para registrar um problema
Isso se parece mais com um microbenchmark. Os mecanismos JS são realmente bons para otimizar microbenchmarks com dados estaticamente conhecidos. Provavelmente não é significativo em aplicativos.
https://github.com/oven-sh/bun/issues/3358
Sobre o código:
`var appendDelay = 1; var chartLen = 10; var delaysSoFar = 0; var arr = [0];
function update(x) { if (arr.length < chartLen) { if (delaysSoFar == appendDelay) { arr.push(x); delaysSoFar = 0; } else { arr[arr.length - 1] = x; } delaysSoFar += 1; }
if (arr.length == chartLen) { for (var i = 0; i < 10; i += 2) { arr[i / 2] = arr[i]; } arr.length = 5;
appendDelay *= 2; arr.push(x); } }
const now = performance.now(); for (var i = 0; i < 1000000000; i++) { update(i); } console.log(performance.now() - now);
console.log(arr);`
obs. ~~ Outro detalhe que eu acredito fazer parte do processo é que mesmo o webpack sendo apple, ela roda em outras máquinas que utilizam o webkit como o gnome epiphany (browser do gnome uma DE linux). Então não acredito que seja isso que possa fazer alguma diferença. ~~ E um ultimo detalhe é Lucas sempre vejo seus videos, bom trabalho.
Que balde de água fria, hein. Está o maior hype que o bun é mais rápido porque foi feito em Rust. Um monte de gente repetindo que bun é mais rápido, porém a realidade é que praticamente estão empatados. Supostamente pelo menos bun é mais seguro, mas não duvido alguém fazer testes reais e descobrir falhas. Nota: Estou repetindo feito papagaio que o Rust é seguro, por ouvir que ele é seguro, mas não manjo de segurança e claramente nunca testei a segurança de Rust.