Skip to content

Commit bbfce85

Browse files
committed
perf(napi/oxlint): optimize compiling visitor
1 parent 2b261cf commit bbfce85

File tree

4 files changed

+722
-35
lines changed

4 files changed

+722
-35
lines changed

napi/oxlint2/src-js/index.js

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { createRequire } from 'node:module';
22
import { lint } from './bindings.js';
33
import { DATA_POINTER_POS_32, SOURCE_LEN_POS_32 } from './generated/constants.cjs';
4+
import { addVisitorToCompiled, compiledVisitor, finalizeCompiledVisitor, initCompiledVisitor } from './visitor.js';
45

5-
// Import lazy visitor from `oxc-parser`.
6+
// Import methods and objects from `oxc-parser`.
67
// Use `require` not `import` as `oxc-parser` uses `require` internally,
78
// and need to make sure get same instance of modules as it uses internally,
89
// otherwise `TOKEN` here won't be same `TOKEN` as used within `oxc-parser`.
910
const require = createRequire(import.meta.url);
1011
const { TOKEN } = require('../../parser/raw-transfer/lazy-common.js'),
11-
{ Visitor, getVisitorsArr } = require('../../parser/raw-transfer/visitor.js'),
1212
walkProgram = require('../../parser/generated/lazy/walk.js');
1313

1414
// --------------------
@@ -115,12 +115,17 @@ function lintFile([filePath, bufferId, buffer, ruleIds]) {
115115
}
116116

117117
// Get visitors for this file from all rules
118-
const visitors = ruleIds.map(
119-
ruleId => registeredRules[ruleId].create(new Context(ruleId, filePath)),
120-
);
121-
const visitor = new Visitor(
122-
visitors.length === 1 ? visitors[0] : combineVisitors(visitors),
123-
);
118+
initCompiledVisitor();
119+
for (const ruleId of ruleIds) {
120+
const visitor = registeredRules[ruleId].create(new Context(ruleId, filePath));
121+
addVisitorToCompiled(visitor);
122+
}
123+
const needsVisit = finalizeCompiledVisitor();
124+
125+
// Skip visiting AST if no visitors visit any nodes.
126+
// Some rules seen in the wild return an empty visitor object from `create` if some initial check fails
127+
// e.g. file extension is not one the rule acts on.
128+
if (!needsVisit) return '[]';
124129

125130
// Visit AST
126131
const programPos = buffer.uint32[DATA_POINTER_POS_32],
@@ -130,38 +135,14 @@ function lintFile([filePath, bufferId, buffer, ruleIds]) {
130135
const sourceIsAscii = sourceText.length === sourceByteLen;
131136
const ast = { buffer, sourceText, sourceByteLen, sourceIsAscii, nodes: new Map(), token: TOKEN };
132137

133-
walkProgram(programPos, ast, getVisitorsArr(visitor));
138+
walkProgram(programPos, ast, compiledVisitor);
134139

135140
// Send diagnostics back to Rust
136141
const ret = JSON.stringify(diagnostics);
137142
diagnostics.length = 0;
138143
return ret;
139144
}
140145

