Skip to content
This repository has been archived by the owner on Dec 5, 2019. It is now read-only.

feat: add support for parallelization && caching (options.parallel) #77

Merged
merged 35 commits into from
Jul 20, 2017

Conversation

aui
Copy link
Contributor

@aui aui commented Jul 9, 2017

  1. Use multiple processes to perform tasks in parallel.
  2. Use the file caching system to skip files that have not changed.

Build time contrast:

| cache | workers | time(ms) |
| ------| ------- | -------- |
| false |       1 |    35216 |
| false |      31 |    16345 |
| true  |      31 |     3384 |

Demo: https://travis-ci.org/aui/uglifyjs-webpack-plugin-demo

@jsf-clabot
Copy link

jsf-clabot commented Jul 9, 2017

CLA assistant check
All committers have signed the CLA.

@michael-ciniawsky
Copy link
Member

michael-ciniawsky commented Jul 9, 2017

Awesome 😊 I will review as soon as possible 😛

package.json Outdated
"source-map": "^0.5.6",
"uglify-es": "^3.0.21",
"uglify-es": "^3.0.24",
Copy link
Member

Choose a reason for hiding this comment

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

Already included in #74 I will merge that PR

Copy link
Member

Choose a reason for hiding this comment

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

#74 merged, this should also update some of the snapshots you updated aswell :)

package.json Outdated
"test": "jest",
"test:coverage": "jest --collectCoverageFrom='src/**/*.js' --coverage",
"test:watch": "jest --watch",
"test": "jest --env node",
Copy link
Member

Choose a reason for hiding this comment

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

Why is that needed and please revert it in any case here, since we handle that in a project called webpack-defaults separately across the organisation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Jest default configuration is --env = jsdom, it can simulate the browser environment, I worry that it takes up extra memory

Copy link
Member

Choose a reason for hiding this comment

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

Actually, Jest uses less memory than Karma does, by quite a bit. That said, this change if needed belongs in webpack-defaults.

All of our libs run the exact same projects setup. So for the scope of this pull request, this needs to be set back to what it was.

If you feel that strongly about this particular configuration, open a pull request in https://github.com/webpack-contrib/webpack-defaults/issues so the change can be applied to all of our libs in the next patch release of defaults.

src/index.js Outdated
ie8,
};
this.options.uglifyOptions = this.options.uglifyOptions || {};
this.options.maxWorkers = this.options.maxWorkers || os.cpus().length;
Copy link
Member

Choose a reason for hiding this comment

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

this.options.parallel = {
   cache: '{Boolean}' || 'path/to/cache',  
   workers: '{Number}'
}

parallelmight not be the best name, open for suggestions here :)

Choose a reason for hiding this comment

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

  • 1 for cpus

Copy link
Member

Choose a reason for hiding this comment

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

@mikesherov Should this be a +1 for options.cpus ? 🙃

Choose a reason for hiding this comment

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

length - 1

import crypto from 'crypto';
import cacache from 'cacache';

const hashAlgorithm = 'sha512';
Copy link
Member

Choose a reason for hiding this comment

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

hashAlgorithm =>hash || algorithm || sha512

@michael-ciniawsky michael-ciniawsky changed the title perf: improve the build speed perf: add support parallelization && caching (options.parallel`) Jul 9, 2017
@michael-ciniawsky michael-ciniawsky changed the title perf: add support parallelization && caching (options.parallel`) perf: add support for parallelization && caching (options.parallel) Jul 9, 2017
@michael-ciniawsky
Copy link
Member

compute-cluster seems to be fairly 'outdated' and is on v0.0.9 with failing tests. Could you elaborate on your choice here please ? :). What about worker-farm ?

@hulkish
Copy link
Contributor

hulkish commented Jul 9, 2017

i agree with @michael-ciniawsky, i really like worker-farm for this also.

};
};

const buildCommentsFunction = (options, uglifyOptions, extractedComments) => {
Copy link
Member

Choose a reason for hiding this comment

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

buildCommentsFunction => buildComments || comments

@@ -0,0 +1,108 @@
import uglify from 'uglify-es';

const defaultUglifyOptions = {
Copy link
Member

Choose a reason for hiding this comment

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

move the {Object} to L21 please :)

},
};

