Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework repeat directive for better performance. Fixes #242. #501

Merged
merged 22 commits into from
Oct 1, 2018

Conversation

kevinpschaaf
Copy link
Member

@kevinpschaaf kevinpschaaf commented Sep 13, 2018

Rework repeat directive to use a more efficient algorithm to avoid unnecessary DOM detachment/attachment during common array operations (add, remove, swap, reverse, etc.).

A few TODO's in the implementation to discuss:

  • The algorithm iterates in-order arrays of (old) Parts and (new) results; however the algorithm also requires in-order arrays of their keys also; as such I found it cleanest to assign the key to the part/result so only one array each for old/new is required. Should keying be promoted to the first-class interface for these objects?
  • I made helper API's for the part operations needed in repeat (create/update/move/remove); these should probably somehow be folded into the Part API, but wanted to discuss first since creation/ownership of the markers seems to be a case-by-case basis right now?
  • I did experiment with an implementation of the LIS algorithm mentioned in the comments, which slotted into this implementation fairly cleanly. I recommend holding off adding that until there is a clear use case that would benefit from the extra code+time complexity.
  • I'm now eagerly generating the key-to-index map (and storing for use in the next turn), as the optimization to only create it in the final else clause (over the subset of old items remaining) didn't show up in benchmarks as significant. This made it straightforward to warn in the case of duplicate keys, which we treat as an error and warn as "undefined behavior", and gives more flexibility to change the algorithm in the future.
  • Let's also discuss benchmarks; I made a parameterized repeat benchmark that I ran using a stripped-down/upgraded version of polyperf; I didn't check it in, pending discussion about direction for benchmarking.

Fixes #242.

@aadamsx
Copy link

aadamsx commented Sep 13, 2018

Really great PR, thank you!

} else {
// else part is in the correct position already
currentMarker = itemPart.endNode.nextSibling!;
const result = newResults[index] = template !(item, index);
Copy link
Member Author

@kevinpschaaf kevinpschaaf Sep 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arr, if duplicate keys are detected the index passed here should still reflect the input array items index (as is it reflects the final result/part index...). It's an error condition either way; worth fixing?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would this change if fixed? template would be called twice with the same item and different index, and the index already is the input array items index, no?

Copy link
Member Author

@kevinpschaaf kevinpschaaf Sep 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current behavior for duplicates is that only the first item for a key with duplicates will be included in the results; the rest will be skipped. But to provide the correct index to the template function, we need to increment index for each item regardless; however since we also store the index in the key map, that one needs to reflect the index taking into account skips -- which is just newKeyToIndexMap.size

@justinfagnani
Copy link
Collaborator

Very exciting :)

Should keying be promoted to the first-class interface for these objects?

I had a keyValue property on Part for a little bit when I was refactoring the Part API. I think it's likely that this will be generally useful. unsafeHTML already stores a separate value on parts, and #458 adds a _promise field to NodePart to check Promise identity. The big question for me is whether having a general value composes - ie, what happens when we have a repeat of promises of unsafeHTML values. Naively it seems like they'd all compete for the key field.

Even without a common keyValue field, we should check that Promises work, to address #193

I made helper API's for the part operations needed in repeat (create/update/move/remove); these should probably somehow be folded into the Part API, but wanted to discuss first since creation/ownership of the markers seems to be a case-by-case basis right now?

Yeah, the current insert/append methods are somewhat ad hoc, and have a restriction that the parts are empty. Now's the time to figure this out and lock it down before 1.0. The PR can be the forcing function for that.

duplicate keys, which we treat as an error and warn as "undefined behavior",

I like that. It fixes #362

Let's also discuss benchmarks

I'm working on this again right now. So far I'm mostly on the launcher / record results side of things, and not the specific benchmark implementation, or client-runner (so much at least).

@kevinpschaaf
Copy link
Member Author

The big question for me is whether having a general value composes

Hmm, in my mind key is not an type of value, rather it goes along with a value to give it an identity (so that it can be later diffed) -- seems cleaner to keep those two things as separate, first-class properties.

But I think I see your point; since the same NodePart is passed to potentially multiple nested directives, there's a risk of directives stomping on keys (I think I had naively assumed directives spawned new nested NodeParts, but clearly not; they're just run on the NodePart they were set into, which makes sense).

It also points out a mistake I think I made, assuming that the ItemTemplate fn had to return a TemplateResult, when it can actually return any value. So assuming .key can be assigned to results here isn't good, since those may be e.g. directives waiting to be run (actually that would probably work ok, but e.g. assigning key to a primitive value would not work; but maybe that's super unlikely?).

src/directives/repeat.ts Outdated Show resolved Hide resolved