141-
/**
142-
* Combine multiple visitor objects into a single visitor object.
143-
* @param {Array<Object>} visitors - Array of visitor objects
144-
* @returns {Object} - Combined visitor object
145-
*/
146-
function combineVisitors(visitors) {
147-
const combinedVisitor = {};
148-
for (const visitor of visitors) {
149-
for (const nodeType of Object.keys(visitor)) {
150-
if (!(nodeType in combinedVisitor)) {
151-
combinedVisitor[nodeType] = function(node) {
152-
for (const v of visitors) {
153-
if (typeof v[nodeType] === 'function') {
154-
v[nodeType](node);
155-
}
156-
}
157-
};
158-
}
159-
}
160-
}
161-
162-
return combinedVisitor;
163-
}
164-
165146
/**
166147
* Context class.
167148
*

napi/oxlint2/src-js/visitor.js

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
// Functions to compile 1 or more visitor objects into a single compiled visitor.
2+
//
3+
// # Visitor objects
4+
//
5+
// Visitor objects which are generated by rules' `create` functions have keys being either:
6+
// * Name of an AST type. or
7+
// * Name of an AST type postfixed with `:exit`.
8+
//
9+
// Each property value must be a function that handles that AST node.
10+
//
11+
// e.g.:
12+
//
13+
// ```
14+
// {
15+
// BinaryExpression(node) {
16+
// // Do stuff on enter
17+
// },
18+
// 'BinaryExpression:exit'(node) {
19+
// // Do stuff on exit
20+
// },
21+
// }
22+
// ```
23+
//
24+
// # Compiled visitor
25+
//
26+
// Compiled visitor is an array with `NODE_TYPES_COUNT` length, keyed by the ID of the node type.
27+
// `NODE_TYPE_IDS_MAP` maps from type name to ID.
28+
//
29+
// Each element of compiled array is one of:
30+
// * No visitor for this type = `null`.
31+
// * Visitor for leaf node = visit function.
32+
// * Visitor for non-leaf node = object of form `{ enter, exit }`,
33+
// where each property is either a visitor function or `null`.
34+
//
35+
// e.g.:
36+
//
37+
// ```
38+
// [
39+
// // Leaf nodes
40+
// function(node) { /* do stuff */ },
41+
// // ...
42+
//
43+
// // Non-leaf nodes
44+
// {
45+
// enter: function(node) { /* do stuff */ },
46+
// exit: null,
47+
// },
48+
// // ...
49+
// ]
50+
// ```
51+
//
52+
// # Object reuse
53+
//
54+
// No more than 1 compiled visitor exists at any time, so we reuse a single array `compiledVisitor`,
55+
// rather than creating a new array for each file being linted.
56+
//
57+
// To compile visitors, call:
58+
// * `initCompiledVisitor` once.
59+
// * `addVisitorToCompiled` with each visitor object.
60+
// * `finalizeCompiledVisitor` once.
61+
//
62+
// After this sequence of calls, `compiledVisitor` is ready to be used to walk the AST.
63+
64+
import types from '../../parser/generated/lazy/types.js';
65+
66+
const { LEAF_NODE_TYPES_COUNT, NODE_TYPE_IDS_MAP, NODE_TYPES_COUNT } = types;
67+
68+
const { isArray } = Array;
69+
70+
// Compiled visitor used for visiting each file.
71+
// Same array is reused for each file.
72+
//
73+
// Initialized with `.push()` to ensure V8 treats the array as "packed" (linear array),
74+
// not "holey" (hash map). This is critical, as looking up elements in this array is a very hot path
75+
// during AST visitation, and holey arrays are much slower.
76+
// https://v8.dev/blog/elements-kinds
77+
export const compiledVisitor = [];
78+
79+
for (let i = NODE_TYPES_COUNT; i !== 0; i--) {
80+
compiledVisitor.push(null);
81+
}
82+
83+
// Arrays containing type IDs of types which have multiple visit functions defined for them.
84+
//
85+
// Filled with `0` initially up to maximum size they could ever need to be so:
86+
// 1. These arrays never need to grow.
87+
// 2. V8 treats these arrays as "PACKED_SMI_ELEMENTS".
88+
const mergedLeafVisitorTypeIds = [],
89+
mergedEnterVisitorTypeIds = [],
90+
mergedExitVisitorTypeIds = [];
91+
92+
for (let i = LEAF_NODE_TYPES_COUNT; i !== 0; i--) {
93+
mergedLeafVisitorTypeIds.push(0);
94+
}
95+
96+
for (let i = NODE_TYPES_COUNT - LEAF_NODE_TYPES_COUNT; i !== 0; i--) {
97+
mergedEnterVisitorTypeIds.push(0);
98+
mergedExitVisitorTypeIds.push(0);
99+
}
100+
101+
mergedLeafVisitorTypeIds.length = 0;
102+
mergedEnterVisitorTypeIds.length = 0;
103+
mergedExitVisitorTypeIds.length = 0;
104+
105+
// `true` if `addVisitor` has been called with a visitor which visits at least one AST type
106+
let hasActiveVisitors = false;
107+
108+
/**
109+
* Initialize compiled visitor, ready for calls to `addVisitor`.
110+
* @returns {undefined}
111+
*/
112+
export function initCompiledVisitor() {
113+
// Reset `compiledVisitor` array after previous compilation
114+
for (let i = 0; i < NODE_TYPES_COUNT; i++) {
115+
compiledVisitor[i] = null;
116+
}
117+
}
118+
119+
/**
120+
* Add a visitor to compiled visitor.
121+
*
122+
* @param {Object} visitor - Visitor object
123+
* @returns {undefined}
124+
*/
125+
export function addVisitorToCompiled(visitor) {
126+
if (visitor === null || typeof visitor !== 'object') {
127+
throw new TypeError('Visitor returned from `create` method must be an object');
128+
}
129+
130+
// Exit if is empty visitor
131+
const keys = Object.keys(visitor);
132+
if (keys.length === 0) return;
133+
134+
hasActiveVisitors = true;
135+
136+
// Populate visitors array from provided object
137+
for (let name of keys) {
138+
const visitFn = visitor[name];
139+
if (typeof visitFn !== 'function') {
140+
throw new TypeError(`'${name}' property of visitor object is not a function`);
141+
}
142+
143+
const isExit = name.endsWith(':exit');
144+
if (isExit) name = name.slice(0, -5);
145+
146+
const typeId = NODE_TYPE_IDS_MAP.get(name);
147+
if (typeId === void 0) throw new Error(`Unknown node type '${name}' in visitor object`);
148+
149+
const existing = compiledVisitor[typeId];
150+
if (typeId < LEAF_NODE_TYPES_COUNT) {
151+
// Leaf node - store just 1 function, not enter+exit pair
152+
if (existing === null) {
153+
compiledVisitor[typeId] = visitFn;
154+
} else if (isArray(existing)) {
155+
if (isExit) {
156+
existing.push(visitFn);
157+
} else {
158+
// Insert before last in array in case last was enter visit function from the current rule,
159+
// to ensure enter is called before exit.
160+
// It could also be either an enter or exit visitor function for another rule, but the order
161+
// rules are called in doesn't matter. We only need to make sure that a rule's exit visitor
162+
// isn't called before enter visitor *for that same rule*.
163+
existing.splice(existing.length - 1, 0, visitFn);
164+
}
165+
} else {
166+
// Same as above, enter visitor is put to front of list to make sure enter is called before exit
167+
compiledVisitor[typeId] = isExit ? [existing, visitFn] : [visitFn, existing];
168+
mergedLeafVisitorTypeIds.push(typeId);
169+
}
170+
} else {
171+
// Not leaf node - store enter+exit pair
172+
if (existing === null) {
173+
compiledVisitor[typeId] = isExit
174+
? { enter: null, exit: visitFn }
175+
: { enter: visitFn, exit: null };
176+
} else if (isExit) {
177+
let { exit } = existing;
178+
if (exit === null) {
179+
existing.exit = visitFn;
180+
} else if (isArray(exit)) {
181+
exit.push(visitFn);
182+
} else {
183+
existing.exit = [exit, visitFn];
184+
mergedExitVisitorTypeIds.push(typeId);
185+
}
186+
} else {
187+
let { enter } = existing;
188+
if (enter === null) {
189+
existing.enter = visitFn;
190+
} else if (isArray(enter)) {
191+
enter.push(visitFn);
192+
} else {
193+
existing.enter = [enter, visitFn];
194+
mergedEnterVisitorTypeIds.push(typeId);
195+
}
196+
}
197+
}
198+
}
199+
}
200+
201+
/**
202+
* Finalize compiled visitor.
203+
*
204+
* After calling this function, `compiledVisitor` is ready to be used to walk the AST.
205+
*
206+
* @returns {boolean} - `true` if compiled visitor visits at least 1 AST type
207+
*/
208+
export function finalizeCompiledVisitor() {
209+
if (hasActiveVisitors === false) return false;
210+
211+
// Merge visit functions for node types which have multiple visitors from different rules,
212+
// or enter+exit functions for leaf nodes
213+
for (const typeId of mergedLeafVisitorTypeIds) {
214+
compiledVisitor[typeId] = mergeVisitFns(compiledVisitor[typeId]);
215+
}
216+
for (const typeId of mergedEnterVisitorTypeIds) {
217+
const enterExit = compiledVisitor[typeId];
218+
enterExit.enter = mergeVisitFns(enterExit.enter);
219+
}
220+
for (const typeId of mergedExitVisitorTypeIds) {
221+
const enterExit = compiledVisitor[typeId];
222+
enterExit.exit = mergeVisitFns(enterExit.exit);
223+
}
224+
225+
// Reset state, ready for next time
226+
mergedLeafVisitorTypeIds.length = 0;
227+
mergedEnterVisitorTypeIds.length = 0;
228+
mergedExitVisitorTypeIds.length = 0;
229+
230+
hasActiveVisitors = false;
231+
232+
return true;
233+
}
234+
235+
/**
236+
* Merge array of visit functions into a single function, which calls each of input functions in turn.
237+
*
238+
* The merged function is statically defined and does not contain a loop, to hopefully allow
239+
* JS engine to heavily optimize it.
240+
*
241+
* `mergers` contains pre-defined functions to merge up to 5 visit functions.
242+
* Merger functions for merging more than 5 visit functions are created dynamically on demand.
243+
*
244+
* @param {Array<function>} visitFns - Array of visit functions
245+
* @returns {function} - Function which calls all of `visitFns` in turn.
246+
*/
247+
function mergeVisitFns(visitFns) {
248+
const numVisitFns = visitFns.length;
249+
250+
// Get or create merger for merging `numVisitFns` functions
251+
let merger;
252+
if (mergers.length <= numVisitFns) {
253+
while (mergers.length < numVisitFns) {
254+
mergers.push(null);
255+
}
256+
merger = createMerger(numVisitFns);
257+
mergers.push(merger);
258+
} else {
259+
merger = mergers[numVisitFns];
260+
if (merger === null) merger = mergers[numVisitFns] = createMerger(numVisitFns);
261+
}
262+
263+
// Merge functions
264+
return merger(...visitFns);
265+
}
266+
267+
/**
268+
* Create a merger function that merges `fnCount` functions.
269+
*
270+
* @param {number} fnCount - Number of functions to be merged
271+
* @returns {function} - Function to merge `fnCount` functions
272+
*/
273+
function createMerger(fnCount) {
274+
const args = [];
275+
let body = 'return node=>{';
276+
for (let i = 1; i <= fnCount; i++) {
277+
args.push(`visit${i}`);
278+
body += `visit${i}(node);`;
279+
}
280+
body += '}';
281+
args.push(body);
282+
return new Function(...args);
283+
}
284+
285+
// Pre-defined mergers for merging up to 5 functions
286+
const mergers = [
287+
null, // No merger for 0 functions
288+
null, // No merger for 1 function
289+
(visit1, visit2) => node => {
290+
visit1(node);
291+
visit2(node);
292+
},
293+
(visit1, visit2, visit3) => node => {
294+
visit1(node);
295+
visit2(node);
296+
visit3(node);
297+
},
298+
(visit1, visit2, visit3, visit4) => node => {
299+
visit1(node);
300+
visit2(node);
301+
visit3(node);
302+
visit4(node);
303+
},
304+
(visit1, visit2, visit3, visit4, visit5) => node => {
305+
visit1(node);
306+
visit2(node);
307+
visit3(node);
308+
visit4(node);
309+
visit5(node);
310+
},
311+
];

0 commit comments

Comments
 (0)