scheduler: read placed demands through the shared index

The confirmation that follows the sweep called the public feasibility
test, which scans the task list once to collect the boundaries and again,
per boundary, to sum the demand of every covering placement. That is
quadratic per task and dominated the search: removing the confirmation
alone cut the crowded case by eight, which located the cost.

The index built for the sweep is now passed into that test, so both the
boundary collection and the demand sum read a task by its identifier
instead of searching for it. The public entry point keeps its signature
and builds the index for a caller that has none.

Planning three thousand two hundred mutually contending tasks falls from
6.7 seconds to 0.48, and the plans are identical: the check compares the
start time and end of every placement, before and after, across two
resources with disjoint availability windows.
This commit is contained in:
ApsisRS contributors 2026-09-30 18:57:18 +08:00
parent fd33405213
commit 46afd88f7e
2 changed files with 29 additions and 22 deletions

View File

@ -94,6 +94,24 @@ pub fn feasible_at(
schedule: &Schedule, schedule: &Schedule,
resources: &ResourceSet, resources: &ResourceSet,
start: f64, start: f64,
) -> bool {
let index = crate::sweep::demand_index(tasks);
feasible_at_indexed(task, schedule, resources, start, &index)
}
/// Returns `true` when a task placed at a time is feasible, using a demand
/// index the caller built once.
///
/// The index replaces two linear scans of the task list: the one that finds a
/// placed task while collecting the boundaries, and the one inside that sums
/// the demand over every placement covering a boundary. The boundary
/// arithmetic is unchanged, so the answer is the same as the scans give.
pub(crate) fn feasible_at_indexed(
task: &Task,
schedule: &Schedule,
resources: &ResourceSet,
start: f64,
index: &crate::sweep::DemandIndex,
) -> bool { ) -> bool {
if !start.is_finite() { if !start.is_finite() {
return false; return false;
@ -112,10 +130,7 @@ pub fn feasible_at(
// overlapping placements, where the total is greatest. // overlapping placements, where the total is greatest.
let mut boundaries: Vec<f64> = vec![start]; let mut boundaries: Vec<f64> = vec![start];
for placement in schedule.placements() { for placement in schedule.placements() {
let Some(other) = tasks.iter().find(|other| other.id == placement.task) else { if crate::sweep::demand_on(index, placement.task, resource.id).is_none() {
continue;
};
if other.demand_for(resource.id).is_none() {
continue; continue;
} }
if placement.start < end && start < placement.end() { if placement.start < end && start < placement.end() {
@ -127,12 +142,7 @@ pub fn feasible_at(
.placements() .placements()
.iter() .iter()
.filter(|placement| placement.covers(boundary)) .filter(|placement| placement.covers(boundary))
.filter_map(|placement| { .filter_map(|placement| crate::sweep::demand_on(index, placement.task, resource.id))
tasks
.iter()
.find(|other| other.id == placement.task)
.and_then(|other| other.demand_for(resource.id))
})
.sum(); .sum();
if placed + requirement.demand > resource.capacity() + 1e-9 { if placed + requirement.demand > resource.capacity() + 1e-9 {
return false; return false;

View File

@ -20,7 +20,7 @@
//! capacity boundary. The fast path is therefore an accelerator, never a //! capacity boundary. The fast path is therefore an accelerator, never a
//! second definition of what is feasible. //! second definition of what is feasible.
use crate::greedy::{feasible_at, resources_have_capacity}; use crate::greedy::{feasible_at_indexed, resources_have_capacity};
use apsis_resource::ResourceId; use apsis_resource::ResourceId;
use apsis_resource::{Resource, ResourceSet}; use apsis_resource::{Resource, ResourceSet};
use apsis_schedule::Schedule; use apsis_schedule::Schedule;
@ -68,7 +68,7 @@ pub fn earliest_feasible_start(
// The sweep, confirmed by the direct test. // The sweep, confirmed by the direct test.
if let Some(candidate) = swept_earliest_start(task, schedule, resources, latest_start, &demands) if let Some(candidate) = swept_earliest_start(task, schedule, resources, latest_start, &demands)
{ {
if feasible_at(task, tasks, schedule, resources, candidate) { if feasible_at_indexed(task, schedule, resources, candidate, &demands) {
return Some(candidate); return Some(candidate);
} }
} }
@ -76,7 +76,7 @@ pub fn earliest_feasible_start(
// The enumeration, which is exact. It runs when the sweep could not // The enumeration, which is exact. It runs when the sweep could not
// produce a confirmed answer, which is when the task does not fit at all // produce a confirmed answer, which is when the task does not fit at all
// or when rounding made the two disagree. // or when rounding made the two disagree.
enumerate_earliest_start(task, tasks, schedule, resources, latest_start) enumerate_earliest_start(task, schedule, resources, latest_start, &demands)
} }
/// Returns the earliest start by testing every candidate in turn. /// Returns the earliest start by testing every candidate in turn.
@ -84,10 +84,10 @@ pub fn earliest_feasible_start(
/// This is the original formulation, kept as the fallback for the sweep. /// This is the original formulation, kept as the fallback for the sweep.
fn enumerate_earliest_start( fn enumerate_earliest_start(
task: &Task, task: &Task,
tasks: &[Task],
schedule: &Schedule, schedule: &Schedule,
resources: &ResourceSet, resources: &ResourceSet,
latest_start: f64, latest_start: f64,
index: &DemandIndex,
) -> Option<f64> { ) -> Option<f64> {
let mut candidates: Vec<f64> = vec![task.earliest_start]; let mut candidates: Vec<f64> = vec![task.earliest_start];
for requirement in &task.requirements { for requirement in &task.requirements {
@ -96,10 +96,7 @@ fn enumerate_earliest_start(
candidates.push(window.start()); candidates.push(window.start());
} }
for placement in schedule.placements() { for placement in schedule.placements() {
let overlaps = tasks let overlaps = demand_on(index, placement.task, resource.id).is_some();
.iter()
.find(|other| other.id == placement.task)
.is_some_and(|other| other.demand_for(resource.id).is_some());
if overlaps { if overlaps {
candidates.push(placement.end()); candidates.push(placement.end());
} }
@ -117,7 +114,7 @@ fn enumerate_earliest_start(
candidates candidates
.into_iter() .into_iter()
.find(|start| feasible_at(task, tasks, schedule, resources, *start)) .find(|start| feasible_at_indexed(task, schedule, resources, *start, index))
} }
/// Returns the earliest start derived from the swept profile. /// Returns the earliest start derived from the swept profile.
@ -264,10 +261,10 @@ fn blocked_intervals(
/// vector indexed by it rather than in a map. Reading one is then an array /// vector indexed by it rather than in a map. Reading one is then an array
/// index rather than a hash, and a hash of a small integer costs more than /// index rather than a hash, and a hash of a small integer costs more than
/// the scan it replaces. /// the scan it replaces.
type DemandIndex = Vec<Option<std::collections::HashMap<ResourceId, f64>>>; pub(crate) type DemandIndex = Vec<Option<std::collections::HashMap<ResourceId, f64>>>;
/// Builds a [`DemandIndex`] in one pass over the task list. /// Builds a [`DemandIndex`] in one pass over the task list.
fn demand_index(tasks: &[Task]) -> DemandIndex { pub(crate) fn demand_index(tasks: &[Task]) -> DemandIndex {
let highest = tasks.iter().map(|task| task.id.index()).max().unwrap_or(0); let highest = tasks.iter().map(|task| task.id.index()).max().unwrap_or(0);
let mut index: DemandIndex = (0..=highest).map(|_| None).collect(); let mut index: DemandIndex = (0..=highest).map(|_| None).collect();
for task in tasks { for task in tasks {
@ -285,7 +282,7 @@ fn demand_index(tasks: &[Task]) -> DemandIndex {
} }
/// Returns the demand a placed task puts on a resource, if any. /// Returns the demand a placed task puts on a resource, if any.
fn demand_on(index: &DemandIndex, task: TaskId, resource: ResourceId) -> Option<f64> { pub(crate) fn demand_on(index: &DemandIndex, task: TaskId, resource: ResourceId) -> Option<f64> {
index index
.get(task.index()) .get(task.index())
.and_then(Option::as_ref) .and_then(Option::as_ref)