Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: improve main-thread performance of source map rename #2000

Merged
merged 1 commit into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This changelog records changes to stable releases since 1.50.2. "TBA" changes he

## Nightly (only)

Nothing, yet
- fix: improve main-thread performance of source map rename ([vscode#210518](https://github.com/microsoft/vscode/issues/210518))

## v1.89 (April 2024)

Expand Down
43 changes: 5 additions & 38 deletions src/common/sourceMaps/renameProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { StackFrame } from '../../adapter/stackTrace';
import { AnyLaunchConfiguration } from '../../configuration';
import { iteratorFirst } from '../arrayUtils';
import { ILogger, LogTag } from '../logging';
import { Base01Position, IPosition, Range } from '../positions';
import { PositionToOffset } from '../stringUtils';
import { ScopeNode, extractScopeRanges } from './renameScopeTree';
import { IPosition, Range } from '../positions';
import { extractScopeRenames } from './renameScopeAndSourceMap';
import { ScopeNode } from './renameScopeTree';
import { SourceMap } from './sourceMap';
import { ISourceMapFactory } from './sourceMapFactory';

Expand All @@ -19,9 +19,6 @@ export interface IRename {
compiled: string;
}

/** Very approximate regex for JS identifiers */
const identifierRe = /[$a-z_][$0-9A-Z_$]*/iy;

export interface IRenameProvider {
/**
* Provides renames at the given stackframe.
Expand Down Expand Up @@ -108,47 +105,17 @@ export class RenameProvider implements IRenameProvider {

private async createFromSourceMap(sourceMap: SourceMap, content: string) {
const start = Date.now();

let scopeTree: ScopeNode<IRename[]>;
try {
scopeTree = await extractScopeRanges(content);
scopeTree = await extractScopeRenames(content, sourceMap);
} catch (e) {
this.logger.info(LogTag.Runtime, `Error parsing content for source tree: ${e}}`, {
url: sourceMap.metadata.compiledPath,
});
return RenameMapping.None;
}

const toOffset = new PositionToOffset(content);

sourceMap.eachMapping(mapping => {
if (!mapping.name) {
return;
}

const position = new Base01Position(mapping.generatedLine, mapping.generatedColumn).base0;
const start = toOffset.convert(position);
identifierRe.lastIndex = start;
const match = identifierRe.exec(content);
if (!match) {
return;
}

const compiled = match[0];
if (compiled === mapping.name) {
return; // it happens sometimes 🤷
}

const scope = scopeTree.search(position) || scopeTree;
scope.data ??= [];

// some tools emit name mapping each time the identifier is used, avoid duplicates.
if (!scope.data.some(r => r.compiled == compiled)) {
scope.data.push({ compiled, original: mapping.name });
}
});

scopeTree.filterHoist(node => !!node.data);

const end = Date.now();
this.logger.info(LogTag.Runtime, `renames calculated in ${end - start}ms`, {
url: sourceMap.metadata.compiledPath,
Expand Down
118 changes: 118 additions & 0 deletions src/common/sourceMaps/renameScopeAndSourceMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/

import { Base0Position } from '../positions';
import { PositionToOffset } from '../stringUtils';
import { IRename } from './renameProvider';
import { ScopeNode, extractScopeRanges } from './renameScopeTree';
import { SourceMap } from './sourceMap';

enum Constant {
SourceMapNameIndex = 4,
}

/** Very approximate regex for JS identifiers */
const identifierRe = /[$a-z_][$0-9A-Z_$]*/iy;

export const extractScopeRenames = async (source: string, sourceMap: SourceMap) => {
const toOffset = new PositionToOffset(source);

/**
* Parsed mappings to their rename data, or undefined if the rename was
* not valid or was already included in a scope.
*/
const usedMappings = new Set<number>();
const decodedMappings = sourceMap.decodedMappings();
const decodedNames = sourceMap.names();

const getNameFromMapping = (
generatedLineBase0: number,
generatedColumnBase0: number,
originalName: string,
) => {
// keep things as numbers for performance: number in upper bits (until MAX_SAFE_INTEGER),
// column in lower 32 bits.
const cacheKey = (generatedLineBase0 * 0x7fffffff) | generatedColumnBase0;
if (usedMappings.has(cacheKey)) {
return undefined;
}

// always say we used this mapping, since trying again would be useless:
usedMappings.add(cacheKey);

const position = new Base0Position(generatedLineBase0, generatedColumnBase0);
const start = toOffset.convert(position);
identifierRe.lastIndex = start;
const match = identifierRe.exec(source);
if (!match) {
return;
}

const compiled = match[0];
if (compiled === originalName) {
return; // it happens sometimes 🤷
}

return compiled;
};

const extract = (node: ScopeNode<IRename[]>): IRename[] | undefined => {
const start = node.range.begin.base0;
const end = node.range.end.base0;
let renames: IRename[] | undefined;
// Reference: https://github.com/jridgewell/trace-mapping/blob/5a658b10d9b6dea9c614ff545ca9c4df895fee9e/src/trace-mapping.ts#L258-L290
for (let i = start.lineNumber; i <= end.lineNumber; i++) {
const mappings = decodedMappings[i];
if (!mappings) {
continue;
}
for (let k = 0; k < mappings.length; k++) {
const mapping: number[] = mappings[k];
if (mapping.length <= Constant.SourceMapNameIndex) {
continue;
}

const generatedLineBase0 = i;
const generatedColumnBase0 = mapping[0];
if (
generatedLineBase0 === node.range.begin.base0.lineNumber &&
node.range.begin.base0.columnNumber > generatedColumnBase0
) {
continue;
}
if (
generatedLineBase0 === node.range.end.base0.lineNumber &&
node.range.end.base0.columnNumber < generatedColumnBase0
) {
continue;
}

const originalName = decodedNames[mapping[4]];
const compiledName = getNameFromMapping(
generatedLineBase0,
generatedColumnBase0,
originalName,
);
if (!compiledName) {
continue;
}

renames ??= [];

// some tools emit name mapping each time the identifier is used, avoid duplicates.
if (!renames.some(r => r.compiled == compiledName)) {
renames.push({ compiled: compiledName, original: originalName });
}
}
}

return renames;
};

const scopeTree = await extractScopeRanges(source, extract);

scopeTree.filterHoist(node => !!node.data);

return scopeTree;
};
9 changes: 6 additions & 3 deletions src/common/sourceMaps/renameScopeTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ describe('extractScopeRanges', () => {

for (const [source, expected] of tcases) {
it(source, async () => {
const actual = calculateActual(source, await extractScopeRanges<void>(source));
const actual = calculateActual(
source,
await extractScopeRanges<void>(source, () => undefined),
);
expect(actual).to.deep.equal(expected);
});
}
Expand All @@ -60,7 +63,7 @@ describe('extractScopeRanges', () => {
'}',
].join('\n');

const tree = await extractScopeRanges<void>(src);
const tree = await extractScopeRanges<void>(src, () => undefined);
tree.filterHoist(n => n.range.begin.base0.lineNumber % 2 === 0);
const actual = calculateActual(src, tree);
expect(actual).to.deep.equal([
Expand All @@ -84,7 +87,7 @@ describe('extractScopeRanges', () => {
'}',
].join('\n');

const tree = await extractScopeRanges<number>(src);
const tree = await extractScopeRanges<number>(src, () => undefined);
let i = 0;
tree.forEach(n => (n.data = i++));

Expand Down
77 changes: 52 additions & 25 deletions src/common/sourceMaps/renameScopeTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import { Position as ESTreePosition } from 'estree';
import { Worker } from 'worker_threads';
import { Base01Position, IPosition, Range } from '../positions';
import { extractScopeRangesWithFactory } from './renameWorker';
import { extractScopeRangesWithFactory as extractScopeFlatTree } from './renameWorker';

export type FlatTree = { start: ESTreePosition; end: ESTreePosition; children?: FlatTree[] };
export type FlatTree = { start: ESTreePosition; end: ESTreePosition; depth: number }[];

/**
* A tree node extracted via `extractScopeRanges`.
Expand All @@ -19,17 +19,45 @@ export class ScopeNode<T> {
public data?: T;

/** Hydrates a serialized tree */
public static hydrate<T>(node: FlatTree): ScopeNode<T> {
const hydrated = new ScopeNode<T>(
public static hydrate<T>(
flat: FlatTree,
depthFirstHydration: (node: ScopeNode<T>) => T | undefined,
): ScopeNode<T> {
const root = new ScopeNode<T>(
new Range(
new Base01Position(node.start.line, node.start.column),
new Base01Position(node.end.line, node.end.column),
new Base01Position(flat[0].start.line, flat[0].start.column),
new Base01Position(flat[0].end.line, flat[0].end.column),
),
);

hydrated.children = node.children?.map(ScopeNode.hydrate) as ScopeNode<T>[];
const stack = [root];
let stackLen = 1;

return hydrated;
for (let i = 1; i < flat.length; i++) {
const node = flat[i];
while (node.depth < stackLen) {
stackLen--;
stack[stackLen].data = depthFirstHydration(stack[stackLen]);
}

const hydrated = new ScopeNode<T>(
new Range(
new Base01Position(node.start.line, node.start.column),
new Base01Position(node.end.line, node.end.column),
),
);

const parent = stack[stackLen - 1];
parent.children ??= [];
parent.children.push(hydrated);
stack[stackLen++] = hydrated;
}

for (let i = stackLen - 1; i >= 0; i--) {
stack[i].data = depthFirstHydration(stack[i]);
}

return root;
}

constructor(public readonly range: Range) {}
Expand Down Expand Up @@ -97,12 +125,18 @@ export class ScopeNode<T> {
}
}

/** Runs the function on each node in the tree. */
/** Runs the function on each node in the tree, breadth-first. */
public forEach(fn: (node: ScopeNode<T>) => void) {
fn(this);
this.children?.forEach(c => c.forEach(fn));
}

/** Runs the function on each node in the tree, depth-first */
public forEachDepthFirst(fn: (node: ScopeNode<T>) => void) {
this.children?.forEach(c => c.forEach(fn));
fn(this);
}

public toJSON() {
return {
range: this.range,
Expand All @@ -117,32 +151,25 @@ const WORKER_SIZE_THRESHOLD = 1024 * 512;
* Gets ranges of scopes in the source code. It returns ranges where variables
* declared in those ranges apply to all child scopes. For example,
* `function(foo) { bar(); }` emits `(foo) { bar(); }` as a range.
*
* `depthFirstHydration` is called on each node in a depth-first order to
* allow creating data as serialization/deserialization happens.
*/
export function extractScopeRanges<T>(source: string) {
export function extractScopeRanges<T>(
source: string,
depthFirstHydration: (node: ScopeNode<T>) => T | undefined,
) {
if (source.length < WORKER_SIZE_THRESHOLD) {
return extractScopeRangesMainProcess<T>(source);
return ScopeNode.hydrate<T>(extractScopeFlatTree(source), depthFirstHydration);
}

return new Promise<ScopeNode<T>>((resolve, reject) => {
const worker = new Worker(`${__dirname}/renameWorker.js`, {
workerData: source,
});

worker.on('message', msg => resolve(ScopeNode.hydrate<T>(msg)));
worker.on('message', msg => resolve(ScopeNode.hydrate<T>(msg, depthFirstHydration)));
worker.on('error', reject);
worker.on('exit', () => reject('rename worker exited'));
});
}

function extractScopeRangesMainProcess<T>(source: string) {
return extractScopeRangesWithFactory(
source,
(start, end) =>
new ScopeNode<T>(
new Range(
new Base01Position(start.start.line, start.start.column).base0,
new Base01Position(end.end.line, end.end.column).base0,
),
),
);
}
Loading
Loading