Skip to content
New issue

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

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

Already on GitHub? # to your account

feat: improve toIncludeSameMembers performance for primitive arrays #7

Merged

Conversation

rluvaton
Copy link
Owner

For array of 1K items (could not do the benchmark for arrays larger than 10K as it was too slow):

Running "testing fast path for toIncludeSameMembers -  actual and expected are different order" suite...
Progress: 100%

  original:
    100 ops/s, ±5.25%     | slowest, 96.14% slower

  updated:
    2 593 ops/s, ±0.37%   | fastest

Finished 2 cases!
  Fastest: updated
  Slowest: original
Running "testing fast path for toIncludeSameMembers - actual and expected have same order" suite...
Progress: 100%

  original:
    3 084 ops/s, ±1.04%   | fastest

  updated:
    2 634 ops/s, ±0.70%   | slowest, 14.59% slower

Finished 2 cases!
  Fastest: original
  Slowest: updated
Running "testing fast path for toIncludeSameMembers - both actual and expected are sorted" suite...
Progress: 100%

  original:
    3 126 ops/s, ±0.72%    | slowest, 82.6% slower

  updated:
    17 962 ops/s, ±0.28%   | fastest

Finished 2 cases!
  Fastest: updated
  Slowest: original
Running "testing fast path for toIncludeSameMembers - actual sorted and expected not" suite...
Progress: 100%

  original:
    92 ops/s, ±3.10%      | slowest, 98% slower

  updated:
    4 601 ops/s, ±0.25%   | fastest

Finished 2 cases!
  Fastest: updated
  Slowest: original
Running "testing fast path for toIncludeSameMembers - expected sorted and actual not" suite...
Progress: 100%

  original:
    91 ops/s, ±5.38%      | slowest, 97.99% slower

  updated:
    4 522 ops/s, ±0.28%   | fastest

Finished 2 cases!
  Fastest: updated
  Slowest: original
Benchmark code
/* benchmark.js */
const b = require('benny')


const {generateArray, generateDataTypeFromType} = require('../generate-data');
const {faker} = require('@faker-js/faker');
const shuffle = require("lodash/shuffle");

const original = require('./original');
const updated = require('./updated');


const dataTypes = ['uuid', 'null', 'undefined', 'number', 'boolean', 'bigint'];

console.time('Generate options');
const size = 50;
const allOptions = Array.from(
    {length: size},
    (_, i) => {
        const dt = dataTypes.map(item => [item]).concat([dataTypes]);
        // Some are from the same type and some are random
        const dataTypesToGenerate = dt[i % dt.length];

        const arr1 = generateArray({
            min: 1_000,
            max: 1_000,
            generateValue: () => generateDataTypeFromType(dataTypesToGenerate.length === 1 ? dataTypesToGenerate[0] : faker.helpers.arrayElement(dataTypesToGenerate))
        })
        const arr2 = shuffle(arr1);
        const sortedArr = arr1.slice(0).sort();
        const sortedArr2 = sortedArr.slice(0);
        const unsortedArray1Clone = arr1.slice(0);

        return {
            unsortedArr1: arr1,
            unsortedArr2: arr2,
            sortedArray1: sortedArr,
            sortedArray2: sortedArr2,
            unsortedArray1Clone: unsortedArray1Clone
        };
    },
);
console.timeEnd('Generate options');

let index1 = 0;
let index2 = 0;
let index3 = 0;
let index4 = 0;
let index5 = 0;

b.suite(
    "testing fast path for toIncludeSameMembers -  actual and expected are different order",

    b.add("original", function () {
        const {unsortedArr1, unsortedArr2} = allOptions[index1++ % size];

        original(unsortedArr1, unsortedArr2);
    }),
    b.add("updated", function () {
        const {unsortedArr1, unsortedArr2} = allOptions[index1++ % size];

        updated(unsortedArr1, unsortedArr2);
    }),

    b.cycle(),
    b.complete()
);



b.suite(
    "testing fast path for toIncludeSameMembers - actual and expected have same order",

    b.add("original", function () {
        const {unsortedArr1, unsortedArray1Clone} = allOptions[index2++ % size];

        original(unsortedArr1, unsortedArray1Clone);
    }),
    b.add("updated", function () {
        const {unsortedArr1, unsortedArray1Clone} = allOptions[index2++ % size];

        updated(unsortedArr1, unsortedArray1Clone);
    }),


    b.cycle(),
    b.complete()
);



