Repository: ember-cli/ember-cli
Pull request #9487 (2021-03-17 22:25:45)

Add support for creating a single addon instance per bundle root (which enables dramatically reducing the total number of addon instances)
This creates a cache of addon instances (one per "bundle host", i.e. project or lazy engine) that meet criteria that they are simple enough and have no behaviors that would prevent them from being a 'singleton', i.e. that we could replace other instances of them (and, importantly, their child addons) with a proxy to the original addon instance. Each proxy contains a pointer to the singleton instance and a local copy of the 'app' and 'parent' properties. It defines 'set' and 'get' to proxy through most property or method requests to the singleton copy. In applications with a large number of references to a given addon that itself has a large number of dependent addons, this can save huge amounts of space (in the largest applications, on the order of several GB of heap and process memory). It does, however, mean that traversals up the 'parent' tree will only work correctly when the addon is being called in the one location that it is not proxied.

As part of creating tests for the new feature, made a significant number of changes to the `fixturify-project' utility in tests/helpers/ to make it more flexible and allow creating fixtures for engines (both in-repo and in node_modules). Those changes are then used in the per-bundle-addon-cache tests.


Patch:
diff --git a/lib/models/instantiate-addons.js b/lib/models/instantiate-addons.js
index 38577018e..e7a820a4c 100644
--- a/lib/models/instantiate-addons.js
+++ b/lib/models/instantiate-addons.js
@@ -70,27 +70,8 @@ function instantiateAddons(parent, project, addonPackages) {
         );
       }
 
-      let AddonConstructor = pkgInfo.getAddonConstructor();
-
-      let addon;
-
-      try {
-        addon = new AddonConstructor(parent, project);
-      } catch (e) {
-        if (parent && parent.ui) {
-          parent.ui.writeError(e);
-        }
-        const SilentError = require('silent-error');
-        throw new SilentError(`An error occurred in the constructor for ${addonInfo.name} at ${addonInfo.path}`);
-      }
-
-      if (addon.initializeAddons) {
-        addon.initializeAddons();
-      } else {
-        addon.addons = [];
-      }
-
-      addon.constructor = AddonConstructor;
+      // get an instance of the addon. If that fails it will throw.
+      let addon = pkgInfo.getAddonInstance(parent, project);
 
       timings.set(addon, Date.now() - start);
 
diff --git a/lib/models/package-info-cache/index.js b/lib/models/package-info-cache/index.js
index 1e307e055..70eb96baa 100644
--- a/lib/models/package-info-cache/index.js
+++ b/lib/models/package-info-cache/index.js
@@ -511,7 +511,7 @@ class PackageInfoCache {
 
     // If we have an ember-addon, check that the main exists and points
     // to a valid file.
-    if (pkgInfo.isAddon()) {
+    if (pkgInfo.isForAddon()) {
       logger.info('%s is an addon', pkg.name);
 
       // Note: when we have both 'main' and ember-addon:main, the latter takes precedence
diff --git a/lib/models/package-info-cache/package-info.js b/lib/models/package-info-cache/package-info.js
index 6e30cf80b..b3e96b986 100644
--- a/lib/models/package-info-cache/package-info.js
+++ b/lib/models/package-info-cache/package-info.js
@@ -5,7 +5,10 @@ const ErrorList = require('./error-list');
 const Errors = require('./errors');
 const AddonInfo = require('../addon-info');
 const isAddon = require('../../utilities/is-addon');
+const isEngine = require('../../utilities/is-engine');
+const isLazyEngine = require('../../utilities/is-lazy-engine');
 const logger = require('heimdalljs-logger')('ember-cli:package-info-cache:package-info');
+const PerBundleAddonCache = require('../per-bundle-addon-cache');
 
 function lexicographically(a, b) {
   const aIsString = typeof a.name === 'string';
@@ -80,7 +83,7 @@ class PackageInfo {
     // not actually be used.
     this.valid = true;
 
-    this.mayHaveAddons = isRoot || this.isAddon(); // mayHaveAddons used in index.js
+    this.mayHaveAddons = isRoot || this.isForAddon(); // mayHaveAddons used in index.js
 
     this._hasDumpedInvalidAddonPackages = false;
   }
@@ -162,7 +165,7 @@ class PackageInfo {
    * been added to the cache.
    *
    * Note: this is for ALL dependencies, not just addons. To get just
-   * addons, filter the result by calling pkgInfo.isAddon().
+   * addons, filter the result by calling pkgInfo.isForAddon().
    *
    * Note: this is only intended for use from PackageInfoCache._resolveDependencies.
    * It is not to be called directly by anything else.
@@ -220,10 +223,49 @@ class PackageInfo {
     return packages;
   }
 
-  isAddon() {
+  /**
+   * Indicate if this packageInfo is for a project. Should be called only after the project
+   * has been loaded (see {@link PackageInfoCache#loadProject} for details).
+   *
+   * @method isForProject
+   * @return {Boolean} true if this packageInfo is for a Project, false otherwise.
+   */
+  isForProject() {
+    return !!this.project && this.project.isEmberCLIProject && this.project.isEmberCLIProject();
+  }
+
+  /**
+   * Indicate if this packageInfo is for an Addon.
+   *
+   * @method isForAddon
+   * @return {Boolean} true if this packageInfo is for an Addon, false otherwise.
+   */
+  isForAddon() {
     return isAddon(this.pkg.keywords);
   }
 
+  /**
+   * Indicate if this packageInfo represents a lazy engine.
+   *
+   * @method isForLazyEngine
+   * @return {Boolean} true if this pkgInfo is configured as an engine and the
+   * module this represents has lazyLoading enabled, false otherwise.
+   */
+  isForLazyEngine() {
+    return isEngine(this.pkg.keywords) && isLazyEngine(require(this.addonMainPath));
+  }
+
+  /**
+   * For use with the PerBundleAddonCache, is this packageInfo representing a
+   * bundle host (for now, a Project or a lazy engine).
+   *
+   * @method isForBundleHost
+   * @return {Boolean} true if this pkgInfo is for a bundle host, false otherwise.
+   */
+  isForBundleHost() {
+    return this.isForProject() || this.isForLazyEngine();
+  }
+
   /**
    * Add to a list of child addon PackageInfos for this packageInfo.
    *
@@ -274,7 +316,7 @@ class PackageInfo {
     this.addPackages(
       addonPackageList,
       this.dependencyPackages,
-      (pkgInfo) => !pkgInfo.isAddon() || pkgInfo.name === 'ember-cli'
+      (pkgInfo) => !pkgInfo.isForAddon() || pkgInfo.name === 'ember-cli'
     );
     this.addPackages(addonPackageList, this.inRepoAddons);
 
@@ -294,8 +336,8 @@ class PackageInfo {
     this.addPackages(addonPackageList, project.isEmberCLIAddon() ? [this] : null);
     this.addPackages(addonPackageList, this.cliInfo ? this.cliInfo.inRepoAddons : null);
     this.addPackages(addonPackageList, this.internalAddons);
-    this.addPackages(addonPackageList, this.devDependencyPackages, (pkgInfo) => !pkgInfo.isAddon());
-    this.addPackages(addonPackageList, this.dependencyPackages, (pkgInfo) => !pkgInfo.isAddon());
+    this.addPackages(addonPackageList, this.devDependencyPackages, (pkgInfo) => !pkgInfo.isForAddon());
+    this.addPackages(addonPackageList, this.dependencyPackages, (pkgInfo) => !pkgInfo.isForAddon());
     this.addPackages(addonPackageList, this.inRepoAddons);
 
     return addonPackageList;
@@ -373,6 +415,7 @@ class PackageInfo {
    * Also, the assumption here is that this PackageInfo really is for an
    * Addon, so we don't need to check each time.
    *
+   * @private
    * @method getAddonConstructor
    * @return {AddonConstructor} an instance of a constructor function for the Addon class
    * whose package information is stored in this object.
@@ -400,7 +443,6 @@ class PackageInfo {
       ctor.prototype.pkg = ctor.prototype.pkg || this.pkg;
     } else {
       const Addon = require('../addon'); // done here because of circular dependency
-
       ctor = Addon.extend(Object.assign({ root: mainDir, pkg: this.pkg }, module));
     }
 
@@ -410,6 +452,100 @@ class PackageInfo {
 
     return (this.addonConstructor = ctor);
   }
+
+  /**
+   * Indicate if a constructor (function or class) has the 'allowCachingPerBundle' flag
+   * set either on itself or on its prototype. Depending on how addon modules or constructor
+   * functions are set up, it could be in either. Needed for per-bundle addon caching.
+   *
+   * @method allowCachingPerBundle
+   * @param {Function|Class} ctor
+   * @return {Boolean} true if the given constructor function or class supports caching per bundle, false otherwise
+   */
+  allowCachingPerBundle(ctor) {
+    let val = !!ctor && (ctor.allowCachingPerBundle || ctor.prototype.allowCachingPerBundle);
+    return val;
+  }
+
+  /**
+   * Construct an addon instance.
+   *
+   * NOTE: this does NOT call constructors for the child addons. That is left to
+   * the caller to do, so they can insert any other logic they want.
+   *
+   * @private
+   * @method constructAddonInstance
+   * @param {Project|Addon} parent the parent that directly contains this addon
+   * @param {Project} project the project that is/contains this addon
+   */
+  constructAddonInstance(parent, project) {
+    let start = Date.now();
+
+    let AddonConstructor = this.getAddonConstructor();
+
+    let addonInstance;
+
+    try {
+      addonInstance = new AddonConstructor(parent, project);
+    } catch (e) {
+      if (parent && parent.ui) {
+        parent.ui.writeError(e);
+      }
+      const SilentError = require('silent-error');
+      throw new SilentError(`An error occurred in the constructor for ${this.name} at ${this.realPath}`);
+    }
+
+    AddonConstructor._meta_.initializeIn = Date.now() - start;
+    addonInstance.constructor = AddonConstructor;
+
+    return addonInstance;
+  }
+
+  /**
+   * Create an instance of the addon represented by this packageInfo or (if we
+   * are supporting per-bundle caching and this is an allow-caching-per-bundle addon)
+   * check if we should be creating a proxy instead.
+   *
+   * NOTE: we assume that the value of 'allowCachingPerBundle' does not change between
+   * calls to the constructor! A given addon is either allowing or not allowing caching
+   * for an entire run.
+   *
+   * @method getAddonInstance
+   * @param {} parent the addon/project that is to be the direct parent of the
+   * addon instance created here
+   * @param {*} project the project that is to contain this addon instance
+   * @return {Object} the constructed instance of the addon
+   */
+  getAddonInstance(parent, project) {
+    let ctor = this.getAddonConstructor();
+    let addonInstance;
+
+    if (PerBundleAddonCache.isEnabled() && this.allowCachingPerBundle(ctor)) {
+      addonInstance = project.perBundleAddonCache.getAddonInstance(parent, this);
+    } else {
+      addonInstance = this.constructAddonInstance(parent, project);
+      this.initChildAddons(addonInstance);
+    }
+
+    return addonInstance;
+  }
+
+  /**
+   * Initialize the child addons array of a newly-created addon instance. Normally when
+   * an addon derives from Addon, child addons will be created during 'setupRegistry' and
+   * this code is essentially unnecessary. But if an addon is created with custom constructors
+   * that don't call 'setupRegistry', any child addons may not yet be initialized.
+   *
+   * @method initChildAddons
+   * @param {Addon} addonInstance
+   */
+  initChildAddons(addonInstance) {
+    if (addonInstance.initializeAddons) {
+      addonInstance.initializeAddons();
+    } else {
+      addonInstance.addons = [];
+    }
+  }
 }
 
 module.exports = PackageInfo;
diff --git a/lib/models/per-bundle-addon-cache/addon-proxy.js b/lib/models/per-bundle-addon-cache/addon-proxy.js
new file mode 100644
index 000000000..a1b5612b6
--- /dev/null
+++ b/lib/models/per-bundle-addon-cache/addon-proxy.js
@@ -0,0 +1,92 @@
+'use strict';
+
+const { TARGET_INSTANCE } = require('./target-instance');
+
+/**
+ * Returns a proxy to a target with specific handling for the
+ * `parent` property, as well has to handle the `app` property;
+ * that is, the proxy should maintain correct local state in
+ * closure scope for the `app` property if it happens to be set
+ * by `ember-cli`. Other than `parent` & `app`, this function also
+ * proxies _almost_ everything to `target[TARGET_INSTANCE] with a few
+ * exceptions: we trap & return `[]` for `addons`, and we don't return
+ * the original `included` (it's already called on the "real" addon
+ * by `ember-cli`).
+ *
+ * Note: the target is NOT the per-bundle cacheable instance of the addon. Rather,
+ * it is a cache entry POJO from PerBundleAddonCache.
+ *
+ * @method getAddonProxy
+ * @param targetCacheEntry the PerBundleAddonCache cache entry we are to proxy. It
+ * has one interesting property, the real addon instance the proxy is forwarding
+ * calls to (that property is not globally exposed).
+ * @param parent the parent object of the proxy being created (the same as
+ * the 'parent' property of a normal addon instance)
+ * @return Proxy
+ */
+function getAddonProxy(targetCacheEntry, parent) {
+  let _app;
+  let addonProxy = new Proxy(targetCacheEntry, {
+    get(targetCacheEntry, property) {
+      if (property === 'parent') {
+        return parent;
+      }
+
+      if (property === 'app') {
+        return _app;
+      }
+
+      // keep proxies from even trying to set or initialize addons
+      if (property === 'initializeAddons') {
+        return undefined;
+      }
+
+      // See the {@link index.js} file for a discussion of why the proxy 'addons'
+      // property returns an empty array.
+      if (property === 'addons') {
+        return [];
+      }
+
+      // allow access to the property pointing to the real instance.
+      if (property === TARGET_INSTANCE) {
+        return targetCacheEntry[TARGET_INSTANCE];
+      }
+
+      // `included` will be called on the "real" addon, so there's no need for it to be
+      // called again; instead we return a no-op implementation here
+      if (property === 'included') {
+        return () => undefined;
+      }
+
+      if (targetCacheEntry[TARGET_INSTANCE]) {
+        if (property !== 'constructor' && typeof targetCacheEntry[TARGET_INSTANCE][property] === 'function') {
+          // If we fall through to the Reflect.get just below, the 'this' context of the function when
+          // invoked is the proxy, not the original instance (so its local state is incorrect).
+          // Wrap the original methods to maintain the correct 'this' context.
+          return function _originalAddonPropMethodWrapper() {
+            return targetCacheEntry[TARGET_INSTANCE][property](...arguments);
+          };
+        }
+
+        return Reflect.get(targetCacheEntry[TARGET_INSTANCE], property);
+      }
+
+      return Reflect.get(targetCacheEntry, property);
+    },
+    set(targetCacheEntry, property, value) {
+      if (property === 'app') {
+        _app = value;
+        return true;
+      }
+
+      if (targetCacheEntry[TARGET_INSTANCE]) {
+        return Reflect.set(targetCacheEntry[TARGET_INSTANCE], property, value);
+      }
+
+      return Reflect.set(targetCacheEntry, property, value);
+    },
+  });
+
+  return addonProxy;
+}
+module.exports = { getAddonProxy };
diff --git a/lib/models/per-bundle-addon-cache/index.js b/lib/models/per-bundle-addon-cache/index.js
new file mode 100644
index 000000000..e5e5064b9
--- /dev/null
+++ b/lib/models/per-bundle-addon-cache/index.js
@@ -0,0 +1,391 @@
+'use strict';
+
+const isLazyEngine = require('../../utilities/is-lazy-engine');
+const { getAddonProxy } = require('./addon-proxy');
+const PROJECT_BUNDLE_HOST_NAME = '__PROJECT__';
+const logger = require('heimdalljs-logger')('ember-cli:per-bundle-addon-cache');
+const { TARGET_INSTANCE } = require('./target-instance');
+
+/**
+ * For large applications with many addons (and many instances of each, resulting in
+ * potentially many millions of addon instances during a build), the build can become
+ * very, very slow (tens of minutes) partially due to the sheer number of addon instances.
+ * The PerBundleAddonCache deals with this slowness by doing 3 things:
+ *
+ * (1) Making only a single copy of each of certain addons and their dependent addons
+ * (2) Replacing any other instances of those addons with Proxy copies to the single instance
+ * (3) Having the Proxies return an empty array for their dependent addons, rather
+ *     than proxying to the contents of the single addon instance. This gives up the
+ *     ability of the Proxies to traverse downward into their child addons,
+ *     something that many addons do not do anyway, for the huge reduction in duplications
+ *     of those child addons. For applications that enable `ember-engines` dedupe logic,
+ *     that logic is stateful, and having the Proxies allow access to the child addons array
+ *     just breaks everything, because that logic will try multiple times to remove items
+ *     it thinks are duplicated, messing up the single copy of the child addon array.
+ *     See the explanation of the dedupe logic in
+ *    {@link https://github.com/ember-engines/ember-engines/blob/master/packages/ember-engines/lib/utils/deeply-non-duplicated-addon.js}
+ *
+ * What follows are the more technical details of how the PerBundleAddonCache implements
+ * the above 3 behaviors.
+ *
+ * This class supports per-bundle-host (bundle host = project or lazy engine)
+ * caching of addon instances. During addon initialization we cannot add a
+ * cache to each bundle host object AFTER it is instantiated because running the
+ * addon constructor ultimately causes Addon class `setupRegistry` code to
+ * run which instantiates child addons, which need the cache to already be
+ * in place for the parent bundle host.
+ * We handle this by providing a global cache that exists independent of the
+ * bundle host objects. That is this object.
+ *
+ * There are a number of "behaviors" being implemented by this object and
+ * its contents. They are:
+ * (1) Any addon that is a lazy engine has only a single real instance per
+ * project - all other references to the lazy engine are to be proxies. These
+ * lazy engines are compared by name, not by packageInfo.realPath.
+ * (2) Any addon that is not a lazy engine, there is only a single real instance
+ * of the addon per "bundle host" (i.e. lazy engine or project).
+ * (3) An optimization - any addon that is in a lazy engine but that is also
+ * in the project (outside of all lazy engines) - the single instance is the
+ * one in the project. All other instances (in any lazy engine) are proxies.
+ * NOTE: the optimization is only enabled if the environment variable that controls that
+ * ember-engine deduplication (process.env.EMBER_ENGINES_ADDON_DEDUPE) is set
+ * to a truthy value.
+ *
+ * Some implementation details given the above desired behavior:
+ * (1) There are actually 2 types of caches in this object:
+ * (1a) the first is keyed by lazy engine name. Note: the real instance of
+ * the lazy engine is created as it is encountered while traversing the addon
+ * tree (just like any real addon instance), it's just also referenced
+ * in this cache so we can create proxies to this single instance from
+ * anywhere in the addon tree.
+ * (2) The cache (one item per lazy engine + project) of real addon instances
+ * found while traversing the dependency tree.
+ *
+ * @public
+ * @class PerBundleAddonCache {
+ */
+class PerBundleAddonCache {
+  constructor(project) {
+    this.project = project;
+
+    // cache of the addon packageInfos for addons that are descendants of the project
+    // (excluding going into lazy engines). Lazy engines that refer to any of these
+    // will defer to the project-level one.
+    this.bundledProjectAddonPkgInfos = this._setupBundledProjectAddonPackageInfos();
+
+    // The cache of bundle-host instances and their individual addon caches.
+    // The cache is keyed by name (since there is only a single instance of any given
+    // engine name, even if 2 engine addons have the same engine name).
+    // To allow the project in the cache, it will use the empty string for a name,
+    // to not collide with any possible engine name.
+    // Each cache entry consists of the bundle host instance (Project or Lazy engine)
+    // and an addon instance cache to bundle with that bundle host.
+    this.bundleHostCache = this._setupBundleHostCache();
+
+    // Indicate if ember-engines deduping is supported.
+    this.engineAddonTransitiveDedupeEnabled = !!process.env.EMBER_ENGINES_ADDON_DEDUPE;
+
+    // For stats purposes, counts on the # addons and proxies created. Addons we
+    // can compare against the bundleHostCache addon caches. Proxies, not so much,
+    // but we'll count them here.
+    this.numAddonInstances = 0;
+    this.numProxies = 0;
+  }
+
+  /**
+   * Get the list of addon PackageInfo objects that are a dependency of this project,
+   * directly or transitively. Do not check any dependency that is itself a bundle host.
+   * This is used as part of the per-bundle addon-caching optimization.
+   *
+   * @private
+   * @method _setupBundledProjectAddonPackageInfos
+   * @return {Set} a Set of {PackageInfo} objects.
+   */
+  _setupBundledProjectAddonPackageInfos() {
+    let pkgInfos = new Set();
+
+    let findAddons = (pkgInfo) => {
+      if (!pkgInfo.valid || !pkgInfo.addonMainPath) {
+        return;
+      }
+
+      if (pkgInfos.has(pkgInfo)) {
+        return;
+      }
+
+      if (pkgInfo.isForBundleHost()) {
+        return;
+      }
+
+      pkgInfos.add(pkgInfo);
+
+      let addonPackageList = pkgInfo.discoverAddonAddons();
+      addonPackageList.forEach((pkgInfo) => findAddons(pkgInfo));
+    };
+
+    let addonPackageList = this.project._packageInfo.discoverProjectAddons();
+    addonPackageList.forEach((pkgInfo) => findAddons(pkgInfo));
+
+    return pkgInfos;
+  }
+
+  /**
+   * Set up the cache of bundle host instances. Each entry is keyed by name (project is given
+   * the name '__PROJECT__', the lazy engines use their engineName, since those are unique)
+   * Each cache entry has the bundle host instance (Project or lazy engine) and the addon instance
+   * cache for any addon instances to bundle with that bundle host.
+   *
+   * @private
+   * @method _setupBundleHostCache
+   * @return {Map} the bundle-host cache
+   */
+  _setupBundleHostCache() {
+    // get all the lazy engine packageInfos, but only keep the first one
+    // with any given engine name.
+    let lazyEnginePkgInfos = new Map();
+
+    let findAddons = (pkgInfo) => {
+      if (!pkgInfo.valid || !pkgInfo.addonMainPath) {
+        return;
+      }
+
+      if (pkgInfo.isForLazyEngine()) {
+        if (lazyEnginePkgInfos.has(pkgInfo.name)) {
+          return;
+        }
+
+        lazyEnginePkgInfos.set(pkgInfo.name, pkgInfo);
+      }
+
+      let addonPackageList = pkgInfo.discoverAddonAddons();
+      addonPackageList.forEach(findAddons);
+    };
+
+    let addonPackageList = this.project._packageInfo.discoverProjectAddons();
+    addonPackageList.forEach(findAddons);
+
+    // Create the cache
+    let cache = new Map();
+    cache.set(
+      PROJECT_BUNDLE_HOST_NAME,
+      this.createBundleHostCacheEntry(this.project, this.project._packageInfo.realPath)
+    );
+
+    lazyEnginePkgInfos.forEach((pkgInfo) => {
+      cache.set(pkgInfo.name, this.createBundleHostCacheEntry(null, pkgInfo.realPath));
+    });
+
+    return cache;
+  }
+
+  /**
+   * Create a cache entry the bundleHostCache. Because we want to use the same sort of proxy
+   * for both bundle hosts and for 'regular' addon instances (though their cache entries have
+   * slightly different structures) we'll use the Symbol from getAddonProxy.
+   *
+   * @method createBundleHostCacheEntry
+   * @param {Project|LazyEngine} bundleHostInstance the instance of the Project or lazy engine
+   * @param {String} bundleHostRealPath bundle host's pkgInfo.realPath
+   * @return {Object} an object in the form of a bundle-host cache entry
+   */
+  createBundleHostCacheEntry(bundleHostInstance, bundleHostRealPath) {
+    return { [TARGET_INSTANCE]: bundleHostInstance, realPath: bundleHostRealPath, addonInstanceCache: new Map() };
+  }
+
+  /**
+   * Create a cache entry object for a given (non-bundle-host) addon to put into
+   * an addon cache.
+   *
+   * @method createAddonCacheEntry
+   * @param {Addon} addonInstance the addon instance to cache
+   * @param {String} addonRealPath the addon's pkgInfo.realPath
+   * @return {Object} an object in the form of an addon-cache entry
+   */
+  createAddonCacheEntry(addonInstance, addonRealPath) {
+    return { [TARGET_INSTANCE]: addonInstance, realPath: addonRealPath };
+  }
+
+  /**
+   * Given a parent object of a potential addon (another addon or the project),
+   * go up the 'parent' chain to find the potential addon's bundle host object
+   * (i.e. lazy engine or project.) Because Projects are always bundle hosts,
+   * this should always pass, but we'll throw if somehow it doesn't work.
+   *
+   * @method findBundleHost
+   * @param {Project|Addon} addonParent the direct parent object of a (potential or real) addon.
+   * @param {PackageInfo} addonPkgInfo the PackageInfo for an addon being instantiated. This is only
+   * used for information if an error is going to be thrown.
+   * @return {Object} the object in the 'parent' chain that is a bundle host.
+   * @throws {Error} if there is not bundle host
+   */
+  findBundleHost(addonParent, addonPkgInfo) {
+    let curr = addonParent;
+    while (curr) {
+      if (curr === this.project || isLazyEngine(curr)) {
+        return curr;
+      }
+
+      curr = curr.parent;
+    }
+
+    // the following should not be able to happen given that Projects are always
+    // bundle hosts, but just in case, throw an error if we didn't find one.
+    throw new Error(`Addon at path\n  ${addonPkgInfo.realPath}\n has 'allowCachingPerBundle' but has no bundleHost`);
+  }
+
+  /**
+   * An optimization we support from lazy engines is the following:
+   * if an addon instance is supposed to be bundled with a particular lazy engine, and
+   * same addon is also to be bundled in the project, prefer the one in the project.
+   *
+   * NOTE: this only applies if this.engineAddonTransitiveDedupeEnabled is truthy. If it is not, the
+   * bundle host always "owns" the addon instance.
+   *
+   * If deduping is enabled and the project does also depend on the same addon,
+   * the lazy-engine instances of the addon will all be proxies to the one in
+   * the project. This function indicates whether the bundle host passed in (either the
+   * project or a lazy engine) is really the bundle host to "own" the new addon.
+   *
+   * @method bundleHostOwnsInstance
+   * @param (Object} bundleHost the project or lazy engine that is trying to "own"
+   * the new addon instance specified by addonPkgInfo
+   * @param {PackageInfo} addonPkgInfo the PackageInfo of the potential new addon instance
+   * @return {Boolean} true if the bundle host is to "own" the instance, false otherwise.
+   */
+  bundleHostOwnsInstance(bundleHost, addonPkgInfo) {
+    return (
+      bundleHost === this.project ||
+      !this.engineAddonTransitiveDedupeEnabled ||
+      !this.bundledProjectAddonPkgInfos.has(addonPkgInfo)
+    );
+  }
+
+  /**
+   * Get the name of a bundle host (used as the key for the bundleHostCache).
+   * Projects have a fake name.
+   *
+   * @method getBundleHostName
+   * @param {Project|Addon} bundleHost the bundle host whose name is desired.
+   * @return {String} the name of the bundle host
+   */
+  getBundleHostName(bundleHost) {
+    return bundleHost === this.project ? PROJECT_BUNDLE_HOST_NAME : bundleHost.name;
+  }
+
+  /**
+   * Called from PackageInfo.getAddonInstance(), return an instance of the requested
+   * addon or a Proxy, based on the type of addon and its bundle host.
+   *
+   * @method getAddonInstance
+   * @param {Addon|Project} parent the parent Addon or Project this addon instance is
+   * a child of.
+   * @param {*} addonPkgInfo the PackageInfo for the addon being created.
+   * @return {Addon|Proxy} An addon instance (for the first copy of the addon) or a Proxy.
+   * An addon that is a lazy engine will only ever have a single copy in the cache.
+   * An addon that is not will have 1 copy per bundle host (Project or lazy engine),
+   * except if it is an addon that's also owned by the project and
+   * is truthy, in which case it will only have a single copy in the project's addon cache.
+   */
+  getAddonInstance(parent, addonPkgInfo) {
+    // If the new addon is itself a bundle host (i.e. lazy engine), there is only one
+    // instance of the bundle host, and it's in the entries of the bundleHostCache, outside
+    // of the 'regular' addon caches. Because 'setupBundleHostCache' ran during construction,
+    // we know that an entry is in the cache with this engine name.
+    if (addonPkgInfo.isForBundleHost()) {
+      let cacheEntry = this.bundleHostCache.get(addonPkgInfo.name);
+      if (cacheEntry[TARGET_INSTANCE]) {
+        logger.debug(`About to construct BR PROXY to cache entry for addon at: ${addonPkgInfo.realPath}`);
+        this.numProxies++;
+        return getAddonProxy(cacheEntry, parent);
+      } else {
+        // create an instance, put it in the pre-existing cache entry, then
+        // return it (as the first instance of the lazy engine.)
+        logger.debug(`About to fill in BR EXISTING cache entry for addon at: ${addonPkgInfo.realPath}`);
+        this.numAddonInstances++;
+        let addon = addonPkgInfo.constructAddonInstance(parent, this.project);
+        cacheEntry[TARGET_INSTANCE] = addon; // cache BEFORE initializing child addons
+        addonPkgInfo.initChildAddons(addon);
+        return addon;
+      }
+    }
+
+    // We know now we're asking for a 'regular' (non-bundle-host) addon instance.
+
+    let bundleHost = this.findBundleHost(parent, addonPkgInfo);
+
+    // if the bundle host "owns" the new addon instance
+    //   * Do we already have an instance of the addon cached?
+    //     * If so, make a proxy for it.
+    //     * If not, make a new instance of the addon and cache it in the
+    //       bundle host's addon cache.
+    // If not, it means the bundle host is a lazy engine but the project also uses
+    // the addon and deduping is enabled
+    //   * If the project already has a cached entry, return a proxy to that
+    //   * If it does not, create a 'blank' cache entry and return a proxy to that.
+    //     When the addon is encountered later when processing the project's addons,
+    //     fill in the instance.
+    if (this.bundleHostOwnsInstance(bundleHost, addonPkgInfo)) {
+      let bundleHostName = this.getBundleHostName(bundleHost);
+      let bundleHostCacheEntry = this.bundleHostCache.get(bundleHostName);
+      let addonInstanceCache = bundleHostCacheEntry.addonInstanceCache;
+      let addonCacheEntry = addonInstanceCache.get(addonPkgInfo.realPath);
+      let addonInstance;
+
+      if (addonCacheEntry) {
+        if (addonCacheEntry[TARGET_INSTANCE]) {
+          logger.debug(`About to construct REGULAR ADDON PROXY for addon at: ${addonPkgInfo.realPath}`);
+          this.numProxies++;
+          return getAddonProxy(addonCacheEntry, parent);
+        } else {
+          // the cache entry was created 'empty' by an earlier call, indicating
+          // an addon that is used in a lazy engine but also used in the project,
+          // and we're now creating the instance for the project.
+          // Fill in the entry and return the new instance.
+          logger.debug(`About to fill in REGULAR ADDON EXISTING cache entry for addon at: ${addonPkgInfo.realPath}`);
+          this.numAddonInstances++;
+          addonInstance = addonPkgInfo.constructAddonInstance(parent, this.project);
+          addonCacheEntry[TARGET_INSTANCE] = addonInstance; // cache BEFORE initializing child addons
+          addonPkgInfo.initChildAddons(addonInstance);
+          return addonInstance;
+        }
+      }
+
+      // There is no entry for this addon in the bundleHost's addon cache. Create a new
+      // instance, cache it in the addon cache, and return it.
+      logger.debug(`About to construct REGULAR ADDON NEW cache entry for addon at: ${addonPkgInfo.realPath}`);
+      this.numAddonInstances++;
+      addonInstance = addonPkgInfo.constructAddonInstance(parent, this.project);
+      addonCacheEntry = this.createAddonCacheEntry(addonInstance, addonPkgInfo.realPath);
+      addonInstanceCache.set(addonPkgInfo.realPath, addonCacheEntry); // cache BEFORE initializing child addons
+      addonPkgInfo.initChildAddons(addonInstance);
+      return addonInstance;
+    } else {
+      // The bundleHost is not the project but the project bundles the addon too and
+      // deduping is enabled, so the cache entry needs to go in the project's cache.
+      // Get/create an empty cache entry and return a proxy to it. The project will
+      // set the instance later (see above).
+      let bundleHostCacheEntry = this.bundleHostCache.get(PROJECT_BUNDLE_HOST_NAME);
+      let addonCacheEntry = bundleHostCacheEntry.addonInstanceCache.get(addonPkgInfo.realPath);
+      if (!addonCacheEntry) {
+        logger.debug(`About to construct REGULAR ADDON EMPTY cache entry for addon at: ${addonPkgInfo.realPath}`);
+        addonCacheEntry = this.createAddonCacheEntry(null, addonPkgInfo.realPath);
+        bundleHostCacheEntry.addonInstanceCache.set(addonPkgInfo.realPath, addonCacheEntry);
+      }
+
+      logger.debug(`About to construct REGULAR ADDON PROXY for EMPTY addon at: ${addonPkgInfo.realPath}`);
+      this.numProxies++;
+      return getAddonProxy(addonCacheEntry, parent);
+    }
+  }
+}
+
+module.exports = PerBundleAddonCache;
+
+// Support for per-bundle addon caching is GLOBAL opt OUT (unless you explicitly set
+// EMBER_CLI_ADDON_INSTANCE_CACHING to false, it will be enabled.) If you opt out, that
+// overrides setting `allowCachingPerBundle` for any particular addon type to true.
+// To help make testing easier, we'll expose the setting as a function so it can be
+// called multiple times and evaluate each time.
+module.exports.isEnabled = function () {
+  return process.env.EMBER_CLI_ADDON_INSTANCE_CACHING !== 'false';
+};
diff --git a/lib/models/per-bundle-addon-cache/target-instance.js b/lib/models/per-bundle-addon-cache/target-instance.js
new file mode 100644
index 000000000..ed3f23eb5
--- /dev/null
+++ b/lib/models/per-bundle-addon-cache/target-instance.js
@@ -0,0 +1,14 @@
+'use strict';
+
+/**
+ * A Symbol constant for sharing between index.js and addon-proxy.js rather than
+ * putting the symbol into the Symbol global cache. The symbol is used in per-bundle
+ * cache entries to refer to the field that points at the real instance that a Proxy
+ * refers to.
+ * @property
+ * @type Symbol
+ * @final
+ */
+const TARGET_INSTANCE = Symbol('_targetInstance_');
+
+module.exports.TARGET_INSTANCE = TARGET_INSTANCE;
diff --git a/lib/models/project.js b/lib/models/project.js
index 6ccde5cc0..f613d3982 100644
--- a/lib/models/project.js
+++ b/lib/models/project.js
@@ -14,9 +14,10 @@ const versionUtils = require('../utilities/version-utils');
 const emberCLIVersion = versionUtils.emberCLIVersion;
 const findAddonByName = require('../utilities/find-addon-by-name');
 const heimdall = require('heimdalljs');
