Skip to content

Commit 41d7c0b

Browse files
perf(utils): improve the performance of scoring and reporting (#212)
Improved the performance of scoring and reporting. Removed nested loops where possible, added dictionaries to go constant time O(1) instead of linear O(n). Made minor refactoring, added some checks. @BioPhoton As task owner, let me know if I understood the task correctly and implemented everything properly. Also, maybe you could propose additional changes for further performance improvements. Closes #132
1 parent ce4d975 commit 41d7c0b

File tree

7 files changed

+223
-95
lines changed

7 files changed

+223
-95
lines changed

packages/core/src/lib/implementation/persist.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ export async function persistReport(
3030
const { persist } = config;
3131
const outputDir = persist.outputDir;
3232
const filename = persist.filename;
33-
let { format } = persist;
34-
format = format && format.length !== 0 ? format : ['stdout'];
33+
const format =
34+
persist.format && persist.format.length !== 0 ? persist.format : ['stdout'];
3535
let scoredReport;
3636
if (format.includes('stdout')) {
3737
scoredReport = scoreReport(report);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
export function calculateScore(refs, scoreFn) {
2+
const { numerator, denominator } = refs.reduce(
3+
(acc, ref) => {
4+
const score = scoreFn(ref);
5+
return {
6+
numerator: acc.numerator + score * ref.weight,
7+
denominator: acc.denominator + ref.weight,
8+
};
9+
},
10+
{ numerator: 0, denominator: 0 },
11+
);
12+
return numerator / denominator;
13+
}
14+
15+
export function deepClone(obj) {
16+
if (obj == null || typeof obj !== 'object') {
17+
return obj;
18+
}
19+
20+
const cloned = Array.isArray(obj) ? [] : {};
21+
for (const key in obj) {
22+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
23+
cloned[key] = deepClone(obj[key]);
24+
}
25+
}
26+
return cloned;
27+
}
28+
29+
export function scoreReportOptimized3(report) {
30+
const scoredReport = deepClone(report);
31+
const allScoredAuditsAndGroups = new Map();
32+
33+
scoredReport.plugins.forEach(plugin => {
34+
const { audits } = plugin;
35+
const groups = plugin.groups || [];
36+
37+
audits.forEach(audit => {
38+
const key = `${plugin.slug}-${audit.slug}-audit`;
39+
audit.plugin = plugin.slug;
40+
allScoredAuditsAndGroups.set(key, audit);
41+
});
42+
43+
function groupScoreFn(ref) {
44+
const score = allScoredAuditsAndGroups.get(
45+
`${plugin.slug}-${ref.slug}-audit`,
46+
)?.score;
47+
if (score == null) {
48+
throw new Error(
49+
`Group has invalid ref - audit with slug ${plugin.slug}-${ref.slug}-audit not found`,
50+
);
51+
}
52+
return score;
53+
}
54+
55+
groups.forEach(group => {
56+
const key = `${plugin.slug}-${group.slug}-group`;
57+
group.score = calculateScore(group.refs, groupScoreFn);
58+
group.plugin = plugin.slug;
59+
allScoredAuditsAndGroups.set(key, group);
60+
});
61+
plugin.groups = groups;
62+
});
63+
64+
function catScoreFn(ref) {
65+
const key = `${ref.plugin}-${ref.slug}-${ref.type}`;
66+
const item = allScoredAuditsAndGroups.get(key);
67+
if (!item) {
68+
throw new Error(
69+
`Category has invalid ref - ${ref.type} with slug ${key} not found in ${ref.plugin} plugin`,
70+
);
71+
}
72+
return item.score;
73+
}
74+
75+
const scoredCategoriesMap = new Map();
76+
77+
for (const category of scoredReport.categories) {
78+
category.score = calculateScore(category.refs, catScoreFn);
79+
scoredCategoriesMap.set(category.slug, category);
80+
}
81+
82+
scoredReport.categories = Array.from(scoredCategoriesMap.values());
83+
84+
return scoredReport;
85+
}

packages/utils/perf/index.mjs

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { scoreReport } from './implementations/base.mjs';
33
import { scoreReportOptimized0 } from './implementations/optimized0.mjs';
44
import { scoreReportOptimized1 } from './implementations/optimized1.mjs';
55
import { scoreReportOptimized2 } from './implementations/optimized2.mjs';
6+
import { scoreReportOptimized3 } from './implementations/optimized3.mjs';
67

78
const PROCESS_ARGUMENT_NUM_AUDITS_P1 = parseInt(
89
process.argv
@@ -59,6 +60,7 @@ suite.add('scoreReport', _scoreReport);
5960
suite.add('scoreReportOptimized0', _scoreReportOptimized0);
6061
suite.add('scoreReportOptimized1', _scoreReportOptimized1);
6162
suite.add('scoreReportOptimized2', _scoreReportOptimized2);
63+
suite.add('scoreReportOptimized3', _scoreReportOptimized3);
6264

6365
// ==================
6466

@@ -109,6 +111,10 @@ function _scoreReportOptimized2() {
109111
scoreReportOptimized2(minimalReport());
110112
}
111113

114+
function _scoreReportOptimized3() {
115+
scoreReportOptimized3(minimalReport());
116+
}
117+
112118
// ==============================================================
113119

114120
function minimalReport(opt) {

packages/utils/src/lib/report.ts

+28-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { join } from 'path';
22
import {
3+
AuditGroup,
34
CategoryRef,
45
IssueSeverity as CliIssueSeverity,
56
Format,
@@ -126,16 +127,35 @@ export function countCategoryAudits(
126127
refs: CategoryRef[],
127128
plugins: ScoredReport['plugins'],
128129
): number {
130+
// Create lookup object for groups within each plugin
131+
const groupLookup = plugins.reduce<
132+
Record<string, Record<string, AuditGroup>>
133+
>((lookup, plugin) => {
134+
if (!plugin.groups.length) {
135+
return lookup;
136+
}
137+
138+
return {
139+
...lookup,
140+
[plugin.slug]: {
141+
...plugin.groups.reduce<Record<string, AuditGroup>>(
142+
(groupLookup, group) => {
143+
return {
144+
...groupLookup,
145+
[group.slug]: group,
146+
};
147+
},
148+
{},
149+
),
150+
},
151+
};
152+
}, {});
153+
154+
// Count audits
129155
return refs.reduce((acc, ref) => {
130156
if (ref.type === 'group') {
131-
const groupRefs = plugins
132-
.find(({ slug }) => slug === ref.plugin)
133-
?.groups?.find(({ slug }) => slug === ref.slug)?.refs;
134-
135-
if (!groupRefs?.length) {
136-
return acc;
137-
}
138-
return acc + groupRefs.length;
157+
const groupRefs = groupLookup[ref.plugin]?.[ref.slug]?.refs;
158+
return acc + (groupRefs?.length || 0);
139159
}
140160
return acc + 1;
141161
}, 0);

packages/utils/src/lib/scoring.ts

+60-85
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PluginReport,
88
Report,
99
} from '@code-pushup/models';
10+
import { deepClone } from './transformation';
1011

1112
type EnrichedAuditReport = AuditReport & { plugin: string };
1213
type ScoredCategoryConfig = CategoryConfig & { score: number };
@@ -24,103 +25,77 @@ export type ScoredReport = Omit<Report, 'plugins' | 'categories'> & {
2425
categories: ScoredCategoryConfig[];
2526
};
2627

27-
function groupRefToScore(
28-
audits: AuditReport[],
29-
): (ref: AuditGroupRef) => number {
30-
return ref => {
31-
const score = audits.find(audit => audit.slug === ref.slug)?.score;
32-
if (score == null) {
33-
throw new Error(
34-
`Group has invalid ref - audit with slug ${ref.slug} not found`,
35-
);
36-
}
37-
return score;
38-
};
39-
}
40-
41-
function categoryRefToScore(
42-
audits: EnrichedAuditReport[],
43-
groups: EnrichedScoredAuditGroup[],
44-
): (ref: CategoryRef) => number {
45-
return (ref: CategoryRef): number => {
46-
switch (ref.type) {
47-
case 'audit':
48-
// eslint-disable-next-line no-case-declarations
49-
const audit = audits.find(
50-
a => a.slug === ref.slug && a.plugin === ref.plugin,
51-
);
52-
if (!audit) {
53-
throw new Error(
54-
`Category has invalid ref - audit with slug ${ref.slug} not found in ${ref.plugin} plugin`,
55-
);
56-
}
57-
return audit.score;
58-
59-
case 'group':
60-
// eslint-disable-next-line no-case-declarations
61-
const group = groups.find(
62-
g => g.slug === ref.slug && g.plugin === ref.plugin,
63-
);
64-
if (!group) {
65-
throw new Error(
66-
`Category has invalid ref - group with slug ${ref.slug} not found in ${ref.plugin} plugin`,
67-
);
68-
}
69-
return group.score;
70-
default:
71-
throw new Error(`Type ${ref.type} is unknown`);
72-
}
73-
};
74-
}
75-
7628
export function calculateScore<T extends { weight: number }>(
7729
refs: T[],
7830
scoreFn: (ref: T) => number,
7931
): number {
80-
const numerator = refs.reduce(
81-
(sum, ref) => sum + scoreFn(ref) * ref.weight,
82-
0,
32+
const { numerator, denominator } = refs.reduce(
33+
(acc, ref) => {
34+
const score = scoreFn(ref);
35+
return {
36+
numerator: acc.numerator + score * ref.weight,
37+
denominator: acc.denominator + ref.weight,
38+
};
39+
},
40+
{ numerator: 0, denominator: 0 },
8341
);
84-
const denominator = refs.reduce((sum, ref) => sum + ref.weight, 0);
8542
return numerator / denominator;
8643
}
8744

8845
export function scoreReport(report: Report): ScoredReport {
89-
const scoredPlugins = report.plugins.map(plugin => {
90-
const { groups, audits } = plugin;
91-
const preparedAudits = audits.map(audit => ({
92-
...audit,
93-
plugin: plugin.slug,
94-
}));
95-
const preparedGroups =
96-
groups?.map(group => ({
97-
...group,
98-
score: calculateScore(group.refs, groupRefToScore(preparedAudits)),
99-
plugin: plugin.slug,
100-
})) || [];
46+
const scoredReport = deepClone(report) as ScoredReport;
47+
const allScoredAuditsAndGroups = new Map();
48+
49+
scoredReport.plugins?.forEach(plugin => {
50+
const { audits } = plugin;
51+
const groups = plugin.groups || [];
52+
53+
audits.forEach(audit => {
54+
const key = `${plugin.slug}-${audit.slug}-audit`;
55+
audit.plugin = plugin.slug;
56+
allScoredAuditsAndGroups.set(key, audit);
57+
});
58+
59+
function groupScoreFn(ref: AuditGroupRef) {
60+
const score = allScoredAuditsAndGroups.get(
61+
`${plugin.slug}-${ref.slug}-audit`,
62+
)?.score;
63+
if (score == null) {
64+
throw new Error(
65+
`Group has invalid ref - audit with slug ${plugin.slug}-${ref.slug}-audit not found`,
66+
);
67+
}
68+
return score;
69+
}
10170

102-
return {
103-
...plugin,
104-
audits: preparedAudits,
105-
groups: preparedGroups,
106-
};
71+
groups.forEach(group => {
72+
const key = `${plugin.slug}-${group.slug}-group`;
73+
group.score = calculateScore(group.refs, groupScoreFn);
74+
group.plugin = plugin.slug;
75+
allScoredAuditsAndGroups.set(key, group);
76+
});
77+
plugin.groups = groups;
10778
});
10879

109-
// @TODO intro dict to avoid multiple find calls in the scoreFn
110-
const allScoredAudits = scoredPlugins.flatMap(({ audits }) => audits);
111-
const allScoredGroups = scoredPlugins.flatMap(({ groups }) => groups);
80+
function catScoreFn(ref: CategoryRef) {
81+
const key = `${ref.plugin}-${ref.slug}-${ref.type}`;
82+
const item = allScoredAuditsAndGroups.get(key);
83+
if (!item) {
84+
throw new Error(
85+
`Category has invalid ref - ${ref.type} with slug ${key} not found in ${ref.plugin} plugin`,
86+
);
87+
}
88+
return item.score;
89+
}
90+
91+
const scoredCategoriesMap = new Map();
92+
// eslint-disable-next-line functional/no-loop-statements
93+
for (const category of scoredReport.categories) {
94+
category.score = calculateScore(category.refs, catScoreFn);
95+
scoredCategoriesMap.set(category.slug, category);
96+
}
11297

113-
const scoredCategories = report.categories.map(category => ({
114-
...category,
115-
score: calculateScore(
116-
category.refs,
117-
categoryRefToScore(allScoredAudits, allScoredGroups),
118-
),
119-
}));
98+
scoredReport.categories = Array.from(scoredCategoriesMap.values());
12099

121-
return {
122-
...report,
123-
categories: scoredCategories,
124-
plugins: scoredPlugins,
125-
};
100+
return scoredReport;
126101
}

packages/utils/src/lib/transformation.spec.ts

+27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import {
33
countOccurrences,
4+
deepClone,
45
distinct,
56
objectToEntries,
67
objectToKeys,
@@ -96,3 +97,29 @@ describe('distinct', () => {
9697
]);
9798
});
9899
});
100+
101+
describe('deepClone', () => {
102+
it('should clone the object with nested array with objects, with null and undefined properties', () => {
103+
const obj = {
104+
a: 1,
105+
b: 2,
106+
c: [
107+
{ d: 3, e: 4 },
108+
{ f: 5, g: 6 },
109+
],
110+
d: null,
111+
e: undefined,
112+
};
113+
const cloned = deepClone(obj);
114+
expect(cloned).toEqual(obj);
115+
expect(cloned).not.toBe(obj);
116+
expect(cloned.c).toEqual(obj.c);
117+
expect(cloned.c).not.toBe(obj.c);
118+
expect(cloned.c[0]).toEqual(obj.c[0]);
119+
expect(cloned.c[0]).not.toBe(obj.c[0]);
120+
expect(cloned.c[1]).toEqual(obj.c[1]);
121+
expect(cloned.c[1]).not.toBe(obj.c[1]);
122+
expect(cloned.d).toBe(obj.d);
123+
expect(cloned.e).toBe(obj.e);
124+
});
125+
});

0 commit comments

Comments
 (0)