// Helper functions for manipulating parts
// TODO(kschaaf): Refactor into Part API?
function createPart(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does "create" and "update" and "insert" so the name is misleading. What about preparePart?

We could add insertIntoPart to the Part API and a lot of the mucking with nodes would go away

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this and all the other comments about the part helpers, I'm assuming this is throwaway and will be refactored into a better Part API, but want to have an IRL conversation about that...

part.commit();
return part;
}
function movePart(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would also use insertIntoPart(on the child) or could be insertBefore or insertBeforePart (on the parent to match the DOM api). It'd have to work on a "live" part though.

} else {
// else part is in the correct position already
currentMarker = itemPart.endNode.nextSibling!;
const result = newResults[index] = template !(item, index);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

format (npm run format). The space after template is weird and hopefully goes away.

Copy link
Member Author

@kevinpschaaf kevinpschaaf Sep 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the result of npm run format. 🤷‍♂️

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you shouldn't need the ! though, template isn't typed to be nullable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was there in the original source. I think it's needed because of the varargs shenanigans. The template symbol is the 3rd arg and it's optional, and TypeScript isn't smart enough to follow the arguments.length condition for its assignment?

src/directives/repeat.ts Outdated Show resolved Hide resolved
} else if (oldStartPart.key == newEndResult.key) {
// Old head matches new tail; update and move to new tail
newParts[newEndIndex] = updatePart(oldStartPart, newEndResult);
movePart(directivePart, oldStartPart, newParts[newEndIndex + 1]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oldStartPart === newParts[newEndIndex]. Since the old part is irrelevant, moving the new one would be easier to understand (similar in next condition as well).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. My mental model was "update and (possibly) move the old part to the new spot", so the operations are all about making old parts correct in the new order.

src/directives/repeat.ts Outdated Show resolved Hide resolved
} else {
// Any mismatches at this point are due to additions or moves; see if
// we have an old part we can reuse and move into place
const oldIndex = oldKeyToIndexMap.get(newEndResult.key);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it important to go bottom-up from here? If not, changing to top-down is simpler.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I can flip it back to being top-down. Experimenting with the LIS algorithm made bottom-up easier, but I pulled that out.

src/directives/repeat.ts Outdated Show resolved Hide resolved
src/directives/repeat.ts Outdated Show resolved Hide resolved
Copy link
Collaborator

@justinfagnani justinfagnani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass...

src/directives/repeat.ts Outdated Show resolved Hide resolved
src/directives/repeat.ts Outdated Show resolved Hide resolved
src/directives/repeat.ts Outdated Show resolved Hide resolved
src/directives/repeat.ts Outdated Show resolved Hide resolved
src/directives/repeat.ts Outdated Show resolved Hide resolved
// worth the code at this point, but could consider if a compelling case
// arises.
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (oldStartPart == null) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check equivalent to if (oldStartIndex > oldEndIndex)? Or is there a later assignment to oldStartPart to null it out?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, spots in the old array are nulled out as they are moved in the last else clause. Steve had a similar suggestion to add a comment that that's what's going on, since the moves come at the end and a new reader sees them out of order.

src/directives/repeat.ts Outdated Show resolved Hide resolved
src/directives/repeat.ts Outdated Show resolved Hide resolved
src/directives/repeat.ts Show resolved Hide resolved
src/directives/repeat.ts Outdated Show resolved Hide resolved
@kevinpschaaf
Copy link
Member Author

kevinpschaaf commented Sep 15, 2018

Covered most of the feedback in latest update.

TODOs:

  • Haven't gotten around to rationalizing the NodePart API with the various helper methods here, but still plan to take a whack at that
  • Add more tests:
    • Test different types returned from item template, including Promises (as suggested)
    • Test objects as items rather than just primitives (especially if we decide to change default key as discussed above))
  • Perf test A/B between before this change and after; the code actually reads cleaner having a separate array of keys and not keeping head/tail references (only indices), but concerned there's more array dereferencing going on
  • The build failure is because unknown (introduced as suggested) is a new type in TypeScript 3.0 and the devDependencies is still on 2.8.3. @justinfagnani any concern upgrading to 3?

let newTail = newResults.length - 1;

// Overview of O(n) reconciliation algorithm (general approach based on
// ideas found in ivi, vue, snabdom, etc.):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its snabbdom with two b's

// newHead ^ ^ newTail
//
// * If either the new or old pointers move past each other then all we have
// left is additions (if old list exhsusted) or removals (if new list
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exhausted*

//
// * Note that for moves/insertions, a part inserted at the head pointer
// is inserted before the current `oldParts[oldHead]`, and a part inserted
// at the tail pointer is inserved before `newParts[newHead+1]`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inserted*

// * Note that for moves/insertions, a part inserted at the head pointer
// is inserted before the current `oldParts[oldHead]`, and a part inserted
// at the tail pointer is inserved before `newParts[newHead+1]`.
// The seeming asymetry lies in the fact that new parts are moved into
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asymmetry* (two m's)

// algorithm, as long as the null checks come first (to ensure we're
// always working on valid old parts) and that the final else clause
// comes last (since that's where the expensive moves occur). The
// order of remaining clauses is is just a simple guess at wich cases
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which*

@kenchris
Copy link
Contributor

Would something like this work with a virtual list where only a part of the data is visible on screen (DOM nodes exist)

@kevinpschaaf
Copy link
Member Author

kevinpschaaf commented Sep 17, 2018

@kenchris It all depends on what the strategy of the virtual list is.

Typically "keyed" updates mean "always keep this DOM associated with this key" (corollary: never reuse with another key). In the virtual lists we've done in the past, usually the cost of reusing (aka updating the bindings with a different item) for a small number of (visible) items is less than the cost of maintaining a strict key-to-DOM contract which at the limit means throwing out and re-creating the visible DOM, possibly per frame when scrolling quickly. If you eliminate the key-to-DOM contract, the algorithm can be much simpler (simply re-assign visible items to the physical rows in order), and this is usually best for scrolling speed, at the cost of more constraints on the user (e.g. list components must be stateless). But yeah, if you were to do a virtual list where you wanted to maintain strict key-to-DOM contract, an algorithm like this would probably be good.

Also, thanks for the spelling fixes; I just installed a comment spellchecker for my vscode!

Also rename result->value, update comments.
@kevinpschaaf
Copy link
Member Author

@justinfagnani @sorvell As discussed, I will start on the NodePart API refactor in a separate PR. Can you take another look at this?

Copy link
Collaborator

@justinfagnani justinfagnani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really amazing!

A few things before merging:

  • Update the changelog
  • Update the repeat docs as necessary. I think mentioning duplicate keys, and the non-keyed behavior would be good.
  • Add a test for duplicate keys, if only so we know what we expect to happen now?

newPart.insertAfterNode(startNode);
return newPart;
};
const updatePart = (part: NodePart, value: unknown) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style nit: add a newline between functions


// Stores previous ordered list of parts and map of key to index
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two spaces between "of" and "parts"

* DOM will always be created for new keys).
*
* If no `keyFn` is provided, this directive will perform similar to mapping
* items to values, and DOM will be reused against potentially different items.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @katejeffreys and @arthurevans for future work to describe this in a user-understandable way :)

