lukeed / clsx

A tiny (239B) utility for constructing `className` strings conditionally.
MIT License
8.08k stars 141 forks source link

Optimize clsx implementation for better performance #73

Closed JonasWijne closed 1 year ago

JonasWijne commented 1 year ago

This PR optimizes the clsx implementation, resulting in more legible and faster code. the benchmarks show improved performance compared to the previous version.

Changes:

Updated toVal function to handle different input types more efficiently. Refactored the clsx function to use a more concise approach. Benchmark results:

# Strings
  clsx (prev)  x 12,954,191 ops/sec ±0.32% (95 runs sampled)
  clsx         x 14,163,905 ops/sec ±1.08% (101 runs sampled)

# Objects
  clsx (prev)  x 8,729,410 ops/sec ±0.15% (98 runs sampled)
  clsx         x 10,610,018 ops/sec ±0.15% (100 runs sampled)

# Arrays
  clsx (prev)  x 10,043,091 ops/sec ±1.28% (99 runs sampled)
  clsx         x 10,956,764 ops/sec ±0.20% (95 runs sampled)

# Nested Arrays
  clsx (prev)  x 8,232,897 ops/sec ±0.70% (93 runs sampled)
  clsx         x 9,248,651 ops/sec ±0.12% (93 runs sampled)

# Nested Arrays w/ Objects
  clsx (prev)  x 7,975,890 ops/sec ±0.10% (98 runs sampled)
  clsx         x 9,101,825 ops/sec ±0.12% (98 runs sampled)

# Mixed
  clsx (prev)  x 8,521,371 ops/sec ±1.77% (98 runs sampled)
  clsx         x 9,270,741 ops/sec ±0.72% (99 runs sampled)

# Mixed (Bad Data)
  clsx (prev)  x 3,406,695 ops/sec ±0.22% (98 runs sampled)
  clsx         x 3,822,356 ops/sec ±0.22% (99 runs sampled)

The updated implementation outperforms the previous version in all test cases.

The resulting build files are also smaller

❯ pnpm run build #before
> clsx@1.2.1 build /Users/jonas/Research/clsx
> node bin

~> "dist/clsx.m.js" (234 b)
~> "dist/clsx.js" (238 b)
~> "dist/clsx.min.js" (297 b)
❯ pnpm run build #after

> clsx@1.2.1 build /Users/jonas/Research/clsx
> node bin

~> "dist/clsx.m.js" (225 b)
~> "dist/clsx.js" (228 b)
~> "dist/clsx.min.js" (289 b)
coeneivan commented 1 year ago

👍

lukeed commented 1 year ago

This is actually slower in all cases except Nested Arrays because the Array.isArray check is moved up. This appears tricky – sorry – but its because the latter (mostly repeat) candidate gets to benefit from JIT optimizations.

I added some more to illustrate:

 ⌁ ~/repos/oss/clsx  master±  node -v
v18.12.1
 ⌁ ~/repos/oss/clsx  master±  node bench

# Strings
  clsx (prev)  x 12,347,638 ops/sec ±0.23% (93 runs sampled)
  clsx (#73)   x 13,488,185 ops/sec ±0.08% (101 runs sampled)
  clsx         x 14,283,213 ops/sec ±0.11% (94 runs sampled)

# Objects
  clsx (prev)  x 8,286,705 ops/sec ±0.62% (99 runs sampled)
  clsx (#73)   x 10,108,131 ops/sec ±0.50% (94 runs sampled)
  clsx         x 10,426,194 ops/sec ±0.43% (94 runs sampled)

# Arrays
  clsx (prev)  x 9,758,624 ops/sec ±0.26% (101 runs sampled)
  clsx (#73)   x 10,445,507 ops/sec ±0.12% (98 runs sampled)
  clsx         x 10,494,471 ops/sec ±0.12% (100 runs sampled)

# Nested Arrays
  clsx (prev)  x 7,944,617 ops/sec ±0.39% (100 runs sampled)
  clsx (#73)   x 8,832,563 ops/sec ±0.12% (101 runs sampled)
  clsx         x 8,112,300 ops/sec ±0.72% (95 runs sampled)

# Nested Arrays w/ Objects
  clsx (prev)  x 7,211,581 ops/sec ±1.47% (96 runs sampled)
  clsx (#73)   x 8,298,702 ops/sec ±1.24% (95 runs sampled)
  clsx         x 8,320,432 ops/sec ±0.49% (94 runs sampled)

# Mixed
  clsx (prev)  x 8,105,500 ops/sec ±0.18% (99 runs sampled)
  clsx (#73)   x 8,611,596 ops/sec ±0.58% (96 runs sampled)
  clsx         x 9,158,882 ops/sec ±0.52% (99 runs sampled)

# Mixed (Bad Data)
  clsx (prev)  x 3,264,060 ops/sec ±0.46% (99 runs sampled)
  clsx (#73)   x 3,553,928 ops/sec ±0.41% (99 runs sampled)
  clsx         x 3,703,517 ops/sec ±0.49% (99 runs sampled)

As mentioned, clsx (#73) is only faster in Nested Arrays.

The ES6+ bits used here are mostly just sugar for the developer, but bring runtime costs. We can see this in action by swapping the benchmark order:

# Strings
  clsx (prev)  x 12,365,782 ops/sec ±0.13% (98 runs sampled)
  clsx         x 14,292,334 ops/sec ±0.06% (101 runs sampled)
  clsx (#73)   x 13,477,591 ops/sec ±0.10% (99 runs sampled)

# Objects
  clsx (prev)  x 8,560,716 ops/sec ±0.11% (99 runs sampled)
  clsx         x 10,526,618 ops/sec ±0.16% (95 runs sampled)
  clsx (#73)   x 10,306,068 ops/sec ±0.13% (98 runs sampled)

# Arrays
  clsx (prev)  x 9,617,684 ops/sec ±0.16% (98 runs sampled)
  clsx         x 10,529,453 ops/sec ±0.12% (102 runs sampled)
  clsx (#73)   x 9,962,834 ops/sec ±0.49% (98 runs sampled)

# Nested Arrays
  clsx (prev)  x 7,691,389 ops/sec ±0.16% (99 runs sampled)
  clsx         x 8,494,205 ops/sec ±0.13% (100 runs sampled)
  clsx (#73)   x 8,650,357 ops/sec ±0.10% (99 runs sampled)

# Nested Arrays w/ Objects
  clsx (prev)  x 7,597,622 ops/sec ±0.36% (96 runs sampled)
  clsx         x 8,444,634 ops/sec ±0.19% (98 runs sampled)
  clsx (#73)   x 8,308,614 ops/sec ±0.16% (102 runs sampled)

# Mixed
  clsx (prev)  x 8,034,305 ops/sec ±0.24% (99 runs sampled)
  clsx         x 9,458,763 ops/sec ±0.12% (99 runs sampled)
  clsx (#73)   x 8,789,075 ops/sec ±0.79% (94 runs sampled)

# Mixed (Bad Data)
  clsx (prev)  x 3,230,945 ops/sec ±0.71% (98 runs sampled)
  clsx         x 3,759,799 ops/sec ±0.20% (100 runs sampled)
  clsx (#73)   x 3,640,722 ops/sec ±0.13% (95 runs sampled)

Even though it's running last (and should have everything JIT-wise going in its favor), it's still slightly slower than clsx directly. That's only not true with the Nested Arrays test since that check is still sooner.


If you want to open another PR that just moves the Array.isArray check to its own else if branch, I think that's the best nugget from here.

Thanks!

JonasWijne commented 1 year ago

👍