-const PackageInfoCache = require('../models/package-info-cache');
+const PackageInfoCache = require('./package-info-cache');
+const PerBundleAddonCache = require('./per-bundle-addon-cache');
 
-const instantiateAddons = require('../models/instantiate-addons');
+const instantiateAddons = require('./instantiate-addons');
 
 let processCwd = process.cwd();
 
@@ -96,6 +97,10 @@ class Project {
     if (this.packageInfoCache.hasErrors()) {
       this.packageInfoCache.showErrors();
     }
+
+    if (PerBundleAddonCache.isEnabled()) {
+      this.perBundleAddonCache = new PerBundleAddonCache(this);
+    }
   }
 
   /**
@@ -203,7 +208,7 @@ class Project {
     @return {Boolean} Whether or not this is an Ember CLI Addon.
    */
   isEmberCLIAddon() {
-    return !!this.pkg.keywords && this.pkg.keywords.indexOf('ember-addon') > -1;
+    return !!this.pkg && !!this.pkg.keywords && this.pkg.keywords.indexOf('ember-addon') > -1;
   }
 
   /**
@@ -429,13 +434,11 @@ class Project {
       return;
     }
 
-    let pkgInfo = this.packageInfoCache.getEntry(this.root);
-
-    let addonPackageList = pkgInfo.discoverProjectAddons();
-    this.addonPackages = pkgInfo.generateAddonPackages(addonPackageList);
+    let addonPackageList = this._packageInfo.discoverProjectAddons();
+    this.addonPackages = this._packageInfo.generateAddonPackages(addonPackageList);
 
     // in case any child addons are invalid, dump to the console about them.
-    pkgInfo.dumpInvalidAddonPackages(addonPackageList);
+    this._packageInfo.dumpInvalidAddonPackages(addonPackageList);
   }
 
   /**
@@ -575,7 +578,8 @@ class Project {
   }
 
   /**
-    Reloads package.json
+    Reloads package.json of the project. Clears and reloads the packageInfo and
+    per-bundle addon cache, too.
 
     @private
     @method reloadPkg
@@ -589,6 +593,10 @@ class Project {
 
     this.packageInfoCache.reloadProjects();
 
+    if (PerBundleAddonCache.isEnabled()) {
+      this.perBundleAddonCache = new PerBundleAddonCache(this);
+    }
+
     return this.pkg;
   }
 
diff --git a/lib/utilities/is-engine.js b/lib/utilities/is-engine.js
new file mode 100644
index 000000000..352f7ee5e
--- /dev/null
+++ b/lib/utilities/is-engine.js
@@ -0,0 +1,5 @@
+'use strict';
+
+module.exports = function isEngine(keywords) {
+  return !!(Array.isArray(keywords) && keywords.indexOf('ember-engine') >= 0);
+};
diff --git a/lib/utilities/is-lazy-engine.js b/lib/utilities/is-lazy-engine.js
new file mode 100644
index 000000000..4b2be5e0a
--- /dev/null
+++ b/lib/utilities/is-lazy-engine.js
@@ -0,0 +1,31 @@
+'use strict';
+
+/**
+ * Indicate if a given object is a constructor function or class or an instance of an Addon.
+ *
+ * @module is-lazy-engine
+ * @param {Object} addonCtorOrInstance the constructor function/class or an instance of an Addon.
+ * @return {Boolean} True if the addonCtorOrInstance is a lazy engine, False otherwise.
+ */
+module.exports = function isLazyEngine(addonCtorOrInstance) {
+  if (!addonCtorOrInstance) {
+    return false;
+  }
+
+  if (addonCtorOrInstance.lazyLoading) {
+    return addonCtorOrInstance.lazyLoading.enabled === true;
+  } else if (addonCtorOrInstance.options) {
+    return !!(addonCtorOrInstance.options.lazyLoading && addonCtorOrInstance.options.lazyLoading.enabled === true);
+  } else if (addonCtorOrInstance.prototype) {
+    if (addonCtorOrInstance.prototype.lazyLoading) {
+      return addonCtorOrInstance.prototype.lazyLoading.enabled === true;
+    } else if (addonCtorOrInstance.prototype.options) {
+      return !!(
+        addonCtorOrInstance.prototype.options.lazyLoading &&
+        addonCtorOrInstance.prototype.options.lazyLoading.enabled === true
+      );
+    }
+  }
+
+  return false;
+};


