Skip to content

Commit eaa13df

Browse files
perf(hydro_lang): avoid allocations in sim scheduler's ready-list partitioning
The simulator's `LaunchedSim::step` spent significant time in `Iterator::partition`, which allocated two fresh `Vec`s per call (four call sites, invoked on every scheduler step and for every async DFIR that made progress). Each partition predicate additionally performed a `HashMap` lookup keyed by `(LocationId, Option<u32>)`, cloning a `LocationId` (a boxed allocation for `Tick` variants) for every element checked. ## Changes - **Introduce `SimTick` and `SimObservation` structs** that store each tick's / observation's hooks (and inline hooks) *inline*, replacing the anonymous `(LocationId, Option<u32>, DfirErased)` tuples plus the shared `hooks` / `inline_hooks` maps on `LaunchedSim`. The maps are drained once in `start()` (where the generated code's serialized-location string keys can be used directly, before any `LocationId` deserialization), so the scheduler's hot paths no longer do any keyed lookups or `LocationId` clones. A `debug_assert!` checks that every hook is claimed by a tick or observation. - **Replace `drain(..).partition(...)` with `Vec::extract_if`**, which moves matching elements between the ready/not-ready lists in place without allocating intermediate vectors. - **Store the tick's parent location** (`SimTick::parent_location`) instead of the full tick location, since the full location was only ever used to extract the parent for readiness matching; this removes an `unreachable!()` destructure from the hot loop. - **Factor the shared readiness predicate** into `hook_can_release` and `SimTick::can_run` / `SimObservation::can_run` helper methods, so the scheduling conditions are named and documented instead of duplicated inline. - Misc cleanup: `run_hooks` now takes `&mut [Box<dyn SimHook>]`, the empty-`default_hooks` scratch `Vec` for observations is gone, and field names are pluralized (`possibly_ready_observations`, etc.) for consistency. Verified with `cargo check`/`clippy --all-features` (clean) and `cargo test --features sim sim::` (36 passed). Co-authored-by: Infinity 🤖 <infinity@hydro.run> PR: #3116
1 parent d8fe20a commit eaa13df

1 file changed

Lines changed: 141 additions & 114 deletions

File tree

hydro_lang/src/sim/compiled.rs

Lines changed: 141 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ use crate::compile::builder::ExternalPortId;
4646
use crate::live_collections::stream::{ExactlyOnce, NoOrder, Ordering, Retries, TotalOrder};
4747
use crate::location::dynamic::LocationId;
4848
use crate::sim::graph::{SimExternalPort, SimExternalPortRegistry};
49-
use crate::sim::runtime::SimHook;
49+
use crate::sim::runtime::{SimHook, SimInlineHook};
5050

