diff --git a/bin.mjs b/bin.mjs index 37dd41d..0112737 100755 --- a/bin.mjs +++ b/bin.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { run } from './dist/tools/appNGram.mjs'; +import { run } from './dist/app.mjs'; run(); diff --git a/package.json b/package.json index d5dd8b4..80f8344 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ }, "dependencies": { "@cspell/cspell-pipe": "^8.7.0", + "as-table": "^1.0.55", "chalk": "^5.3.0", "commander": "^12.0.0", "globby": "^14.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 787ce07..2477ca4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@cspell/cspell-pipe': specifier: ^8.7.0 version: 8.7.0 + as-table: + specifier: ^1.0.55 + version: 1.0.55 chalk: specifier: ^5.3.0 version: 5.3.0 @@ -1298,6 +1301,12 @@ packages: is-shared-array-buffer: 1.0.3 dev: true + /as-table@1.0.55: + resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + dependencies: + printable-characters: 1.0.42 + dev: false + /assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true @@ -3775,6 +3784,10 @@ packages: react-is: 18.2.0 dev: true + /printable-characters@1.0.42: + resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + dev: false + /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} diff --git a/src/app.mts b/src/app.mts index ccc34b2..83dcf73 100644 --- a/src/app.mts +++ b/src/app.mts @@ -1,70 +1,85 @@ -import { promises as fs } from 'node:fs'; +import './perf-suites/measureAnonymous.mjs'; +import './perf-suites/measureMap.mjs'; +import './perf-suites/measureSearch.mjs'; + import { fileURLToPath } from 'node:url'; +import asTable from 'as-table'; import chalk from 'chalk'; import { Argument, Command, program as defaultCommand } from 'commander'; import * as path from 'path'; -import { measureAnonymous } from './measureAnonymous.mjs'; -import { measureMap } from './measureMap.mjs'; -import { measureSearch } from './measureSearch.mjs'; +import { getActiveSuites, PerfSuite } from './perfSuite.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -async function version(): Promise { - const pathPackageJson = path.join(__dirname, '../package.json'); - const packageJson = JSON.parse(await fs.readFile(pathPackageJson, 'utf8')); - return (typeof packageJson === 'object' && packageJson?.version) || '0.0.0'; -} - -const knownTests = { - search: measureSearch, - anonymous: measureAnonymous, - map: measureMap, -}; - -const allTests = { - search: measureSearch, - anonymous: measureAnonymous, - map: measureMap, - all: async (timeout?: number) => { - for (const test of Object.values(knownTests)) { - await test(timeout); - } - }, -}; - interface AppOptions { timeout?: number; + all?: boolean; } export async function app(program = defaultCommand): Promise { - const argument = new Argument('[test-methods...]', 'list of test methods to run'); - argument.choices(Object.keys(allTests)); - argument.default(['all']); + const suites = getActiveSuites(); + const setOfSuiteNames = new Set(suites.map((suite) => suite.name)); + const suitesNames = [...setOfSuiteNames, 'all']; + + const argument = new Argument('[test-suite...]', 'list of test suites to run'); + argument.choices(suitesNames); argument.variadic = true; program .name('perf runner') .addArgument(argument) .description('Run performance tests.') + .option('-a, --all', 'run all tests', false) .option('-t, --timeout ', 'timeout for each test', (v) => Number(v), 1000) - .version(await version()) - .action(async (methods: string[], options: AppOptions) => { + .action(async (suiteNamesToRun: string[], options: AppOptions) => { // console.log('Options: %o', optionsCli); const timeout = options.timeout || 1000; - const tests = Object.entries(allTests); - for (const method of methods) { - const test = tests.find(([key]) => key === method); - if (!test) { - console.log(chalk.red(`Unknown test method: ${method}`)); - continue; + const suitesRun = new Set(); + + async function _runSuite(suites: PerfSuite[]) { + for (const suite of suites) { + if (suitesRun.has(suite)) continue; + suitesRun.add(suite); + console.log(chalk.green(`Running Perf Suite: ${suite.name}`)); + await suite.setTimeout(timeout).runTests(); + } + } + + async function runSuite(name: string) { + if (name === 'all') { + await _runSuite(suites); + return; } - const [key, fn] = test; - console.log(chalk.green(`Running test: ${key}`)); - await fn(timeout); + const matching = suites.filter((suite) => suite.name === name); + if (!matching.length) { + console.log(chalk.red(`Unknown test method: ${name}`)); + return; + } + await _runSuite(matching); + } + + for (const name of suiteNamesToRun) { + await runSuite(name); + } + + if (!suitesRun.size) { + console.log(chalk.red('No suites to run.')); + console.log(chalk.yellow('Available suites:')); + const width = process.stdout.columns || 80; + const table = asTable.configure({ maxTotalWidth: width - 2 })( + suites.map((suite) => ({ Suite: suite.name, Description: suite.description })), + ); + console.log( + table + .split('\n') + .map((line) => ` ${line}`) + .join('\n'), + ); } + console.log(chalk.green('done.')); }); diff --git a/src/SimpleTrie.mts b/src/lib/SimpleTrie.mts similarity index 100% rename from src/SimpleTrie.mts rename to src/lib/SimpleTrie.mts diff --git a/src/Trie.mts b/src/lib/Trie.mts similarity index 100% rename from src/Trie.mts rename to src/lib/Trie.mts diff --git a/src/measureAnonymous.mts b/src/measureAnonymous.mts deleted file mode 100644 index 6747a09..0000000 --- a/src/measureAnonymous.mts +++ /dev/null @@ -1,32 +0,0 @@ -import { loremIpsum } from 'lorem-ipsum'; - -import { runTests } from './runner.mjs'; - -export async function measureAnonymous(defaultTimeout = 2000) { - const knownWords = loremIpsum({ count: 10000, units: 'words' }).split(' '); - - await runTests('Measure Anonymous', async ({ test, setTimeout }) => { - setTimeout(defaultTimeout); - - // test('baseline', () => {}); - test('()=>{}', () => { - knownWords.forEach(() => {}); - }); - - test('()=>undefined', () => { - knownWords.forEach(() => undefined); - }); - - function getFn() { - return () => undefined; - } - - test('(getFn)', () => { - knownWords.forEach(getFn); - }); - - test('() => getFn()', () => { - knownWords.forEach(() => getFn()()); - }); - }); -} diff --git a/src/measureMap.mts b/src/measureMap.mts deleted file mode 100644 index ce72bff..0000000 --- a/src/measureMap.mts +++ /dev/null @@ -1,110 +0,0 @@ -import { loremIpsum } from 'lorem-ipsum'; - -import { runTests } from './runner.mjs'; - -export async function measureMap(defaultTimeout = 2000) { - const knownWords = loremIpsum({ count: 10000, units: 'words' }).split(' '); - - await runTests('Map Anonymous', async ({ test, setTimeout }) => { - setTimeout(defaultTimeout); - - // test('baseline', () => {}); - test('(a) => a.length', () => { - return knownWords.map((a) => a.length); - }); - - test('filter Boolean', () => { - return knownWords.filter(Boolean); - }); - - test('filter (a) => a', () => { - return knownWords.filter((a) => a); - }); - - test('filter (a) => !!a', () => { - return knownWords.filter((a) => !!a); - }); - - test('(a) => { return a.length; }', () => { - return knownWords.map((a) => { - return a.length; - }); - }); - - function fnLen(a: string) { - return a.length; - } - - test('(fnLen)', () => { - return knownWords.map(fnLen); - }); - - test('(a) => fnLen(a)', () => { - return knownWords.map((a) => fnLen(a)); - }); - - const vfLen = (a: string) => a.length; - - test('(vfLen)', () => { - return knownWords.map(vfLen); - }); - - test('for of', () => { - const result: number[] = []; - for (const a of knownWords) { - result.push(a.length); - } - return result; - }); - - test('for i', () => { - const result: number[] = []; - const words = knownWords; - const len = words.length; - for (let i = 0; i < len; i++) { - result.push(words[i].length); - } - return result; - }); - - test('for i r[i]=v', () => { - const words = knownWords; - const result: number[] = []; - const len = words.length; - for (let i = 0; i < len; i++) { - result[i] = words[i].length; - } - return result; - }); - - test('for i Array.from(words)', () => { - const words = knownWords; - const result: number[] = Array.from(words) as unknown as number[]; - const len = words.length; - for (let i = 0; i < len; i++) { - result[i] = words[i].length; - } - return result; - }); - - test('for i Array.from', () => { - const words = knownWords; - const result: number[] = Array.from({ length: words.length }); - const len = words.length; - for (let i = 0; i < len; i++) { - result[i] = words[i].length; - } - return result; - }); - - test('for i Array(size)', () => { - const words = knownWords; - const result: number[] = new Array(words.length); - const len = words.length; - for (let i = 0; i < len; i++) { - result[i] = words[i].length; - } - return result; - }); - }); -} diff --git a/src/measureSearch.mts b/src/measureSearch.mts deleted file mode 100644 index cdd7db8..0000000 --- a/src/measureSearch.mts +++ /dev/null @@ -1,201 +0,0 @@ -import { loremIpsum } from 'lorem-ipsum'; - -import { runTests } from './runner.mjs'; -import { SimpleTrie } from './SimpleTrie.mjs'; -import { Trie } from './Trie.mjs'; - -const numWords = 10000; - -export async function measureSearch(defaultTimeout = 1000) { - const knownWords = [...new Set(loremIpsum({ count: 1000, units: 'words' }).split(' '))]; - const wordsToSearch = loremIpsum({ count: numWords, units: 'words' }).split(' '); - - const termNumber = [5, 10, 20, 30]; - - for (const numTerms of termNumber) { - await runTests( - `Search Dictionary, Size of dictionary: ${numTerms}, Number of words: ${wordsToSearch.length}, timeout: ${defaultTimeout}ms`, - async (context) => { - const test = context.test; - context.timeout = defaultTimeout; - - // test('lorem-ipsum words', () => { - // loremIpsum({ count: 1000, units: 'words' }); - // }); - // test('lorem-ipsum sentences', () => { - // loremIpsum({ count: 100, units: 'sentences' }); - // }); - // test('lorem-ipsum sentences', () => { - // loremIpsum({ count: 30, units: 'paragraphs' }); - // }); - - // test('lorem-ipsum word x 1000', () => { - // for (let i = 0; i < 1000; i++) { - // loremIpsum({ count: 1, units: 'words' }); - // } - // // throw new Error('test error'); - // }); - - const words = wordsToSearch; - const searchTerms = knownWords.slice(0, numTerms); - const searchObjMap = Object.fromEntries(searchTerms.map((term) => [term, true])); - const searchSet = new Set(searchTerms); - - test('search `searchTerms.includes`', () => { - const terms = searchTerms; - return words.filter((word) => terms.includes(word)); - }); - - test('search `searchTerms.includes` for', () => { - const terms = searchTerms; - const result: string[] = []; - for (const word of words) { - if (terms.includes(word)) { - result.push(word); - } - } - return result; - }); - - test('search `word in searchObjMap`', () => { - return words.filter((word) => word in searchObjMap); - }); - - test('search `word in searchObjMap` local', () => { - const map = searchObjMap; - return words.filter((word) => word in map); - }); - - test('search `word in searchObjMap` for', () => { - const map = searchObjMap; - const result: string[] = []; - for (const word of words) { - if (word in map) { - result.push(word); - } - } - return result; - }); - - test('search `searchSet.has`', () => { - return words.filter((word) => searchSet.has(word)); - }); - - test('search `searchSet.has` local', () => { - const s = searchSet; - return words.filter((word) => s.has(word)); - }); - - test('search `searchSet.has` for', () => { - const s = searchSet; - const result: string[] = []; - for (const word of words) { - if (s.has(word)) { - result.push(word); - } - } - return result; - }); - - test('search `searchSet.has` for i', () => { - const s = searchSet; - const result: string[] = []; - const w = words; - const len = w.length; - for (let i = 0, c = 0; i < len; i++) { - const word = w[i]; - if (s.has(word)) { - result[c++] = word; - } - } - return result; - }); - - test('search `searchSet.has` for i push', () => { - const s = searchSet; - const result: string[] = []; - const w = words; - const len = w.length; - for (let i = 0; i < len; i++) { - const word = w[i]; - if (s.has(word)) { - result.push(word); - } - } - return result; - }); - - test('search `searchSet.has` for i alloc', () => { - let c = 0; - const s = searchSet; - const result: string[] = new Array(words.length); - const w = words; - const len = w.length; - for (let i = 0; i < len; i++) { - const word = w[i]; - if (s.has(word)) { - result[c++] = word; - } - } - result.length = c; - return result; - }); - - const hasWords = new RegExp(`^${searchTerms.map((w) => w.replaceAll(/\W/g, '')).join('|')}$`); - - test('search regexp', () => { - const re = hasWords; - return words.filter((word) => re.test(word)); - }); - - test('search regexp for', () => { - const re = hasWords; - const result: string[] = []; - for (const word of words) { - if (re.test(word)) { - result.push(word); - } - } - return result; - }); - - const sTrie = new SimpleTrie().addWords(searchTerms); - const trie = new Trie().addWords(searchTerms); - - test('search trie', () => { - const t = trie; - return words.filter((word) => t.has(word)); - }); - - test('search trie for', () => { - const t = trie; - const result: string[] = []; - for (const word of words) { - if (t.has(word)) { - result.push(word); - } - } - return result; - }); - - test('search sTrie', () => { - const t = sTrie; - return words.filter((word) => t.has(word)); - }); - - test('search sTrie for', () => { - const t = sTrie; - const result: string[] = []; - for (const word of words) { - if (t.has(word)) { - result.push(word); - } - } - return result; - }); - }, - ); - } - - // console.log('%o', result); -} diff --git a/src/measureSearchLarge.mts b/src/measureSearchLarge.mts deleted file mode 100644 index cdd7db8..0000000 --- a/src/measureSearchLarge.mts +++ /dev/null @@ -1,201 +0,0 @@ -import { loremIpsum } from 'lorem-ipsum'; - -import { runTests } from './runner.mjs'; -import { SimpleTrie } from './SimpleTrie.mjs'; -import { Trie } from './Trie.mjs'; - -const numWords = 10000; - -export async function measureSearch(defaultTimeout = 1000) { - const knownWords = [...new Set(loremIpsum({ count: 1000, units: 'words' }).split(' '))]; - const wordsToSearch = loremIpsum({ count: numWords, units: 'words' }).split(' '); - - const termNumber = [5, 10, 20, 30]; - - for (const numTerms of termNumber) { - await runTests( - `Search Dictionary, Size of dictionary: ${numTerms}, Number of words: ${wordsToSearch.length}, timeout: ${defaultTimeout}ms`, - async (context) => { - const test = context.test; - context.timeout = defaultTimeout; - - // test('lorem-ipsum words', () => { - // loremIpsum({ count: 1000, units: 'words' }); - // }); - // test('lorem-ipsum sentences', () => { - // loremIpsum({ count: 100, units: 'sentences' }); - // }); - // test('lorem-ipsum sentences', () => { - // loremIpsum({ count: 30, units: 'paragraphs' }); - // }); - - // test('lorem-ipsum word x 1000', () => { - // for (let i = 0; i < 1000; i++) { - // loremIpsum({ count: 1, units: 'words' }); - // } - // // throw new Error('test error'); - // }); - - const words = wordsToSearch; - const searchTerms = knownWords.slice(0, numTerms); - const searchObjMap = Object.fromEntries(searchTerms.map((term) => [term, true])); - const searchSet = new Set(searchTerms); - - test('search `searchTerms.includes`', () => { - const terms = searchTerms; - return words.filter((word) => terms.includes(word)); - }); - - test('search `searchTerms.includes` for', () => { - const terms = searchTerms; - const result: string[] = []; - for (const word of words) { - if (terms.includes(word)) { - result.push(word); - } - } - return result; - }); - - test('search `word in searchObjMap`', () => { - return words.filter((word) => word in searchObjMap); - }); - - test('search `word in searchObjMap` local', () => { - const map = searchObjMap; - return words.filter((word) => word in map); - }); - - test('search `word in searchObjMap` for', () => { - const map = searchObjMap; - const result: string[] = []; - for (const word of words) { - if (word in map) { - result.push(word); - } - } - return result; - }); - - test('search `searchSet.has`', () => { - return words.filter((word) => searchSet.has(word)); - }); - - test('search `searchSet.has` local', () => { - const s = searchSet; - return words.filter((word) => s.has(word)); - }); - - test('search `searchSet.has` for', () => { - const s = searchSet; - const result: string[] = []; - for (const word of words) { - if (s.has(word)) { - result.push(word); - } - } - return result; - }); - - test('search `searchSet.has` for i', () => { - const s = searchSet; - const result: string[] = []; - const w = words; - const len = w.length; - for (let i = 0, c = 0; i < len; i++) { - const word = w[i]; - if (s.has(word)) { - result[c++] = word; - } - } - return result; - }); - - test('search `searchSet.has` for i push', () => { - const s = searchSet; - const result: string[] = []; - const w = words; - const len = w.length; - for (let i = 0; i < len; i++) { - const word = w[i]; - if (s.has(word)) { - result.push(word); - } - } - return result; - }); - - test('search `searchSet.has` for i alloc', () => { - let c = 0; - const s = searchSet; - const result: string[] = new Array(words.length); - const w = words; - const len = w.length; - for (let i = 0; i < len; i++) { - const word = w[i]; - if (s.has(word)) { - result[c++] = word; - } - } - result.length = c; - return result; - }); - - const hasWords = new RegExp(`^${searchTerms.map((w) => w.replaceAll(/\W/g, '')).join('|')}$`); - - test('search regexp', () => { - const re = hasWords; - return words.filter((word) => re.test(word)); - }); - - test('search regexp for', () => { - const re = hasWords; - const result: string[] = []; - for (const word of words) { - if (re.test(word)) { - result.push(word); - } - } - return result; - }); - - const sTrie = new SimpleTrie().addWords(searchTerms); - const trie = new Trie().addWords(searchTerms); - - test('search trie', () => { - const t = trie; - return words.filter((word) => t.has(word)); - }); - - test('search trie for', () => { - const t = trie; - const result: string[] = []; - for (const word of words) { - if (t.has(word)) { - result.push(word); - } - } - return result; - }); - - test('search sTrie', () => { - const t = sTrie; - return words.filter((word) => t.has(word)); - }); - - test('search sTrie for', () => { - const t = sTrie; - const result: string[] = []; - for (const word of words) { - if (t.has(word)) { - result.push(word); - } - } - return result; - }); - }, - ); - } - - // console.log('%o', result); -} diff --git a/src/perf-suites/measureAnonymous.mts b/src/perf-suites/measureAnonymous.mts new file mode 100644 index 0000000..602b5e6 --- /dev/null +++ b/src/perf-suites/measureAnonymous.mts @@ -0,0 +1,30 @@ +import { loremIpsum } from 'lorem-ipsum'; + +import { suite } from '../perfSuite.mjs'; + +const defaultTimeout = 2000; + +suite('anonymous', 'Measure the cost of creating an anonymous or arrow function', async (test) => { + const knownWords = loremIpsum({ count: 10000, units: 'words' }).split(' '); + + // test('baseline', () => {}); + test('forEach(()=>{})', () => { + knownWords.forEach(() => {}); + }); + + test('forEach(()=>undefined)', () => { + knownWords.forEach(() => undefined); + }); + + function getFn() { + return () => undefined; + } + + test('forEach(getFn)', () => { + knownWords.forEach(getFn); + }); + + test('forEach(() => getFn())', () => { + knownWords.forEach(() => getFn()()); + }); +}).setTimeout(defaultTimeout); diff --git a/src/perf-suites/measureMap.mts b/src/perf-suites/measureMap.mts new file mode 100644 index 0000000..0982fe6 --- /dev/null +++ b/src/perf-suites/measureMap.mts @@ -0,0 +1,108 @@ +import { loremIpsum } from 'lorem-ipsum'; + +import { suite } from '../perfSuite.mjs'; + +const defaultTimeout = 2000; + +suite('map', 'Measure .map and .filter performance with different functions', async (test) => { + const knownWords = loremIpsum({ count: 10000, units: 'words' }).split(' '); + + // test('baseline', () => {}); + test('(a) => a.length', () => { + return knownWords.map((a) => a.length); + }); + + test('filter Boolean', () => { + return knownWords.filter(Boolean); + }); + + test('filter (a) => a', () => { + return knownWords.filter((a) => a); + }); + + test('filter (a) => !!a', () => { + return knownWords.filter((a) => !!a); + }); + + test('(a) => { return a.length; }', () => { + return knownWords.map((a) => { + return a.length; + }); + }); + + function fnLen(a: string) { + return a.length; + } + + test('(fnLen)', () => { + return knownWords.map(fnLen); + }); + + test('(a) => fnLen(a)', () => { + return knownWords.map((a) => fnLen(a)); + }); + + const vfLen = (a: string) => a.length; + + test('(vfLen)', () => { + return knownWords.map(vfLen); + }); + + test('for of', () => { + const result: number[] = []; + for (const a of knownWords) { + result.push(a.length); + } + return result; + }); + + test('for i', () => { + const result: number[] = []; + const words = knownWords; + const len = words.length; + for (let i = 0; i < len; i++) { + result.push(words[i].length); + } + return result; + }); + + test('for i r[i]=v', () => { + const words = knownWords; + const result: number[] = []; + const len = words.length; + for (let i = 0; i < len; i++) { + result[i] = words[i].length; + } + return result; + }); + + test('for i Array.from(words)', () => { + const words = knownWords; + const result: number[] = Array.from(words) as unknown as number[]; + const len = words.length; + for (let i = 0; i < len; i++) { + result[i] = words[i].length; + } + return result; + }); + + test('for i Array.from', () => { + const words = knownWords; + const result: number[] = Array.from({ length: words.length }); + const len = words.length; + for (let i = 0; i < len; i++) { + result[i] = words[i].length; + } + return result; + }); + + test('for i Array(size)', () => { + const words = knownWords; + const result: number[] = new Array(words.length); + const len = words.length; + for (let i = 0; i < len; i++) { + result[i] = words[i].length; + } + return result; + }); +}).setTimeout(defaultTimeout); diff --git a/src/perf-suites/measureSearch.mts b/src/perf-suites/measureSearch.mts new file mode 100644 index 0000000..72805af --- /dev/null +++ b/src/perf-suites/measureSearch.mts @@ -0,0 +1,205 @@ +import { loremIpsum } from 'lorem-ipsum'; + +import { suite } from '../perfSuite.mjs'; +import { SimpleTrie } from '../lib/SimpleTrie.mjs'; +import { Trie } from '../lib/Trie.mjs'; + +const numWords = 10000; + +const termNumber = [5, 10, 20, 30]; + +const defaultTimeout = 1000; + +let data: { knownWords: string[]; wordsToSearch: string[] } | undefined; + +function getData() { + if (data) return data; + const knownWords = [...new Set(loremIpsum({ count: 1000, units: 'words' }).split(' '))]; + const wordsToSearch = loremIpsum({ count: numWords, units: 'words' }).split(' '); + + data = { knownWords, wordsToSearch }; + return data; +} + +for (const numTerms of termNumber) { + suite(`search`, `Search Dictionary, Size of dictionary: ${numTerms}`, async (test) => { + const { wordsToSearch, knownWords } = getData(); + + // test('lorem-ipsum words', () => { + // loremIpsum({ count: 1000, units: 'words' }); + // }); + // test('lorem-ipsum sentences', () => { + // loremIpsum({ count: 100, units: 'sentences' }); + // }); + // test('lorem-ipsum sentences', () => { + // loremIpsum({ count: 30, units: 'paragraphs' }); + // }); + + // test('lorem-ipsum word x 1000', () => { + // for (let i = 0; i < 1000; i++) { + // loremIpsum({ count: 1, units: 'words' }); + // } + // // throw new Error('test error'); + // }); + + const words = wordsToSearch; + const searchTerms = knownWords.slice(0, numTerms); + const searchObjMap = Object.fromEntries(searchTerms.map((term) => [term, true])); + const searchSet = new Set(searchTerms); + + test('search `searchTerms.includes`', () => { + const terms = searchTerms; + return words.filter((word) => terms.includes(word)); + }); + + test('search `searchTerms.includes` for', () => { + const terms = searchTerms; + const result: string[] = []; + for (const word of words) { + if (terms.includes(word)) { + result.push(word); + } + } + return result; + }); + + test('search `word in searchObjMap`', () => { + return words.filter((word) => word in searchObjMap); + }); + + test('search `word in searchObjMap` local', () => { + const map = searchObjMap; + return words.filter((word) => word in map); + }); + + test('search `word in searchObjMap` for', () => { + const map = searchObjMap; + const result: string[] = []; + for (const word of words) { + if (word in map) { + result.push(word); + } + } + return result; + }); + + test('search `searchSet.has`', () => { + return words.filter((word) => searchSet.has(word)); + }); + + test('search `searchSet.has` local', () => { + const s = searchSet; + return words.filter((word) => s.has(word)); + }); + + test('search `searchSet.has` for', () => { + const s = searchSet; + const result: string[] = []; + for (const word of words) { + if (s.has(word)) { + result.push(word); + } + } + return result; + }); + + test('search `searchSet.has` for i', () => { + const s = searchSet; + const result: string[] = []; + const w = words; + const len = w.length; + for (let i = 0, c = 0; i < len; i++) { + const word = w[i]; + if (s.has(word)) { + result[c++] = word; + } + } + return result; + }); + + test('search `searchSet.has` for i push', () => { + const s = searchSet; + const result: string[] = []; + const w = words; + const len = w.length; + for (let i = 0; i < len; i++) { + const word = w[i]; + if (s.has(word)) { + result.push(word); + } + } + return result; + }); + + test('search `searchSet.has` for i alloc', () => { + let c = 0; + const s = searchSet; + const result: string[] = new Array(words.length); + const w = words; + const len = w.length; + for (let i = 0; i < len; i++) { + const word = w[i]; + if (s.has(word)) { + result[c++] = word; + } + } + result.length = c; + return result; + }); + + const hasWords = new RegExp(`^${searchTerms.map((w) => w.replaceAll(/\W/g, '')).join('|')}$`); + + test('search regexp', () => { + const re = hasWords; + return words.filter((word) => re.test(word)); + }); + + test('search regexp for', () => { + const re = hasWords; + const result: string[] = []; + for (const word of words) { + if (re.test(word)) { + result.push(word); + } + } + return result; + }); + + const sTrie = new SimpleTrie().addWords(searchTerms); + const trie = new Trie().addWords(searchTerms); + + test('search trie', () => { + const t = trie; + return words.filter((word) => t.has(word)); + }); + + test('search trie for', () => { + const t = trie; + const result: string[] = []; + for (const word of words) { + if (t.has(word)) { + result.push(word); + } + } + return result; + }); + + test('search sTrie', () => { + const t = sTrie; + return words.filter((word) => t.has(word)); + }); + + test('search sTrie for', () => { + const t = sTrie; + const result: string[] = []; + for (const word of words) { + if (t.has(word)) { + result.push(word); + } + } + return result; + }); + }).setTimeout(defaultTimeout); + + // console.log('%o', result); +} diff --git a/src/perf-suites/measureSearchLarge.mts b/src/perf-suites/measureSearchLarge.mts new file mode 100644 index 0000000..fd01d2c --- /dev/null +++ b/src/perf-suites/measureSearchLarge.mts @@ -0,0 +1,184 @@ +import { loremIpsum } from 'lorem-ipsum'; + +import { suite } from '../perfSuite.mjs'; +import { SimpleTrie } from '../lib/SimpleTrie.mjs'; +import { Trie } from '../lib/Trie.mjs'; + +const numWords = 10000; + +const defaultTimeout = 1000; + +const numTerms = 100; + +let data: { knownWords: string[]; wordsToSearch: string[] } | undefined; + +suite(`search-dictionary-large}`, `Search a Large Dictionary`, async (test) => { + const { wordsToSearch, knownWords } = getData(); + + const words = wordsToSearch; + const searchTerms = knownWords.slice(0, numTerms); + const searchObjMap = Object.fromEntries(searchTerms.map((term) => [term, true])); + const searchSet = new Set(searchTerms); + + test('search `searchTerms.includes`', () => { + const terms = searchTerms; + return words.filter((word) => terms.includes(word)); + }); + + test('search `searchTerms.includes` for', () => { + const terms = searchTerms; + const result: string[] = []; + for (const word of words) { + if (terms.includes(word)) { + result.push(word); + } + } + return result; + }); + + test('search `word in searchObjMap`', () => { + return words.filter((word) => word in searchObjMap); + }); + + test('search `word in searchObjMap` local', () => { + const map = searchObjMap; + return words.filter((word) => word in map); + }); + + test('search `word in searchObjMap` for', () => { + const map = searchObjMap; + const result: string[] = []; + for (const word of words) { + if (word in map) { + result.push(word); + } + } + return result; + }); + + test('search `searchSet.has`', () => { + return words.filter((word) => searchSet.has(word)); + }); + + test('search `searchSet.has` local', () => { + const s = searchSet; + return words.filter((word) => s.has(word)); + }); + + test('search `searchSet.has` for', () => { + const s = searchSet; + const result: string[] = []; + for (const word of words) { + if (s.has(word)) { + result.push(word); + } + } + return result; + }); + + test('search `searchSet.has` for i', () => { + const s = searchSet; + const result: string[] = []; + const w = words; + const len = w.length; + for (let i = 0, c = 0; i < len; i++) { + const word = w[i]; + if (s.has(word)) { + result[c++] = word; + } + } + return result; + }); + + test('search `searchSet.has` for i push', () => { + const s = searchSet; + const result: string[] = []; + const w = words; + const len = w.length; + for (let i = 0; i < len; i++) { + const word = w[i]; + if (s.has(word)) { + result.push(word); + } + } + return result; + }); + + test('search `searchSet.has` for i alloc', () => { + let c = 0; + const s = searchSet; + const result: string[] = new Array(words.length); + const w = words; + const len = w.length; + for (let i = 0; i < len; i++) { + const word = w[i]; + if (s.has(word)) { + result[c++] = word; + } + } + result.length = c; + return result; + }); + + const hasWords = new RegExp(`^${searchTerms.map((w) => w.replaceAll(/\W/g, '')).join('|')}$`); + + test('search regexp', () => { + const re = hasWords; + return words.filter((word) => re.test(word)); + }); + + test('search regexp for', () => { + const re = hasWords; + const result: string[] = []; + for (const word of words) { + if (re.test(word)) { + result.push(word); + } + } + return result; + }); + + const sTrie = new SimpleTrie().addWords(searchTerms); + const trie = new Trie().addWords(searchTerms); + + test('search trie', () => { + const t = trie; + return words.filter((word) => t.has(word)); + }); + + test('search trie for', () => { + const t = trie; + const result: string[] = []; + for (const word of words) { + if (t.has(word)) { + result.push(word); + } + } + return result; + }); + + test('search sTrie', () => { + const t = sTrie; + return words.filter((word) => t.has(word)); + }); + + test('search sTrie for', () => { + const t = sTrie; + const result: string[] = []; + for (const word of words) { + if (t.has(word)) { + result.push(word); + } + } + return result; + }); +}).setTimeout(defaultTimeout); + +function getData() { + if (data) return data; + const knownWords = [...new Set(loremIpsum({ count: 1000, units: 'words' }).split(' '))]; + const wordsToSearch = loremIpsum({ count: numWords, units: 'words' }).split(' '); + + data = { knownWords, wordsToSearch }; + return data; +} diff --git a/src/runner.mts b/src/perfSuite.mts similarity index 56% rename from src/runner.mts rename to src/perfSuite.mts index d9d45f6..1acfb23 100644 --- a/src/runner.mts +++ b/src/perfSuite.mts @@ -1,14 +1,36 @@ +import assert from 'node:assert'; + import type { Ora } from 'ora'; import ora from 'ora'; import { createRunningStdDev, RunningStdDev } from './sd.mjs'; -export type testFn = (name: string, method: () => void, timeout?: number) => void; -export type testAsyncFn = (name: string, method: () => void | Promise, timeout?: number) => void; +export type PerfTestFn = (name: string, method: () => unknown | Promise, timeout?: number) => void; + +export type UserFn = () => unknown | Promise; export interface RunnerContext { - test: testFn; - testAsync: testAsyncFn; + test: PerfTestFn; + /** + * Register a function to be called after all tests have been run to allow for cleanup. + * @param fn - The function to run after all tests have been run. + */ + afterAll: (fn: UserFn) => void; + /** + * Register a function to be called after each test has been run to allow for cleanup. + * @param fn - The function to run after each test. + */ + afterEach: (fn: UserFn) => void; + /** + * Register a function to be called before all tests have been run to allow for setup. + * @param fn - The function to run before all tests. + */ + beforeAll: (fn: UserFn) => void; + /** + * Register a function to be called before each test has been run to allow for setup. + * @param fn - The function to run before all tests. + */ + beforeEach: (fn: UserFn) => void; timeout: number; setTimeout: (timeoutMs: number) => void; } @@ -40,7 +62,8 @@ export interface TestResult { } export interface RunnerResult { - description: string; + name: string; + description: string | undefined; results: TestResult[]; } @@ -53,7 +76,6 @@ interface TestDef { interface TestDefinitionSync extends TestDef { method: () => void; - isAsync: false; } interface TestDefinitionAsync extends TestDef { @@ -72,16 +94,118 @@ interface ProgressReporting { spinner?: Ora; } -const defaultTime = 10_000; +export type SuiteFn = (test: PerfTestFn, context: RunnerContext) => void | Promise; + +const defaultTime = 500; // 1/2 second + +const activeSuites = new Set(); + +function registerSuite(suite: PerfSuite): void { + activeSuites.add(suite); +} + +export function getActiveSuites(): PerfSuite[] { + return Array.from(activeSuites); +} + +export interface PerfSuite { + readonly name: string; + readonly description?: string | undefined; + readonly runTests: () => Promise; + /** + * Sets the default timeout for all tests in the suite. + * @param timeout - time in milliseconds. + * @returns PerfSuite + */ + readonly setTimeout: (timeout: number) => this; +} + +export function suite(name: string, suiteFn: SuiteFn): PerfSuite; +export function suite(name: string, description: string | undefined, suiteFn: SuiteFn): PerfSuite; +export function suite(name: string, description: string, suiteFn: SuiteFn): PerfSuite; +export function suite(name: string, p2: string | undefined | SuiteFn, p3?: SuiteFn): PerfSuite { + const description = typeof p2 === 'string' ? p2 : undefined; + const suiteFn = typeof p2 === 'function' ? p2 : p3; + assert(suiteFn, 'suiteFn must be a function'); + + return new PerfSuiteImpl(name, description, suiteFn); +} + +export function runSuite(suite: PerfSuite): Promise; +export function runSuite(name: string, description: string | undefined, suiteFn: SuiteFn): Promise; +export function runSuite(name: string, suiteFn: SuiteFn): Promise; +export function runSuite(suiteOrName: PerfSuite | string, p2?: string | SuiteFn, p3?: SuiteFn): Promise { + if (typeof suiteOrName === 'object') { + return suiteOrName.runTests(); + } + + if (typeof p2 === 'function') { + return suite(suiteOrName, p2).runTests(); + } + + assert(typeof p3 === 'function', 'suiteFn must be a function'); + return suite(suiteOrName, p2, p3).runTests(); +} + +function toError(e: unknown): Error { + return e instanceof Error ? e : new Error(String(e)); +} + +function formatResult(result: TestResult, nameWidth: number): string { + const { name, duration, iterations, sd } = result; + + const min = sd.min; + const max = sd.max; + const p95 = sd.ok ? sd.p95 : NaN; + const mean = sd.ok ? sd.mean : NaN; + const ops = (iterations * 1000) / duration; + + if (result.error) { + return `${name.padEnd(nameWidth)}: ${duration.toFixed(2)}ms\n` + `Error: ${result.error}`; + } -export async function runTests( - description: string, - testWrapperFn: (context: RunnerContext) => void | Promise, - progress?: ProgressReporting, -): Promise { + const msg = + `${name.padEnd(nameWidth)}: ` + + `ops: ${ops.toFixed(2).padStart(8)} ` + + `cnt: ${iterations.toFixed(0).padStart(6)} ` + + `mean: ${mean.toPrecision(5).padStart(8)} ` + + `p95: ${p95.toPrecision(5).padStart(8)} ` + + `min/max: ${min.toPrecision(5).padStart(8)}/${max.toPrecision(5).padStart(8)} ` + + `${duration.toFixed(2).padStart(7)}ms `; + + return msg; +} + +// function wait(time: number): Promise { +// return new Promise((resolve) => setTimeout(resolve, time)); +// } + +class PerfSuiteImpl implements PerfSuite { + timeout: number = defaultTime; + + constructor( + public readonly name: string, + public readonly description: string | undefined, + public readonly suiteFn: SuiteFn, + ) { + registerSuite(this); + } + + runTests(): Promise { + return runTests(this); + } + + setTimeout(timeout: number): this { + this.timeout = timeout; + return this; + } +} + +async function runTests(suite: PerfSuiteImpl, progress?: ProgressReporting): Promise { const stdout = progress?.stdout || process.stdout; - const spinner = progress?.spinner || ora({ stream: stdout }); + const spinner = progress?.spinner || ora({ stream: stdout, discardStdin: false, hideCursor: false }); const log = (msg: string) => stdout.write(msg + '\n'); + const { name, description } = suite; let nameWidth = 0; @@ -118,8 +242,11 @@ export async function runTests( const context: RunnerContext = { test, - testAsync, - timeout: defaultTime, + beforeAll, + beforeEach, + afterAll, + afterEach, + timeout: suite.timeout || defaultTime, setTimeout: (timeout: number) => { context.timeout = timeout; }, @@ -127,13 +254,29 @@ export async function runTests( const tests: TestDefinition[] = []; const results: TestResult[] = []; + const beforeEachFns: UserFn[] = []; + const afterEachFns: UserFn[] = []; + const beforeAllFns: UserFn[] = []; + const afterAllFns: UserFn[] = []; function test(name: string, method: () => void, timeout?: number): void { tests.push({ name, method, timeout: timeout ?? context.timeout, isAsync: false }); } - function testAsync(name: string, method: () => void | Promise, timeout?: number): void { - tests.push({ name, method, timeout: timeout ?? context.timeout, isAsync: true }); + function beforeEach(fn: UserFn) { + beforeEachFns.push(fn); + } + + function afterEach(fn: UserFn) { + afterEachFns.push(fn); + } + + function beforeAll(fn: UserFn) { + beforeAllFns.push(fn); + } + + function afterAll(fn: UserFn) { + afterAllFns.push(fn); } async function runTestAsync(test: TestDefinition): Promise { @@ -161,6 +304,7 @@ export async function runTests( try { while (performance.now() - startTime < test.timeout && !error) { + await runBeforeEach(); const startTime = performance.now(); let delta: number; try { @@ -170,6 +314,7 @@ export async function runTests( error = toError(e); break; } + await runAfterEach(); duration += delta; const now = performance.now(); if (now > nextSd) { @@ -210,52 +355,49 @@ export async function runTests( return result; } - await testWrapperFn(context); + async function runBeforeAll() { + for (const fn of beforeAllFns) { + await fn(); + } + } + + async function runAfterAll() { + for (const fn of afterAllFns) { + await fn(); + } + } + + async function runBeforeEach() { + for (const fn of beforeEachFns) { + await fn(); + } + } + + async function runAfterEach() { + for (const fn of afterEachFns) { + await fn(); + } + } + + await suite.suiteFn(context.test, context); nameWidth = Math.max(...tests.map((t) => t.name.length)); - log(`Running: ${description}:`); + // log(`Running: ${name}:`); + if (description) { + log(description); + } + await runBeforeAll(); for (const test of tests) { reportTestStart(test.name); const result = await runTest(test); reportTestEnd(result); } + await runAfterAll(); return { + name, description, results, }; } - -function toError(e: unknown): Error { - return e instanceof Error ? e : new Error(String(e)); -} - -function formatResult(result: TestResult, nameWidth: number): string { - const { name, duration, iterations, sd } = result; - - const min = sd.min; - const max = sd.max; - const p95 = sd.ok ? sd.p95 : NaN; - const mean = sd.ok ? sd.mean : NaN; - const ops = (iterations * 1000) / duration; - - if (result.error) { - return `${name.padEnd(nameWidth)}: ${duration.toFixed(2)}ms\n` + `Error: ${result.error}`; - } - - const msg = - `${name.padEnd(nameWidth)}: ` + - `ops: ${ops.toFixed(2).padStart(8)} ` + - `cnt: ${iterations.toFixed(0).padStart(6)} ` + - `mean: ${mean.toPrecision(5).padStart(8)} ` + - `p95: ${p95.toPrecision(5).padStart(8)} ` + - `min/max: ${min.toPrecision(5).padStart(8)}/${max.toPrecision(5).padStart(8)} ` + - `${duration.toFixed(2).padStart(7)}ms `; - - return msg; -} - -// function wait(time: number): Promise { -// return new Promise((resolve) => setTimeout(resolve, time)); -// }