Skip to content

Commit e08d916

Browse files
committedDec 1, 2020
Memoize range parsing
This yields about a 10-20% speed improvement to a lot of common npm operations that spend most of their CPU time parsing a relatively small set of dependency ranges. It does mean that one `range.set` may contain the same Comparator objects as another range with the same sub-range. That was technically already possible, due to the `Comparator.ANY` magic range, but it did require a bit of tweaking in the `subset` function, which is also now a bit faster when calculating subsets for ranges that are partly or entirely identical.
1 parent 6088070 commit e08d916

File tree

8 files changed

+118
-31
lines changed

8 files changed

+118
-31
lines changed
 

‎classes/comparator.js

+2-6
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@ class Comparator {
55
return ANY
66
}
77
constructor (comp, options) {
8-
if (!options || typeof options !== 'object') {
9-
options = {
10-
loose: !!options,
11-
includePrerelease: false
12-
}
13-
}
8+
options = parseOptions(options)
149

1510
if (comp instanceof Comparator) {
1611
if (comp.loose === !!options.loose) {
@@ -132,6 +127,7 @@ class Comparator {
132127

133128
module.exports = Comparator
134129

130+
const parseOptions = require('../internal/parse-options')
135131
const {re, t} = require('../internal/re')
136132
const cmp = require('../functions/cmp')
137133
const debug = require('../internal/debug')

‎classes/range.js

+18-8
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
// hoisted class for cyclic dependency
22
class Range {
33
constructor (range, options) {
4-
if (!options || typeof options !== 'object') {
5-
options = {
6-
loose: !!options,
7-
includePrerelease: false
8-
}
9-
}
4+
options = parseOptions(options)
105

116
if (range instanceof Range) {
127
if (
@@ -82,8 +77,17 @@ class Range {
8277
}
8378

8479
parseRange (range) {
85-
const loose = this.options.loose
8680
range = range.trim()
81+
82+
// memoize range parsing for performance.
83+
// this is a very hot path, and fully deterministic.
84+
const memoOpts = Object.keys(this.options).join(',')
85+
const memoKey = `parseRange:${memoOpts}:${range}`
86+
const cached = cache.get(memoKey)
87+
if (cached)
88+
return cached
89+
90+
const loose = this.options.loose
8791
// `1.2.3 - 1.2.4` => `>=1.2.3 <=1.2.4`
8892
const hr = loose ? re[t.HYPHENRANGELOOSE] : re[t.HYPHENRANGE]
8993
range = range.replace(hr, hyphenReplace(this.options.includePrerelease))
@@ -129,7 +133,9 @@ class Range {
129133
if (rangeMap.size > 1 && rangeMap.has(''))
130134
rangeMap.delete('')
131135

132-
return [...rangeMap.values()]
136+
const result = [...rangeMap.values()]
137+
cache.set(memoKey, result)
138+
return result
133139
}
134140

135141
intersects (range, options) {
@@ -178,6 +184,10 @@ class Range {
178184
}
179185
module.exports = Range
180186

187+
const LRU = require('lru-cache')
188+
const cache = new LRU({ max: 1000 })
189+
190+
const parseOptions = require('../internal/parse-options')
181191
const Comparator = require('./comparator')
182192
const debug = require('../internal/debug')
183193
const SemVer = require('./semver')

‎classes/semver.js

+3-6
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@ const debug = require('../internal/debug')
22
const { MAX_LENGTH, MAX_SAFE_INTEGER } = require('../internal/constants')
33
const { re, t } = require('../internal/re')
44

5+
const parseOptions = require('../internal/parse-options')
56
const { compareIdentifiers } = require('../internal/identifiers')
67
class SemVer {
78
constructor (version, options) {
8-
if (!options || typeof options !== 'object') {
9-
options = {
10-
loose: !!options,
11-
includePrerelease: false
12-
}
13-
}
9+
options = parseOptions(options)
10+
1411
if (version instanceof SemVer) {
1512
if (version.loose === !!options.loose &&
1613
version.includePrerelease === !!options.includePrerelease) {

‎functions/parse.js

+2-6
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,9 @@ const {MAX_LENGTH} = require('../internal/constants')
22
const { re, t } = require('../internal/re')
33
const SemVer = require('../classes/semver')
44

5+
const parseOptions = require('../internal/parse-options')
56
const parse = (version, options) => {
6-
if (!options || typeof options !== 'object') {
7-
options = {
8-
loose: !!options,
9-
includePrerelease: false
10-
}
11-
}
7+
options = parseOptions(options)
128

139
if (version instanceof SemVer) {
1410
return version

‎internal/parse-options.js

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// parse out just the options we care about so we always get a consistent
2+
// obj with keys in a consistent order.
3+
const opts = ['includePrerelease', 'loose', 'rtl']
4+
const parseOptions = options =>
5+
!options ? {}
6+
: typeof options !== 'object' ? { loose: true }
7+
: opts.filter(k => options[k]).reduce((options, k) => {
8+
options[k] = true
9+
return options
10+
}, {})
11+
module.exports = parseOptions

‎ranges/subset.js

+11-4
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,18 @@ const compare = require('../functions/compare.js')
2121
// - If EQ satisfies every C, return true
2222
// - Else return false
2323
// - If GT
24-
// - If GT is lower than any > or >= comp in C, return false
24+
// - If GT.semver is lower than any > or >= comp in C, return false
2525
// - If GT is >=, and GT.semver does not satisfy every C, return false
2626
// - If LT
27-
// - If LT.semver is greater than that of any > comp in C, return false
27+
// - If LT.semver is greater than any < or <= comp in C, return false
2828
// - If LT is <=, and LT.semver does not satisfy every C, return false
2929
// - If any C is a = range, and GT or LT are set, return false
3030
// - Else return true
3131

3232
const subset = (sub, dom, options) => {
33+
if (sub === dom)
34+
return true
35+
3336
sub = new Range(sub, options)
3437
dom = new Range(dom, options)
3538
let sawNonNull = false
@@ -52,6 +55,9 @@ const subset = (sub, dom, options) => {
5255
}
5356

5457
const simpleSubset = (sub, dom, options) => {
58+
if (sub === dom)
59+
return true
60+
5561
if (sub.length === 1 && sub[0].semver === ANY)
5662
return dom.length === 1 && dom[0].semver === ANY
5763

@@ -90,6 +96,7 @@ const simpleSubset = (sub, dom, options) => {
9096
if (!satisfies(eq, String(c), options))
9197
return false
9298
}
99+
93100
return true
94101
}
95102

@@ -101,15 +108,15 @@ const simpleSubset = (sub, dom, options) => {
101108
if (gt) {
102109
if (c.operator === '>' || c.operator === '>=') {
103110
higher = higherGT(gt, c, options)
104-
if (higher === c)
111+
if (higher === c && higher !== gt)
105112
return false
106113
} else if (gt.operator === '>=' && !satisfies(gt.semver, String(c), options))
107114
return false
108115
}
109116
if (lt) {
110117
if (c.operator === '<' || c.operator === '<=') {
111118
lower = lowerLT(lt, c, options)
112-
if (lower === c)
119+
if (lower === c && lower !== lt)
113120
return false
114121
} else if (lt.operator === '<=' && !satisfies(lt.semver, String(c), options))
115122
return false

‎test/internal/parse-options.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const t = require('tap')
2+
const parseOptions = require('../../internal/parse-options.js')
3+
4+
t.test('falsey values always empty options object', t => {
5+
t.strictSame(parseOptions(null), {})
6+
t.strictSame(parseOptions(false), {})
7+
t.strictSame(parseOptions(undefined), {})
8+
t.strictSame(parseOptions(), {})
9+
t.strictSame(parseOptions(0), {})
10+
t.strictSame(parseOptions(''), {})
11+
t.end()
12+
})
13+
14+
t.test('truthy non-objects always loose mode, for backwards comp', t => {
15+
t.strictSame(parseOptions('hello'), {loose: true})
16+
t.strictSame(parseOptions(true), {loose: true})
17+
t.strictSame(parseOptions(1), {loose: true})
18+
t.end()
19+
})
20+
21+
22+
t.test('objects only include truthy flags we know about, set to true', t => {
23+
t.strictSame(parseOptions(/asdf/), {})
24+
t.strictSame(parseOptions(new Error('hello')), {})
25+
t.strictSame(parseOptions({loose: true,a:1,rtl:false}), { loose: true })
26+
t.strictSame(parseOptions({loose: 1,rtl:2,includePrerelease:10}), {
27+
loose: true,
28+
rtl: true,
29+
includePrerelease: true,
30+
})
31+
t.end()
32+
})

‎test/ranges/subset.js

+39-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
const t = require('tap')
22
const subset = require('../../ranges/subset.js')
3+
const Range = require('../../classes/range')
34

45
// sub, dom, expect, [options]
56
const cases = [
67
['1.2.3', '1.2.3', true],
8+
['1.2.3', '1.x', true],
79
['1.2.3 1.2.4', '1.2.3', true],
10+
['1.2.3 1.2.4', '1.2.9', true], // null set is subset of everything
11+
['1.2.3', '>1.2.0', true],
812
['1.2.3 2.3.4 || 2.3.4', '3', false],
913
['^1.2.3-pre.0', '1.x', false],
1014
['^1.2.3-pre.0', '1.x', true, { includePrerelease: true }],
@@ -65,11 +69,45 @@ const cases = [
6569
['<=3 <=2 <=1', '<4', true],
6670
['>=1 >=2 >=3', '>0', true],
6771
['>=3 >=2 >=1', '>0', true],
72+
['>=3 >=2 >=1', '>=3 >=2 >=1', true],
73+
['>2.0.0', '>=2.0.0', true],
6874
]
6975

70-
t.plan(cases.length)
76+
77+
t.plan(cases.length + 1)
7178
cases.forEach(([sub, dom, expect, options = {}]) => {
7279
const msg = `${sub || "''"}${dom || "''"} = ${expect}` +
7380
(options ? ' ' + Object.keys(options).join(',') : '')
7481
t.equal(subset(sub, dom, options), expect, msg)
7582
})
83+
84+
t.test('range should be subset of itself in obj or string mode', t => {
85+
const range = '^1'
86+
t.equal(subset(range, range), true)
87+
t.equal(subset(range, new Range(range)), true)
88+
t.equal(subset(new Range(range), range), true)
89+
t.equal(subset(new Range(range), new Range(range)), true)
90+
91+
// test with using the same actual object
92+
const r = new Range(range)
93+
t.equal(subset(r, r), true)
94+
95+
// different range object with same set array
96+
const r2 = new Range(range)
97+
r2.set = r.set
98+
t.equal(subset(r2, r), true)
99+
t.equal(subset(r, r2), true)
100+
101+
// different range with set with same simple set arrays
102+
const r3 = new Range(range)
103+
r3.set = [...r.set]
104+
t.equal(subset(r3, r), true)
105+
t.equal(subset(r, r3), true)
106+
107+
// different range with set with simple sets with same comp objects
108+
const r4 = new Range(range)
109+
r4.set = r.set.map(s => [...s])
110+
t.equal(subset(r4, r), true)
111+
t.equal(subset(r, r4), true)
112+
t.end()
113+
})

0 commit comments

Comments
 (0)
Please sign in to comment.