Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
53525f9
✨ Add optional Slurm launch validation
flowerthrower Sep 22, 2026
7d06dd2
📝 Record completed provider validation
flowerthrower Sep 22, 2026
4f5edee
🔧 Apply current validation formatting
flowerthrower Sep 22, 2026
82873e4
🔀 Update Slurm validation with checker fixes
flowerthrower Sep 23, 2026
4899499
🧹 Remove Slurm validation implementation plan
flowerthrower Sep 23, 2026
c201820
🔀 Integrate checker runtime and coverage fixes
flowerthrower Sep 23, 2026
5bee90b
🐛 Validate tasks when launch environments differ
flowerthrower Sep 23, 2026
7d035f8
Merge branch 'codex/qdmi-device-checker' into codex/slurm-launch-vali…
flowerthrower Sep 23, 2026
368c35e
♻️ Share one launch check per node and step
flowerthrower Sep 28, 2026
c56a52b
🔀 Include checker coverage in launch validation
flowerthrower Sep 28, 2026
2893523
🔀 Update the Slurm launch-validation base
flowerthrower Sep 30, 2026
42d9146
🔀 Update validation with current checker base
flowerthrower Oct 6, 2026
8dcffc4
🔀 Include the installed checker lookup fix
flowerthrower Oct 6, 2026
4bbfa30
🧪 Distinguish Slurm cancellation from timeout
flowerthrower Oct 6, 2026
b530893
🔀 Merge updated QDMI checker into validation
flowerthrower Oct 9, 2026
705e802
🔀 Preserve checker build paths in validation
flowerthrower Oct 9, 2026
5ac05d8
🔀 Update the checker base
flowerthrower Oct 9, 2026
823efc0
🔀 Update checker cleanup coverage
flowerthrower Oct 9, 2026
b05bf61
🔀 Update Slurm session documentation
flowerthrower Oct 9, 2026
3275431
🧹 Finish Slurm validation review fixes
burgholzer Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/slurm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
sudo apt-get install --no-install-recommends -y clang-tidy-23 libslurm-dev
cmake -S spank -B build/spank -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build/spank
clang-tidy-23 -p build/spank spank/spank.cpp --warnings-as-errors='*'
clang-tidy-23 -p build/spank spank/spank.cpp --header-filter='(^|/)spank/' --warnings-as-errors='*'

