[rhir] Refactor ReactiveScopeDependency, unconditional dependencies (1/2)
--- See comment block in `PropagateScopeDependencies` and added test case `reduce-reactive-unconditional-dependencies` for correctness properties / dependency merging logic.
Mofei Zhang committed
Feb 27, 2023 at 13:38 UTC
4005f862bd811e92073a0b5047bc8ede84a65d80
2 files changed
+205
-34
compiler/forget/src/ReactiveScopes/PropagateScopeDependencies.ts
+201
-30
@@ -5,6 +5,7 @@
5
* LICENSE file in the root directory of this source tree.
6
*/
7
8
+import invariant from "invariant";
9
import {
10
Identifier,
11
IdentifierId,
@@ -21,6 +22,7 @@ import {
22
ReactiveValue,
23
} from "../HIR/HIR";
24
import { eachInstructionValueOperand } from "../HIR/visitors";
25
+import { todoInvariant } from "../Utils/todo";
26
import { assertExhaustive } from "../Utils/utils";
27
import { eachReactiveValueOperand } from "./visitors";
28
@@ -55,13 +57,206 @@ type Decl = {
57
58
type Scopes = Array<ReactiveScope>;
59
60
+// TODO(@mofeiZ): remove once we replace Context.#dependencies, #properties with tree
61
+// representation
62
+function areDependenciesEqual(
63
+ dep1: ReactiveScopeDependency,
64
+ dep2: ReactiveScopeDependency
65
+): boolean {
66
+ if (dep1.identifier.id !== dep2.identifier.id) {
67
+ return false;
68
+ }
69
+ const dep1Path = dep1.path;
70
+ const dep2Path = dep2.path;
71
+
72
+ if (dep1Path === dep2Path) {
73
+ // dep1Path and dep2Path might both be null (representing the empty path)
74
+ return true;
75
+ } else if (
76
+ dep1Path === null ||
77
+ dep2Path === null ||
78
+ dep2Path.length != dep1Path.length
79
+ ) {
80
+ return false;
81
+ }
82
+
83
+ return dep1Path.every((dep1Property, idx) => {
84
+ return dep1Property === dep2Path[idx];
85
+ });
86
+}
87
+
88
+/**
89
+ * Enum representing the access type of single property on a parent object.
90
+ * We distinguish on two independent axes:
91
+ * Conditional / Unconditional:
92
+ * - whether this property is accessed unconditionally (within the ReactiveBlock)
93
+ * Access / Dependency:
94
+ * - Access: this property is read on the path of a dependency. We do not
95
+ * need to track change variables for accessed properties. Tracking accesses
96
+ * helps Forget do more granular dependency tracking.
97
+ * - Dependency: this property is read as a dependency and we must track changes
98
+ * to it for correctness.
99
+ *
100
+ */
101
+enum PropertyAccessType {
102
+ UnconditionalAccess = "UnconditionalAccess",
103
+ UnconditionalDependency = "UnconditionalDependency",
104
+}
105
+
106
+function merge(
107
+ access1: PropertyAccessType,
108
+ access2: PropertyAccessType
109
+): PropertyAccessType {
110
+ if (
111
+ access1 === PropertyAccessType.UnconditionalDependency ||
112
+ access2 === PropertyAccessType.UnconditionalDependency
113
+ ) {
114
+ return PropertyAccessType.UnconditionalDependency;
115
+ } else {
116
+ return PropertyAccessType.UnconditionalAccess;
117
+ }
118
+}
119
+
120
+type DependencyNode = {
121
+ properties: Map<string, DependencyNode>;
122
+ accessType: PropertyAccessType;
123
+};
124
+
125
+type ReduceResultNode = {
126
+ relativePath: Array<string>;
127
+ accessType: PropertyAccessType;
128
+};
129
+
130
+const promoteUncondResult = [
131
+ {
132
+ relativePath: [],
133
+ accessType: PropertyAccessType.UnconditionalDependency,
134
+ },
135
+];
136
+
137
+function deriveMinimalDependenciesInSubtree(
138
+ dep: DependencyNode
139
+): Array<ReduceResultNode> {
140
+ const results: Array<ReduceResultNode> = [];
141
+ for (const [childName, childNode] of dep.properties) {
142
+ const reduceResult = deriveMinimalDependenciesInSubtree(childNode).map(
143
+ ({ relativePath, accessType }) => {
144
+ return {
145
+ relativePath: [childName, ...relativePath],
146
+ accessType,
147
+ };
148
+ }
149
+ );
150
+ results.push(...reduceResult);
151
+ }
152
+
153
+ switch (dep.accessType) {
154
+ case PropertyAccessType.UnconditionalDependency: {
155
+ return promoteUncondResult;
156
+ }
157
+ case PropertyAccessType.UnconditionalAccess: {
158
+ // all children are unconditional dependencies, return them to preserve granularity
159
+ return results;
160
+ }
161
+ default: {
162
+ todoInvariant(
163
+ false,
164
+ "[PropgateScopeDependencies] Handle conditional dependencies."
165
+ );
166
+ }
167
+ }
168
+}
169
+
170
+/**
171
+ * Finalizes a set of ReactiveScopeDependencies to produce a set of minimal unconditional
172
+ * dependencies, preserving granular accesses when possible.
173
+ *
174
+ * Correctness properties:
175
+ * - All dependencies to a ReactiveBlock must be tracked.
176
+ * We can always truncate a dependency's path to a subpath, due to Forget assuming
177
+ * deep immutability. If the value produced by a subpath has not changed, then
178
+ * dependency must have not changed.
179
+ * i.e. props.a === $[..] implies props.a.b === $[..]
180
+ *
181
+ * Note the inverse is not true, but this only means a false positive (we run the
182
+ * reactive block more than needed).
183
+ * i.e. props.a !== $[..] does not imply props.a.b !== $[..]
184
+ *
185
+ * - The dependencies of a finalized ReactiveBlock must be all safe to access
186
+ * unconditionally (i.e. preserve program semantics with respect to nullthrows).
187
+ * If a dependency is only accessed within a conditional, we must track the nearest
188
+ * unconditionally accessed subpath instead.
189
+ * @param initialDeps
190
+ * @returns
191
+ */
192
+
193
+// TODO(@mofeiZ): change once we replace Context.#dependencies, #properties with tree
194
+// representation
195
+function deriveMinimalDependencies(
196
+ initialDeps: Set<ReactiveScopeDependency>
197
+): Set<ReactiveScopeDependency> {
198
+ const depRoots = new Map<IdentifierId, [Identifier, DependencyNode]>();
199
+
200
+ for (const dep of initialDeps) {
201
+ let root = depRoots.get(dep.identifier.id)?.[1];
202
+ const path = dep.path ?? [];
203
+ if (root == null) {
204
+ // roots can always be accessed unconditionally in JS
205
+ root = {
206
+ properties: new Map(),
207
+ accessType: PropertyAccessType.UnconditionalAccess,
208
+ };
209
+ depRoots.set(dep.identifier.id, [dep.identifier, root]);
210
+ }
211
+ let currNode: DependencyNode = root;
212
+ // TODO(@mofeiZ) add conditional access/dependencies here
213
+ const accessType = PropertyAccessType.UnconditionalAccess;
214
+ const depType = PropertyAccessType.UnconditionalDependency;
215
+
216
+ for (const property of path) {
217
+ // all properties read 'on the way' to a dependency are marked as 'access'
218
+ let currChild = currNode.properties.get(property);
219
+ if (currChild == null) {
220
+ currChild = {
221
+ properties: new Map(),
222
+ accessType,
223
+ };
224
+ currNode.properties.set(property, currChild);
225
+ } else {
226
+ currChild.accessType = merge(currChild.accessType, accessType);
227
+ }
228
+ currNode = currChild;
229
+ }
230
+
231
+ // final property read should be marked as `dependency`
232
+ currNode.accessType = merge(currNode.accessType, depType);
233
+ }
234
+
235
+ const results = new Set<ReactiveScopeDependency>();
236
+ for (const [_, [rootId, rootNode]] of depRoots) {
237
+ const deps = deriveMinimalDependenciesInSubtree(rootNode);
238
+ invariant(
239
+ deps.every(
240
+ (dep) => dep.accessType === PropertyAccessType.UnconditionalDependency
241
+ ),
242
+ "[PropagateScopeDependencies] All dependencies must be reduced to unconditional dependencies."
243
+ );
244
+
245
+ for (const dep of deps) {
246
+ results.add({
247
+ identifier: rootId,
248
+ path: dep.relativePath,
249
+ });
250
+ }
251
+ }
252
+
253
+ return results;
254
+}
255
+
256
class Context {
257
#declarations: DeclMap = new Map();
258
#reassignments: Map<Identifier, Decl> = new Map();
259
#dependencies: Set<ReactiveScopeDependency> = new Set();
62
- // Produces a de-duplicated mapping of Id -> ReactiveScopeDependency
63
- // This helps with.. temporaries that are created only for property loads
64
- // but can be generalized to all non-allocating temporaries
260
#properties: Map<Identifier, ReactiveScopeDependency> = new Map();
261
#temporaries: Map<Identifier, Place> = new Map();
262
#scopes: Scopes = [];
@@ -74,7 +269,7 @@ class Context {
269
fn();
270
this.#scopes.pop();
271
this.#dependencies = previousDependencies;
77
- return scopedDependencies;
272
+ return deriveMinimalDependencies(scopedDependencies);
273
}
274
275
/**
@@ -195,35 +390,11 @@ class Context {
390
(currentDeclaration.scope == null ||
391
!this.#isScopeActive(currentDeclaration.scope))
392
) {
198
- // Below logic ensures that `operand` is either added to `this.#dependencies`
199
- // directly, or is covered by an existing dependency.
200
-
393
// Check if there is an existing dependency that describes this operand
394
+ // We do not try to join/reduce dependencies here due to missing info
395
for (const dep of this.#dependencies) {
203
- // not the same identifier
204
- if (dep.identifier.id !== maybeDependency.identifier.id) {
205
- continue;
206
- }
207
- const depPath = dep.path ?? [];
208
- const operandPath = maybeDependency.path ?? [];
209
- // both the operand and dep have paths, determine if the existing path
210
- // is a subset of the new path
211
- let commonPathIndex = 0;
212
- while (
213
- commonPathIndex < operandPath.length &&
214
- commonPathIndex < depPath.length &&
215
- operandPath[commonPathIndex] === depPath[commonPathIndex]
216
- ) {
217
- commonPathIndex++;
218
- }
219
- if (commonPathIndex === depPath.length) {
220
- // existing dep is a subpath of the operand, so we don't need to
221
- // add the operand
396
+ if (areDependenciesEqual(dep, maybeDependency)) {
397
return;
223
- } else if (commonPathIndex === operandPath.length) {
224
- // operand is a subpath of the existing path, delete the existing
225
- // path
226
- this.#dependencies.delete(dep);
398
}
399
}
400
this.#dependencies.add(maybeDependency);
compiler/forget/src/__tests__/fixtures/hir/reduce-reactive-uncond-deps-nonoverlap-descendant.expect.md
+4
-4
@@ -22,8 +22,8 @@ function TestNonOverlappingDescendantTracked(props) {
22
function TestNonOverlappingDescendantTracked(props) {
23
const $ = React.unstable_useMemoCache(4);
24
const c_0 = $[0] !== props.a.x.y;
25
- const c_1 = $[1] !== props.b;
26
- const c_2 = $[2] !== props.a.c.x.y.z;
25
+ const c_1 = $[1] !== props.a.c.x.y.z;
26
+ const c_2 = $[2] !== props.b;
27
let x;
28
if (c_0 || c_1 || c_2) {
29
x = {};
@@ -31,8 +31,8 @@ function TestNonOverlappingDescendantTracked(props) {
31
x.b = props.b;
32
x.c = props.a.c.x.y.z;
33
$[0] = props.a.x.y;
34
- $[1] = props.b;
35
- $[2] = props.a.c.x.y.z;
34
+ $[1] = props.a.c.x.y.z;
35
+ $[2] = props.b;
36
$[3] = x;
37
} else {
38
x = $[3];