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.
This commit is contained in:
wraitii 2022-01-30 13:33:34 +00:00
parent 0779c64052
commit 4df03ed2d2
10 changed files with 105 additions and 116 deletions

View File

@ -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() {

View File

@ -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)
{

View File

@ -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() });
};
/**

View File

@ -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)

View File

@ -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");

View File

@ -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");

View File

@ -51,7 +51,7 @@
struct ScriptInterface_impl
{
ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr<ScriptContext>& context);
ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr<ScriptContext>& 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<ScriptContext>& context) :
ScriptInterface_impl::ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr<ScriptContext>& 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<ScriptContext>& context) :
m(std::make_unique<ScriptInterface_impl>(nativeScopeName, context))
m(std::make_unique<ScriptInterface_impl>(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<ScriptInterface_impl>(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())

View File

@ -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<ScriptContext>& 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

View File

@ -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<SimulationComman
if (m_EnableOOSLog)
DumpState();
// Start computing AI for the next turn
CmpPtr<ICmpAIManager> 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<ICmpAIManager> cmpAIManager(simContext, SYSTEM_ENTITY);
if (cmpAIManager)
cmpAIManager->PushCommands();
CmpPtr<ICmpCommandQueue> 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<ICmpAIManager> cmpAIManager(simContext, SYSTEM_ENTITY);
if (cmpAIManager)
{
cmpAIManager->StartComputation();
cmpAIManager->PushCommands();
}
// Process all remaining moves
if (cmpPathfinder)
{

View File

@ -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<ScriptInterface> m_ScriptInterface;
JS::PersistentRootedValue m_Obj;
std::vector<Script::StructuredClone > m_Commands;
std::vector<Script::StructuredClone> m_Commands;
};
public:
struct SCommandSets
{
player_id_t player;
std::vector<Script::StructuredClone > commands;
std::vector<Script::StructuredClone> 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<ScriptInterface>("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<CAIWorker>>(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<ScriptContext> m_ScriptContext;
std::shared_ptr<ScriptInterface> 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;