const buildDefaultUglifyOptions = ({ ecma, warnings, parse = {}, compress = {}, mangle, output, toplevel, ie8 }) => {
Copy link
Member

Choose a reason for hiding this comment

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

buildDefaultUglifyOptions => buildOptions || defaults

import ComputeCluster from 'compute-cluster';
import { get, put } from './cache';
import { encode } from './serialization'; // eslint-disable-line import/newline-after-import
const uglifyVersion = require(require.resolve('uglify-es/package.json')).version; // eslint-disable-line import/no-dynamic-require
Copy link
Member

Choose a reason for hiding this comment

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

const version = {
  uglify: require(require.resolve('uglify-es/package.json')).version,
  plugin: require('../../package.json').version // require.resolve() here aswell ?
}

Maybe consider a separate file src/uglify/versions.js ?

@@ -0,0 +1,46 @@
const toType = value => (Object.prototype.toString.call(value).slice(8, -1));
Copy link
Member

Choose a reason for hiding this comment

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

Please give an example, why this is needed here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since RegExp or Function can not be serialized, they need special handling:

worker.send({ extractComments: /foo/ });
// worker
process.on('message', (message) => {
    console.log(message); // -> { extractComments: {} } 😢
})

Use serialization :

worker.send(encode({ extractComments: /foo/ }));
// worker
process.on('message', (message) => {
    message = decode(message);
    console.log(message); // -> { extractComments: /foo/ } 😊
})

Choose a reason for hiding this comment

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

I don't think we can legitimately do this for functions, though.

var a = 1;
function toBeSerialized() {
  console.log(a);
}

in the case above, this results in non-obvious errors when serializing and deserializing in a different context. Is there any way we can avoid passing functions or regexes down to the worker and instead post process once comments are sent back to us?

Copy link
Contributor Author

@aui aui Jul 11, 2017

Choose a reason for hiding this comment

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

Thanks for reminding me. I now think of three viable options:

  1. When parallel.workers> 0, users are not allowed to use Function or RegExp
  2. Before serializing, use ESlint to check the Function that contains context-dependent
  3. At the run-time stage, catch the error and tell the user why the error occurred

I am currently inclined to the third program because it is friendly and simple. :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mikesherov I refactored the serialization.js, and now the user can know the details of the error :-)

Error: options serialization failed: "test" parse failed: 'console' is not defined.
    1| function toBeSerialized() {
 >> 2|     console.log(a);
    3|   }

@mikesherov
Copy link

Great work here, a couple of notes:

  1. Is workerization faster when CPU count is 1? Same question for when number of files to uglify is less than CPU count?
  2. Separately, we should use worker-farm to manage processes.
  3. Please max sure maxWorkers is maxed out at CPU count - 1. Having more workers than CPUs - 1 will introduce jank and slowdown.

@michael-ciniawsky
Copy link
Member

michael-ciniawsky commented Jul 19, 2017

