Skip to content

Conversation

H4ad
Copy link
Contributor

@H4ad H4ad commented Oct 4, 2025

A simple change to avoid Regex when we already know both values are number.

-compare 0.0.0 to 0.0.0-foo x 2,281,198 ops/sec ±0.36% (98 runs sampled)
+compare 0.0.0 to 0.0.0-foo x 3,020,851 ops/sec ±0.38% (99 runs sampled)
-compare 0.0.1 to 0.0.0 x 3,874,114 ops/sec ±0.53% (94 runs sampled)
+compare 0.0.1 to 0.0.0 x 5,925,824 ops/sec ±0.28% (99 runs sampled)
-compare 1.0.0 to 0.9.9 x 4,764,980 ops/sec ±0.62% (94 runs sampled)
+compare 1.0.0 to 0.9.9 x 5,837,427 ops/sec ±0.38% (97 runs sampled)
-compare 0.10.0 to 0.9.0 x 3,799,602 ops/sec ±0.49% (94 runs sampled)
+compare 0.10.0 to 0.9.0 x 5,390,337 ops/sec ±0.40% (98 runs sampled)
-compare 0.99.0 to 0.10.0 x 3,457,321 ops/sec ±0.54% (94 runs sampled)
+compare 0.99.0 to 0.10.0 x 4,729,216 ops/sec ±0.25% (101 runs sampled)
-compare 2.0.0 to 1.2.3 x 4,784,659 ops/sec ±0.68% (92 runs sampled)
+compare 2.0.0 to 1.2.3 x 5,773,274 ops/sec ±0.35% (94 runs sampled)
-compare v0.0.0 to 0.0.0-foo x 2,266,819 ops/sec ±0.76% (93 runs sampled)
+compare v0.0.0 to 0.0.0-foo x 3,074,139 ops/sec ±0.43% (96 runs sampled)
-compare v0.0.1 to 0.0.0 x 3,798,026 ops/sec ±0.75% (93 runs sampled)
+compare v0.0.1 to 0.0.0 x 5,824,666 ops/sec ±0.38% (97 runs sampled)
-compare v1.0.0 to 0.9.9 x 4,704,188 ops/sec ±0.97% (91 runs sampled)
+compare v1.0.0 to 0.9.9 x 5,727,448 ops/sec ±0.43% (98 runs sampled)
-compare v0.10.0 to 0.9.0 x 3,785,330 ops/sec ±0.57% (96 runs sampled)
+compare v0.10.0 to 0.9.0 x 5,385,605 ops/sec ±0.22% (101 runs sampled)
-compare v0.99.0 to 0.10.0 x 3,444,526 ops/sec ±0.57% (92 runs sampled)
+compare v0.99.0 to 0.10.0 x 4,847,083 ops/sec ±0.35% (97 runs sampled)
-compare v2.0.0 to 1.2.3 x 4,795,300 ops/sec ±0.57% (94 runs sampled)
+compare v2.0.0 to 1.2.3 x 5,754,048 ops/sec ±0.32% (96 runs sampled)
-compare 0.0.0 to v0.0.0-foo x 2,299,780 ops/sec ±0.44% (94 runs sampled)
+compare 0.0.0 to v0.0.0-foo x 2,954,600 ops/sec ±0.36% (97 runs sampled)
-compare 0.0.1 to v0.0.0 x 3,980,408 ops/sec ±0.48% (97 runs sampled)
+compare 0.0.1 to v0.0.0 x 5,934,794 ops/sec ±0.34% (94 runs sampled)
-compare 1.0.0 to v0.9.9 x 4,840,697 ops/sec ±0.65% (97 runs sampled)
+compare 1.0.0 to v0.9.9 x 5,927,552 ops/sec ±0.63% (91 runs sampled)
-compare 0.10.0 to v0.9.0 x 3,765,578 ops/sec ±0.73% (99 runs sampled)
+compare 0.10.0 to v0.9.0 x 5,208,391 ops/sec ±0.27% (93 runs sampled)
-compare 0.99.0 to v0.10.0 x 3,464,883 ops/sec ±0.55% (96 runs sampled)
+compare 0.99.0 to v0.10.0 x 4,751,421 ops/sec ±0.25% (100 runs sampled)
-compare 2.0.0 to v1.2.3 x 4,884,354 ops/sec ±0.66% (94 runs sampled)
+compare 2.0.0 to v1.2.3 x 5,888,450 ops/sec ±0.38% (91 runs sampled)
-compare 1.2.3 to 1.2.3-asdf x 2,225,975 ops/sec ±0.65% (95 runs sampled)
+compare 1.2.3 to 1.2.3-asdf x 3,042,336 ops/sec ±0.42% (97 runs sampled)
-compare 1.2.3 to 1.2.3-4 x 2,570,852 ops/sec ±0.38% (96 runs sampled)
+compare 1.2.3 to 1.2.3-4 x 3,520,964 ops/sec ±0.29% (95 runs sampled)
-compare 1.2.3 to 1.2.3-4-foo x 2,117,202 ops/sec ±0.36% (97 runs sampled)
+compare 1.2.3 to 1.2.3-4-foo x 2,846,106 ops/sec ±0.49% (95 runs sampled)
-compare 1.2.3-5-foo to 1.2.3-5 x 1,541,290 ops/sec ±0.79% (99 runs sampled)
+compare 1.2.3-5-foo to 1.2.3-5 x 1,803,126 ops/sec ±0.35% (93 runs sampled)
-compare 1.2.3-5 to 1.2.3-4 x 1,652,015 ops/sec ±0.39% (97 runs sampled)
+compare 1.2.3-5 to 1.2.3-4 x 2,351,313 ops/sec ±0.49% (93 runs sampled)
-compare 1.2.3-5-foo to 1.2.3-5-Foo x 1,375,682 ops/sec ±0.28% (99 runs sampled)
+compare 1.2.3-5-foo to 1.2.3-5-Foo x 1,773,293 ops/sec ±0.26% (97 runs sampled)
-compare 3.0.0 to 2.7.2+asdf x 3,175,150 ops/sec ±0.35% (100 runs sampled)
+compare 3.0.0 to 2.7.2+asdf x 4,535,879 ops/sec ±0.40% (95 runs sampled)
-compare 1.2.3-a.10 to 1.2.3-a.5 x 1,114,865 ops/sec ±0.15% (97 runs sampled)
+compare 1.2.3-a.10 to 1.2.3-a.5 x 1,368,780 ops/sec ±0.19% (99 runs sampled)
-compare 1.2.3-a.b to 1.2.3-a.5 x 1,151,804 ops/sec ±0.34% (98 runs sampled)
+compare 1.2.3-a.b to 1.2.3-a.5 x 1,339,954 ops/sec ±0.32% (97 runs sampled)
-compare 1.2.3-a.b to 1.2.3-a x 1,456,826 ops/sec ±0.32% (93 runs sampled)
+compare 1.2.3-a.b to 1.2.3-a x 1,826,409 ops/sec ±0.74% (97 runs sampled)
-compare 1.2.3-a.b.c.10.d.5 to 1.2.3-a.b.c.5.d.100 x 774,897 ops/sec ±0.79% (101 runs sampled)
+compare 1.2.3-a.b.c.10.d.5 to 1.2.3-a.b.c.5.d.100 x 870,045 ops/sec ±0.34% (95 runs sampled)
-compare 1.2.3-r2 to 1.2.3-r100 x 1,505,620 ops/sec ±0.15% (100 runs sampled)
+compare 1.2.3-r2 to 1.2.3-r100 x 1,788,234 ops/sec ±0.53% (94 runs sampled)
-compare 1.2.3-r100 to 1.2.3-R2 x 1,494,922 ops/sec ±0.15% (98 runs sampled)
+compare 1.2.3-r100 to 1.2.3-R2 x 1,769,180 ops/sec ±0.60% (93 runs sampled)