b.suite(
    "testing fast path for toIncludeSameMembers - both actual and expected are sorted",


    b.add("original", function () {
        const {sortedArray1, sortedArray2} = allOptions[index3++ % size];

        original(sortedArray1, sortedArray2);
    }),
    b.add("updated", function () {
        const {sortedArray1, sortedArray2} = allOptions[index3++ % size];

        updated(sortedArray1, sortedArray2);
    }),

    b.cycle(),
    b.complete()
);



b.suite(
    "testing fast path for toIncludeSameMembers - actual sorted and expected not",


    b.add("original", function () {
        const {unsortedArr1, sortedArray1} = allOptions[index4++ % size];

        original(sortedArray1, unsortedArr1);
    }),
    b.add("updated", function () {
        const {unsortedArr1, sortedArray1} = allOptions[index4++ % size];

        updated(sortedArray1, unsortedArr1);
    }),

    b.cycle(),
    b.complete()
);



b.suite(
    "testing fast path for toIncludeSameMembers - expected sorted and actual not",

    b.add("original", function () {
        const {unsortedArr1, sortedArray1} = allOptions[index5++ % size];

        original(unsortedArr1, sortedArray1);
    }),
    b.add("updated", function () {
        const {unsortedArr1, sortedArray1} = allOptions[index5++ % size];

        updated(unsortedArr1, sortedArray1);
    }),

    b.cycle(),
    b.complete()
);

original.js:

const equals = require('./equals');
module.exports = function originalPredicate(actual, expected) {
    if (!Array.isArray(actual) || !Array.isArray(expected) || actual.length !== expected.length) {
        return false;
    }

    const remaining = expected.reduce((remaining, secondValue) => {
        if (remaining === null) return remaining;

        const index = remaining.findIndex(firstValue => equals(secondValue, firstValue));

        if (index === -1) {
            return null;
        }

        return remaining.slice(0, index).concat(remaining.slice(index + 1));
    }, actual);

    return !!remaining && remaining.length === 0;
};

equals.js:

module.exports = require('@jest/expect-utils').equals;

updated.js:

const equals = require('./equals');

function isArraySuitableForPrimitiveFastPath(array) {
    return array.every(
        item =>
            typeof item !== 'function' &&
            (typeof item !== 'object' || item === null) &&
            // Can't sort of array of symbols
            typeof item !== 'symbol',
    );
}

module.exports = function updatedPredicate(actual, expected) {
    if (!Array.isArray(actual) || !Array.isArray(expected) || actual.length !== expected.length) {
        return false;
    }

    const isActualAndExpectedPrimitiveArrays =
        // testing expected first as it is the most likely to include asymmetric matchers
        isArraySuitableForPrimitiveFastPath(expected) && isArraySuitableForPrimitiveFastPath(actual);

    if (isActualAndExpectedPrimitiveArrays) {
        return fasterPredicateForPrimitiveArray(actual, expected);
    }

    const remaining = expected.reduce((remaining, secondValue) => {
        if (remaining === null) return remaining;

        const index = remaining.findIndex(firstValue => equals(secondValue, firstValue));

        if (index === -1) {
            return null;
        }

        return remaining.slice(0, index).concat(remaining.slice(index + 1));
    }, actual);

    return !!remaining && remaining.length === 0;
};

/**
 * Faster predicate for primitive arrays
 * @param {(string | null | undefined | number | boolean | symbol | bigint)[]} actual
 * @param {(string | null | undefined | number | boolean | symbol | bigint)[]} expected
 */
function fasterPredicateForPrimitiveArray(actual, expected) {
    // Sort is mutating, so we want to avoid mutating the array
    const actualSorted = actual.slice(0).sort();
    const expectedSorted = expected.slice(0).sort();

    const length = actualSorted.length;

    for (let i = 0; i < length; i++) {
        if (actualSorted[i] !== expectedSorted[i]) {
            return false;
        }
    }

    return true;
}

@rluvaton rluvaton merged commit 5e07f60 into main Jan 29, 2024
4 checks passed
@rluvaton rluvaton deleted the improve-to-include-same-members-matchers-for-primitives branch January 29, 2024 19:08
Copy link

🎉 This PR is included in version 1.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant