Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(trie-router): optimize 2x faster #3732

Closed
wants to merge 4 commits into from

Conversation

EdamAme-x
Copy link
Contributor

@EdamAme-x EdamAme-x commented Dec 6, 2024

TrieRouter is now much faster!

This PR does not include any attempt to reduce the file size, so a patch must be applied after the merge.

Speedups were achieved by using strange optimization methods to create null objects in JavaScript.

(() => {
  const E = function () {};
  E.prototype = Object.create(null);
  return E;
})()

Benchmarks

In Node (1.1x faster)

  Hono RegExpRouter
   1.14x faster than Memoirist
   1.38x faster than koa-tree-router
   1.71x faster than @medley/router
   1.89x faster than trek-router
   2.46x faster than rou3
   2.52x faster than find-my-way
   3.78x faster than Hono PatternRouter
   4.61x faster than radix3
   7.89x faster than Hono TrieRouter
   8.11x faster than koa-router
   8.67x faster than Hono BeforeTrieRouter
   35.13x faster than express (WARNING: includes handling)

In Deno (2x faster)

  Hono RegExpRouter
   1.13x faster than Memoirist
   1.23x faster than koa-tree-router
   1.39x faster than @medley/router
   1.46x faster than rou3
   1.91x faster than trek-router
   1.98x faster than radix3
   2.4x faster than find-my-way
   4.14x faster than Hono PatternRouter
   4.67x faster than koa-router
   7.86x faster than Hono TrieRouter
   11.2x faster than express (WARNING: includes handling)
   13.12x faster than Hono BeforeTrieRouter

In Bun (1.5x faster)

  Memoirist
   1.14x faster than Hono RegExpRouter
   1.42x faster than @medley/router
   2.2x faster than radix3
   2.31x faster than rou3
   2.35x faster than koa-tree-router
   2.93x faster than find-my-way
   4.5x faster than Hono PatternRouter
   4.75x faster than trek-router
   5.61x faster than koa-router
   10.42x faster than Hono TrieRouter
   12.87x faster than express (WARNING: includes handling)
   15.35x faster than Hono BeforeTrieRouter

The author should do the following, if applicable

  • Add tests
  • Run tests
  • bun run format:fix && bun run lint:fix to format the code
  • Add TSDoc/JSDoc to document the code

Copy link

codecov bot commented Dec 6, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 91.71%. Comparing base (50ff212) to head (4df431b).
Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3732      +/-   ##
==========================================
- Coverage   91.72%   91.71%   -0.01%     
==========================================
  Files         159      159              
  Lines       10184    10181       -3     
  Branches     2990     2992       +2     
==========================================
- Hits         9341     9338       -3     
  Misses        842      842              
  Partials        1        1              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@EdamAme-x
Copy link
Contributor Author

hi @yusukebe, @usualoma
Can you review this?

@usualoma
Copy link
Member

usualoma commented Dec 7, 2024

Hi @EdamAme-x

That's great! I have also confirmed that the following optimisations are also effective. Well, but I also think it's a little tricky.

(() => {
  const E = function () {};
  E.prototype = Object.create(null);
  return E;
})()

Another simple way.

Although we haven't tracked down the specific cause, I think we can say that your excellent investigation has revealed that Object.create(null) in the following section is the bottleneck.

this.#params = Object.create(null)

Even with changes like the ones below, the performance will be the same as this PR, so I think this simple way is best if possible.