@H4ad H4ad requested a review from a team as a code owner October 4, 2025 21:34
@H4ad H4ad force-pushed the perf/compare-main branch from 3a33b93 to 024ffda Compare October 4, 2025 22:03
@wraithgar
Copy link
Member

I think this is worth digging into just a little bit.

Under what situations is the identifier parseable as an integer but not cast as one? This internal function is only ever used in the semver class itself.

  • Semver always casts this.major, this.minor, and this.patch to numbers.
  • It also casts any numbers in the this.prerelease array to numbers.
  • It also iterates through each entry in this.prerelease individually when passing to compareIdentifiers

I think what we have here is a classic pitfall of unit testing: by testing the internals file directly we have disregarded the ways in which it can actually be called. Unless you start manually setting entries in the this.prerelease array, and setting them as strings representing a number, we don't actually ever need to cast these to numbers.

Unfortunately because of the coverage map.js file we can't do away with this test files itself. What we can do is write tests in that file that don't require the internals, but get to the code from the actual Semver object.

TLDR: let's stop casting these things to numbers at all. We can remove

const numeric = /^[0-9]+$/

and

  const anum = numeric.test(a)
  const bnum = numeric.test(b)

  if (anum && bnum) {
    a = +a
    b = +b
  }

What do you think?

@wraithgar
Copy link
Member

