Planung eines GPU-Clusters
Ein Trainingslauf ist mit seinem Knoten ausgefallen. Starten Sie ihn vor 15:00 wieder im Cluster und ziehen Sie seine Pipeline mit, direkt im Terminal.
- Rechenzentrum
- batchd
- Geteilte Bereiche
- Tastatursteuerung
- Rechteckauswahl
- Mehrfachauswahl
- Live-Suche
- Gespeicherte Ansichten
- Abhängigkeiten
- Minimap
- Theming
- Demo mit synthetischen Daten
Aufgabe
0 von 3 Schritten erledigtMittwoch, 14:05. gpu-a04 meldete um 13:50 ECC-Fehler und wurde aus dem Betrieb genommen; sein Trainingslauf train-vision-7b kam mit hoher Priorität zurück in die Warteschlange. Er muss spätestens um 15:00 wieder starten.
Starten Sie train-vision-7b vor 15:00 auf einem Knoten der Klasse A. — Offen
Ziehen Sie ihn aus der Zeile high im Warteschlangenfenster direkt nach oben auf gpu-a03: Beim Ablegen erhält er die GPUs der beiden verdrängbaren Hyperparameterläufe. Knoten der Klasse B lehnen ihn ab (48 GB). Oder geben Sie :mv! train-vision-7b gpu-a03 14:15 ein.
Verschieben Sie Auswertung und Export hinter das Training. — Offen
Drücken Sie auf gpu-b02 v und ziehen Sie ein Rechteck um eval-vision-7b und export-vision-7b. Ziehen Sie dann einen der beiden Jobs nach rechts hinter 20:15. Oder klicken Sie mit der rechten Maustaste auf die Auswertung und wählen Sie Kaskade nach ….
Speichern Sie diese Ansicht als triage. — Offen
Drücken Sie :, geben Sie w triage ein und drücken Sie die Eingabetaste. Die Ansicht erscheint als Fenster 1 in der Statuszeile; :e triage stellt sie wieder her.
Etwa 30 Sekunden. Erledigte Schritte werden abgehakt.
Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.
Vorschau des ersten BildesLive: die echte Pro-Engine, in Ihrem BrowserEtwa 30 Sekunden. Erledigte Schritte werden abgehakt. Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.
Die Ausgangslage.
batchd ist der fiktive Scheduler eines Labors für maschinelles Lernen: zehn Knoten mit je acht GPUs in drei Racks (Klasse A mit 80 GB für Training, Klasse B mit 48 GB für Auswertung, Klasse L mit 24 GB für Inferenz) und eine Warteschlange mit drei Prioritätsklassen. Forschende reichen Jobs ein; ein Knoten führt mehrere gleichzeitig aus, solange seine GPUs ausreichen. Verdrängbare Hyperparameterläufe füllen die Lücken, bis ein wichtigerer Job die Ressourcen braucht.
Wer einen solchen Cluster betreibt, arbeitet im Terminal. Dieser Plan greift die vertraute Bedienung auf (Teilfenster, Befehlszeile, Balkenanzeigen aus Blockzeichen), zeigt die Planung aber auf einer Zeitachse: was jeden Knoten jetzt und heute Nacht belegt, was in der Warteschlange steht und welche Pipeline bricht, wenn ein Job verschoben wird. Ziehen und Befehleingabe folgen denselben Regeln.
Was Sie tun werden
Sie planen einen fehlgeschlagenen Trainingslauf auf einem Knoten der Klasse A neu ein, indem Sie zwei Hyperparameterläufe verdrängen, verschieben Auswertung und Export gemeinsam hinter das Training und speichern das Ergebnis als Ansicht.
Wo die Bibliothek endet und Ihre Anwendung beginnt.
SuperScheduler liefert
- SchedulerPanes (super-scheduler/panes): Knoten und Warteschlange als zwei Teilfenster auf einer Zeitachse, mit gemeinsamem Zoom und Scrollen, einer per Tastatur verstellbaren Trennlinie und Verschieben zwischen den Fenstern.
- Ein Ressourcenbaum (Racks und Knoten) mit rowHeaderColumns, eine fixierte Summenzeile, die onBeforeCellRender zeichnet, und zwei Zeitkopfzeilen, die onBeforeTimeHeaderRender schreibt.
- Überlappende Events in gestapelten Spuren (allowEventOverlap, eventMarginBottom), zweizeilige HTML-Karten, gesperrte Zellen für Knoten außer Betrieb, Ende-Start-Verknüpfungen und ein Separator zur Clusterzeit.
- Eventauswahl per Klick, Ctrl/⌘+Klick und Rechteck (rectangleSelectHandling, startRectangle), gemeinsames Verschieben mit allowMultiMove und die vollständige Tastatursteuerung (keyboardMode: 'Full').
- rows.filter und events.filter hinter dem Befehl :filter und der Livesuche, getViewState und applyViewState (super-scheduler/views) hinter :w und :e.
- Die Minimap (super-scheduler/minimap) mit eigenen Datenreihen und Farben, Zoomstufen, Rückgängig-Verlauf, Hover-Karte, Drag-Karte und einem Kontextmenü im Skin des Produkts.
Der Code dieses Beispiels entscheidet
- Die Regeln: GPU-Speicher je Klasse, freie GPUs je Knoten mit der Rechnung in jeder Ablehnung, Knoten außer Betrieb, Pipelines und die verdrängbaren Buchungen, die ein Job höherer Priorität beim Ablegen verdrängt.
- Die Befehlssprache: Parser, Vervollständigung von Job- und Knotennamen mit Tab, Verlauf, Fehlermeldungen im Shell-Stil und die y/n-Abfrage, bevor ein gebuchter Job zurück in die Warteschlange kommt.
- Jede Verschiebung, per Ziehen oder Befehl, als eine Zustandsänderung und ein Rückgängig-Schritt, einschließlich der verdrängten Jobs.
- Die Kennzahlen: Belegung je Stunde und Knoten, Warteschlange, Fehler und Leistung sowie die Datenreihen der Minimap.
- Was eine gespeicherte Ansicht zusätzlich enthält (Suche und Filter) und die tmux-Statuszeile, die Ansichten als Fenster auflistet.
Der Code hinter dieser Demo.
Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.
Die Integration: SchedulerPanes mit zwei Teilfenstern, Zoomstufen (das Warteschlangenfenster ohne Kopfzeilen), rowHeaderColumns, die fixierte Clusterzeile, gesperrte Zellen für Knoten außer Betrieb, Verknüpfungen aus Pipelines, die Drag-Karte mit dem beim Ablegen ausgeführten Befehl, das Kontextmenü und onEventMoving / onEventMove, wo die Regeln entscheiden und die Anwendung das Ablegen übernimmt.
/**
* batchd: a GPU cluster and its batch queue as two tmux panes over one timeline. The nodes pane on
* top (racks and their eight-GPU nodes under a frozen cluster row), the queue pane below (one row
* per priority class); a job dragged from the queue onto a node is scheduled, one dragged back is
* requeued.
*
* Library: SchedulerPanes (super-scheduler/panes) with cross-pane moves and a splitter, a resource
* tree with rowHeaderColumns, a frozen summary row painted by onBeforeCellRender, two time header
* rows written by onBeforeTimeHeaderRender, zoom levels, overlapping events (allowEventOverlap)
* stacked in lanes, two-line HTML cards, disabled cells for drained nodes, finish-to-start links,
* a separator at the cluster clock, the full crosshair, rows.filter and events.filter, event
* selection with Ctrl/⌘+click and rectangles (rectangleSelectHandling) moved together
* (allowMultiMove), the full keyboard model, undo history, the event hover card and the drag card
* (super-scheduler/react-render) and a context menu. Application: GPU memory per class, free GPUs
* per node, drains, pipelines, which preemptible bookings a drop displaces, the queue's priorities
* and every figure. Those rules live in ./rules; ./paint writes the markup. Every move is applied
* by the demo as one state change and one undo step (onEventMove cancels the library's own commit).
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { subscribeScheduler } from 'super-scheduler/hooks'
import {
SchedulerPanes,
type SchedulerPane,
type SchedulerPanesHandle,
} from 'super-scheduler/panes'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { DAY, HOUR, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { KEYS } from '../kit/keys'
import { useDropRefusal } from '../kit/use-drop-refusal'
import { skinClass, skinMenuTheme } from '../registry'
import {
BOARD,
SUMMARY_ID,
nodesPaneHeight,
readJobFields,
rowKind,
type JobEvent,
} from './adapter'
import { ZOOM_IDS, type ZoomId } from './commands'
import { compact as walltimeText } from './glyphs'
import { JobCard } from './job-card'
import {
INITIAL_VIEW_START,
NOW,
NOW_TICKS,
PLAN,
PRIORITIES,
RACKS,
classOf,
type GpuClass,
type Job,
type NodeId,
type QueueId,
} from './model'
import {
cellHeaderHtml,
clock,
dayHeaderHtml,
drainHtml,
escape,
jobHtml,
jobLabel,
meterHtml,
nodeHtml,
queueHtml,
rackHtml,
refusalText,
sparkHtml,
summaryCellHtml,
summaryHeaderHtml,
summaryLegendHtml,
summaryMetaHtml,
verdictHtml,
verdictWarnings,
} from './paint'
import {
allocation,
bestFit,
cascade,
drainsBetween,
earliestStart,
isNode,
isQueue,
jobMatches,
nextQuarter,
nodeLoad,
passesFilter,
priorityOfQueue,
rackUsage,
roundQuarter,
stateOf,
planMove,
usageAt,
walltime,
type FilterTerms,
type Issue,
type MovePlan,
type MoveTarget,
type Refusal,
} from './rules'
import type { BatchdText } from './text'
import 'super-scheduler/styles.css'
import '../../scheduler/skin.css'
import './tokens.css'
import './tty.css'
const SLUG = 'compute-cluster'
/** What the board asks the demo to do (context menu, job pane). */
export type JobAction =
| 'show'
| 'run'
| 'hold'
| 'release'
| 'requeue'
| 'cascade'
| 'prio-high'
| 'prio-normal'
| 'prio-preemptible'
/** A drop or a keyboard move: one job, or a selection shifted in time (`shift` ms). */
export interface MoveRequest {
readonly targets: readonly MoveTarget[]
readonly shift: number | null
}
/** The live search and the `:filter` terms, handed to events.filter. */
export interface EventFilter {
readonly query: string
readonly terms: FilterTerms
}
export interface BatchdSchedulerProps {
readonly events: JobEvent[]
/** The same events as jobs, for the rules. */
readonly jobs: readonly Job[]
readonly issues: readonly Issue[]
readonly resources: SuperScheduler.ResourceData[]
readonly text: BatchdText
readonly tag: string
readonly schedulerLocale: string
/** Total height of the panes, px (the panes need a number). */
readonly height: number
/** Phones: the node column only. */
readonly narrow: boolean
/** Tablets: no sparkline column. */
readonly compact: boolean
readonly initialZoom: ZoomId
readonly panesRef: RefObject<SchedulerPanesHandle | null>
readonly history: SchedulerHistory
readonly eventFilter: EventFilter | null
readonly classFilter: GpuClass | null
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onMove: (request: MoveRequest) => void
readonly onRefused: (id: string, message: string) => void
readonly onSelection: () => void
readonly onAction: (action: JobAction, id: string) => void
readonly onZoomLevel: (level: ZoomId) => void
/** The visible range once scrolling settles (ticks). */
readonly onViewport: (start: number, end: number) => void
readonly onPanes: () => void
}
/** Header rows of a zoom level; the queue pane gets none (the nodes pane draws them). */
function zoomLevels(header: boolean): SuperScheduler.ZoomLevel[] {
const rows = (
group: SuperScheduler.GroupBy,
cellFormat: string,
): SuperScheduler.TimeHeaderData[] =>
header
? [
{ groupBy: group, height: BOARD.header.group },
// A declared format tells the level-of-detail planner how wide the label really is.
{ groupBy: 'Cell', height: BOARD.header.cell, format: cellFormat },
]
: []
return [
{
id: '15m',
properties: {
scale: 'CellDuration',
cellDuration: 15,
cellWidth: BOARD.cellWidth['15m'],
timeHeaders: rows('Hour', 'mm'),
},
},
{
id: '1h',
properties: {
scale: 'Hour',
cellWidth: BOARD.cellWidth['1h'],
timeHeaders: rows('Day', 'HH'),
},
},
{
id: '6h',
properties: {
scale: 'CellDuration',
cellDuration: 360,
cellWidth: BOARD.cellWidth['6h'],
timeHeaders: rows('Day', 'HH'),
},
},
]
}
const NODE_LEVELS = zoomLevels(true)
const QUEUE_LEVELS = zoomLevels(false)
const percent = (value: number) => `${Math.round(value * 10000) / 100}%`
/**
* Where a dragged job would land: on a node, on the quarter hour under the pointer and never
* before the next quarter after the clock; in the queue, at its earliest start (its time there is
* when it could run, not a choice).
*/
export function landing(
job: Job,
place: NodeId | QueueId,
start: number,
jobs: readonly Job[],
): number {
if (isQueue(place)) return earliestStart(job, jobs)
return Math.max(nextQuarter(NOW_TICKS), roundQuarter(start))
}
/** The command a placement stands for, as the drag card prints it. */
export function commandOf(
job: Job,
place: NodeId | QueueId,
start: number,
preempts: boolean,
): string {
if (isQueue(place)) {
const priority = priorityOfQueue(place)
return isQueue(job.place) ? `prio ${job.name} ${priority}` : `requeue ${job.name}`
}
const time = civil(start).slice(11, 16)
return `mv${preempts ? '!' : ''} ${job.name} ${place} ${time}`
}
/** A refusal, prefixed with the member's name when a selection member is the one refused. */
function refusalMessage(
failed: Job,
reason: Refusal,
master: Job,
text: BatchdText,
tag: string,
): string {
const sentence = refusalText(failed, reason, text, tag)
return failed.id === master.id ? sentence : `${failed.name}: ${sentence}`
}
/** A signed shift as the command line writes it: `+1h15`, `-30m`. */
export function shiftText(ms: number): string {
return `${ms < 0 ? '-' : '+'}${walltimeText(ms)}`
}
/** Registers the open card's `close` so the board can dismiss it. */
function Registered({
close,
register,
children,
}: {
close: () => void
register: (close: (() => void) | null) => void
children: ReactNode
}) {
useLayoutEffect(() => {
register(close)
return () => register(null)
}, [close, register])
return children
}
/** The library's eventHover with batchd's `show job` card; kept away while a menu is open. */
function useJobHover(build: (event: SuperScheduler.Event) => ReactNode | null) {
const current = useRef<(() => void) | null>(null)
const suppressed = useRef(false)
const register = useCallback((close: (() => void) | null) => {
current.current = close
}, [])
const closeHover = useCallback(() => current.current?.(), [])
const suppressHover = useCallback((value: boolean) => {
suppressed.current = value
if (value) current.current?.()
}, [])
const eventHover = useMemo<NonNullable<SchedulerProps['eventHover']>>(
() => ({
delay: 260,
leaveGrace: 140,
placement: 'auto',
glide: true,
pin: false,
render: ({ e, close }) => {
if (suppressed.current) return null
const node = build(e)
return node === null ? null : (
<Registered close={close} register={register}>
{node}
</Registered>
)
},
}),
[build, register],
)
return { eventHover, closeHover, suppressHover }
}
export function BatchdScheduler(props: BatchdSchedulerProps) {
const {
events,
jobs,
issues,
resources,
text,
tag,
schedulerLocale,
height,
narrow,
compact,
initialZoom,
panesRef,
history,
eventFilter,
classFilter,
} = props
const {
onEventsChange,
onMove,
onRefused,
onSelection,
onAction,
onZoomLevel,
onViewport,
onPanes,
} = props
const byId = useMemo(() => new Map(jobs.map((job) => [job.id, job])), [jobs])
const refusal = useDropRefusal(onRefused)
// The rules' decision for one frame of a drag, cached per frame.
const judge = useMemo(() => createJudge(jobs, byId), [jobs, byId])
// Header labels depend on the level's scale: each settled level gets a fresh hook, so the
// control labels its headers again after an animated zoom.
const [level, setLevel] = useState<ZoomId>(initialZoom)
const timeHeaders = useMemo(() => {
const cache = new Map<
string,
Pick<SuperScheduler.TimeHeaderRenderHeader, 'html' | 'cssClass' | 'areas'>
>()
const nowTag = escape(clock(NOW_TICKS, tag))
const nowTip = `${text.status.clock} ${clock(NOW_TICKS, tag)}`
const hook = (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
const header = args.header
const start = header.start.getTime()
const span = header.end.getTime() - start
const key = `${header.level}|${start}|${span}`
let painted = cache.get(key)
if (painted === undefined) {
const holdsNow = NOW_TICKS >= start && NOW_TICKS < start + span
if (header.level === 0) {
// An hour group holding the clock shows the clock tag instead of its own label.
const label = dayHeaderHtml(start, span, text, tag)
painted = {
html: holdsNow && span < DAY ? `<span class="sr-only">${label}</span>` : label,
cssClass: 'bd-th bd-th--group',
areas: holdsNow
? [
{
// Centred on the clock, kept inside its group (an hour group is narrow at 15 minutes).
left: `clamp(0px, calc(${percent((NOW_TICKS - start) / span)} - 21px), calc(100% - 42px))`,
width: 42,
top: 3,
bottom: 3,
cssClass: 'bd-now-tag',
html: nowTag,
toolTip: nowTip,
},
]
: [],
}
} else {
const classes = ['bd-th', 'bd-th--cell']
if (holdsNow) classes.push('bd-th--now')
else if (start + span <= NOW_TICKS) classes.push('bd-th--past')
painted = { html: cellHeaderHtml(start, span), cssClass: classes.join(' '), areas: [] }
}
cache.set(key, painted)
}
header.html = painted.html
header.cssClass = painted.cssClass
header.areas = painted.areas
}
return { level, hook }
}, [level, tag, text]).hook
// Hover card: the `show job` box, from the rules.
const card = useCallback(
(event: SuperScheduler.Event): ReactNode | null => {
const job = byId.get(String(event.id()))
return job === undefined ? null : (
<JobCard job={job} jobs={jobs} issues={issues} text={text} tag={tag} />
)
},
[byId, jobs, issues, text, tag],
)
const { eventHover, closeHover, suppressHover } = useJobHover(card)
// Drag card: the command the drop runs, where it lands, then the refusal or the warnings.
const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
const cache = new Map<string, string>()
return {
duration: false,
row: false,
markerFormat: 'HH:mm',
html: (info) => {
const id = info.eventId === null ? null : String(info.eventId)
const job = id === null ? undefined : byId.get(id)
const place = info.resource
if (job === undefined || (!isNode(place) && !isQueue(place))) return null
const selection = selectedIds(panesRef.current, id ?? '')
const key = `${job.id}|${String(place)}|${info.start.getTime()}|${selection.join(',')}`
let html = cache.get(key)
if (html === undefined) {
const decision = judge(job, place, info.start.getTime(), selection)
const target = decision.targets.find((candidate) => candidate.id === job.id)
const start = target?.start ?? info.start.getTime()
const refused = decision.plan.refused
const message =
refused === null ? null : refusalMessage(refused.job, refused.refusal, job, text, tag)
const warnings =
message === null
? isQueue(place) && isNode(job.place)
? [text.verdict.requeue]
: verdictWarnings(job, decision.plan, text, tag)
: []
html = verdictHtml(
decision.command,
String(place),
start,
start + walltime(job),
message,
warnings,
tag,
)
if (cache.size > 300) cache.clear()
cache.set(key, html)
}
return html
},
}
}, [byId, judge, panesRef, text, tag])
// Context menu: rebuilt with the board, so its labels name the real consequence.
const contextMenu = useMemo(
() =>
createEventMenu({
theme: skinMenuTheme(SLUG),
title: (event) => event.text(),
onOpenChange: (open) => suppressHover(open),
entries: (event) => {
const id = String(event.id())
const job = byId.get(id)
if (job === undefined) return []
const state = stateOf(job)
const entries: MenuEntry[] = [
{
label: text.actions.show,
icon: 'details',
keys: KEYS.details,
run: () => onAction('show', id),
},
]
if (state === 'queued' || state === 'preempted' || state === 'scheduled') {
const fit = bestFit(job, jobs)
entries.push({
label:
fit === null
? text.actions.noFit
: fit.preempts.length > 0
? plural(text.actions.runPreempts, fit.preempts.length, tag, {
node: fit.place,
time: clock(fit.start, tag),
})
: format(text.actions.run, { node: fit.place, time: clock(fit.start, tag) }),
icon: 'slot',
primary: state !== 'scheduled',
disabled:
fit === null || (fit.place === job.place && fit.start === parseCivil(job.start)),
run: () => onAction('run', id),
})
}
if (state === 'scheduled') {
const chain = cascade(id, jobs)
if (chain !== null)
entries.push({
label: plural(text.actions.cascade, chain.length, tag, {
upstream: job.after ?? '',
}),
icon: 'after',
primary: true,
run: () => onAction('cascade', id),
})
entries.push({
label: text.actions.requeue,
icon: 'pending',
run: () => onAction('requeue', id),
})
}
if (state === 'queued' || state === 'preempted')
entries.push({
label: text.actions.hold,
icon: 'pending',
run: () => onAction('hold', id),
})
if (state === 'held')
entries.push({
label: text.actions.release,
icon: 'confirm',
primary: true,
run: () => onAction('release', id),
})
if (
state === 'queued' ||
state === 'preempted' ||
state === 'held' ||
state === 'scheduled'
) {
entries.push('separator')
for (const priority of PRIORITIES)
if (priority !== job.priority)
entries.push({
label: format(text.actions.prio, { priority: text.priorities[priority] }),
icon: 'layers',
run: () => onAction(`prio-${priority}`, id),
})
}
return entries
},
}),
[byId, jobs, text, tag, suppressHover, onAction],
)
// Pipelines from the data: finish-to-start links between booked jobs; late ones are red.
const links = useMemo<SuperScheduler.LinkData[]>(() => {
const late = new Set(issues.filter((issue) => issue.kind === 'late').map((issue) => issue.job))
return events.flatMap((event): SuperScheduler.LinkData[] => {
const after = event.after
const upstream = after === undefined ? undefined : byId.get(after)
if (after === undefined || upstream === undefined) return []
if (!isNode(event.resource) || !isNode(upstream.place)) return []
const id = String(event.id)
return [
{
id: `${after}->${id}`,
from: after,
to: event.id,
type: 'FinishToStart',
cssClass: late.has(id) ? 'bd-link bd-link--late ss-link--late' : 'bd-link',
},
]
})
}, [events, issues, byId])
// Columns belong to the nodes pane; the queue's rows span the whole row header.
const columns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
() =>
narrow
? [{ text: text.columns.node, width: BOARD.narrowColumn, nonresizable: true }]
: [
{ text: text.columns.node, width: BOARD.columns.node, nonresizable: true },
{ text: text.columns.gpu, width: BOARD.columns.gpu, nonresizable: true },
...(compact
? []
: [{ text: text.columns.next, width: BOARD.columns.next, nonresizable: true }]),
],
[narrow, compact, text],
)
const headerWidth = narrow
? BOARD.narrowColumn
: compact
? BOARD.columns.node + BOARD.columns.gpu
: BOARD.rowHeaderWidth
// The nodes pane fits its rows as they open (the queue takes the rest), sized once per mount.
const [nodesSize] = useState(() => nodesPaneHeight(jobs))
const panes = useMemo<SchedulerPane[]>(
() => [
{
id: 'nodes',
rowFilter: (resource) => rowKind(resource) !== 'queue',
size: nodesSize,
minSize: BOARD.nodesMin,
props: { rowHeaderColumns: columns },
},
{
id: 'queue',
rowFilter: (resource) => rowKind(resource) === 'queue',
minSize: BOARD.queueMin,
props: { zoomLevels: QUEUE_LEVELS },
},
],
[columns, nodesSize],
)
// Once both panes rendered: the first scroll (imperative and once: a scroll prop would be
// re-applied on every update), then the demo picks up the controls (minimap, views).
const rendered = useRef(new Set<string>())
const narrowRef = useRef(narrow)
const ready = useRef(onPanes)
useEffect(() => {
ready.current = onPanes
}, [onPanes])
const onAfterRender = useCallback(
(args: { readonly pane?: string }) => {
const pane = args.pane
if (pane === undefined || rendered.current.has(pane)) return
rendered.current.add(pane)
if (rendered.current.size < 2) return
requestAnimationFrame(() => {
// Phones open three hours before the clock; wider stages show the morning's history too.
panesRef.current?.scrollTo(
narrowRef.current ? civil(NOW_TICKS - 3 * HOUR) : INITIAL_VIEW_START,
'left',
)
ready.current()
})
},
[panesRef],
)
const shared = useMemo<
Omit<SchedulerProps, 'height' | 'heightSpec' | 'resources' | 'controlRef'>
>(() => {
const issueOf = new Map(issues.map((issue) => [issue.job, issue]))
const usage = new Map(
RACKS.flatMap((rack) => rack.nodes).map((node) => [node, usageAt(node, jobs, NOW_TICKS)]),
)
const racks = rackUsage(jobs)
const loads = new Map(
RACKS.flatMap((rack) => rack.nodes).map((node) => [
node,
nodeLoad(node, jobs, NOW_TICKS - (NOW_TICKS % HOUR), 12),
]),
)
const queues = new Map(
PRIORITIES.map((priority) => {
const waiting = jobs.filter((job) => job.place === `q-${priority}`)
return [
priority,
{ count: waiting.length, gpus: waiting.reduce((sum, job) => sum + job.gpus, 0) },
]
}),
)
const cellCache = new Map<
string,
{ html: string; css: string; disabled: boolean; areas: SuperScheduler.AreaData[] }
>()
const cardCache = new Map<string, string>()
const nodeIds = RACKS.flatMap((rack) => rack.nodes)
const isRack = (id: SuperScheduler.ResourceId) => RACKS.some((rack) => rack.id === id)
const asNode = (id: SuperScheduler.ResourceId): NodeId | null => {
const found = nodeIds.find((node) => node === id)
return found ?? null
}
return {
locale: schedulerLocale,
cssClass: skinClass(SLUG),
startDate: PLAN.start,
days: PLAN.days,
weekStarts: 1,
zoomLevels: NODE_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
zoomGesture: { min: 12, max: 160 },
onBeforeTimeHeaderRender: timeHeaders,
// Exact times: a job booked at 14:15 starts at 14:15. Drops land on the quarter hour under
// the pointer at every zoom level (onEventMoving steers them), not on whole cells.
useEventBoxes: 'Never',
snapToGridEventMoving: false,
cellsMarkBusiness: false,
treeEnabled: true,
treePreventParentUsage: true,
treeIndent: BOARD.treeIndent,
rowHeaderWidth: headerWidth,
rowHeaderWidthAutoFit: false,
rowHeaderColumnsMergeParents: true,
eventHeight: BOARD.eventHeight,
eventMarginBottom: BOARD.eventGap,
rowMarginTop: BOARD.rowMarginTop,
rowMarginBottom: BOARD.rowMarginBottom,
// A node runs several jobs at once while their GPUs fit: overlap is the application's rule.
allowEventOverlap: true,
eventResizeHandling: 'Disabled',
eventClickHandling: 'Select',
allowMultiSelect: true,
allowMultiMove: true,
multiMoveVerticalMode: 'Disabled',
rectangleSelectHandling: 'EventSelect',
rectangleSelectModifiers: true,
durationBarVisible: false,
linkShape: 'RightAngled',
linkLayer: 'Below',
crosshairType: 'Full',
// The cluster clock: a fixed instant of the scenario, not the visitor's clock.
separators: [{ location: NOW, layer: 'AboveEvents', width: 1, cssClass: 'bd-now' }],
showToolTip: false,
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
links,
eventHover,
dragCard,
contextMenu,
onEventsChange,
onBeforeCornerRender: (args) => {
const title = format(text.chrome.nodesPane, {
nodes: String(nodeIds.length),
gpus: String(nodeIds.length * 8),
})
const [name = '', ...rest] = title.split(' · ')
args.html = `<span class="bd-corner" aria-hidden="true"><b>${escape(name)}</b><span class="bd-corner__meta">· ${escape(rest.join(' · '))}</span></span>`
},
onBeforeRowHeaderRender: (args) => {
const row = args.row
const id = row.id
const [first, second, third] = row.columns
if (id === SUMMARY_ID) {
row.cssClass = 'bd-rh bd-rh--sum'
const html = summaryHeaderHtml(text)
if (first === undefined) row.html = html
else {
first.html = html
if (second !== undefined) second.html = summaryMetaHtml(text.rows.summaryMeta[level])
if (third !== undefined) third.html = summaryLegendHtml()
}
return
}
if (isQueue(id)) {
const priority = priorityOfQueue(id)
const queue = queues.get(priority) ?? { count: 0, gpus: 0 }
row.cssClass = `bd-rh bd-rh--queue bd-rh--${priority}`
row.html = queueHtml(priority, queue.count, queue.gpus, text, tag)
if (first !== undefined) first.html = row.html
return
}
const rack = RACKS.find((candidate) => candidate.id === id)
if (rack !== undefined) {
row.cssClass = 'bd-rh bd-rh--rack'
// Parent rows span the columns (rowHeaderColumnsMergeParents): their header is row.html.
row.html = rackHtml(rack, racks.get(rack.id) ?? 0, text)
if (first !== undefined) first.html = row.html
return
}
const node = asNode(id)
if (node === null) return
const used = usage.get(node) ?? 0
row.cssClass = `bd-rh bd-rh--node bd-rh--${classOf(node)}`
const html = nodeHtml(node, used, text)
if (first === undefined) {
row.html = html
return
}
first.html = html
if (second !== undefined) second.html = meterHtml(node, used)
if (third !== undefined) third.html = sparkHtml(node, loads.get(node) ?? [])
},
// `:filter class=A`: racks and nodes of another class step out (the cluster row stays).
onRowFilter: (args) => {
const filter = args.filterParam
if (typeof filter !== 'string' || args.row.id === SUMMARY_ID) return
const rack = RACKS.find((candidate) => candidate.id === args.row.id)
const node = asNode(args.row.id)
args.visible =
rack !== undefined ? rack.gpuClass === filter : node === null || classOf(node) === filter
},
// The live search and `:filter team=… state=…`, one predicate for both panes.
onEventFilter: (args) => {
const filter = args.filterParam
if (filter === null || typeof filter !== 'object') return
const job = byId.get(String(args.e.id()))
if (job === undefined) return
const query = 'query' in filter && typeof filter.query === 'string' ? filter.query : ''
const terms: FilterTerms =
'terms' in filter && typeof filter.terms === 'object' && filter.terms !== null
? filter.terms
: {}
args.visible = jobMatches(job, query) && passesFilter(job, terms)
},
onBeforeCellRender: (args) => {
const cell = args.cell
const from = cell.start.getTime()
const to = cell.end.getTime()
const width = args.control.cellWidth ?? BOARD.cellWidth['1h']
const key = `${String(cell.resource)}|${from}|${to}|${width < 34 ? 'n' : 'w'}`
let painted = cellCache.get(key)
if (painted === undefined) {
const past = to <= NOW_TICKS ? ' bd-c--past' : from < NOW_TICKS ? ' bd-c--now' : ''
const plain = (css: string, html = '') => ({ html, css, disabled: false, areas: [] })
if (cell.resource === SUMMARY_ID) {
const value = allocation(jobs, from, to - from, 1)[0] ?? 0
painted = plain(`bd-c bd-c--sum${past}`, summaryCellHtml(value, width, text, tag))
} else if (isRack(cell.resource)) {
painted = plain(`bd-c bd-c--rack${past}`)
} else if (isQueue(cell.resource)) {
painted = plain(`bd-c bd-c--queue${past}`)
} else {
const node = asNode(cell.resource)
const drain = node === null ? undefined : drainsBetween(node, from, to)[0]
const begins = drain !== undefined && parseCivil(drain.start) >= from
painted =
drain === undefined
? plain(`bd-c${past}`)
: {
// A drained node refuses drops (disabled cells); a label where the drain starts.
html: '',
css: `bd-c bd-c--drain${begins ? ' bd-c--drain-start' : ''}${past}`,
disabled: true,
areas: begins
? [
{
left: percent((parseCivil(drain.start) - from) / (to - from)),
top: 0,
height: 16,
width: 160,
cssClass: 'bd-drain',
html: drainHtml(drain.reason, text),
},
]
: [],
}
}
if (cellCache.size > 4000) cellCache.clear()
cellCache.set(key, painted)
}
cell.properties.html = painted.html
cell.properties.cssClass = painted.css
cell.properties.areas = painted.areas
if (painted.disabled) cell.properties.disabled = true
},
onBeforeEventRender: (args) => {
const data = args.data
const id = String(data.id)
const job = byId.get(id)
if (job === undefined || readJobFields(data) === null) return
const state = stateOf(job)
const issue = issueOf.get(id)
const flags = { late: issue?.kind === 'late', waiting: issue?.kind === 'waiting' }
const key = `${id}|${job.place}|${job.start}|${state}|${flags.late ? 1 : 0}${flags.waiting ? 1 : 0}`
let html = cardCache.get(key)
if (html === undefined) {
html = jobHtml(job, state, flags, text, tag)
cardCache.set(key, html)
}
data.html = html
const classes = ['bd-ev', `bd-ev--${state}`, `bd-ev--${job.priority}`]
if (flags.late) classes.push('bd-ev--late')
data.cssClass = classes.join(' ')
data.ariaLabel = jobLabel(job, state, flags, text)
},
onEventMoving: (args) => {
const id = String(args.e.id())
const job = byId.get(id)
const place = args.resource
if (job === undefined || (!isNode(place) && !isQueue(place))) {
args.allowed = false
return
}
const memberIds =
args.multimove.length > 1 ? args.multimove.map((item) => String(item.event.id())) : [id]
const decision = judge(job, place, args.start.getTime(), memberIds)
const master = decision.targets.find((target) => target.id === id)
if (master !== undefined) {
// Steer the shadow to where the job will really land (quarters, never in the past).
args.start = new SuperScheduler.Date(civil(master.start))
args.end = new SuperScheduler.Date(civil(master.start + walltime(job)))
}
for (const item of args.multimove) {
const target = decision.targets.find(
(candidate) => candidate.id === String(item.event.id()),
)
const original = byId.get(String(item.event.id()))
if (target === undefined || original === undefined) continue
item.start = new SuperScheduler.Date(civil(target.start))
item.end = new SuperScheduler.Date(civil(target.start + walltime(original)))
}
const refused = decision.plan.refused
if (refused !== null) {
const message = refusalMessage(refused.job, refused.refusal, job, text, tag)
args.allowed = false
args.message = message
refusal.track(id, message)
return
}
refusal.track(id, null)
},
// The drop: the library's own commit is cancelled; the demo applies the move (and what it
// displaces) as one state change and one undo step. Keyboard moves arrive here too.
onEventMove: (args) => {
args.preventDefault()
refusal.clear()
const id = String(args.e.id())
const job = byId.get(id)
const place = args.newResource
if (job === undefined || (!isNode(place) && !isQueue(place))) return
const memberIds =
args.multimove.length > 1 ? args.multimove.map((item) => String(item.event.id())) : [id]
const decision = judge(job, place, args.newStart.getTime(), memberIds)
const refused = decision.plan.refused
if (refused !== null) {
onRefused(id, refusalMessage(refused.job, refused.refusal, job, text, tag))
return
}
onMove({ targets: decision.targets, shift: decision.shift })
},
onAfterRender,
onEventClick: () => closeHover(),
onEventSelected: () => onSelection(),
onRectangleSelected: () => onSelection(),
onZoom: (args) => {
if (args.phase !== 'end') return
const next = ZOOM_IDS[args.level]
if (next === undefined) return
setLevel(next)
onZoomLevel(next)
},
}
}, [
schedulerLocale,
initialZoom,
timeHeaders,
headerWidth,
history,
links,
eventHover,
dragCard,
contextMenu,
onEventsChange,
issues,
jobs,
byId,
text,
tag,
level,
judge,
refusal,
onRefused,
onMove,
closeHover,
onSelection,
onZoomLevel,
onAfterRender,
])
// The rest of the board follows the panes once both exist: first scroll, filters, labels.
const control = useCallback(
(pane: 'nodes' | 'queue') => {
const found = panesRef.current?.control(pane) ?? null
return found === null || found.disposed() ? null : found
},
[panesRef],
)
useEffect(() => {
panesRef.current?.forEach((pane) => {
if (!pane.disposed()) pane.events.filter(eventFilter)
})
}, [eventFilter, panesRef, height])
useEffect(() => {
control('nodes')?.rows.filter(classFilter)
}, [classFilter, control, height])
// The visible range, published once scrolling (or a minimap drag) settles.
useEffect(() => {
const nodes = control('nodes')
if (nodes === null) return
return subscribeScheduler(nodes, 'viewport', (viewport) =>
onViewport(viewport.start.ticks, viewport.end.ticks),
)
}, [control, onViewport, height])
return height > 0 ? (
<SchedulerPanes
{...shared}
panesRef={panesRef}
panes={panes}
height={height}
resources={resources}
events={events}
splitter={{ size: BOARD.splitter, step: 16 }}
timeHeader="first"
component={SuperSchedulerComponent}
/>
) : null
}
/**
* The decision for one frame of a drag: where each member lands and what the rules say. A
* multi-move shifts every booked member by the master's snapped shift; queued members stay.
* Cached per frame: holding the pointer still costs a map lookup.
*/
function createJudge(jobs: readonly Job[], byId: ReadonlyMap<string, Job>) {
const cache = new Map<string, Judgement>()
return (master: Job, place: NodeId | QueueId, rawStart: number, memberIds: readonly string[]) => {
const key = `${master.id}|${place}|${rawStart}|${memberIds.join(',')}`
const cached = cache.get(key)
if (cached !== undefined) return cached
let judgement: Judgement
if (memberIds.length <= 1) {
const start = landing(master, place, rawStart, jobs)
const plan = planMove(jobs, [{ id: master.id, place, start }])
judgement = {
targets: [{ id: master.id, place, start }],
shift: null,
plan,
command: commandOf(master, place, start, plan.preempted.length > 0),
}
} else {
const shift = isNode(master.place)
? landing(master, master.place, rawStart, jobs) - parseCivil(master.start)
: 0
const targets = memberIds.flatMap((id): MoveTarget[] => {
const member = byId.get(id)
return member === undefined || !isNode(member.place)
? []
: [{ id, place: member.place, start: parseCivil(member.start) + shift }]
})
judgement = {
targets,
shift,
plan: planMove(jobs, targets),
command: `'<,'>mv ${shiftText(shift)}`,
}
}
if (cache.size > 300) cache.clear()
cache.set(key, judgement)
return judgement
}
}
interface Judgement {
readonly targets: readonly MoveTarget[]
readonly shift: number | null
readonly plan: MovePlan
readonly command: string
}
/** Ids of the selected jobs when `id` is one of them (a multi-move), else just `id`. */
function selectedIds(panes: SchedulerPanesHandle | null, id: string): string[] {
if (panes === null) return [id]
const ids: string[] = []
panes.forEach((pane) => {
if (pane.disposed()) return
for (const event of pane.multiselect.events()) ids.push(String(event.id()))
})
return ids.includes(id) && ids.length > 1 ? ids : [id]
}Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.