concinnity_core/behavior/system/eval.rs
1// The read phase of a tick: what the bodies may see, the buffers they run
2// against, and how those buffers are worked through.
3//
4// Bodies never observe another body's same-tick writes (the apply phase runs
5// after every body), which is what makes evaluation order unobservable and lets
6// a host fan the runs across threads.
7
8use alloc::vec::Vec;
9
10use super::BehaviorSystem;
11use super::instance::Instance;
12use crate::behavior::{Effect, Program, Val, View, exec};
13use crate::components::Transform;
14use crate::ecs::{ComponentStorage, Entity, EntityByName, PipelineContext};
15
16// Below this many firing instances the fan-out costs more than the work.
17pub(super) const PARALLEL_EVAL_MIN_JOBS: usize = 64;
18
19/// One worker's share of a tick's evaluation: a contiguous slice of the tick's
20/// job list, its own effect and trace buffers, and its own binding scratch.
21/// Opaque to a scheduler, which only hands each bucket to the closure it was
22/// given. Everything inside keeps its capacity across ticks.
23#[derive(Debug, Default)]
24pub struct EvalBucket {
25 pub(super) jobs: core::ops::Range<usize>,
26 pub(super) effects: Vec<Effect>,
27 pub(super) produced: Vec<(usize, Option<Entity>, usize)>,
28 pub(super) fired: Vec<(usize, Vec<u32>)>,
29 pub(super) bindings: Vec<Option<Val>>,
30}
31
32/// Runs a tick's evaluation buckets, in any order and on any thread.
33///
34/// The buckets share nothing: each reads only the tick's starting state and
35/// writes only its own effects, so a host with a thread pool can work them
36/// through in parallel. A world whose host installs none evaluates every run on
37/// the calling thread.
38pub trait EvalScheduler: core::fmt::Debug + Send {
39 /// How many buckets to split a tick's firing instances into.
40 fn workers(&self) -> usize;
41
42 /// Apply `eval` to every bucket, then return.
43 fn run(&self, buckets: &mut [EvalBucket], eval: &(dyn Fn(&mut EvalBucket) + Send + Sync));
44}
45
46// The entity sets a body iterates this tick, resolved before anything runs.
47//
48// Single-entity reads (a name, a position, a liveness test) are not here: no
49// body runs while the world is mutable, so they read the world directly and
50// cost one lookup each instead of a whole-world copy per tick.
51#[derive(Debug, Default)]
52pub(super) struct Snapshot {
53 // Per program, per declared query, in stable order.
54 pub(super) queries: Vec<Vec<Vec<Entity>>>,
55 // Per program, the entities its scope matched, in stable order.
56 pub(super) scoped: Vec<Vec<Entity>>,
57}
58
59// What a body run reads that the tick, rather than the body, determines: the
60// per-tick half of the behavior VM's `View`. Everything here is shared and
61// immutable, so evaluation can fan across workers; single-entity reads (a name,
62// a position, a liveness test) read the storage directly and cost one lookup
63// each instead of a whole-world copy per tick.
64pub(super) struct EvalCtx<'a> {
65 pub(super) components: &'a ComponentStorage,
66 // Resolved once per tick rather than per name lookup.
67 pub(super) names: Option<&'a EntityByName>,
68 pub(super) snapshot: &'a Snapshot,
69 pub(super) programs: &'a [Program],
70 pub(super) instances: &'a [Vec<Instance>],
71 pub(super) vars: &'a [Val],
72 pub(super) dt: f32,
73 pub(super) elapsed: f32,
74 pub(super) tracing: bool,
75}
76
77// Run one instance's body against the tick's starting state, appending its
78// effects to `out`. Returns how many it appended, plus the nodes it executed
79// when tracing.
80//
81// `bindings` is the caller's reused buffer: resized to the body's compiled
82// binding high-water mark and cleared per run, so however many instances fire
83// a tick, binding scratch costs zero allocations in steady state. (The
84// previous per-run frame-arena grab exhausted the reserve on behavior-heavy
85// worlds and degraded to contended heap allocation across eval workers.)
86pub(super) fn eval_one(
87 ec: &EvalCtx<'_>,
88 bindings: &mut Vec<Option<Val>>,
89 i: usize,
90 entity: Option<Entity>,
91 out: &mut Vec<Effect>,
92) -> Option<(usize, Vec<u32>)> {
93 let locals = ec.instances[i]
94 .iter()
95 .find(|inst| inst.entity == entity)
96 .map(|inst| inst.locals.as_slice())?;
97 bindings.clear();
98 bindings.resize(ec.programs[i].bindings, None);
99 let mut nodes: Option<Vec<u32>> = ec.tracing.then(Vec::new);
100 let before = out.len();
101 let mut view = View {
102 dt: ec.dt,
103 elapsed: ec.elapsed,
104 vars: ec.vars,
105 locals,
106 bindings: bindings.as_mut_slice(),
107 queries: &ec.snapshot.queries[i],
108 // A name index entry can outlive its entity, so each is confirmed.
109 by_name: &|id| {
110 ec.names
111 .and_then(|n| n.get(id))
112 .filter(|e| ec.components.is_alive(*e))
113 },
114 transforms: &|e| ec.components.get::<Transform>(e).copied(),
115 alive: &|e| ec.components.is_alive(e),
116 self_entity: entity,
117 trace: &mut nodes,
118 };
119 exec(&ec.programs[i].body, &mut view, out);
120 Some((out.len() - before, nodes.unwrap_or_default()))
121}
122
123impl BehaviorSystem {
124 // Entities carrying every one of these component tags, filled into `out`
125 // in stable order. Column order shifts as entities are removed
126 // (swap-remove), so the result is sorted: an unstable iteration order
127 // would make a body's effects depend on unrelated despawns. `scratch`
128 // holds each extra tag's sorted set; both buffers keep their capacity.
129 pub(super) fn entities_matching_into(
130 ctx: &PipelineContext,
131 tags: &[u8],
132 scratch: &mut Vec<Entity>,
133 out: &mut Vec<Entity>,
134 ) {
135 out.clear();
136 let Some((first, rest)) = tags.split_first() else {
137 return;
138 };
139 out.extend_from_slice(ctx.entities_with_tag(*first));
140 for tag in rest {
141 scratch.clear();
142 scratch.extend_from_slice(ctx.entities_with_tag(*tag));
143 scratch.sort_unstable_by_key(|e| e.to_bits());
144 out.retain(|e| {
145 scratch
146 .binary_search_by_key(&e.to_bits(), |o| o.to_bits())
147 .is_ok()
148 });
149 }
150 out.sort_unstable_by_key(|e| e.to_bits());
151 }
152
153 // Refill the reused snapshot with this tick's entity sets. The shape
154 // (programs and their query counts) is fixed after `init`, so in steady
155 // state every vector here just refills in place.
156 pub(super) fn gather(&mut self, ctx: &PipelineContext, snapshot: &mut Snapshot) {
157 snapshot.queries.resize_with(self.programs.len(), Vec::new);
158 snapshot.scoped.resize_with(self.programs.len(), Vec::new);
159 for (i, p) in self.programs.iter().enumerate() {
160 snapshot.queries[i].resize_with(p.queries.len(), Vec::new);
161 for (q, tags) in p.queries.iter().enumerate() {
162 Self::entities_matching_into(
163 ctx,
164 tags,
165 &mut self.tag_scratch,
166 &mut snapshot.queries[i][q],
167 );
168 }
169 Self::entities_matching_into(
170 ctx,
171 &p.scope,
172 &mut self.tag_scratch,
173 &mut snapshot.scoped[i],
174 );
175 }
176 }
177}