Oh, I see it now. This is optimized for the absolute least common path possible. If you are comparing prerelease identifiers, and one is a number and the other is not, then we prefer the number.

We do not need to be using this function at all on anything except the prerelease array. And then I think this function can be left as-is.

@wraithgar
Copy link
Member

wraithgar commented Oct 4, 2025

bench-compare.js needs to have pre-release identifiers added to its suites

@wraithgar
Copy link
Member

Yeah compareIdentifier only makes sense when parsing prerelease components. For major/minor/patch it's just standard number comparison. We can do that inline.

Check it out: 03315c7

@wraithgar
Copy link
Member

Another thing: prerelease portions are already cast as numbers if they're numbers.

if (/^[0-9]+$/.test(id)) {
const num = +id
if (num >= 0 && num < MAX_SAFE_INTEGER) {
return num
}
}

The only reason we even need the re-cast back to a number is because build does not do this:

this.build = m[5] ? m[5].split('.') : []

Some consistency between these two approaches would mean a much more efficient anum and bnum. They could be simple typeof checks instead, no regex needed.

@H4ad H4ad force-pushed the perf/compare-main branch from 024ffda to 114b57d Compare October 5, 2025 15:24
@H4ad
Copy link
Contributor Author

H4ad commented Oct 5, 2025

About your last comment, doing that change will not be a breaking change? If so, I would like to keep that in a different PR

About this change, if we add a regex to test for build aswell, I thought it would affect the performance but I tried to convert the regexp to just +num and I didn't get any significant perf improvement:

-parse(1.0.0) x 12,395,608 ops/sec ±0.30% (98 runs sampled)
+parse(1.0.0) x 12,014,007 ops/sec ±0.43% (94 runs sampled)
-parse(2.1.0) x 12,556,489 ops/sec ±0.57% (100 runs sampled)
+parse(2.1.0) x 12,337,414 ops/sec ±0.25% (98 runs sampled)
-parse(3.2.1) x 12,518,209 ops/sec ±0.33% (98 runs sampled)
+parse(3.2.1) x 11,770,690 ops/sec ±0.42% (98 runs sampled)
-parse(v1.2.3) x 12,264,666 ops/sec ±0.38% (96 runs sampled)
+parse(v1.2.3) x 11,690,669 ops/sec ±0.44% (97 runs sampled)
-parse(1.2.3-0) x 5,224,079 ops/sec ±0.42% (97 runs sampled)
+parse(1.2.3-0) x 5,635,402 ops/sec ±1.22% (93 runs sampled)
-parse(1.2.3-123) x 3,259,160 ops/sec ±0.82% (97 runs sampled)
+parse(1.2.3-123) x 3,677,786 ops/sec ±0.34% (96 runs sampled)
-parse(1.2.3-1.2.3) x 2,523,342 ops/sec ±0.40% (100 runs sampled)
+parse(1.2.3-1.2.3) x 3,021,260 ops/sec ±0.47% (100 runs sampled)
-parse(1.2.3-1a) x 4,114,595 ops/sec ±0.49% (91 runs sampled)
+parse(1.2.3-1a) x 3,956,804 ops/sec ±0.27% (98 runs sampled)
-parse(1.2.3-a1) x 4,147,904 ops/sec ±1.07% (98 runs sampled)
+parse(1.2.3-a1) x 4,173,043 ops/sec ±0.48% (96 runs sampled)
-parse(1.2.3-alpha) x 3,875,760 ops/sec ±1.09% (94 runs sampled)
+parse(1.2.3-alpha) x 3,923,173 ops/sec ±0.37% (96 runs sampled)
-parse(1.2.3-alpha.1) x 2,668,685 ops/sec ±0.84% (92 runs sampled)
+parse(1.2.3-alpha.1) x 2,754,900 ops/sec ±0.25% (98 runs sampled)
-parse(1.2.3-alpha-1) x 3,846,811 ops/sec ±1.10% (96 runs sampled)
+parse(1.2.3-alpha-1) x 3,899,305 ops/sec ±0.45% (96 runs sampled)
-parse(1.2.3-alpha-.-beta) x 2,788,394 ops/sec ±1.34% (97 runs sampled)
+parse(1.2.3-alpha-.-beta) x 2,579,693 ops/sec ±0.59% (96 runs sampled)
-parse(1.2.3+456) x 6,923,477 ops/sec ±0.61% (94 runs sampled)
+parse(1.2.3+456) x 7,217,113 ops/sec ±0.75% (97 runs sampled)
-parse(1.2.3+build) x 6,379,351 ops/sec ±0.89% (91 runs sampled)
+parse(1.2.3+build) x 6,937,235 ops/sec ±1.29% (95 runs sampled)
-parse(1.2.3+new-build) x 6,220,420 ops/sec ±1.42% (95 runs sampled)
+parse(1.2.3+new-build) x 6,836,206 ops/sec ±0.77% (95 runs sampled)
-parse(1.2.3+build.1) x 4,624,219 ops/sec ±0.46% (97 runs sampled)
+parse(1.2.3+build.1) x 5,122,833 ops/sec ±0.62% (94 runs sampled)
-parse(1.2.3+build.1a) x 4,366,437 ops/sec ±0.52% (95 runs sampled)
+parse(1.2.3+build.1a) x 4,391,526 ops/sec ±2.63% (91 runs sampled)
-parse(1.2.3+build.a1) x 4,316,662 ops/sec ±0.66% (94 runs sampled)
+parse(1.2.3+build.a1) x 4,131,423 ops/sec ±3.49% (89 runs sampled)
-parse(1.2.3+build.alpha) x 4,204,876 ops/sec ±0.96% (97 runs sampled)
+parse(1.2.3+build.alpha) x 4,486,159 ops/sec ±0.82% (98 runs sampled)
-parse(1.2.3+build.alpha.beta) x 3,773,754 ops/sec ±0.50% (89 runs sampled)
+parse(1.2.3+build.alpha.beta) x 4,049,244 ops/sec ±1.07% (92 runs sampled)
-parse(1.2.3-alpha+build) x 3,168,472 ops/sec ±0.36% (95 runs sampled)
+parse(1.2.3-alpha+build) x 3,193,028 ops/sec ±1.02% (94 runs sampled)

