Skip to content
New issue

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

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

Already on GitHub? Sign in 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 📦🚀

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

Successfully merging this pull request may close these issues.

1 participant