Skip to content

Commit c140e06

Browse files
committed
[compiler][wip] Infer alias effects for function expressions
This is a stab at addressing a pattern that mofeiz and I have both stumbled across. Today, FunctionExpression's context list describes values from the outer context that are accessed in the function, and with what effect they were accessed. This allows us to describe the fact that a value from the outer context is known to be mutated inside a function expression, or is known to be captured (aliased) into some other value in the function expression. However, the basic `Effect` kind is insufficient to describe the full semantics. Notably, it doesn't let us describe more complex aliasing relationships. From an example mofeiz added: ```js const x = {}; const y = {}; const f = () => { const a = [y]; const b = x; // this sets y.x = x a[0].x = b; } f(); mutate(y.x); // which means this mutates x! ``` Here, the Effect on the context operands are `[mutate y, read x]`. The `mutate y` is bc of the array push. But the `read x` is surprising — `x` is captured into `y`, but there is no subsequent mutation of y or x, so we consider this a read. But as the comments indicate, the final line mutates x! We need to reflect the fact that even though x isn't mutated inside the function, it is aliased into y, such that if y is subsequently mutated that this should count as a mutation of x too. The idea of this PR is to extend the FunctionEffect type with a CaptureEffect variant which lists out the aliasing groups that occur inside the function expression. This allows us to bubble up the results of alias analysis from inside a function. The idea is to: * Return the alias sets from InferMutableRanges * Augment them with capturing of the form above, handling cases such as the `a[0].x = b` * For each alias group, record a CaptureEffect for any group that contains 2+ context operands * Extend the alias sets in the _outer_ function with the CaptureEffect sets from FunctionExpression/ObjectMethod instructions. As part of this, I realized that our handling of PropertyStore's effect wasn't quite right. We used a store effect for the object, but only if it was a known object type — otherwise we recorded it as a mutation. But a PropertyStore really always is a store — it only mutates direct aliases of a value, not any interior objects that are captured. So I updated to always use store for known properties, and use mutate for computed properties. The latter is still also wrong, but i want to debug the change there separately. ghstack-source-id: 3c4a370 Pull Request resolved: facebook/react#33151
1 parent d3c74d0 commit c140e06

12 files changed

+386
-194
lines changed

compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@ export type FunctionEffect =
300300
places: ReadonlySet<Place>;
301301
effect: Effect;
302302
loc: SourceLocation;
303+
}
304+
| {
305+
kind: 'CaptureEffect';
306+
places: ReadonlySet<Place>;
303307
};
304308