Change:

diff --git a/classes/semver.js b/classes/semver.js
index 92254be..9d18f21 100644
--- a/classes/semver.js
+++ b/classes/semver.js
@@ -64,10 +64,10 @@ class SemVer {
       this.prerelease = []
     } else {
       this.prerelease = m[4].split('.').map((id) => {
-        if (/^[0-9]+$/.test(id)) {
-          const num = +id
-          if (num >= 0 && num < MAX_SAFE_INTEGER) {
-            return num
+        if (id !== '') {
+          const idNum = +id
+          if (idNum >= 0 && idNum < MAX_SAFE_INTEGER) {
+            return idNum
           }
         }
         return id

@wraithgar
Copy link
Member

About your last comment, doing that change will not be a breaking change? If so, I would like to keep that in a different PR

Yeah I don't know what it'd take to make them consistent, it's potentially breaking. We should probably put it in the breaking change backlog

@wraithgar wraithgar changed the title fix: fast path for compareIdentifiers when num fix: faster paths for compare Oct 6, 2025
Copy link
Member

@wraithgar wraithgar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving this as-is as a good iteration.

@wraithgar wraithgar merged commit e37e0ca into npm:main Oct 6, 2025
32 checks passed
@github-actions github-actions bot mentioned this pull request Sep 29, 2025
@wraithgar
Copy link
Member

Change:

diff --git a/classes/semver.js b/classes/semver.js
index 92254be..9d18f21 100644
--- a/classes/semver.js
+++ b/classes/semver.js
@@ -64,10 +64,10 @@ class SemVer {
       this.prerelease = []
     } else {
       this.prerelease = m[4].split('.').map((id) => {
-        if (/^[0-9]+$/.test(id)) {
-          const num = +id
-          if (num >= 0 && num < MAX_SAFE_INTEGER) {
-            return num
+        if (id !== '') {
+          const idNum = +id
+          if (idNum >= 0 && idNum < MAX_SAFE_INTEGER) {
+            return idNum
           }
         }
         return id

Fun challenge: try to get id to be '' in this map.

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.

2 participants