diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000000..38275dbea4 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,40 @@ +name: Benchmarks + +on: + push: + branches: + - master + pull_request: + paths: + - 'packages/loot-core/**' + - 'packages/crdt/**' + - 'vitest.bench.config.mts' + - 'package.json' + - 'yarn.lock' + - '.github/workflows/benchmark.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +jobs: + benchmark: + runs-on: depot-ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up environment + uses: ./.github/actions/setup + with: + download-translations: 'false' + - name: Run benchmarks + uses: CodSpeedHQ/action@1aa3d227c0eaa4157abb72f64750a032cac12a9b # v4.8.2 + with: + run: yarn bench + token: ${{ secrets.CODSPEED_TOKEN }} diff --git a/package.json b/package.json index 5e24e97cba..752796d434 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "generate:release-notes": "ts-node ./bin/release-note-generator.ts", "test": "lage test --continue", "test:debug": "lage test --no-cache --continue", + "bench": "vitest bench --run --config vitest.bench.config.mts", "e2e": "yarn workspace @actual-app/web run e2e", "e2e:desktop": "yarn build:desktop --skip-exe-build --skip-translations && yarn workspace desktop-electron e2e", "playwright": "yarn workspace @actual-app/web run playwright", @@ -64,6 +65,7 @@ "prepare": "husky" }, "devDependencies": { + "@codspeed/vitest-plugin": "^5.6.0", "@monorepo-utils/workspaces-to-typescript-project-references": "^2.11.0", "@octokit/rest": "^22.0.1", "@types/node": "^22.19.21", diff --git a/packages/crdt/src/crdt/merkle.bench.ts b/packages/crdt/src/crdt/merkle.bench.ts new file mode 100644 index 0000000000..fa9e1aca41 --- /dev/null +++ b/packages/crdt/src/crdt/merkle.bench.ts @@ -0,0 +1,46 @@ +import { bench, describe } from 'vitest'; + +import * as merkle from './merkle'; +import { Timestamp } from './timestamp'; + +const NODE = '0123456789abcdef'; +const MINUTE = 1000 * 60; +const BASE = Date.UTC(2020, 0, 1); + +function makeTimestamps(count: number, startMillis = BASE): Timestamp[] { + const timestamps: Timestamp[] = []; + for (let i = 0; i < count; i++) { + // Spread messages across time so the trie gains realistic depth. + timestamps.push(new Timestamp(startMillis + i * MINUTE, i % 100, NODE)); + } + return timestamps; +} + +const small = makeTimestamps(100); +const large = makeTimestamps(5000); +const largeTrie = merkle.build(large); + +// A second trie that diverges from `largeTrie` near the end, simulating two +// clients that have synced most of their history but differ on recent edits. +const diverged = merkle.build([ + ...large.slice(0, large.length - 10), + ...makeTimestamps(10, BASE + large.length * MINUTE * 2), +]); + +describe('merkle trie', () => { + bench('build trie from 100 messages', () => { + merkle.build(small); + }); + + bench('build trie from 5000 messages', () => { + merkle.build(large); + }); + + bench('diff two large tries', () => { + merkle.diff(largeTrie, diverged); + }); + + bench('prune large trie', () => { + merkle.prune(largeTrie); + }); +}); diff --git a/packages/loot-core/src/server/aql/compiler.bench.ts b/packages/loot-core/src/server/aql/compiler.bench.ts new file mode 100644 index 0000000000..417244574e --- /dev/null +++ b/packages/loot-core/src/server/aql/compiler.bench.ts @@ -0,0 +1,87 @@ +import { bench, describe } from 'vitest'; + +import { q } from '#shared/query'; + +import { generateSQLWithState } from './compiler'; + +const schema = { + transactions: { + id: { type: 'id' }, + date: { type: 'date' }, + amount: { type: 'integer' }, + notes: { type: 'string' }, + cleared: { type: 'boolean' }, + payee: { type: 'id', ref: 'payees' }, + category: { type: 'id', ref: 'categories' }, + account: { type: 'id', ref: 'accounts' }, + tombstone: { type: 'boolean' }, + }, + payees: { + id: { type: 'id' }, + name: { type: 'string' }, + tombstone: { type: 'boolean' }, + }, + categories: { + id: { type: 'id' }, + name: { type: 'string' }, + is_income: { type: 'boolean' }, + tombstone: { type: 'boolean' }, + }, + accounts: { + id: { type: 'id' }, + name: { type: 'string' }, + offbudget: { type: 'boolean' }, + tombstone: { type: 'boolean' }, + }, +}; + +const simpleQuery = q('transactions') + .filter({ account: 'abc' }) + .select(['date', 'amount', 'notes']) + .serialize(); + +const complexQuery = q('transactions') + .filter({ + $and: [ + { 'account.offbudget': false }, + { date: { $gte: '2023-01-01' } }, + { date: { $lte: '2023-12-31' } }, + { + $or: [ + { 'category.is_income': true }, + { amount: { $lt: 0 } }, + { 'payee.name': { $like: '%market%' } }, + ], + }, + ], + }) + .select([ + 'date', + 'amount', + 'notes', + 'payee.name', + 'category.name', + 'account.name', + ]) + .orderBy([{ date: 'desc' }, 'amount']) + .serialize(); + +const groupedQuery = q('transactions') + .filter({ 'account.offbudget': false }) + .groupBy(['category.name']) + .select(['category.name', { total: { $sum: '$amount' } }]) + .serialize(); + +describe('aql compiler', () => { + bench('compile simple query', () => { + generateSQLWithState(simpleQuery, schema); + }); + + bench('compile complex filtered/joined query', () => { + generateSQLWithState(complexQuery, schema); + }); + + bench('compile grouped aggregate query', () => { + generateSQLWithState(groupedQuery, schema); + }); +}); diff --git a/packages/loot-core/src/server/transactions/import/ofx2json.bench.ts b/packages/loot-core/src/server/transactions/import/ofx2json.bench.ts new file mode 100644 index 0000000000..2c5abb1af4 --- /dev/null +++ b/packages/loot-core/src/server/transactions/import/ofx2json.bench.ts @@ -0,0 +1,62 @@ +import { bench, describe } from 'vitest'; + +import { ofx2json } from './ofx2json'; + +function generateOfx(transactionCount: number): string { + const header = [ + 'OFXHEADER:100', + 'DATA:OFXSGML', + 'VERSION:102', + 'SECURITY:NONE', + 'ENCODING:USASCII', + 'CHARSET:1252', + 'COMPRESSION:NONE', + 'OLDFILEUID:NONE', + 'NEWFILEUID:NONE', + '', + ].join('\n'); + + const transactions: string[] = []; + for (let i = 0; i < transactionCount; i++) { + const day = String((i % 28) + 1).padStart(2, '0'); + transactions.push( + '', + 'DEBIT', + `201801${day}120000`, + `-${(i % 1000) + 0.99}`, + `${i}`, + `Payee Name ${i % 50}`, + `Memo for transaction ${i}`, + '', + ); + } + + return `${header} + + + + +USD + +20180101120000 +20181231120000 +${transactions.join('\n')} + + + + +`; +} + +const small = generateOfx(100); +const large = generateOfx(2000); + +describe('ofx2json', () => { + bench('parse 100 transactions', async () => { + await ofx2json(small); + }); + + bench('parse 2000 transactions', async () => { + await ofx2json(large); + }); +}); diff --git a/packages/loot-core/src/server/transactions/import/qif2json.bench.ts b/packages/loot-core/src/server/transactions/import/qif2json.bench.ts new file mode 100644 index 0000000000..4a2c560328 --- /dev/null +++ b/packages/loot-core/src/server/transactions/import/qif2json.bench.ts @@ -0,0 +1,34 @@ +import { bench, describe } from 'vitest'; + +import { qif2json } from './qif2json'; + +function generateQif(transactionCount: number): string { + const lines = ['!Type:Bank']; + for (let i = 0; i < transactionCount; i++) { + const day = (i % 28) + 1; + const month = (i % 12) + 1; + lines.push( + `D${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')}/18`, + `T-${(i % 1000) + 0.99}`, + `N${i}`, + `PPayee Name ${i % 50}`, + `MMemo for transaction ${i}`, + `LCategory:Subcategory${i % 20}`, + '^', + ); + } + return lines.join('\n'); +} + +const small = generateQif(100); +const large = generateQif(5000); + +describe('qif2json', () => { + bench('parse 100 transactions', () => { + qif2json(small, { dateFormat: 'MM/dd/yy' }); + }); + + bench('parse 5000 transactions', () => { + qif2json(large, { dateFormat: 'MM/dd/yy' }); + }); +}); diff --git a/packages/loot-core/src/server/transactions/import/xmlcamt2json.bench.ts b/packages/loot-core/src/server/transactions/import/xmlcamt2json.bench.ts new file mode 100644 index 0000000000..1265a584e9 --- /dev/null +++ b/packages/loot-core/src/server/transactions/import/xmlcamt2json.bench.ts @@ -0,0 +1,80 @@ +import { bench, describe } from 'vitest'; + +import { xmlCAMT2json } from './xmlcamt2json'; + +function generateEntry(i: number): string { + const day = String((i % 28) + 1).padStart(2, '0'); + const isDebit = i % 2 === 0; + return ` + + ${(i % 1000) + 0.6} + ${isDebit ? 'DBIT' : 'CRDT'} + BOOK + +
2014-01-${day}
+
+ +
2014-01-${day}
+
+ 2013123001153870${String(i).padStart(3, '0')} + + + + STZV-EtE-${i} + + + + Debtor ${i % 50} + + + Creditor ${i % 50} + + + + Payment reference for transaction ${i} + + + +
`; +} + +function generateCamt(transactionCount: number): string { + const entries: string[] = []; + for (let i = 0; i < transactionCount; i++) { + entries.push(generateEntry(i)); + } + return ` + + + + 053D2014 + 2014-01-03T22:01:36.0+01:00 + + + 0352C5320140103220142 + + + DE14740618130000033626 + + EUR + + ${entries.join('')} + + +`; +} + +const small = generateCamt(100); +const large = generateCamt(2000); + +describe('xmlCAMT2json', () => { + bench('parse 100 transactions', async () => { + await xmlCAMT2json(small); + }); + + bench('parse 2000 transactions', async () => { + await xmlCAMT2json(large); + }); +}); diff --git a/upcoming-release-notes/benchmark-codspeed.md b/upcoming-release-notes/benchmark-codspeed.md new file mode 100644 index 0000000000..93b53b6b73 --- /dev/null +++ b/upcoming-release-notes/benchmark-codspeed.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Add continuous benchmarking with vitest bench and CodSpeed to guard runtime performance of hot paths (CRDT merge, AQL query compilation, and OFX/QIF/CAMT import parsers) diff --git a/vitest.bench.config.mts b/vitest.bench.config.mts new file mode 100644 index 0000000000..ee4f9c1c9d --- /dev/null +++ b/vitest.bench.config.mts @@ -0,0 +1,23 @@ +import codspeedPlugin from '@codspeed/vitest-plugin'; +import { defineConfig } from 'vitest/config'; + +// Continuous benchmarking config. Benchmarks live next to the code they measure +// as `*.bench.ts` files and run with `yarn bench`. In CI the CodSpeed plugin +// instruments them so performance deltas can be reported on pull requests. +export default defineConfig({ + plugins: [codspeedPlugin()], + // Mirror loot-core's node test resolution so platform-specific (`#platform`, + // `#shared`, …) subpath imports resolve the same way they do under `yarn test`. + resolve: { + conditions: ['electron', 'module', 'browser', 'development'], + }, + ssr: { + resolve: { conditions: ['electron', 'module', 'node', 'development'] }, + }, + test: { + benchmark: { + include: ['packages/*/src/**/*.bench.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/lib-dist/**'], + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 1c218c1a8d..722e1f6e09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2019,6 +2019,32 @@ __metadata: languageName: node linkType: hard +"@codspeed/core@npm:^5.6.0": + version: 5.6.0 + resolution: "@codspeed/core@npm:5.6.0" + dependencies: + axios: "npm:^1.4.0" + find-up: "npm:^6.3.0" + form-data: "npm:^4.0.4" + node-gyp-build: "npm:^4.6.0" + stack-trace: "npm:1.0.0-pre2" + checksum: 10/36ef33a56a0f6dccf9ec19e420ed02ffdbd22d9e86c467a66d72f48b9eb75217ca0c91763cec13c0b27916bc843e9347a2afae800da556638d1ef240d4aee880 + languageName: node + linkType: hard + +"@codspeed/vitest-plugin@npm:^5.6.0": + version: 5.6.0 + resolution: "@codspeed/vitest-plugin@npm:5.6.0" + dependencies: + "@codspeed/core": "npm:^5.6.0" + peerDependencies: + tinybench: ">=2.9.0" + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vitest: ^3.2 || ^4 + checksum: 10/476cbea5f230fae5126f1b0f97fe844e43480781b709a77a10ca6d6e9e117ce6ca277da1b5a17c26b6c2cbcfceb1690f9bdca2e129e4fddec9f4911fe6c9ff95 + languageName: node + linkType: hard + "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -9409,6 +9435,7 @@ __metadata: version: 0.0.0-use.local resolution: "actual@workspace:." dependencies: + "@codspeed/vitest-plugin": "npm:^5.6.0" "@monorepo-utils/workspaces-to-typescript-project-references": "npm:^2.11.0" "@octokit/rest": "npm:^22.0.1" "@types/node": "npm:^22.19.21" @@ -10010,6 +10037,18 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.4.0": + version: 1.18.0 + resolution: "axios@npm:1.18.0" + dependencies: + follow-redirects: "npm:^1.16.0" + form-data: "npm:^4.0.5" + https-proxy-agent: "npm:^5.0.1" + proxy-from-env: "npm:^2.1.0" + checksum: 10/586a1a9534531c6ec4ee8fbab07a536ecaa8d29bd16b40ab0f9fce361fcd24da1538a54aadde0562f08f30b55bf80f9b7ededf1987e6506f722e1fb338b09d5d + languageName: node + linkType: hard + "b4a@npm:^1.6.4": version: 1.7.3 resolution: "b4a@npm:1.7.3" @@ -14950,7 +14989,7 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0, form-data@npm:^4.0.5": +"form-data@npm:^4.0.0, form-data@npm:^4.0.4, form-data@npm:^4.0.5": version: 4.0.6 resolution: "form-data@npm:4.0.6" dependencies: @@ -19784,7 +19823,7 @@ __metadata: languageName: node linkType: hard -"node-gyp-build@npm:^4.8.4": +"node-gyp-build@npm:^4.6.0, node-gyp-build@npm:^4.8.4": version: 4.8.4 resolution: "node-gyp-build@npm:4.8.4" bin: @@ -24870,6 +24909,13 @@ __metadata: languageName: node linkType: hard +"stack-trace@npm:1.0.0-pre2": + version: 1.0.0-pre2 + resolution: "stack-trace@npm:1.0.0-pre2" + checksum: 10/a64099f86acc01980b0a7fbc662f3233bf8626daf95c53e31c835b2252ae11fc3dbfe8f3e77a7f8310132dd488af2795057cd7db599de0c41a6fa99b16068273 + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2"