305309
/*

compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -546,12 +546,23 @@ export function printInstructionValue(instrValue: ReactiveValue): string {
546546
const effects =
547547
instrValue.loweredFunc.func.effects
548548
?.map(effect => {
549-
if (effect.kind === 'ContextMutation') {
550-
return `ContextMutation places=[${[...effect.places]
551-
.map(place => printPlace(place))
552-
.join(', ')}] effect=${effect.effect}`;
553-
} else {
554-
return `GlobalMutation`;
549+
switch (effect.kind) {
550+
case 'ContextMutation': {
551+
return `ContextMutation places=[${[...effect.places]
552+
.map(place => printPlace(place))
553+
.join(', ')}] effect=${effect.effect}`;
554+
}
555+
case 'GlobalMutation': {
556+
return 'GlobalMutation';
557+
}
558+
case 'ReactMutation': {
559+
return 'ReactMutation';
560+
}
561+
case 'CaptureEffect': {
562+
return `CaptureEffect places=[${[...effect.places]
563+
.map(place => printPlace(place))
564+
.join(', ')}]`;
565+
}
555566
}
556567
})
557568
.join(', ') ?? '';

compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
HIRFunction,
1212
Identifier,
1313
LoweredFunction,
14+
Place,
1415
isRefOrRefValue,
1516
makeInstructionId,
1617
} from '../HIR';
@@ -19,15 +20,23 @@ import {inferReactiveScopeVariables} from '../ReactiveScopes';
1920
import {rewriteInstructionKindsBasedOnReassignment} from '../SSA';
2021
import {inferMutableRanges} from './InferMutableRanges';
2122
import inferReferenceEffects from './InferReferenceEffects';
23+
import DisjointSet from '../Utils/DisjointSet';
24+
import {
25+
eachInstructionLValue,
26+
eachInstructionValueOperand,
27+
} from '../HIR/visitors';
28+
import prettyFormat from 'pretty-format';
29+
import {printIdentifier} from '../HIR/PrintHIR';
30+
import {Iterable_some} from '../Utils/utils';
2231

2332
export default function analyseFunctions(func: HIRFunction): void {
2433
for (const [_, block] of func.body.blocks) {
2534
for (const instr of block.instructions) {
2635
switch (instr.value.kind) {
2736
case 'ObjectMethod':
2837
case 'FunctionExpression': {
29-
lower(instr.value.loweredFunc.func);
30-
infer(instr.value.loweredFunc);
38+
const aliases = lower(instr.value.loweredFunc.func);
39+
infer(instr.value.loweredFunc, aliases);
3140

3241
/**
3342
* Reset mutable range for outer inferReferenceEffects
@@ -44,21 +53,82 @@ export default function analyseFunctions(func: HIRFunction): void {
4453
}
4554
}
4655

47-
function lower(func: HIRFunction): void {
56+
function lower(func: HIRFunction): DisjointSet<Identifier> {
4857
analyseFunctions(func);
4958
inferReferenceEffects(func, {isFunctionExpression: true});
5059
deadCodeElimination(func);
51-
inferMutableRanges(func);
60+
const aliases = inferMutableRanges(func);
5261
rewriteInstructionKindsBasedOnReassignment(func);
5362
inferReactiveScopeVariables(func);
5463
func.env.logger?.debugLogIRs?.({
5564
kind: 'hir',
5665
name: 'AnalyseFunction (inner)',
5766
value: func,
5867
});
68+
inferAliasesForCapturing(func, aliases);
69+
return aliases;
70+
}
71+
72+
export function debugAliases(aliases: DisjointSet<Identifier>): void {
73+
console.log(
74+
prettyFormat(
75+
aliases
76+
.buildSets()
77+
.map(set => [...set].map(ident => printIdentifier(ident))),
78+
),
79+
);
80+
}
81+
82+
/**
83+
* The alias sets returned by InferMutableRanges() accounts only for aliases that
84+
* are known to mutate together. Notably this skips cases where a value is captured
85+
* into some other value, but neither is subsequently mutated. An example is pushing
86+
* a mutable value onto an array, where neither the array or value are subsequently
87+
* mutated.
88+
*
89+
* This function extends the aliases sets to account for such capturing, so that we
90+
* can detect cases where one of the values in a set is mutated later (in an outer function)
91+
* we can correctly infer them as mutating together.
92+
*/
93+
function inferAliasesForCapturing(
94+
fn: HIRFunction,
95+
aliases: DisjointSet<Identifier>,
96+
): void {
97+
for (const block of fn.body.blocks.values()) {
98+
for (const instr of block.instructions) {
99+
const {lvalue, value} = instr;
100+
const hasStore =
101+
lvalue.effect === Effect.Store ||
102+
Iterable_some(
103+
eachInstructionValueOperand(value),
104+
operand => operand.effect === Effect.Store,
105+
);
106+
if (!hasStore) {
107+
continue;
108+
}
109+
const operands: Array<Identifier> = [];
110+
for (const lvalue of eachInstructionLValue(instr)) {
111+
operands.push(lvalue.identifier);
112+
}
113+
for (const operand of eachInstructionValueOperand(instr.value)) {
114+
if (
115+
operand.effect === Effect.Store ||
116+
operand.effect === Effect.Capture
117+
) {
118+
operands.push(operand.identifier);
119+
}
120+
}
121+
if (operands.length > 1) {
122+
aliases.union(operands);
123+
}
124+
}
125+
}
59126
}
60127

