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

fix: improve app build times #10789

Closed
wants to merge 2 commits into from

Conversation

sgtcoolguy
Copy link
Contributor

@sgtcoolguy sgtcoolguy commented Mar 20, 2019

JIRA: https://jira.appcelerator.org/browse/TIMOB-26917

Description:
Note that this fix does not yet touch the originally reported issue that we're checking all files for changes resulting in slower builds - instead this simply makes our processing of JS files run in parallel. Specifically the code around reading the files, parsing/transpiling/minifying and then writing the results.

Here's some quick and dirty timings of just that portion of the build for me:

ios

As-is:

  • local ios-test: Took 13506ms to process 181 JS files
  • mocha suite with npm packages: Took 724538ms to process 1799 JS files

in parallel:

  • local ios-test: Took 6752ms to process 181 JS files
  • mocha suite with npm packages: Took 24881ms to process 1799 JS files

android

As-is:

  • local android-test: Took 6734ms to process 181 JS files
  • mocha suite with npm packages: Took 17536ms to process 1801 JS files

parallel:

  • local android-test: Took 4661ms to process 181 JS files
  • mocha suite with npm packages: Took 15008ms to process 1801 JS files

So this does substantially improve performance for me. The difference is less noticeable on large Android projects as I suspect we'll hit a general chokepoint of the babel parse/transform (perhaps moving to async APIs in node-titanium-sdk could help there?) But for iOS projects, and especially large ones we see a huge performance increase (in this case 29x!).

Interesting to note that even with this changes there's an obvious order-of-magnitude performance difference between iOS and Android, likely due to the exact issue @hansemannn reported.

On Android, we compare the original contents to the contents post-jsanalyze (both values we already have in vars/memory at the time). If they don't differ and we can do symlinks we simply symlink the original. If they do differ we delete the destination (in case it was a symlink) and then write the new contents.

On iOS, we compare the contents post-jsanalyze to the destination file contents (here we check if the file exists and then if it does we read it in). If they match we do nothing. If the destination does not exist or is different, we write the new contents.

The issue here is how each plays with "differential" builds (and indeed if having to read the destination files in each time negates any benefit of a differential build). Android is comparing original src js to post-babel contents and ignoring if we've already got a post-babel version in the app destination. iOS is comparing post-babel to app destination file and attempting to avoid modifying the file in the app destination if nothing changed.