// pointer range and never visited again.
//
// * Example below: Here the old tail key matches the new head key, so
// the part at the `oldTail` position and move its dom to the new
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dom => DOM

oldTail--;
newHead++;
} else {
if (newKeyToIndexMap! === undefined) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need the !?

// we have an old part we can reuse and move into place
const oldIndex = oldKeyToIndexMap!.get(newKeys[newHead]);
const oldPart = oldIndex !== undefined ? oldParts[oldIndex] : null;
if (oldPart == null) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ===

let newHead = 0;
let newTail = newValues.length - 1;

// Overview of O(n) reconciliation algorithm (general approach based on
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment is all kinds of awesome

Copy link
Member

@sorvell sorvell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are awesome. Just a couple nits.

export function repeat<T>(
items: T[], keyFn: KeyFn<T>, template: ItemTemplate<T>):
items: Iterable<T>, keyFn: KeyFn<T>, template: ItemTemplate<T>):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep keyFn optional, it's simpler if it's the last argument.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather do that as a separate change, maybe release with any other breaking changes we have at the time. Let's get this repeat() in the hands of current users and make sure it's a drop-in replacement.

src/directives/repeat.ts Outdated Show resolved Hide resolved
// we have an old part we can reuse and move into place
const oldIndex = oldKeyToIndexMap!.get(newKeys[newHead]);
const oldPart = oldIndex !== undefined ? oldParts[oldIndex] : null;
if (oldPart == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

===

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

keyMap.forEach(cleanMap);
// Add parts for any remaining new values
while (newHead <= newTail) {
const newPart = createAndInsertPart(containerPart, oldParts[oldHead]!);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you insert before newParts[newTail] here? Using the oldParts as anchors is confusing so it'd be nice to only doing it when we have.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newTail has already been advanced and so it would actually be newParts[newTail+1].

But, I already described the rules for where to insert in both cases here: https://github.com/Polymer/lit-html/pull/501/files#diff-fa22dc099475a72fdfd85c634894b896R202

Even though we're inserting at the "head", the above is only possible since the old parts are out of the picture at this point in the algorithm, so it would be a special difference that would need to be explained, and I think I'd prefer to keep the rule simple as documented: head goes at oldParts[oldHead], tail goes at newParts[newTail+1]

Copy link
Member

@sorvell sorvell Sep 20, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, sounds reasonable, but aren't we concerned that oldParts[oldHead] might be nulled out? That would make the items out of order. It seems like newParts[newTail+1] is always safe.

Copy link
Member

@sorvell sorvell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs changelog but otherwise LTGM

@stramel
Copy link
Contributor

stramel commented Sep 28, 2018

I know you guys mentioned some perf benchmarking was being done. Do you have any number you can show for this PR compared to existing?

Great work @kevinpschaaf 👏

@CaptainCodeman
Copy link

Here's one based a this branch a few commits ago:

before
lit-html-0 11 4

after
lit-html-0 11 4-repeat

That's using js-framework-benchmark

@stramel
Copy link
Contributor

stramel commented Sep 28, 2018

Wow, that's fantastic! Excellent work everyone! Thanks for the benchmark @CaptainCodeman

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants