feat: noise runtime estimator

This commit is contained in:
2026-08-21 12:46:55 +02:00
parent fb85e20f44
commit b0a5447797
10 changed files with 423 additions and 153 deletions

View File

@@ -248,6 +248,18 @@ class K8sConfig implements ConfigScope {
""") """)
final String schedulingStrategy final String schedulingStrategy
@ConfigOption
@Description("""
Selects a runtime estimator
""")
final String runtimeEstimator
@ConfigOption
@Description("""
Noise magnitude used for the noise runtime estimator
""")
final Duration noiseRuntimeEstimatorNoiseMagnitude
/* required by extension point -- do not remove */ /* required by extension point -- do not remove */
K8sConfig() { K8sConfig() {
this(Collections.emptyMap()) this(Collections.emptyMap())
@@ -287,6 +299,8 @@ class K8sConfig implements ConfigScope {
runtimeRecordPath = opts.runtimeRecordPath as String ?: "${workDir}/runtimes.csv" runtimeRecordPath = opts.runtimeRecordPath as String ?: "${workDir}/runtimes.csv"
schedulingStrategy = opts.schedulingStrategy as String ?: "Hash" schedulingStrategy = opts.schedulingStrategy as String ?: "Hash"
runtimeEstimator = opts.runtimeEstimator as String ?: "LinearFit"
noiseRuntimeEstimatorNoiseMagnitude = opts.noiseRuntimeEstimatorNoiseMagnitude as Duration ?: new Duration(10, TimeUnit.SECONDS)
// -- shortcut to pod image pull-policy // -- shortcut to pod image pull-policy
if( imagePullPolicy ) if( imagePullPolicy )

View File

@@ -97,7 +97,15 @@ class K8sExecutor extends Executor implements ExtensionPoint {
log.debug "[K8s] config=$k8sConfig; API client config=$client.config" log.debug "[K8s] config=$k8sConfig; API client config=$client.config"
this.runtimeRecorder = new K8sRuntimeRecorder(k8sConfig.recordTaskRuntimes, k8sConfig.runtimeRecordPath) this.runtimeRecorder = new K8sRuntimeRecorder(k8sConfig.recordTaskRuntimes, k8sConfig.runtimeRecordPath)
this.runtimeEstimator = new K8sRuntimeEstimator(k8sConfig.runtimeRecordPath)
if (k8sConfig.runtimeEstimator == "LinearFit") {
this.runtimeEstimator = new K8sLinearFitRuntimeEstimator(k8sConfig.runtimeRecordPath)
} else if (k8sConfig.runtimeEstimator == "Noise") {
this.runtimeEstimator = new K8sNoiseRuntimeEstimator(k8sConfig.runtimeEstimator, k8sConfig.noiseRuntimeEstimatorNoiseMagnitude)
} else {
log.error "[K8s] invalid runtime estimator ${k8sConfig.runtimeEstimator} - falling back on \"LinearFit\""
this.runtimeEstimator = new K8sLinearFitRuntimeEstimator(k8sConfig.runtimeRecordPath)
}
String[] nodes = getNodeList() String[] nodes = getNodeList()
@@ -113,6 +121,17 @@ class K8sExecutor extends Executor implements ExtensionPoint {
strategy = new K8sDVFSSchedulingStrategy(this.runtimeEstimator, strategy = new K8sDVFSSchedulingStrategy(this.runtimeEstimator,
new K8sDVFSClient(nodes, ips), new K8sDVFSClient(nodes, ips),
() -> getClient()) () -> getClient())
strategy.fullSpeedMode = false
} else if (k8sConfig.schedulingStrategy == "DVFS-SPEED") {
String[] ips = new String[nodes.length]
for (int i = 0; i < nodes.length; i++) {
ips[i] = client.getPodIpAddress(K8sNodeInitDeployer.buildPodName(nodes[i]))
log.info "[K8s] node ${nodes[i]} -> ${ips[i]}"
}
strategy = new K8sDVFSSchedulingStrategy(this.runtimeEstimator,
new K8sDVFSClient(nodes, ips),
() -> getClient())
strategy.fullSpeedMode = true
} else { } else {
log.error "[K8s] invalid scheduling strategy $k8sConfig.schedulingStrategy, falling back on \"Hash\"" log.error "[K8s] invalid scheduling strategy $k8sConfig.schedulingStrategy, falling back on \"Hash\""
strategy = new K8sHashSchedulingStrategy() strategy = new K8sHashSchedulingStrategy()

View File

@@ -0,0 +1,99 @@
package nextflow.k8s
import groovy.util.logging.Slf4j
import java.nio.file.Files
import java.nio.file.Path
/**
* Provides runtime estimates for tasks based on task-name and input file size
*/
@Slf4j
class K8sLinearFitRuntimeEstimator extends K8sRuntimeEstimator {
class Function {
private double m
private double n
Function(double m, double n) {
this.m = m
this.n = n
}
double estimate(long x) {
return m * (double)x + n
}
}
HashMap<String, Function> estimators;
/**
* Initialize the estimator with data recorded by K8sRuntimeRecorder
* @param dataFilePath
*/
K8sLinearFitRuntimeEstimator(String dataFilePath) {
def data = parseDataFile(dataFilePath)
fit(data)
}
/**
* Initializes the runtime estimator with statically known data.
* @param data Map from task name to list of recordings, where each recording is a tuple (input-size, runtime-in-ms)
*/
K8sLinearFitRuntimeEstimator(HashMap<String, ArrayList<Tuple2<Long, Long>>> data) {
fit(data)
}
/**
* Returns an estimation of the task runtime in milliseconds
* @param taskName the name of the task
* @param inputSize the total input size in bytes
* @return estimated task runtime in milliseconds OR infinity if the task is unknown.
*/
double estimate(String taskName, long inputSize) {
Function estimator = estimators.get(taskName)
if (estimator == null) {
//log.warn "[K8s] Unable to estimate take ${taskName}: unknown task"
return Double.POSITIVE_INFINITY
}
return estimator.estimate(inputSize)
}
private void fit(HashMap<String, ArrayList<Tuple2<Long, Long>>> data) {
estimators = new HashMap<>()
for (Map.Entry<String, ArrayList<Tuple2<Long, Long>>> t : data) {
Function f = fit(t.value)
estimators.put(t.key, f)
}
}
/**
* Uses linear regression to fit a linear function (y = m * x + n) to the provided observations
* @param observations list of tuples (input size, runtime in ms)
* @return linear function fitted to the input
*/
private Function fit(ArrayList<Tuple2<Long, Long>> observations) throws IllegalArgumentException {
int n = observations.size()
if (n > 1) {
double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0
for (Tuple2<Long, Long> o : observations) {
double x = (double) o.get(0)
double y = (double) o.get(1)
sumX += x
sumY += y
sumXY += x * y
sumX2 += x * x
}
double m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
return new Function(
m,
(sumY - m * sumX) / n
)
} else if (n == 1) {
// Special case: We only have 1 measurement. We will just assume that the runtime is constant,
// because in our observed data, it is.
return new Function(0.0, (double) observations[0].get(1))
}
throw new IllegalArgumentException("requires at least 1 observation")
}
}

View File

@@ -0,0 +1,50 @@
package nextflow.k8s
import nextflow.util.Duration
class K8sNoiseRuntimeEstimator extends K8sRuntimeEstimator {
// Magnitude of the noise we add
private long noiseMag
private HashMap<String, ArrayList<Tuple2<Long, Long>>> data;
private synchronized Random rnd;
/**
* Initialize the estimator with data recorded by K8sRuntimeRecorder
* @param dataFilePath
* @param noiseMagnitude magnitude of the noise added to recordings
*/
K8sNoiseRuntimeEstimator(String dataFilePath, Duration noiseMagnitude) {
this.data = parseDataFile(dataFilePath)
this.noiseMag = noiseMagnitude.toMillis()
this.rnd = new Random()
}
/**
* Initializes the runtime estimator with statically known data.
* @param data Map from task name to list of recordings, where each recording is a tuple (input-size, runtime-in-ms)
*/
K8sNoiseRuntimeEstimator(HashMap<String, ArrayList<Tuple2<Long, Long>>> data, Duration noiseMagnitude) {
this.data = data;
this.noiseMag = noiseMagnitude.toMillis()
}
/**
* Returns an estimation of the task runtime in milliseconds
* @param taskName the name of the task
* @param inputSize the total input size in bytes
* @return estimated task runtime in milliseconds OR infinity if the task is unknown.
*/
@Override
double estimate(String taskName, long inputSize) {
ArrayList recordings = data.get(taskName)
for (Tuple2<Long, Long> recording : recordings) {
if (recording.get(0).longValue() == inputSize) {
double noise = rnd.nextDouble(-1.0, 1.0) * (double)noiseMag
long ms = recording.get(1).longValue()
return (double)ms + noise
}
}
return 0
}
}

View File

@@ -9,29 +9,48 @@ import java.nio.file.Path
* Provides runtime estimates for tasks based on task-name and input file size * Provides runtime estimates for tasks based on task-name and input file size
*/ */
@Slf4j @Slf4j
class K8sRuntimeEstimator { abstract class K8sRuntimeEstimator {
/**
class Function { * Returns an estimation of the task runtime in milliseconds
private double m * @param handler the task handler
private double n * @return estimated task runtime in milliseconds OR infinity if the task is unknown.
*/
Function(double m, double n) { double estimate(K8sTaskHandler handler) {
this.m = m long x = getTaskHandlerInputSize(handler)
this.n = n return estimate(handler.task.processor.name, x)
}
double estimate(long x) {
return m * (double)x + n
}
} }
HashMap<String, Function> estimators;
/** /**
* Initialize the estimator with data recorded by K8sRuntimeRecorder * Returns an estimation of the task runtime in milliseconds
* @param dataFilePath * @param taskName the name of the task
* @param inputSize the total input size in bytes
* @return estimated task runtime in milliseconds OR infinity if the task is unknown.
*/ */
K8sRuntimeEstimator(String dataFilePath) { abstract double estimate(String taskName, long inputSize);
protected long getTaskHandlerInputSize(K8sTaskHandler handler) {
long inputSizeSum = 0
// File input
def inputFiles = handler.task.getInputFilesMap()
for (Map.Entry<String, Path> f : inputFiles) {
try {
inputSizeSum += Files.size(f.value)
} catch (IOException ex) {
log.error "[K8s] failed to get size of input file ${f.value} of task ${task.task.name}: ${ex.message}"
}
}
// Non file input
def inputs = handler.task.getInputs()
for (Map.Entry i : inputs) {
inputSizeSum += i.value.toString().length()
}
return inputSizeSum
}
/// @brief Parses the runtime recording data file
/// @return Map from task name to list of recordings, where each recording is a tuple (input-size, runtime-in-ms)
protected HashMap<String, ArrayList<Tuple2<Long, Long>>> parseDataFile(String dataFilePath) {
HashMap<String, ArrayList<Tuple2<Long, Long>>> data = new HashMap<>(); HashMap<String, ArrayList<Tuple2<Long, Long>>> data = new HashMap<>();
try { try {
BufferedReader reader = new BufferedReader(new FileReader(dataFilePath)) BufferedReader reader = new BufferedReader(new FileReader(dataFilePath))
@@ -60,98 +79,6 @@ class K8sRuntimeEstimator {
} catch (IOException ex) { } catch (IOException ex) {
log.error "[K8s] Failed to load ${dataFilePath}: ${ex.message}" log.error "[K8s] Failed to load ${dataFilePath}: ${ex.message}"
} }
return data
fit(data)
}
/**
* Initializes the runtime estimator with statically known data.
* @param data Map from task name to list of recordings, where each recording is a tuple (input-size, runtime-in-ms)
*/
K8sRuntimeEstimator(HashMap<String, ArrayList<Tuple2<Long, Long>>> data) {
fit(data)
}
/**
* Returns an estimation of the task runtime in milliseconds
* @param handler the task handler
* @return estimated task runtime in milliseconds OR infinity if the task is unknown.
*/
double estimate(K8sTaskHandler handler) {
long x = getTaskHandlerInputSize(handler)
return estimate(handler.task.processor.name, x)
}
/**
* Returns an estimation of the task runtime in milliseconds
* @param taskName the name of the task
* @param inputSize the total input size in bytes
* @return estimated task runtime in milliseconds OR infinity if the task is unknown.
*/
double estimate(String taskName, long inputSize) {
Function estimator = estimators.get(taskName)
if (estimator == null) {
//log.warn "[K8s] Unable to estimate take ${taskName}: unknown task"
return Double.POSITIVE_INFINITY
}
return estimator.estimate(inputSize)
}
private void fit(HashMap<String, ArrayList<Tuple2<Long, Long>>> data) {
estimators = new HashMap<>()
for (Map.Entry<String, ArrayList<Tuple2<Long, Long>>> t : data) {
Function f = fit(t.value)
estimators.put(t.key, f)
}
}
/**
* Uses linear regression to fit a linear function (y = m * x + n) to the provided observations
* @param observations list of tuples (input size, runtime in ms)
* @return linear function fitted to the input
*/
private Function fit(ArrayList<Tuple2<Long, Long>> observations) throws IllegalArgumentException {
int n = observations.size()
if (n > 1) {
double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0
for (Tuple2<Long, Long> o : observations) {
double x = (double) o.get(0)
double y = (double) o.get(1)
sumX += x
sumY += y
sumXY += x * y
sumX2 += x * x
}
double m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
return new Function(
m,
(sumY - m * sumX) / n
)
} else if (n == 1) {
// Special case: We only have 1 measurement. We will just assume that the runtime is constant,
// because in our observed data, it is.
return new Function(0.0, (double) observations[0].get(1))
}
throw new IllegalArgumentException("requires at least 1 observation")
}
private long getTaskHandlerInputSize(K8sTaskHandler handler) {
long inputSizeSum = 0
// File input
def inputFiles = handler.task.getInputFilesMap()
for (Map.Entry<String, Path> f : inputFiles) {
try {
inputSizeSum += Files.size(f.value)
} catch (IOException ex) {
log.error "[K8s] failed to get size of input file ${f.value} of task ${task.task.name}: ${ex.message}"
}
}
// Non file input
def inputs = handler.task.getInputs()
for (Map.Entry i : inputs) {
inputSizeSum += i.value.toString().length()
}
return inputSizeSum
} }
} }

View File

@@ -46,6 +46,7 @@ class K8sRuntimeRecorder {
inputSizeSum += i.value.toString().length() inputSizeSum += i.value.toString().length()
} }
log.info "[K8s] task ${task.task.processor.name} - input ${inputSizeSum} bytes - ran ${runtimeMilis} ms (${task.startTimeMillis} -> ${task.completeTimeMillis})"
records.add(new K8sRuntimeRecord(task.task.processor.name, inputSizeSum, runtimeMilis)) records.add(new K8sRuntimeRecord(task.task.processor.name, inputSizeSum, runtimeMilis))
} }

View File

@@ -1,9 +1,8 @@
package nextflow.k8s package nextflow.k8s
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j import groovy.util.logging.Slf4j
import nextflow.k8s.client.K8sClient
import nextflow.util.Duration import nextflow.util.Duration
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
@@ -32,7 +31,7 @@ class K8sTaskScheduler implements Runnable {
queue.add(new K8sSchedulingRequest(handler)) queue.add(new K8sSchedulingRequest(handler))
final pending = new ArrayList<K8sSchedulingRequest>(queue) final pending = new ArrayList<K8sSchedulingRequest>(queue)
if (strategy.scheduleImmediately(this, pending)) if (strategy.scheduleImmediately(this, pending))
drain() schedule()
} }
/** /**
@@ -45,23 +44,23 @@ class K8sTaskScheduler implements Runnable {
/* If we have pending tasks, now would be a good time to schedule a new one. /* If we have pending tasks, now would be a good time to schedule a new one.
* Because resources were freed right now */ * Because resources were freed right now */
if (queue.size() > 0) if (queue.size() > 0)
drain() schedule()
} }
protected synchronized void drain() { protected synchronized void schedule() {
while( true ) { final pending = new ArrayList<K8sSchedulingRequest>(queue)
final pending = new ArrayList<K8sSchedulingRequest>(queue) final decision = strategy.schedule(this, pending)
final decision = strategy.schedule(this, pending)
if ( !decision ) if (!decision)
return return
if ( !queue.remove(decision.request) ) if (!queue.remove(decision.request)) {
continue log.warn "[K8s] failed to remove selected task from queue ${decision.request.task.name}"
return
log.info "[K8s] launching queued task ${decision.request.task.name} on node: ${decision.nodeName}"
decision.request.handler.submitNow(decision.nodeName)
} }
log.info "[K8s] launching queued task ${decision.request.task.name} on node: ${decision.nodeName}"
decision.request.handler.submitNow(decision.nodeName)
} }
/* Scheduling Strategy Interface */ /* Scheduling Strategy Interface */
@@ -75,9 +74,14 @@ class K8sTaskScheduler implements Runnable {
void run() { void run() {
this.shouldStop = false this.shouldStop = false
final interval = this.interval final interval = this.interval
if (interval.toMillis() == 0L) {
log.info("[K8s] scheduler loop disabled (interval is 0)")
return
}
log.info("[K8s] launched scheduler loop (${interval.toString()} interval)")
while (!shouldStop) { while (!shouldStop) {
sleep(interval.toMillis()) sleep(interval.toMillis())
drain() schedule()
} }
log.info("[K8s] terminated scheduler loop") log.info("[K8s] terminated scheduler loop")
} }

View File

@@ -684,9 +684,9 @@ class K8sClient {
'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb',
'k', 'm', 'g', 't', 'p', 'e'] 'k', 'm', 'g', 't', 'p', 'e']
// For CPU, we only care about 'm' (millicores) and 'n' (nanocores) suffixes // For CPU, we only care about 'm' (millicores), 'u' (microcores) and 'n' (nanocores) suffixes
if (forCpu) { if (forCpu) {
suffixes = ['m', 'n'] suffixes = ['m', 'u', 'n']
} }
for (def s : suffixes) { for (def s : suffixes) {
@@ -711,13 +711,16 @@ class K8sClient {
} }
if (forCpu) { if (forCpu) {
// CPU: handle millicores (m) and nanocores (n) suffixes // CPU: handle millicores (m), microcores (u) and nanocores (n) suffixes
if (suffix == 'm') { if (suffix == 'm') {
// Already in millicores // Already in millicores
return (long)(numericValue) return (long)(numericValue)
} else if (suffix == 'n') { } else if (suffix == 'n') {
// Nanocores: convert to millicores (1 millicore = 1,000,000 nanocores) // Nanocores: convert to millicores (1 millicore = 1,000,000 nanocores)
return (long)(numericValue / 1000000.0) return (long)(numericValue / 1000000.0)
} else if (suffix == 'u') {
// Microcores: convert to millicores (1 millicore = 1,000 microcores)
return (long)(numericValue / 1000)
} else { } else {
// Default is cores, convert to millicores // Default is cores, convert to millicores
return (long)(numericValue * 1000) return (long)(numericValue * 1000)
@@ -853,6 +856,130 @@ class K8sClient {
return result return result
} }
/**
* Query the total CPU allocated to pods on a node (sum of pod requests)
* @param nodeName The name of the node
* @return The allocated CPU in millicores as a Long, or null if unparseable
*/
Long getNodeCpuAllocatedMillis(String nodeName) {
assert nodeName
try {
// List all pods on the node and sum their CPU requests
final action = "/api/v1/pods?fieldSelector=spec.nodeName=$nodeName"
final resp = get(action)
trace('GET', action, resp.text)
final podList = new K8sResponseJson(resp.text)
final items = podList.items as List<Map>
long totalAllocated = 0L
if (items) {
for (Map pod : items) {
final spec = pod.spec as Map
if (spec) {
final containers = spec.containers as List<Map>
if (containers) {
for (Map container : containers) {
final resources = container.resources as Map
final requests = resources?.requests as Map
if (requests?.cpu) {
final cpuStr = requests.cpu as String
final cpuMillis = parseK8sQuantity(cpuStr, true)
if (cpuMillis != null) {
totalAllocated += cpuMillis
}
}
}
}
}
}
}
return totalAllocated
}
catch (Exception e) {
log.warn("Failed to get allocated CPU for node $nodeName: ${e.message}")
return null
}
}
/**
* Query the available CPU on a node (allocatable - allocated)
* For scheduling purposes, we need allocated (requests), not usage (from metrics server)
* @param nodeName The name of the node
* @return The available CPU in millicores as a Long, or null if unparseable
*/
Long getNodeCpuAvailableMillis(String nodeName) {
assert nodeName
final capacity = getNodeCpuCapacityMillis(nodeName)
final allocated = getNodeCpuAllocatedMillis(nodeName)
if (capacity == null || allocated == null) {
return null
}
return capacity - allocated
}
/**
* Query the total memory allocated to pods on a node (sum of pod requests)
* @param nodeName The name of the node
* @return The allocated memory in bytes as a Long, or null if unparseable
*/
Long getNodeMemoryAllocatedBytes(String nodeName) {
assert nodeName
try {
// List all pods on the node and sum their memory requests
final action = "/api/v1/pods?fieldSelector=spec.nodeName=$nodeName"
final resp = get(action)
trace('GET', action, resp.text)
final podList = new K8sResponseJson(resp.text)
final items = podList.items as List<Map>
long totalAllocated = 0L
if (items) {
for (Map pod : items) {
final spec = pod.spec as Map
if (spec) {
final containers = spec.containers as List<Map>
if (containers) {
for (Map container : containers) {
final resources = container.resources as Map
final requests = resources?.requests as Map
if (requests?.memory) {
final memStr = requests.memory as String
final memBytes = parseK8sQuantity(memStr, false)
if (memBytes != null) {
totalAllocated += memBytes
}
}
}
}
}
}
}
return totalAllocated
}
catch (Exception e) {
log.warn("Failed to get allocated memory for node $nodeName: ${e.message}")
return null
}
}
/**
* Query the available memory on a node (allocatable - allocated)
* For scheduling purposes, we need allocated (requests), not usage (from metrics server)
* @param nodeName The name of the node
* @return The available memory in bytes as a Long, or null if unparseable
*/
Long getNodeMemoryAvailableBytes(String nodeName) {
assert nodeName
final capacity = getNodeMemoryCapacityBytes(nodeName)
final allocated = getNodeMemoryAllocatedBytes(nodeName)
if (capacity == null || allocated == null) {
return null
}
return capacity - allocated
}
protected void checkInvalidWaitingState( Map waiting, K8sResponseJson resp ) { protected void checkInvalidWaitingState( Map waiting, K8sResponseJson resp ) {
if( waiting.reason == 'ErrImagePull' || waiting.reason == 'ImagePullBackOff') { if( waiting.reason == 'ErrImagePull' || waiting.reason == 'ImagePullBackOff') {
def message = "K8s pod image cannot be pulled" def message = "K8s pod image cannot be pulled"

View File

@@ -34,7 +34,7 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
} }
private static int getTaskCPURequirement(TaskRun task) { private static int getTaskCPURequirement(TaskRun task) {
return task.config.hasCpus() ? task.config.getCpus() : 1 return task.config.hasCpus() ? task.config.getCpus() * 1000 : 1000
} }
@Slf4j @Slf4j
@@ -68,26 +68,35 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
/* Return the number of available (unoccupied) bytes */ /* Return the number of available (unoccupied) bytes */
long getAvailableMemory() { long getAvailableMemory() {
def k8sClient = clientGetter.getClient() def k8sClient = clientGetter.getClient()
Long capacity = k8sClient.getNodeMemoryCapacityBytes(this.name) Long available = k8sClient.getNodeMemoryAvailableBytes(this.name)
Long used = k8sClient.getNodeMemoryUsedBytes(this.name) if (available == null) {
if (capacity == null || used == null) { log.warn "[K8s] failed to retrieve available memory for node ${name}"
log.warn "[K8s] failed to retrieve memory statistics for node ${name}" // Fallback: try capacity - allocated
Long capacity = k8sClient.getNodeMemoryCapacityBytes(this.name)
Long allocated = k8sClient.getNodeMemoryAllocatedBytes(this.name)
if (capacity != null && allocated != null) {
return capacity - allocated
}
return 0 return 0
} }
return capacity.longValue() - used.longValue() return available
} }
/* Return the number of available (unoccupied) cpu cores */ /* Return the number of available (unoccupied) cpu cores */
long getAvailableCPUs() { long getAvailableCPUs() {
def k8sClient = clientGetter.getClient() def k8sClient = clientGetter.getClient()
Long capacity = k8sClient.getNodeCpuCapacityMillis(this.name) Long available = k8sClient.getNodeCpuAvailableMillis(this.name)
Long used = k8sClient.getNodeCpuUsedMillis(this.name) if (available == null) {
if (capacity == null || used == null) { log.warn "[K8s] failed to retrieve available CPU for node ${name}"
log.warn "[K8s] failed to retrieve cpu statistics for node ${name}: capacity ${capacity}, used ${used}" // Fallback: try capacity - allocated
Long capacity = k8sClient.getNodeCpuCapacityMillis(this.name)
Long allocated = k8sClient.getNodeCpuAllocatedMillis(this.name)
if (capacity != null && allocated != null) {
return capacity - allocated
}
return 0 return 0
} }
long availMilis = capacity.longValue() - used.longValue() return available
return availMilis.intdiv(1000)
} }
/* Return the total amount of installed memory */ /* Return the total amount of installed memory */
@@ -101,7 +110,7 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
long getCPUCount() { long getCPUCount() {
def k8sClient = clientGetter.getClient() def k8sClient = clientGetter.getClient()
Long capacity = k8sClient.getNodeCpuCapacityMillis(this.name) Long capacity = k8sClient.getNodeCpuCapacityMillis(this.name)
return capacity != null ? capacity.longValue().intdiv(1000) : 0 return capacity != null ? capacity.longValue(): 0
} }
// Sets the frequency to the max. requested frequency of all currently running tasks. // Sets the frequency to the max. requested frequency of all currently running tasks.
@@ -170,6 +179,8 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
private K8sClientGetter clientGetter private K8sClientGetter clientGetter
boolean fullSpeedMode
K8sDVFSSchedulingStrategy(K8sRuntimeEstimator runtimeEstimator, K8sDVFSClient dvfsClient, K8sClientGetter clientGetter) { K8sDVFSSchedulingStrategy(K8sRuntimeEstimator runtimeEstimator, K8sDVFSClient dvfsClient, K8sClientGetter clientGetter) {
this.runtimeEstimator = runtimeEstimator this.runtimeEstimator = runtimeEstimator
this.dvfsClient = dvfsClient this.dvfsClient = dvfsClient
@@ -205,7 +216,7 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
final double taskEstimation = runtimeEstimator.estimate(req.handler) final double taskEstimation = runtimeEstimator.estimate(req.handler)
final boolean isCriticalPath = taskEstimation > averageRuntime final boolean isCriticalPath = taskEstimation > averageRuntime
long frequency = globalMaxFrequency long frequency = globalMaxFrequency
if (!isCriticalPath) { if (!isCriticalPath && !fullSpeedMode) {
/* Set frequency so that we expect the runtime to be close to the mean runtime. */ /* Set frequency so that we expect the runtime to be close to the mean runtime. */
frequency = (long)Math.floor((taskEstimation * globalMaxFrequency) / averageRuntime) frequency = (long)Math.floor((taskEstimation * globalMaxFrequency) / averageRuntime)
frequency = Math.max(frequency, globalMinFrequency) frequency = Math.max(frequency, globalMinFrequency)
@@ -220,6 +231,10 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
return null return null
} }
/* No node can currently execute this task, but it should be possible in the future */ /* No node can currently execute this task, but it should be possible in the future */
/* log.info "[K8s] ${req.task} can not be scheduled: ${getTaskMemoryRequirment(req.task)} bytes ${getTaskCPURequirement(req.task)} CPUs"
for (WorkerNode n : this.nodes) {
log.info "[K8s] node ${n.name} - ${n.availableMemory}, ${n.availableCPUs}"
} */
continue continue
} }
@@ -236,15 +251,24 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
closest.assignTask(req.task, frequency, dvfsClient) closest.assignTask(req.task, frequency, dvfsClient)
taskToNode.put(req.task.hash.toString(), closest) taskToNode.put(req.task.hash.toString(), closest)
log.info "[K8s] DVFS: Assigned task ${req.task} to node ${closest.name} - ${taskToNode.size()} assigned tasks"
return new K8sSchedulingDecision(req, closest.name) return new K8sSchedulingDecision(req, closest.name)
} }
log.info "[K8s] unable to schedule any task. The queue contains ${queue.size()} tasks."
return null return null
} }
@Override @Override
boolean scheduleImmediately(K8sTaskScheduler scheduler, List<K8sSchedulingRequest> queue) { boolean scheduleImmediately(K8sTaskScheduler scheduler, List<K8sSchedulingRequest> queue) {
return queue != null && queue.size() > 0 && taskToNode.size() < nodes.size() if (nodes.isEmpty()) {
if (!initNodes(scheduler))
return false
}
/* We want to schedule immediately if there are unoccupied nodes */
boolean doIt = queue != null && queue.size() > 0 && taskToNode.size() < nodes.size()
log.info "[K8s] scheduleImmediately: ${queue.size()} tasks in queue, ${taskToNode.size()} tasks running on ${nodes.size()} nodes: ${doIt}"
return doIt
} }
@Override @Override
@@ -267,9 +291,14 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
} else { } else {
log.warn "[K8s] no node recorded for task ${task.toString()}" log.warn "[K8s] no node recorded for task ${task.toString()}"
} }
log.info "[K8s] task ${task.toString()} finished - ${taskToNode.size()} tasks running"
if (node != null) {
log.info "[K8s] task ran on node ${node.name} - ${node.availableMemory} bytes ${node.availableCPUs}"
}
} }
private boolean initNodes(K8sTaskScheduler scheduler) { private synchronized boolean initNodes(K8sTaskScheduler scheduler) {
this.globalMaxFrequency = Long.MAX_VALUE this.globalMaxFrequency = Long.MAX_VALUE
final nodes = scheduler.getNodes() final nodes = scheduler.getNodes()
for (String node : nodes) { for (String node : nodes) {
@@ -299,7 +328,7 @@ class K8sDVFSSchedulingStrategy implements K8sSchedulingStrategy {
final int reqCPUs = getTaskCPURequirement(task) final int reqCPUs = getTaskCPURequirement(task)
ArrayList<WorkerNode> suitableNodes = new ArrayList<>() ArrayList<WorkerNode> suitableNodes = new ArrayList<>()
for (WorkerNode n : nodes) { for (WorkerNode n : nodes) {
if (n.availableMemory > reqBytes && n.availableCPUs > reqCPUs) { if (n.availableMemory >= reqBytes && n.availableCPUs >= reqCPUs) {
log.info "[K8s] task ${task.name}: ${reqBytes} bytes ${reqCPUs} cpus: node ${n.name} has ${n.availableMemory} bytes, ${n.availableCPUs} cpus" log.info "[K8s] task ${task.name}: ${reqBytes} bytes ${reqCPUs} cpus: node ${n.name} has ${n.availableMemory} bytes, ${n.availableCPUs} cpus"
suitableNodes.add(n) suitableNodes.add(n)
} }

View File

@@ -20,13 +20,13 @@ k8s {
storageMountPath = '/workspace' storageMountPath = '/workspace'
launchDir = '/workspace/launch' launchDir = '/workspace/launch'
projectDir = '/workspace/projects' projectDir = '/workspace/projects'
cleanup = false cleanup = true
nextflowImage = 'gitea.kleine.eulenhexe.de/kevin/ma/nextflow-dvfs:0.6.29' nextflowImage = 'gitea.kleine.eulenhexe.de/kevin/ma/nextflow-dvfs:0.8.18'
imagePullPolicy = 'IfNotPresent' imagePullPolicy = 'IfNotPresent'
schedulerInterval = '10s' schedulerInterval = '1m'
recordTaskRuntimes = true recordTaskRuntimes = true
schedulingStrategy = 'DVFS' schedulingStrategy = 'DVFS-SPEED'
nodeInit { nodeInit {
enabled = true enabled = true