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

Optimize css() #216

Merged
merged 2 commits into from
Apr 27, 2017
Merged

Optimize css() #216

merged 2 commits into from
Apr 27, 2017

Conversation

lencioni
Copy link
Collaborator

@lencioni lencioni commented Mar 10, 2017

After the 1.2.0 release, I noticed that most of my optimizations were
completely offset by the ordering bugfixes that were added. After some
profiling, I noticed that generateCSSRuleset was ripe for optimization.

My approach here is to reduce the amount of work that needs to be done
to a bare minimum. I accomplish this by being smarter about when to loop
and how many loops to run. In my benchmark, this PR reduces the runtime
of css() from 2750 ms to 900 ms, which is 3.05x faster.

Some of this performance improvement will only be seen when running on the client,
but on the server this should still result in ~2x faster improvement.

This is probably best reviewed commit-by-commit.

Before:

screen shot 2017-03-21 at 2 31 50 pm

After:

screen shot 2017-03-21 at 2 31 26 pm

@xymostech

@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch 2 times, most recently from 51cf81d to 5c6106b Compare March 10, 2017 20:50
src/generate.js Outdated
// the second character to avoid colliding with Moz-prefixed
// styles. Let's find its original style's sort order.
originalStyle = elementNames[i][2].toLowerCase() + elementNames[i].slice(3);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Looks like coverage is failing branch coverage for the else case here. Any thoughts on how to fix this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I decided to change it from an else if to an else.

@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch 2 times, most recently from aa483df to 187056b Compare March 10, 2017 22:39
@@ -1,5 +1,4 @@
/* @flow */
/* global Map */