@aui Are there any options definitely known to not work in when options.parallel is enabled? I need to update the docs PR (#61) with the changes here accordingly, so anything thats needs attention in terms of documentation would be appreciated if you could give me a brief sum up on that 😛

@@ -16,14 +16,14 @@ describe('when applied with no options', () => {
const compilerEnv = pluginEnvironment.getEnvironmentStub();
compilerEnv.context = '';

const plugin = new UglifyJsPlugin();
const plugin = new UglifyJsPlugin({ parallel: { cache: false, workers: 0 } });
Copy link
Contributor

Choose a reason for hiding this comment

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

Please move all options related changes in this test to it's own test file, or to parallel-option.test.js. The purpose of this test is to literally have empty options.

@@ -17,6 +17,7 @@ describe('when applied with uglifyOptions.ecma', () => {
});

new UglifyJsPlugin({
parallel: { cache: false, workers: 0 },
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment I made for empty-options.test> It's important that these options for these tests are not changed in order for us to really understand that it is not breaking those use cases.

@@ -11,6 +11,7 @@ describe('when applied with extract option set to a single file', () => {
compilerEnv.context = '';

const plugin = new UglifyJsPlugin({
parallel: { cache: false, workers: 0 },
Copy link
Contributor

@hulkish hulkish Jul 19, 2017

Choose a reason for hiding this comment

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

Same as #77 (comment)

@@ -10,6 +10,7 @@ describe('when applied with invalid options', () => {
it('matches snapshot', () => {
const compiler = createCompiler();
new UglifyJsPlugin({
parallel: { cache: false, workers: 0 },
Copy link
Contributor

@hulkish hulkish Jul 19, 2017

Choose a reason for hiding this comment

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

Same as #77 (comment)

const pluginEnvironment = new PluginEnvironment();
const compilerEnv = pluginEnvironment.getEnvironmentStub();
compilerEnv.context = '';

const plugin = new UglifyJsPlugin({
parallel: { cache: false, workers: 0 },
Copy link
Contributor

@hulkish hulkish Jul 19, 2017

Choose a reason for hiding this comment

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

Same as #77 (comment)

@@ -3,9 +3,9 @@
exports[`errors 1`] = `
Array [
"Error: main.0c220ec66316af2c1b24.js from UglifyJs
DefaultsError: \`invalid-option\` is not a supported option",
\`invalid-option\` is not a supported option",
Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

Where did DefaultsError: go?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

uglify-es error object does not have a msg field.

src/index.js > buildError() :

} else if (err.msg) {
  return new Error(`${file} from UglifyJs\n${err.msg}`);
}
return new Error(`${file} from UglifyJs\n${err.stack}`); // -> ...DefaultsError: `invalid-option` is not a supported option"
} else if (err.message) {
  return new Error(`${file} from UglifyJs\n${err.message}`); // -> ...`invalid-option` is not a supported option"
}
return new Error(`${file} from UglifyJs\n${err.stack}`);

Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

can you try changing this line to:

const str = exports.removeCWD(error.message)

"Error: manifest.6afe1bc6685e9ab36c1c.js from UglifyJs
DefaultsError: \`invalid-option\` is not a supported option",
\`invalid-option\` is not a supported option",
Copy link
Contributor

Choose a reason for hiding this comment

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

Again about DefaultsError

@@ -17,6 +17,7 @@ describe('when applied with all options', () => {
compilerEnv.context = '';

const plugin = new UglifyJsPlugin({
parallel: { cache: false, workers: 0 },
Copy link
Contributor

Choose a reason for hiding this comment

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

This one's fine, since this is an all-options test.

@@ -311,13 +325,14 @@ describe('when applied with all options', () => {
compilationEventBindings = chunkPluginEnvironment.getEventBindings();
});

it('should get all warnings', () => {
it('should get all warnings', (done) => {
Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

Remove, because this is a synchronous test (ensured via PluginEnvironment). This should not use a callback. or return a promise. This will also help to make sure the parallel feature being added does not break this expected behavior.

You can instead basically replicate this test inside parallel-option.test.js - this way we can have coverage for both single-core and multicore environments.

@@ -365,28 +380,16 @@ describe('when applied with all options', () => {
compilationEventBindings = chunkPluginEnvironment.getEventBindings();
});

it('should get no warnings', () => {
it('should get no warnings', (done) => {
Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

Please remove all of these for any existiing tests. So that we can keep visibility on the synchronous behavior.

compilationEventBinding.handler([{
files: ['test', 'test.js'],
}], () => {
expect(Object.keys(compilation.assets).length).toBe(4);
done();
Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

Remove these too, please.

const pluginEnvironment = new PluginEnvironment();
const compilerEnv = pluginEnvironment.getEnvironmentStub();
compilerEnv.context = '';

const plugin = new UglifyJsPlugin({
parallel: { cache: false, workers: 0 },
Copy link
Contributor

Choose a reason for hiding this comment

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

This one is ok, because it's helpful to test this against es2015 feature.

const pluginEnvironment = new PluginEnvironment();
const compilerEnv = pluginEnvironment.getEnvironmentStub();
compilerEnv.context = '';

const plugin = new UglifyJsPlugin({
parallel: { cache: false, workers: 0 },
Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

Remove please.

plugin.apply(compilerEnv);
eventBindings = pluginEnvironment.getEventBindings();
});

it('matches snapshot', () => {
const compiler = createCompiler();
new UglifyJsPlugin().apply(compiler);
new UglifyJsPlugin({ parallel: { cache: false } }).apply(compiler);
Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

Please remove this change here and just make a new test inside parallel-option.test.ja. Something like:

  it('matches snapshot', () => {
    const compiler = createCompiler();
    new UglifyJsPlugin({ parallel: { cache: false, workers: 0 } }).apply(compiler);

    return compile(compiler).then((stats) => {
      const errors = stats.compilation.errors.map(cleanErrorStack);
      const warnings = stats.compilation.warnings.map(cleanErrorStack);

      expect(errors).toMatchSnapshot('errors');
      expect(warnings).toMatchSnapshot('warnings');

      for (const file in stats.compilation.assets) {
        if (Object.prototype.hasOwnProperty.call(stats.compilation.assets, file)) {
          expect(stats.compilation.assets[file].source()).toMatchSnapshot(file);
        }
      }
    });
  });

Copy link

@mikesherov mikesherov 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 we're finally ready to land this!

Copy link
Contributor

@hulkish hulkish left a comment

Choose a reason for hiding this comment

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

Looks great, good stuff.

@@ -121,7 +123,7 @@ describe('when applied with no options', () => {
}], () => {
expect(compilation.errors.length).toBe(1);
expect(compilation.errors[0]).toBeInstanceOf(Error);
expect(compilation.errors[0].message).toEqual(expect.stringContaining('TypeError'));
Copy link
Contributor

@hulkish hulkish Jul 20, 2017

Choose a reason for hiding this comment

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

Just making note of this for when #80 is rebased on top of this. This should remain a TypeError - but I think the options schema validation should cover this. Nothing needing done for this pr i believe.

Copy link
Member

@michael-ciniawsky michael-ciniawsky left a comment

Choose a reason for hiding this comment

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

@aui Thx very much fo putting up this PR, this is very good work :)

@mikesherov
Copy link

Wooo!

@scott-thrillist
Copy link

scott-thrillist commented Jul 21, 2017

Benchmarks vs webpack-parallel-uglify-plugin

webpack-parallel-uglify-plugin
23.18 seconds
22.61 seconds
22.19 seconds
22.51 seconds

uglifyjs-webpack-plugin
25.96 seconds
18.41 seconds
16.14 seconds
16.18 seconds

uglifyjs-webpack-plugin (parallel: false)
51.89 seconds

👍 👍 👍

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

Successfully merging this pull request may close these issues.

9 participants