@samitouri / QOS-React-2 / commits / 4005f862bd

[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];