- name: Build the MQT Core wheel
timeout-minutes: 15
Expand Down
45 changes: 45 additions & 0 deletions docs/qdmi/slurm.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,51 @@ Provider-specific installation and credentials remain in the
[Braket guide](https://github.com/munich-quantum-software/amazon-braket-qdmi-device)
and [IQM guide](https://github.com/iqm-finland/QDMI-on-IQM).

## Optionally validate before task launch

Administrators can enable a bounded readiness check by adding the absolute
checker path to the shared module's plugstack line:

```ini
required /usr/local/lib/slurm/mqt-core-qdmi-spank.so licenses=mqt.sc.default validate=/usr/local/bin/mqt-core-qdmi-check validation_timeout=30
```

Validation is disabled when `validate` is absent. `validation_timeout` defaults
to 30 seconds and accepts whole seconds from 1 to 3600. The selected license
must name one configured device with an implicit count or `:1`.

The module runs the checker as the job user with the job environment after
configuration injection. It does not load a provider in a Slurm daemon. An
unavailable device, checker failure, or timeout prevents tasks using that check
from starting and does not drain the node. The checker accepts both `IDLE` and
`BUSY`; it does not reserve the device or authorize a later submission.

Each node runs one check per job step and shares the result across its tasks.
Tasks on another node may already have started when a check fails. All tasks
must use the same site-established provider runtime, catalogue, and credentials
on that node, configured before this hook. Place the module after plugins that
set these inputs. Task prologs run later and must not change them.

The configured checker must use the workload's provider runtime and have its
[device catalogue](driver.md#check-a-device-before-launch) available. It cannot
validate a virtual environment, module, credentials, or container activated
later in a batch script. For such jobs, administrators must leave automatic
validation disabled. Job authors can run the checker inside the chosen
environment after setup, for example:

```bash
#!/bin/bash
#SBATCH --licenses=mqt.sc.default
set -eu
source /path/to/venv/bin/activate
export MQT_CORE_QDMI_CONFIG_FILE=/path/to/qdmi.json
mqt-core-qdmi-check --device mqt.sc.default --timeout 30
python workload.py
```

Both forms provide a readiness snapshot. The application must still handle
changes in device availability and credentials.

## Submit a DDSIM job

Save this program as `bell.py` in a location that all compute nodes can read:
Expand Down
179 changes: 179 additions & 0 deletions spank/Validation.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH
* All rights reserved.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <https://www.gnu.org/licenses/>.
*/

#pragma once

#include <array>
#include <atomic>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <fcntl.h>
#include <memory>
#include <signal.h> // NOLINT(modernize-deprecated-headers)
#include <stdexcept>
#include <string>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>

namespace mqt::spank {

class Validation final {
enum class State : uint8_t { Empty, Checking, Available, Failed };
struct Shared {
std::atomic<State> state{State::Empty};
};
static_assert(std::atomic<State>::is_always_lock_free);

public:
Validation() = default;
Validation(const Validation&) = delete;
Validation& operator=(const Validation&) = delete;
Validation(Validation&&) = delete;
Validation& operator=(Validation&&) = delete;

~Validation() {
if (shared_ != nullptr) {
munmap(shared_, sizeof(Shared));
}
}

void prepare() {
auto* memory = mmap(nullptr, sizeof(Shared), PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (memory == MAP_FAILED) {
throw std::runtime_error("could not prepare launch validation");
}
shared_ = std::construct_at(static_cast<Shared*>(memory));
}

void check(const std::string& executable, const int timeout,
const std::string& device, char* const* environment) {
if (shared_ == nullptr) {
throw std::runtime_error("launch validation was not prepared");
}
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(timeout + 2);
auto expected = State::Empty;
if (shared_->state.compare_exchange_strong(expected, State::Checking)) {
const bool available = execute(executable, timeout, device, environment);
shared_->state.store(available ? State::Available : State::Failed,
std::memory_order_release);
}

while (true) {
const auto state = shared_->state.load(std::memory_order_acquire);
if (state == State::Available) {
return;
}
if (state == State::Failed) {
throw std::runtime_error("QDMI launch validation failed");
}
if (std::chrono::steady_clock::now() >= deadline) {
throw std::runtime_error("QDMI launch validation timed out");
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}

private:
static bool execute(const std::string& executable, const int timeout,
const std::string& device, char* const* environment) {
const auto seconds = std::to_string(timeout);
// execve does not modify the strings despite its mutable argument types.
// NOLINTBEGIN(cppcoreguidelines-pro-type-const-cast)
std::array arguments{
const_cast<char*>(executable.c_str()), const_cast<char*>("--device"),
const_cast<char*>(device.c_str()), const_cast<char*>("--timeout"),
const_cast<char*>(seconds.c_str()), static_cast<char*>(nullptr),
};
// NOLINTEND(cppcoreguidelines-pro-type-const-cast)
const auto parent = getpid();
const auto child = fork();
if (child < 0) {
return false;
}
if (child == 0) {
// Slurm's caught handlers survive fork until execve resets them.
struct sigaction action{};
action.sa_handler = SIG_DFL;
sigemptyset(&action.sa_mask);
sigset_t mask{};
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
if (sigaction(SIGTERM, &action, nullptr) != 0 ||
sigprocmask(SIG_UNBLOCK, &mask, nullptr) != 0) {
_exit(1);
}
// prctl uses the Linux variadic C ABI.
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg)
if (prctl(PR_SET_PDEATHSIG, SIGTERM) != 0 || getppid() != parent ||
setpgid(0, 0) != 0) {
_exit(1);
}
// Never send provider or launcher diagnostics to Slurm's log stream.
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg)
const auto sink = open("/dev/null", O_RDWR);
if (sink < 0 || dup2(sink, STDIN_FILENO) < 0 ||
dup2(sink, STDOUT_FILENO) < 0 || dup2(sink, STDERR_FILENO) < 0) {
_exit(1);
}
if (sink > STDERR_FILENO) {
close(sink);
}
execve(executable.c_str(), arguments.data(), environment);
_exit(1);
}
static_cast<void>(setpgid(child, child));
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(timeout + 1);
bool available = false;
while (true) {
siginfo_t status{};
const auto result = waitid(P_PID, static_cast<id_t>(child), &status,
WEXITED | WNOHANG | WNOWAIT);
if (result == 0 && status.si_pid == child) {
available = status.si_code == CLD_EXITED && status.si_status == 0;
break;
}
if ((result < 0 && errno != EINTR) ||
std::chrono::steady_clock::now() >= deadline) {
static_cast<void>(kill(child, SIGTERM));
std::this_thread::sleep_for(std::chrono::milliseconds(100));
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
// Reap only after cleanup, so the checker PID cannot be reused.
static_cast<void>(kill(-child, SIGKILL));
static_cast<void>(kill(child, SIGKILL));
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
}
return available;
}

Shared* shared_ = nullptr;
};

} // namespace mqt::spank
74 changes: 71 additions & 3 deletions spank/spank.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@
/// @brief Transport administrator-declared configuration references to QDMI
/// jobs.

#include "Validation.hpp"

#include <algorithm>
#include <charconv>
#include <cstddef>
#include <exception>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <vector>

extern "C" {
Expand Down Expand Up @@ -159,6 +163,25 @@ class Configuration final {
throw std::runtime_error("invalid catalogue reference");
}
catalogueDefault_ = value;
} else if (key == "validate" && checker_.empty()) {
if (!validValue(value) || !value.starts_with('/')) {
throw std::runtime_error(
"validate requires an absolute checker path");
}
checker_ = value;
} else if (key == "validation_timeout" && !hasTimeout_) {
// from_chars accepts the bounds of this string view as pointers.
// NOLINTBEGIN(cppcoreguidelines-pro-bounds-pointer-arithmetic)
const auto parsed = std::from_chars(
value.data(), value.data() + value.size(), timeout_);
if (parsed.ec != std::errc{} ||
parsed.ptr != value.data() + value.size() || timeout_ < 1 ||
timeout_ > 3600) {
throw std::runtime_error(
"validation_timeout must be 1 to 3600 seconds");
}
// NOLINTEND(cppcoreguidelines-pro-bounds-pointer-arithmetic)
hasTimeout_ = true;
} else if (key == "reference") {
const auto nameEnd = value.find(':');
const auto idsEnd =
Expand Down Expand Up @@ -199,6 +222,9 @@ class Configuration final {
if (licenses_.empty()) {
throw std::runtime_error("configure concrete license IDs with licenses=");
}
if (hasTimeout_ && checker_.empty()) {
throw std::runtime_error("validation_timeout requires validate");
}
for (const auto& reference : references_) {
for (const auto& id : reference.licenses) {
if (std::ranges::find(licenses_, id) == licenses_.end()) {
Expand Down Expand Up @@ -246,7 +272,23 @@ class Configuration final {
references_[static_cast<size_t>(option) - 1].option = argument;
}

void inject(spank_t spank) const {
[[nodiscard]] bool validationEnabled() const { return !checker_.empty(); }

void validate(spank_t spank, mqt::spank::Validation& validation,
const std::string& device) const {
// S_JOB_ENV requires a mutable char*** output parameter.
char** values = nullptr; // NOLINT(misc-const-correctness)
// Slurm exposes item lookup through a variadic C ABI.
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg)
if (spank_get_item(spank, S_JOB_ENV, &values) != ESPANK_SUCCESS ||
values == nullptr) {
throw std::runtime_error("could not read the job environment");
}
// S_JOB_ENV is a borrowed, null-terminated array owned by Slurm.
validation.check(checker_, timeout_, device, values);
}

auto inject(spank_t spank) const -> std::optional<std::string> {
const auto expression = jobEnvironment(spank, LICENSE_ENVIRONMENT);
const auto selected = std::ranges::find_if(licenses_, [&](const auto& id) {
return expression && (*expression == id || *expression == id + ":1");
Expand All @@ -259,7 +301,7 @@ class Configuration final {
throw std::runtime_error(
"QDMI options require a configured device license");
}
return;
return std::nullopt;
}

apply(spank, CATALOGUE_ENVIRONMENT, catalogueDefault_, catalogueOption_);
Expand All @@ -275,6 +317,10 @@ class Configuration final {
apply(spank, reference.environment.c_str(), reference.defaultValue,
reference.option);
}
if (!validationEnabled()) {
return std::nullopt;
}
return *selected;
}

private:
Expand Down Expand Up @@ -307,12 +353,19 @@ class Configuration final {
std::vector<Reference> references_;
std::optional<std::string> catalogueDefault_;
std::optional<std::string> catalogueOption_;
std::string checker_;
int timeout_ = 30;
bool hasTimeout_ = false;
};

Configuration
configuration; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
bool licenseEnvironmentReady = true;
mqt::spank::Validation
validation; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
bool validationReady = true;

int optionCallback(const int value, const char* argument, int /*remote*/) {
try {
Expand All @@ -336,6 +389,7 @@ int slurm_spank_init(spank_t spank, const int count, char* arguments[]) {
try {
configuration = Configuration{};
licenseEnvironmentReady = true;
validationReady = true;
configuration.parse(count, arguments);
configuration.registerOptions(spank);
return ESPANK_SUCCESS;
Expand All @@ -357,6 +411,14 @@ int slurm_spank_user_init(spank_t spank, int /*count*/, char* /*arguments*/[]) {
licenseEnvironmentReady = false;
fail("could not clear inherited license selection");
}
if (configuration.validationEnabled()) {
try {
validation.prepare();
} catch (...) {
validationReady = false;
fail("could not prepare launch validation");
}
}
}
return ESPANK_SUCCESS;
}
Expand All @@ -369,7 +431,13 @@ int slurm_spank_task_init(spank_t spank, int /*count*/, char* /*arguments*/[]) {
if (!licenseEnvironmentReady) {
throw std::runtime_error("could not clear inherited license selection");
}
configuration.inject(spank);
const auto device = configuration.inject(spank);
if (device) {
if (!validationReady) {
throw std::runtime_error("could not prepare launch validation");
}
configuration.validate(spank, validation, *device);
}
return ESPANK_SUCCESS;
} catch (const std::exception& error) {
fail(error.what());
Expand Down
Loading
Loading