-
Notifications
You must be signed in to change notification settings - Fork 127
This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: Apennine Architecture (multi-bundle support) #127
Comments
@zamotany, Thanks for writing this very detailed proposal, this is awesome. I think I got most of what you are proposing, so here are some questions I had. Also, please correct me if some of the assumptions in my questions are incorrect. As I understand, you are proposing this architecture for RN production apps, right ? Are you proposing this architecture for every single RN app, or more for super apps ? While I understand that this will greatly help super apps, how would it help standalone react native apps. Those apps will have to load the host, and the DLL parts, in addition to the actual bundle. With inline requires and RAM bundles, we could already evaluate and run code lazily - how else will this work ? |
Yes. All RN apps would be running on this architecture, however decision whether to leverage multi-bundle feature is up to the developer and it's opt-in - it will be up to developer, if they want to use Host/DLL/apps bundles or a single regular/RAM bundle.
Not necessarily, as I mentioned in Implementation breakdown, the trigger for loading additional bundles (DLL/app) is coming from JS, so if the Host bundle is actually just a regular bundle (non-RAM/RAM), with application code, then nothing else will ever be loaded, since there's no code for that and the Host bundle will effectively become a normal application bundle (non-RAM/RAM) - the bundle we're all using currently.
Inline requires and RAM bundles does help with reducing TTI/TTR, however putting everything in single RAM bundle doesn't scale well. |
I think my question is - can this not be done today with the unbundle (RAM bundle command), where the files are split ? the "import" command sort of already does this, right ? Sorry, I am having trouble understanding the difference. |
The three biggest differences that I think are:
|
@karanjthakkar there's already PR to fix Indexed RAM bundles on Androrid: facebook/react-native#24967 |
@zamotany This is super well thought! Good job! I've been wanting for something like this since I did this lazy bundling experiment last year. If you want to chat about implementing this from the iOS side, I'd be happy to help! One of the questions that I have after reading this is, do you expect Haul to be the default bundler/packager if this were to be baked into RN? I don't know if metro supports this kind of code splitting atm.
Wow! Thank you! I'm going to give this a try internally and see how it works out! |
@karanjthakkar Not really, for an average RN developer dealing with Haul and using this architecture might not be worth it. The main target audience here are big companies, where ability to scale easily is crucial. Webpack ecosystem has it's own shortcomings, so I don't see Haul as a replacement of Metro but rather an an alternative - for developers/companies whom will actually benefit from Haul + multi-bundles. |
@zamotany Got it. So folks who want to use multi-bundles will essentially need to use Haul is what I wanted to confirm. Another question, how does the native side decide if it needs to run in multi bundle mode? Does the host bundle have some identifier to indicate that? |
@karanjthakkar Unless the Host bundle requests to load additional bundles (DLL/app) from JS, it's basically operating in single-bundle mode, so no - there's no need for any additional identifier. In other words: by default it's running in single-bundle mode, when the trigger to load new bundle is received from JS, then it's running in multi-bundle mode. |
Thanks @axemclion for your questions! Full disclosure, this architecture initially came out of some internal work I did, and @matthargett and I have been working with Callstack to build an implementation of this architecture. For standalone RN apps, the intention is that they would still be able to be deployed as a single RAM bundle, without any changes to how they’re deployed. I think your mention of super apps is a great example of where this could provide benefit over a single RAM bundle. For super apps, the “mini apps” would be able to be built against a common DLL bundle that’s provided by the super app, and deployed independently. The main benefit being that mini apps would be able to be run in an independent JS context from the super app. The independent JS context can be pre-warmed with the host app and DLL bundles, before it’s known what mini app will be loaded next. When a mini app is loaded, only its app-specific code needs to be evaluated, removing the initialization time of the common dependencies from the TTI. Ideally this can bring the TTI time down to be on par with the duration of the animation used to transition to the mini app. Another example might be something like the Facebook app. I don’t know its internals at all, but let’s say hypothetically that Marketplace and Profile are separate bundles. Code that is common to both, such as react, react-native, data access libraries like relay, common telemetry libraries, etc could be moved into the DLL bundle and preloaded into the host app in the background. Once the user selected the Marketplace or Profile tab, just the code specific to that tab could be loaded, providing a middle ground between a cold boot of those tabs and having them completely preloaded. |
We use something very similar in Office. Boot perf is one benefit, although it would possibly be able to be optimized by a perfect bundle delay load solution instead. But another benefit of the separate instance solution is the ability to shut down the "mini apps". Using the Facebook app example, assuming Marketplace and Profile are both RN solutions. As you navigate around the Facebook app, loading more and more RN solutions, more and more of the bundle would get parsed and loaded into the instance. So once you've ever navigated to both the Marketplace and Profile, your instance would be forever the size of both those apps together. As the number of mini apps goes up, this becomes a massive memory hog. What we want is the ability after the user has navigated away from the MarketPlace, for the app to be able to shutdown the Marketplace app, and return to a memory usage based on just what the user is currently using. -- Otherwise RN features become essentially massive memory leaks. Having at least part of the bundle be shared between the instances also allows for optimizations around memory mapped files that allows the disk read to be consolidated between those instances, for additional performance gains. |
In our company, we also have a scenario very similar to what you are describing and our current app is already working in a multi-bundle scenario. It's really good to see our teams working on this scenario! We should work together to find a better solution to it. As a Bank, our App has more than 100 difference services on it: credit cards, insurance, loans, Financial Tracker, Payments and many others. Many of those services are developed and owned by different teams. Each one of these team have a different backlog and business priorities. These services/team were mini apps inside my super app. In the last months, my team came with a simple solution to overcome schedule conflict's and bugs from mini app 1 impact on mini app2. We gave each mini app a bundle and let them develop and deploy it on their own time. A wrote a medium post about it. On my post, I describe same problems that the face. In addition to those described there, loading time and the memory consumption as described by @acoates-ms were another problems. However, the app have being used by more 15 M users without big problems. After a couple months in production, we were planning to design a system similar to what @zamotany I want to endorse this suggestion and offer our help. I'm really down a Skype meeting. |
@joeblynch @acoates-ms @oximer |
@zamotany Why using a single JSContext would be better? If we use two JSContext, we could avoid JS bugs from App 0 to affect App1. In addition, we avoid some issues with privacy and sensitive data between bundles. It would be pretty similar to different tabs inside a browser. |
@oximer Yeah, that's true. After talking with @joeblynch, we decided to revisit the proposed architecture to make it use multiple |
As an (now) external member of @oximer team, I’m stepping up to participate in the discussion as well. |
I might not be able to see the bigger picture yet but some disadvantages with multiple contexts I can think of are:
If we want to use multiple JSContext's, then is it currently not possible to do that by calling |
@karanjthakkar one option I had very briefly explored internally was the idea of being able to have an "internal deeplink" within the host app, which would allow additional app bundles to be loaded into an existing host app context. Like you mentioned, this was primarily to allow for data to be shared between "mini apps", to reduce memory overhead, and to allow the pre-warmed "host+dll" context to be left running in the background for the next "external deeplink" that needed an isolated instance. For the memory reasons that @acoates-ms mentioned, and the isolation requirements that @oximer mentioned, we also have cases where it's beneficial to have a separate instance for "mini apps" at times. Perhaps a solution that supports both use cases would be ideal? |
@karanjthakkar In terms of over-fetching, having a shared, transformed cache (e.g. Apollo GraphQL) on-disk shared between apps is one way to address your concern. Re-fetching is certainly a thing to optimize away from, but so is re-parsing the JSON and HTTP cache headers you did fetch. Keeping each app's in-memory cache tuned to be a low as reasonable for the real-world user flows, and relying on the shared disk cache, gets you the safety and the performance (CPU, memory, and heat/battery life). |
This seems right to me. In my experience, having the option to share data between sub-applications is very useful in an organization whose lifeblood is product iteration. Put another way, using a single JSContext preserves flexibility to change the lines of division in both the product and the org chart. At the same time, for some products the lines of division are intrinsic and likely to stay the same. The super apps that Ram mentioned are one of the better examples of legitimate lines of division between sub-applications. MS Office or something like Xbox are also good examples (ex: isolating Word from Excel or the Xbox store from Xbox settings). So I think multi-bundle support is desirable for both the single-JSContext and multi-JSContext scenarios -- or if only the single JSContext scenario is supported, for another JSContext to be able to load code from the same source as other apps. This said, here are a few deeper principles I think a good system would exhibit:
I don't see these as requirements but think they are guiding principles towards a small, stable 1.0. |
@ide Those guiding principles you mentioned are more targeted towards bundlers, not this architecture. What we are proposing here is the architecture that would support multiple bundles - how would you use them and how the bundles would be generated is up to the bundlers. For instance, if someone wants to have multiple DLL bundles, nothing is preventing that in Apennine Architecture from happening, only the packager needs to create those multiple DLL bundles and the Host bundle needs to load them. Apennine Architecture could support as many bundles as you want + JS API to load and handle them.
What the RN would need to know is that there can be multiple bundles and support an JS API to manage them. |
In our project for sharing data between bundle, we boost the AsyncStorage module. When you save something on the smartphone HD, you can save it on a common area for your app or on your mini app. area. To avoid concurrency, we add a prefix (bundle_name) to all keys saved to AsyncStorage. There are also other scenario, as example, where a (mini app) bundle wants to start another (mini app) bundle. We also solve it, creating some extra React Native Native Modules. Navigation between mini apps introduce some complexity, but we figure out a way to solve it. The first step on this thread should be allowing multi bundle using DLL. |
We did some experiments in an attempt to achieve similar goal. With webpack's DLL plugin, all the inter-ops between bundles will rely on module IDs (rel paths in dev, numbers in ship). This poses several limitations:
|
@zamotany @oximer @karanjthakkar I’ve been thinking more about this, and perhaps at least for the type of work described in the implementation breakdown, it makes sense to focus on the loading of multiple bundles into a single JSContext. For the case of multiple contexts within a super app, maybe that’s a related but somewhat tangental problem. To implement that, we use a component that’s basically akin to an iframe. This gives the super app full control of the loading and positioning of mini apps. Of course, this also requires that there’s a mechanism to map the URL given to the iframe component to the file path of the bundle being loaded. Within the super app, one of these iframe components can created in the background. If and when the super app choses, it can load the host and DLL bundles into the JSContext running in the iframe component, to pre-warm whatever mini app the user choses to launch next. Once the super app then loads an app bundle into the iframe, it can move that component into view to display the mini app. At that point, the super app could optionally pre-warm another iframe/JSContext in the background to prepare for the next mini app that gets loaded, or it can reuse the existing iframe/JSContext instead. |
@statm very good point! If the module IDs were something like |
This sounds very close to what ram-bundle/unbundle already provides. |
@CaptainNic it's similar, the distinction is that the host and DLL bundles are able to be deployed separately and shared by multiple app bundles. Since they're shared, they can be preloaded before it's known what app bundle will be loaded next. |
@joeblynch It will help partially, but won't solve all these issues. Imagine the host bundle is built in one mono-repo, and App A bundle is built in another repo. Both contain module
In this case, as long as the hash contains relative paths, |
@zamotany It would be nice for the design to move more of the logic and decision-making to the bundlers. As you wrote -- "Those guiding principles you mentioned are more targeted towards bundlers, not this architecture" -- I would love for the design to shift the principles to the bundlers. On React Native's side, this could happen by keeping the API small (both number of functions and what those functions can do), down to maybe just a couple variants of Currently in the proposal, React Native is aware of bundles and startup code. I'm suggesting to follow a browser-like model (principle 2) and expose a few script execution functions that the JS can call. Then this starts to open up pathways towards user-space HMR (ex: webpack-dev-server), different forms of bundle splitting and code reuse, and other features that JS can implement without needing to change React Native. |
@axemclion @karanjthakkar @joeblynch @acoates-ms @oximer @ide @matthargett I have updated the issue with new details to account for multiple |
I have researched the
|
The Apennine Architecture allows you to decide if you want to run all bundles on a single |
This is really great! Few questions:
|
It appears that the PR with the proposed implementation was sent before we reached consensus. I would like to propose a way forward for this RFC and a possible implementation. Current State
Consensus is important because:
The size of the PR is an issue because:
This process certainly has trade-offs and you may have opinions about how we could do things differently. I am trying to explain why it is currently hard for us to integrate large changes such as this and why they need to be broken down into fully-working isolated changes step-by-step. Moving ForwardI propose the following steps to unblock this work that have the highest chance of getting included into RN:
Proposed changesAs discussed here by @ide and privately with me and other FB employees we believe there could be a much less invasive first solution that will gives us most of the benefits of the proposed architecture and allowing others to build third-party modules around this area to make it even better. I propose:
(thanks @rickhanlonii for reviewing this) |
To reiterate the point about Hermes...from @acoates-ms:
I believe Hermes gives us all this on Android and it sounds like there was some unfortunate timing between this RFC and our efforts to release Hermes. Because the bytecode is a read-only mmapped file, it can actually return pages of bytecode back to the OS when they are no longer needed (I believe the GC can also return pages to the OS after collecting and compacting). @amnn might have more to add (or correct). |
Thank you @cpojer and @sahrens for your feedback! It sounds like Facebook has been thinking about how to do achieve TTI reduction similar to this, but that this approach we've taken is not the same as Facebook's ideas. Could you share some more specifics of what the solution you've been thinking about looks like? We want to make sure we can get it working for all the users of RN on platforms they currently deploy on, and hopefully also be able to give it to the community who will be using it without Hermes / on iOS for a while. I agree fully that bytecode bundles are preferable over RAM bundles. I see the architecture proposed in this RFC as being a technique that's used in addition to bytecode, with both implemented to reduce TTI, but in different and complementary ways. The primary target for this architecture is environments like super apps, in which multiple "mini apps" run in separate JS contexts, while sharing much of the same initialization process. Each mini app within the super app will need to stand up an RN app instance, and for example might need to initialize libraries for navigation, data access, telemetry, etc. Since this work is common to every mini-app, there's no need to wait to do this work until it's known what mini app will launch next. Instead we preinitialize these common dependencies in a JS context, and when a mini app is loaded, its app bundle is evaluated in this pre-warmed context. The app's root component can then be mounted into the waiting RN app instance. By using bytecode bundles instead of RAM bundles, we get the additional benefit that the preinitialization time and app bundle eval time are both reduced, by being able to skip the lexing and parsing work. In theory, if the JS VM in use supports heap snapshots, it may be even faster to load the pre-warmed context from a snapshot. @matthargett and I discussed the proposed changes to move this forward with @zamotany today, and we are in agreement that we'd like to keep the required changes to the core of RN as lean as possible. He plans to comment further on the implementation of these changes. |
@joeblynch, could you help us understand more about your approach of super apps and if that approach is common in the industry? For context, I think us at Facebook would consider the Facebook mobile app a "super app" 😉 . It has hundreds (thousands?) of distinct feature sets, and we try to think about things similarly. For example, when someone goes to Marketplace, and then to Dating, Dating should start up as quickly as possible with as little overhead as possible, and Marketplace should be able to be evicted, returning memory back to the operating system. I'm sure your group is needing to make some different tradeoffs than we are for the Facebook app, so it might be helpful to have some more context on those tradeoffs in order to figure out how they impact approaches here. |
@cpojer After some thinking with @joeblynch and @matthargett we have an idea how to move forward that would be easier for FB to approve: First of all, we would split the work between refactoring and features, meaning the first PR (or set of PRs) would refactor the bundle types and logic around them. We'll aim at making the changes as small as possible in terms on PR size and bytecode size increase, so that it would be feasible to be merged. After that, the feature works, as suggested, would improve upon existing Please, let us know what you and the RN team at FB think about that. If there're any suggestions or comments, please write them here. |
@zamotany, @joeblynch, and @matthargett what do you think about @cpojer's proposal? |
@rickhanlonii As for using
Please correct me if I misunderstood the proposal. As for the first point with bundling, there's already a quite a lot of work in bundling step. Even in original proposal and original PR, the native side didn't know anything except of the initial bundle. Everything else was done by JS or requested from JS. Right now, we would like to focus on getting the native side done, since we already have the bundling done in Haul for multi-bundle scenario. |
@cpojer Is there any feedback from RN team on #127 (comment) and #127 (comment) ?? |
This issue was moved to a discussion.
You can continue the conversation there. Go to discussion →
RFC: Apennine Architecture
The Apennine architecture builds up the mountain of JS dependencies that are common to any RN app running on the platform, providing a ready and waiting foundation from which any app can quickly run.
Apennine aims to build this mountain as high as possible, by not only pre-evaluating common JS dependencies, but also initializing the framework through the point of starting an empty application that can host any app's components.
Disclaimer: for simplicity we are referring only to Android implementation here.
Architecture breakdown
When using the Apennine architecture the app would consist of at least 3 bundles:
react
,react-native
) or shared logic packed together.Both DLL bundles and app bundles can be a regular JS bundle (
BasicBundle
) or a Indexed/File RAM bundle.This multi-bundle architecture would be available not only in release mode like RAM bundles, but also in development mode and served from packager server, to allow for easy JS debugging with Remote debugger for all of the bundles including Host bundle and DLL bundles.
BundleRegistry
The single point for holding all bundles and managing them would be in
BundleRegistry
(which itself would be stored inInstance
). Each bundle would be given it's ownBundleExecutionEnvironment
.BundleExecutionEnvironment
would containNativeToJsBridge
instance (withJsToNativeBridge
) as well asMessageQueueThread
and aJSIExecutor
, which would hold aJSContext
for given bundle.All bundles would be stored in a
std::vector<Bundle>
and have aBundle* initialBundle
inBundleExecutionEnvironment
, so thatBundleExecutionEnvironment
knows, which bundle to load first. From there, the loading of additional bundles will be triggered from JavaScript.BundleRegistry
would also expose an API to be used on the native side.Bundles overview
Formats
A JS source code can be stored in one of the following formats:
BasicBundle
- regular bundle format, where all JS is loaded evaluated at once (the results for runningmetro/haul bundle
command).IndexedRAMBundle
- Single-file RAM bundle, where all modules are stored in single file, but loaded and evaluated lazily with constant lookup time (the results for runningmetro/haul ram-bundle --indexed-ram-bundle
command).FileRAMBundle
- Multi-file RAM bundle, where all modules are split into separate filesmetro/haul ram-bundle --platform=android
.DeltaBundle
- Delta bundle used bymetro
.Each bundle format would derive from abstract
Bundle
class with the following public interface:makeJavaScriptApi
would be responsible for preparing the JS environment, providing globals (likenativeRequire
) etc.load
would evaluate bundle's source code in given runtime.Host bundle
Format:
BasicBundle
.Content: Runtime logic and the only point, where
AppRegistry.registerComponent
should be present. Once theappSwitch
event is received it should take new App's root component from globalBundleRegistry
and render it.Notes:
BundleRegistry.initializeHost();
before doing anything.DLL bundles
Format:
BasicBundle
/IndexedRAMBundle
/FileRAMBundle
.Content: Common dependencies and shared logic.
Notes: React Native app can use 0, 1 or more DLL bundles. It's up to the developer to deicide which amount of DLL is the best for their use case and properly split them.
App bundles
Format:
BasicBundle
/IndexedRAMBundle
/FileRAMBundle
.Content: Application logic, components with root component exported as default export:
Notes: React Native app can use 1 or more app bundles. It's up to the developer to deicide how to split those.
Backwards compatibility
Be default
BundleRegistry
would be running in single-bundle mode and support single-bundle application regardless of bundle format. CallingBundleRegistry.initializeHost()
from JavaScript effectively switches into multi-bundle mode. This means that the older apps or apps that don't use multi-bundle functionality should work the same.Single vs multi-
JSContext
modeEach App bundle and Host bundle would have it's own
JSContext
assigned. All of thoseJSContext
s would be in the same group to allow for cross-JSContext
communication in a synchronous manner. This is to ensure that when app bundle is loaded and it's root component is added toBundleRegistry
, the Host bundle running in HostJSContext
can obtain theJSValueRef
to the root component and render it.The trigger to load new bundle (DLL or app) will can be done from both JS and Native code and the developer can decide if they want to load bundle in current execution environment (same
JSContext
) or a different one, but calling appropriate function:If
isAppBundle
istrue
, it will notify the Host bundle in HostJSContext
(which can be the same asJSContext
to load bundle to ifloadInCurrentEnv
is used) to use new App root component and render it.WIP: figure out how to provide cross-
JSContext
communication ifJSContext
s are running on separate threads.Bundle creation on native side
On native side the decision which bundle format to choose would be made in implementation of the following functions:
CatalystInstanceImpl::jniLoadScriptFromFile
CatalystInstanceImpl::jniLoadScriptFromDeltaBundle
CatalystInstanceImpl::jniLoadScriptFromFile
CatalystInstanceImpl::jniLoadScriptFromAssets
Instead of creating
RAMBundleRegistry
,JSDeltaBundleClientRAMBundle
or passingJSBigString
would create an instance ofBasicBundle
/IndexedRAMBundle
/FileRAMBundle
/DeltaBundle
and move it asstd::unique_ptr<Bundle>
toInstance::loadBundle
, which would evaluate the given bundleBundleRegistry
.Note: Source URL would be stored in instance of
Bundle
for later retrieval.Bundle creation
To create Host, DLL and App bundles, we would use Webpack's
DllPlugin
andDllReferencePlugin
together some custom logic in Haul.Discussion points
At this point we need to gather as many feedback regarding the native implementation and architecture as possible. Those changes will affect the core functionality of React Native so it is crucial to make sure nothing is broken. There might be small nuances, that I'm not aware in
ReactCommon
orCxxReact
- if you know about any of those, please share it with us.Other open questions are:
Instance::initializeBridge
?JSContext
communication and data sharing in user space?Co-Authored with @dratwas and @joeblynch
cc: @matthargett @nparashuram @fkgozali @TheSavior @cpojer @kelset @thymikee @dratwas @grabbou @satya164
The text was updated successfully, but these errors were encountered: