HIR-based AlignReactiveScopesToBlockScopes
ghstack-source-id: ae560a93f24fdbdb4ed73cdd889feb084bb172e5 Pull Request resolved: https://github.com/facebook/react-forget/pull/2822
Joe Savona committed
Apr 8, 2024 at 13:48 UTC
f4fbeb88038523edfb37646bc9d5314c0f7423ed
3 files changed
+220
-6
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Pipeline.ts
+18
-6
@@ -64,6 +64,7 @@ import {
64
renameVariables,
65
} from "../ReactiveScopes";
66
import { alignMethodCallScopes } from "../ReactiveScopes/AlignMethodCallScopes";
67
+import { alignReactiveScopesToBlockScopesHIR } from "../ReactiveScopes/AlignReactiveScopesToBlockScopesHIR";
68
import { pruneAlwaysInvalidatingScopes } from "../ReactiveScopes/PruneAlwaysInvalidatingScopes";
69
import { eliminateRedundantPhi, enterSSA, leaveSSA } from "../SSA";
70
import { inferTypes } from "../TypeInference";
@@ -233,6 +234,15 @@ function* runWithEnvironment(
234
value: hir,
235
});
236
237
+ if (env.config.enableAlignReactiveScopesToBlockScopesHIR) {
238
+ alignReactiveScopesToBlockScopesHIR(hir);
239
+ yield log({
240
+ kind: "hir",
241
+ name: "AlignReactiveScopesToBlockScopesHIR",
242
+ value: hir,
243
+ });
244
+ }
245
+
246
const reactiveFunction = buildReactiveFunction(hir);
247
yield log({
248
kind: "reactive",
@@ -247,12 +257,14 @@ function* runWithEnvironment(
257
value: reactiveFunction,
258
});
259
250
- alignReactiveScopesToBlockScopes(reactiveFunction);
251
- yield log({
252
- kind: "reactive",
253
- name: "AlignReactiveScopesToBlockScopes",
254
- value: reactiveFunction,
255
- });
260
+ if (!env.config.enableAlignReactiveScopesToBlockScopesHIR) {
261
+ alignReactiveScopesToBlockScopes(reactiveFunction);
262
+ yield log({
263
+ kind: "reactive",
264
+ name: "AlignReactiveScopesToBlockScopes",
265
+ value: reactiveFunction,
266
+ });
267
+ }
268
269
mergeOverlappingReactiveScopes(reactiveFunction);
270
yield log({
compiler/packages/babel-plugin-react-forget/src/HIR/Environment.ts
+2
@@ -169,6 +169,8 @@ const EnvironmentConfigSchema = z.object({
169
*/
170
enableUseTypeAnnotations: z.boolean().default(false),
171
172
+ enableAlignReactiveScopesToBlockScopesHIR: z.boolean().default(true),
173
+
174
/*
175
* Enable validation of hooks to partially check that the component honors the rules of hooks.
176
* When disabled, the component is assumed to follow the rules (though the Babel plugin looks
compiler/packages/babel-plugin-react-forget/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts
new
+200
@@ -0,0 +1,200 @@
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 { CompilerError } from "..";
9
+import {
10
+ BlockId,
11
+ HIRFunction,
12
+ InstructionId,
13
+ Place,
14
+ ReactiveScope,
15
+ makeInstructionId,
16
+} from "../HIR/HIR";
17
+import {
18
+ eachInstructionLValue,
19
+ eachInstructionValueOperand,
20
+ eachTerminalOperand,
21
+ mapTerminalSuccessors,
22
+ terminalFallthrough,
23
+} from "../HIR/visitors";
24
+import { retainWhere } from "../Utils/utils";
25
+
26
+/*
27
+ * Note: this is the 2nd of 4 passes that determine how to break a function into discrete
28
+ * reactive scopes (independently memoizeable units of code):
29
+ * 1. InferReactiveScopeVariables (on HIR) determines operands that mutate together and assigns
30
+ * them a unique reactive scope.
31
+ * 2. AlignReactiveScopesToBlockScopes (this pass, on ReactiveFunction) aligns reactive scopes
32
+ * to block scopes.
33
+ * 3. MergeOverlappingReactiveScopes (on ReactiveFunction) ensures that reactive scopes do not
34
+ * overlap, merging any such scopes.
35
+ * 4. BuildReactiveBlocks (on ReactiveFunction) groups the statements for each scope into
36
+ * a ReactiveScopeBlock.
37
+ *
38
+ * Prior inference passes assign a reactive scope to each operand, but the ranges of these
39
+ * scopes are based on specific instructions at arbitrary points in the control-flow graph.
40
+ * However, to codegen blocks around the instructions in each scope, the scopes must be
41
+ * aligned to block-scope boundaries - we can't memoize half of a loop!
42
+ *
43
+ * This pass updates reactive scope boundaries to align to control flow boundaries, for
44
+ * example:
45
+ *
46
+ * ```javascript
47
+ * function foo(cond, a) {
48
+ * ⌵ original scope
49
+ * ⌵ expanded scope
50
+ * const x = []; ⌝ ⌝
51
+ * if (cond) { ⎮ ⎮
52
+ * ... ⎮ ⎮
53
+ * x.push(a); ⌟ ⎮
54
+ * ... ⎮
55
+ * } ⌟
56
+ * }
57
+ * ```
58
+ *
59
+ * Here the original scope for `x` ended partway through the if consequent, but we can't
60
+ * memoize part of that block. This pass would align the scope to the end of the consequent.
61
+ *
62
+ * The more general rule is that a reactive scope may only end at the same block scope as it
63
+ * began: this pass therefore finds, for each scope, the block where that scope started and
64
+ * finds the first instruction after the scope's mutable range in that same block scope (which
65
+ * will be the updated end for that scope).
66
+ */
67
+
68
+export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void {
69
+ type BlockContext =
70
+ | { kind: "block"; block: BlockId; scopes: Array<ReactiveScope> }
71
+ | {
72
+ kind: "value";
73
+ start: InstructionId;
74
+ end: InstructionId;
75
+ scopes: Array<ReactiveScope>;
76
+ };
77
+ const blockContexts = new Map<BlockId, BlockContext>();
78
+ const seen = new Set<ReactiveScope>();
79
+
80
+ function recordPlace(place: Place, context: BlockContext): void {
81
+ const scope = place.identifier.scope;
82
+ if (scope == null) {
83
+ return;
84
+ }
85
+
86
+ if (seen.has(scope)) {
87
+ return;
88
+ }
89
+ if (context.kind === "value") {
90
+ scope.range.start = makeInstructionId(
91
+ Math.min(context.start, scope.range.start)
92
+ );
93
+ scope.range.end = makeInstructionId(
94
+ Math.max(context.end, scope.range.end)
95
+ );
96
+ }
97
+ seen.add(scope);
98
+ context.scopes.push(scope);
99
+ }
100
+
101
+ for (const [, block] of fn.body.blocks) {
102
+ const { instructions, terminal } = block;
103
+ let context = blockContexts.get(block.id);
104
+ if (context === undefined) {
105
+ if (block.kind === "block" || block.kind === "catch") {
106
+ context = { kind: "block", block: block.id, scopes: [] };
107
+ } else {
108
+ CompilerError.invariant(false, {
109
+ reason: `Expected a context to be initialized for value block`,
110
+ loc: instructions[0]?.loc ?? terminal.loc,
111
+ description: `No value for block bb${block.id}`,
112
+ });
113
+ }
114
+ } else if (block.kind === "block" && context.kind !== "block") {
115
+ CompilerError.invariant(false, {
116
+ reason: `Expected a block context for block`,
117
+ loc: instructions[0]?.loc ?? terminal.loc,
118
+ description: `Got value block for bb${block.id}`,
119
+ });
120
+ }
121
+
122
+ /*
123
+ * Any scopes that carried over across a terminal->fallback need their range extended
124
+ * to at least the first instruction of the fallback
125
+ */
126
+ const startId = instructions.at(0)?.id ?? terminal.id;
127
+ for (const scope of context.scopes) {
128
+ scope.range.end = makeInstructionId(Math.max(scope.range.end, startId));
129
+ }
130
+
131
+ /*
132
+ * Visit instructions, pruning scopes that end and recording new scopes that appear
133
+ * on operands
134
+ */
135
+ for (const instr of instructions) {
136
+ retainWhere(context.scopes, (scope) => scope.range.end > instr.id);
137
+ for (const lvalue of eachInstructionLValue(instr)) {
138
+ recordPlace(lvalue, context);
139
+ }
140
+ for (const operand of eachInstructionValueOperand(instr.value)) {
141
+ recordPlace(operand, context);
142
+ }
143
+ }
144
+
145
+ // Close scopes that complete at the terminal, and visit scopes of operands
146
+ retainWhere(context.scopes, (scope) => scope.range.end > terminal.id);
147
+ for (const operand of eachTerminalOperand(terminal)) {
148
+ recordPlace(operand, context);
149
+ }
150
+
151
+ // Save the current context for the fallback block, where this block scope continues
152
+ const fallthrough = terminalFallthrough(terminal);
153
+ if (fallthrough !== null && !blockContexts.has(fallthrough)) {
154
+ blockContexts.set(fallthrough, context);
155
+ }
156
+
157
+ /*
158
+ * Visit all successors (not just direct successors for control-flow ordering) to
159
+ * set a value block context where necessary to align the value block start/end
160
+ * back to the outer block scope.
161
+ *
162
+ * TODO: add a variant of eachTerminalSuccessor() that visits _all_ successors, not
163
+ * just those that are direct successors for normal control-flow ordering.
164
+ */
165
+ mapTerminalSuccessors(terminal, (successor) => {
166
+ const successorBlock = fn.body.blocks.get(successor)!;
167
+ /*
168
+ * we need the block kind check here because the do..while terminal's successor
169
+ * is a block, and try's successor is a catch block
170
+ */
171
+ if (
172
+ !blockContexts.has(successor) &&
173
+ successorBlock.kind !== "block" &&
174
+ successorBlock.kind !== "catch"
175
+ ) {
176
+ let valueContext: BlockContext;
177
+ if (context!.kind === "value") {
178
+ valueContext = context!;
179
+ } else {
180
+ CompilerError.invariant(fallthrough !== null, {
181
+ reason: `Expected a fallthrough for value block`,
182
+ loc: terminal.loc,
183
+ });
184
+ const fallthroughBlock = fn.body.blocks.get(fallthrough)!;
185
+ const nextId =
186
+ fallthroughBlock.instructions[0]?.id ??
187
+ fallthroughBlock.terminal.id;
188
+ valueContext = {
189
+ kind: "value",
190
+ start: terminal.id,
191
+ end: nextId,
192
+ scopes: [],
193
+ } as BlockContext;
194
+ }
195
+ blockContexts.set(successor, valueContext);
196
+ }
197
+ return successor;
198
+ });
199
+ }
200
+}