64e60fedac
Closes https://github.com/dart-lang/sdk/pull/60091 GitOrigin-RevId: 3e4a62f56820c02997ea78cfb95ef5b632cc12af Change-Id: Ie5b0adfdf4bc05435e0d5f07dc6ae36b1d413f76 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/408881 Reviewed-by: Sigmund Cherem <sigmund@google.com> Commit-Queue: Sigmund Cherem <sigmund@google.com>
386 lines
11 KiB
Markdown
386 lines
11 KiB
Markdown
# Guide to Program Split Constraints in Dart2js
|
||
|
||
## Introduction
|
||
Deferred loading can be a very powerful tool for improving IPL in
|
||
Dart2js. However, if Dart2js generates too many part files, the user
|
||
experience can degrade significantly. This degradation occurs
|
||
because each part file has to be downloaded and initialized. While
|
||
the download overhead can be minimized via bundling, there is
|
||
currently no way to alleviate initialization overhead.
|
||
|
||
The Dart2js team has explored ways to reduce part file
|
||
initialization overhead, and we have ideas for further incremental
|
||
improvements. However, ‘zero’ overhead is impossible as a
|
||
significant source of initialization overhead can be attributed to
|
||
parsing and compiling JS, and a naive restructuring of part files
|
||
for reduced overhead will significantly harm steady state
|
||
performance.
|
||
|
||
Though someday initializing part files may be as close to ‘free’ as
|
||
possible, in the meantime clients want to ship more granular apps,
|
||
and thus there is an urgent need for addressing initialization
|
||
overhead. One obvious way to address initialization overhead is to
|
||
reduce the number of part files. Unfortunately, doing this
|
||
automatically turns out to be non-trivial. Dart2js simply does not
|
||
have enough information to intelligently reduce the number of part
|
||
files, and a naive reduction will do more harm than good, bloating
|
||
load lists with code that may be totally unrelated.
|
||
|
||
In order to reduce the number of part files efficiently, Dart2js
|
||
needs accurate load order information. Program split constraints are
|
||
a way for user’s of Dart2js to supply load order information to the
|
||
compiler. With accurate information about the relative ordering of
|
||
loadLibrary calls, Dart2js is able to constrain the deferred loading
|
||
graph, and reduce the number of part files all without bloating load
|
||
lists unnecessarily.
|
||
|
||
One final point, program split constraints are only useful if a
|
||
program is structured in a hierarchical manner, i.e. where
|
||
loadLibrary calls frequently dominate other loadLibrary calls. If
|
||
programs are structured hierarchically, and a complete load order
|
||
graph can be provided, then Dart2js can reduce the number of part
|
||
files, in many cases very significantly.
|
||
|
||
|
||
## Constraints
|
||
Practically, a program split constraints file is just a yaml list of
|
||
constraint nodes. There are different types of constraint nodes,
|
||
with different properties, but nearly all of them reduce the number
|
||
of part files. Writing constraint files requires a good
|
||
understanding of the ordering of loadLibrary calls in a program. It
|
||
is worth pointing out that constraints always affect performance,
|
||
and never program correctness. Thus, incorrect use of constraints
|
||
should never break a valid Dart program, but can negatively impact
|
||
performance.
|
||
|
||
### Reference Nodes
|
||
The most basic node type is a reference node. A reference node is
|
||
just a way to create a symbol which represents a deferred import.
|
||
|
||
For example:
|
||
**foo.dart:**
|
||
|
||
```dart
|
||
import '...' deferred as baz;
|
||
```
|
||
|
||
**constraints.yaml:**
|
||
|
||
```yaml
|
||
...
|
||
- type: reference
|
||
name: baz
|
||
import: /path/to/foo.dart#baz
|
||
...
|
||
```
|
||
|
||
Creates a reference ‘baz’ which can be used in other nodes. We could
|
||
support references inline with the body of a node, but explicit
|
||
reference nodes do help keep constraints organized.
|
||
|
||
### Order Nodes
|
||
The most important constraint node is the order node. An order node
|
||
indicates that the ‘predecessor’ temporally dominates a given
|
||
‘successor’ and thus Dart2js should ensure any code shared between
|
||
predecessor and successor loads with the predecessor.
|
||
|
||
Sequencing even just two nodes will reduce the total number of
|
||
output units if those nodes share code but because sequencing is a
|
||
transitive operation the real benefit comes from deep hierarchies.
|
||
|
||
For example:
|
||
|
||
**foo.dart:**
|
||
|
||
```dart
|
||
import '...' deferred as step1;
|
||
import '...' deferred as step2;
|
||
|
||
do() {
|
||
step1.loadLibrary().then((_) { step2.loadLibrary().then(...) } );
|
||
}
|
||
```
|
||
|
||
**constraints.yaml:**
|
||
|
||
```yaml
|
||
...
|
||
- type: relative_order
|
||
predecessor: step1
|
||
successor: step2
|
||
...
|
||
```
|
||
|
||
### Combiner Nodes
|
||
Order nodes support both fan in and fan out, that is multiple
|
||
predecessors mapping to the same successor and multiple successors
|
||
mapping to the same predecessor. In addition to fan in / out, other
|
||
ways of combining constraints are supported via explicit combiner
|
||
nodes.
|
||
|
||
The primary purpose of combiner nodes is to allow users to propagate
|
||
ordering information deeper into the deferred graph. However,
|
||
combiner nodes will also reduce the number of part files in many
|
||
cases.
|
||
|
||
For v0, combiner nodes must have only reference nodes as children,
|
||
though in the longer term we may relax this restriction and allow
|
||
combiners to nest under certain situations. A further limitation is
|
||
that we do not currently support cycles in the constraints graph,
|
||
and thus every reference to a node references the exact same node.
|
||
|
||
#### ‘And’ Combiner Node
|
||
‘And’ nodes are used for cases where multiple loadLibrary calls are
|
||
guaranteed to occur at a certain point in time, but the relative
|
||
ordering of those nodes may change.
|
||
|
||
In the case of an ‘and’ node, because all of the nodes in an ‘and’
|
||
node are guaranteed to load at a given point in time, Dart2js can
|
||
merge certain part files. In the case of the below example, any code
|
||
shared between step1a and its successors can be merged into step1a.
|
||
And code shared between step1a and its predecessors can be merged
|
||
into its predecessors. The same optimizations will also apply to
|
||
step1b. However step1a and step1b themselves will not merge.
|
||
|
||
For example:
|
||
|
||
**foo.dart**:
|
||
|
||
```dart
|
||
import '...' deferred as step1a;
|
||
import '...' deferred as step1b;
|
||
|
||
do() {
|
||
if (...) {
|
||
step1a.loadLibrary().then((_) { step1b.loadLibrary().then(...) } );
|
||
} else {
|
||
step1b.loadLibrary().then((_) { step1a.loadLibrary().then(...) } );
|
||
}
|
||
}
|
||
```
|
||
|
||
**constraints.yaml**:
|
||
|
||
```yaml
|
||
...
|
||
- type: and
|
||
name: step1
|
||
nodes:
|
||
- step1a
|
||
- step1b
|
||
...
|
||
```
|
||
|
||
#### ‘Or’ Combiner Node
|
||
‘Or’ nodes are used for cases where at least one of multiple
|
||
loadLibrary calls will occur at a certain point in time. Nodes
|
||
within an ‘or’ node need not be mutually exclusive.
|
||
|
||
Because at least one of the nodes within the ‘or’ is guaranteed to
|
||
load at a certain point in time, Dart2js can perform optimizations
|
||
with the code shared between all of the nodes in the ‘or.’
|
||
Specifically, code shared between successors of the ‘or’ node and
|
||
all of the nodes in the ‘or’ node can merge with the shared ‘or’
|
||
code, and code shared between all of the nodes in the ‘or’ and
|
||
predecessors can merge with predecessors without bloating load
|
||
lists.
|
||
|
||
For example:
|
||
|
||
**foo.dart:**
|
||
|
||
```dart
|
||
import '...' deferred as step1a;
|
||
import '...' deferred as step1b;
|
||
|
||
do() {
|
||
if (...) {
|
||
step1a.loadLibrary().then((_) { ... });
|
||
} else {
|
||
step1b.loadLibrary().then((_) { ... } );
|
||
}
|
||
}
|
||
```
|
||
|
||
**constraints.yaml:**
|
||
|
||
```yaml
|
||
...
|
||
- type: or
|
||
name: step1
|
||
nodes:
|
||
- step1a
|
||
- step1b
|
||
...
|
||
```
|
||
|
||
#### Fuse Combiner Node
|
||
Fuse nodes combine multiple nodes into a strongly connected
|
||
component. This is very useful in cases where two loadLibrary calls
|
||
almost always happen together. Fuse nodes can greatly reduce the
|
||
number of part files.
|
||
|
||
For example:
|
||
|
||
**foo.dart:**
|
||
```dart
|
||
import '...' deferred as step1a;
|
||
import '...' deferred as step1b;
|
||
|
||
do() {
|
||
...
|
||
}
|
||
```
|
||
|
||
**constraints.yaml:**
|
||
```yaml
|
||
...
|
||
- type: fuse
|
||
name: step1
|
||
nodes:
|
||
- step1a
|
||
- step1b
|
||
...
|
||
```
|
||
|
||
## Examples
|
||
Below is a more complete example. The part files impact section is
|
||
based on a worst case scenario where code is shared between all
|
||
combinations of deferred imports.
|
||
|
||
**foo.dart**:
|
||
|
||
```dart
|
||
import '...' as deferred S1;
|
||
import '...' as deferred S2a;
|
||
import '...' as deferred S2b;
|
||
import '...' as deferred S3;
|
||
|
||
main() {
|
||
S1.loadLibrary().then((_) {
|
||
if (...) {
|
||
S2a.loadLibrary().then((_) {
|
||
S2b.loadLibrary().then((_) { S3.loadLibrary().then((_) {...}); });
|
||
});
|
||
} else {
|
||
S2b.loadLibrary().then((_) {
|
||
S2a.loadLibrary().then((_) { S3.loadLibrary().then((_) {...}); });
|
||
});
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
**constraints.yaml**:
|
||
|
||
```yaml
|
||
- type: reference
|
||
name: s1
|
||
import: /path/to/foo.dart#S1
|
||
- type: reference
|
||
name: s2a
|
||
import: /path/to/foo.dart#S2a
|
||
- type: reference
|
||
name: s2b
|
||
import: /path/to/foo.dart#S2b
|
||
- type: reference
|
||
name: s3
|
||
import: /path/to/foo.dart#S3
|
||
- type: $COMBINER_TYPE
|
||
name: s2
|
||
nodes: [ s2a, s2b ]
|
||
- type: relative_order
|
||
predecessor: s1
|
||
successor: s2
|
||
- type: relative_order
|
||
predecessor: s2
|
||
successor: s3
|
||
```
|
||
|
||
**part files impact**:
|
||
|
||
**unconstrained**
|
||
* {S1}
|
||
* {S2a}
|
||
* {S2b}
|
||
* {S3}
|
||
* {S1, S2a}
|
||
* {S1, S2b}
|
||
* {S1, S3}
|
||
* {S2a, S2b}
|
||
* {S2a, S3}
|
||
* {S2b, S3}
|
||
* {S1, S2a, S2b}
|
||
* {S1, S2a, S3}
|
||
* {S1, S2b, S3}
|
||
* {S2a, S2b, S3}
|
||
* {S1, S2a, S2b, S3}
|
||
|
||
**COMBINER\_TYPE = or**
|
||
* {S2a}
|
||
* {S2b}
|
||
* {S3}
|
||
* {S2a, S3}
|
||
* {S2b, S3}
|
||
* {S2a, S2b, S3}
|
||
* {S1, S2a, S2b, S3}
|
||
|
||
**COMBINER\_TYPE = and**
|
||
* {S3}
|
||
* {S2a, S3}
|
||
* {S2b, S3}
|
||
* {S2a, S2b, S3}
|
||
* {S1, S2a, S2b, S3}
|
||
|
||
**COMBINER\_TYPE = fuse**
|
||
* {S3}
|
||
* {S2a, S2b, S3}
|
||
* {S1, S2a, S2b, S3}
|
||
|
||
**load list impact(redundant loads are ~~crossed out~~):**
|
||
|
||
**unconstrained**
|
||
* S1 : {S1, S2a, S2b, S3}, {S1, S2a, S2b}, {S1, S2a, S3}, {S1, S2b, S3}, {S1, S2a}, {S1, S2b}, {S1, S3}, {S1}
|
||
* S2a: ~~{S1, S2a, S2b, S3}, {S1, S2a, S2b}, {S1, S2a, S3}~~, {S2a, S2b, S3}, ~~{S1, S2a}~~, {S2a, S2b}, {S2a, S3}, {S2a}
|
||
* S2b: ~~{S1, S2a, S2b, S3}, {S1, S2a, S2b}, {S1, S2b, S3}~~, {S2a, S2b, S3}, ~~{S1, S2b}~~, {S2a, S2b}, {S2b, S3}, {S2b}
|
||
* S3 : ~~{S1, S2a, S2b, S3}, {S2a, S2b, S3}, {S1, S2a, S3}, {S1, S2b, S3}, {S2a, S3}, {S2b, S3}, {S1, S3}~~, {S3}
|
||
|
||
**COMBINER_TYPE = or**
|
||
* S1 : {S1, S2a, S2b, S3}
|
||
* S2a: ~~{S1, S2a, S2b, S3}~~, {S2a, S2b, S3}, {S2a, S3}, {S2a}
|
||
* S2b: ~~{S1, S2a, S2b, S3}~~, {S2a, S2b, S3}, {S2b, S3}, {S2b}
|
||
* S3 : ~~{S1, S2a, S2b, S3}, {S2a, S2b, S3}, {S2a, S3}, {S2b, S3}~~, {S3}
|
||
|
||
**COMBINER_TYPE = and**
|
||
* S1: {S1, S2a, S2b, S3}
|
||
* S2a: ~~{S1, S2a, S2b, S3}~~, {S2a, S2b, S3}, {S2a, S3}
|
||
* S2b: ~~{S1, S2a, S2b, S3}~~, {S2a, S2b, S3}, {S2b, S3}
|
||
* S3 : ~~{S1, S2a, S2b, S3}, {S2a, S2b, S3}, {S2a, S3}, {S2b, S3}~~, {S3}
|
||
|
||
**COMBINER_TYPE = fuse**
|
||
* S1: {S1, S2a, S2b, S3}
|
||
* S2a: ~~{S1, S2a, S2b, S3}~~, {S2a, S2b, S3}
|
||
* S2b: ~~{S1, S2a, S2b, S3}~~, {S2a, S2b, S3}
|
||
* S3 : ~~{S1, S2a, S2b, S3}, {S2a, S2b, S3}~~, {S3}
|
||
|
||
## TODO
|
||
* Documentation for Inline references, glob support, groups, set
|
||
operations on groups, nested combiners
|
||
|
||
## Glossary
|
||
* deferred import: A method of asynchronously loading code. Deferred
|
||
imports are used to reduce the size of the main part file and thus
|
||
improve IPL.
|
||
* deferred load: The runtime implementation of a deferred import.
|
||
* load list: A list of part files which must be loaded before a
|
||
loadLibrary call completes.
|
||
* main part file: The part file representing the initial chunk of
|
||
code which must be downloaded and initialized before a given
|
||
program can run.
|
||
* part file: A chunk of JS code representing some subset of the
|
||
compiled output which results from compiling a Dart program to JS
|
||
via Dart2js.
|
||
* IPL: Stands for initial page load, i.e. the time it takes for a
|
||
given web page to complete its first load.
|
||
|