61-
function infer(loweredFunc: LoweredFunction): void {
128+
function infer(
129+
loweredFunc: LoweredFunction,
130+
aliases: DisjointSet<Identifier>,
131+
): void {
62132
for (const operand of loweredFunc.func.context) {
63133
const identifier = operand.identifier;
64134
CompilerError.invariant(operand.effect === Effect.Unknown, {
@@ -85,6 +155,23 @@ function infer(loweredFunc: LoweredFunction): void {
85155
operand.effect = Effect.Read;
86156
}
87157
}
158+
const contextIdentifiers = new Map(
159+
loweredFunc.func.context.map(place => [place.identifier, place]),
160+
);
161+
for (const set of aliases.buildSets()) {
162+
const contextOperands: Set<Place> = new Set(
163+
[...set]
164+
.map(identifier => contextIdentifiers.get(identifier))
165+
.filter(place => place != null) as Array<Place>,
166+
);
167+
if (contextOperands.size !== 0) {
168+
loweredFunc.func.effects ??= [];
169+
loweredFunc.func.effects?.push({
170+
kind: 'CaptureEffect',
171+
places: contextOperands,
172+
});
173+
}
174+
}
88175
}
89176

90177
function isMutatedOrReassigned(id: Identifier): boolean {

compiler/packages/babel-plugin-react-compiler/src/Inference/InferAlias.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ function inferInstr(
6060
alias = instrValue.value;
6161
break;
6262
}
63+
case 'IteratorNext': {
64+
alias = instrValue.collection;
65+
break;
66+
}
6367
default:
6468
return;
6569
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {HIRFunction, Identifier} from '../HIR/HIR';
9+
import DisjointSet from '../Utils/DisjointSet';
10+
11+
export function inferAliasForFunctionCaptureEffects(
12+
func: HIRFunction,
13+
aliases: DisjointSet<Identifier>,
14+
): void {
15+
for (const [_, block] of func.body.blocks) {
16+
for (const instr of block.instructions) {
17+
const {value} = instr;
18+
if (
19+
value.kind !== 'FunctionExpression' &&
20+
value.kind !== 'ObjectMethod'
21+
) {
22+
continue;
23+
}
24+
const loweredFunction = value.loweredFunc.func;
25+
for (const effect of loweredFunction.effects ?? []) {
26+
if (effect.kind !== 'CaptureEffect') {
27+
continue;
28+
}
29+
aliases.union([...effect.places].map(place => place.identifier));
30+
}
31+
}
32+
}
33+
}

compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts

Lines changed: 74 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -95,45 +95,58 @@ function inheritFunctionEffects(
9595

9696
return effects
9797
.flatMap(effect => {
98-
if (effect.kind === 'GlobalMutation' || effect.kind === 'ReactMutation') {
99-
return [effect];
100-
} else {
101-
const effects: Array<FunctionEffect | null> = [];
102-
CompilerError.invariant(effect.kind === 'ContextMutation', {
103-
reason: 'Expected ContextMutation',
104-
loc: null,
105-
});
106-
/**
107-
* Contextual effects need to be replayed against the current inference
108-
* state, which may know more about the value to which the effect applied.
109-
* The main cases are:
110-
* 1. The mutated context value is _still_ a context value in the current scope,
111-
* so we have to continue propagating the original context mutation.
112-
* 2. The mutated context value is a mutable value in the current scope,
113-
* so the context mutation was fine and we can skip propagating the effect.
114-
* 3. The mutated context value is an immutable value in the current scope,
115-
* resulting in a non-ContextMutation FunctionEffect. We propagate that new,
116-
* more detailed effect to the current function context.
117-
*/
118-
for (const place of effect.places) {
119-
if (state.isDefined(place)) {
120-
const replayedEffect = inferOperandEffect(state, {
121-
...place,
122-
loc: effect.loc,
123-
effect: effect.effect,
124-
});
125-
if (replayedEffect != null) {
126-
if (replayedEffect.kind === 'ContextMutation') {
127-
// Case 1, still a context value so propagate the original effect
128-
effects.push(effect);
129-
} else {
130-
// Case 3, immutable value so propagate the more precise effect
131-
effects.push(replayedEffect);
132-
}
133-
} // else case 2, local mutable value so this effect was fine
98+
switch (effect.kind) {
99+
case 'GlobalMutation':
100+
case 'ReactMutation': {
101+
return [effect];
102+
}
103+
case 'ContextMutation': {
104+
const effects: Array<FunctionEffect | null> = [];
105+
CompilerError.invariant(effect.kind === 'ContextMutation', {
106+
reason: 'Expected ContextMutation',
107+
loc: null,
108+
});
109+
/**
110+
* Contextual effects need to be replayed against the current inference
111+
* state, which may know more about the value to which the effect applied.
112+
* The main cases are:
113+
* 1. The mutated context value is _still_ a context value in the current scope,
114+
* so we have to continue propagating the original context mutation.
115+
* 2. The mutated context value is a mutable value in the current scope,
116+
* so the context mutation was fine and we can skip propagating the effect.
117+
* 3. The mutated context value is an immutable value in the current scope,
118+
* resulting in a non-ContextMutation FunctionEffect. We propagate that new,
119+
* more detailed effect to the current function context.
120+
*/
121+
for (const place of effect.places) {
122+
if (state.isDefined(place)) {
123+
const replayedEffect = inferOperandEffect(state, {
124+
...place,
125+
loc: effect.loc,
126+
effect: effect.effect,
127+
});
128+
if (replayedEffect != null) {
129+
if (replayedEffect.kind === 'ContextMutation') {
130+
// Case 1, still a context value so propagate the original effect
131+
effects.push(effect);
132+
} else {
133+
// Case 3, immutable value so propagate the more precise effect
134+
effects.push(replayedEffect);
135+
}
136+
} // else case 2, local mutable value so this effect was fine
137+
}
134138
}
139+
return effects;
140+
}
141+
case 'CaptureEffect': {
142+
return [];
143+
}
144+
default: {
145+
assertExhaustive(
146+
effect,
147+
`Unexpected effect kind '${(effect as any).kind}'`,
148+
);
135149
}
136-
return effects;
137150
}
138151
})
139152
.filter((effect): effect is FunctionEffect => effect != null);
@@ -298,26 +311,31 @@ export function inferTerminalFunctionEffects(
298311
export function transformFunctionEffectErrors(
299312
functionEffects: Array<FunctionEffect>,
300313
): Array<CompilerErrorDetailOptions> {
301-
return functionEffects.map(eff => {
302-
switch (eff.kind) {
303-
case 'ReactMutation':
304-
case 'GlobalMutation': {
305-
return eff.error;
306-
}
307-
case 'ContextMutation': {
308-
return {
309-
severity: ErrorSeverity.Invariant,
310-
reason: `Unexpected ContextMutation in top-level function effects`,
311-
loc: eff.loc,
312-
};
314+
return functionEffects
315+
.map(eff => {
316+
switch (eff.kind) {
317+
case 'ReactMutation':
318+
case 'GlobalMutation': {
319+
return eff.error;
320+
}
321+
case 'ContextMutation': {
322+
return {
323+
severity: ErrorSeverity.Invariant,
324+
reason: `Unexpected ContextMutation in top-level function effects`,
325+
loc: eff.loc,
326+
};
327+
}
328+
case 'CaptureEffect': {
329+
return null;
330+
}
331+
default:
332+
assertExhaustive(
333+
eff,
334+
`Unexpected function effect kind \`${(eff as any).kind}\``,
335+
);
313336
}
314-
default:
315-
assertExhaustive(
316-
eff,
317-
`Unexpected function effect kind \`${(eff as any).kind}\``,
318-
);
319-
}
320-
});
337+
})
338+
.filter(eff => eff != null) as Array<CompilerErrorDetailOptions>;
321339
}
322340

323341
function isEffectSafeOutsideRender(effect: FunctionEffect): boolean {

0 commit comments

Comments
 (0)