From 4df03ed2d2c5bf4417ef94b047a9093fd8522fb0 Mon Sep 17 00:00:00 2001 From: wraitii Date: Sun, 30 Jan 2022 13:33:34 +0000 Subject: [PATCH] Run the AI in the same Compartment as the simulation. Let the AI access Sim data. This is a paradigm change for AI computation. Historically, the AI was intended to be run in a separate thread from the simulation. The idea was that slow AI wouldn't stop the renderer from being smooth. In that original design, the AI received a copy of the game world and used that to run its logic. This meant the simulation could safely do whatever it wanted in the meantime. This copy was done via AIProxy & AIInterface. This design ended up having significant flaws: - The copying impacts the simulation negatively, particularly because AIProxy subscribes to a lot of messages (sometimes sent exclusively to it). This time cannot be threaded, and impacts MP games without AIs. - Copying the data is increasingly difficult. Modifiers are a headache, LOS is not implemented. Lots of logic is duplicated. The intended benefits of the design also failed to realise somewhat: - The AI was never threaded, and in fact, it is probably better to try and thread Sim + AI from the renderer than just the AI, at which point threading the AI specifically brings little benefit. The new design is much simpler and straighforward, but this has some side-effects: - The AI can now change the simulation. This can be used for cheating, or possibly for a tutorial AI. - The AI runs in the same GC zone as the simulation, which may lead to more frequent Sim GCs (but overall we might expect a reduction in temporary objects). - The AI state was essentially cached, so replacing some functions with Engine.QueryInterface might be slower. The tradeoff should be balanced by lower AIProxy computation times. Future work: - Threading some specific AI tasks could still be worthwhile, but should be done in specific worker threads, allowed to run over several turns if needed. Technical note: the AI 'global' is in its own Realm, which means name collisions with the same are not possible. Other notes: - The RL Interface uses the AI Interface and thus will gradually lose some data there. Given that the RL Interface can now request data however, this should be dine. Refs #5962, #2370 Differential Revision: https://code.wildfiregames.com/D3769 This was SVN commit r26274. --- .../public/simulation/ai/common-api/entity.js | 13 +-- .../public/simulation/components/AIProxy.js | 36 -------- .../simulation/components/ResourceGatherer.js | 11 --- .../simulation/components/ResourceSupply.js | 4 - .../components/interfaces/ResourceGatherer.js | 6 -- .../components/interfaces/ResourceSupply.js | 6 -- source/scriptinterface/ScriptInterface.cpp | 31 ++++++- source/scriptinterface/ScriptInterface.h | 12 ++- source/simulation2/Simulation2.cpp | 19 ++--- .../simulation2/components/CCmpAIManager.cpp | 83 +++++++++++-------- 10 files changed, 105 insertions(+), 116 deletions(-) diff --git a/binaries/data/mods/public/simulation/ai/common-api/entity.js b/binaries/data/mods/public/simulation/ai/common-api/entity.js index 4eea9a37e3..01a4377df4 100644 --- a/binaries/data/mods/public/simulation/ai/common-api/entity.js +++ b/binaries/data/mods/public/simulation/ai/common-api/entity.js @@ -590,6 +590,8 @@ m.Entity = m.Class({ this._entityModif = sharedAI._entitiesModifications.get(entity.id); }, + "queryInterface": function(iid) { return SimEngine.QueryInterface(this.id(), iid) }, + "toString": function() { return "[Entity " + this.id() + " " + this.templateName() + "]"; }, "id": function() { return this._entity.id; }, @@ -679,24 +681,25 @@ m.Entity = m.Class({ }, "resourceSupplyAmount": function() { - return this._entity.resourceSupplyAmount; + return this.queryInterface(Sim.IID_ResourceSupply)?.GetCurrentAmount(); }, "resourceSupplyNumGatherers": function() { - return this._entity.resourceSupplyNumGatherers; + return this.queryInterface(Sim.IID_ResourceSupply)?.GetNumGatherers(); }, "isFull": function() { - if (this._entity.resourceSupplyNumGatherers !== undefined) - return this.maxGatherers() === this._entity.resourceSupplyNumGatherers; + let numGatherers = this.resourceSupplyNumGatherers(); + if (numGatherers) + return this.maxGatherers() === numGatherers; return undefined; }, "resourceCarrying": function() { - return this._entity.resourceCarrying; + return this.queryInterface(Sim.IID_ResourceGatherer)?.GetCarryingStatus(); }, "currentGatherRate": function() { diff --git a/binaries/data/mods/public/simulation/components/AIProxy.js b/binaries/data/mods/public/simulation/components/AIProxy.js index 7e6d33beb5..d398100949 100644 --- a/binaries/data/mods/public/simulation/components/AIProxy.js +++ b/binaries/data/mods/public/simulation/components/AIProxy.js @@ -179,27 +179,6 @@ AIProxy.prototype.OnGarrisonedUnitsChanged = function(msg) this.cmpAIInterface.PushEvent("UnGarrison", { "entity": ent, "holder": this.entity }); }; -AIProxy.prototype.OnResourceSupplyChanged = function(msg) -{ - if (!this.NotifyChange()) - return; - this.changes.resourceSupplyAmount = msg.to; -}; - -AIProxy.prototype.OnResourceSupplyNumGatherersChanged = function(msg) -{ - if (!this.NotifyChange()) - return; - this.changes.resourceSupplyNumGatherers = msg.to; -}; - -AIProxy.prototype.OnResourceCarryingChanged = function(msg) -{ - if (!this.NotifyChange()) - return; - this.changes.resourceCarrying = msg.to; -}; - AIProxy.prototype.OnFoundationProgressChanged = function(msg) { if (!this.NotifyChange()) @@ -305,21 +284,6 @@ AIProxy.prototype.GetFullRepresentation = function() ret.foundationProgress = cmpFoundation.GetBuildPercentage(); } - let cmpResourceSupply = Engine.QueryInterface(this.entity, IID_ResourceSupply); - if (cmpResourceSupply) - { - // Updated by OnResourceSupplyChanged - ret.resourceSupplyAmount = cmpResourceSupply.GetCurrentAmount(); - ret.resourceSupplyNumGatherers = cmpResourceSupply.GetNumGatherers(); - } - - let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); - if (cmpResourceGatherer) - { - // Updated by OnResourceCarryingChanged - ret.resourceCarrying = cmpResourceGatherer.GetCarryingStatus(); - } - let cmpResourceDropsite = Engine.QueryInterface(this.entity, IID_ResourceDropsite); if (cmpResourceDropsite) { diff --git a/binaries/data/mods/public/simulation/components/ResourceGatherer.js b/binaries/data/mods/public/simulation/components/ResourceGatherer.js index 71fded63ee..7b292509d3 100644 --- a/binaries/data/mods/public/simulation/components/ResourceGatherer.js +++ b/binaries/data/mods/public/simulation/components/ResourceGatherer.js @@ -75,8 +75,6 @@ ResourceGatherer.prototype.GiveResources = function(resources) { for (let resource of resources) this.carrying[resource.type] = +resource.amount; - - Engine.PostMessage(this.entity, MT_ResourceCarryingChanged, { "to": this.GetCarryingStatus() }); }; /** @@ -284,8 +282,6 @@ ResourceGatherer.prototype.PerformGather = function(data, lateness) if (cmpStatisticsTracker) cmpStatisticsTracker.IncreaseResourceGatheredCounter(type.generic, status.amount, type.specific); - Engine.PostMessage(this.entity, MT_ResourceCarryingChanged, { "to": this.GetCarryingStatus() }); - if (!this.CanCarryMore(type.generic)) this.StopGathering("InventoryFilled"); else if (status.exhausted) @@ -399,17 +395,12 @@ ResourceGatherer.prototype.CommitResources = function(target) return; let change = cmpResourceDropsite.ReceiveResources(this.carrying, this.entity); - let changed = false; for (let type in change) { this.carrying[type] -= change[type]; if (this.carrying[type] == 0) delete this.carrying[type]; - changed = true; } - - if (changed) - Engine.PostMessage(this.entity, MT_ResourceCarryingChanged, { "to": this.GetCarryingStatus() }); }; /** @@ -420,8 +411,6 @@ ResourceGatherer.prototype.CommitResources = function(target) ResourceGatherer.prototype.DropResources = function() { this.carrying = {}; - - Engine.PostMessage(this.entity, MT_ResourceCarryingChanged, { "to": this.GetCarryingStatus() }); }; /** diff --git a/binaries/data/mods/public/simulation/components/ResourceSupply.js b/binaries/data/mods/public/simulation/components/ResourceSupply.js index 9bc651c8b8..56d62ad7cb 100644 --- a/binaries/data/mods/public/simulation/components/ResourceSupply.js +++ b/binaries/data/mods/public/simulation/components/ResourceSupply.js @@ -277,7 +277,6 @@ ResourceSupply.prototype.AddGatherer = function(gathererID) return true; this.gatherers.push(gathererID); - Engine.PostMessage(this.entity, MT_ResourceSupplyNumGatherersChanged, { "to": this.GetNumGatherers() }); return true; }; @@ -310,10 +309,7 @@ ResourceSupply.prototype.RemoveGatherer = function(gathererID) { let index = this.gatherers.indexOf(gathererID); if (index != -1) - { this.gatherers.splice(index, 1); - Engine.PostMessage(this.entity, MT_ResourceSupplyNumGatherersChanged, { "to": this.GetNumGatherers() }); - } index = this.activeGatherers.indexOf(gathererID); if (index == -1) diff --git a/binaries/data/mods/public/simulation/components/interfaces/ResourceGatherer.js b/binaries/data/mods/public/simulation/components/interfaces/ResourceGatherer.js index 44529534ca..3c5be75d57 100644 --- a/binaries/data/mods/public/simulation/components/interfaces/ResourceGatherer.js +++ b/binaries/data/mods/public/simulation/components/interfaces/ResourceGatherer.js @@ -1,7 +1 @@ Engine.RegisterInterface("ResourceGatherer"); - -/** - * Message of the form { "to": [{ "type": string, "amount": number, "max": number }] } - * sent from ResourceGatherer component whenever the amount of carried resources changes. - */ -Engine.RegisterMessageType("ResourceCarryingChanged"); diff --git a/binaries/data/mods/public/simulation/components/interfaces/ResourceSupply.js b/binaries/data/mods/public/simulation/components/interfaces/ResourceSupply.js index 7bed5273da..7c9862486f 100644 --- a/binaries/data/mods/public/simulation/components/interfaces/ResourceSupply.js +++ b/binaries/data/mods/public/simulation/components/interfaces/ResourceSupply.js @@ -5,9 +5,3 @@ Engine.RegisterInterface("ResourceSupply"); * sent from ResourceSupply component whenever the supply level changes. */ Engine.RegisterMessageType("ResourceSupplyChanged"); - -/** - * Message of the form { "to": number } - * sent from ResourceSupply component whenever the number of gatherer changes. - */ -Engine.RegisterMessageType("ResourceSupplyNumGatherersChanged"); diff --git a/source/scriptinterface/ScriptInterface.cpp b/source/scriptinterface/ScriptInterface.cpp index 67a319ba1f..4661473fa5 100644 --- a/source/scriptinterface/ScriptInterface.cpp +++ b/source/scriptinterface/ScriptInterface.cpp @@ -51,7 +51,7 @@ struct ScriptInterface_impl { - ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr& context); + ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr& context, JS::Compartment* compartment); ~ScriptInterface_impl(); // Take care to keep this declaration before heap rooted members. Destructors of heap rooted @@ -299,7 +299,7 @@ bool ScriptInterface::Math_random(JSContext* cx, uint argc, JS::Value* vp) return true; } -ScriptInterface_impl::ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr& context) : +ScriptInterface_impl::ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr& context, JS::Compartment* compartment) : m_context(context), m_cx(context->GetGeneralJSContext()), m_glob(context->GetGeneralJSContext()), m_nativeScope(context->GetGeneralJSContext()) { JS::RealmCreationOptions creationOpt; @@ -307,6 +307,13 @@ ScriptInterface_impl::ScriptInterface_impl(const char* nativeScopeName, const st creationOpt.setPreserveJitCode(true); // Enable uneval creationOpt.setToSourceEnabled(true); + + if (compartment) + creationOpt.setExistingCompartment(compartment); + else + // This is the default behaviour. + creationOpt.setNewCompartmentAndZone(); + JS::RealmOptions opt(creationOpt, JS::RealmBehaviors{}); m_glob = JS_NewGlobalObject(m_cx, &global_class, nullptr, JS::OnNewGlobalHookOption::FireOnNewGlobalHook, opt); @@ -340,7 +347,7 @@ ScriptInterface_impl::~ScriptInterface_impl() } ScriptInterface::ScriptInterface(const char* nativeScopeName, const char* debugName, const std::shared_ptr& context) : - m(std::make_unique(nativeScopeName, context)) + m(std::make_unique(nativeScopeName, context, nullptr)) { // Profiler stats table isn't thread-safe, so only enable this on the main thread if (Threading::IsMainThread()) @@ -354,6 +361,24 @@ ScriptInterface::ScriptInterface(const char* nativeScopeName, const char* debugN JS::SetRealmPrivate(JS::GetObjectRealmOrNull(rq.glob), (void*)&m_CmptPrivate); } +ScriptInterface::ScriptInterface(const char* nativeScopeName, const char* debugName, const ScriptInterface& neighbor) +{ + ScriptRequest nrq(neighbor); + JS::Compartment* comp = JS::GetCompartmentForRealm(JS::GetCurrentRealmOrNull(nrq.cx)); + m = std::make_unique(nativeScopeName, neighbor.GetContext(), comp); + + // Profiler stats table isn't thread-safe, so only enable this on the main thread + if (Threading::IsMainThread()) + { + if (g_ScriptStatsTable) + g_ScriptStatsTable->Add(this, debugName); + } + + ScriptRequest rq(this); + m_CmptPrivate.pScriptInterface = this; + JS::SetRealmPrivate(JS::GetObjectRealmOrNull(rq.glob), (void*)&m_CmptPrivate); +} + ScriptInterface::~ScriptInterface() { if (Threading::IsMainThread()) diff --git a/source/scriptinterface/ScriptInterface.h b/source/scriptinterface/ScriptInterface.h index 6bb744b606..d41bdad2b2 100644 --- a/source/scriptinterface/ScriptInterface.h +++ b/source/scriptinterface/ScriptInterface.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -85,6 +85,16 @@ public: */ ScriptInterface(const char* nativeScopeName, const char* debugName, const std::shared_ptr& context); + /** + * Alternate constructor. This creates the new Realm in the same Compartment as the neighbor scriptInterface. + * This means that data can be freely exchanged between these two script interfaces without cloning. + * @param nativeScopeName Name of global object that functions (via ScriptFunction::Register) will + * be placed into, as a scoping mechanism; typically "Engine" + * @param debugName Name of this interface for CScriptStats purposes. + * @param scriptInterface 'Neighbor' scriptInterface to share a compartment with. + */ + ScriptInterface(const char* nativeScopeName, const char* debugName, const ScriptInterface& neighbor); + ~ScriptInterface(); struct CmptPrivate diff --git a/source/simulation2/Simulation2.cpp b/source/simulation2/Simulation2.cpp index 6864dfa3c7..13045a6ceb 100644 --- a/source/simulation2/Simulation2.cpp +++ b/source/simulation2/Simulation2.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -510,11 +510,6 @@ void CSimulation2Impl::Update(int turnLength, const std::vector cmpAIManager(m_SimContext, SYSTEM_ENTITY); - if (cmpAIManager) - cmpAIManager->StartComputation(); - ++m_TurnNumber; } @@ -535,10 +530,6 @@ void CSimulation2Impl::UpdateComponents(CSimContext& simContext, fixed turnLengt componentManager.BroadcastMessage(msgTurnStart); } - // Push AI commands onto the queue before we use them - CmpPtr cmpAIManager(simContext, SYSTEM_ENTITY); - if (cmpAIManager) - cmpAIManager->PushCommands(); CmpPtr cmpCommandQueue(simContext, SYSTEM_ENTITY); if (cmpCommandQueue) @@ -583,6 +574,14 @@ void CSimulation2Impl::UpdateComponents(CSimContext& simContext, fixed turnLengt // Clean up any entities destroyed during the simulation update componentManager.FlushDestroyedComponents(); + // Compute AI immediately at turn's end. + CmpPtr cmpAIManager(simContext, SYSTEM_ENTITY); + if (cmpAIManager) + { + cmpAIManager->StartComputation(); + cmpAIManager->PushCommands(); + } + // Process all remaining moves if (cmpPathfinder) { diff --git a/source/simulation2/components/CCmpAIManager.cpp b/source/simulation2/components/CCmpAIManager.cpp index fd30387230..fd36a128ce 100644 --- a/source/simulation2/components/CCmpAIManager.cpp +++ b/source/simulation2/components/CCmpAIManager.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -57,27 +57,24 @@ extern void QuitEngine(); * AI is primarily scripted, and the CCmpAIManager component defined here * takes care of managing all the scripts. * - * To avoid slow AI scripts causing jerky rendering, they are run in a background - * thread (maintained by CAIWorker) so that it's okay if they take a whole simulation - * turn before returning their results (though preferably they shouldn't use nearly - * that much CPU). + * The original idea was to run CAIWorker in a separate thread to prevent + * slow AIs from impacting framerate. However, copying the game-state every turn + * proved difficult and rather slow itself (and isn't threadable, obviously). + * For these reasons, the design was changed to a single-thread, same-compartment, different-realm design. + * The AI can therefore directly use the simulation data via the 'Sim' & 'SimEngine' globals. + * As a result, a lof of the code is still designed to be "thread-ready", but this no longer matters. * - * CCmpAIManager grabs the world state after each turn (making use of AIInterface.js - * and AIProxy.js to decide what data to include) then passes it to CAIWorker. - * The AI scripts will then run asynchronously and return a list of commands to execute. - * Any attempts to read the command list (including indirectly via serialization) - * will block until it's actually completed, so the rest of the engine should avoid - * reading it for as long as possible. + * TODO: despite the above, it would still be useful to allow the AI to run tasks asynchronously (and off-thread). + * This could be implemented by having a separate JS runtime in a different thread, + * that runs tasks and returns after a distinct # of simulation turns (to maintain determinism). * - * JS::Values are passed between the game and AI threads using Script::StructuredClone. - * - * TODO: actually the thread isn't implemented yet, because performance hasn't been - * sufficiently problematic to justify the complexity yet, but the CAIWorker interface - * is designed to hopefully support threading when we want it. + * Note also that the RL Interface, by default, uses the 'AI representation'. + * This representation, alimented by the JS AIInterface/AIProxy tandem, is likely to grow smaller over time + * as the AI uses more sim data directly. */ /** - * Implements worker thread for CCmpAIManager. + * AI computation orchestator for CCmpAIManager. */ class CAIWorker { @@ -206,27 +203,43 @@ private: std::shared_ptr m_ScriptInterface; JS::PersistentRootedValue m_Obj; - std::vector m_Commands; + std::vector m_Commands; }; public: struct SCommandSets { player_id_t player; - std::vector commands; + std::vector commands; }; CAIWorker() : - m_ScriptInterface(new ScriptInterface("Engine", "AI", g_ScriptContext)), m_TurnNum(0), m_CommandsComputed(true), m_HasLoadedEntityTemplates(false), - m_HasSharedComponent(false), - m_EntityTemplates(g_ScriptContext->GetGeneralJSContext()), - m_SharedAIObj(g_ScriptContext->GetGeneralJSContext()), - m_PassabilityMapVal(g_ScriptContext->GetGeneralJSContext()), - m_TerritoryMapVal(g_ScriptContext->GetGeneralJSContext()) + m_HasSharedComponent(false) { + } + + ~CAIWorker() + { + // Init will always be called. + JS_RemoveExtraGCRootsTracer(m_ScriptInterface->GetGeneralJSContext(), Trace, this); + } + + void Init(const ScriptInterface& simInterface) + { + // Create the script interface in the same compartment as the simulation interface. + // This will allow us to directly share data from the sim to the AI (and vice versa, should the need arise). + m_ScriptInterface = std::make_shared("Engine", "AI", simInterface); + + ScriptRequest rq(m_ScriptInterface); + + m_EntityTemplates.init(rq.cx); + m_SharedAIObj.init(rq.cx); + m_PassabilityMapVal.init(rq.cx); + m_TerritoryMapVal.init(rq.cx); + m_ScriptInterface->ReplaceNondeterministicRNG(m_RNG); @@ -234,7 +247,15 @@ public: JS_AddExtraGCRootsTracer(m_ScriptInterface->GetGeneralJSContext(), Trace, this); - ScriptRequest rq(m_ScriptInterface); + { + ScriptRequest simrq(simInterface); + // Register the sim globals for easy & explicit access. Mark it replaceable for hotloading. + JS::RootedValue global(rq.cx, simrq.globalValue()); + m_ScriptInterface->SetGlobal("Sim", global, true); + JS::RootedValue scope(rq.cx, JS::ObjectValue(*simrq.nativeScope.get())); + m_ScriptInterface->SetGlobal("SimEngine", scope, true); + } + #define REGISTER_FUNC_NAME(func, name) \ ScriptFunction::Register<&CAIWorker::func, ScriptInterface::ObjectFromCBData>(rq, name); @@ -253,11 +274,7 @@ public: // Globalscripts may use VFS script functions m_ScriptInterface->LoadGlobalScripts(); - } - ~CAIWorker() - { - JS_RemoveExtraGCRootsTracer(m_ScriptInterface->GetGeneralJSContext(), Trace, this); } bool HasLoadedEntityTemplates() const { return m_HasLoadedEntityTemplates; } @@ -814,10 +831,6 @@ private: } } - // Take care to keep this declaration before heap rooted members. Destructors of heap rooted - // members have to be called before the context destructor. - std::shared_ptr m_ScriptContext; - std::shared_ptr m_ScriptInterface; boost::rand48 m_RNG; u32 m_TurnNum; @@ -870,6 +883,8 @@ public: virtual void Init(const CParamNode& UNUSED(paramNode)) { + m_Worker.Init(GetSimContext().GetScriptInterface()); + m_TerritoriesDirtyID = 0; m_TerritoriesDirtyBlinkingID = 0; m_JustDeserialized = false;