Skip to content

Commit fcba31b

Browse files
committed
chore(internals): add performance eslint rules for typescript
- Introduced `performanceConfig` to enable performance-related ESLint rules for production TypeScript. - Added rules: `no-gpu-upload-in-loop`, `no-hot-path-buffer-allocation`, `no-hot-path-collection-allocation`, `no-inline-gpu-upload-allocation`, `prefer-direct-typed-array-iteration`, `require-animation-frame-cleanup`, `require-gpu-resource-cleanup`, and `require-observer-disconnect`. - Updated README to document the new performance rules and their usage. - Added tests for the new rules to ensure correct functionality. Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent aca8979 commit fcba31b

22 files changed

Lines changed: 1797 additions & 2 deletions

‎projects/internals/eslint/README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@ import { browserTypescriptConfig, libraryConfig, litConfig, jsonConfig } from '@
2929
export default [...browserTypescriptConfig, ...libraryConfig, ...litConfig, ...jsonConfig];
3030
```
3131

32-
Plugin namespaces (`local-typescript`, `local-html`, `local-css`, `local-json`) register the custom rules inside those configs. Projects do not opt in per rule.
32+
Performance rules use a separate config. Spread `performanceConfig` to enable every performance rule for production TypeScript:
33+
34+
```js
35+
import { browserTypescriptConfig, performanceConfig } from '@internals/eslint';
36+
37+
export default [...browserTypescriptConfig, ...performanceConfig];
38+
```
39+
40+
Plugin namespaces (`local-typescript`, `local-performance`, `local-html`, `local-css`, `local-json`) register the custom rules inside those configs.
3341

3442
## Authoring a new rule
3543

@@ -110,3 +118,18 @@ Applied to `package.json` files.
110118

111119
- **`no-missing-bundle-registration`**. A component that ships a `define.ts` must also appear in `src/bundle.ts` so the CDN bundle registers it.
112120
- **`no-missing-bundle-test`**. Every component registered by `src/bundle.ts` must also appear in the Lighthouse direct-import benchmark. The required `lighthouseTestFile` option names the project's combined Lighthouse test file.
121+
122+
### Performance rules
123+
124+
The `performanceConfig` enables these rules as errors for production TypeScript files.
125+
126+
- **`prefer-direct-typed-array-iteration`**. Avoids `Array.from(typedArray)` copies before operations such as `some`, `find`, and `forEach` that do not transform the array.
127+
- **`require-animation-frame-cleanup`**. Requires a class that stores an animation frame handle to cancel that handle.
128+
- **`require-observer-disconnect`**. Requires observers stored on class fields to call `disconnect()`. The rule recognizes standard observer constructors and the repository's observer factory names.
129+
- **`no-inline-gpu-upload-allocation`**. Flags typed arrays and other buffer sources constructed directly in WebGPU `writeBuffer` and `writeTexture` calls.
130+
- **`no-hot-path-collection-allocation`**. Flags collection-producing array operations, collection constructors, `Array.from`, and array spread inside explicit or recognized renderer hot paths.
131+
- **`no-hot-path-buffer-allocation`**. Flags typed arrays, `ArrayBuffer`, and `DataView` construction inside explicit or recognized renderer hot paths.
132+
- **`no-gpu-upload-in-loop`**. Flags direct WebGPU queue uploads inside loops and collection callbacks so callers batch work before crossing the browser boundary.
133+
- **`require-gpu-resource-cleanup`**. Requires owning classes to destroy buffers, textures, and query sets retained on class fields.
134+
135+
Add `@hotPath` to a function or method to opt it into hot-path allocation checks. The rules also treat methods beginning with `draw`, `prepare`, or `render` on classes whose names end in `Renderer` as hot paths.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import noGpuUploadInLoop from '../local/no-gpu-upload-in-loop.js';
2+
import noHotPathBufferAllocation from '../local/no-hot-path-buffer-allocation.js';
3+
import noHotPathCollectionAllocation from '../local/no-hot-path-collection-allocation.js';
4+
import noInlineGpuUploadAllocation from '../local/no-inline-gpu-upload-allocation.js';
5+
import preferDirectTypedArrayIteration from '../local/prefer-direct-typed-array-iteration.js';
6+
import requireAnimationFrameCleanup from '../local/require-animation-frame-cleanup.js';
7+
import requireGpuResourceCleanup from '../local/require-gpu-resource-cleanup.js';
8+
import requireObserverDisconnect from '../local/require-observer-disconnect.js';
9+
10+
const source = ['src/**/*.ts', 'src/**/*.tsx'];
11+
const ignores = [
12+
'**/*.examples.ts',
13+
'**/*.test*.ts',
14+
'**/*.test*.tsx',
15+
'build/',
16+
'coverage/',
17+
'dist/',
18+
'node_modules/'
19+
];
20+
21+
/**
22+
* Enables performance rules for production TypeScript. Consumers may override
23+
* individual severities in a later flat-config entry after spreading this config.
24+
*
25+
* @type {import('eslint').Linter.Config[]}
26+
*/
27+
export const performanceConfig = [
28+
{
29+
plugins: {
30+
'local-performance': {
31+
rules: {
32+
'no-gpu-upload-in-loop': noGpuUploadInLoop,
33+
'no-hot-path-buffer-allocation': noHotPathBufferAllocation,
34+
'no-hot-path-collection-allocation': noHotPathCollectionAllocation,
35+
'no-inline-gpu-upload-allocation': noInlineGpuUploadAllocation,
36+
'prefer-direct-typed-array-iteration': preferDirectTypedArrayIteration,
37+
'require-animation-frame-cleanup': requireAnimationFrameCleanup,
38+
'require-gpu-resource-cleanup': requireGpuResourceCleanup,
39+
'require-observer-disconnect': requireObserverDisconnect
40+
}
41+
}
42+
}
43+
},
44+
{
45+
files: source,
46+
ignores,
47+
rules: {
48+
'local-performance/no-gpu-upload-in-loop': 'error',
49+
'local-performance/no-hot-path-buffer-allocation': 'error',
50+
'local-performance/no-hot-path-collection-allocation': 'error',
51+
'local-performance/no-inline-gpu-upload-allocation': 'error',
52+
'local-performance/prefer-direct-typed-array-iteration': 'error',
53+
'local-performance/require-animation-frame-cleanup': 'error',
54+
'local-performance/require-gpu-resource-cleanup': 'error',
55+
'local-performance/require-observer-disconnect': 'error'
56+
}
57+
}
58+
];
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import { performanceConfig } from './performance.js';
4+
5+
test('enables performance rules for production TypeScript', () => {
6+
assert.equal(performanceConfig.length, 2);
7+
const [pluginConfig, ruleConfig] = performanceConfig;
8+
assert.deepEqual(ruleConfig.files, ['src/**/*.ts', 'src/**/*.tsx']);
9+
assert.equal(ruleConfig.ignores.includes('**/*.test*.ts'), true);
10+
assert.deepEqual(Object.keys(pluginConfig.plugins['local-performance'].rules).sort(), [
11+
'no-gpu-upload-in-loop',
12+
'no-hot-path-buffer-allocation',
13+
'no-hot-path-collection-allocation',
14+
'no-inline-gpu-upload-allocation',
15+
'prefer-direct-typed-array-iteration',
16+
'require-animation-frame-cleanup',
17+
'require-gpu-resource-cleanup',
18+
'require-observer-disconnect'
19+
]);
20+
assert.equal(
21+
Object.values(ruleConfig.rules).every(severity => severity === 'error'),
22+
true
23+
);
24+
});

‎projects/internals/eslint/src/configs/typescript.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ const config = {
164164
tags: { name: true },
165165
aria: { name: true },
166166
experimental: { name: true },
167+
hotPath: { name: false },
167168
alpha: { name: true },
168169
beta: { name: true },
169170
stable: { name: true },

‎projects/internals/eslint/src/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export { jsonConfig } from './configs/json.js';
44
export { libraryConfig } from './configs/library.js';
55
export { appConfig } from './configs/app.js';
66
export { litConfig } from './configs/lit.js';
7+
export { performanceConfig } from './configs/performance.js';
78
export { browserTypescriptConfig, nodeTypescriptConfig } from './configs/typescript.js';
89
export { cssConfig } from './configs/css.js';
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
const ITERATION_METHODS = new Set([
2+
'every',
3+
'filter',
4+
'find',
5+
'findIndex',
6+
'flatMap',
7+
'forEach',
8+
'map',
9+
'reduce',
10+
'reduceRight',
11+
'some'
12+
]);
13+
const LOOP_NODES = new Set(['DoWhileStatement', 'ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement']);
14+
15+
/** @type {import('eslint').Rule.RuleModule} */
16+
export default {
17+
meta: {
18+
type: 'suggestion',
19+
name: 'no-gpu-upload-in-loop',
20+
docs: {
21+
description: 'Flags direct WebGPU queue uploads repeated by loops or collection callbacks.',
22+
category: 'Performance',
23+
recommended: false
24+
},
25+
schema: [],
26+
messages: {
27+
'repeated-upload':
28+
'`{{method}}` runs inside repeated iteration. Batch uploads or merge dirty ranges before crossing the WebGPU boundary.'
29+
}
30+
},
31+
create(context) {
32+
return {
33+
CallExpression(node) {
34+
const method = getUploadMethod(node.callee);
35+
if (method && isRepeated(node)) {
36+
context.report({ node, messageId: 'repeated-upload', data: { method } });
37+
}
38+
}
39+
};
40+
}
41+
};
42+
43+
function getUploadMethod(callee) {
44+
if (callee.type !== 'MemberExpression' || !isGpuQueue(callee.object)) return null;
45+
const name = callee.computed
46+
? callee.property.type === 'Literal' && typeof callee.property.value === 'string'
47+
? callee.property.value
48+
: undefined
49+
: callee.property.type === 'Identifier'
50+
? callee.property.name
51+
: undefined;
52+
return name === 'writeBuffer' || name === 'writeTexture' ? name : null;
53+
}
54+
55+
function isGpuQueue(node) {
56+
if (node.type === 'Identifier') return /queue$/iu.test(node.name);
57+
if (node.type !== 'MemberExpression') return false;
58+
if (!node.computed && node.property.type === 'Identifier') return /queue$/iu.test(node.property.name);
59+
return node.property.type === 'Literal' && typeof node.property.value === 'string'
60+
? /queue$/iu.test(node.property.value)
61+
: false;
62+
}
63+
64+
function isRepeated(node) {
65+
let current = node.parent;
66+
while (current) {
67+
if (LOOP_NODES.has(current.type)) return true;
68+
if (isFunction(current)) return isIterationCallback(current);
69+
current = current.parent;
70+
}
71+
return false;
72+
}
73+
74+
function isFunction(node) {
75+
return (
76+
node.type === 'ArrowFunctionExpression' || node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression'
77+
);
78+
}
79+
80+
function isIterationCallback(node) {
81+
const call = node.parent;
82+
if (call?.type !== 'CallExpression' || !call.arguments.includes(node)) return false;
83+
const callee = call.callee;
84+
return (
85+
callee.type === 'MemberExpression' &&
86+
!callee.computed &&
87+
callee.property.type === 'Identifier' &&
88+
ITERATION_METHODS.has(callee.property.name)
89+
);
90+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import assert from 'node:assert/strict';
2+
import { beforeEach, test } from 'node:test';
3+
import { RuleTester } from 'eslint';
4+
import tseslint from 'typescript-eslint';
5+
import noGpuUploadInLoop from './no-gpu-upload-in-loop.js';
6+
7+
let tester;
8+
9+
beforeEach(() => {
10+
tester = new RuleTester({
11+
languageOptions: { parser: tseslint.parser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module' } }
12+
});
13+
});
14+
15+
test('defines rule metadata', () => {
16+
assert.equal(noGpuUploadInLoop.meta.type, 'suggestion');
17+
assert.equal(noGpuUploadInLoop.meta.name, 'no-gpu-upload-in-loop');
18+
assert.ok(noGpuUploadInLoop.meta.messages['repeated-upload']);
19+
});
20+
21+
test('valid: accepts single uploads and separately declared callbacks', () => {
22+
tester.run('no-gpu-upload-in-loop', noGpuUploadInLoop, {
23+
valid: [
24+
{ code: `queue.writeBuffer(buffer, 0, bytes);` },
25+
{ code: `for (const value of values) writer.writeBuffer(value);` },
26+
{
27+
code: `
28+
const upload = bytes => queue.writeBuffer(buffer, 0, bytes);
29+
for (const bytes of batches) schedule(upload, bytes);
30+
`
31+
}
32+
],
33+
invalid: []
34+
});
35+
});
36+
37+
test('invalid: reports queue uploads in loops and collection callbacks', () => {
38+
tester.run('no-gpu-upload-in-loop', noGpuUploadInLoop, {
39+
valid: [],
40+
invalid: [
41+
{
42+
code: `for (const range of ranges) queue.writeBuffer(buffer, range.offset, range.bytes);`,
43+
errors: [{ messageId: 'repeated-upload', data: { method: 'writeBuffer' } }]
44+
},
45+
{
46+
code: `images.forEach(image => queue.writeTexture(destination, image, layout, size));`,
47+
errors: [{ messageId: 'repeated-upload', data: { method: 'writeTexture' } }]
48+
},
49+
{
50+
code: `while (pending()) queue['writeBuffer'](buffer, 0, next());`,
51+
errors: [{ messageId: 'repeated-upload', data: { method: 'writeBuffer' } }]
52+
}
53+
]
54+
});
55+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { isHotPath, walk } from './utils.js';
2+
3+
const BUFFER_CONSTRUCTORS = new Set([
4+
'ArrayBuffer',
5+
'BigInt64Array',
6+
'BigUint64Array',
7+
'DataView',
8+
'Float32Array',
9+
'Float64Array',
10+
'Int8Array',
11+
'Int16Array',
12+
'Int32Array',
13+
'SharedArrayBuffer',
14+
'Uint8Array',
15+
'Uint8ClampedArray',
16+
'Uint16Array',
17+
'Uint32Array'
18+
]);
19+
20+
/** @type {import('eslint').Rule.RuleModule} */
21+
export default {
22+
meta: {
23+
type: 'suggestion',
24+
name: 'no-hot-path-buffer-allocation',
25+
docs: {
26+
description: 'Flags buffer and view construction in annotated and renderer hot paths.',
27+
category: 'Performance',
28+
recommended: false
29+
},
30+
schema: [],
31+
messages: {
32+
'buffer-constructor':
33+
'`new {{kind}}` allocates storage or a view inside a hot path. Reuse owned scratch storage or move initialization outside the repeated path.'
34+
}
35+
},
36+
create(context) {
37+
return {
38+
FunctionDeclaration(node) {
39+
if (isHotPath(context, node)) inspectHotPath(context, node.body);
40+
},
41+
MethodDefinition(node) {
42+
if (isHotPath(context, node)) inspectHotPath(context, node.value.body);
43+
},
44+
PropertyDefinition(node) {
45+
if (isFunction(node.value) && isHotPath(context, node)) inspectHotPath(context, node.value.body);
46+
},
47+
VariableDeclaration(node) {
48+
if (!isHotPath(context, node)) return;
49+
for (const declaration of node.declarations) {
50+
if (isFunction(declaration.init)) inspectHotPath(context, declaration.init.body);
51+
}
52+
}
53+
};
54+
}
55+
};
56+
57+
function isFunction(node) {
58+
return (
59+
node?.type === 'ArrowFunctionExpression' ||
60+
node?.type === 'FunctionDeclaration' ||
61+
node?.type === 'FunctionExpression'
62+
);
63+
}
64+
65+
function inspectHotPath(context, body) {
66+
walk(body, node => {
67+
if (isFunction(node)) return false;
68+
if (
69+
node.type === 'NewExpression' &&
70+
node.callee.type === 'Identifier' &&
71+
BUFFER_CONSTRUCTORS.has(node.callee.name)
72+
) {
73+
context.report({ node, messageId: 'buffer-constructor', data: { kind: node.callee.name } });
74+
}
75+
});
76+
}

0 commit comments

Comments
 (0)