🏎️

HonoのNode.jsアダプタが2.7倍速くなりました

2023/11/28に公開

昨日、HonoのNode.jsアダプタのv1.3.0をリリースしました。これまでのものより2.7倍速くなるパフォーマンス向上が含まれています。

https://github.com/honojs/node-server/releases

SS

このリリースは「2.7倍」という数値以上にHonoにとって大きなものになりますので、それについて解説します。

Request/Responseの翻訳

HonoはWebスタンダードAPIのみを利用したWebフレームワークで、WebスタンダードAPIのみで構成されたものがWebアプリになるうるランタイムのみを対象にしていました。具体的には、Cloudflare Workers、Deno、Bun、Fastly Computeなどです。

Honoを使わない例を出すと、以下のコードはWebスタンダードのみを利用してる非常に素朴なものですが、これだけでCloudflareでもBunでも立派にWebアプリになります。

app.js
export default {
  fetch: (req) => {
    return new Response(`You access ${new URL(req.url).pathname}`)
  }
}

Cloudflare Workersの場合は

wrangler dev app.js

Bunの場合は

bun run app.js

で立ち上がります。

一方、Node.jsでは動くかな?と以下を実行してもエラーにはならないものの何も動きません。

node app.js

いくらエントリポイントを工夫しても立ち上がりません。それもそのはずです。Node.jsにはWebスタンダードAPIが実装されていても、それを立ち上げるサーバーのAPIがないのです。

そこでHonoではNode.jsで動かすためにNode.jsのIncommingMessage/OutgoingMessageとWebスタンダードのRequest/Responseを「翻訳」するようなアダプタを用意してNode.jsに対応させていました。

import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const app = new Hono()
app.get('/', (c) => c.json({ hello: 'world' }))

serve(app)

しかし、これが遅い。当然です。毎回それぞれの変換が走ります。以下は省略したコード例で、ほぼ同じロジックが動いていました。

export const getRequestListener = (fetchCallback: FetchCallback) => {
  return async (incoming: IncomingMessage, outgoing: ServerResponse) => {
    const method = incoming.method || 'GET'
    const url = `http://${incoming.headers.host}${incoming.url}`

    // ...

    const init = {
      method: method,
      headers: headerRecord
    } as RequestInit

    const res = (await fetchCallback(new Request(url, init))) as Response
    const buffer = await res.arrayBuffer()
    outgoing.writeHead(res.status, resHeaderRecord)
    outgoing.end(new Uint8Array(buffer))
  }
}

どうでしょう。当然オーバーヘッドあります。

戦えない

すると戦えないです。少しチューニングするとExpressよりは速いくらいになりますが、Expressは超遅いです。なので、その程度です。以下は高速フレームワークFastifyが出してるベンチマークプロジェクトからのスクショです。

SS

Fastifyとは4倍近く差がついています。これでは戦えない。なので、こうなります。

HonoはNode.jsでも動きますが、Node.js用には作られていませんので遅いです。

いくらHonoが魅力的でも、Node.jsの場合は「遅いから」という理由で例えばFastifyやPolkaはおろかやh3やhapi、Koaともそもそも比べられないかもしれないのです。もしHonoを選ぶ理由があるとしたら、

  • 他のランタイムでも動くから
  • Expressより速いならまあいいんじゃん?
  • 応援してるから

なんてのがあって、「無理して使ってもらっている」気がしていました。

PR #95

そこへ @usualomaさんが「perf: Reduce object generation and optimize performance. #95」という神PRを出してきました。これを見たときは衝撃で、これまで理論的に無理だと思っていた壁をゆうに超えて、2倍、3倍速くなるというものだったからです。僕以外の人たちも驚いていました。

ただ、よくみると実は非常に素朴な仕組みになっています。チューニングの鉄則である「やらなくていいことは極力やらない」をしているだけです。軽量版のRequest/Responseを作って、それを最初使います。その場合は速いです。で、もしRequestの中身が欲しいとき、例えばreq.json() とかするとき、に初めてnew Request()が裏で走り、そのインスタンスが使われ続けるというものです。これだと速いし、また、挙動もおかしなところが出ないはずです。

この場合特にnewのコスト以外にResponseボディをawait res.arrayBuffer()しなくてもとってこれるケースが多く、その場合のパフォーマンス向上がかなりあります。

言われてみればシンプルです。とはいえそれをさっと出してきて、実装する@usualomaさんはすごいです。

結果

@usualomaさん以外にも以前からこのNode.jsアダプタをみてもらってる@tangye1234さんという方も加わってPRが完成し、マージ、リリースとなりました。

で、結果です。上記のFastifyのベンチマークを使います。手元で回してみました。じゃじゃーん。

SS

おお、上位に行ってます。Fastifyには負けていますが、数値的にはこれは十分戦える!

*ちなみに上位がスコア的に固まってるのはこのベンチマークのいけてないところだと思っていて、autocannonよりもっと速いHTTPクライアントを使った方がいいと思いました。

同じ土俵

僕らは散々ベンチマークを取ってきて、ベンチマークはベンチマークでしかないこと十分承知なのですが、このベンチマークは非常に価値があります。それはつまり、Fastifyや他のNode.js専門のフレームワークと「同じ土俵に立つ」ということです。これまで遠慮がちに「Node.jsでも動くよ」と言ってきたものが自信を持って言えます。

@usualomaさんが言っていましたが、あとはDXで勝負できるのです。

まとめ

以上。HonoのNode.jsアダプタが2.7倍速くなった話をしました。「自信を持てる」というのはとてもすがすがしいもので、いい気分です。ISUCON13のNode.js実装がHonoで書かれていたらしいのですが、この新しいアダプタを使っていたらスコアが上がっていたかもしれませんね!

Discussion