export default class OrderedElements {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The branch coverage on this file falls to 75%, but I can't see any missed branches in the HTML report. I'm guessing it might not be getting the coverage right with the Babel transform happening here. Thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Figured it out. The constructor was never called with arguments, so I removed the args.

@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch from 187056b to 678f11e Compare March 10, 2017 22:50
@lencioni lencioni changed the title Optimize generateCSSRuleset Optimize css() Mar 10, 2017
@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch from 678f11e to 13a7422 Compare March 11, 2017 16:50
@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 13a7422 on optimize-generateCSSRuleset into c42ae31 on master.

@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch from 24ab852 to 2325e6d Compare March 11, 2017 17:56
@lencioni lencioni mentioned this pull request Mar 11, 2017
@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 2325e6d on optimize-generateCSSRuleset into c42ae31 on master.

@lencioni lencioni requested a review from xymostech March 13, 2017 16:36
@coveralls
Copy link

Coverage Status

Coverage decreased (-0.3%) to 99.675% when pulling 1ab7400 on optimize-generateCSSRuleset into c42ae31 on master.

@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 54dba86 on optimize-generateCSSRuleset into c42ae31 on master.

@iancmyers
Copy link

Super excited for this!

@lencioni
Copy link
Collaborator Author

@xymostech Are there any changes you'd like me to make to this PR before merging?

@xymostech
Copy link
Contributor

@lencioni Sorry, I'd like to review this before merging but I've just been terribly busy. I'll review this today or over the weekend.

@xymostech
Copy link
Contributor

Hi! My computer broke over the weekend, so I didn't get to reviewing this... It's fixed now, I'll review this today.

@lencioni
Copy link
Collaborator Author

@xymostech no worries! I'm sorry to hear about your computer troubles. :( Glad to hear that you are back in action.

Copy link
Contributor

@xymostech xymostech left a comment

Choose a reason for hiding this comment

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

Wow! This is an insane amount of optimization, thank you so much for doing all of this!

I'm requesting changes because I think there's a few major impacts that this is going to have, and one critical bug that I noticed (the deep merge thing). I have a bunch of general questions and some nits, but overall I'm super happy with the direction of this!

const merged /* : OrderedElements */ = styleTypes.reduce(
recursiveMerge,
new OrderedElements());
const merged = new OrderedElements();
Copy link
Contributor

Choose a reason for hiding this comment

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

This is no longer doing a recursive merge.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That's right. When I did this, I thought it would be alright since the tests pass, but I think actually this is a gap in the tests. I've fixed this, and in the process I think I found and fixed an existing bug where nested objects are mutated. My new implementation does not feel the cleanest anymore, so it could probably use a little refactoring.

src/generate.js Outdated
// value that was added by prefixAll. Let's try to figure out where it
// goes.
let originalStyle;
if (elementNames[i][0] === 'W') {
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems vaguely fragile. Are we really sure that the only things that are not going to be in the original array here are prefixed things, not like, renamed properties or something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Can you expand on what you mean by renamed properties?

If you look at line 284 you'll see if (!originalElements.hasOwnProperty(elementNames[i])) { which means that this should only be running on properties that were not there before the prefixAll() call but were there after it.

Copy link
Contributor

Choose a reason for hiding this comment

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

I actually don't remember what I meant by renamed properties.. :P

What I meant in general is that are we sure that the only properties that were not there before the prefixAll() call are ones that start with Webkit, Moz, or ms? Could there be others?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I just looked through the code of inline-style-prefixer and I was unable to find any others. @rofrischmann can you confirm?

src/generate.js Outdated
// calculation above.
return sortOrder[key];
if (originalStyle && originalElements.hasOwnProperty(originalStyle)) {
const originalIndex = handledDeclarations.keyOrder.indexOf(originalStyle);
Copy link
Contributor

Choose a reason for hiding this comment

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

Did your benchmark include any number of things that get prefixed? I'd imagine this might be slower in that case. On the other hand, are people going to be generating a whole bunch of styles that get prefixed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yep, my benchmark included some things that are prefixed and some things that are not prefixed. This is faster for both because it gets all of the work done in fewer loops and avoids creating new objects in favor of mutating.

src/generate.js Outdated
.map(([key, value]) => `${kebabifyStyleName(key)}:${transformValue(key, value)};`)
.join("");
const rules = [];
for (let i = 0; i < handledDeclarations.keyOrder.length; i ++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: no space before ++

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fixed

src/generate.js Outdated
// multiple rules for the same key. Here we flatten to multiple
// pairs with the same key.
for (let j = 0; j < value.length; j++) {
rules.push(transformRule(key, value[j], transformValue));
Copy link
Contributor

Choose a reason for hiding this comment

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

This is just going to put them in the same order that inline-style-prefixer gave them to us, correct? Is that order in the order that we want it to be?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think that's correct. At least in the examples I was looking at, this seemed to be the order we wanted. Although I'm not sure if that is always true or if it will remain stable. It might be worth adding a check here to ensure that the original or unprefixed value is last.

@@ -21,9 +21,6 @@ export const mapObj = (
return mappedObj;
}

export const flattenDeep = (list /* : any[] */) /* : any[] */ =>
list.reduce((memo, x) => memo.concat(Array.isArray(x) ? flattenDeep(x) : x), []);
Copy link
Contributor

Choose a reason for hiding this comment

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

oh yeah, I forgot how horribly inefficient this function was. Thanks!

package.json Outdated
"npm-run-all": "^1.7.0",
"nyc": "^6.4.4",
"rimraf": "^2.5.2",
"webpack": "^1.12.2"
},
"dependencies": {
"asap": "^2.0.3",
"can-use-dom": "^0.1.0",
Copy link
Contributor

Choose a reason for hiding this comment

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

how big is this dependency? Can we just replace this with a typeof document !== "undefined" check like we do in other places? (or replace those other checks with this?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The dependency is very small. Here's the whole thing:

var canUseDOM = !!(
  typeof window !== 'undefined' &&
  window.document &&
  window.document.createElement
);

module.exports = canUseDOM;

I think it is best to prefer dependencies over code duplication, but if you feel strongly about it, I can inline it into this project.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nope, using that module seems fine! I'm just cautious about including something if it's trying to do something more complicated.

src/prefix.js Outdated
// prefixed styles.
const prefixer = new Prefixer({
keepUnprefixed: true,
//userAgent: navigator.userAgent,
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: this is commented out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Whoops!

src/prefix.js Outdated

// We need keepUnprefixed so our sorting code knows how to order the
// prefixed styles.
const prefixer = new Prefixer({
Copy link
Contributor

Choose a reason for hiding this comment

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

Aside from server-side rendering, I know one of the reasons we initially didn't go with this approach is because the code to determine which prefixes were needed (which we're doing a build time in staticPrefixData) took up a lot of space. Can you build aphrodite before and after this change and see how much bigger the resulting dist file is?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, that's a fair point. I'll look into this when I have a moment.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Looks like this takes the build size from 73 KiB to 134 KiB.

Copy link
Contributor

Choose a reason for hiding this comment

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

:( That's a pretty big size bump. I'd like to get this landed because of the bug fix in it, but I'm worried about throwing this significant change onto users. Could we maybe pull this change out into a separate PR to discuss?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sounds good!

const originalUserAgent = global.navigator && global.navigator.userAgent;
let prefix;

beforeEach(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

<3 great tests

@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch 2 times, most recently from ebf5d3a to a54fd13 Compare March 21, 2017 22:02
src/inject.js Outdated
key, [val[key]], selectorHandlers, stringHandlers, false);
});

if (val instanceof OrderedElements) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure if we can change things so that this fork doesn't need to happen. If we can't it might be worth adding a utility function that takes either a plain object or OrderedElements instance and iterates over the keys/values for convenience.

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems fine for now. Maybe add a TODO for later?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I will add a TODO.

My main concern is less about this specific piece of code and more about what this means for folks who have added their own string handlers that will also need to account for this.

}

forEach(callback /* : (string, any) => void */) {
for (let i = 0; i < this.keyOrder.length; i++) {
callback(this.keyOrder[i], this.elements[this.keyOrder[i]]);
// (value, key) to match Map's API
callback(this.elements[this.keyOrder[i]], this.keyOrder[i]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note: I changed the order of arguments here to match how Map works.

@@ -134,6 +154,24 @@ ${formatStyles(actual)}
}], '.foo{color:blue !important;}');
});

it('does not mutate nested objects', () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note: this test exposes a bug that is currently in master that this commit fixes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This might be #226?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Also #231

Copy link
Contributor

@xymostech xymostech left a comment

Choose a reason for hiding this comment

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

These changes seem fine now! And I'd like to get them out soon so we can get that bug fixed. :) I would like to pull out the prefixer change so that we can think about that separately before doubling the size of Aphrodite. Sound good?

Thank you so much for all the work you've been doing. I'm learning a lot about performance optimization!

src/generate.js Outdated
// value that was added by prefixAll. Let's try to figure out where it
// goes.
let originalStyle;
if (elementNames[i][0] === 'W') {
Copy link
Contributor

Choose a reason for hiding this comment

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

I actually don't remember what I meant by renamed properties.. :P

What I meant in general is that are we sure that the only properties that were not there before the prefixAll() call are ones that start with Webkit, Moz, or ms? Could there be others?

@@ -49,7 +49,6 @@
"caniuse-api": "^1.5.3",
"chai": "^3.3.0",
"coveralls": "^2.12.0",
"es6-shim": "^0.35.3",
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, interesting. I guess if it's available in node 4, it's probably fine for us to remove it.

@@ -369,11 +369,11 @@ describe('String handlers', () => {
css(sheet.animate);
flushToStyleTag();

assertStylesInclude('@keyframes keyframe_1kmnkfo');
assertStylesInclude('@keyframes keyframe_tmjr6');
Copy link
Contributor

Choose a reason for hiding this comment

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

why did this change?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't remember exactly anymore, but I think it was because previously the nested objects were plain objects and part of my change (making things actually recursive) caused them to be OrderedElements objects, so they serialized differently. This is related to the code I commented on here: https://github.com/Khan/aphrodite/pull/216/files#r107288563

}
}

set(key /* : string */, value /* : any */) {
if (!this.elements.hasOwnProperty(key)) {
this.keyOrder.push(key);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

It seems non-intuitive that .set() does a deep merge of the elements. Could we maybe pull this deep merge into a separate method, or if this isn't being used anywhere else, rename this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah this makes sense to me. I'll try to look into this soon.

stringHandlers, useImportant);

injectGeneratedCSSOnce(key, generated);
if (alreadyInjected[key]) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nah, that's fine, I was just curious. :)

package.json Outdated
"npm-run-all": "^1.7.0",
"nyc": "^6.4.4",
"rimraf": "^2.5.2",
"webpack": "^1.12.2"
},
"dependencies": {
"asap": "^2.0.3",
"can-use-dom": "^0.1.0",
Copy link
Contributor

Choose a reason for hiding this comment

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

Nope, using that module seems fine! I'm just cautious about including something if it's trying to do something more complicated.

src/prefix.js Outdated

// We need keepUnprefixed so our sorting code knows how to order the
// prefixed styles.
const prefixer = new Prefixer({
Copy link
Contributor

Choose a reason for hiding this comment

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

:( That's a pretty big size bump. I'd like to get this landed because of the bug fix in it, but I'm worried about throwing this significant change onto users. Could we maybe pull this change out into a separate PR to discuss?

src/inject.js Outdated
key, [val[key]], selectorHandlers, stringHandlers, false);
});

if (val instanceof OrderedElements) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems fine for now. Maybe add a TODO for later?

Copy link
Collaborator Author

@lencioni lencioni left a comment

Choose a reason for hiding this comment

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

I think I'm going to split this up into a few separate PRs so we can focus on the specific changes a bit better.

src/generate.js Outdated
// value that was added by prefixAll. Let's try to figure out where it
// goes.
let originalStyle;
if (elementNames[i][0] === 'W') {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I just looked through the code of inline-style-prefixer and I was unable to find any others. @rofrischmann can you confirm?

src/inject.js Outdated
key, [val[key]], selectorHandlers, stringHandlers, false);
});

if (val instanceof OrderedElements) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I will add a TODO.

My main concern is less about this specific piece of code and more about what this means for folks who have added their own string handlers that will also need to account for this.

}
}

set(key /* : string */, value /* : any */) {
if (!this.elements.hasOwnProperty(key)) {
this.keyOrder.push(key);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah this makes sense to me. I'll try to look into this soon.

src/prefix.js Outdated

// We need keepUnprefixed so our sorting code knows how to order the
// prefixed styles.
const prefixer = new Prefixer({
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sounds good!

@@ -369,11 +369,11 @@ describe('String handlers', () => {
css(sheet.animate);
flushToStyleTag();

assertStylesInclude('@keyframes keyframe_1kmnkfo');
assertStylesInclude('@keyframes keyframe_tmjr6');
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't remember exactly anymore, but I think it was because previously the nested objects were plain objects and part of my change (making things actually recursive) caused them to be OrderedElements objects, so they serialized differently. This is related to the code I commented on here: https://github.com/Khan/aphrodite/pull/216/files#r107288563

@lencioni lencioni mentioned this pull request Mar 23, 2017
@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch from a54fd13 to 893386a Compare March 23, 2017 17:17
@lencioni
Copy link
Collaborator Author

Once #233 is merged, I'll rebase this again and it will be only 2 commits.

@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch from 893386a to 7831e48 Compare March 23, 2017 17:20
@SamyPesse
Copy link

Thanks @lencioni 🍻

Any chance someone from Khan can review it ? (@xymostech) This PR fixes #231.

lencioni and others added 2 commits April 20, 2017 11:40
We only use this in one place, so I decided to rewrite this for the
specific use-case to improve performance.

While I was at it, I found and fixed a bug that causes nested objects to
be mutated.

I decided to change the signature of OrderedElements's forEach callback
to match that of Map's forEach.

In my benchmark this brings css() down from 1470ms to 1350ms.
In my profiling, calling flattenDeep in here takes 1437ms out of
3418ms, and that's with already flattened arrays. By refactoring this
code, we can actually avoid a lot of the work done here and make this
faster. This change drops css() down from 3418ms to 2045ms.
@lencioni lencioni force-pushed the optimize-generateCSSRuleset branch from 7831e48 to 0700a57 Compare April 20, 2017 18:41
Copy link
Collaborator Author

@lencioni lencioni left a comment

Choose a reason for hiding this comment

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

@xymostech I've rebased this but didn't make any code changes.

// utility method that can iterate over either a plain object, an
// instance of OrderedElements, or a Map, and then use that here and
// elsewhere.
if (val instanceof OrderedElements) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is the bit that I am mostly worried about. But, it looks like string handlers can't be added by extensions, so maybe this is safe?

@elischutze
Copy link

this looks great I support this

@lencioni
Copy link
Collaborator Author

I have @xymostech's approval for these changes, so I'm going to merge this. But @xymostech or someone else with access will either need to publish a new version or give me access (I'm lencioni on npm).

@lencioni lencioni merged commit cd1069b into master Apr 27, 2017
@lencioni lencioni deleted the optimize-generateCSSRuleset branch April 27, 2017 15:39
@jlfwong
Copy link
Collaborator

jlfwong commented Apr 27, 2017

@lencioni Just gave you access! The version bump should happen with npm version, then the result committed and pushed

@lencioni
Copy link
Collaborator Author

Great! I should have some time this afternoon to take care of this.

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