12 KiB
Brief: scheduler-preemption
Problem
The XCE scheduler has no preemption mechanism. When a high-priority job cannot be scheduled because all resources are consumed by lower-priority running jobs, the scheduler has no way to free those resources. The job is simply pended, and it waits indefinitely — regardless of its priority — until running jobs naturally complete.
Three specific gaps exist:
- No preemption decision type — the
xce_decision_op_tenum hasDISPATCHandPENDbut noPREEMPT. The scheduler cannot communicate a preemption intent to the controller. - No victim selection mechanism — even if a preemption decision type existed, there is no logic to select which running jobs should be preempted. Cross-queue victim selection (a high-priority job in queue A preempting a low-priority job in queue B) is entirely absent.
- No SIGSTP pipeline — the controller and worker have no path to deliver a stop signal to a running job and transition its state to
JOB_SUSPENDED, freeing its resources for the preempting job.
Operators with mixed-priority workloads need preemption to ensure critical work proceeds without manual intervention — the operator should not have to manually kill low-priority jobs to make room.
Current State
What exists
- Decision framework (
decision.h):xce_decision_op_twithDISPATCH,PEND, andNONEops. Core/decision callback flows to driver/controller. - Queue structure (
xce_sched_queue_t): haspriorityfield for inter-queue ordering within the picker, but no preemption-related fields. - HOOK_DETAIL_JOB_STOP (enum value 65 in
cluster_def.h): existing hook detail enum, reused for preemption stop notifications. - Controller→worker messaging: existing message pipeline for dispatch/pend decisions; the same path can carry preemption actions to workers.
What's missing
XCE_DECISION_PREEMPTopcode andxce_preempt_decision_tpayload indecision.h- Victim selection policy: which running jobs get preempted for a given high-priority job
- Per-queue preemption configuration: which queues can preempt from which other queues, which queues are immune to preemption
- SIGSTP delivery pipeline: scheduler → controller → worker → process group
- Job state transition to
JOB_SUSPENDEDtriggered by preemption - Integration of preemption into the scheduling loop: when allocation fails, attempt preemption before pended
Desired Outcome
A job preemption system with three layers:
1. Decision type — XCE_DECISION_PREEMPT carries victim jobids, signal type, and preempting job id through the core's decision callback → normal-mode driver → controller → worker pipeline, reusing the same callback infrastructure (xce_core_decision_cb_t) used by DISPATCH and PEND.
2. Victim selection — a preempt_policy plugin on each queue selects which running jobs to preempt. The default policy "lowest_priority_first" implements LSF-style cross-queue victim selection:
- Collect running jobs from the preempting job's
preemptible_queueslist - Filter: victim priority < preempting job priority AND victim's queue has
preemptible == true - Rank by priority ascending (lowest priority first)
- Select the minimum subset whose freed resources satisfy the preempting job's resreq
3. SIGSTP pipeline — the controller receives XCE_DECISION_PREEMPT, delivers HOOK_DETAIL_JOB_STOP to each victim's worker, the worker sends SIGSTP to the job's process group, the controller transitions the job to JOB_SUSPENDED, and resources are released. The preempting job is pended and retried in the next scheduling round (async preemption).
Queue Preemption Configuration
Each queue carries three new fields:
| Field | Type | Purpose |
|---|---|---|
preemptible_queues |
char** |
List of queue names this queue CAN preempt from |
preemptible |
bool |
Whether this queue's jobs CAN BE preempted by other queues |
preempt_policy |
char* |
Victim-selection plugin name (NULL = use default) |
Alternatives Considered
Approach A: Kill-and-requeue only (rejected)
| Aspect | Detail |
|---|---|
| Mechanism | SIGKILL victims, requeue from scratch |
| Pros | Simple, no suspended state, no resume logic |
| Cons | Destroys progress, lose all intermediate computation. Requeued jobs restart from zero. Not viable for long-running HPC workloads where checkpointing is explicit. |
Rejected because: HPC jobs can run for hours or days. Killing a job that is 90% complete to make room for a higher-priority job wastes all accumulated work. SIGSTP + resume preserves progress when the preempted job is later resumed.
Approach B: SIGSTP with suspend/resume (chosen)
| Aspect | Detail |
|---|---|
| Mechanism | SIGSTP to process group → job → JOB_SUSPENDED → resources freed → preempting job runs → preempted job optionally resumed later |
| Victim selection | Policy plugin per queue, default "lowest_priority_first" |
| Cross-queue | Per-queue preemptible_queues list enables cross-queue preemption |
| Scope | v1 is SIGSTP only; kill-and-requeue escalation deferred |
Chosen because: Preserves victim job progress. Enables later resume (outside this spec's scope). Aligns with LSF-style preemption semantics familiar to HPC operators. The async pattern (pend preempting job, retry next round after SIGSTP) keeps the scheduling loop simple.
Approach
Preemption Flow
scheduler controller worker
─────────── ────────── ──────
alloc fails for high-prio job J
preempt_policy.select_victims(J):
collect running jobs from
Q_aggressor.preemptible_queues
filter: victim.prio < J.prio
filter: victim.queue.preemptible
rank: lowest priority first
select minimum set satisfying J.resreq
→ [victim1, victim2]
XCE_DECISION_PREEMPT ────────────▶ handle_job_preempt() ──────▶ SIGSTP victim1
.victim_jobids = [v1, v2] deliver to each job → JOB_SUSPENDED
.signal = SIGSTP victim's worker release resources
.preempting_jobid = J
J pended → next round → retry alloc
Integration into Scheduling Loop
Preemption fires at Level 3 in the core scheduling loop — after allocation failure, before pending:
while ((job = picker->next_job(picker)) != NULL) {
if (try_allocate(job) > 0) {
emit_decision(DISPATCH, job);
picker->on_dispatch(picker, job->jobid);
} else if (can_preempt(job)) {
victims = select_victims(job);
emit_decision(PREEMPT, job, victims);
picker->on_pend(picker, job->jobid); // retry next round
} else {
emit_decision(PEND, job);
picker->on_pend(picker, job->jobid);
}
}
The core loop itself contains no victim selection logic — that lives in the preempt_policy plugin. The core only checks can_preempt(job) (does the job's queue have a non-NULL preemptible_queues list?) and delegates to the policy plugin for victim selection.
What stays unchanged
- Core scheduling loop — preemption is an additional branch in the alloc-fail path (Level 3), not a restructure
- Decision callback (
xce_core_decision_cb_t) —PREEMPTflows through the same callback asDISPATCHandPEND - Existing plugin framework —
preempt_policyis registered, loaded, and invoked through the existingplugin_register/key_findmechanism HOOK_DETAIL_JOB_STOP— existing enum value, reused without change
What's new
XCE_DECISION_PREEMPTinxce_decision_op_t(decision.h)xce_preempt_decision_tpayload carryingvictim_jobids[],n_victims,signal,preempting_jobidpreemptible_queues,preemptible,preempt_policyfields onxce_sched_queue_t- Default
"lowest_priority_first"preemption policy plugin - Controller handler for
XCE_DECISION_PREEMPT→ SIGSTP delivery to workers - Worker-side SIGSTP handler → process group signal +
JOB_SUSPENDEDstate transition
Scope
In
XCE_DECISION_PREEMPTdecision type andxce_preempt_decision_tpayloadpreempt_policyplugin interface (victim selection)- Default
"lowest_priority_first"preemption policy - LSF-style cross-queue victim selection via
preemptible_queueslist - SIGSTP pipeline: scheduler → controller → worker → process group
- Per-queue preemption config:
preemptible_queues,preemptible,preempt_policy - Victim safety guards: never preempt same-or-higher priority, never preempt non-
preemptiblequeues JOB_SUSPENDEDstate transition on victim jobs- Reuse of
HOOK_DETAIL_JOB_STOPfor preemption stop notifications - Unit tests: victim selection policy, safety guard enforcement, edge cases (empty target queues, no eligible victims, insufficient resources from any victim set)
- Integration test: end-to-end preemption flow — high-priority job submits, preemption fires, victims get SIGSTP, resources freed, preempting job dispatched in next round
Out
- Kill-and-requeue escalation (v1 is SIGSTP only)
- Deadline-based preemption (requires
deadlinefield on jobs — deferred) - Preempted job resume logic (outside this spec's scope — the
JOB_SUSPENDEDstate is defined, but the resume mechanism is a separate feature) - Preemption priority inversion guards beyond the priority comparison already specified
- Fair-share or proportional victim selection beyond the default
"lowest_priority_first"policy (the plugin interface enables custom policies, but none beyond the default ship here)
Upstream / Downstream
Upstream (dependencies)
- scheduler-core (sibling spec): provides the decision callback (
xce_core_decision_cb_t) through whichXCE_DECISION_PREEMPTflows. The core scheduling loop andxce_sched_core_tare defined there. Preemption hooks into the core loop at Level 3 (alloc-fail branch). - scheduler-allocation (sibling spec): provides
try_allocate()— preemption fires only when allocation fails. The resreq fingerprint cache interacts with preemption (a cached-unsatisfiable resreq skips allocation AND preemption). - Controller message handling: existing handler dispatch path for decisions — extended to handle
XCE_DECISION_PREEMPT. - Worker job control: existing job lifecycle management — extended to handle SIGSTP delivery and
JOB_SUSPENDED.
Downstream (future consumers)
- Resume mechanism — a future spec for resuming
JOB_SUSPENDEDjobs, potentially with priority-based ordering - Kill-and-requeue escalation — a future enhancement where repeated preemption failure escalates to SIGKILL
- Custom preemption policies — deadline-aware, fair-share, or resource-specific victim selection implemented as new
preempt_policyplugins
Constraints
- C (gnu17) — no C++, no external libraries beyond existing (pthreads)
- Async preemption — the preempting job is pended and retried in the next scheduling round; preemption is not synchronous
- Priority guard — victim priority must be strictly less than preempting job priority (never preempt equal or higher)
- Preemptible guard — victim queue must have
preemptible == true(opt-in — queues are not preemptible by default) - Minimal victim set — select the minimum subset of victims whose freed resources satisfy the preempting job's resreq (avoid over-preemption)
- No self-preemption — the preempting job's own queue is implicitly excluded from victim selection
- Cancel-safe —
preempt_policy.select_victims()must havepthread_cancelcancellation points