Test patch:
diff --git a/tests/helpers/fixturify-project.js b/tests/helpers/fixturify-project.js
index e072f34b6..6ff426e91 100644
--- a/tests/helpers/fixturify-project.js
+++ b/tests/helpers/fixturify-project.js
@@ -14,10 +14,44 @@ class ProjectWithoutInternalAddons extends Project {
   }
 }
 
-function prepareAddon(addon) {
+function prepareAddon(addon, options) {
   addon.pkg.keywords.push('ember-addon');
   addon.pkg['ember-addon'] = {};
-  addon.files['index.js'] = 'module.exports = { name: require("./package").name };';
+  addon.files['index.js'] = `module.exports = {
+    name: require("./package").name,
+    allowCachingPerBundle: ${Boolean(options.allowCachingPerBundle)},
+    ${options.additionalContent || ''}
+  };`;
+}
+
+/**
+ * Gets a normalized object with provided defaults. If the 2nd argument is a function,
+ * we add this to the returned object with `callback` as its key.
+ *
+ * @name getOptionsObjectWithCallbackFunction
+ * @param {Object} defaultOptions The default options
+ * @param {Object|Function} optionsOrCallback The options object or callback function
+ * @returns {Object} The normalized options object
+ */
+function getOptionsObjectWithCallbackFunction(defaultOptions, optionsOrCallback) {
+  return Object.assign(
+    {},
+    defaultOptions,
+    typeof optionsOrCallback === 'function' ? { callback: optionsOrCallback } : optionsOrCallback
+  );
+}
+
+// Essentially a copy of the function in node-fixturify-project, converted from TS to JS.
+// We need this for use during toJSON().
+function parseScoped(name) {
+  let matched = name.match(/(@[^@/]+)\/(.*)/);
+  if (matched) {
+    return {
+      scope: matched[1],
+      name: matched[2],
+    };
+  }
+  return null;
 }
 
 module.exports = class EmberCLIFixturifyProject extends FixturifyProject {
@@ -25,9 +59,11 @@ module.exports = class EmberCLIFixturifyProject extends FixturifyProject {
     super.writeSync(...arguments);
     this._hasWritten = true;
   }
+
   addFiles(filesObj) {
     merge(this.files, filesObj);
   }
+
   buildProjectModel(ProjectClass = ProjectWithoutInternalAddons) {
     if (!this._hasWritten) {
       this.writeSync();
@@ -40,40 +76,208 @@ module.exports = class EmberCLIFixturifyProject extends FixturifyProject {
     return new ProjectClass(root, pkg, cli.ui, cli);
   }
 
-  addAddon(name, version = '0.0.0', cb) {
-    return this.addDependency(name, version, (addon) => {
-      prepareAddon(addon);
+  /**
+   * Add an entry for this object's `dependencies` list. When this object is written out, the
+   * dependency will also then write out appropriate files in this object's `node_modules' subdirectory.
+   *
+   * @param {String} name name of the dependency to add
+   * @param {String} version version of the dependency to add
+   * @param {Object|Function} optionsOrCallback options to configure the new FixturifyProject, or a callback function to call after creating
+   * the dependency's FixturifyProject. If the parameter is a function, it will be assumed to be a callback function. If instead
+   * the parameter is an object, a callback function can be provided using the property 'callback' in the object.
+   * @returns the new  FixturifyProject
+   */
+  addDependency(name, version, optionsOrCallback) {
+    const options = getOptionsObjectWithCallbackFunction(optionsOrCallback);
+    return super.addDependency(name, version, options.callback);
+  }
 
-      if (typeof cb === 'function') {
-        cb(addon);
-      }
+  /**
+   * Add a 'reference' entry to this object's `dependencies` list. A 'reference' dependency is
+   * an entry in `dependencies` where the caller knows the dependency's source files are being
+   * created elsewhere in the project tree, so no source files should be created locally in
+   * `node_modules`, which is the standard FixturifyProject (and node-fixturify-project) behavior.
+   * We do this by adding the necessary reference to `dependencies` during `toJSON`.
+   *
+   * This is used when two addons wish to share a single definition on disk for a dependency (various parts of
+   * ember-cli optimize processing based on paths on disk.)
+   *
+   * Because there is no FixturifyProject being created, no callback is given as in other methods.
+   *
+   * @param {String} name name of the dependency
+   * @param {String} version version of the dependency, defaults to '*'. For our purposes, '*' means
+   * "whatever version was specified elsewhere."
+   */
+  addReferenceDependency(name, version = '*') {
+    if (!this._referenceDependencies) {
+      this._referenceDependencies = {};
+    }
+
+    this._referenceDependencies[name] = version;
+  }
+
+  /**
+   * Add an entry to this object's `devDependencies` list. When this object is written out, the
+   * dependency will also then write out appropriate files in this object's `node_modules' subdirectory.
+   *
+   * @param {String} name name of the dev dependency to add
+   * @param {String} version version of the dev dependency to add
+   * @param {Object|Function} optionsOrCallback options to configure the new FixturifyProject, or a callback function to call after creating
+   * the dependency's FixturifyProject. If the parameter is a function, it will be assumed to be a callback function. If instead
+   * the parameter is an object, a callback function can be provided using the property 'callback' in the object.
+   * @returns the new  FixturifyProject
+   */
+  addDevDependency(name, version, optionsOrCallback) {
+    const options = getOptionsObjectWithCallbackFunction(optionsOrCallback);
+    return super.addDevDependency(name, version, options.callback);
+  }
+
+  /**
+   * Add a 'reference' entry to this object's `devDependencies` list. A 'reference' devDependency is
+   * an entry in `devDependencies` where the caller knows the dependency's source files are being
+   * created elsewhere in the project tree, so no source files should be created locally in
+   * `node_modules`, which is the standard FixturifyProject (and node-fixturify-project) behavior.
+   * We do this by adding the necessary reference to `devDependencies` during `toJSON`.
+   *
+   * This is used when two addons wish to share a single definition on disk for a devDependency
+   * (various parts of ember-cli optimize processing based on paths on disk.)
+   *
+   * Because there is no FixturifyProject being created, no callback is given as in other methods.
+   *
+   * @param {String} name name of the devDependency
+   * @param {String} version version of the devDependency, defaults to '*'. For our purposes, '*' means
+   * "whatever version was specified elsewhere."
+   */
+  addReferenceDevDependency(name, version = '*') {
+    if (!this._referenceDevDependencies) {
+      this._referenceDevDependencies = {};
+    }
+
+    this._referenceDevDependencies[name] = version;
+  }
+
+  /**
+   * Add an addon to this object's `dependencies` list. The addon files will be written in
+   * this object's `node_modules/<addon_name>` directory when this object is written out.
+   *
+   * @param {String} name name of the addon
+   * @param {String} version version of the addon, defaults to '0.0.0'
+   * @param {Object|Function} optionsOrCallback an object consisting of properties and values to apply when creating
+   * the addon, or a callback function to pass the newly-created FixturifyProject to. Important options
+   * include 'allowCachingPerBundle' (true if the addon can be proxied, defaults to false) and 'callback' (if you want to include
+   * a callback function while also specifying other properties.)
+   * @returns {FixturifyProject} the newly-created addon
+   */
+  addAddon(name, version = '0.0.0', optionsOrCallback) {
+    const options = getOptionsObjectWithCallbackFunction({ allowCachingPerBundle: false }, optionsOrCallback);
+
+    return this.addDependency(name, version, {
+      ...options,
+      callback: (addon) => {
+        prepareAddon(addon, options);
+
+        // call original `options.callback` if it exists
+        if (typeof options.callback === 'function') {
+          options.callback(addon);
+        }
+      },
     });
   }
 
-  addDevAddon(name, version = '0.0.0', cb) {
-    return this.addDevDependency(name, version, (addon) => {
-      prepareAddon(addon);
-      if (typeof cb === 'function') {
-        cb(addon);
-      }
+  /**
+   * Add an addon to this object's `devDependencies` list. The addon files will be written in
+   * this object's `node_modules/<addon_name>` directory when this object is written out.
+   *
+   * @param {String} name name of the addon
+   * @param {String} version version of the addon, defaults to '0.0.0'
+   * @param {Object|Function} optionsOrCallback an object consisting of properties and values to apply when creating
+   * the addon, or a callback function to pass the newly-created FixturifyProject to. Important options
+   * include 'allowCachingPerBundle' (true if the addon can be proxied, defaults to false) and 'callback' (if you want to include
+   * a callback function while also specifying other properties.)
+   * @returns {FixturifyProject} the newly-created addon
+   */
+  addDevAddon(name, version = '0.0.0', optionsOrCallback) {
+    const options = getOptionsObjectWithCallbackFunction({ allowCachingPerBundle: false }, optionsOrCallback);
+
+    return this.addDevDependency(name, version, {
+      ...options,
+      callback: (addon) => {
+        prepareAddon(addon, options);
+
+        // call original `options.callback` if it exists
+        if (typeof options.callback === 'function') {
+          options.callback(addon);
+        }
+      },
     });
   }
 
-  addInRepoAddon(name, version = '0.0.0', cb) {
-    const inRepoAddon = new FixturifyProject(name, version, (project) => {
-      project.pkg.keywords.push('ember-addon');
-      project.pkg['ember-addon'] = {};
-      project.files['index.js'] = 'module.exports = { name: require("./package").name };';
+  /**
+   * Add an addon to this object's `dependencies` list. The engine's addon files will be written in
+   * this object's `node_modules/<addon_name>` directory when this object is written out.
+   *
+   * @param {String} name name of the engine
+   * @param {String} version version of the engine, defaults to '0.0.0'
+   * @param {Object|Function} optionsOrCallback an object consisting of properties and values to apply when creating
+   * the engine, or a callback function to pass the newly-created FixturifyProject to. Important options
+   * include 'allowCachingPerBundle' (true if the engine can be proxied, defaults to false), 'enableLazyLoading' (true
+   * if the engine is to be lazily loaded, defaults to false) and 'callback' (if you want to include
+   * a callback function while also specifying other properties.)
+   * @returns {FixturifyProject} the newly-created engine addon
+   */
+  addEngine(name, version = '0.0.0', options = { allowCachingPerBundle: false, enableLazyLoading: false }) {
+    const callback = (engine) => {
+      engine.pkg.keywords.push('ember-engine');
 
-      if (typeof cb === 'function') {
-        cb(project);
+      // call original callback if it exists
+      if (typeof options.callback === 'function') {
+        options.callback(engine);
+      }
+    };
+
+    if (options.enableLazyLoading) {
+      return this.addAddon(name, version, {
+        ...options,
+        additionalContent: 'lazyLoading: { enabled: true },',
+        callback,
+      });
+    }
+
+    return this.addAddon(name, version, { ...options, callback });
+  }
+
+  /**
+   * Add an in-repo addon to this object. The addon files will be written in
+   * this object's `lib/<addon_name>` directory when this object is written out.
+   *
+   * @param {String} name name of the addon
+   * @param {String} version version of the addon, defaults to '0.0.0'
+   * @param {Object|Function} optionsOrCallback an object consisting of properties and values to apply when creating
+   * the addon, or a callback function to pass the newly-created FixturifyProject to. Important options
+   * include 'allowCachingPerBundle' (true if the addon can be proxied, defaults to false) and 'callback' (if you want to include
+   * a callback function while also specifying other properties.)
+   * @returns {FixturifyProject} the newly-created addon
+   */
+  addInRepoAddon(name, version = '0.0.0', optionsOrCallback) {
+    const options = getOptionsObjectWithCallbackFunction({ allowCachingPerBundle: false }, optionsOrCallback);
+
+    const inRepoAddon = new EmberCLIFixturifyProject(name, version, (addon) => {
+      prepareAddon(addon, options);
+
+      if (typeof options.callback === 'function') {
+        options.callback(addon);
       }
     });
 
-    // configure the current project to have an ember-addon configured at the appropriate path
+    // configure the current project to have an ember-addon configured at the
+    // appropriate path, i.e. under a common root directory (lib).
+    const addonRootDir = 'lib';
+
+    // Add to ember-addon.paths list
     let addon = (this.pkg['ember-addon'] = this.pkg['ember-addon'] || {});
     addon.paths = addon.paths || [];
-    const addonPath = `lib/${name}`;
+
+    const addonPath = `${addonRootDir}/${name}`;
 
     if (addon.paths.find((path) => path.toLowerCase() === addonPath.toLowerCase())) {
       throw new Error(`project: ${this.name} already contains the in-repo-addon: ${name}`);
@@ -81,9 +285,95 @@ module.exports = class EmberCLIFixturifyProject extends FixturifyProject {
 
     addon.paths.push(addonPath);
 
-    this.files.lib = this.files.lib || {};
+    this.files[addonRootDir] = this.files[addonRootDir] || {};
+
+    let addonJSON = inRepoAddon.toJSON();
+    Object.assign(this.files[addonRootDir], addonJSON);
+  }
+
+  /**
+   * Add an in-repo engine to this object. The engine files will be written in
+   * this object's `lib/<engine-name>` directory when this object is written out.
+   *
+   * @param {String} name name of the engine
+   * @param {String} version version of the engine, defaults to '0.0.0'
+   * @param {Object|Function} optionsOrCallback an object consisting of properties and values to apply when creating
+   * the engine, or a callback function to pass the newly-created FixturifyProject to. Important options
+   * include 'allowCachingPerBundle' (true if the addon can be proxied, defaults to false), 'enableLazyLoading' (true
+   * if the engine is to be lazily loaded, defaults to false)  and 'callback' (if you want to include
+   * a callback function while also specifying other properties.)
+   * @returns {FixturifyProject} the newly-created addon
+   */
+  addInRepoEngine(name, version = '0.0.0', options = { allowCachingPerBundle: false, enableLazyLoading: false }) {
+    const callback = (engine) => {
+      engine.pkg.keywords.push('ember-engine');
+
+      // call original callback if it exists
+      if (typeof options.callback === 'function') {
+        options.callback(engine);
+      }
+    };
+
+    if (options.enableLazyLoading) {
+      return this.addInRepoAddon(name, version, {
+        ...options,
+        additionalContent: 'lazyLoading: { enabled: true },',
+        callback,
+      });
+    }
+
+    return this.addInRepoAddon(name, version, { ...options, callback });
+  }
+
+  /**
+   * Convert the object into a data structure suitable for passing to `fixturify`.
+   *
+   * @param {String} key optional key. If specified, the object will be run through toJSON, then the given
+   * property extracted and returned.
+   * @returns {Object} the `toJSON` value of the object (wrapped) or the toJSON value of the specified field
+   * (not wrapped.)
+   */
+  toJSON(key) {
+    if (key) {
+      return super.toJSON(key);
+    }
+
+    let jsonData = super.toJSON();
+
+    let scoped = parseScoped(this.name);
+
+    // Allowing for scoped names, get the object in the JSON structure that corresponds
+    // to this FixturifyProject.
+    let container = scoped ? jsonData[scoped.scope][scoped.name] : jsonData[this.name];
+
+    if (this._referenceDependencies || this._referenceDevDependencies) {
+      let pkg = JSON.parse(container['package.json']);
+
+      if (this._referenceDependencies) {
+        if (!pkg.dependencies) {
+          pkg.dependencies = {};
+        }
+
+        Object.assign(pkg.dependencies, this._referenceDependencies);
+      }
+
+      if (this._referenceDevDependencies) {
+        if (!pkg.devDependencies) {
+          pkg.devDependencies = {};
+        }
+
+        Object.assign(pkg.devDependencies, this._referenceDevDependencies);
+      }
+
+      container['package.json'] = JSON.stringify(pkg);
+    }
+
+    // an optimization to remove any node_modules declaration that has nothing in it,
+    // to avoid creating extra directories for no reason.
+    if (container['node_modules'] && Object.keys(container['node_modules']).length === 0) {
+      delete container['node_modules'];
+    }
 
-    // insert inRepoAddon into files
-    Object.assign(this.files.lib, inRepoAddon.toJSON());
+    return jsonData;
   }
 };
diff --git a/tests/helpers/per-bundle-addon-cache.js b/tests/helpers/per-bundle-addon-cache.js
new file mode 100644
index 000000000..909c5e95c
--- /dev/null
+++ b/tests/helpers/per-bundle-addon-cache.js
@@ -0,0 +1,192 @@
+'use strict';
+
+const FixturifyProject = require('./fixturify-project');
+const { TARGET_INSTANCE } = require('../../lib/models/per-bundle-addon-cache/target-instance');
+const isLazyEngine = require('../../lib/utilities/is-lazy-engine');
+
+/**
+ * This collects all addons by name within a given host; it stops traversing when it
+ * encounters another host (i.e., a lazy engine). Within a given host we should expect
+ * at most 1 real addon, otherwise this is an error condition. We otherwise add all
+ * proxies to `config.proxies`
+ *
+ * @name getAllAddonsByNameWithinHost
+ * @param {Project|Addon} projectOrAddon
+ * @param {string} addonName
+ * @param {Object} [config]
+ * @returns {{proxies: Proxy[], realAddon: Addon}}
+ */
+function getAllAddonsByNameWithinHost(projectOrAddon, addonName, config = { proxies: [] }) {
+  if (!config.originalHost) {
+    config.originalHost = projectOrAddon;
+  }
+
+  projectOrAddon.addons.forEach((addon) => {
+    if (addon.name === addonName) {
+      if (config.realAddon && !addon[TARGET_INSTANCE]) {
+        throw new Error(
+          `The real addon (\`${addon.name}\`) has already been set for a given host (\`${
+            typeof config.originalHost.name === 'function' ? config.originalHost.name() : config.originalHost.name
+          }\`); the proxy for addon caching is not working correctly`
+        );
+      }
+
+      if (addon[TARGET_INSTANCE]) {
+        config.proxies.push(addon);
+      } else {
+        config.realAddon = addon;
+      }
+    }
+
+    // stop traversing within another host
+    if (!isLazyEngine(addon)) {
+      getAllAddonsByNameWithinHost(addon, addonName, config);
+    }
+  });
+
+  return config;
+}
+
+/**
+ * Returns whether all instances within a given host are equal (i.e., that there's a single
+ * "real addon") and all proxies have the `TARGET_INSTANCE` property that's strictly equal to
+ * the aforementioned real addon
+ *
+ * @name areAllInstancesEqualWithinHost
+ * @param {Project|Addon} projectOrAddon
+ * @param {string} addonName
+ * @returns {boolean}
+ */
+function areAllInstancesEqualWithinHost(projectOrAddon, addonName) {
+  const { realAddon, proxies } = getAllAddonsByNameWithinHost(projectOrAddon, addonName);
+  return proxies.length > 0 && proxies.every((proxy) => proxy[TARGET_INSTANCE] === realAddon);
+}
+
+/**
+ * For a given project/addon, this counts addon instances within said project/addon;
+ * specifically we're interested in the number of "real" addon instances, and proxy
+ * objects.
+ *
+ * @name countAddons
+ * @param {Project|Addon} projectOrAddon
+ * @param {Object} [config]
+ * @returns {{byName: Object, proxyCount: number, realAddonInstanceCount: number}}
+ */
+function countAddons(projectOrAddon, config = { byName: {}, proxyCount: 0, realAddonInstanceCount: 0 }) {
+  projectOrAddon.addons.forEach((addon) => {
+    const addonName = addon.name;
+
+    if (!config.byName[addonName]) {
+      config.byName[addonName] = {
+        addons: [],
+        proxyCount: 0,
+        realAddonInstanceCount: 0,
+      };
+    }
+
+    if (addon[TARGET_INSTANCE]) {
+      config.proxyCount++;
+      config.byName[addonName].proxyCount++;
+    } else {
+      config.realAddonInstanceCount++;
+      config.byName[addonName].realAddonInstanceCount++;
+    }
+
+    config.byName[addonName].addons.push(addon);
+    countAddons(addon, config);
+  });
+
+  return config;
+}
+
+/**
+ * Generate the file structure used for the cache-bundle-hosts and enable-cache tests.
+ * Puts it into the usual temporary location defined by ECFP.
+ *
+ * In this fixture, all the addon definitions are to be held in PROJECT/lib, even
+ * though the project itself doesn't directly depend on a few of them. This is so
+ * it's easier to create a single reference to a particular addon path, to enable
+ * the proxy code to function.
+ *
+ * @name createStandardCacheFixture
+ */
+function createStandardCacheFixture() {
+  let project = new FixturifyProject('test-ember-project', '1.0.0');
+
+  project.addInRepoAddon('test-addon-a', '1.0.0', {
+    callback: (addonA) => {
+      addonA.addInRepoAddon('test-addon-dep', '1.0.0');
+
+      // At this point, TAD has been run through toJSON inside of TAA.
+      // TAD itself has no issues.
+      // in TAA, we want to store all the inrepo addons, at any level, in
+      // PROJ/lib, so move TAD from TAA and change its path in TAA.
+      addonA.pkg['ember-addon'].paths = ['../test-addon-dep'];
+      project.files.lib = project.files.lib || {};
+      project.files.lib['test-addon-dep'] = addonA.files.lib['test-addon-dep'];
+      delete addonA.files.lib;
+    },
+  });
+
+  project.addInRepoEngine('lazy-engine-a', '1.0.0', {
+    enableLazyLoading: true,
+    callback: (lazyEngineA) => {
+      lazyEngineA.addInRepoAddon('test-engine-dep', '1.0.0');
+
+      // Similar to above
+      lazyEngineA.pkg['ember-addon'].paths = ['../test-engine-dep'];
+      project.files.lib['test-engine-dep'] = lazyEngineA.files.lib['test-engine-dep'];
+      delete lazyEngineA.files.lib;
+    },
+  });
+
+  project.addInRepoEngine('lazy-engine-b', '1.0.0', {
+    enableLazyLoading: true,
+    callback: (lazyEngineB) => {
+      // These two addon definitions have already been moved to project, so just
+      // fix the ember-addon.paths and remove the files.lib entry.
+      lazyEngineB.pkg['ember-addon'].paths = ['../test-engine-dep', '../test-addon-dep'];
+      delete lazyEngineB.files.lib;
+    },
+  });
+
+  project.addInRepoEngine('regular-engine-c', '1.0.0', {
+    callback: (regularEngineC) => {
+      regularEngineC.pkg['ember-addon'].paths = ['../test-engine-dep'];
+      delete regularEngineC.files.lib;
+    },
+  });
+
+  return project;
+}
+
+/**
+ * For help with testing, given a bundleHostName and an addon name, return
+ * a list of the addon cache entries that have that addon name.
+ *
+ * @name findAddonCacheEntriesByName
+ */
+function findAddonCacheEntriesByName(perBundleAddonCacheInstance, bundleHostName, addonName) {
+  let bundleHostCacheEntry = perBundleAddonCacheInstance.bundleHostCache.get(bundleHostName);
+
+  if (!bundleHostCacheEntry) {
+    return null;
+  }
+
+  let addonInstanceCache = bundleHostCacheEntry.addonInstanceCache;
+  let cacheEntries = Array.from(addonInstanceCache.values());
+  let addonEntries = cacheEntries.filter((entry) => entry[TARGET_INSTANCE].name === addonName);
+
+  return addonEntries;
+}
+
+/**
+ * Simple utilities to help test the PerBundleAddonCache feature.
+ */
+module.exports = {
+  findAddonCacheEntriesByName,
+  createStandardCacheFixture,
+  getAllAddonsByNameWithinHost,
+  areAllInstancesEqualWithinHost,
+  countAddons,
+};
diff --git a/tests/unit/models/per-bundle-addon-cache/cache-bundle-hosts-test.js b/tests/unit/models/per-bundle-addon-cache/cache-bundle-hosts-test.js
new file mode 100644
index 000000000..dd253f981
--- /dev/null
+++ b/tests/unit/models/per-bundle-addon-cache/cache-bundle-hosts-test.js
@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ * Tests for checking that the list of 'bundle hosts' in the cache is correct.
+ * A 'bundle host' is either the project or a lazy engine.
+ */
+const expect = require('chai').expect;
+const Project = require('../../../../lib/models/project');
+
+const { createStandardCacheFixture } = require('../../../../tests/helpers/per-bundle-addon-cache');
+
+describe('Unit | per-bundle-addon-cache bundle host', function () {
+  let project;
+
+  before('setup fixture', function setup() {
+    let fixture = createStandardCacheFixture();
+    project = fixture.buildProjectModel(Project);
+    project.initializeAddons();
+  });
+
+  it('Should have 4 inRepo addons in project', function () {
+    // project should contain 4 children, 3 of which are engines and 1 is a regular addon.
+    expect(project._packageInfo.inRepoAddons.length).to.equal(4);
+  });
+
+  it('project.perBundleAddonCache should exist', function () {
+    expect(project.perBundleAddonCache).to.exist;
+  });
+
+  it('Should have 3 bundle hosts (project, lazy-engine-a, lazy-engine-b)', function () {
+    const bundleHostCache = project.perBundleAddonCache.bundleHostCache;
+
+    expect(bundleHostCache.size).to.equal(3); // project, lazy engine A, lazy engine B
+
+    expect(bundleHostCache.has('__PROJECT__')).to.equal(true);
+    expect(bundleHostCache.has('lazy-engine-a')).to.equal(true);
+    expect(bundleHostCache.has('lazy-engine-b')).to.equal(true);
+  });
+
+  it('Should not have any addonInstanceCache entries', function () {
+    const bundleHostCache = project.perBundleAddonCache.bundleHostCache;
+
+    for (const [key] of bundleHostCache) {
+      let value = bundleHostCache.get(key);
+      expect(value.addonInstanceCache && value.addonInstanceCache.size).to.equal(0);
+      expect(value.realPath).to.exist;
+    }
+  });
+});
diff --git a/tests/unit/models/per-bundle-addon-cache/enable-cache-test.js b/tests/unit/models/per-bundle-addon-cache/enable-cache-test.js
new file mode 100644
index 000000000..b89d9f622
--- /dev/null
+++ b/tests/unit/models/per-bundle-addon-cache/enable-cache-test.js
@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ * Tests for enabling and disabling per-bundle-addon-cache support
+ */
+const expect = require('chai').expect;
+const { createStandardCacheFixture } = require('../../../../tests/helpers/per-bundle-addon-cache');
+const Project = require('../../../../lib/models/project');
+
+function enablePerBundleAddonCache(explicitValue) {
+  // default is opt-out
+  if (explicitValue !== null && explicitValue !== undefined) {
+    process.env.EMBER_CLI_ADDON_INSTANCE_CACHING = explicitValue;
+  } else {
+    delete process.env.EMBER_CLI_ADDON_INSTANCE_CACHING;
+  }
+}
+
+function disablePerBundleAddonCache() {
+  process.env.EMBER_CLI_ADDON_INSTANCE_CACHING = false;
+}
+
+function createProject() {
+  let fixture = createStandardCacheFixture();
+  let project = fixture.buildProjectModel(Project);
+  return project;
+}
+
+describe('Unit | per-bundle-addon-cache enable caching', function () {
+  afterEach(function () {
+    enablePerBundleAddonCache();
+  });
+
+  it('perBundleAddonCache should be set in Project if EMBER_CLI_ADDON_INSTANCE_CACHING is not false', function () {
+    enablePerBundleAddonCache('foo');
+    let project = createProject();
+    expect(project.perBundleAddonCache).to.exist;
+
+    enablePerBundleAddonCache();
+    project = createProject();
+    expect(project.perBundleAddonCache).to.exist;
+  });
+
+  it('perBundleAddonCache should not be set in Project if EMBER_CLI_ADDON_INSTANCE_CACHING is false', function () {
+    disablePerBundleAddonCache();
+    let project = createProject();
+    expect(project.perBundleAddonCache).not.to.exist;
+  });
+});
diff --git a/tests/unit/models/per-bundle-addon-cache/proxy-test.js b/tests/unit/models/per-bundle-addon-cache/proxy-test.js
new file mode 100644
index 000000000..b99c27ff5
--- /dev/null
+++ b/tests/unit/models/per-bundle-addon-cache/proxy-test.js
@@ -0,0 +1,702 @@
+'use strict';
+
+/**
+ * Tests for the various proxies and instances once the project has initialized
+ * its addons
+ */
+const expect = require('chai').expect;
+const FixturifyProject = require('../../../../tests/helpers/fixturify-project');
+
+const {
+  findAddonCacheEntriesByName,
+  createStandardCacheFixture,
+  getAllAddonsByNameWithinHost,
+  areAllInstancesEqualWithinHost,
+  countAddons,
+} = require('../../../../tests/helpers/per-bundle-addon-cache');
+
+const Project = require('../../../../lib/models/project');
+const { TARGET_INSTANCE } = require('../../../../lib/models/per-bundle-addon-cache/target-instance');
+
+describe('models/per-bundle-addon-cache', function () {
+  let fixturifyProject;
+
+  beforeEach(function () {
+    fixturifyProject = new FixturifyProject('awesome-proj', '1.0.0');
+    fixturifyProject.addDevDependency('ember-cli', '*');
+  });
+
+  afterEach(function () {
+    fixturifyProject.dispose();
+  });
+
+  it('simple case: bundle addon caching within a single project host', function () {
+    fixturifyProject.addInRepoAddon('foo', '1.0.0', { allowCachingPerBundle: true });
+    fixturifyProject.addInRepoAddon('foo-bar', '1.0.0', {
+      callback: (inRepoAddon) => {
+        inRepoAddon.pkg['ember-addon'].paths = ['../foo'];
+      },
+    });
+
+    fixturifyProject.writeSync();
+    let project = fixturifyProject.buildProjectModel();
+
+    project.initializeAddons();
+
+    expect(areAllInstancesEqualWithinHost(project, 'foo')).to.be.true;
+  });
+
+  it('it should create multiple proxies within a project host', function () {
+    fixturifyProject.addInRepoAddon('foo', '1.0.0', { allowCachingPerBundle: true });
+
+    for (let i = 0; i < 10; i++) {
+      fixturifyProject.addInRepoAddon(`foo-bar-${i}`, '1.0.0', {
+        callback: (inRepoAddon) => {
+          inRepoAddon.pkg['ember-addon'].paths = ['../foo'];
+        },
+      });
+    }
+
+    fixturifyProject.writeSync();
+    let project = fixturifyProject.buildProjectModel();
+
+    project.initializeAddons();
+
+    expect(areAllInstancesEqualWithinHost(project, 'foo')).to.be.true;
+  });
+
+  it('it should create a proxy for a regular addon when added as a dependency to an in-repo addon', function () {
+    fixturifyProject.addAddon('foo', '1.0.0', { allowCachingPerBundle: true });
+
+    for (let i = 0; i < 10; i++) {
+      fixturifyProject.addInRepoAddon(`foo-bar-${i}`, '1.0.0', {
+        callback: (inRepoAddon) => {
+          inRepoAddon.addReferenceDependency('foo', '1.0.0');
+        },
+      });
+    }
+
+    fixturifyProject.writeSync();
+    let project = fixturifyProject.buildProjectModel();
+
+    project.initializeAddons();
+
+    expect(areAllInstancesEqualWithinHost(project, 'foo')).to.be.true;
+  });
+
+  it('it should create a proxy for a regular addon when added as a dependency to a regular addon', function () {
+    fixturifyProject.addAddon('foo', '1.0.0', { allowCachingPerBundle: true });
+
+    for (let i = 0; i < 10; i++) {
+      fixturifyProject.addAddon(`foo-bar-${i}`, '1.0.0', {
+        callback: (addon) => {
+          addon.addReferenceDependency('foo', '1.0.0');
+        },
+      });
+    }
+
+    fixturifyProject.writeSync();
+    let project = fixturifyProject.buildProjectModel();
+
+    project.initializeAddons();
+
+    expect(areAllInstancesEqualWithinHost(project, 'foo')).to.be.true;
+  });
+
+  it('it should create a proxy to a target "real addon" per host', function () {
+    fixturifyProject.addAddon('foo', '1.0.0', { allowCachingPerBundle: true });
+
+    fixturifyProject.addInRepoEngine('in-repo-lazy-engine', '1.0.0', {
+      enableLazyLoading: true,
+      callback: (lazyEngine) => {
+        lazyEngine.addReferenceDependency('foo', '1.0.0');
+        lazyEngine.addReferenceDependency('foo-bar', '1.0.0');
+      },
+    });
+
+    fixturifyProject.addAddon('foo-bar', '1.0.0', {
+      callback: (addon) => {
+        addon.addReferenceDependency('foo', '1.0.0');
+      },
+    });
+
+    fixturifyProject.writeSync();
+    let project = fixturifyProject.buildProjectModel();
+
+    project.initializeAddons();
+
+    expect(areAllInstancesEqualWithinHost(project, 'foo')).to.be.true;
+
+    // addons within lazy engine host are cached
+    expect(
+      areAllInstancesEqualWithinHost(
+        project.addons.find((addon) => addon.name === 'in-repo-lazy-engine'),
+        'foo'
+      )
+    ).to.be.true;
+  });
+
+  describe('when `EMBER_ENGINES_ADDON_DEDUPE` is enabled', function () {
+    beforeEach(function () {
+      process.env.EMBER_ENGINES_ADDON_DEDUPE = true;
+    });
+
+    afterEach(function () {
+      delete process.env.EMBER_ENGINES_ADDON_DEDUPE;
+    });
+
+    it('it should create a proxy to a target "real addon" using the project host', function () {
+      fixturifyProject.addAddon('foo', '1.0.0', { allowCachingPerBundle: true });
+
+      fixturifyProject.addInRepoEngine('in-repo-lazy-engine', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (lazyEngine) => {
+          lazyEngine.addReferenceDependency('foo', '1.0.0');
+          lazyEngine.addReferenceDependency('foo-bar', '1.0.0');
+        },
+      });
+
+      fixturifyProject.addAddon('foo-bar', '1.0.0', {
+        callback: (addon) => {
+          addon.addReferenceDependency('foo', '1.0.0');
+        },
+      });
+
+      fixturifyProject.writeSync();
+      let project = fixturifyProject.buildProjectModel();
+
+      project.initializeAddons();
+
+      // we use project addon instance as the "real addon"
+      expect(areAllInstancesEqualWithinHost(project, 'foo')).to.be.true;
+
+      const { realAddon: realAddonForProject, proxies: proxiesForProject } = getAllAddonsByNameWithinHost(
+        project,
+        'foo'
+      );
+      const { proxies: proxiesForEngine } = getAllAddonsByNameWithinHost(
+        project.addons.find((addon) => addon.name === 'in-repo-lazy-engine'),
+        'foo'
+      );
+
+      expect(
+        [...proxiesForProject, ...proxiesForEngine].every((proxy) => proxy[TARGET_INSTANCE] === realAddonForProject)
+      ).to.be.true;
+    });
+
+    it('addon with `allowCachingPerBundle`, 1 in each of 2 lazy engines; project also depends on this addon', function () {
+      fixturifyProject.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+
+      fixturifyProject.addEngine('lazy-engine-a', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.addReferenceDependency('test-addon-a', '1.0.0');
+        },
+      });
+
+      fixturifyProject.addEngine('lazy-engine-b', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.addReferenceDependency('test-addon-a', '1.0.0');
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let counts = countAddons(project);
+
+      expect(counts.byName['lazy-engine-a'].addons.length).to.equal(1);
+      expect(counts.byName['lazy-engine-b'].addons.length).to.equal(1);
+
+      expect(counts.proxyCount).to.equal(2);
+      expect(project.perBundleAddonCache.numProxies).to.equal(2);
+
+      expect(counts.byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(counts.byName['test-addon-a'].proxyCount).to.equal(2);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+
+      // project cache should be used
+      expect(cacheEntries.length).to.equal(0);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-b', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+
+      // project cache should be used
+      expect(cacheEntries.length).to.equal(0);
+    });
+
+    it('2 lazy engines; each depend on two addons; project also depends on these addons, ensure project cache is used', function () {
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0', {
+        allowCachingPerBundle: true,
+        callback: (addon) => {
+          addon.pkg['ember-addon'].paths = ['../test-addon-b'];
+        },
+      });
+
+      fixturifyProject.addInRepoAddon('test-addon-b', '1.0.0', { allowCachingPerBundle: true });
+
+      fixturifyProject.addInRepoEngine('lazy-engine-a', '1.0.0', {
+        allowCachingPerBundle: true,
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.pkg['ember-addon'].paths = ['../test-addon-a', '../test-addon-b'];
+        },
+      });
+
+      fixturifyProject.addInRepoEngine('lazy-engine-b', '1.0.0', {
+        allowCachingPerBundle: true,
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.pkg['ember-addon'].paths = ['../test-addon-a', '../test-addon-b'];
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { byName } = countAddons(project);
+
+      expect(byName['lazy-engine-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['lazy-engine-a'].proxyCount).to.equal(0);
+
+      expect(byName['lazy-engine-b'].realAddonInstanceCount).to.equal(1);
+      expect(byName['lazy-engine-b'].proxyCount).to.equal(0);
+
+      expect(byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-a'].proxyCount).to.equal(2);
+
+      expect(byName['test-addon-b'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-b'].proxyCount).to.equal(3);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+
+      // project cache should be used
+      expect(cacheEntries.length).to.equal(0);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-b');
+      expect(cacheEntries).to.exist;
+
+      // project cache should be used
+      expect(cacheEntries.length).to.equal(0);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-b', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+
+      // project cache should be used
+      expect(cacheEntries.length).to.equal(0);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-b', 'test-addon-b');
+      expect(cacheEntries).to.exist;
+
+      // project cache should be used
+      expect(cacheEntries.length).to.equal(0);
+    });
+  });
+
+  describe('proxy checks with addon counts', function () {
+    it('no `allowCachingPerBundle` set, no proxies, verify instance counts', function () {
+      let fixture = createStandardCacheFixture();
+      let project = fixture.buildProjectModel(Project);
+      project.initializeAddons();
+
+      let counts = countAddons(project);
+
+      expect(counts.proxyCount).to.equal(0);
+      expect(counts.byName['test-addon-a'].addons.length).to.equal(1);
+      expect(counts.byName['test-addon-dep'].addons.length).to.equal(2);
+      expect(counts.byName['test-engine-dep'].addons.length).to.equal(3);
+      expect(counts.byName['lazy-engine-a'].addons.length).to.equal(1);
+      expect(counts.byName['lazy-engine-b'].addons.length).to.equal(1);
+      expect(counts.byName['regular-engine-c'].addons.length).to.equal(1);
+
+      // addon cache should also have 0 proxies. test-addon-b was the only addon marked as cacheable,
+      // so it will end up in the count of addon instances for the addon cache, but have no proxies.
+      expect(project.perBundleAddonCache.numProxies).to.equal(0);
+    });
+
+    it('addon with allowCachingPerBundle, 1 instance, the rest proxies', function () {
+      // PROJ to TAA, TAB, TAC and TAD. TAB, TAC and TAD have TAA underneath.
+      fixturifyProject.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+      fixturifyProject.addAddon('test-addon-b', '1.0.0', {
+        callback: (addon) => {
+          addon.addReferenceDependency('test-addon-a', '*');
+        },
+      });
+
+      fixturifyProject.addAddon('test-addon-c', '1.0.0', {
+        callback: (addon) => {
+          addon.addReferenceDependency('test-addon-a', '*');
+        },
+      });
+
+      fixturifyProject.addAddon('test-addon-d', '1.0.0', {
+        callback: (addon) => {
+          addon.addReferenceDependency('test-addon-a', '1.0.0');
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let counts = countAddons(project);
+      expect(counts.proxyCount).to.equal(3);
+      expect(project.perBundleAddonCache.numProxies).to.equal(3);
+      expect(counts.byName['test-addon-a'].addons.length).to.equal(4);
+
+      expect(counts.byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(counts.byName['test-addon-a'].proxyCount).to.equal(3);
+
+      expect(counts.byName['test-addon-b'].addons.length).to.equal(1);
+      expect(counts.byName['test-addon-c'].addons.length).to.equal(1);
+    });
+
+    it('addon with `allowCachingPerBundle`, 1 in lazy engine, one in regular', function () {
+      // PROJ to LEA, REB, LEA and REB both depend on TAA
+      // Neither instance of test-addon-a is declared in the project, but the one in engine B
+      // will be 'owned' by Project as far as PerBundleAddonCache is concerned.
+      // Should end with 2 instances of test-addon-a, one in PROJECT, one in lazy-engine-a,
+      // and no proxies.
+      fixturifyProject.addEngine('lazy-engine-a', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+        },
+      });
+
+      fixturifyProject.addEngine('regular-engine-b', '1.0.0', {
+        callback: (engine) => {
+          engine.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let counts = countAddons(project);
+
+      expect(counts.byName['lazy-engine-a'].addons.length).to.equal(1);
+      expect(counts.byName['regular-engine-b'].addons.length).to.equal(1);
+
+      expect(counts.proxyCount).to.equal(0);
+      expect(project.perBundleAddonCache.numProxies).to.equal(0);
+
+      expect(counts.byName['test-addon-a'].realAddonInstanceCount).to.equal(2);
+      expect(counts.byName['test-addon-a'].proxyCount).to.equal(0);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, '__PROJECT__', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+    });
+
+    it('addon with allowCachingPerBundle, 1 in each of 2 lazy engines', function () {
+      // Same as above, but regular-engine-b is now lazy-engine-b
+      // Should have 2 instances, 1 in LEA, 1 in LEB, separate paths.
+      fixturifyProject.addEngine('lazy-engine-a', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+        },
+      });
+
+      fixturifyProject.addEngine('lazy-engine-b', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let counts = countAddons(project);
+
+      expect(counts.byName['lazy-engine-a'].addons.length).to.equal(1);
+      expect(counts.byName['lazy-engine-b'].addons.length).to.equal(1);
+
+      expect(counts.proxyCount).to.equal(0);
+      expect(project.perBundleAddonCache.numProxies).to.equal(0);
+
+      expect(counts.byName['test-addon-a'].realAddonInstanceCount).to.equal(2);
+      expect(counts.byName['test-addon-a'].proxyCount).to.equal(0);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-b', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+    });
+
+    it('addon with `allowCachingPerBundle`, 1 in each of 2 lazy engines; project also depends on this addon', function () {
+      fixturifyProject.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+
+      fixturifyProject.addEngine('lazy-engine-a', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.addReferenceDependency('test-addon-a');
+        },
+      });
+
+      fixturifyProject.addEngine('lazy-engine-b', '1.0.0', {
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.addReferenceDependency('test-addon-a');
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let counts = countAddons(project);
+
+      expect(counts.byName['lazy-engine-a'].addons.length).to.equal(1);
+      expect(counts.byName['lazy-engine-b'].addons.length).to.equal(1);
+
+      expect(counts.proxyCount).to.equal(0);
+      expect(project.perBundleAddonCache.numProxies).to.equal(0);
+
+      expect(counts.byName['test-addon-a'].realAddonInstanceCount).to.equal(3);
+      expect(counts.byName['test-addon-a'].proxyCount).to.equal(0);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-b', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+    });
+
+    it('addon with allowCachingPerBundle, 2 regular engines - cache entries in project but not declared there', function () {
+      // Project declares an in-repo addon TAA. Then remove the ember-addon.paths entry so the project
+      // "doesn't know" about it but it's available for engines. Declare 2 non-lazy in-repo engines.
+      // Then have them add a shared in-repo dependency to TAA, with the path pointing to the one in
+      // PROJ/lib (i.e. '../test-addon-a')
+      // Should have 1 instance, 1 proxy, both in project.
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+      fixturifyProject.pkg['ember-addon'].paths = []; // remove the 'dependency' (file still exists)
+
+      fixturifyProject.addInRepoEngine('regular-engine-a', '1.0.0', {
+        enableLazyLoading: false,
+        shouldShareDependencies: true,
+        callback: (inRepoEngine) => {
+          inRepoEngine.pkg['ember-addon'].paths = ['../test-addon-a'];
+        },
+      });
+
+      fixturifyProject.addInRepoEngine('regular-engine-b', '1.0.0', {
+        enableLazyLoading: false,
+        shouldShareDependencies: true,
+        callback: (inRepoEngine) => {
+          inRepoEngine.pkg['ember-addon'].paths = ['../test-addon-a'];
+        },
+      });
+
+      fixturifyProject.writeSync();
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { proxyCount, byName } = countAddons(project);
+
+      expect(byName['regular-engine-a'].addons.length).to.equal(1);
+      expect(byName['regular-engine-b'].addons.length).to.equal(1);
+
+      expect(proxyCount).to.equal(1);
+      expect(project.perBundleAddonCache.numProxies).to.equal(1);
+
+      expect(byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-a'].proxyCount).to.equal(1);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, '__PROJECT__', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+    });
+
+    it('addon with allowCachingPerBundle, 2 regular engines - cache entries in project (also declared there)', function () {
+      // Same as above, now both are regular engines.
+      // Should have 1 instance, 2 proxies, both in project.
+      fixturifyProject.addAddon('test-addon-a', '1.0.0', { allowCachingPerBundle: true });
+
+      fixturifyProject.addEngine('regular-engine-a', '1.0.0', {
+        callback: (engine) => {
+          engine.addReferenceDependency('test-addon-a', '1.0.0');
+        },
+      });
+
+      fixturifyProject.addEngine('regular-engine-b', '1.0.0', {
+        callback: (engine) => {
+          engine.addReferenceDependency('test-addon-a', '1.0.0');
+        },
+      });
+
+      fixturifyProject.writeSync();
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { proxyCount, byName } = countAddons(project);
+
+      expect(byName['regular-engine-a'].addons.length).to.equal(1);
+      expect(byName['regular-engine-b'].addons.length).to.equal(1);
+
+      expect(proxyCount).to.equal(2);
+      expect(project.perBundleAddonCache.numProxies).to.equal(2);
+
+      expect(byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-a'].proxyCount).to.equal(2);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, '__PROJECT__', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+    });
+
+    it('2 lazy engines; each depend on two addons; ensure that each lazy engine has proxy for subsequent instantiations of duplicate addons', function () {
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0', {
+        allowCachingPerBundle: true,
+        callback: (addon) => {
+          addon.pkg['ember-addon'].paths = ['../test-addon-b'];
+        },
+      });
+
+      fixturifyProject.addInRepoAddon('test-addon-b', '1.0.0', { allowCachingPerBundle: true });
+      fixturifyProject.pkg['ember-addon'].paths = []; // project now 'doesn't know' about test-addon-b
+
+      fixturifyProject.addInRepoEngine('lazy-engine-a', '1.0.0', {
+        allowCachingPerBundle: true,
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.pkg['ember-addon'].paths = ['../test-addon-a', '../test-addon-b'];
+        },
+      });
+
+      fixturifyProject.addInRepoEngine('lazy-engine-b', '1.0.0', {
+        allowCachingPerBundle: true,
+        enableLazyLoading: true,
+        callback: (engine) => {
+          engine.pkg['ember-addon'].paths = ['../test-addon-a', '../test-addon-b'];
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { byName } = countAddons(project);
+
+      expect(byName['lazy-engine-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['lazy-engine-a'].proxyCount).to.equal(0);
+
+      expect(byName['lazy-engine-b'].realAddonInstanceCount).to.equal(1);
+      expect(byName['lazy-engine-b'].proxyCount).to.equal(0);
+
+      expect(byName['test-addon-a'].realAddonInstanceCount).to.equal(2);
+      expect(byName['test-addon-a'].proxyCount).to.equal(0);
+
+      expect(byName['test-addon-b'].realAddonInstanceCount).to.equal(2);
+      expect(byName['test-addon-b'].proxyCount).to.equal(2);
+
+      let cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-a', 'test-addon-b');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-b', 'test-addon-a');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+
+      cacheEntries = findAddonCacheEntriesByName(project.perBundleAddonCache, 'lazy-engine-b', 'test-addon-b');
+      expect(cacheEntries).to.exist;
+      expect(cacheEntries.length).to.equal(1);
+    });
+
+    it('2 regular engines; each depend on two addons; ensure that project cache is used', function () {
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0', {
+        allowCachingPerBundle: true,
+        callback: (addon) => {
+          addon.pkg['ember-addon'].paths = ['../test-addon-b'];
+        },
+      });
+
+      fixturifyProject.addInRepoAddon('test-addon-b', '1.0.0', { allowCachingPerBundle: true });
+      fixturifyProject.pkg['ember-addon'].paths = [];
+
+      fixturifyProject.addInRepoEngine('engine-a', '1.0.0', {
+        allowCachingPerBundle: true,
+        callback: (engine) => {
+          engine.pkg['ember-addon'].paths = ['../test-addon-a', '../test-addon-b'];
+        },
+      });
+
+      fixturifyProject.addInRepoEngine('engine-b', '1.0.0', {
+        allowCachingPerBundle: true,
+        callback: (engine) => {
+          engine.pkg['ember-addon'].paths = ['../test-addon-a', '../test-addon-b'];
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { byName } = countAddons(project);
+
+      expect(byName['engine-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['engine-a'].proxyCount).to.equal(0);
+
+      expect(byName['engine-b'].realAddonInstanceCount).to.equal(1);
+      expect(byName['engine-b'].proxyCount).to.equal(0);
+
+      expect(byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-a'].proxyCount).to.equal(1);
+
+      expect(byName['test-addon-b'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-b'].proxyCount).to.equal(2);
+    });
+
+    it('multiple references to a single lazy engine that has opted-in to `allowCachingPerBundle`', function () {
+      fixturifyProject.addInRepoEngine('lazy-engine-a', '1.0.0', {
+        allowCachingPerBundle: true,
+        enableLazyLoading: true,
+      });
+
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0', {
+        callback: (addon) => {
+          addon.pkg['ember-addon'].paths = ['../lazy-engine-a'];
+        },
+      });
+
+      fixturifyProject.addInRepoAddon('test-addon-b', '1.0.0', {
+        callback: (addon) => {
+          addon.pkg['ember-addon'].paths = ['../lazy-engine-a'];
+        },
+      });
+
+      fixturifyProject.addInRepoAddon('test-addon-c', '1.0.0', {
+        callback: (addon) => {
+          addon.pkg['ember-addon'].paths = ['../lazy-engine-a'];
+        },
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { byName } = countAddons(project);
+
+      expect(byName['lazy-engine-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['lazy-engine-a'].proxyCount).to.equal(3);
+
+      expect(areAllInstancesEqualWithinHost(project, 'lazy-engine-a')).to.be.true;
+    });
+  });
+});
diff --git a/tests/unit/utilities/is-engine-test.js b/tests/unit/utilities/is-engine-test.js
new file mode 100644
index 000000000..b29e26aa7
--- /dev/null
+++ b/tests/unit/utilities/is-engine-test.js
@@ -0,0 +1,19 @@
+'use strict';
+
+const expect = require('chai').expect;
+const isEngine = require(`../../../lib/utilities/is-engine`);
+
+describe('Unit | is-engine', function () {
+  it('it identifies an engine correctly', function () {
+    expect(isEngine([null, 'ember-engine', 'foo', 'bar'])).to.equal(true);
+    expect(isEngine(['ember-engine'])).to.equal(true);
+  });
+
+  it("it returns false if it's not an engine", function () {
+    expect(isEngine({ 'ember-engine': true })).to.equal(false);
+    expect(isEngine('ember-engine')).to.equal(false);
+    expect(isEngine(['foo', 'bar'])).to.equal(false);
+    expect(isEngine({})).to.equal(false);
+    expect(isEngine(undefined)).to.equal(false);
+  });
+});
diff --git a/tests/unit/utilities/is-lazy-engine-test.js b/tests/unit/utilities/is-lazy-engine-test.js
new file mode 100644
index 000000000..3a14c1bdc
--- /dev/null
+++ b/tests/unit/utilities/is-lazy-engine-test.js
@@ -0,0 +1,18 @@
+'use strict';
+
+const expect = require('chai').expect;
+const isLazyEngine = require(`../../../lib/utilities/is-lazy-engine`);
+
+describe('Unit | is-lazy-engine', function () {
+  it('it identifies a lazy engine correctly', function () {
+    expect(isLazyEngine({ options: { lazyLoading: { enabled: true } } })).to.equal(true);
+  });
+
+  it("it returns false if it's not a lazy engine", function () {
+    expect(isLazyEngine({ options: { lazyLoading: { enabled: false } } })).to.equal(false);
+    expect(isLazyEngine({ options: { lazyLoading: {} } })).to.equal(false);
+    expect(isLazyEngine({ options: {} })).equal(false);
+    expect(isLazyEngine({})).to.equal(false);
+    expect(isLazyEngine()).to.equal(false);
+  });
+});