Skip to content

Latest commit

 

History

History
282 lines (203 loc) · 16 KB

PERFORMANCE.md

File metadata and controls

282 lines (203 loc) · 16 KB

There's a lot of interest in improving the performance of the Meteor command-line tool and build system, and if you're interesting in helping, this document is meant to be a jumping off point.

Sooner or later, the tool will be inevitably be substantially rewritten or replaced in order to achieve those sweet sub-second build times everyone loves, because it is currently too complex and reliant on the file system to get there. However, until then, there's work that can be done to make things faster.

For more on the tool, see also the README files in this directory and its sub-directories.

Profiling

The best way to get information about where time is going in the Meteor tool is with built-in profiler, activated with the environment variable METEOR_PROFILE. The value is interpreted as a threshold in ms, so the value 1 means to report on all calls that take more than 1ms:

METEOR_PROFILE=1 meteor ...

You'll then see a report (or a series of reports) printed to the console that might look something like:

| (#1) Profiling: ProjectContext prepareProjectForBuild
| ProjectContext prepareProjectForBuild..........9,207 ms (1)
|    _initializeCatalog.............................24 ms (1)
|       files.readFile                               7 ms (2)
|       runJavaScript package.js                     2 ms (1)
|       files.rm_recursive                           4 ms (4)
|       other _initializeCatalog                    11 ms
|    _resolveConstraints.........................6,702 ms (1)
|       bundler.readJsImage.........................42 ms (1)
...
| (#1) Total: 9,544 ms (ProjectContext prepareProjectForBuild)

The report is a simple top-down listing, where each entry represents a function call or other instrumented block in the code. A dotted line is drawn for entries that have child entries, and the total time of the child entries should add up to the parent entry (though turning up the profiling threshold may mess with the math). The number in parentheses is the number of times that function or block was called, and the time listed is the total cumulative time of all the calls.

Entries in the profiler come from annotations that instrument code, and unlike what you get with sampling profilers (like node --prof or dtrace), the call counts are exact. All times are measured with high-resolution wall-clock timers and only rounded for display. However, time measurements are quite susceptible to being inflated by random acts of garbage collection; profiler overhead; competition for the CPU; and so on. Also, if the tool were more highly parallelized, these stopwatch-type measurements might stop adding up, but so far they've been pretty accurate as far as we know.

Please see the section "Performance Considerations" before interpreting a profiler report.

Performance Considerations

Read this section before drawing conclusions or comparing numbers online.

The Meteor build tool is complex and has several layers of caches and modes of operation. It's not apples-to-apples to compare a rebuild time between a Meteor release and a checkout, for example, or to compare a start-up with a cold cache to one with a warm cache. This section lists the various nuances of build tool performance.

Unreported Work

Not everything is covered by the reports we generate ("prepare project" and "build app"). For example, you won't see:

  • Time spent in app start-up
  • Time spent creating file watchers after a rebuild

Caching

The tool saves work using various disk caches and in-memory caches. There are caches of:

  • Built packages and plugins
  • Compiler plugin results - and not just on the most recent version of a file! If you are testing rebuilds by toggling a character back and forth, you may hit cache.
  • Linker output
  • The entire server program, for client-only changes
  • Constraint solver results

Therefore, app start-up will be slow if the disk caches haven't been generated or are invalid because a package needs to be rebuilt, for instance, or the constraint solver needs to run. Rebuilds are much faster than the initial build of the app, and the second rebuild even seems to be faster than the first, at which point the time levels off.

See the section "More About Caches".

Release vs. Checked-Out Branch

The Meteor tool behaves differently depending on whether you are running a Meteor release, or a checked-out branch of a git repository. Most people run a Meteor release, but if you are making any changes to the tool or core packages, you'll be running Meteor in checked-out mode for testing, debugging, and measurement, so it's important to know the discrepancies. (See also "More About Release vs. Branch".)

The following are performance-related differences between running an app with a release of Meteor vs. a checked-out branch of Meteor:

  • Checked-out Meteor recompiles core packages. When you run an app using checked-out Meteor, it will compile core packages into APP/.meteor/local/isopacks -- that's right, into your app! This process can take a while -- as much as a couple minutes -- but it's only done once per package per app, unless the package changes. When the app starts up, it will read in the built packages and decide if it needs to do anything. However, there's noticeable extra time "preparing" for each rebuild, because the core packages are rescanned, and the large number of files being watched for changes can cause several seconds of delay after a rebuild as well.

  • Constraint-solver performance is different. Core packages are constrained by a release and also by running from checkout, but the effect is different, and the constraint solver seems to have an easier time on the constraint problems it gets when running from checkout, because of the way core packages are pinned like local packages.

  • Constraint-solver won't save APP/.meteor/versions if there's a release in APP/.meteor/release. Normally, when running released Meteor, APP/.meteor/versions provide stability to your package versions between runs of your app. However, when you run REPO/meteor, core package versions are temporarily overridden by the versions in the repo, but not saved, unless APP/.meteor/release is set to none, meaning the app is not written against a released version of Meteor. This difference comes up when testing and measuring the behavior of the constraint solver.

Time spent rebundling and rebuilding the app seems pretty similar across Release and Checkout.

App Differences

Different apps can strain different parts of the Meteor tool, such as:

  • Constraint solver - lots of dependencies; large package graph
  • File watchers - lots of total files, e.g. thousands
  • Modules - as of Meteor 1.3, imports from packages and node_modules must be scanned and analyzed
  • CSS processing - e.g. in case of a large LESS or Sass framework in the app
  • Community plugins, including legacy plugins without caching between builds

Hardware and OS

Spinning disks are much slower than SSDs.

Windows has a very different file system from Mac OS X or Linux.

On OS X and Linux, we have all these nice properties:

  • Usable symlinks
  • Renaming a file is cheap
  • Overwriting a file by rename is atomic
  • An file can be deleted ("unlinked") while open
  • A file can be moved (renamed) while open

Windows doesn't have these properties; for example, renaming a file is a copy operation. In addition, virus scanners and other software will sometimes block or postpone file operations. And, of course, there are the path separator differences. Parts of the tool work really hard to target OS X and Linux efficiently (with symlinks and renames, for example) while doing something that behaves well on Windows, even if it's slower.

Instrumenting Code for the Profiler

If you see a large time next to "other (function)", that's a case where the tool needs further instrumentation. For example, you may see something like this:

| ImportScanner#_readFile...............1,338 ms (329)
|    files.readFile                       100 ms (329)
|    sha1                                   3 ms (329)
|    other ImportScanner#_readFile      1,235 ms

In this example, some function calls under ImportScanner#_readFile just happen to be instrumented (files.readFile and sha1), but no other calls under ImportScanner#_readFile have been annotated yet. Given how much time is spent in this call, we should seek out more calls to annotate. You can determine by experimentation which calls are significant enough to add to the profile.

To annotate a function or block in tool code to be included in the profile, use one of the following forms:

// Profile(.) takes a function and wraps it, preserving arguments
// and this.  You can often get away without introducing any extra
// indentation in your text editor.
var doIt = Profile("doIt", function (...) {
  ...
});

// Wrap a method
MyClass.prototype.doIt =
  Profile("MyClass doIt", MyClass.prototype.doIt);

// Profile.time(.) invokes a block immediately and times it,
// passing the return value through.
function doIt() {
  ...
  var result = Profile.time("doIt", () => {
    ...
  });
  ...
}

The Profiler is activated using Profile.run(.), which will cause a report to be generated. Currently we generate a couple reports when METEOR_PROFILE is set -- one for preparing the project (including constraint solving) and one for building or rebuilding the app.

In addition to tool code, you can also use Profile in compiler plugins. If you want to use Profile in packages that are loaded into the tool (e.g. packages depended on by compiler plugins, or specially loaded into the tool as isopackets), you should always test (typeof Profile !== 'undefined') before accessing Profile, or pass it in from the tool as an option.

Known Areas for Improvement

These are areas to improve or investigate, along with what's known, in no particular order.

CSS Minification

See: meteor#5875.

Meteor's CSS minification is user-pluggable since 1.2. What the standard minifier does is concatenate your CSS into one file with import statements hoisted to the top. In production mode, this file is minified before being served. The benefit of doing the same CSS merging in development and production is that the meanings of relative paths in CSS files and the order of rules doesn't change. However, reparsing and merging the CSS for a typical app can take anywhere from 500ms to a couple seconds or more, depending on the amount of CSS.

Initial profiling shows the time is split between different operations like parsing CSS, generating CSS, and working with source maps. Approaches could include:

  • Inserting a new cache, such as around parsing files
  • Only recalculating the CSS if it changes
  • Using a faster CSS library

Source Maps

The tool spends noticeable building source maps, as do other tools such as Webpack, which have evolved creative mechanisms to cut down the time. There's some question of whether the source-map library is as efficient as it could be, and whether we're using it as efficiently as we could be.

Linker Cache

See: meteor#5818

The "linker cache" on disk at APP/.meteor/local/bundler-cache/linker has some very large files that take a long time write, and they are apparently not cleaned up either. Sometimes you will see calls to files.writeFileAtomically taking many seconds at the top of the "Rebuild App" report, but somehow outside the top level (probably because they are fired off asynchronously).

Constraint Solving

Some initial work has been done to make constraint solving fast in the typical case where a previous solution exists in APP/.meteor/versions, but there is possible room for improvement.

For example, the previous solution could be checked without invoking the logic solver at all, though the manual logic would have to match closely. Implemented!

Time spent reading from the packages database (a SQLite file) could be understood better and improved by batching queries, reading less data, or using SQLite better.

See: meteor#6137

"Updating package catalog..."

Talking to the package server to download new package metadata seems to take longer than it should; easily 5 seconds, based on watching the progress display, and occasionally much longer.

Hoisting Build Work

You'll find scattered through the tool code comments about how it would be more efficient to do certain processing or reading in a way such that it wouldn't have to be done a second time in a different part of the tool, or it wouldn't have to be done on each rebuild. The first one is about the flow of information in the tool, and the second is about making better use of information about what changed, either through caching or file-watching.

File watching

It seems like it would be great if file-watching gave the tool perfect information, so it wouldn't have to go to disk and read lots of files even though, in theory, if they had changed it would know. You can imagine a tool that is super fast because it basically reads and writes diffs of the state of the file system. It's not clear how feasible that is, though. Right now, when we're told a file has changed, we immediately go to work, which includes tearing down file-watchers; we don't wait to get the exact list of what files have changed. For the tool to remain receptive to changes would require a different paradigm.

However, it seems like some middle ground would be possible, where the tool does not simply merge all the lists of files it has ("WatchSets") and wait for any change to any of them, but where rather it responds to changes to different sets of files by rebuilding different parts and then putting them together; for example, only processing the CSS if some CSS changed.

It also might be that changing to a different file-watching library would have performance benefits.

More About Caches

Disk caches exist in the following locations, where APP is the app:

  • APP/.meteor/local/isopacks
  • APP/.meteor/local/bundler-cache
  • APP/.meteor/local/plugin-cache
  • ~/.meteor (package storage, when running a release)
  • REPO/.meteor (package storage, when running a checked-out branch)

You can safely remove these directories to clear your caches, but don't remove APP/.meteor/local if you have data in your app's database, because the local Mongo DB resides in APP/.meteor/local/db. (If you don't care about the DB, you can remove all of local for the ultimate clean checkout experience.) The package storage directory holds master copies of packages that will be copied or linked into your project (like Maven's .m2). There's probably no reason to clear them out, except to save some disk space. It's sometimes nice to know where these files are located in case you want to poke around or add console.logs to released code for debugging.

More About Release vs. Branch

Most people run a released version of the meteor tool, but if you are making any changes to the tool or core packages, you'll be running it in checked-out mode for testing and debugging. Here's how the two modes work:

  • Release: This is when you run the installed meteor tool on your computer. The tool and core packages are part of a release with a name like METEOR@1.2.1 and were downloaded from the package server and cached in ~/.meteor along with other Atmosphere packages. When the tool is run, the current release is determined from APP/.meteor/release when running an app, or the value of --release on the command-line.

  • Checked-Out Branch: This is when you check out the meteor/meteor GitHub repository as REPO and run REPO/meteor. The tool is loaded from REPO/tools, and core packages are loaded from REPO/packages. The tool takes care of transpiling itself and rebuilding out-of-date core packages. Core packages in the repo behave much like local packages in your app; package foo is treated as the only known version of foo, and its files are watched for hot-reload.