5151
struct QuiescenceState {
5252
/// Set to true when the scheduler reaches quiescence; reset to false when new input is sent.
@@ -663,38 +663,60 @@ impl<'a> CompiledSimInstance<'a> {
663663
/// Consumes this instance and constructs the [`LaunchedSim`] state struct, which is
664664
/// advanced incrementally via [`LaunchedSim::step`].
665665
fn start<W: std::io::Write>(mut self, log_override: Option<W>) -> LaunchedSim<W> {
666-
let (async_dfirs, tick_dfirs, hooks, inline_hooks) = self.dylib_result.take().unwrap();
667-
668-
let not_ready_observation = async_dfirs
669-
.iter()
670-
.map(|(lid, c_id, _)| (serde_json::from_str(lid).unwrap(), *c_id))
666+
let (async_dfirs, tick_dfirs, mut hooks, mut inline_hooks) =
667+
self.dylib_result.take().unwrap();
668+
669+
// The generated code keys hooks and tick DFIRs by the same serialized location
670+
// strings, so we can move each tick's / observation's hooks out of the maps and
671+
// attach them directly. This lets the scheduler's hot paths avoid keyed lookups
672+
// (which would clone `LocationId`s) entirely.
673+
let not_ready_ticks = tick_dfirs
674+
.into_iter()
675+
.map(|(lid, cluster_id, dfir)| {
676+
let location: LocationId = serde_json::from_str(lid).unwrap();
677+
let LocationId::Tick(_, parent_location) = location else {
678+
unreachable!("tick DFIRs are always keyed by a tick location")
679+
};
680+
SimTick {
681+
parent_location: *parent_location,
682+
cluster_id,
683+
dfir,
684+
hooks: hooks
685+
.remove(&(lid, cluster_id))
686+
.expect("every tick DFIR must have at least one hook"),
687+
inline_hooks: inline_hooks.remove(&(lid, cluster_id)).unwrap_or_default(),
688+
}
689+
})
671690
.collect();
672691

673692
let quiescence = CURRENT_SIM_CONNECTIONS.with(|connections| {
674693
let connections = connections.borrow();
675694
connections.quiescence.clone()
676695
});
677696

697+
let not_ready_observations = async_dfirs
698+
.iter()
699+
.map(|(lid, cluster_id, _)| SimObservation {
700+
location: serde_json::from_str(lid).unwrap(),
701+
cluster_id: *cluster_id,
702+
hooks: hooks.remove(&(*lid, *cluster_id)).unwrap_or_default(),
703+
})
704+
.collect();
705+
706+
debug_assert!(
707+
hooks.is_empty() && inline_hooks.is_empty(),
708+
"all hooks should belong to either a tick DFIR or a top-level location"
709+
);
710+
678711
LaunchedSim {
679712
async_dfirs: async_dfirs
680713
.into_iter()
681714
.map(|(lid, c_id, dfir)| (serde_json::from_str(lid).unwrap(), c_id, dfir))
682715
.collect(),
683716
possibly_ready_ticks: vec![],
684-
not_ready_ticks: tick_dfirs
685-
.into_iter()
686-
.map(|(lid, c_id, dfir)| (serde_json::from_str(lid).unwrap(), c_id, dfir))
687-
.collect(),
688-
possibly_ready_observation: vec![],
689-
not_ready_observation,
690-
hooks: hooks
691-
.into_iter()
692-
.map(|((lid, cid), hs)| ((serde_json::from_str(lid).unwrap(), cid), hs))
693-
.collect(),
694-
inline_hooks: inline_hooks
695-
.into_iter()
696-
.map(|((lid, cid), hs)| ((serde_json::from_str(lid).unwrap(), cid), hs))
697-
.collect(),
717+
not_ready_ticks,
718+
possibly_ready_observations: vec![],
719+
not_ready_observations,
698720
log: if self.log {
699721
if let Some(w) = log_override {
700722
LogKind::Custom(w)
@@ -1176,6 +1198,60 @@ impl<W: std::io::Write> std::fmt::Write for LogKind<W> {
11761198
}
11771199
}
11781200

1201+
/// A tick-scoped DFIR together with the hooks that feed it data.
1202+
struct SimTick {
1203+
/// The location of the process/cluster the tick lives on, used to match this tick
1204+
/// against the async DFIR that produces its input data.
1205+
parent_location: LocationId,
1206+
/// The cluster member ID, if the tick lives on a cluster.
1207+
cluster_id: Option<u32>,
1208+
/// The tick DFIR, executed once per tick.
1209+
dfir: DfirErased,
1210+
/// Hooks (e.g. from `batch`) resolved *before* the tick runs, deciding what data to
1211+
/// release into it.
1212+
hooks: Vec<Box<dyn SimHook>>,
1213+
/// Hooks (e.g. from `assume_ordering` inside the tick) resolved *while* the tick DFIR
1214+
/// is running, via a `tokio::select!` loop, for operators that block on ordering
1215+
/// decisions mid-tick.
1216+
inline_hooks: Vec<Box<dyn SimInlineHook>>,
1217+
}
1218+
1219+
impl SimTick {
1220+
/// Whether the scheduler can execute this tick right now.
1221+
fn can_run(&self) -> bool {
1222+
// All hooks must be ready (have received input or have a last value)...
1223+
self.hooks.iter().all(|hook| hook.is_ready())
1224+
// ...and at least one hook must be able to release data into the tick.
1225+
&& self.hooks.iter().any(|hook| hook_can_release(&**hook))
1226+
}
1227+
}
1228+
1229+
/// A top-level location whose hooks (e.g. from `assume_ordering` on a non-tick stream)
1230+
/// need scheduling decisions, but which has no tick DFIR to execute. The scheduler just
1231+
/// resolves the hooks.
1232+
struct SimObservation {
1233+
/// The top-level location, used to match this observation against the async DFIR that
1234+
/// produces its input data.
1235+
location: LocationId,
1236+
/// The cluster member ID, if the location is a cluster.
1237+
cluster_id: Option<u32>,
1238+
/// Hooks resolved when the scheduler selects this observation.
1239+
hooks: Vec<Box<dyn SimHook>>,
1240+
}
1241+
1242+
impl SimObservation {
1243+
/// Whether the scheduler can resolve any of this observation's hooks right now.
1244+
fn can_run(&self) -> bool {
1245+
self.hooks.iter().any(|hook| hook_can_release(&**hook))
1246+
}
1247+
}
1248+
1249+
/// Whether the hook has already decided to release data, or has pending input that would
1250+
/// allow it to decide to do so.
1251+
fn hook_can_release(hook: &dyn SimHook) -> bool {
1252+
hook.current_decision().unwrap_or(false) || hook.can_make_nontrivial_decision()
1253+
}
1254+
11791255
/// A running simulation, which manages the async DFIRs, tick DFIRs, and hook-based
11801256
/// scheduling decisions for non-deterministic operators like `batch` and `assume_ordering`.
11811257
///
@@ -1192,24 +1268,16 @@ struct LaunchedSim<W: std::io::Write> {
11921268
/// Top-level async DFIRs, one per process/cluster member. These run continuously and
11931269
/// produce data that feeds into ticks and observations.
11941270
async_dfirs: Vec<(LocationId, Option<u32>, DfirErased)>,
1195-
/// Tick DFIRs whose parent async DFIR has made progress, so they may be ready to run.
1271+
/// Ticks whose parent async DFIR has made progress, so they may be ready to run.
11961272
/// The scheduler further filters these by checking whether their hooks have pending decisions.
1197-
possibly_ready_ticks: Vec<(LocationId, Option<u32>, DfirErased)>,
1198-
/// Tick DFIRs whose parent async DFIR has not yet made progress since they were last checked.
1199-
not_ready_ticks: Vec<(LocationId, Option<u32>, DfirErased)>,
1200-
/// Top-level locations whose async DFIR has made progress and whose hooks (from top-level
1201-
/// `assume_ordering`) may have ordering decisions to resolve. Unlike ticks, these have no
1202-
/// DFIR to execute — only hook resolution.
1203-
possibly_ready_observation: Vec<(LocationId, Option<u32>)>,
1204-
/// Top-level locations whose async DFIR has not yet made progress since they were last checked.
1205-
not_ready_observation: Vec<(LocationId, Option<u32>)>,
1206-
/// Hooks keyed by (location, cluster_member_id). These are resolved *before* a tick runs
1207-
/// (for `batch` hooks) or standalone (for top-level `assume_ordering` hooks via observations).
1208-
hooks: Hooks<LocationId>,
1209-
/// Inline hooks keyed by (tick location, cluster_member_id). These are resolved *during*
1210-
/// tick execution via a `tokio::select!` loop, for operators like `assume_ordering` inside
1211-
/// a tick that block on ordering decisions while the tick DFIR is running.
1212-
inline_hooks: InlineHooks<LocationId>,
1273+
possibly_ready_ticks: Vec<SimTick>,
1274+
/// Ticks whose parent async DFIR has not yet made progress since they were last checked.
1275+
not_ready_ticks: Vec<SimTick>,
1276+
/// Observations whose async DFIR has made progress, so their hooks may have decisions
1277+
/// to resolve.
1278+
possibly_ready_observations: Vec<SimObservation>,
1279+
/// Observations whose async DFIR has not yet made progress since they were last checked.
1280+
not_ready_observations: Vec<SimObservation>,
12131281
log: LogKind<W>,
12141282
/// Represents quiescence state of the simulation.
12151283
quiescence: Rc<QuiescenceState>,
@@ -1227,26 +1295,17 @@ impl<W: std::io::Write> LaunchedSim<W> {
12271295
for (loc, c_id, dfir) in &mut self.async_dfirs {
12281296
if dfir.run_tick().await {
12291297
any_made_progress = true;
1230-
let (now_ready, still_not_ready): (Vec<_>, Vec<_>) = self
1231-
.not_ready_ticks
1232-
.drain(..)
1233-
.partition(|(tick_loc, tick_c_id, _)| {
1234-
let LocationId::Tick(_, outer) = tick_loc else {
1235-
unreachable!()
1236-
};
1237-
outer.as_ref() == loc && tick_c_id == c_id
1238-
});
1239-
1240-
self.possibly_ready_ticks.extend(now_ready);
1241-
self.not_ready_ticks.extend(still_not_ready);
1242-
1243-
let (now_ready_obs, still_not_ready_obs): (Vec<_>, Vec<_>) = self
1244-
.not_ready_observation
1245-
.drain(..)
1246-
.partition(|(obs_loc, obs_c_id)| obs_loc == loc && obs_c_id == c_id);
1247-
1248-
self.possibly_ready_observation.extend(now_ready_obs);
1249-
self.not_ready_observation.extend(still_not_ready_obs);
1298+
1299+
// This async DFIR may have produced new data, so the ticks and observations
1300+
// it feeds may now be ready.
1301+
self.possibly_ready_ticks
1302+
.extend(self.not_ready_ticks.extract_if(.., |tick| {
1303+
tick.parent_location == *loc && tick.cluster_id == *c_id
1304+
}));
1305+
self.possibly_ready_observations.extend(
1306+
self.not_ready_observations
1307+
.extract_if(.., |obs| obs.location == *loc && obs.cluster_id == *c_id),
1308+
);
12501309
}
12511310
}
12521311

@@ -1256,47 +1315,22 @@ impl<W: std::io::Write> LaunchedSim<W> {
12561315

12571316
use bolero::generator::*;
12581317

1259-
let (ready_tick, mut not_ready_tick): (Vec<_>, Vec<_>) = self
1260-
.possibly_ready_ticks
1261-
.drain(..)
1262-
.partition(|(name, cid, _)| {
1263-
let hooks = self.hooks.get(&(name.clone(), *cid)).unwrap();
1264-
// All hooks must be ready (have received input or have a last value)
1265-
hooks.iter().all(|hook| hook.is_ready())
1266-
// And at least one hook must be able to make progress
1267-
&& hooks.iter().any(|hook| {
1268-
hook.current_decision().unwrap_or(false)
1269-
|| hook.can_make_nontrivial_decision()
1270-
})
1271-
});
1272-
1273-
self.possibly_ready_ticks = ready_tick;
1274-
self.not_ready_ticks.append(&mut not_ready_tick);
1275-
1276-
let (ready_obs, mut not_ready_obs): (Vec<_>, Vec<_>) = self
1277-
.possibly_ready_observation
1278-
.drain(..)
1279-
.partition(|(name, cid)| {
1280-
self.hooks
1281-
.get(&(name.clone(), *cid))
1282-
.into_iter()
1283-
.flatten()
1284-
.any(|hook| {
1285-
hook.current_decision().unwrap_or(false)
1286-
|| hook.can_make_nontrivial_decision()
1287-
})
1288-
});
1289-
1290-
self.possibly_ready_observation = ready_obs;
1291-
self.not_ready_observation.append(&mut not_ready_obs);
1318+
// Send anything that can't make a scheduling decision back to the not-ready lists.
1319+
self.not_ready_ticks.extend(
1320+
self.possibly_ready_ticks
1321+
.extract_if(.., |tick| !tick.can_run()),
1322+
);
1323+
self.not_ready_observations.extend(
1324+
self.possibly_ready_observations
1325+
.extract_if(.., |obs| !obs.can_run()),
1326+
);
12921327

1293-
if self.possibly_ready_ticks.is_empty() && self.possibly_ready_observation.is_empty() {
1328+
if self.possibly_ready_ticks.is_empty() && self.possibly_ready_observations.is_empty() {
12941329
// If any tick is blocked because a hook is not ready, that's a
12951330
// simulator bug — it means a singleton never received a value.
1296-
for (name, cid, _) in &self.not_ready_ticks {
1297-
let hooks = self.hooks.get(&(name.clone(), *cid)).unwrap();
1331+
for tick in &self.not_ready_ticks {
12981332
abort_assert!(
1299-
hooks.iter().all(|hook| hook.is_ready()),
1333+
tick.hooks.iter().all(|hook| hook.is_ready()),
13001334
"tick has a hook that never became ready"
13011335
);
13021336
}
@@ -1305,17 +1339,16 @@ impl<W: std::io::Write> LaunchedSim<W> {
13051339
self.quiescence.wait_for_resume().await;
13061340
} else {
13071341
let next_tick_or_obs = (0..(self.possibly_ready_ticks.len()
1308-
+ self.possibly_ready_observation.len()))
1342+
+ self.possibly_ready_observations.len()))
13091343
.any();
13101344

13111345
if next_tick_or_obs < self.possibly_ready_ticks.len() {
1312-
let next_tick = next_tick_or_obs;
1313-
let mut removed = self.possibly_ready_ticks.remove(next_tick);
1346+
let mut tick = self.possibly_ready_ticks.remove(next_tick_or_obs);
13141347

13151348
match &mut self.log {
13161349
LogKind::Null => {}
13171350
LogKind::Stderr => {
1318-
if let Some(cid) = &removed.1 {
1351+
if let Some(cid) = &tick.cluster_id {
13191352
eprintln!(
13201353
"\n{}",
13211354
format!("Running Tick (Cluster Member {})", cid)
@@ -1347,13 +1380,10 @@ impl<W: std::io::Write> LaunchedSim<W> {
13471380
})
13481381
});
13491382

1350-
let hooks = self.hooks.get_mut(&(removed.0.clone(), removed.1)).unwrap();
1351-
run_hooks(tick_decision_writer.as_mut(), hooks);
1383+
run_hooks(tick_decision_writer.as_mut(), &mut tick.hooks);
13521384

1353-
let run_tick_future = removed.2.run_tick();
1354-
if let Some(inline_hooks) =
1355-
self.inline_hooks.get_mut(&(removed.0.clone(), removed.1))
1356-
{
1385+
let run_tick_future = tick.dfir.run_tick();
1386+
if !tick.inline_hooks.is_empty() {
13571387
let mut run_tick_future_pinned = pin!(run_tick_future);
13581388

13591389
loop {
@@ -1365,7 +1395,7 @@ impl<W: std::io::Write> LaunchedSim<W> {
13651395
}
13661396
_ = async {} => {
13671397
bolero_generator::any::scope::borrow_with(|driver| {
1368-
for hook in inline_hooks.iter_mut() {
1398+
for hook in tick.inline_hooks.iter_mut() {
13691399
if hook.pending_decision() {
13701400
if !hook.has_decision() {
13711401
hook.autonomous_decision(driver);
@@ -1386,25 +1416,22 @@ impl<W: std::io::Write> LaunchedSim<W> {
13861416
abort_assert!(run_tick_future.await, "tick DFIR run_tick() returned false");
13871417
}
13881418

1389-
self.possibly_ready_ticks.push(removed);
1419+
self.possibly_ready_ticks.push(tick);
13901420
} else {
13911421
let next_obs = next_tick_or_obs - self.possibly_ready_ticks.len();
1392-
let mut default_hooks = vec![];
1393-
let hooks = self
1394-
.hooks
1395-
.get_mut(&self.possibly_ready_observation[next_obs])
1396-
.unwrap_or(&mut default_hooks);
1397-
13981422
let log_writer = (!matches!(self.log, LogKind::Null)).then_some(&mut self.log);
1399-
run_hooks(log_writer, hooks);
1423+
run_hooks(
1424+
log_writer,
1425+
&mut self.possibly_ready_observations[next_obs].hooks,
1426+
);
14001427
}
14011428
}
14021429
}
14031430
}
14041431

14051432
fn run_hooks<W: std::fmt::Write>(
14061433
mut tick_decision_writer: Option<&mut W>,
1407-
hooks: &mut Vec<Box<dyn SimHook>>,
1434+
hooks: &mut [Box<dyn SimHook>],
14081435
) {
14091436
let mut remaining_decision_count = hooks.len();
14101437
let mut made_nontrivial_decision = false;

0 commit comments

Comments
 (0)