@@ -5884,7 +5884,7 @@ iOSBuilder.prototype.copyResources = function copyResources(next) {
// dest doesn't exist, or new contents differs from existing dest file
if (!exists || newContents !== fs.readFileSync(to).toString()) {
this.logger.debug(__('Copying and minifying %s => %s', from.cyan, to.cyan));
exists && fs.unlinkSync(to);
// no need to delete if it exists, writeFile will overwrite anyways
Copy link
Contributor

Choose a reason for hiding this comment

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

Android does the same thing on line 2974.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, but there it's needed. Because on Android we try to symlink files over, and if the destination is a symlink it must be removed first or the write will actually write to the symlink target (i.e. the user's original source)

@build build added this to the 8.1.0 milestone Mar 20, 2019
@build
Copy link
Contributor

build commented Mar 20, 2019

Warnings
⚠️

iphone/cli/commands/_build.js#L6410 - iphone/cli/commands/_build.js line 6410 – 'out' is defined but never used. (no-unused-vars)

Messages
📖 👍 Hey!, You deleted more code than you added. That's awesome!
📖

💾 Here's the generated SDK zipfile.

📖

✅ All tests are passing
Nice one! All 3630 tests are passing.

Generated by 🚫 dangerJS against 460fe50

@cb1kenobi
Copy link
Contributor

Android should be comparing the post-jsanalyze to the destination like iOS. We must transpile every file every build so that we can compare the hashes and see if we need to write the new contents. This could be a huge bug. The code related to the part of the build has been touched since I first wrote it, so I won't be surprised if something slipped through the cracks.

Another thing that's bugging me about this PR is if I could have parallelized this logic, I would have. There has to be some reason I didn't. I need to think hard why it wasn't parallelized.

@sgtcoolguy
Copy link
Contributor Author

So I probably need to re-run my results here to compare a 2nd build, since the timings above are "clean"/1st builds (so it doesn't measure how a differential build logic may help/hurt each platform)

We do transpile the code every build on both platforms. The difference here is that Android doesn't care if a previous build has already written the destination file - it just writes out the current transpiled contents regardless (so potentially it may write the exact same contents to the same file and cause the modification timestamp to update unnecessarily). iOS looks at the previous build's output to compare before choosing to write the contents or not.

The question is whether we must compare existing files from previous builds to current build on Android like we do iOS. Or if the need to check existence and read before writes negates any benefit to doing so.

@cb1kenobi
Copy link
Contributor

Maybe I'm missing something, but where's the differential build code? Is it only resources? I went back to older 7.x and 6.x versions of the build and I question if differential checks ever worked for source files. I swear I added it.

@cb1kenobi
Copy link
Contributor

We should not compare existing files to new contents. We should be comparing the mtime from the build manifest and if that differs, then compare hashes.

@sgtcoolguy
Copy link
Contributor Author

For more timings, here's what I got for runs of 1 clean build versus a clean build followed by a second "incremental" build pre and post-pR for each platform:

ios

clean build, series (pre-PR):

  • local ios-test: Took 13506 to process 181 JS files
  • suite: Took 724538 to process 1799 JS files

clean build, parallel:

  • local ios-test: Took 6752 to process 181 JS files
  • suite: Took 24881 to process 1799 JS files

multiple runs, series:

  • 1st run of ios-test: Took 13350ms to process 181 JS files (Finished building the application in 28s 178ms)

  • 2nd run of ios-test: Took 12774ms to process 181 JS files (Finished building the application in 16s 468ms)

  • 1st run of suite: Took 714134ms to process 1799 JS files (Finished building the application in 12m 8s 478ms)

  • 2nd run of suite: Took 763060ms to process 1799 JS files (Finished building the application in 12m 48s 344ms)

multiple runs, parallel:

  • 1st run of ios-test: Took 6067ms to process 181 JS files (Finished building the application in 21s 182ms)

  • 2nd run of ios-test: Took 5842ms to process 181 JS files (Finished building the application in 9s 715ms)

  • 1st run of suite: Took 25618ms to process 1799 JS files (Finished building the application in 40s 120ms)

  • 2nd run of suite: Took 23710ms to process 1799 JS files (Finished building the application in 29s 880ms)

android

clean build, series (pre-PR):

  • local android-test: Took 6734 to process 181 JS files
  • suite: Took 17536 to process 1801 JS files

clean build, parallel:

  • local android-test: Took 4661 to process 181 JS files
  • suite: Took 15008 to process 1801 JS files

multiple runs, series:

  • 1st run of android-test: Took 4869ms to process 181 JS files (Project built successfully in 54s 782ms)

  • 2nd run of android-test: Took 4900ms to process 181 JS files (Project built successfully in 34s 525ms)

  • 1st run of suite: Took 14815ms to process 1801 JS files (Project built successfully in 1m 18s 87ms)

  • 2nd run of suite: Took 15734ms to process 1801 JS files (Project built successfully in 57s 909ms)

multiple runs, parallel:

  • 1st run of android-test: Took 4701ms to process 181 JS files (Project built successfully in 1m 242ms)

  • 2nd run of android-test: Took 5371ms to process 181 JS files (Project built successfully in 37s 133ms)

  • 1st run of suite: Took 14489ms to process 1801 JS files (Project built successfully in 1m 14s 789ms)

  • 2nd run of suite: Took 15354ms to process 1801 JS files (Project built successfully in 57s 667ms)

To try and summarize:

  • iOS clean build timings for processing JS files roughly halve for our 181 JS file suite
  • Once you add a lot of npm packages and increase the JS files 10x, the performance boost is now roughly 29x
  • incremental builds are faster across the board (both platforms, pre and post PR) and on iOS the difference in overall app build times (between pre and post PR) is the savings we got from JS file processing in parallel
  • This change does not have an appreciable difference on Android timings

@@ -5818,7 +5818,7 @@ iOSBuilder.prototype.copyResources = function copyResources(next) {
this.logger.info(__('Processing JavaScript files'));
const sdkCommonFolder = path.join(this.titaniumSdkPath, 'common', 'Resources');

async.eachSeries(Object.keys(jsFiles), function (file, next) {
async.each(Object.keys(jsFiles), function (file, next) {
setImmediate(function () {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this setImmediate necessary anymore?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't remember why the setImmediate() was there in to begin with in this situation, however I generally use setImmediate() to guarantee a block is truly async (which is sometimes necessary if returning an event emitter or there's unified error handling) or to collapse the call stack, though this function isn't very recursive.

@hansemannn
Copy link
Collaborator

hansemannn commented Mar 20, 2019

Some quick benchmarks from our project (276+ JavaScript source files):

Android

TBD

iOS

8.1.0 (master)

  • COLD: Finished building the application in 1m 12s 589ms
  • INCREMENTAL: Finished building the application in 30s 869ms

8.1.0 (this pull)

  • COLD: Finished building the application in 52s 560ms
  • INCREMENTAL: Finished building the application in 13s 621ms

Note: We use a Crashlytics script phase to upload our DSYMs after each clean build which takes ~ 10s, so that needs to be removed from the clean build times.


Side notes:

  • Our biggest bottleneck with build time right now is Alloy. All source files are recompiled every time, although Alloy supports incremental compiling already. A "simple" change to use that + some kind of file hash would allow to save ~ 4.2s on every build. It also copies over all generated files from app/ to Resources/ every build, which doesn't make sense if not changed.
  • All Files are scanned on every build (using the infamous No change, skipping log), although not changed. There must be a better way to check this instead of checking it for every file. I also wonder if this is really an interesting information to the developer, even on a debug log level.
  • We use a lot native frameworks, so the framework scanner from @janvennemann helps a lot. But it also does not have an incremental option so far, resulting in scans inside every native module inside modules/ for every build. That directory could definitely be hashed and compared before attempting to re-scan.

That's it! With these changes, we could probably have a 5s incremental build, which is the most important thing to use. After that's done, Liveview "v2" could use that as a base to enable hot reloading but...different topic ✌️ Thanks for the great work @sgtcoolguy!

@sgtcoolguy
Copy link
Contributor Author

Juts a general note here that I'm just looking for easy low hanging fruit perf win here, not to rewrite the build pipeline (which I think requires a much larger discussion and some tech spikes - including around possibly taking advantage of gulp/vinyl). It's clear we have an issue of Alloy/classic/hyperloop doing lots of unnecessary additional reads/writes/copies/parses when combined together.

@sgtcoolguy
Copy link
Contributor Author

Also, to record a note that @ewanharris and I discussed.: he was concerned not using eachLimit may result in us hitting the system limit on open files. I have not changed the default of 256 locally and did not run into this when testing the 1800 JS file suite.

I appreciate the concerns here that just throwing so many files at once may bog the system down or hit some limit - and it is entirely possible, but I did not run into it with a fairly sizable number of files - and I'm not sure what would be a reasonable value to choose as a limit here.

@cb1kenobi
Copy link
Contributor

The limit should be set to 699050. You can go higher or lower, but then it wouldn't equal 10101010101010101010 in binary.

@sgtcoolguy
Copy link
Contributor Author

Merged manually to master.

@sgtcoolguy sgtcoolguy closed this Mar 21, 2019
@sgtcoolguy sgtcoolguy deleted the build-perf-js-files branch March 21, 2019 19:51
@janvennemann
Copy link
Contributor

@hansemannn, the framework scanner has incremental build support. It's just the initial scanning of paths that is done repeatedly. However, that just reads the directories and should not take a long time. No processing is done during that phase.

If no frameworks in the scanned paths change the framework hook will do nothing. You can see that in the trace logs of a build.

@sgtcoolguy, i think we could use the change detection from https://github.com/appcelerator/appc-tasks (which is used by the framework hook hans mentioned above) to further speed up our builds without changing too much on the existing build pipeline. I will give it a look when i have some free time.

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

Successfully merging this pull request may close these issues.

None yet

6 participants