diff --git a/src/router/trie-router/node.ts b/src/router/trie-router/node.ts
index 94ab4de1..da415a3a 100644
--- a/src/router/trie-router/node.ts
+++ b/src/router/trie-router/node.ts
@@ -82,7 +82,7 @@ export class Node<T> {
     node: Node<T>,
     method: string,
     nodeParams: Record<string, string>,
-    params: Record<string, string>
+    params?: Record<string, string>
   ): HandlerParamsSet<T>[] {
     const handlerSets: HandlerParamsSet<T>[] = []
     for (let i = 0, len = node.#methods.length; i < len; i++) {
@@ -95,7 +95,7 @@ export class Node<T> {
           const key = handlerSet.possibleKeys[i]
           const processed = processedSet[handlerSet.score]
           handlerSet.params[key] =
-            params[key] && !processed ? params[key] : nodeParams[key] ?? params[key]
+            params?.[key] && !processed ? params[key] : nodeParams[key] ?? params?.[key]
           processedSet[handlerSet.score] = true
         }
 
@@ -107,7 +107,7 @@ export class Node<T> {
 
   search(method: string, path: string): [[T, Params][]] {
     const handlerSets: HandlerParamsSet<T>[] = []
-    this.#params = Object.create(null)
+    this.#params = {}
 
     // eslint-disable-next-line @typescript-eslint/no-this-alias
     const curNode: Node<T> = this
@@ -129,17 +129,10 @@ export class Node<T> {
             // '/hello/*' => match '/hello'
             if (nextNode.#children['*']) {
               handlerSets.push(
-                ...this.#getHandlerSets(
-                  nextNode.#children['*'],
-                  method,
-                  node.#params,
-                  Object.create(null)
-                )
+                ...this.#getHandlerSets(nextNode.#children['*'], method, node.#params)
               )
             }
-            handlerSets.push(
-              ...this.#getHandlerSets(nextNode, method, node.#params, Object.create(null))
-            )
+            handlerSets.push(...this.#getHandlerSets(nextNode, method, node.#params))
           } else {
             tempNodes.push(nextNode)
           }
@@ -155,9 +148,7 @@ export class Node<T> {
           if (pattern === '*') {
             const astNode = node.#children['*']
             if (astNode) {
-              handlerSets.push(
-                ...this.#getHandlerSets(astNode, method, node.#params, Object.create(null))
-              )
+              handlerSets.push(...this.#getHandlerSets(astNode, method, node.#params))
               tempNodes.push(astNode)
             }
             continue

@usualoma
Copy link
Member

usualoma commented Dec 7, 2024

Oops, sorry, {} doesn't work.

@EdamAme-x
Copy link
Contributor Author

I had considered same approach in the past, but was concerned about unintended side effects caused by properties defined in prototypes such as constructor.

This seems pretty magical, but it works well.

@usualoma
Copy link
Member

usualoma commented Dec 7, 2024

@EdamAme-x Thanks for the reply!

I researched afterward and found that the spread operator for Object.create(null) is very slow. (Probably slow in v8) And your new OptimizedEmptyParams() are very fast 🚀

import { run, bench, group } from 'mitata'

bench('noop', () => {})

const emptyParams: Readonly<Record<string, never>> = Object.create(null)
const OptimizedEmptyParams = (() => {
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  const E = function () {}
  E.prototype = emptyParams
  return E
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
})() as unknown as { new (): Record<string, any> }

group('spread operator', () => {
  bench('Object.create(null)', () => {
    const obj = Object.create(null)
    return { ...obj }
  })

  bench('new OptimizedEmptyParams()', () => {
    const obj = new OptimizedEmptyParams()
    return { ...obj }
  })

  bench('{}', () => {
    const obj = {}
    return { ...obj }
  })
})

run()
% npx tsx src/empty.ts
cpu: Apple M2 Pro
runtime: node v22.2.0 (arm64-darwin)

benchmark                       time (avg)             (min … max)       p75       p99      p999
------------------------------------------------------------------ -----------------------------
noop                            70 ps/iter        (20 ps … 148 ns)     81 ps    102 ps    326 ps !

• spread operator
------------------------------------------------------------------ -----------------------------
Object.create(null)          49.87 ns/iter     (45.78 ns … 171 ns)  49.74 ns  94.85 ns    129 ns
new OptimizedEmptyParams()     7.1 ns/iter      (6.06 ns … 115 ns)   6.63 ns  40.89 ns  59.63 ns
{}                           13.62 ns/iter     (11.35 ns … 628 ns)   12.9 ns  54.32 ns  98.59 ns

summary for spread operator
  new OptimizedEmptyParams()
   1.92x faster than {}
   7.03x faster than Object.create(null)
% deno -A src/empty.ts
cpu: Apple M2 Pro
runtime: deno 2.0.6 (aarch64-apple-darwin)

benchmark                       time (avg)             (min … max)       p75       p99      p999
------------------------------------------------------------------ -----------------------------
noop                           508 ps/iter     (407 ps … 88.16 ns)    488 ps    712 ps   4.07 ns

• spread operator
------------------------------------------------------------------ -----------------------------
Object.create(null)          52.55 ns/iter     (46.85 ns … 137 ns)  52.14 ns  86.93 ns    108 ns
new OptimizedEmptyParams()     6.4 ns/iter    (5.59 ns … 72.77 ns)    6.1 ns   24.8 ns  44.15 ns
{}                            6.62 ns/iter    (5.72 ns … 73.63 ns)   6.12 ns  34.61 ns  42.28 ns

summary for spread operator
  new OptimizedEmptyParams()
   1.03x faster than {}
   8.21x faster than Object.create(null)
% bun run src/empty.ts
cpu: Apple M2 Pro
runtime: bun 1.1.30 (arm64-darwin)

benchmark                       time (avg)             (min … max)       p75       p99      p999
------------------------------------------------------------------ -----------------------------
noop                            48 ps/iter       (0 ps … 31.78 ns)     61 ps     82 ps    142 ps !

• spread operator
------------------------------------------------------------------ -----------------------------
Object.create(null)          15.04 ns/iter     (13.12 ns … 292 ns)  13.81 ns  26.08 ns    174 ns
new OptimizedEmptyParams()    15.3 ns/iter     (13.37 ns … 218 ns)  14.18 ns  21.89 ns    173 ns
{}                           14.99 ns/iter     (13.16 ns … 204 ns)  13.83 ns  20.28 ns    171 ns

summary for spread operator
  {}
   1x faster than Object.create(null)
   1.02x faster than new OptimizedEmptyParams()

However, in the case of TrieRouter, I think it is better to optimize using standard coding methods rather than techniques based on special code.

The following branch avoids the spread operator for Object.create(null) and makes a few adjustments. It seems to be a little faster than perf-trie-2x on my machine, but I wonder how it would be like with this approach.

main...usualoma:hono:perf-trie-1208

• all together
----------------------------------------------------------- -----------------------------
Hono RegExpRouter       510 ns/iter       (487 ns … 686 ns)    521 ns    615 ns    686 ns
Hono perf-trie-1208   1'633 ns/iter   (1'568 ns … 1'830 ns)  1'647 ns  1'809 ns  1'830 ns
Hono perf-trie-2x     1'882 ns/iter   (1'822 ns … 2'249 ns)  1'889 ns  2'099 ns  2'249 ns

summary for all together
  Hono RegExpRouter
   3.2x faster than Hono perf-trie-1208
   3.69x faster than Hono perf-trie-2x

@EdamAme-x
Copy link
Contributor Author

EdamAme-x commented Dec 8, 2024

Thank you for your careful research.
If we don't have to use magic, that might be better.

@usualoma
Copy link
Member

usualoma commented Dec 8, 2024

@EdamAme-x Thank you!

I'd like to hear your opinion (I have no intention of confronting you). The perf-trie-1208 branch seems to be the best performing at the moment. For example, if we use a magical technique, will it perform better than perf-trie-1208?

I made the following changes to perf-trie-1208 and measured the performance, but it did not improve.

diff --git a/src/router/trie-router/node.ts b/src/router/trie-router/node.ts
index a580e0cb..f896ee47 100644
--- a/src/router/trie-router/node.ts
+++ b/src/router/trie-router/node.ts
@@ -14,6 +14,13 @@ type HandlerParamsSet<T> = HandlerSet<T> & {
 }
 
 const emptyParams = Object.create(null)
+const OptimizedEmptyParams = (() => {
+  // eslint-disable-next-line @typescript-eslint/no-empty-function
+  const E = function () {}
+  E.prototype = emptyParams
+  return E
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+})() as unknown as { new (): any }
 
 export class Node<T> {
   #methods: Record<string, HandlerSet<T>>[]
@@ -92,7 +99,7 @@ export class Node<T> {
       const handlerSet = (m[method] || m[METHOD_NAME_ALL]) as HandlerParamsSet<T>
       const processedSet: Record<number, boolean> = {}
       if (handlerSet !== undefined) {
-        handlerSet.params = Object.create(null)
+        handlerSet.params = new OptimizedEmptyParams()
         handlerSets.push(handlerSet)
         if (nodeParams) {
           for (let i = 0, len = handlerSet.possibleKeys.length; i < len; i++) {

@EdamAme-x
Copy link
Contributor Author

To me, the perf-trie-1208 looks cooler.

usualoma added a commit to usualoma/hono that referenced this pull request Dec 8, 2024
…ull)`

This optimization is based on the discussion in the following Pull Request.
Thanks to @EdamAme-x!
honojs#3732

Co-authored-by: EdamAmex <[email protected]>
@usualoma
Copy link
Member

usualoma commented Dec 8, 2024

Hi @EdamAme-x Thank you!

Then, I would like to proceed with the following pull request by adding you as a co-author.
#3735

What do you think, @yusukebe?

@yusukebe
Copy link
Member

yusukebe commented Dec 8, 2024

I'll check it tomorrow!

yusukebe pushed a commit that referenced this pull request Dec 9, 2024
…ull)` (#3735)

* perf(trie-router): avoid calling spread operator for `Object.create(null)`

This optimization is based on the discussion in the following Pull Request.
Thanks to @EdamAme-x!
#3732

Co-authored-by: EdamAmex <[email protected]>

* perf(trie-router): make some #getHandlerSets parameters optional

* fix(trie-router): fix #getHandlerSets bug

---------

Co-authored-by: EdamAmex <[email protected]>
@EdamAme-x EdamAme-x closed this Dec 9, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants