HIR-based MergeOverlappingScopes

ghstack-source-id: 93ac68114683044d7e6332fe7898b0dcc5dc6685
Pull Request resolved: https://github.com/facebook/react-forget/pull/2828
This commit is contained in:
Joe Savona
2024-04-08 17:09:07 -07:00
parent f4fbeb8803
commit f707cb5ff3
10 changed files with 737 additions and 75 deletions
@@ -264,14 +264,14 @@ function* runWithEnvironment(
name: "AlignReactiveScopesToBlockScopes",
value: reactiveFunction,
});
}
mergeOverlappingReactiveScopes(reactiveFunction);
yield log({
kind: "reactive",
name: "MergeOverlappingReactiveScopes",
value: reactiveFunction,
});
mergeOverlappingReactiveScopes(reactiveFunction);
yield log({
kind: "reactive",
name: "MergeOverlappingReactiveScopes",
value: reactiveFunction,
});
}
buildReactiveBlocks(reactiveFunction);
yield log({
@@ -10,6 +10,7 @@ import {
BlockId,
HIRFunction,
InstructionId,
MutableRange,
Place,
ReactiveScope,
makeInstructionId,
@@ -21,7 +22,9 @@ import {
mapTerminalSuccessors,
terminalFallthrough,
} from "../HIR/visitors";
import DisjointSet from "../Utils/DisjointSet";
import { retainWhere } from "../Utils/utils";
import { getPlaceScope } from "./BuildReactiveBlocks";
/*
* Note: this is the 2nd of 4 passes that determine how to break a function into discrete
@@ -64,119 +67,149 @@ import { retainWhere } from "../Utils/utils";
* finds the first instruction after the scope's mutable range in that same block scope (which
* will be the updated end for that scope).
*/
export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void {
type BlockContext =
| { kind: "block"; block: BlockId; scopes: Array<ReactiveScope> }
| {
kind: "value";
start: InstructionId;
end: InstructionId;
scopes: Array<ReactiveScope>;
};
const blockContexts = new Map<BlockId, BlockContext>();
const blockNodes = new Map<BlockId, BlockNode>();
const rootNode: BlockNode = {
kind: "node",
valueRange: null,
children: [],
id: makeInstructionId(0),
};
blockNodes.set(fn.body.entry, rootNode);
const seen = new Set<ReactiveScope>();
const placeScopes = new Map<Place, ReactiveScope>();
function recordPlace(place: Place, context: BlockContext): void {
const scope = place.identifier.scope;
function recordPlace(id: InstructionId, place: Place, node: BlockNode): void {
if (place.identifier.scope !== null) {
placeScopes.set(place, place.identifier.scope);
}
const scope = getPlaceScope(id, place);
if (scope == null) {
return;
}
node.children.push({ kind: "scope", scope, id });
if (seen.has(scope)) {
return;
}
if (context.kind === "value") {
seen.add(scope);
if (node.valueRange !== null) {
scope.range.start = makeInstructionId(
Math.min(context.start, scope.range.start)
Math.min(node.valueRange.start, scope.range.start)
);
scope.range.end = makeInstructionId(
Math.max(context.end, scope.range.end)
Math.max(node.valueRange.end, scope.range.end)
);
}
seen.add(scope);
context.scopes.push(scope);
}
for (const [, block] of fn.body.blocks) {
const { instructions, terminal } = block;
let context = blockContexts.get(block.id);
if (context === undefined) {
if (block.kind === "block" || block.kind === "catch") {
context = { kind: "block", block: block.id, scopes: [] };
} else {
CompilerError.invariant(false, {
reason: `Expected a context to be initialized for value block`,
loc: instructions[0]?.loc ?? terminal.loc,
description: `No value for block bb${block.id}`,
});
}
} else if (block.kind === "block" && context.kind !== "block") {
const node = blockNodes.get(block.id);
if (node === undefined) {
CompilerError.invariant(false, {
reason: `Expected a block context for block`,
reason: `Expected a node to be initialized for block`,
loc: instructions[0]?.loc ?? terminal.loc,
description: `Got value block for bb${block.id}`,
description: `No node for block bb${block.id} (${block.kind})`,
});
}
/*
* Any scopes that carried over across a terminal->fallback need their range extended
* to at least the first instruction of the fallback
*/
const startId = instructions.at(0)?.id ?? terminal.id;
for (const scope of context.scopes) {
scope.range.end = makeInstructionId(Math.max(scope.range.end, startId));
}
/*
* Visit instructions, pruning scopes that end and recording new scopes that appear
* on operands
*/
for (const instr of instructions) {
retainWhere(context.scopes, (scope) => scope.range.end > instr.id);
for (const lvalue of eachInstructionLValue(instr)) {
recordPlace(lvalue, context);
recordPlace(instr.id, lvalue, node);
}
for (const operand of eachInstructionValueOperand(instr.value)) {
recordPlace(operand, context);
recordPlace(instr.id, operand, node);
}
}
// Close scopes that complete at the terminal, and visit scopes of operands
retainWhere(context.scopes, (scope) => scope.range.end > terminal.id);
for (const operand of eachTerminalOperand(terminal)) {
recordPlace(operand, context);
recordPlace(terminal.id, operand, node);
}
// Save the current context for the fallback block, where this block scope continues
// Save the current node for the fallback block, where this block scope continues
const fallthrough = terminalFallthrough(terminal);
if (fallthrough !== null && !blockContexts.has(fallthrough)) {
blockContexts.set(fallthrough, context);
if (fallthrough !== null && !blockNodes.has(fallthrough)) {
/*
* Any scopes that carried over across a terminal->fallback need their range extended
* to at least the first instruction of the fallback
*
* Note that it's possible for a terminal such as an if or switch to have a null fallback,
* indicating that all control-flow paths diverge instead of reaching the fallthrough.
* In this case there isn't an instruction id in the program that we can point to for the
* updated range. Since the output is correct in this case we leave it, but it would be
* more correct to find the maximum instuction id in the whole program and set the range.end
* to one greater. Alternatively, we could leave in an unreachable fallthrough (with a new
* "unreachable" terminal variant, perhaps) and use that instruction id.
*/
const fallthroughBlock = fn.body.blocks.get(fallthrough)!;
const nextId =
fallthroughBlock.instructions[0]?.id ?? fallthroughBlock.terminal.id;
for (const child of node.children) {
if (child.kind !== "scope") {
continue;
}
const scope = child.scope;
if (scope.range.end > terminal.id) {
scope.range.end = makeInstructionId(
Math.max(scope.range.end, nextId)
);
}
}
blockNodes.set(fallthrough, node);
}
/*
* Visit all successors (not just direct successors for control-flow ordering) to
* set a value block context where necessary to align the value block start/end
* set a value block node where necessary to align the value block start/end
* back to the outer block scope.
*
* TODO: add a variant of eachTerminalSuccessor() that visits _all_ successors, not
* just those that are direct successors for normal control-flow ordering.
*/
mapTerminalSuccessors(terminal, (successor) => {
if (blockNodes.has(successor)) {
return successor;
}
const successorBlock = fn.body.blocks.get(successor)!;
/*
* we need the block kind check here because the do..while terminal's successor
* is a block, and try's successor is a catch block
*/
if (
!blockContexts.has(successor) &&
successorBlock.kind !== "block" &&
successorBlock.kind !== "catch"
if (successorBlock.kind === "block" || successorBlock.kind === "catch") {
const childNode: BlockNode = {
kind: "node",
id: terminal.id,
children: [],
valueRange: null,
};
node.children.push(childNode);
blockNodes.set(successor, childNode);
} else if (
node.valueRange === null ||
terminal.kind === "ternary" ||
terminal.kind === "logical" ||
terminal.kind === "optional"
) {
let valueContext: BlockContext;
if (context!.kind === "value") {
valueContext = context!;
} else {
/**
* Create a new scope node whenever we transition from block scope -> value scope.
*
* For compatibility with the previous ReactiveFunction-based scope merging logic,
* we also create new scope nodes for ternary, logical, and optional terminals.
* However, inside value blocks we always store a range (valueRange) that is the
* start/end instruction ids at the nearest parent block scope level, so that
* scopes inside the value blocks can be extended to align with block scope
* instructions.
*/
const childNode = {
kind: "node",
id: terminal.id,
children: [],
valueRange: null,
} as BlockNode;
if (node.valueRange === null) {
// Transition from block->value scope, derive the outer block scope range
CompilerError.invariant(fallthrough !== null, {
reason: `Expected a fallthrough for value block`,
loc: terminal.loc,
@@ -185,16 +218,172 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void {
const nextId =
fallthroughBlock.instructions[0]?.id ??
fallthroughBlock.terminal.id;
valueContext = {
kind: "value",
childNode.valueRange = {
start: terminal.id,
end: nextId,
scopes: [],
} as BlockContext;
};
} else {
// else value->value transition, reuse the range
childNode.valueRange = node.valueRange;
}
blockContexts.set(successor, valueContext);
node.children.push(childNode);
blockNodes.set(successor, childNode);
} else {
// this is a value -> value block transition, reuse the node
blockNodes.set(successor, node);
}
return successor;
});
}
// console.log(_debug(rootNode));
const joinedScopes: DisjointSet<ReactiveScope> =
mergeOverlappingScopes(rootNode);
joinedScopes.forEach((scope, groupScope) => {
if (scope !== groupScope) {
groupScope.range.start = makeInstructionId(
Math.min(groupScope.range.start, scope.range.start)
);
groupScope.range.end = makeInstructionId(
Math.max(groupScope.range.end, scope.range.end)
);
}
});
for (const [place, originalScope] of placeScopes) {
const nextScope = joinedScopes.find(originalScope);
if (nextScope !== null && nextScope !== originalScope) {
place.identifier.scope = nextScope;
}
}
}
type BlockNode = {
kind: "node";
id: InstructionId;
valueRange: MutableRange | null;
children: Array<BlockNode | ReactiveScopeNode>;
};
type ReactiveScopeNode = {
kind: "scope";
id: InstructionId;
scope: ReactiveScope;
};
function _debug(node: BlockNode): string {
const buf: Array<string> = [];
_printNode(node, buf, 0);
return buf.join("\n");
}
function _printNode(
node: BlockNode | ReactiveScopeNode,
out: Array<string>,
depth: number = 0
): void {
let prefix = " ".repeat(depth);
if (node.kind === "scope") {
out.push(
`${prefix}[${node.id}] @${node.scope.id} [${node.scope.range.start}:${node.scope.range.end}]`
);
} else {
let range =
node.valueRange !== null
? ` [${node.valueRange.start}:${node.valueRange.end}]`
: "";
out.push(`${prefix}[${node.id}] node${range} [`);
for (const child of node.children) {
_printNode(child, out, depth + 1);
}
out.push(`${prefix}]`);
}
}
type ScopeItem = {
scope: ReactiveScope;
shadowedBy: ReactiveScope | null;
};
class BlockItem {
seen: Set<ReactiveScope> = new Set();
scopes: Array<ScopeItem> = [];
}
function mergeOverlappingScopes(root: BlockNode): DisjointSet<ReactiveScope> {
const seen = new Set<ReactiveScope>();
const joined = new DisjointSet<ReactiveScope>();
function visit(node: BlockNode, stack: Array<BlockItem>): void {
const currentBlock = stack.at(-1)!;
child: for (const child of node.children) {
retainWhere(currentBlock.scopes, (item) => {
if (item.scope.range.end > child.id) {
return true;
} else {
currentBlock.seen.delete(item.scope);
return false;
}
});
if (child.kind === "node") {
visit(child, [...stack, new BlockItem()]);
} else {
const scope = child.scope;
if (!seen.has(scope)) {
seen.add(scope);
currentBlock.seen.add(scope);
currentBlock.scopes.push({ shadowedBy: null, scope });
continue;
}
let index = stack.length - 1;
let nextBlock = currentBlock;
while (!nextBlock.seen.has(scope)) {
joined.union([scope, ...nextBlock.scopes.map((s) => s.scope)]);
index--;
if (index < 0) {
currentBlock.seen.add(scope);
currentBlock.scopes.push({ shadowedBy: null, scope });
continue child;
}
nextBlock = stack[index]!;
}
// Handle interleaving within a given block scope
let found = false;
for (let i = 0; i < nextBlock.scopes.length; i++) {
const current = nextBlock.scopes[i]!;
if (current.scope.id === scope.id) {
found = true;
if (current.shadowedBy !== null) {
joined.union([current.shadowedBy, current.scope]);
}
} else if (found && current.shadowedBy === null) {
// `scope` is shadowing `current` and may interleave
current.shadowedBy = scope;
if (current.scope.range.end > scope.range.end) {
/*
* Current is shadowed by `scope`, and we know that `current` will mutate
* again (per its range), so the scopes are already known to interleave.
*
* Eagerly extend the ranges of the scopes so that we don't prematurely end
* a scope relative to its eventual post-merge mutable range
*/
const end = makeInstructionId(
Math.max(current.scope.range.end, scope.range.end)
);
current.scope.range.end = end;
scope.range.end = end;
joined.union([current.scope, scope]);
}
}
}
if (!currentBlock.seen.has(scope)) {
currentBlock.seen.add(scope);
currentBlock.scopes.push({ shadowedBy: null, scope });
}
}
}
}
visit(root, [new BlockItem()]);
return joined;
}
@@ -0,0 +1,66 @@
## Input
```javascript
import { makeObject_Primitives } from "shared-runtime";
function Component(props) {
const object = makeObject_Primitives();
if (props.cond) {
object.value = 1;
return object;
} else {
object.value = props.value;
return object;
}
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, value: [0, 1, 2] }],
};
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react";
import { makeObject_Primitives } from "shared-runtime";
function Component(props) {
const $ = useMemoCache(2);
let t0;
if ($[0] !== props) {
t0 = Symbol.for("react.early_return_sentinel");
bb9: {
const object = makeObject_Primitives();
if (props.cond) {
object.value = 1;
t0 = object;
break bb9;
} else {
object.value = props.value;
t0 = object;
break bb9;
}
}
$[0] = props;
$[1] = t0;
} else {
t0 = $[1];
}
if (t0 !== Symbol.for("react.early_return_sentinel")) {
return t0;
}
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, value: [0, 1, 2] }],
};
```
### Eval output
(kind: ok) {"a":0,"b":"value1","c":true,"value":[0,1,2]}
@@ -0,0 +1,17 @@
import { makeObject_Primitives } from "shared-runtime";
function Component(props) {
const object = makeObject_Primitives();
if (props.cond) {
object.value = 1;
return object;
} else {
object.value = props.value;
return object;
}
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, value: [0, 1, 2] }],
};
@@ -0,0 +1,106 @@
## Input
```javascript
import { useState } from "react";
function Component(props) {
const items = props.items ? props.items.slice() : [];
const [state] = useState("");
return props.cond ? (
<div>{state}</div>
) : (
<div>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, items: [{ id: 0, name: "Alice" }] }],
sequentialRenders: [
{ cond: false, items: [{ id: 0, name: "Alice" }] },
{
cond: false,
items: [
{ id: 0, name: "Alice" },
{ id: 1, name: "Bob" },
],
},
{
cond: true,
items: [
{ id: 0, name: "Alice" },
{ id: 1, name: "Bob" },
],
},
{
cond: false,
items: [
{ id: 1, name: "Bob" },
{ id: 2, name: "Claire" },
],
},
],
};
```
## Code
```javascript
import { useState } from "react";
function Component(props) {
const items = props.items ? props.items.slice() : [];
const [state] = useState("");
return props.cond ? (
<div>{state}</div>
) : (
<div>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, items: [{ id: 0, name: "Alice" }] }],
sequentialRenders: [
{ cond: false, items: [{ id: 0, name: "Alice" }] },
{
cond: false,
items: [
{ id: 0, name: "Alice" },
{ id: 1, name: "Bob" },
],
},
{
cond: true,
items: [
{ id: 0, name: "Alice" },
{ id: 1, name: "Bob" },
],
},
{
cond: false,
items: [
{ id: 1, name: "Bob" },
{ id: 2, name: "Claire" },
],
},
],
};
```
### Eval output
(kind: ok) <div><div>Alice</div></div>
<div><div>Alice</div><div>Bob</div></div>
<div></div>
<div><div>Bob</div><div>Claire</div></div>
@@ -0,0 +1,44 @@
import { useState } from "react";
function Component(props) {
const items = props.items ? props.items.slice() : [];
const [state] = useState("");
return props.cond ? (
<div>{state}</div>
) : (
<div>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, items: [{ id: 0, name: "Alice" }] }],
sequentialRenders: [
{ cond: false, items: [{ id: 0, name: "Alice" }] },
{
cond: false,
items: [
{ id: 0, name: "Alice" },
{ id: 1, name: "Bob" },
],
},
{
cond: true,
items: [
{ id: 0, name: "Alice" },
{ id: 1, name: "Bob" },
],
},
{
cond: false,
items: [
{ id: 1, name: "Bob" },
{ id: 2, name: "Claire" },
],
},
],
};
@@ -0,0 +1,115 @@
## Input
```javascript
import { identity } from "shared-runtime";
const DISPLAY = true;
function Component({ cond = false, id }) {
return (
<>
<div className={identity(styles.a, id !== null ? styles.b : {})}></div>
{cond === false && (
<div className={identity(styles.c, DISPLAY ? styles.d : {})} />
)}
</>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, id: 42 }],
sequentialRenders: [
{ cond: false, id: 4 },
{ cond: true, id: 4 },
{ cond: true, id: 42 },
],
};
const styles = {
a: "a",
b: "b",
c: "c",
d: "d",
};
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react";
import { identity } from "shared-runtime";
const DISPLAY = true;
function Component(t0) {
const $ = useMemoCache(9);
const { cond: t1, id } = t0;
const cond = t1 === undefined ? false : t1;
let t2;
if ($[0] !== id) {
t2 = identity(styles.a, id !== null ? styles.b : {});
$[0] = id;
$[1] = t2;
} else {
t2 = $[1];
}
let t3;
if ($[2] !== t2) {
t3 = <div className={t2} />;
$[2] = t2;
$[3] = t3;
} else {
t3 = $[3];
}
let t4;
if ($[4] !== cond) {
t4 = cond === false && (
<div className={identity(styles.c, DISPLAY ? styles.d : {})} />
);
$[4] = cond;
$[5] = t4;
} else {
t4 = $[5];
}
let t5;
if ($[6] !== t3 || $[7] !== t4) {
t5 = (
<>
{t3}
{t4}
</>
);
$[6] = t3;
$[7] = t4;
$[8] = t5;
} else {
t5 = $[8];
}
return t5;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, id: 42 }],
sequentialRenders: [
{ cond: false, id: 4 },
{ cond: true, id: 4 },
{ cond: true, id: 42 },
],
};
const styles = {
a: "a",
b: "b",
c: "c",
d: "d",
};
```
### Eval output
(kind: ok) <div class="a"></div><div class="c"></div>
<div class="a"></div>
<div class="a"></div>
@@ -0,0 +1,31 @@
import { identity } from "shared-runtime";
const DISPLAY = true;
function Component({ cond = false, id }) {
return (
<>
<div className={identity(styles.a, id !== null ? styles.b : {})}></div>
{cond === false && (
<div className={identity(styles.c, DISPLAY ? styles.d : {})} />
)}
</>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false, id: 42 }],
sequentialRenders: [
{ cond: false, id: 4 },
{ cond: true, id: 4 },
{ cond: true, id: 42 },
],
};
const styles = {
a: "a",
b: "b",
c: "c",
d: "d",
};
@@ -0,0 +1,71 @@
## Input
```javascript
import fbt from "fbt";
import { Stringify } from "shared-runtime";
function Component(props) {
const label = fbt(
fbt.plural("bar", props.value.length, {
many: "bars",
showCount: "yes",
}),
"The label text"
);
return props.cond ? (
<Stringify
description={<fbt desc="Some text">Text here</fbt>}
label={label.toString()}
/>
) : null;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: true, value: [0, 1, 2] }],
};
```
## Code
```javascript
import { unstable_useMemoCache as useMemoCache } from "react";
import fbt from "fbt";
import { Stringify } from "shared-runtime";
function Component(props) {
const $ = useMemoCache(3);
let t0;
if ($[0] !== props.value.length || $[1] !== props.cond) {
const label = fbt._(
{ "*": "{number} bars", _1: "1 bar" },
[fbt._plural(props.value.length, "number")],
{ hk: "4mUen7" }
);
t0 = props.cond ? (
<Stringify
description={fbt._("Text here", null, { hk: "21YpZs" })}
label={label.toString()}
/>
) : null;
$[0] = props.value.length;
$[1] = props.cond;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: true, value: [0, 1, 2] }],
};
```
### Eval output
(kind: ok) <div>{"description":"Text here","label":"3 bars"}</div>
@@ -0,0 +1,23 @@
import fbt from "fbt";
import { Stringify } from "shared-runtime";
function Component(props) {
const label = fbt(
fbt.plural("bar", props.value.length, {
many: "bars",
showCount: "yes",
}),
"The label text"
);
return props.cond ? (
<Stringify
description={<fbt desc="Some text">Text here</fbt>}
label={label.toString()}
/>
) : null;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: true, value: [0, 1, 2] }],
};