Skip to content
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

Performance with lots of entities #22

Closed
RJ opened this issue Jan 29, 2025 · 10 comments · Fixed by #32
Closed

Performance with lots of entities #22

RJ opened this issue Jan 29, 2025 · 10 comments · Fixed by #32
Labels
A-Control-Flow Control flow systems like behavior trees C-Performance A change motivated by improving speed, memory usage or compile times
Milestone

Comments

@RJ
Copy link
Contributor

RJ commented Jan 29, 2025

Looks like adding an entity to a behavior tree averages about 5-6 additional entities being spawned, for the various observers.
Trigger functions for the OnRun, OnRunResult, OnChildResult, etc.

When i spawn 1000 of my ship entities with a small behavior tree (a hierarchy of ~7 entities) i end up with over 40,000 entities created. ie, 40 entities for every one of my character entities. This includes the behavior tree nodes plus all their observers.

Here I am running 3000 ships (128k entities), each ship running the OrbitKeeper action, which is a PID controller to maintain a stable orbit within the (simplified) newtonian gravity physics environment.

Video: 3000 characters each with a behavior tree
https://github.com/user-attachments/assets/94ef5cab-b8c1-4720-a67b-444aa7b5238e

I have noticed a slight fps reduction with higher entity counts. I tested it by just putting the main executor action component directly on the character so the same systems would be running, see inline comments for fps change:

// toggle to try with/without behavior tree.
let use_bt = true;
if !use_bt {
    // hack to avoid using the full behavior tree, and directly using executor component.
    // release mode,
    // 3000 ships that orbit, 144fps
    // 4000 ships that orbit, ~110fps (44k entities)
    let ship = commands
        .spawn((
            Ship,
            Position::new(pos),
            Rotation::radians(rand_rot),
            PlaceInOrbit::around(rolodex.earth).with_clockwise_direction(),
        ))
        .id();
    use beet::prelude::*;
    commands.entity(ship).insert((
        executors::OrbitKeeperExecutor::new(rolodex.earth)
            .with_altitude(altitude)
            .with_clockwise_direction(),
        // add these to pretend it's running in a behavior tree:
        TargetEntity(ship),
        Running,
    ));
} else {
    // using behaviour tree:
    // release mode,
    //  2000 ships that orbit, 144fps
    //  3000 ships that orbit, ~120fps (128k entities)
    //  4000 ships that orbit, ~99fps (171k entities)
    let b = behaviour::Orbit::new(rolodex.earth)
        .with_altitude(altitude)
        .with_clockwise_direction();
    commands
        .spawn((
            Ship,
            Position::new(pos),
            Rotation::radians(rand_rot),
            PlaceInOrbit::around(rolodex.earth).with_clockwise_direction(),
        ))
        .install_behaviour(b);
}

The orbiting behavior is steady state, it basically runs forever, never triggering success or failure once it gets going.
So there shouldn't really be any observers triggering.

Wondering if the overhead of having lots of entities created is generally slowing things down, or what.
Filing this in case anyone has any insight. I will endeavour to update it when I do some proper profiling.

@mrchantey
Copy link
Owner

Nice find, this will be an important one to get right.

The solution here may be a kind of SharedTree, for example a single tree that is used for each of these agents and something like OnRun(pub Entity) instead of TargetEntity would determine which agent it is running for, maybe the long running actions would need to maintain a list of currently running agents instead of their own Running component.

This would also create a clear division between what should be AgentState and TreeState, ie Fuel lives on the agent and DesiredOrbitAltitude may live on a behavior.

I have considered OnRun and OnRunResult being generic with default=(), may be time to revisit that so we can reuse the same SequenceFlow etc.

@RJ
Copy link
Contributor Author

RJ commented Jan 30, 2025

making the observers for OnRun etc global would already cut down on entity count quite a bit.
The trigger events would need to carry the target_entity, and also the tree_entity (?) to use to fetch the relevant action component from the behavior tree (the entity that the observer is currently triggered on).

from a consumer of beet's API this feels like a pretty neutral change. doesn't really add or remove any boilerplate in my code.

Before: (per-entity observers)

#[derive(Clone, Component, Debug, Reflect, Action)]
#[require(ContinueRun, RootIsTargetEntity)]
#[observers(on_run)]
pub struct OrbitKeeperExecutor {
  stuff: u32, // etc
}


fn on_run(
    trigger: Trigger<OnRun>,
    q: Query<(&TargetEntity, &OrbitKeeperExecutor)>,
    q_ships: Query<&Position, With<Ship>>,
    mut commands: Commands,
) {
    let Ok((target_entity, orbit_keeper)) = q.get(trigger.entity()) else {
        warn!("OrbitKeeper: Entity not found");
        return;
    };
    let ship_pos = q_ships.get(target_entity.0).unwrap();
    // so something with the ship for OnRun...
}

After: (global observers)

#[derive(Clone, Component, Debug, Reflect, Action)]
#[require(ContinueRun, RootIsTargetEntity)]
#[observers(on_run)]
pub struct OrbitKeeperExecutor {
  stuff: u32, // etc
}


