From c74410be95070c44d0c322701b18fd5978d545cf Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 27 Mar 2025 17:20:01 +0100 Subject: [PATCH 1/5] assert,util: improve deep object comparison performance This improves the performance for almost all objects when comparing them deeply. --- .../deepequal-prims-and-objs-big-loop.js | 1 - benchmark/assert/deepequal-set.js | 2 +- benchmark/assert/partial-deep-equal.js | 2 +- lib/internal/util/comparisons.js | 106 ++++++++++++------ 4 files changed, 74 insertions(+), 37 deletions(-) diff --git a/benchmark/assert/deepequal-prims-and-objs-big-loop.js b/benchmark/assert/deepequal-prims-and-objs-big-loop.js index 1ab4ff4dd81f33..51fb2732b7d919 100644 --- a/benchmark/assert/deepequal-prims-and-objs-big-loop.js +++ b/benchmark/assert/deepequal-prims-and-objs-big-loop.js @@ -14,7 +14,6 @@ const primValues = { 'number': 1_000, 'boolean': true, 'object': { property: 'abcdef' }, - 'object_other_property': { property: 'abcdef' }, 'array': [1, 2, 3], 'set_object': new Set([[1]]), 'set_simple': new Set([1, 2, 3]), diff --git a/benchmark/assert/deepequal-set.js b/benchmark/assert/deepequal-set.js index e771c81928a897..f36cefae0af529 100644 --- a/benchmark/assert/deepequal-set.js +++ b/benchmark/assert/deepequal-set.js @@ -6,7 +6,7 @@ const { deepEqual, deepStrictEqual, notDeepEqual, notDeepStrictEqual } = const bench = common.createBenchmark(main, { n: [1e3], - len: [5e2], + len: [2, 5e2], strict: [0, 1], method: [ 'deepEqual_primitiveOnly', diff --git a/benchmark/assert/partial-deep-equal.js b/benchmark/assert/partial-deep-equal.js index cdda4006874d20..6e479115050cde 100644 --- a/benchmark/assert/partial-deep-equal.js +++ b/benchmark/assert/partial-deep-equal.js @@ -62,7 +62,7 @@ function createSets(length, extraProps, depth = 0) { number: i, }, ['array', 'with', 'values'], - !depth ? new Set([1, 2, { nested: i }]) : new Set(), + !depth ? new Set([1, { nested: i }]) : new Set(), !depth ? createSets(2, extraProps, depth + 1) : null, ])); } diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index b7e7b9d92c47ae..8f7a67c9329ada 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -9,6 +9,7 @@ const { DatePrototypeGetTime, Error, NumberPrototypeValueOf, + ObjectGetOwnPropertyDescriptor, ObjectGetOwnPropertySymbols: getOwnSymbols, ObjectGetPrototypeOf, ObjectIs, @@ -192,7 +193,10 @@ function innerDeepEqual(val1, val2, mode, memos) { typeof val1 !== 'object' || val1 === null || val2 === null || - (mode === kStrict && ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2))) { + (mode === kStrict && + (val1.constructor !== val2.constructor || + (val1.constructor === undefined && + ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2))))) { return false; } } else { @@ -316,6 +320,10 @@ function innerDeepEqual(val1, val2, mode, memos) { isNativeError(val2) || val2 instanceof Error) { return false; + } else if (isURL(val1)) { + if (!isURL(val2) || val1.href !== val2.href) { + return false; + } } else if (isKeyObject(val1)) { if (!isKeyObject(val2) || !val1.equals(val2)) { return false; @@ -332,10 +340,6 @@ function innerDeepEqual(val1, val2, mode, memos) { } } else if (isWeakMap(val1) || isWeakSet(val1)) { return false; - } else if (isURL(val1)) { - if (!isURL(val2) || val1.href !== val2.href) { - return false; - } } return keyCheck(val1, val2, mode, memos, kNoIterator); @@ -345,6 +349,21 @@ function getEnumerables(val, keys) { return ArrayPrototypeFilter(keys, (key) => hasEnumerable(val, key)); } +function partialSymbolEquiv(val1, val2, keys2) { + const symbolKeys = getOwnSymbols(val2); + if (symbolKeys.length !== 0) { + for (const key of symbolKeys) { + if (hasEnumerable(val2, key)) { + if (!hasEnumerable(val1, key)) { + return false; + } + ArrayPrototypePush(keys2, key); + } + } + } + return true; +} + function keyCheck(val1, val2, mode, memos, iterationType, keys2) { // For all remaining Object pairs, including Array, objects and Maps, // equivalence is determined by having: @@ -358,31 +377,15 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { if (keys2 === undefined) { keys2 = ObjectKeys(val2); } - - // Cheap key test - if (keys2.length > 0) { - for (const key of keys2) { - if (!hasEnumerable(val1, key)) { - return false; - } - } - } + let keys1; if (!isArrayLikeObject) { // The pair must have the same number of owned properties. if (mode === kPartial) { - const symbolKeys = getOwnSymbols(val2); - if (symbolKeys.length !== 0) { - for (const key of symbolKeys) { - if (hasEnumerable(val2, key)) { - if (!hasEnumerable(val1, key)) { - return false; - } - ArrayPrototypePush(keys2, key); - } - } + if (!partialSymbolEquiv(val1, val2, keys2)) { + return false; } - } else if (keys2.length !== ObjectKeys(val1).length) { + } else if (keys2.length !== (keys1 = ObjectKeys(val1)).length) { return false; } else if (mode === kStrict) { const symbolKeysA = getOwnSymbols(val1); @@ -431,7 +434,7 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { d: undefined, deep: false, }; - return objEquiv(val1, val2, mode, keys2, memos, iterationType); + return objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); } if (memos.set === undefined) { @@ -445,7 +448,7 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { memos.c = val1; memos.d = val2; memos.deep = true; - const result = objEquiv(val1, val2, mode, keys2, memos, iterationType); + const result = objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); memos.deep = false; return result; } @@ -465,7 +468,7 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { return originalSize === set.size; } - const areEq = objEquiv(val1, val2, mode, keys2, memos, iterationType); + const areEq = objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); set.delete(val1); set.delete(val2); @@ -581,7 +584,8 @@ function setEquiv(a, b, mode, memo) { // This is a lazily initiated Set of entries which have to be compared // pairwise. let set = null; - for (const val of b) { + const iteratorB = b.values(); + for (const val of iteratorB) { if (!a.has(val)) { if ((typeof val !== 'object' || val === null) && (mode !== kLoose || !setMightHaveLoosePrim(a, b, val))) { @@ -589,8 +593,25 @@ function setEquiv(a, b, mode, memo) { } if (set === null) { - if (a.size === 1) { - return innerDeepEqual(a.values().next().value, val, mode, memo); + if (a.size < 3) { + const iteratorA = a.values(); + const firstA = iteratorA.next().value; + const first = innerDeepEqual(firstA, val, mode, memo); + if (first) { + if (b.size === 1) { // Partial mode && a.size === 1 + return true; + } + const secondA = iteratorA.next().value; + return b.has(secondA) || innerDeepEqual(secondA, iteratorB.next().value, mode, memo); + } + if (a.size === 1) { + return false; + } + return innerDeepEqual(iteratorA.next().value, val, mode, memo) && ( + b.size === 1 || // Partial mode + b.has(firstA) || // Primitive or reference equal + innerDeepEqual(firstA, iteratorB.next().value, mode, memo) + ); } set = new SafeSet(); } @@ -770,11 +791,28 @@ function sparseArrayEquiv(a, b, mode, memos, i) { return true; } -function objEquiv(a, b, mode, keys2, memos, iterationType) { +function objEquiv(a, b, mode, keys1, keys2, memos, iterationType) { // The pair must have equivalent values for every corresponding key. if (keys2.length > 0) { - for (const key of keys2) { - if (!innerDeepEqual(a[key], b[key], mode, memos)) { + let i = 0; + // Ordered keys + if (keys1 !== undefined) { + for (; i < keys2.length; i++) { + const key = keys2[i]; + if (keys1[i] !== key) { + break; + } + if (!innerDeepEqual(a[key], b[key], mode, memos)) { + return false; + } + } + } + // Unordered keys + for (; i < keys2.length; i++) { + const key = keys2[i]; + const descriptor = ObjectGetOwnPropertyDescriptor(a, key); + if (!descriptor?.enumerable || + !innerDeepEqual(descriptor.value !== undefined ? descriptor.value : a[key], b[key], mode, memos)) { return false; } } From 5dc2a5d43a57c743e7bafe487ab4f86089c847e8 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Fri, 28 Mar 2025 13:31:37 +0100 Subject: [PATCH 2/5] assert,util: improve deep equal comparison performance This allows the compiler to inline parts of the code in a way that especially primitives in arrays can be compared faster. --- lib/internal/util/comparisons.js | 56 +++++++++++++++----------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 8f7a67c9329ada..db37801f033c49 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -164,18 +164,6 @@ function isEnumerableOrIdentical(val1, val2, prop, mode, memos, method) { innerDeepEqual(val1[prop], val2[prop], mode, memos); } -// Notes: Type tags are historical [[Class]] properties that can be set by -// FunctionTemplate::SetClassName() in C++ or Symbol.toStringTag in JS -// and retrieved using Object.prototype.toString.call(obj) in JS -// See https://tc39.github.io/ecma262/#sec-object.prototype.tostring -// for a list of tags pre-defined in the spec. -// There are some unspecified tags in the wild too (e.g. typed array tags). -// Since tags can be altered, they only serve fast failures -// -// For strict comparison, objects should have -// a) The same built-in type tag. -// b) The same prototypes. - function innerDeepEqual(val1, val2, mode, memos) { // All identical values are equivalent, as determined by ===. if (val1 === val2) { @@ -192,11 +180,7 @@ function innerDeepEqual(val1, val2, mode, memos) { if (typeof val2 !== 'object' || typeof val1 !== 'object' || val1 === null || - val2 === null || - (mode === kStrict && - (val1.constructor !== val2.constructor || - (val1.constructor === undefined && - ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2))))) { + val2 === null) { return false; } } else { @@ -210,6 +194,17 @@ function innerDeepEqual(val1, val2, mode, memos) { return false; } } + return objectComparisonStart(val1, val2, mode, memos); +} + +function objectComparisonStart(val1, val2, mode, memos) { + if (mode === kStrict && + (val1.constructor !== val2.constructor || + (val1.constructor === undefined && + ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2)))) { + return false; + } + const val1Tag = ObjectPrototypeToString(val1); const val2Tag = ObjectPrototypeToString(val2); @@ -218,7 +213,6 @@ function innerDeepEqual(val1, val2, mode, memos) { } if (ArrayIsArray(val1)) { - // Check for sparse arrays and general fast path if (!ArrayIsArray(val2) || (val1.length !== val2.length && (mode !== kPartial || val1.length < val2.length))) { return false; @@ -476,9 +470,9 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { return areEq; } -function setHasEqualElement(set, val1, mode, memo) { +function setHasEqualElement(set, val1, mode, memo, fn) { for (const val2 of set) { - if (innerDeepEqual(val1, val2, mode, memo)) { + if (fn(val1, val2, mode, memo)) { // Remove the matching element to make sure we do not check that again. set.delete(val2); return true; @@ -540,7 +534,7 @@ function partialObjectSetEquiv(a, b, mode, set, memo) { let aPos = 0; for (const val of a) { aPos++; - if (!b.has(val) && setHasEqualElement(set, val, mode, memo) && set.size === 0) { + if (!b.has(val) && setHasEqualElement(set, val, mode, memo, innerDeepEqual) && set.size === 0) { return true; } if (a.size - aPos < set.size) { @@ -555,7 +549,7 @@ function setObjectEquiv(a, b, mode, set, memo) { // Fast path for objects only if (mode !== kLoose && set.size === a.size) { for (const val of a) { - if (!setHasEqualElement(set, val, mode, memo)) { + if (!setHasEqualElement(set, val, mode, memo, objectComparisonStart)) { return false; } } @@ -565,15 +559,16 @@ function setObjectEquiv(a, b, mode, set, memo) { return partialObjectSetEquiv(a, b, mode, set, memo); } + const fn = mode === kStrict ? objectComparisonStart : innerDeepEqual; for (const val of a) { // Primitive values have already been handled above. if (typeof val === 'object') { - if (!b.has(val) && !setHasEqualElement(set, val, mode, memo)) { + if (!b.has(val) && !setHasEqualElement(set, val, mode, memo, fn)) { return false; } } else if (mode === kLoose && !b.has(val) && - !setHasEqualElement(set, val, mode, memo)) { + !setHasEqualElement(set, val, mode, memo, innerDeepEqual)) { return false; } } @@ -629,12 +624,12 @@ function setEquiv(a, b, mode, memo) { return true; } -function mapHasEqualEntry(set, map, key1, item1, mode, memo) { +function mapHasEqualEntry(set, map, key1, item1, mode, memo, fn) { // To be able to handle cases like: // Map([[{}, 'a'], [{}, 'b']]) vs Map([[{}, 'b'], [{}, 'a']]) // ... we need to consider *all* matching keys, not just the first we find. for (const key2 of set) { - if (innerDeepEqual(key1, key2, mode, memo) && + if (fn(key1, key2, mode, memo) && innerDeepEqual(item1, map.get(key2), mode, memo)) { set.delete(key2); return true; @@ -650,7 +645,7 @@ function partialObjectMapEquiv(a, b, mode, set, memo) { aPos++; if (typeof key1 === 'object' && key1 !== null && - mapHasEqualEntry(set, b, key1, item1, mode, memo) && + mapHasEqualEntry(set, b, key1, item1, mode, memo, objectComparisonStart) && set.size === 0) { return true; } @@ -666,7 +661,7 @@ function mapObjectEquivalence(a, b, mode, set, memo) { // Fast path for objects only if (mode !== kLoose && set.size === a.size) { for (const { 0: key1, 1: item1 } of a) { - if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) { + if (!mapHasEqualEntry(set, b, key1, item1, mode, memo, objectComparisonStart)) { return false; } } @@ -675,16 +670,17 @@ function mapObjectEquivalence(a, b, mode, set, memo) { if (mode === kPartial) { return partialObjectMapEquiv(a, b, mode, set, memo); } + const fn = mode === kStrict ? objectComparisonStart : innerDeepEqual; for (const { 0: key1, 1: item1 } of a) { if (typeof key1 === 'object' && key1 !== null) { - if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) + if (!mapHasEqualEntry(set, b, key1, item1, mode, memo, fn)) return false; } else if (set.size === 0) { return true; } else if (mode === kLoose && (!b.has(key1) || !innerDeepEqual(item1, b.get(key1), mode, memo)) && - !mapHasEqualEntry(set, b, key1, item1, mode, memo)) { + !mapHasEqualEntry(set, b, key1, item1, mode, memo, innerDeepEqual)) { return false; } } From 43ea921bf2e18522d18e2ce0b63f6793759100cf Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 31 Mar 2025 09:38:29 +0200 Subject: [PATCH 3/5] assert,util: improve (partial) deep equal comparison performance The circular check is now done lazily. That way only users that actually make use of such structures have to do the calculation overhead. It is an initial overhead the very first time it's run to detect the circular structure, while every following call uses the check for circular structures by default. This improves the performance for object comparison significantly. On top of that, this includes an optimised algorithm for sets and maps that contain objects as keys. The tracking is now done in an array and the array size is not changed when elements at the start or at the end of the array is detected. The order of the elements matter, so a reversed key order is now a magnitude faster. Insert sort comparison is also signficantly faster, random order is a tad faster. --- benchmark/assert/deepequal-map.js | 2 +- benchmark/assert/deepequal-set.js | 41 +++- lib/internal/util/comparisons.js | 350 +++++++++++++++++++----------- 3 files changed, 256 insertions(+), 137 deletions(-) diff --git a/benchmark/assert/deepequal-map.js b/benchmark/assert/deepequal-map.js index 4f651551c58c82..c336a471b25101 100644 --- a/benchmark/assert/deepequal-map.js +++ b/benchmark/assert/deepequal-map.js @@ -31,7 +31,7 @@ function benchmark(method, n, values, values2) { } function main({ n, len, method, strict }) { - const array = Array(len).fill(1); + const array = Array.from({ length: len }, () => ''); switch (method) { case 'deepEqual_primitiveOnly': { diff --git a/benchmark/assert/deepequal-set.js b/benchmark/assert/deepequal-set.js index f36cefae0af529..2667cf88d73708 100644 --- a/benchmark/assert/deepequal-set.js +++ b/benchmark/assert/deepequal-set.js @@ -6,8 +6,9 @@ const { deepEqual, deepStrictEqual, notDeepEqual, notDeepStrictEqual } = const bench = common.createBenchmark(main, { n: [1e3], - len: [2, 5e2], + len: [2, 1e2], strict: [0, 1], + order: ['insert', 'random', 'reversed'], method: [ 'deepEqual_primitiveOnly', 'deepEqual_objectOnly', @@ -16,12 +17,30 @@ const bench = common.createBenchmark(main, { 'notDeepEqual_objectOnly', 'notDeepEqual_mixed', ], +}, { + combinationFilter(p) { + return p.order !== 'random' || p.strict === 1 && p.method !== 'notDeepEqual_objectOnly'; + }, }); -function benchmark(method, n, values, values2) { +function shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } +} + +function benchmark(method, n, values, values2, order) { const actual = new Set(values); // Prevent reference equal elements - const deepCopy = JSON.parse(JSON.stringify(values2 ? values2 : values)); + let deepCopy = JSON.parse(JSON.stringify(values2)); + if (order === 'reversed') { + deepCopy = deepCopy.reverse(); + } else if (order === 'random') { + shuffleArray(deepCopy); + } const expected = new Set(deepCopy); bench.start(); for (let i = 0; i < n; ++i) { @@ -30,39 +49,39 @@ function benchmark(method, n, values, values2) { bench.end(n); } -function main({ n, len, method, strict }) { - const array = Array(len).fill(1); +function main({ n, len, method, strict, order }) { + const array = Array.from({ length: len }, () => ''); switch (method) { case 'deepEqual_primitiveOnly': { const values = array.map((_, i) => `str_${i}`); - benchmark(strict ? deepStrictEqual : deepEqual, n, values); + benchmark(strict ? deepStrictEqual : deepEqual, n, values, values, order); break; } case 'deepEqual_objectOnly': { const values = array.map((_, i) => [`str_${i}`, null]); - benchmark(strict ? deepStrictEqual : deepEqual, n, values); + benchmark(strict ? deepStrictEqual : deepEqual, n, values, values, order); break; } case 'deepEqual_mixed': { const values = array.map((_, i) => { return i % 2 ? [`str_${i}`, null] : `str_${i}`; }); - benchmark(strict ? deepStrictEqual : deepEqual, n, values); + benchmark(strict ? deepStrictEqual : deepEqual, n, values, values, order); break; } case 'notDeepEqual_primitiveOnly': { const values = array.map((_, i) => `str_${i}`); const values2 = values.slice(0); values2[Math.floor(len / 2)] = 'w00t'; - benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2); + benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2, order); break; } case 'notDeepEqual_objectOnly': { const values = array.map((_, i) => [`str_${i}`, null]); const values2 = values.slice(0); values2[Math.floor(len / 2)] = ['w00t']; - benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2); + benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2, order); break; } case 'notDeepEqual_mixed': { @@ -71,7 +90,7 @@ function main({ n, len, method, strict }) { }); const values2 = values.slice(); values2[0] = 'w00t'; - benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2); + benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2, order); break; } default: diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index db37801f033c49..31fb809a06c13e 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -418,6 +418,13 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { return true; } + if (memos === null) { + return objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); + } + return handleCycles(val1, val2, mode, keys1, keys2, memos, iterationType); +} + +function handleCycles(val1, val2, mode, keys1, keys2, memos, iterationType) { // Use memos to handle cycles. if (memos === undefined) { memos = { @@ -470,18 +477,6 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { return areEq; } -function setHasEqualElement(set, val1, mode, memo, fn) { - for (const val2 of set) { - if (fn(val1, val2, mode, memo)) { - // Remove the matching element to make sure we do not check that again. - set.delete(val2); - return true; - } - } - - return false; -} - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness#Loose_equality_using // Sadly it is not possible to detect corresponding values properly in case the // type is a string, number, bigint or boolean. The reason is that those values @@ -530,55 +525,124 @@ function mapMightHaveLoosePrim(a, b, prim, item2, memo) { return !b.has(altValue) && innerDeepEqual(item1, item2, kLoose, memo); } -function partialObjectSetEquiv(a, b, mode, set, memo) { +function partialObjectSetEquiv(array, a, b, mode, memo) { let aPos = 0; - for (const val of a) { + let direction = 1; + let start = 0; + let end = array.length - 1; + for (const val1 of a) { aPos++; - if (!b.has(val) && setHasEqualElement(set, val, mode, memo, innerDeepEqual) && set.size === 0) { - return true; + if (!b.has(val1)) { + let innerStart = start; + if (direction === 1) { + if (innerDeepEqual(val1, array[start], mode, memo)) { + if (end === start) { + return true; + } + start += 1; + continue; + } + if (start === end) { + continue; + } + direction = -1; + innerStart += 1; + } + let matched = true; + if (!innerDeepEqual(val1, array[end], mode, memo)) { + direction = 1; + matched = arrayHasEqualElement(array, val1, mode, memo, innerDeepEqual, innerStart, end); + } + if (matched) { + if (end === start) { + return true; + } + end -= 1; + } } - if (a.size - aPos < set.size) { + if (a.size - aPos <= end - start) { return false; } } - /* c8 ignore next */ - assert.fail('Unreachable code'); + return false; } -function setObjectEquiv(a, b, mode, set, memo) { - // Fast path for objects only - if (mode !== kLoose && set.size === a.size) { - for (const val of a) { - if (!setHasEqualElement(set, val, mode, memo, objectComparisonStart)) { - return false; - } +function arrayHasEqualElement(array, val1, mode, memo, comparator, start, end) { + let matched = false; + for (let i = end - 1; i >= start; i--) { + if (comparator(val1, array[i], mode, memo)) { + // Remove the matching element to make sure we do not check that again. + array.splice(i, 1); + matched = true; + break; } - return true; - } - if (mode === kPartial) { - return partialObjectSetEquiv(a, b, mode, set, memo); } + return matched; +} + +function setObjectEquiv(array, a, b, mode, memo) { + let direction = 1; + let start = 0; + let end = array.length - 1; + const comparator = mode !== kLoose ? objectComparisonStart : innerDeepEqual; + const extraChecks = mode === kLoose || array.length !== a.size; + for (const val1 of a) { + if (extraChecks) { + if (typeof val1 === 'object') { + if (b.has(val1)) { + continue; + } + } else if (mode !== kLoose || b.has(val1)) { + continue; + } + } - const fn = mode === kStrict ? objectComparisonStart : innerDeepEqual; - for (const val of a) { - // Primitive values have already been handled above. - if (typeof val === 'object') { - if (!b.has(val) && !setHasEqualElement(set, val, mode, memo, fn)) { + let innerStart = start; + if (direction === 1) { + if (comparator(val1, array[start], mode, memo)) { + start += 1; + continue; + } + if (start === end) { return false; } - } else if (mode === kLoose && - !b.has(val) && - !setHasEqualElement(set, val, mode, memo, innerDeepEqual)) { - return false; + direction = -1; + innerStart += 1; + } + if (!comparator(val1, array[end], mode, memo)) { + direction = 1; + if (!arrayHasEqualElement(array, val1, mode, memo, comparator, innerStart, end)) { + return false; + } + } + end -= 1; + } + return true; +} + +function compareSmallSets(a, b, val, iteratorB, mode, memo) { + const iteratorA = a.values(); + const firstA = iteratorA.next().value; + const first = innerDeepEqual(firstA, val, mode, memo); + if (first) { + if (b.size === 1) { // Partial mode && a.size === 1 || b.size === 1 + return true; } + const secondA = iteratorA.next().value; + return b.has(secondA) || innerDeepEqual(secondA, iteratorB.next().value, mode, memo); } - return set.size === 0; + return a.size !== 1 && innerDeepEqual(iteratorA.next().value, val, mode, memo) && ( + b.size === 1 || // Partial mode + b.has(firstA) || // Primitive or reference equal + innerDeepEqual(firstA, iteratorB.next().value, mode, memo) + ); } function setEquiv(a, b, mode, memo) { // This is a lazily initiated Set of entries which have to be compared // pairwise. - let set = null; + let array; + const iteratorB = b.values(); for (const val of iteratorB) { if (!a.has(val)) { @@ -587,120 +651,141 @@ function setEquiv(a, b, mode, memo) { return false; } - if (set === null) { + if (array === undefined) { if (a.size < 3) { - const iteratorA = a.values(); - const firstA = iteratorA.next().value; - const first = innerDeepEqual(firstA, val, mode, memo); - if (first) { - if (b.size === 1) { // Partial mode && a.size === 1 - return true; - } - const secondA = iteratorA.next().value; - return b.has(secondA) || innerDeepEqual(secondA, iteratorB.next().value, mode, memo); - } - if (a.size === 1) { - return false; - } - return innerDeepEqual(iteratorA.next().value, val, mode, memo) && ( - b.size === 1 || // Partial mode - b.has(firstA) || // Primitive or reference equal - innerDeepEqual(firstA, iteratorB.next().value, mode, memo) - ); + return compareSmallSets(a, b, val, iteratorB, mode, memo); } - set = new SafeSet(); + array = []; } // If the specified value doesn't exist in the second set it's a object // (or in loose mode: a non-matching primitive). Find the // deep-(mode-)equal element in a set copy to reduce duplicate checks. - set.add(val); + array.push(val); } } - if (set !== null) { - return setObjectEquiv(a, b, mode, set, memo); + if (array === undefined) { + return true; } - - return true; -} - -function mapHasEqualEntry(set, map, key1, item1, mode, memo, fn) { - // To be able to handle cases like: - // Map([[{}, 'a'], [{}, 'b']]) vs Map([[{}, 'b'], [{}, 'a']]) - // ... we need to consider *all* matching keys, not just the first we find. - for (const key2 of set) { - if (fn(key1, key2, mode, memo) && - innerDeepEqual(item1, map.get(key2), mode, memo)) { - set.delete(key2); - return true; - } + if (mode === kPartial) { + return partialObjectSetEquiv(array, a, b, mode, memo); } - - return false; + return setObjectEquiv(array, a, b, mode, memo); } -function partialObjectMapEquiv(a, b, mode, set, memo) { +function partialObjectMapEquiv(array, a, b, mode, memo) { let aPos = 0; + let direction = 1; + let start = 0; + let end = array.length - 1; for (const { 0: key1, 1: item1 } of a) { aPos++; - if (typeof key1 === 'object' && - key1 !== null && - mapHasEqualEntry(set, b, key1, item1, mode, memo, objectComparisonStart) && - set.size === 0) { - return true; + if (typeof key1 === 'object' && key1 !== null) { + let innerStart = start; + if (direction === 1) { + const key2 = array[start]; + if (objectComparisonStart(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { + if (end === start) { + return true; + } + start += 1; + continue; + } + if (start === end) { + continue; + } + direction = -1; + innerStart += 1; + } + let matched = true; + const key2 = array[end]; + if (!objectComparisonStart(key1, key2, mode, memo) || !innerDeepEqual(item1, b.get(key2), mode, memo)) { + direction = 1; + matched = arrayHasEqualMapElement(array, key1, item1, b, mode, memo, objectComparisonStart, innerStart, end); + } + if (matched) { + if (end === start) { + return true; + } + end -= 1; + } } - if (a.size - aPos < set.size) { + if (a.size - aPos <= end - start) { return false; } } - /* c8 ignore next */ - assert.fail('Unreachable code'); + return false; } -function mapObjectEquivalence(a, b, mode, set, memo) { - // Fast path for objects only - if (mode !== kLoose && set.size === a.size) { - for (const { 0: key1, 1: item1 } of a) { - if (!mapHasEqualEntry(set, b, key1, item1, mode, memo, objectComparisonStart)) { - return false; - } +function arrayHasEqualMapElement(array, key1, item1, b, mode, memo, comparator, start, end) { + let matched = false; + for (let i = end - 1; i >= start; i--) { + const key2 = array[i]; + if (comparator(key1, key2, mode, memo) && + innerDeepEqual(item1, b.get(key2), mode, memo)) { + // Remove the matching element to make sure we do not check that again. + array.splice(i, 1); + matched = true; + break; } - return true; - } - if (mode === kPartial) { - return partialObjectMapEquiv(a, b, mode, set, memo); } - const fn = mode === kStrict ? objectComparisonStart : innerDeepEqual; + return matched; +} + +function mapObjectEquiv(array, a, b, mode, memo) { + let direction = 1; + let start = 0; + let end = array.length - 1; + const comparator = mode !== kLoose ? objectComparisonStart : innerDeepEqual; + const extraChecks = mode === kLoose || array.length !== a.size; + for (const { 0: key1, 1: item1 } of a) { - if (typeof key1 === 'object' && key1 !== null) { - if (!mapHasEqualEntry(set, b, key1, item1, mode, memo, fn)) + if (extraChecks && + (typeof key1 !== 'object' || key1 === null) && + (mode !== kLoose || + (b.has(key1) && innerDeepEqual(item1, b.get(key1), mode, memo)))) { // Mixed mode + continue; + } + + let innerStart = start; + if (direction === 1) { + const key2 = array[start]; + if (comparator(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { + start += 1; + continue; + } + if (start === end) { return false; - } else if (set.size === 0) { - return true; - } else if (mode === kLoose && - (!b.has(key1) || - !innerDeepEqual(item1, b.get(key1), mode, memo)) && - !mapHasEqualEntry(set, b, key1, item1, mode, memo, innerDeepEqual)) { - return false; + } + direction = -1; + innerStart += 1; } + const key2 = array[end]; + if ((!comparator(key1, key2, mode, memo) || !innerDeepEqual(item1, b.get(key2), mode, memo))) { + direction = 1; + if (!arrayHasEqualMapElement(array, key1, item1, b, mode, memo, comparator, innerStart, end)) { + return false; + } + } + end -= 1; } - return set.size === 0; + return true; } function mapEquiv(a, b, mode, memo) { - let set = null; + let array; for (const { 0: key2, 1: item2 } of b) { if (typeof key2 === 'object' && key2 !== null) { - if (set === null) { + if (array === undefined) { if (a.size === 1) { const { 0: key1, 1: item1 } = a.entries().next().value; return innerDeepEqual(key1, key2, mode, memo) && - innerDeepEqual(item1, item2, mode, memo); + innerDeepEqual(item1, item2, mode, memo); } - set = new SafeSet(); + array = []; } - set.add(key2); + array.push(key2); } else { // By directly retrieving the value we prevent another b.has(key2) check in // almost all possible cases. @@ -713,19 +798,23 @@ function mapEquiv(a, b, mode, memo) { // keys. if (!mapMightHaveLoosePrim(a, b, key2, item2, memo)) return false; - if (set === null) { - set = new SafeSet(); + if (array === undefined) { + array = []; } - set.add(key2); + array.push(key2); } } } - if (set !== null) { - return mapObjectEquivalence(a, b, mode, set, memo); + if (array === undefined) { + return true; } - return true; + if (mode === kPartial) { + return partialObjectMapEquiv(array, a, b, mode, memo); + } + + return mapObjectEquiv(array, a, b, mode, memo); } function partialSparseArrayEquiv(a, b, mode, memos, startA, startB) { @@ -841,14 +930,25 @@ function objEquiv(a, b, mode, keys1, keys2, memos, iterationType) { return true; } +// Only handle cycles when they are detected. +// eslint-disable-next-line func-style +let detectCycles = function(val1, val2, mode) { + try { + return innerDeepEqual(val1, val2, mode, null); + } catch { + detectCycles = innerDeepEqual; + return innerDeepEqual(val1, val2, mode, undefined); + } +}; + module.exports = { isDeepEqual(val1, val2) { - return innerDeepEqual(val1, val2, kLoose); + return detectCycles(val1, val2, kLoose); }, isDeepStrictEqual(val1, val2) { - return innerDeepEqual(val1, val2, kStrict); + return detectCycles(val1, val2, kStrict); }, isPartialStrictEqual(val1, val2) { - return innerDeepEqual(val1, val2, kPartial); + return detectCycles(val1, val2, kPartial); }, }; From 2cf9cfcfaa4755b5a00966ccf2ac2f8d289c3614 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 3 Apr 2025 10:56:05 +0200 Subject: [PATCH 4/5] test: add assert.deepStrictEqual test cases to cover more cases --- test/parallel/test-assert-deep.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/parallel/test-assert-deep.js b/test/parallel/test-assert-deep.js index c53ce8f4cc62cf..c6326fa26f6387 100644 --- a/test/parallel/test-assert-deep.js +++ b/test/parallel/test-assert-deep.js @@ -406,6 +406,18 @@ test('es6 Maps and Sets', () => { new Set([xarray, ['y']]), new Set([xarray, ['y']]) ); + assertDeepAndStrictEqual( + new Set([2, xarray, ['y'], 1]), + new Set([xarray, ['y'], 1, 2]) + ); + assertDeepAndStrictEqual( + new Set([{ a: 1 }, { a: 3 }, { a: 2 }, { a: 4 }]), + new Set([{ a: 2 }, { a: 1 }, { a: 4 }, { a: 3 }]) + ); + assertNotDeepOrStrict( + new Set([{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }]), + new Set([{ a: 1 }, { a: 2 }, { a: 3 }, { a: 5 }]) + ); assertOnlyDeepEqual( new Set([null, '', 1n, 5, 2n, false]), new Set([undefined, 0, 5n, true, '2', '-000']) From 75d8fbbdb9e0f89950d2c5c86481b94ebb67f871 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Fri, 4 Apr 2025 20:08:05 +0200 Subject: [PATCH 5/5] fixup! add comments and change argument order for consistency --- lib/internal/util/comparisons.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 31fb809a06c13e..3120c902905f1d 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -536,13 +536,14 @@ function partialObjectSetEquiv(array, a, b, mode, memo) { let innerStart = start; if (direction === 1) { if (innerDeepEqual(val1, array[start], mode, memo)) { - if (end === start) { + if (start === end) { return true; } start += 1; continue; } if (start === end) { + // The last element of set b might match a later element in set a. continue; } direction = -1; @@ -554,7 +555,7 @@ function partialObjectSetEquiv(array, a, b, mode, memo) { matched = arrayHasEqualElement(array, val1, mode, memo, innerDeepEqual, innerStart, end); } if (matched) { - if (end === start) { + if (start === end) { return true; } end -= 1; @@ -685,13 +686,14 @@ function partialObjectMapEquiv(array, a, b, mode, memo) { if (direction === 1) { const key2 = array[start]; if (objectComparisonStart(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { - if (end === start) { + if (start === end) { return true; } start += 1; continue; } if (start === end) { + // The last element of map b might match a later element in map a. continue; } direction = -1; @@ -704,7 +706,7 @@ function partialObjectMapEquiv(array, a, b, mode, memo) { matched = arrayHasEqualMapElement(array, key1, item1, b, mode, memo, objectComparisonStart, innerStart, end); } if (matched) { - if (end === start) { + if (start === end) { return true; } end -= 1; @@ -895,6 +897,9 @@ function objEquiv(a, b, mode, keys1, keys2, memos, iterationType) { // Unordered keys for (; i < keys2.length; i++) { const key = keys2[i]; + // It is faster to get the whole descriptor and to check it's enumerable + // property in V8 13.0 compared to calling Object.propertyIsEnumerable() + // and accessing the property regularly. const descriptor = ObjectGetOwnPropertyDescriptor(a, key); if (!descriptor?.enumerable || !innerDeepEqual(descriptor.value !== undefined ? descriptor.value : a[key], b[key], mode, memos)) {