fn on_run(
    trigger: Trigger<OnRun>,
    q: Query<&OrbitKeeperExecutor>,
    q_ships: Query<&Position, With<Ship>>,
    mut commands: Commands,
) {
    let OnRun{ target_entity, tree_entity } = trigger.event();
    let Ok(orbit_keeper) = q.get(tree_entity) else {
        warn!("OrbitKeeper: Entity not found");
        return;
    };
    let ship_pos = q_ships.get(target_entity).unwrap();
    // so something with the ship for OnRun...
}

In fact this probably reduces boilerplate, since i always need the target entity and sometimes don't care about fetching the action component in OnRun, if i'm just doing a bit of logging – in which case it's one fewer Query.

@RJ
Copy link
Contributor Author

RJ commented Jan 30, 2025

Moving to a shared tree – ie, one copy of each unique behavior tree in the app – seems a bit more complex, since at the moment when i spawn a behavior tree for a ship, i randomly pick an altitude and store that in the action component: OrbitKeeperExecutor::new().with_altitude(...). So each behavior tree is different, since it has different orbital parameters in its action components.

It's also very convenient at the moment that you can just recursively despawn a behavior tree and it cleans up any intermediate state, without worrying about it polluting the target entity.

@mrchantey
Copy link
Owner

mrchantey commented Jan 31, 2025

I really like this solution of OnRun (and probably OnRunResult) being responsible for providing the entities. It would also close #23 right? No observers to spawn, track and clean up.

@RJ
Copy link
Contributor Author

RJ commented Jan 31, 2025

yes it would close #23 i think, as long as all the triggers did this. i think i saw one relating to ChildResult or something too.

I think perhaps in this scenario#[observers(..)] is unnecessary abstraction?
the user should just register a global observer if they need to react to OnRun. although OnRun etc should be generic over the action component type?

#[derive(Clone, Component, Debug, Reflect, Action)]
#[require(ContinueRun, RootIsTargetEntity)]
#[observers(on_run)]  /// <--- Redundant?
pub struct OrbitKeeperExecutor {
  stuff: u32, // etc
}

// ..


app.add_observer(on_run);

// ..

fn on_run(
    trigger: Trigger<OnRun<OrbitKeeperExecutor>>,  // <-- OnRun<T>
    q: Query<&OrbitKeeperExecutor>,
    q_ships: Query<&Position, With<Ship>>,
    mut commands: Commands,
) {
    let OnRun{ target_entity, tree_entity } = trigger.event();
    // ...
}

@RJ
Copy link
Contributor Author

RJ commented Jan 31, 2025

and does that mean OnRunSuccess should be generic too? ie OnRunSuccess::<T>::success()

@mrchantey
Copy link
Owner

@RJ I really like this 'OnRun Context' proposal

pub struct OnRun{
  agent: Entity,
  tree: Entity
}

The more i think about it the more problems it seems to solve, particularly global observers and deprecating TargetEntity. I suppose it would be spawned something like:

// Use case 1: specify tree, may be a child of the agent or shared
world.trigger(OnRun::new_with_tree(agent_entity,tree_entity));

// Use case 2: agent is root
let agent = world.spawn((
  Name::new("Spaceship"),
  Mesh::new("ship.glb"), 
  SequenceFlow
))
  .with_child(FlyToMoon)
  .id();

world.trigger(OnRun::new(agent));

A really nice part of use case 2 is we get is_root() for free which sounds useful.

impl OnRun{
  /// check if the agent is the same entity as the tree
  pub fn is_root(&self) -> bool{
    self.agent == self.tree
  }
}

Generics

If we went with per-component generic OnRun and OnRunResult we would need an extra mechanism for parents to ungenerically call their children, and for children to ungenerically return results. I'm sure its possible but it is an extra layer of indirection.

Explicit Binding

I feel like there may still be some value in the explicit binding of Components, Observers and Systems, ie #[observers(on_run)].

At the moment I'm thinking about the automatic registration and cleanup of observers/systems to solve #18, i also very commonly get stung by 'I added the component but nothing happened'.

@RJ
Copy link
Contributor Author

RJ commented Jan 31, 2025

without generics for global OnRun observers, each time any component type needed OnRunning the triggers would fire for all components, so each trigger fn would have to do its own check if the OnRun.tree_entity contained a component of the type it was responsible for, which would create additional overhead (not sure how much) and feels a bit wrong, no?

as i understand it, it would mean that my on_run observer defined in the orbit_keeper.rs would end up being triggered whenever any other random component needed an OnRun, and i'd have to filter that out.

@RJ
Copy link
Contributor Author

RJ commented Jan 31, 2025

that said i'm not exactly sure how it would work with generics..

@mrchantey
Copy link
Owner

good point ive replied on discord.

@mrchantey mrchantey added this to the 0.0.5 milestone Feb 1, 2025
@mrchantey mrchantey added A-Control-Flow Control flow systems like behavior trees C-Performance A change motivated by improving speed, memory usage or compile times labels Feb 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Control-Flow Control flow systems like behavior trees C-Performance A change motivated by improving speed, memory usage or compile times
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants