From 6f4c056dc55399c89223a0aca81dcf468fc0d271 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 30 Oct 2026 01:40:27 +0900 Subject: [PATCH 1/8] 0.21.0: deps-cmake compiles a cross build's subproject from the engine's contract When the build's target is not the machine, the subproject is configured with the compilers, words and archiver of the engine's contract for the configuration (${mcpp.contract}, mcpp 2026.10.30.1+), read when the action runs, under Ninja; CMAKE_SYSTEM_NAME is the target's system and the try-compile builds a static library. The package needs mcpp 2026.10.30.1. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/scripts/check-deps-and-qt.sh | 24 ++++- .github/workflows/ci.yml | 5 + README.md | 12 ++- deps/cmake.cppm | 109 +++++++++++++++++++--- docs/deps.md | 21 +++++ docs/plugin-development.md | 2 +- mcpp.toml | 4 +- src/plugins.cppm | 2 +- tests/cmake-cross/answer/CMakeLists.txt | 9 ++ tests/cmake-cross/answer/answer.c | 14 +++ tests/cmake-cross/answer/include/answer.h | 8 ++ tests/cmake-cross/build.mcpp | 10 ++ tests/cmake-cross/mcpp.toml | 22 +++++ tests/cmake-cross/src/main.cpp | 8 ++ 14 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 tests/cmake-cross/answer/CMakeLists.txt create mode 100644 tests/cmake-cross/answer/answer.c create mode 100644 tests/cmake-cross/answer/include/answer.h create mode 100644 tests/cmake-cross/build.mcpp create mode 100644 tests/cmake-cross/mcpp.toml create mode 100644 tests/cmake-cross/src/main.cpp diff --git a/.github/scripts/check-deps-and-qt.sh b/.github/scripts/check-deps-and-qt.sh index 9ab9af6..56cf920 100644 --- a/.github/scripts/check-deps-and-qt.sh +++ b/.github/scripts/check-deps-and-qt.sh @@ -210,6 +210,27 @@ cmake_consumer() { echo "ok: a file added to the subproject re-ran its installation" } +# A BUILD FOR ANOTHER TARGET (0.21.0): the subproject is compiled with the +# compilers and words of the engine's contract for the target, read when the +# action runs, and not with the machine's toolset. On a glibc machine, +# `--target x86_64-linux-musl` must give a subproject compiled against musl. +cmake_cross() { + cd "$ROOT/tests/cmake-cross" + rm -rf target + mkdir -p target/ci + "$MCPP" build --target x86_64-linux-musl > target/ci/build.log 2>&1 || { cat target/ci/build.log; fail "the cross build failed"; } + script=$(find target -path '*deps-cmake/answer/answer.cmake' | head -1) + [ -n "$script" ] || fail "no deps-cmake script was written" + grep -q "the engine's contract for x86_64-linux-musl" "$script" || { cat "$script"; fail "the script does not read the contract"; } + cache=$(find target -path '*deps-cmake/answer/build-*/CMakeCache.txt' | head -1) + grep -q '^CMAKE_SYSTEM_NAME:.*=Linux' "$cache" || { grep CMAKE_SYSTEM_NAME "$cache"; fail "CMAKE_SYSTEM_NAME is not Linux"; } + grep -q '^CMAKE_TRY_COMPILE_TARGET_TYPE:.*=STATIC_LIBRARY' "$cache" || fail "the try-compile links" + "$MCPP" run --target x86_64-linux-musl | tee target/ci/run.log + grep -q '^cmake-cross: the subproject was compiled against not glibc$' target/ci/run.log || + fail "the subproject was compiled with the machine's C library" + echo "ok: a subproject of a cross build is compiled from the engine's contract" +} + # AN INSTALLATION IS BUILT ONCE (0.18.0). deps-cmake keeps what a subproject # installed under a key that holds no path of the machine, and copies it into # the prefix of any later build with the same key. Only a toolset the engine @@ -673,6 +694,7 @@ case "${1:-}" in vcpkg-workspace) vcpkg_workspace ;; cmake-consumer) cmake_consumer ;; cmake-cache) cmake_cache ;; + cmake-cross) cmake_cross ;; qt-consumer) qt_consumer ;; qt-widgets-consumer) qt_widgets_consumer ;; qt-sdk-consumer) qt_sdk_consumer ;; @@ -682,5 +704,5 @@ case "${1:-}" in vcpkg-managed) vcpkg_managed ;; vcpkg-msbuild-refused) vcpkg_msbuild_refused ;; vcpkg-make-port) vcpkg_make_port ;; - *) echo "usage: $0 vcpkg-consumer|vcpkg-libcxx|archive-consumer|vcpkg-workspace|cmake-consumer|cmake-cache|qt-consumer|qt-widgets-consumer|qt-sdk-consumer|qt-import-only|plugin-logic|tool-sources|vcpkg-crt|vcpkg-managed|vcpkg-msbuild-refused|vcpkg-make-port"; exit 2 ;; + *) echo "usage: $0 vcpkg-consumer|vcpkg-libcxx|archive-consumer|vcpkg-workspace|cmake-consumer|cmake-cache|cmake-cross|qt-consumer|qt-widgets-consumer|qt-sdk-consumer|qt-import-only|plugin-logic|tool-sources|vcpkg-crt|vcpkg-managed|vcpkg-msbuild-refused|vcpkg-make-port"; exit 2 ;; esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6356622..f54455c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1756,6 +1756,11 @@ jobs: - name: deps-cmake keeps an installation and takes it for the same key run: bash .github/scripts/check-deps-and-qt.sh cmake-cache + # 0.21.0: a subproject of a build for another target is compiled from the + # engine's contract for it (mcpp 2026.10.30.1+): musl from a glibc machine. + - name: deps-cmake compiles a cross build's subproject from the engine's contract + run: bash .github/scripts/check-deps-and-qt.sh cmake-cross + - name: rules-qt runs moc, rcc, lupdate and lrelease, with the SDK the project declares run: bash .github/scripts/check-deps-and-qt.sh qt-consumer diff --git a/README.md b/README.md index cfdd8f0..cef0443 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ module name the member declares, and configures it there. ```toml [build-dependencies.mcpp] -plugins = { version = "0.20.0", features = ["rules-spirv"], host-module = true } +plugins = { version = "0.21.0", features = ["rules-spirv"], host-module = true } ``` ```cpp @@ -51,9 +51,11 @@ naming it to avoid a download, and the scenarios on both sides -- is in ## Members -From 0.20.0 the package as a whole needs mcpp 2026.10.27.1 (`[package] mcpp`), -whose protocol 17 states a platform's fields and whose `mcpp pack` writes -`mcpp-package.json` beside every staged tree (0.19.0 needed 2026.10.1.3, whose +From 0.21.0 the package as a whole needs mcpp 2026.10.30.1 (`[package] mcpp`), +which writes the configuration's contract that `deps-cmake` compiles a cross +build from (0.20.0 needed 2026.10.27.1, whose protocol 17 states a platform's +fields and whose `mcpp pack` writes `mcpp-package.json` beside every staged +tree; 0.19.0 needed 2026.10.1.3, whose protocol 15 states where each tool and payload comes from); the column states the release each member's behaviour first needed. @@ -76,7 +78,7 @@ the release each member's behaviour first needed. | `dist-web` | `mcpp.dist.web` | 2026.9.13.1 | Copies a `wasm32-emscripten` program and its files into a web directory with an `index.html`. | [dist](docs/dist.md#dist-web) | | `dist-apk` | `mcpp.dist.apk` | 2026.10.1.3 | Packs the native closure into a signed APK or App Bundle, with Java, Kotlin and Maven libraries. | [dist-apk](docs/dist-apk.md) | | `deps-vcpkg` | `mcpp.deps.vcpkg` | 2026.10.1.3 | Installs a `vcpkg.json` manifest as an action with the toolset mcpp resolved, and maps the prefix into the build. | [deps](docs/deps.md#deps-vcpkg) | -| `deps-cmake` | `mcpp.deps.cmake` | 2026.10.1.3 | Builds and installs a CMake subproject as an action with Ninja and the toolset mcpp resolved, keeps the installation for any build with the same key, and maps the prefix into the build. | [deps](docs/deps.md#deps-cmake) | +| `deps-cmake` | `mcpp.deps.cmake` | 2026.10.1.3; a cross build 2026.10.30.1 | Builds and installs a CMake subproject as an action with Ninja and the toolset mcpp resolved -- for another target, the compilers and words of the engine's contract -- keeps the installation for any build with the same key, and maps the prefix into the build. | [deps](docs/deps.md#deps-cmake) | | `deps-archive` | `mcpp.deps.archive` | 2026.10.1.3 | Extracts a zip archive the project keeps and places its tree beside the program. | [deps](docs/deps.md#deps-archive) | Some features add a sub-capability to a member: `dist-apk-kotlin` and diff --git a/deps/cmake.cppm b/deps/cmake.cppm index b96290f..f653d0d 100644 --- a/deps/cmake.cppm +++ b/deps/cmake.cppm @@ -118,8 +118,58 @@ struct options { // `~/.cache/mcpp-plugins/deps-cmake`). Read when the action runs; the place // is no part of what is built. See docs/deps.md. std::string cache; + // `CMAKE_SYSTEM_NAME` of a build for another target (0.21.0). Empty + // derives it from the target's system: Linux, Android, Darwin, iOS, + // Windows, Emscripten, Generic for bare metal, and Linux for an ok image, + // whose C library is POSIX at the source level. + std::string system_name; }; +// The `CMAKE_SYSTEM_NAME` of a target system (`mcpp::target_os()`). +inline std::string cmake_system_name(std::string_view os) { + if (os == "linux" || os == "openkal") return "Linux"; + if (os == "android") return "Android"; + if (os == "macos") return "Darwin"; + if (os == "ios") return "iOS"; + if (os == "windows") return "Windows"; + if (os == "emscripten") return "Emscripten"; + return "Generic"; +} + +// The CMake code that reads the engine's contract for this configuration +// (`${mcpp.contract}`, mcpp 2026.10.30.1+) into variables when the action +// runs: `_mcpp_` and `_mcpp_ld` the programs, `_mcpp_<...>_flags` +// their words joined, `_mcpp_ar` the archiver. Read then and not when this +// program plans: the engine writes the file after build programs have run. +inline std::string contract_reader() { + return R"(if(NOT EXISTS "${MCPP_CONTRACT}") + message(FATAL_ERROR "mcpp.deps.cmake: a build for another target reads the engine's contract, " + "and '${MCPP_CONTRACT}' does not exist; it is written by mcpp 2026.10.30.1 and later") +endif() +file(READ "${MCPP_CONTRACT}" _mcpp_contract) +function(_mcpp_words out) + string(JSON _n LENGTH "${_mcpp_contract}" ${ARGN}) + set(_w "") + if(_n GREATER 0) + math(EXPR _last "${_n} - 1") + foreach(_i RANGE ${_last}) + string(JSON _a GET "${_mcpp_contract}" ${ARGN} ${_i}) + string(APPEND _w " ${_a}") + endforeach() + endif() + string(STRIP "${_w}" _w) + set(${out} "${_w}" PARENT_SCOPE) +endfunction() +foreach(_lang c cxx asm) + string(JSON _mcpp_${_lang} GET "${_mcpp_contract}" compile ${_lang} program) + _mcpp_words(_mcpp_${_lang}_flags compile ${_lang} args) +endforeach() +string(JSON _mcpp_ld GET "${_mcpp_contract}" link program) +string(JSON _mcpp_ar GET "${_mcpp_contract}" link archiver) +_mcpp_words(_mcpp_ld_flags link args) +)"; +} + // The variable a `-D[:]=` argument defines; empty for any // other argument. inline std::string_view defined_variable(std::string_view arg) { @@ -376,11 +426,20 @@ inline prefix use(const options& opt) { const bool chosen = chooses_compiler(opt.cache_args); const bool generatorChosen = chooses_generator(opt.cache_args); const bool visualStudio = opt.generator == options::generator_kind::visual_studio; - auto tools = visualStudio ? ts::resolve(opt.toolset) : ts::resolve_named(opt.toolset); - if (!tools) { - std::cerr << std::format("{}: {}\n", who, tools.error()); + // A BUILD FOR ANOTHER TARGET IS COMPILED AS THE ENGINE COMPILES IT (0.21.0): + // the compilers, target words and archiver of the engine's contract, which + // the action reads when it runs. The toolset this program resolves is the + // machine's, and a cross build linked with it would not be the target's. + const std::string targetTriple = mcpp::target(), hostTriple = mcpp::host(); + const bool contract = !chosen && !targetTriple.empty() && !hostTriple.empty() + && targetTriple != hostTriple; + auto resolvedTools = visualStudio ? ts::resolve(opt.toolset) : ts::resolve_named(opt.toolset); + if (!resolvedTools && !contract) { + std::cerr << std::format("{}: {}\n", who, resolvedTools.error()); return {}; } + const ts::resolved_tools noTools{}; + const ts::resolved_tools* tools = resolvedTools ? &*resolvedTools : &noTools; const std::string crt = opt.crt_linkage.empty() ? tools->crt : opt.crt_linkage; if (!crt.empty() && crt != "static" && crt != "dynamic") { std::cerr << std::format("{}: options::crt_linkage is '{}'; it is \"static\" or \"dynamic\".\n", @@ -388,15 +447,16 @@ inline prefix use(const options& opt) { return {}; } const std::string ninja = mcpp::ninja_program(); - const bool named = !chosen && tools->how == ts::mechanism::chain && !tools->cxx.empty(); - const bool useNinja = named && !generatorChosen && !ninja.empty(); - const bool instance = !chosen && !generatorChosen && tools->how == ts::mechanism::instance; + const bool named = !contract && !chosen && tools->how == ts::mechanism::chain && !tools->cxx.empty(); + const bool useNinja = (named || contract) && !generatorChosen && !ninja.empty(); + const bool instance = !contract && !chosen && !generatorChosen && tools->how == ts::mechanism::instance; // One build directory per toolset statement: CMake refuses a cache made // with another generator or instance. What 0.16.0 configured stays in // `build/`. std::string key; - if (useNinja) key = "ninja;" + tools->identity + ";" + tools->cxx; + if (contract) key = "contract;" + targetTriple; + else if (useNinja) key = "ninja;" + tools->identity + ";" + tools->cxx; else if (named) key = "named;" + tools->identity + ";" + tools->cxx; else if (instance) key = "instance;" + tools->instance_dir + ";" + tools->toolset_version; const std::string name = opt.name.empty() ? source.filename().string() : opt.name; @@ -409,8 +469,9 @@ inline prefix use(const options& opt) { p.include = mcpp::deps::generic(root / opt.dirs.include); p.lib = mcpp::deps::generic(root / opt.dirs.lib); p.bin = mcpp::deps::generic(root / opt.dirs.bin); - p.mechanism = std::string(ts::name(named ? ts::mechanism::chain - : instance ? ts::mechanism::instance : ts::mechanism::detected)); + p.mechanism = contract ? std::string("contract") + : std::string(ts::name(named ? ts::mechanism::chain + : instance ? ts::mechanism::instance : ts::mechanism::detected)); const auto tool = cmake_exe(opt); const std::string cmake = tool.program; @@ -486,21 +547,41 @@ inline prefix use(const options& opt) { + (crt == "dynamic" ? "DLL" : "")); } for (auto const& x : opt.cache_args) same(x); + if (contract) { + same("-DCMAKE_SYSTEM_NAME=" + (opt.system_name.empty() ? cmake_system_name(mcpp::target_os()) + : opt.system_name)); + // A try-compile links nothing: the target's startup files and C + // library reach a program only through the engine's link. + same("-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY"); + keyed.push_back("contract " + targetTriple); + } - std::string steps = "execute_process(COMMAND ${CMAKE_COMMAND}"; + std::string steps = contract ? contract_reader() : std::string(); + steps += "execute_process(COMMAND ${CMAKE_COMMAND}"; for (auto const& x : configure) steps += "\n " + bracket(x); + if (contract) + steps += "\n \"-DCMAKE_C_COMPILER=${_mcpp_c}\" \"-DCMAKE_CXX_COMPILER=${_mcpp_cxx}\"" + "\n \"-DCMAKE_ASM_COMPILER=${_mcpp_asm}\" \"-DCMAKE_AR=${_mcpp_ar}\"" + "\n \"-DCMAKE_C_FLAGS=${_mcpp_c_flags}\" \"-DCMAKE_CXX_FLAGS=${_mcpp_cxx_flags}\"" + "\n \"-DCMAKE_ASM_FLAGS=${_mcpp_asm_flags}\"" + "\n \"-DCMAKE_EXE_LINKER_FLAGS=${_mcpp_ld_flags}\"" + "\n \"-DCMAKE_SHARED_LINKER_FLAGS=${_mcpp_ld_flags}\""; steps += "\n RESULT_VARIABLE rc)\n" "if(NOT rc EQUAL 0)\n message(FATAL_ERROR \"configure exited ${rc}\")\nendif()\n" "execute_process(COMMAND ${CMAKE_COMMAND} --build " + bracket(buildS) + " --config " + bracket(opt.config) + " --target install --parallel\n RESULT_VARIABLE rc)\n" "if(NOT rc EQUAL 0)\n message(FATAL_ERROR \"build and install exited ${rc}\")\nendif()\n"; std::string text = "# Written by mcpp.deps.cmake: configure, build and install " + name + ".\n" - "# toolset: " + ts::describe(*tools) + "\n"; + "# toolset: " + (contract ? std::string("the engine's contract for ") + targetTriple + : ts::describe(*tools)) + "\n"; const auto files = mcpp::deps::files_under(source); // A key states the compilers through the toolset's identity. On the // MSVC ABI that is the MSVC toolset, which a row compiler (clang-cl, // `compiler::row`) is not, so such an installation is not kept. - const bool identified = instance || (named && (!tools->msvc_abi || opt.toolset.cc == ts::compiler::abi_native)); + // A contract build is not kept either: its compilers are named by the + // contract when the action runs, after this key is written. + const bool identified = !contract + && (instance || (named && (!tools->msvc_abi || opt.toolset.cc == ts::compiler::abi_native))); if (!identified || opt.cache == "off") { text += steps; } else { @@ -520,7 +601,9 @@ inline prefix use(const options& opt) { a.id = id.c_str(); a.role = mcpp::roles::prepare; a.description = desc.c_str(); - a.arg(cmake.c_str()).arg("-P").arg(scriptS.c_str()); + a.arg(cmake.c_str()); + if (contract) a.arg("-DMCPP_CONTRACT=${mcpp.contract}"); + a.arg("-P").arg(scriptS.c_str()); // Named tools run with the environment the engine runs them with, and // their directories first on PATH. if (named) { diff --git a/docs/deps.md b/docs/deps.md index d9fcd74..0fc7753 100644 --- a/docs/deps.md +++ b/docs/deps.md @@ -256,6 +256,27 @@ Each toolset statement configures its own build directory, because CMake refuses a cache made with another generator or instance; the directory 0.16.0 configured stays `build/`. +**A build for another target (0.21.0, mcpp 2026.10.30.1+).** When the build's +target is not the machine (`mcpp build --target x86_64-linux-musl`, an ok image +`--target x86_64-openkal`), the toolset above is the machine's and is not used. +The subproject is compiled as the engine compiles the target: the action reads +the engine's contract for the configuration (`${mcpp.contract}`, see mcpp's +docs/30) when it runs, and configures with its compilers +(`CMAKE__COMPILER`), its words (`CMAKE__FLAGS`: target, sysroot, +target-side headers, platform and profile flags), its link words and its +archiver, under Ninja. `CMAKE_SYSTEM_NAME` is the target's system (Linux, +Android, Darwin, iOS, Windows, Emscripten, `Generic` for bare metal, and Linux +for an ok image, whose C library is POSIX at the source level), or +`options::system_name`; `CMAKE_TRY_COMPILE_TARGET_TYPE` is `STATIC_LIBRARY`, +because a program reaches the target's startup files and C library only through +the engine's link. The libraries the subproject installs are linked into the +program by that link. A tool the subproject runs at build time for the machine +(LLVM's `llvm-tblgen`) is built in the host graph, as a build dependency's tool, +and named to the subproject with a `cache_args` entry. An installation of a +cross build is not kept in the cache below: its compilers are named by the +contract when the action runs, after the key is written. Compilers or a +toolchain file in `cache_args` decide here too. + ### An installation is built once (0.18.0) The action keeps what it installed in a cache outside the package, and a later diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 02a964b..970b63f 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -52,7 +52,7 @@ mcpp-index refuses such a package. ```toml [build-dependencies.mcpp] -plugins = { version = "0.20.0", features = ["plugins-core"], host-module = true, reexport = true } +plugins = { version = "0.21.0", features = ["plugins-core"], host-module = true, reexport = true } ``` `reexport = true` is needed when the plugin's consumers import L2 modules in diff --git a/mcpp.toml b/mcpp.toml index c2beb0f..707cbcf 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] name = "plugins" namespace = "mcpp" -version = "0.20.0" +version = "0.21.0" # The first mcpp release that speaks protocol 17 (SPEC-013, mcpp#847): the # platform's directives `mcpp.plugins.platform` states (`link_driver`, # `link_input`, `artifact_name`, `seal`, `run_host`), the `platform` phase, and @@ -9,7 +9,7 @@ version = "0.20.0" # reads. It carries protocol 15 (mcpp#755): `xpkg_source`, `xpkg_program`, # `xpkg_request`, `mcpp::decision`, `mcpp::toolchain`, `mcpp::phase`, and the # `provision = "on-request"` entries below. -mcpp = ">=2026.10.27.1" +mcpp = ">=2026.10.30.1" description = "Official mcpp build plugins: rule packages under mcpp.rules.*, build-time utilities under mcpp.tools.*, each member selected by a feature" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/plugins.cppm b/src/plugins.cppm index 8e30a34..ed8f95f 100644 --- a/src/plugins.cppm +++ b/src/plugins.cppm @@ -49,7 +49,7 @@ export namespace mcpp::plugins { // // One package, one version: the number lives in mcpp.toml, and the CI step // `the collection states its own version` compares the two. -inline constexpr std::string_view version = "0.20.0"; +inline constexpr std::string_view version = "0.21.0"; } // namespace mcpp::plugins diff --git a/tests/cmake-cross/answer/CMakeLists.txt b/tests/cmake-cross/answer/CMakeLists.txt new file mode 100644 index 0000000..cb133ab --- /dev/null +++ b/tests/cmake-cross/answer/CMakeLists.txt @@ -0,0 +1,9 @@ +# A static library whose one function says which C library compiled it. +cmake_minimum_required(VERSION 3.16) +project(answer LANGUAGES C) + +add_library(answer STATIC answer.c) +target_include_directories(answer PUBLIC $) + +install(TARGETS answer ARCHIVE DESTINATION lib) +install(FILES include/answer.h DESTINATION include) diff --git a/tests/cmake-cross/answer/answer.c b/tests/cmake-cross/answer/answer.c new file mode 100644 index 0000000..35ff5e3 --- /dev/null +++ b/tests/cmake-cross/answer/answer.c @@ -0,0 +1,14 @@ +#include "answer.h" +#include + +/* glibc defines __GLIBC__ in , which does not include; + does, and so does every glibc header that a musl one is not. */ +#include + +const char* answer_libc(void) { +#if defined(__GLIBC__) + return "glibc"; +#else + return "not glibc"; +#endif +} diff --git a/tests/cmake-cross/answer/include/answer.h b/tests/cmake-cross/answer/include/answer.h new file mode 100644 index 0000000..c5d972c --- /dev/null +++ b/tests/cmake-cross/answer/include/answer.h @@ -0,0 +1,8 @@ +#pragma once +#ifdef __cplusplus +extern "C" { +#endif +const char* answer_libc(void); +#ifdef __cplusplus +} +#endif diff --git a/tests/cmake-cross/build.mcpp b/tests/cmake-cross/build.mcpp new file mode 100644 index 0000000..dadfce0 --- /dev/null +++ b/tests/cmake-cross/build.mcpp @@ -0,0 +1,10 @@ +import std; +import mcpp; +import mcpp.deps.cmake; + +int main() { + mcpp::deps::cmake::options o; + o.source = "answer"; + o.libraries = { "answer" }; + return mcpp::deps::cmake::use(o) ? 0 : 1; +} diff --git a/tests/cmake-cross/mcpp.toml b/tests/cmake-cross/mcpp.toml new file mode 100644 index 0000000..6a24aab --- /dev/null +++ b/tests/cmake-cross/mcpp.toml @@ -0,0 +1,22 @@ +# Fixture: a CMake subproject built for ANOTHER TARGET (0.21.0). Built with +# `--target x86_64-linux-musl` on a glibc machine, the subproject is compiled +# with the compilers and words of the engine's contract for that target, not +# with the machine's toolset, and linked into the static program. +[package] +name = "cmake-cross" +version = "0.1.0" +description = "Fixture: a CMake subproject compiled for the build's target from the engine's contract" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = true + +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["deps-cmake"], host-module = true } + +[targets.cmake-cross] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/cmake-cross/src/main.cpp b/tests/cmake-cross/src/main.cpp new file mode 100644 index 0000000..863bec1 --- /dev/null +++ b/tests/cmake-cross/src/main.cpp @@ -0,0 +1,8 @@ +#include + +#include + +int main() { + std::printf("cmake-cross: the subproject was compiled against %s\n", answer_libc()); + return 0; +} From d82c89bc9365ec4d14e3c4144797facd07ecdd8d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 30 Oct 2026 02:39:01 +0900 Subject: [PATCH 2/8] ci: the AppImage fixture asks that the refusal lists appimage, not the whole list of an older engine mcpp 2026.10.27.1 lists zip and single-file among the built-in formats. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f54455c..4a0f853 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1206,7 +1206,7 @@ jobs: set -e [ "$rc" = 2 ] || { echo "FAIL: --format bogus exited $rc, expected 2"; exit 1; } [ -z "$so" ] || { echo "FAIL: the refusal wrote to stdout"; exit 1; } - grep -q 'available in this build: tar, dir, appimage' <<<"$se" \ + grep -qE 'available in this build: .*\bappimage\b' <<<"$se" \ || { echo "FAIL: the refusal does not name appimage"; echo "$se"; exit 1; } "$MCPP" pack --format appimage | tee pack.log From 2b0d90c0a60a02c8a479e1f1938bec6379e742cb Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 30 Oct 2026 06:34:20 +0900 Subject: [PATCH 3/8] deps-cmake: a contract word with a space is quoted in the flags string, as CMake splits it Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- deps/cmake.cppm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deps/cmake.cppm b/deps/cmake.cppm index f653d0d..05a0aa4 100644 --- a/deps/cmake.cppm +++ b/deps/cmake.cppm @@ -139,7 +139,8 @@ inline std::string cmake_system_name(std::string_view os) { // The CMake code that reads the engine's contract for this configuration // (`${mcpp.contract}`, mcpp 2026.10.30.1+) into variables when the action // runs: `_mcpp_` and `_mcpp_ld` the programs, `_mcpp_<...>_flags` -// their words joined, `_mcpp_ar` the archiver. Read then and not when this +// their words joined (a word with a space quoted, as CMake splits a flags +// string), `_mcpp_ar` the archiver. Read then and not when this // program plans: the engine writes the file after build programs have run. inline std::string contract_reader() { return R"(if(NOT EXISTS "${MCPP_CONTRACT}") @@ -154,6 +155,9 @@ function(_mcpp_words out) math(EXPR _last "${_n} - 1") foreach(_i RANGE ${_last}) string(JSON _a GET "${_mcpp_contract}" ${ARGN} ${_i}) + if(_a MATCHES " ") + set(_a "\"${_a}\"") + endif() string(APPEND _w " ${_a}") endforeach() endif() From 8d4b150596589da1bec4f4a05ebb9815e4718580 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 07:35:51 +0900 Subject: [PATCH 4/8] fix: keep accelerator targets with their implementation --- .github/workflows/ci.yml | 11 ++- docs/rules.md | 11 ++- mcpp.toml | 19 +--- rules/cuda.cppm | 39 +++----- rules/hip.cppm | 45 +++++---- rules/spirv.cppm | 27 +++--- rules/sycl.cppm | 102 +++++++++++++++----- src/accel.cppm | 126 +++++++++++++++++++++++++ tests/plugin-logic/build.mcpp | 85 +++++++++++++++++ tests/plugin-logic/mcpp.toml | 2 +- tests/sycl-consumer/mcpp.toml | 18 ++-- tests/sycl-consumer/src/cuda/unused.cu | 3 + tests/sycl-consumer/verify-images.py | 39 ++++++++ 13 files changed, 413 insertions(+), 114 deletions(-) create mode 100644 src/accel.cppm create mode 100644 tests/sycl-consumer/src/cuda/unused.cu create mode 100644 tests/sycl-consumer/verify-images.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bacb47e..732032b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ env: # phase -- and `mcpp-package.json` beside every staged tree, which # `mcpp::plugins::package` reads. Until that release exists the jobs build it # from `MCPP_SOURCE_REF` (the repository variable names the branch). - MCPP_VERSION: 2026.10.27.1 + MCPP_VERSION: 2026.11.3.1 # THE RELEASE EVERY JOB FETCHES, WHICH IS NOT ALWAYS THE ONE UNDER TEST. # # `MCPP_VERSION` states the engine this collection needs -- the package floor @@ -94,7 +94,7 @@ env: # # It moves to `MCPP_VERSION` once that release exists, so the ordinary path # runs the engine this collection states. - MCPP_BOOTSTRAP: 2026.10.27.1 + MCPP_BOOTSTRAP: 2026.11.3.1 # AN ENGINE BUILT FROM SOURCE, WHEN A DISPATCH NAMES ONE. # # Empty on every push and pull request, so the steps run the release above, @@ -117,7 +117,7 @@ env: # one-line change here. The mcpp is the bootstrap release, which also builds # the engine under review when `MCPP_SOURCE_REF` names one. MCPPLS_VERSION: 0.0.15 - MCPP_EDITOR_VERSION: 2026.10.27.1 + MCPP_EDITOR_VERSION: 2026.11.3.1 # PINNED, AND WITHOUT IT THE CACHE BELOW CACHED NOTHING. # # A released mcpp is self-contained: with no `MCPP_HOME`, `mcpp self env` @@ -1470,6 +1470,11 @@ jobs: "$MCPP" build test -f $(sh "$GITHUB_WORKSPACE/tests/out-dir.sh")/saxpy.sycl.o test -f $(sh "$GITHUB_WORKSPACE/tests/out-dir.sh")/sycl_device_link.o + python3 verify-images.py sm_89 + "$MCPP" build --accel 'sycl+{nvptx64-sm_89,nvptx64-sm_90}' + python3 verify-images.py sm_89,sm_90 + "$MCPP" build --accel 'sycl+{spir64,nvptx64-sm_89}' + python3 verify-images.py spir64,sm_89 "$MCPP" build --no-accel "$MCPP" run --no-accel | tee run.log grep -q '^12 24 36 48' run.log diff --git a/docs/rules.md b/docs/rules.md index ef10937..42fc379 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -18,7 +18,7 @@ Module `mcpp.rules.cuda`; engine floor: 2026.10.1.3 (0.19.0, mcpp#755; 2026.9.6. Module `mcpp.rules.hip`; engine floor: 2026.10.1.3 (0.19.0, mcpp#755; 2026.9.6.6 before). -**Needs and behaviour.** `[build] accel = "hip, cuda12.9+{sm_89}"`, a constrained glob for `*.hip`. On the NVIDIA platform HIP is a header layer over the CUDA runtime, so the compiler is the project's own clang and there is no ROCm on the machine +**Needs and behaviour.** `[build] accel = "hip+{sm_89,sm_90}"`, a constrained glob for `*.hip`. On the NVIDIA platform HIP is a header layer over the CUDA runtime, so the compiler is the project's own clang and there is no ROCm on the machine ## `rules-metal` @@ -50,4 +50,11 @@ Module `mcpp.rules.sycl`; engine floor: 2026.10.1.3 (0.19.0, mcpp#755; 2026.9.6. **One C++ runtime, libstdc++ (0.20.0).** `libsycl.so` is compiled against libstdc++ and throws `sycl::exception` on it, and a typed `catch` does not match across two C++ runtimes in one process (mcpp#838). The program's own C++ library is therefore libstdc++: a gcc toolchain, or llvm with `gcc.libstdcxx` in the graph (`[target.'cfg(accelerator = "sycl")'.dependencies] gcc.libstdcxx = "16.1.0.1"`). On Linux the rule refuses any other C++ library before a unit is compiled, naming both remedies, and `compat:sycl-runtime` states the same requirement. The rule names no C++ library on the link line (0.19 added `-l:libstdc++.so.6` beside an llvm build's libc++); `--gcc-install-dir` follows the build's GCC: the graph's supplier (`MCPP_CXX_ABI_ROOT`), else the gcc toolchain's payload, else the `xim:gcc` the rule declares. -**Needs and behaviour.** `[build] accel = "sycl"` or `"sycl, cuda12.9+{sm_89}"`, a constrained glob for `*.sycl`, and `compat:sycl-runtime` so the artifact can reach `libsycl.so.9` at run time. Its own engine need is `.sycl` in the device-source table, 2026.9.6.1 +**Needs and behaviour.** `[build] accel = "sycl"` or `"sycl+{nvptx64-sm_89}"`, a constrained glob for `*.sycl`, and `compat:sycl-runtime` so the artifact can reach `libsycl.so.9` at run time. Its own engine need is `.sycl` in the device-source table, 2026.9.6.1 + + +**SYCL targets belong to SYCL.** `sycl+{nvptx64-sm_89,nvptx64-sm_90}` produces both NVIDIA images. `sycl+{spir64,nvptx64-sm_89}` carries portable SPIR-V beside an NVIDIA image. `sycl` and `sycl+{spir64}` request portable SPIR-V; `sm_89` remains an alias for `nvptx64-sm_89`. An unknown target is refused before compilation. AMD `gfx*` requests are recognized but refused because this rule does not yet supply a ROCm compiler/runtime closure. + +Each selected implementation owns its targets. A repository may retain CUDA and SYCL source groups while selecting only `sycl+{nvptx64-sm_89}`; the CUDA group is excluded by its source constraint and the CUDA rule need not be imported. Selecting `cuda+{sm_90},sycl+{nvptx64-sm_89}` selects both implementations with different targets. The rule passes the same target vector to compilation and device linking. Its CUDA compiler payload is requested only for a SYCL NVIDIA target; `options::cuda` accepts the same explicit choice as other rule tools. + +The older `sycl,cuda+{sm_89}` form still inherits CUDA targets when the SYCL member has no targets, with a migration warning. Both named implementations remain selected. Explicit SYCL targets are never replaced by a CUDA member. Move the device target to the SYCL member to select only SYCL. GPU execution still requires a compatible device and driver; CI verifies the emitted images and the CPU fallback separately. diff --git a/mcpp.toml b/mcpp.toml index 707cbcf..b83f57a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -92,7 +92,7 @@ default = [] # build system (`instance`, `chain`, `detected`) # mcpp.plugins.fs deterministic file generation and placement [features.plugins-core] -sources = ["src/declare.cppm", "src/toolset.cppm", "src/fs.cppm", "src/tool.cppm", +sources = ["src/declare.cppm", "src/toolset.cppm", "src/fs.cppm", "src/tool.cppm", "src/accel.cppm", "src/compat/detected_toolset.cppm"] # `mcpp.plugins.toolchain` -- the builders a root build program states its @@ -408,19 +408,10 @@ implies = ["plugins-core"] "xim:glibc" = "" "xim:linux-headers" = "" -# Only when the SYCL build actually names an NVIDIA target. `accelerator` is a -# SET, so this is a conjunction of two membership tests rather than a -# contradiction -- and without it every SYCL build would download a CUDA -# toolkit it may have no target for. -# -# `linux` is part of the predicate for a reason that is measured rather than -# stylistic: upstream does not build the CUDA plugin for Windows, and the -# published Windows asset carries Level Zero and OpenCL adapters only. The rule -# refuses that combination on that host, and a refusal that arrives after a -# multi-gigabyte download is a worse refusal -- provisioning runs before the -# rule does. -[target.'cfg(all(accelerator = "sycl", accelerator = "cuda", linux))'.feature-xlings.rules-sycl] -"xim:cuda-nvcc" = "12.9.86" +# The toolkit is visible to SYCL without selecting CUDA source compilation. +# Only a SYCL NVIDIA target requests it; other SYCL targets install nothing. +# Windows has no published CUDA adapter, and the rule refuses it there. +"xim:cuda-nvcc" = { version = "12.9.86", provision = "on-request" } # ONE COMPILER PER PLATFORM, AND NOT THE SAME ONE EVERYWHERE. # diff --git a/rules/cuda.cppm b/rules/cuda.cppm index 2c422dc..199f171 100644 --- a/rules/cuda.cppm +++ b/rules/cuda.cppm @@ -44,6 +44,7 @@ export module mcpp.rules.cuda; import std; import mcpp; +import mcpp.plugins.accel; import mcpp.plugins.tool; @@ -127,6 +128,7 @@ struct target { std::vector archs; // {"sm_89"} std::string ptx; // "89" when a portable form is embedded bool present = false; + std::string error; }; // Split on one character. Written out rather than taken from : GCC 16 @@ -150,32 +152,16 @@ inline std::string_view trim(std::string_view s) { return s; } -inline target parse_target(std::string_view accel) { +inline target parse_target(std::string_view text) { target t; - for (std::size_t i = 0; i <= accel.size();) { - auto comma = accel.find(',', i); - auto open = accel.find('{', i), close = accel.find('}', i); - if (open != std::string_view::npos && close != std::string_view::npos - && comma != std::string_view::npos && comma > open && comma < close) - comma = accel.find(',', close); - auto chunk = trim(comma == std::string_view::npos ? accel.substr(i) - : accel.substr(i, comma - i)); - i = comma == std::string_view::npos ? accel.size() + 1 : comma + 1; - if (!chunk.starts_with("cuda")) continue; + const auto parsed = mcpp::plugins::accel::parse(text); + if (!parsed) { t.error = parsed.error(); return t; } + for (const auto& entry : *parsed) { + if (entry.backend != "cuda") continue; t.present = true; - auto plus = chunk.find('+'); - t.version = std::string(trim(chunk.substr(4, plus == std::string_view::npos - ? chunk.size() - 4 : plus - 4))); - if (plus != std::string_view::npos) { - auto o = chunk.find('{', plus), c = chunk.find('}', plus); - if (o != std::string_view::npos && c != std::string_view::npos) - for (auto part : split(chunk.substr(o + 1, c - o - 1), ',')) - if (auto a = trim(part); !a.empty()) t.archs.emplace_back(a); - auto tail = chunk.substr(c == std::string_view::npos ? chunk.size() : c + 1); - for (auto key : {"ptx>=", "floor>="}) - if (auto p = tail.find(key); p != std::string_view::npos) - t.ptx = std::string(trim(tail.substr(p + std::string_view(key).size()))); - } + t.version = entry.version; + t.archs = entry.archs; + t.ptx = entry.portable_floor; } return t; } @@ -586,6 +572,7 @@ inline std::vector plan(std::span sources, options opt return out; } const auto tg = parse_target(mcpp::accel()); + if (!tg.error.empty()) { std::cerr << "mcpp.rules.cuda: " << tg.error << '\n'; return {}; } if (!tg.present || tg.archs.empty()) { // C19: a device build that names no device is refused HERE, not at // run time as `no kernel image is available for execution`. @@ -841,7 +828,9 @@ inline bool compile(options opt = {}) { // `accel` itself, which is what the rule already does. A rule whose // backend this build does not name has nothing to do, and that is not a // mistake and must not be reported as one. - if (!parse_target(mcpp::accel()).present) return true; + const auto requested = parse_target(mcpp::accel()); + if (!requested.error.empty()) { std::cerr << "mcpp.rules.cuda: " << requested.error << '\n'; return false; } + if (!requested.present) return true; const auto sources = device_sources(); if (sources.empty()) { mcpp::warning("[build] accel names cuda but no constrained glob matched a `.cu`; " diff --git a/rules/hip.cppm b/rules/hip.cppm index 4342a0e..a62fd31 100644 --- a/rules/hip.cppm +++ b/rules/hip.cppm @@ -37,6 +37,7 @@ export module mcpp.rules.hip; import std; import mcpp; +import mcpp.plugins.accel; import mcpp.plugins.tool; @@ -98,6 +99,7 @@ struct options { // many programming models reach it, so `sm_89` does not acquire a second // spelling because the source file says `hip` instead of `cu`. struct target { + std::string error; bool hip = false; // the `hip` chunk is present std::string cuda_version; // "12.9", from a `cuda12.9` chunk std::vector cuda_archs; // {"sm_89"} @@ -135,25 +137,25 @@ inline std::vector parse_arch_set(std::string_view chunk) { return archs; } -inline target parse_target(std::string_view accel) { +inline target parse_target(std::string_view text) { target t; - for (auto raw : split(accel, ',')) { - auto chunk = trim(raw); - if (chunk.starts_with("hip")) { + const auto parsed = mcpp::plugins::accel::parse(text); + if (!parsed) { t.error = parsed.error(); return t; } + for (const auto& entry : *parsed) { + if (entry.backend == "hip") { t.hip = true; - t.amd_archs = parse_arch_set(chunk); - } else if (chunk.starts_with("cuda")) { - auto rest = chunk.substr(std::string_view("cuda").size()); - auto plus = rest.find('+'); - t.cuda_version = std::string(trim( - plus == std::string_view::npos ? rest : rest.substr(0, plus))); - // A version may be followed by a space-separated clause such as - // `ptx>=89`; the version is the leading numeric run. - if (auto sp = t.cuda_version.find(' '); sp != std::string::npos) - t.cuda_version.resize(sp); - t.cuda_archs = parse_arch_set(chunk); + for (const auto& arch : entry.archs) { + if (arch.starts_with("sm_")) t.cuda_archs.push_back(arch); + else if (arch.starts_with("gfx")) t.amd_archs.push_back(arch); + else { t.error = "unsupported HIP target '" + arch + "'"; return t; } + } } } + for (const auto& entry : *parsed) + if (entry.backend == "cuda") { + t.cuda_version = entry.version; + if (t.cuda_archs.empty() && t.amd_archs.empty()) t.cuda_archs = entry.archs; + } return t; } @@ -276,12 +278,13 @@ inline std::vector plan(std::span sources, options opt } const auto tg = parse_target(mcpp::accel()); + if (!tg.error.empty()) { std::cerr << "mcpp.rules.hip: " << tg.error << '\n'; return out; } auto which = opt.which; if (which == platform::automatic) which = tg.cuda_archs.empty() && !tg.amd_archs.empty() ? platform::amd : platform::nvidia; - if (which == platform::amd) { + if (which == platform::amd || !tg.amd_archs.empty()) { // Stated rather than approximated. The AMD platform needs a ROCm // runtime and device library, and this ecosystem publishes neither // yet; compiling for it would produce an object nothing on this @@ -289,7 +292,7 @@ inline std::vector plan(std::span sources, options opt std::cerr << std::format("mcpp.rules.hip: [build] accel names AMD architectures ({}) and no ROCm\n" " payload is published in this ecosystem yet, so nothing could link or run\n" " the result. The NVIDIA platform is available today:\n" - " accel = \"hip, cuda12.9+{{sm_89}}\"\n" + " accel = \"hip+{{sm_89}}\"\n" " which reaches the device through the CUDA runtime, with HIP as the API.", tg.amd_archs.empty() ? std::string("none") : tg.amd_archs.front()) << '\n'; return out; @@ -301,7 +304,7 @@ inline std::vector plan(std::span sources, options opt std::cerr << std::format("mcpp.rules.hip: [build] accel names no device architecture (accel = \"{}\").\n" " On the NVIDIA platform HIP compiles through the CUDA back end, and the\n" " device is spelled the way every other rule in this ecosystem spells it:\n" - " accel = \"hip, cuda12.9+{{sm_89}}\"\n" + " accel = \"hip+{{sm_89}}\"\n" " The set a build compiles for is a decision; the machine's own hardware is\n" " a poor default for it.", mcpp::accel()) << '\n'; @@ -523,7 +526,9 @@ inline bool compile(options opt = {}) { // `accel` itself, which is what the rule already does. A rule whose // backend this build does not name has nothing to do, and that is not a // mistake and must not be reported as one. - if (!parse_target(mcpp::accel()).hip) return true; + const auto requested = parse_target(mcpp::accel()); + if (!requested.error.empty()) { std::cerr << "mcpp.rules.hip: " << requested.error << '\n'; return false; } + if (!requested.hip) return true; const auto sources = device_sources(); if (sources.empty()) { mcpp::warning("[build] accel names hip but no constrained glob matched a `.hip`; " @@ -531,7 +536,7 @@ inline bool compile(options opt = {}) { return true; } auto edges = plan(sources, std::move(opt)); - if (edges.empty()) return false; + if (edges.empty()) return mcpp::xpkg_pending(); return submit(edges); } diff --git a/rules/spirv.cppm b/rules/spirv.cppm index 7ed3fe5..a375d31 100644 --- a/rules/spirv.cppm +++ b/rules/spirv.cppm @@ -59,6 +59,7 @@ export module mcpp.rules.spirv; import std; import mcpp; +import mcpp.plugins.accel; // The lib root, which carries `mcpp::plugins::surface` -- the declarations a // consumer names, written once for every member that embeds a payload. import mcpp.plugins; @@ -161,6 +162,7 @@ inline std::string include_dir(const options& opt) { struct target { std::string version; // "1.2", from `vulkan1.2` bool present = false; + std::string error; }; inline std::string_view trim(std::string_view s) { @@ -184,21 +186,14 @@ inline std::vector split(std::string_view s, char sep) { return out; } -inline target parse_target(std::string_view accel) { +inline target parse_target(std::string_view text) { target t; - for (std::size_t i = 0; i <= accel.size();) { - auto comma = accel.find(',', i); - auto chunk = trim(comma == std::string_view::npos ? accel.substr(i) - : accel.substr(i, comma - i)); - i = comma == std::string_view::npos ? accel.size() + 1 : comma + 1; - if (!chunk.starts_with("vulkan")) continue; + const auto parsed = mcpp::plugins::accel::parse(text); + if (!parsed) { t.error = parsed.error(); return t; } + for (const auto& entry : *parsed) { + if (entry.backend != "vulkan") continue; t.present = true; - auto rest = chunk.substr(std::string_view("vulkan").size()); - // A bare `vulkan` is legitimate and means "whatever the loader offers"; - // the target environment then falls back to the default below. - auto plus = rest.find('+'); - t.version = std::string(trim(plus == std::string_view::npos ? rest - : rest.substr(0, plus))); + t.version = entry.version; } return t; } @@ -600,6 +595,8 @@ inline bool write_header(const std::string& header, const std::string& inc, inline bool compile(std::span shaders, options opt = {}) { if (shaders.empty()) return true; + const auto requested = parse_target(mcpp::accel()); + if (!requested.error.empty()) { std::cerr << "mcpp.rules.spirv: " << requested.error << '\n'; return false; } const auto cc = find_compiler(opt); // Asked for: the engine installs the payload and runs this program again. @@ -921,7 +918,9 @@ inline bool compile(options opt = {}) { // `accel` itself, which is what the rule already does. A rule whose // backend this build does not name has nothing to do, and that is not a // mistake and must not be reported as one. - if (!parse_target(mcpp::accel()).present) return true; + const auto requested = parse_target(mcpp::accel()); + if (!requested.error.empty()) { std::cerr << "mcpp.rules.spirv: " << requested.error << '\n'; return false; } + if (!requested.present) return true; const auto shaders = device_shaders(); if (shaders.empty()) { mcpp::warning("[build] accel names vulkan but no constrained glob matched a " diff --git a/rules/sycl.cppm b/rules/sycl.cppm index ff28e9a..19edd9d 100644 --- a/rules/sycl.cppm +++ b/rules/sycl.cppm @@ -67,6 +67,7 @@ export module mcpp.rules.sycl; import std; import mcpp; +import mcpp.plugins.accel; import mcpp.plugins.tool; @@ -110,21 +111,22 @@ struct options { // C++ libraries (`xim:gcc`, `xim:glibc`, `xim:linux-headers`) are payloads // too, and an override in `[xlings.overrides]` names another of each. mcpp::plugins::tool::choice compiler; + // NVIDIA assembler and libdevice; requested only by NVIDIA targets. + mcpp::plugins::tool::choice cuda; std::string out_dir = std::string(mcpp::out_dir()); }; // ─── What the engine said ────────────────────────────────────────────────── -// THE PROGRAMMING MODEL AND THE DEVICE ARE TWO CHUNKS, NOT ONE. +// EACH PROGRAMMING MODEL OWNS ITS DEVICE-CODE TARGETS. // // accel = "sycl" -- SPIR-V, compiled by the runtime for // whatever device it finds: a CPU, a // Level Zero GPU, an OpenCL device. -// accel = "sycl, cuda12.9+{sm_89}" -- ahead of time for NVIDIA. +// accel = "sycl+{nvptx64-sm_89}" -- ahead of time for NVIDIA. // -// The device is spelled the way every other rule in this ecosystem spells it, -// so `sm_89` does not acquire a second spelling because the source file says -// `.sycl` instead of `.cu`. +// `nvptx64-sm_89` names NVIDIA code generated by SYCL; `sm_89` remains +// an alias. A separate CUDA member selects CUDA source compilation. // THE TWO PUBLISHED SYCL TOOLCHAINS DIFFER IN MORE THAN A FILE SUFFIX. // // Upstream publishes `sycl_linux.tar.gz` and `sycl_windows.tar.gz` from one @@ -151,7 +153,10 @@ inline constexpr const char* kExe = ""; struct target { bool sycl = false; - std::vector cuda_archs; // {"sm_89"} + bool portable = true; + bool legacy_targets = false; + std::string error; + std::vector cuda_archs; // {"sm_89", "sm_90"} std::vector amd_archs; // {"gfx1100"} }; @@ -186,13 +191,41 @@ inline std::vector parse_arch_set(std::string_view chunk) { return archs; } -inline target parse_target(std::string_view accel) { +inline target parse_target(std::string_view text) { target t; - for (auto raw : split(accel, ',')) { - auto chunk = trim(raw); - if (chunk.starts_with("sycl")) t.sycl = true; - else if (chunk.starts_with("cuda")) t.cuda_archs = parse_arch_set(chunk); - else if (chunk.starts_with("hip")) t.amd_archs = parse_arch_set(chunk); + const auto parsed = mcpp::plugins::accel::parse(text); + if (!parsed) { t.error = parsed.error(); return t; } + const mcpp::plugins::accel::request* own = nullptr; + for (const auto& entry : *parsed) + if (entry.backend == "sycl") own = &entry; + if (!own) return t; + t.sycl = true; + t.portable = own->archs.empty(); + for (const auto& arch : own->archs) { + if (arch == "spir64") t.portable = true; + else if (arch.starts_with("nvptx64-sm_")) t.cuda_archs.push_back(arch.substr(8)); + else if (arch.starts_with("sm_")) t.cuda_archs.push_back(arch); + else if (arch.starts_with("gfx")) t.amd_archs.push_back(arch); + else { + t.error = "unsupported SYCL target '" + arch + "'; name spir64 or nvptx64-sm_"; + return t; + } + } + // Preserve the old spelling's target inheritance, while keeping BOTH + // explicitly named implementations selected. New requests put targets on + // sycl itself; they never activate a CUDA implementation implicitly. + if (own->archs.empty()) { + for (const auto& entry : *parsed) { + if (entry.backend == "cuda" && !entry.archs.empty()) { + t.cuda_archs = entry.archs; + t.portable = false; + t.legacy_targets = true; + } + if (entry.backend == "hip" && !entry.archs.empty()) { + t.amd_archs = entry.archs; + t.legacy_targets = true; + } + } } return t; } @@ -341,6 +374,10 @@ inline std::vector plan(std::span sources, options opt } const auto tg = parse_target(mcpp::accel()); + if (!tg.error.empty()) { std::cerr << "mcpp.rules.sycl: " << tg.error << '\n'; return out; } + if (tg.legacy_targets) + mcpp::warning("mcpp.rules.sycl: legacy targets inherited from another explicitly selected implementation; " + "put SYCL targets on sycl+{nvptx64-sm_89}. Both named implementations remain selected."); if constexpr (kWindows) { if (!tg.cuda_archs.empty()) { std::cerr << std::format("mcpp.rules.sycl: [build] accel names an NVIDIA target and this host's SYCL\n" @@ -352,11 +389,11 @@ inline std::vector plan(std::span sources, options opt return out; } } - if (!tg.amd_archs.empty() && tg.cuda_archs.empty()) { + if (!tg.amd_archs.empty()) { std::cerr << std::format("mcpp.rules.sycl: [build] accel names AMD architectures and this ecosystem\n" " publishes no ROCm payload yet, so nothing could link or run the result.\n" " Available today: accel = \"sycl\" (SPIR-V, any device the runtime finds)\n" - " or accel = \"sycl, cuda12.9+{{sm_89}}\" (ahead of time for NVIDIA).") << '\n'; + " or accel = \"sycl+{{nvptx64-sm_89}}\" (ahead of time for NVIDIA).") << '\n'; return out; } @@ -389,7 +426,19 @@ inline std::vector plan(std::span sources, options opt const std::string dpcpp = dpcppTool.program; const std::string dpcppRoot = dpcppTool.root; const std::string gcc = cxx_gcc_root(); - const std::string cuda = tg.cuda_archs.empty() ? std::string{} : payload("cuda-nvcc"); + std::string cuda; + if (!tg.cuda_archs.empty()) { + const mcpp::plugins::tool::spec cudaSpec{ + .who = "mcpp.rules.sycl", .package = "cuda-nvcc", .programs = {"ptxas"}, + .option = "options::cuda" }; + const auto cudaTool = mcpp::plugins::tool::resolve(cudaSpec, opt.cuda); + if (cudaTool.pending()) return out; + if (!cudaTool) { + std::cerr << mcpp::plugins::tool::describe_missing(cudaSpec, cudaTool) << '\n'; + return out; + } + cuda = cudaTool.root; + } // THE C LIBRARY IS THE SAME QUESTION AS THE C++ ONE, ONE LAYER DOWN. // // `--gcc-install-dir` below names the C++ standard library because, left @@ -478,9 +527,10 @@ inline std::vector plan(std::span sources, options opt // did not produce and says so as a warning rather than an error. std::vector targeting; if (!tg.cuda_archs.empty()) { - targeting = { "-fsycl-targets=nvptx64-nvidia-cuda", "--cuda-path=" + cuda }; + targeting = { tg.portable ? "-fsycl-targets=spir64,nvptx64-nvidia-cuda" + : "-fsycl-targets=nvptx64-nvidia-cuda", "--cuda-path=" + cuda }; for (auto const& a : tg.cuda_archs) { - targeting.push_back("-Xsycl-target-backend"); + targeting.push_back("-Xsycl-target-backend=nvptx64-nvidia-cuda"); targeting.push_back("--cuda-gpu-arch=" + a); } } @@ -526,13 +576,13 @@ inline std::vector plan(std::span sources, options opt "kernels are compiled to SPIR-V and the runtime chooses. A back end " "that cannot consume SPIR-V -- CUDA is one -- fails inside the SYCL " "scheduler, where the program cannot catch it. Name the device to " - "compile ahead of time: accel = \"sycl, cuda12.9+{sm_89}\"."); + "compile ahead of time: accel = \"sycl+{nvptx64-sm_89}\"."); - std::cout << std::format("mcpp.rules.sycl: {} -- {} for {}", - tg.cuda_archs.empty() ? "SPIR-V, compiled by the runtime" - : "ahead of time, NVIDIA back end", - std::filesystem::path(exe).filename().string(), - tg.cuda_archs.empty() ? std::string("any device") : tg.cuda_archs.front()) << '\n'; + std::string targets = tg.portable ? "spir64" : ""; + for (const auto& arch : tg.cuda_archs) + targets += (targets.empty() ? "" : ",") + std::string("nvptx64-") + arch; + std::cout << std::format("mcpp.rules.sycl: {} -- {}", targets, + std::filesystem::path(exe).filename().string()) << '\n'; std::vector objects; for (auto const& src : sources) { @@ -617,7 +667,9 @@ inline bool compile(options opt = {}) { // `accel` itself, which is what the rule already does. A rule whose // backend this build does not name has nothing to do, and that is not a // mistake and must not be reported as one. - if (!parse_target(mcpp::accel()).sycl) return true; + const auto requested = parse_target(mcpp::accel()); + if (!requested.error.empty()) { std::cerr << "mcpp.rules.sycl: " << requested.error << '\n'; return false; } + if (!requested.sycl) return true; const auto sources = device_sources(); if (sources.empty()) { mcpp::warning("[build] accel names sycl but no constrained glob matched a `.sycl`; " @@ -625,7 +677,7 @@ inline bool compile(options opt = {}) { return true; } auto edges = plan(sources, std::move(opt)); - if (edges.empty()) return false; + if (edges.empty()) return mcpp::xpkg_pending(); return submit(edges); } diff --git a/src/accel.cppm b/src/accel.cppm new file mode 100644 index 0000000..5e148b9 --- /dev/null +++ b/src/accel.cppm @@ -0,0 +1,126 @@ +// Shared reading of the engine's accelerator requests. Targets are opaque; +// each rule interprets only the member whose backend name it owns. +module; +#include +export module mcpp.plugins.accel; +import std; + +export namespace mcpp::plugins::accel { +struct request { + std::string backend; + std::string version; + std::vector archs; + std::string portable_floor; +}; +std::expected, std::string> parse(std::string_view text); +} // namespace mcpp::plugins::accel + +namespace mcpp::plugins::accel { +namespace { + +std::string_view trim_sv(std::string_view s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1); + return s; +} + +std::expected, std::string> +read_request_targets(std::string_view s, std::size_t& pos) { + std::vector targets; + // Empty is the existing unconstrained set, also emitted for a bare name. + if (pos < s.size() && s[pos] == '}') return targets; + while (true) { + const auto begin = pos; + while (pos < s.size() && s[pos] != ',' && s[pos] != '}') { + if (s[pos] == '{' || s[pos] == '+') return std::unexpected(std::format( + "invalid accelerator request at byte {}: invalid target-set delimiter", pos + 1)); + ++pos; + } + const auto value = trim_sv(s.substr(begin, pos - begin)); + if (value.empty() || std::ranges::any_of(value, [](unsigned char c) { return std::isspace(c); })) + return std::unexpected(std::format( + "invalid accelerator request at byte {}: expected one nonempty target token", begin + 1)); + targets.emplace_back(value); + if (pos == s.size() || s[pos] == '}') return targets; + ++pos; + } +} + +} // namespace + +std::expected, std::string> parse(std::string_view s) { + std::size_t pos = 0; + auto spaces = [&] { + while (pos < s.size() && (s[pos] == ' ' || s[pos] == '\t')) ++pos; + }; + auto error = [&](std::string_view why) { + return std::unexpected(std::format("invalid accelerator request at byte {}: {}", + pos + 1, why)); + }; + spaces(); + if (pos == s.size() || trim_sv(s.substr(pos)) == "(none)") return std::vector{}; + + std::vector out; + std::set names; + while (pos < s.size()) { + spaces(); + const auto begin = pos; + while (pos < s.size() && std::isalpha(static_cast(s[pos]))) ++pos; + if (pos == begin) return error("expected an implementation name"); + request a; + a.backend = std::string(s.substr(begin, pos - begin)); + if (!names.insert(a.backend).second) + return error(std::format("implementation '{}' appears more than once; combine its targets in one set", + a.backend)); + const auto version = pos; + if (pos < s.size() && std::isdigit(static_cast(s[pos]))) { + while (pos < s.size()) { + const auto c = static_cast(s[pos]); + if (!std::isalnum(c) && c != '.' && c != '-' && c != '_') break; + ++pos; + } + } + a.version = std::string(s.substr(version, pos - version)); + spaces(); + if (pos < s.size() && s[pos] == '+') { + ++pos; + spaces(); + if (pos == s.size() || s[pos] != '{') return error("expected '{' after '+'"); + ++pos; + spaces(); + auto targets = read_request_targets(s, pos); + if (!targets) return std::unexpected(targets.error()); + a.archs = std::move(*targets); + if (pos == s.size() || s[pos] != '}') return error("expected '}' to close the target set"); + ++pos; + spaces(); + } + const auto tail = s.substr(pos); + const std::size_t floorPrefix = tail.starts_with("floor>=") ? 7 : tail.starts_with("ptx>=") ? 5 : 0; + if (floorPrefix) { + pos += floorPrefix; + spaces(); + const auto floor = pos; + while (pos < s.size() && s[pos] != ',' && s[pos] != ' ' && s[pos] != '\t') { + if (s[pos] == '{' || s[pos] == '}' || s[pos] == '+') return error("invalid portable-form floor"); + ++pos; + } + if (pos == floor) return error("expected a portable-form floor"); + a.portable_floor = std::string(s.substr(floor, pos - floor)); + spaces(); + } + if (pos < s.size() && s[pos] != ',') return error("unexpected text after the implementation request"); + std::ranges::sort(a.archs); + a.archs.erase(std::unique(a.archs.begin(), a.archs.end()), a.archs.end()); + out.push_back(std::move(a)); + if (pos == s.size()) break; + ++pos; + spaces(); + if (pos == s.size()) return error("expected an implementation after ','"); + } + std::ranges::sort(out, {}, &request::backend); + return out; +} + + +} // namespace mcpp::plugins::accel diff --git a/tests/plugin-logic/build.mcpp b/tests/plugin-logic/build.mcpp index 7ac9292..8aa26cd 100644 --- a/tests/plugin-logic/build.mcpp +++ b/tests/plugin-logic/build.mcpp @@ -14,6 +14,11 @@ import mcpp.rules.qt; import mcpp.plugins.tool; import mcpp.plugins.platform; import mcpp.plugins; +import mcpp.plugins.accel; +import mcpp.rules.cuda; +import mcpp.rules.sycl; +import mcpp.rules.hip; +import mcpp.rules.spirv; namespace t = mcpp::plugins::testing; namespace ts = mcpp::plugins::toolset; @@ -139,6 +144,86 @@ mcpp::plugins::tool::found tool_resolve(mcpp::plugins::tool::choice c = {}) { int main(int argc, char** argv) { return t::run(argc, argv, { + { "accel: targets stay on their implementation across internal commas", + t::row::linux_gcc(), + [] { + const auto parsed = mcpp::plugins::accel::parse( + "sycl+{spir64,nvptx64-sm_90,nvptx64-sm_89},cuda12.9+{sm_90,sm_89}"); + return parsed && parsed->size() == 2 && (*parsed)[0].backend == "cuda" + && (*parsed)[0].archs.size() == 2 && (*parsed)[1].backend == "sycl" + && (*parsed)[1].archs.size() == 3 ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "member ownership survives set commas"); } }, + + { "accel: implementation membership is exact rather than a prefix", + t::row::linux_gcc(), + [] { + const char* text = "cudaextra+{x},syclish+{y},hipster+{z},vulkanother1.0"; + return !mcpp::rules::cuda::parse_target(text).present + && !mcpp::rules::sycl::parse_target(text).sycl + && !mcpp::rules::hip::parse_target(text).hip + && !mcpp::rules::spirv::parse_target(text).present ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "no rule takes another implementation's prefix"); } }, + + { "sycl: multiple NVIDIA targets do not select a CUDA implementation", + t::row::linux_gcc(), + [] { + const auto a = mcpp::rules::sycl::parse_target("sycl+{nvptx64-sm_90,nvptx64-sm_89}"); + return a.error.empty() && a.sycl && !a.portable && !a.legacy_targets + && a.cuda_archs == std::vector{"sm_89", "sm_90"} + && !mcpp::rules::cuda::parse_target("sycl+{nvptx64-sm_90,nvptx64-sm_89}").present ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "the NVIDIA target belongs to SYCL"); } }, + + { "sycl: generic and NVIDIA forms coexist in one request", + t::row::linux_gcc(), + [] { + const auto a = mcpp::rules::sycl::parse_target("sycl+{spir64,nvptx64-sm_89}"); + return a.error.empty() && a.portable && a.cuda_archs.size() == 1 ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "both forms are preserved"); } }, + + { "sycl: explicit targets are not replaced by a coexisting CUDA request", + t::row::linux_gcc(), + [] { + const auto a = mcpp::rules::sycl::parse_target("cuda12.9+{sm_90},sycl+{nvptx64-sm_89}"); + return a.error.empty() && !a.legacy_targets + && a.cuda_archs == std::vector{"sm_89"} ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "each implementation keeps its targets"); } }, + + { "sycl: the old explicit two-member spelling retains its migration boundary", + t::row::linux_gcc(), + [] { + const auto a = mcpp::rules::sycl::parse_target("sycl,cuda12.9+{sm_89,sm_90}"); + return a.error.empty() && a.legacy_targets && a.cuda_archs.size() == 2 + && mcpp::rules::cuda::parse_target("sycl,cuda12.9+{sm_89,sm_90}").present ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "CUDA remains selected in the legacy spelling"); } }, + + { "sycl: an unknown target is refused instead of defaulting to SPIR-V", + t::row::linux_gcc(), + [] { return mcpp::rules::sycl::parse_target("sycl+{unknown-device}").error.empty() ? 1 : 0; }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "unknown target has a diagnostic"); } }, + + { "hip: AMD target sets retain all targets", + t::row::linux_gcc(), + [] { + const auto a = mcpp::rules::hip::parse_target("hip6.4+{gfx942,gfx1100}"); + return a.error.empty() && a.hip && a.amd_archs.size() == 2 ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "HIP does not split the set into members"); } }, + + { "accel: malformed and duplicate requests are diagnosed before a rule can no-op", + t::row::linux_gcc(), + [] { + for (auto text : {"sycl+{sm_89", "sycl+{sm_89}junk", "sycl+{a,,b}", "sycl,sycl"}) + if (mcpp::plugins::accel::parse(text)) return 1; + return 0; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "all malformed requests have an error"); } }, + // ── deps-vcpkg ────────────────────────────────────────────────────── { "vcpkg: a Visual Studio toolset is selected through its instance", with_vcpkg(t::row::windows_visual_studio()), diff --git a/tests/plugin-logic/mcpp.toml b/tests/plugin-logic/mcpp.toml index b5b4c70..f46cab0 100644 --- a/tests/plugin-logic/mcpp.toml +++ b/tests/plugin-logic/mcpp.toml @@ -18,7 +18,7 @@ modules = true import_std = true [build-dependencies.mcpp] -plugins = { path = "../..", features = ["deps-vcpkg", "deps-cmake", "rules-qt", "plugins-platform", "plugins-testing"], host-module = true } +plugins = { path = "../..", features = ["deps-vcpkg", "deps-cmake", "rules-qt", "plugins-platform", "plugins-testing", "rules-cuda", "rules-sycl", "rules-hip", "rules-spirv"], host-module = true } [targets.plugin-logic] kind = "bin" diff --git a/tests/sycl-consumer/mcpp.toml b/tests/sycl-consumer/mcpp.toml index 580234a..7deb03c 100644 --- a/tests/sycl-consumer/mcpp.toml +++ b/tests/sycl-consumer/mcpp.toml @@ -3,9 +3,8 @@ name = "sycl-consumer" namespace = "example" version = "0.1.0" description = "A SYCL kernel behind a seam module, reaching an NVIDIA device, with a CPU fallback" -# BOTH, for the reason the HIP consumer records: the glob below names the -# NVIDIA target the SYCL unit is compiled for, so `cuda` is a backend this -# package's sources are written for. +# The repository keeps a CUDA source group, but this build selects SYCL. +# The NVIDIA code target belongs to the SYCL request. accelerators = ["sycl", "cuda"] [language] @@ -55,18 +54,17 @@ libstdcxx = "16.1.0.1" # and C libraries the SYCL unit compiles against -- dpcpp's clang is not the # clang mcpp resolved and is configured with neither, so left alone its search # list reaches /usr/include with no ecosystem glibc on it); and cuda-nvcc, -# whose libdevice the NVIDIA back end needs -- declared only when the accel -# actually names cuda, so a SYCL build for another target does not pay for it. +# whose libdevice the NVIDIA back end needs -- requested only by a SYCL NVIDIA +# target, so a different SYCL target does not pay for it. [build] -# Two chunks: the programming model, and the device. Written `sycl` alone, the -# unit is compiled to SPIR-V and the runtime picks a device at run time; with -# the second chunk it is compiled ahead of time for that architecture. -accel = "sycl, cuda12.9+{sm_89}" +# One selected implementation, with its own NVIDIA device-code target. +accel = "sycl+{nvptx64-sm_89}" sources = [ "src/*.cppm", "src/*.cpp", - { glob = "src/kernels/**/*.sycl", accel = "sycl, cuda12.9+{sm_89}" }, + { glob = "src/kernels/**/*.sycl", accel = "sycl" }, + { glob = "src/cuda/**/*.cu", accel = "cuda" }, ] include_dirs = ["include"] diff --git a/tests/sycl-consumer/src/cuda/unused.cu b/tests/sycl-consumer/src/cuda/unused.cu new file mode 100644 index 0000000..48c1ee1 --- /dev/null +++ b/tests/sycl-consumer/src/cuda/unused.cu @@ -0,0 +1,3 @@ +// A CUDA implementation retained beside SYCL. A pure SYCL build must +// leave it out without requiring a CUDA-source rule to consume it. +extern "C" __global__ void unused_cuda_kernel() {} diff --git a/tests/sycl-consumer/verify-images.py b/tests/sycl-consumer/verify-images.py new file mode 100644 index 0000000..8b554a5 --- /dev/null +++ b/tests/sycl-consumer/verify-images.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Check actual rule outputs; no device or installed GPU driver is required.""" +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile + +expected = sys.argv[1].split(',') +graph = max(Path('target').glob('*/*/build.ninja'), key=lambda p: p.stat().st_mtime) +text = graph.read_text() +assert 'src/cuda/unused.cu' not in text, 'a pure SYCL request compiled the retained CUDA group' +commands = [line.strip().removeprefix('command = ') for line in text.splitlines() + if line.strip().startswith('command = ') and '-fsycl-link ' in line] +assert len(commands) == 1, 'expected one rule device-link command' +command = shlex.split(commands[0]) +with tempfile.TemporaryDirectory(prefix='sycl-images-') as temporary: + work = Path(temporary) + # Keep the rule's exact compiler, flags and inputs; save intermediates from + # the device link so the test verifies images rather than just flag spelling. + command.insert(1, '-save-temps=obj') + command[command.index('-o') + 1] = str(work / 'device-link.o') + subprocess.run(command, cwd=work, check=True, timeout=180) + images = [] + for table in work.glob('*.table'): + lines = table.read_text().splitlines() + if not lines or lines[0] != '[Code|Properties|Symbols]': + continue + for line in lines[1:]: + path = Path(line.split('|')[0]) + assert path.is_file() and path.stat().st_size > 100, str(path) + images.append((table.name, path.read_bytes()[:4])) + for target in expected: + if target == 'spir64': + assert any(magic == bytes.fromhex('03022307') for _, magic in images), images + else: + assert any(target in name and magic == bytes.fromhex('50ed55ba') + for name, magic in images), (target, images) + print('PASS: actual device images:', ', '.join(expected)) From d215e0d444b08446b10749a17ac6741ca7453522 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 08:00:44 +0900 Subject: [PATCH 5/8] feat: report optional action progress from plugin workers --- README.md | 2 ++ deps/cmake.cppm | 42 +++++++++++++++++++++++++++++++++ docs/deps.md | 3 +++ docs/plugin-development.md | 6 +++++ mcpp.toml | 9 ++++++- src/progress.cppm | 35 +++++++++++++++++++++++++++ src/progress_writer.cppm | 38 +++++++++++++++++++++++++++++ tests/cmake-consumer/build.mcpp | 1 + tests/plugin-logic/build.mcpp | 39 ++++++++++++++++++++++++++++++ tests/plugin-logic/mcpp.toml | 2 +- 10 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/progress.cppm create mode 100644 src/progress_writer.cppm diff --git a/README.md b/README.md index cef0443..287d6c3 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ The first three need mcpp 2026.10.1.3. `surface`, the name of `plugins-core` bef mechanism runs; the libraries and SDKs a program links are the project's declaration (0.15.0 removed `rules-qt-xim*`; see [rules-qt](docs/rules-qt.md)). +`mcpp.plugins.accel` reads complete implementation-owned target requests; `mcpp.plugins.progress` enables optional action telemetry. Worker programs can select `plugins-progress` for `mcpp.plugins.progress.writer`, the atomic snapshot writer. See [plugin-development](docs/plugin-development.md#optional-action-progress). + ## Layout ``` diff --git a/deps/cmake.cppm b/deps/cmake.cppm index 05a0aa4..2931944 100644 --- a/deps/cmake.cppm +++ b/deps/cmake.cppm @@ -52,6 +52,7 @@ export module mcpp.deps.cmake; import std; import mcpp; +import mcpp.plugins.progress; import mcpp.plugins; import mcpp.deps; import mcpp.plugins.fs; @@ -69,6 +70,7 @@ struct layout { }; struct options { + bool progress = false; // optional configure/build/install stage telemetry // The subproject's source directory, relative to the package root. std::string source; // Names the action and the prefix. Empty takes the source directory's name. @@ -229,6 +231,42 @@ inline mcpp::plugins::tool::found cmake_exe(const options& opt) { namespace detail { +// Stage telemetry is interpreted here, at the adapter that owns CMake. +// The helper invocation isolates optional I/O errors from the actual build. +inline std::string progress_prelude() { + return R"cmake( +if(DEFINED MCPP_PROGRESS_PHASE) + set(_file "$ENV{MCPP_ACTION_PROGRESS_FILE}") + if(NOT _file STREQUAL "") + file(WRITE "${_file}.tmp" "{\"v\":1,\"seq\":${MCPP_PROGRESS_SEQ},\"phase\":\"${MCPP_PROGRESS_PHASE}\"}") + file(RENAME "${_file}.tmp" "${_file}" RESULT _ignored) + endif() + return() +endif() +function(mcpp_progress phase) + if("$ENV{MCPP_ACTION_PROGRESS_FILE}" STREQUAL "") + return() + endif() + string(TIMESTAMP _now "%s" UTC) + get_property(_last GLOBAL PROPERTY _mcpp_progress_time) + # At most one snapshot per second: short stages may finish between reports. + if(_last STREQUAL _now) + return() + endif() + get_property(_seq GLOBAL PROPERTY _mcpp_progress_seq) + if(NOT _seq) + set(_seq 0) + endif() + math(EXPR _seq "${_seq}+1") + set_property(GLOBAL PROPERTY _mcpp_progress_seq "${_seq}") + set_property(GLOBAL PROPERTY _mcpp_progress_time "${_now}") + execute_process(COMMAND "${CMAKE_COMMAND}" "-DMCPP_PROGRESS_PHASE=${phase}" + "-DMCPP_PROGRESS_SEQ=${_seq}" -P "${CMAKE_CURRENT_LIST_FILE}" + OUTPUT_QUIET ERROR_QUIET RESULT_VARIABLE _ignored) +endfunction() +)cmake"; +} + // The script of a keyed installation: `steps` (configure, build, install) runs // only when the cache holds no installation with the key. The key is // computed when the action runs, from `recipe` (the plugin's version, the @@ -561,6 +599,7 @@ inline prefix use(const options& opt) { } std::string steps = contract ? contract_reader() : std::string(); + if (opt.progress) steps += "mcpp_progress(configure)\n"; steps += "execute_process(COMMAND ${CMAKE_COMMAND}"; for (auto const& x : configure) steps += "\n " + bracket(x); if (contract) @@ -572,12 +611,14 @@ inline prefix use(const options& opt) { "\n \"-DCMAKE_SHARED_LINKER_FLAGS=${_mcpp_ld_flags}\""; steps += "\n RESULT_VARIABLE rc)\n" "if(NOT rc EQUAL 0)\n message(FATAL_ERROR \"configure exited ${rc}\")\nendif()\n" + + (opt.progress ? std::string("mcpp_progress(build-install)\n") : std::string{}) + "execute_process(COMMAND ${CMAKE_COMMAND} --build " + bracket(buildS) + " --config " + bracket(opt.config) + " --target install --parallel\n RESULT_VARIABLE rc)\n" "if(NOT rc EQUAL 0)\n message(FATAL_ERROR \"build and install exited ${rc}\")\nendif()\n"; std::string text = "# Written by mcpp.deps.cmake: configure, build and install " + name + ".\n" "# toolset: " + (contract ? std::string("the engine's contract for ") + targetTriple : ts::describe(*tools)) + "\n"; + if (opt.progress) text = detail::progress_prelude() + text; const auto files = mcpp::deps::files_under(source); // A key states the compilers through the toolset's identity. On the // MSVC ABI that is the MSVC toolset, which a row compiler (clang-cl, @@ -602,6 +643,7 @@ inline prefix use(const options& opt) { mcpp::plugins::fs::write_if_changed(script, text); const std::string scriptS = mcpp::deps::generic(script); mcpp::action a; + mcpp::plugins::progress::enable(a, opt.progress); a.id = id.c_str(); a.role = mcpp::roles::prepare; a.description = desc.c_str(); diff --git a/docs/deps.md b/docs/deps.md index 0fc7753..572d0ef 100644 --- a/docs/deps.md +++ b/docs/deps.md @@ -188,6 +188,9 @@ Not supported: vcpkg's classic mode; the debug libraries under `debug/lib`. ## `deps-cmake`: a CMake subproject +Set `options::progress = true` to report the configure and build-install stages when running with mcpp 2026.11.5.1 or later. The default is false. Older engine APIs continue without telemetry. Counts are omitted because this adapter does not know the number of compiler tasks; a restored cached installation need not run either stage. Optional snapshot I/O errors do not fail configure/build/install. The CMake snapshot helper uses `file(RENAME ... RESULT ...)` (CMake 3.21+); older tools can still build while telemetry remains unavailable. + + ```cpp import mcpp.deps.cmake; diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 970b63f..b5a25c9 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -215,3 +215,9 @@ A plugin in this package consists of: - a version bump. A project's own plugin is a module in the project (a path dependency with `host-module = true`) and follows §2 in the same way. + +## Optional action progress + +`mcpp.plugins.progress` is part of `plugins-core`. `enable(action, requested)` sets the optional action flag when the engine API provides it. With older APIs it reports that telemetry is unavailable and leaves the action usable. Engine support starts at mcpp 2026.11.5.1 (protocol 18); this optional path does not raise the package's 2026.10.30.1 floor. + +Worker programs select `plugins-progress` and import `mcpp.plugins.progress.writer`. `writer` reads `MCPP_ACTION_PROGRESS_FILE`; an absent channel is a no-op. `report({.phase="compile", .done=3, .total=9})` atomically replaces the latest schema-1 snapshot, assigns increasing sequence numbers and publishes at most five times per second. Omit counts when the total is unknown. `report()` returns whether it published; this result does not determine whether the worker's actual task succeeded. The optional writer feature uses `mcpplibs.os.fs` for portable atomic replacement. The general helper and CMake adapter do not add this dependency to ordinary consumers. diff --git a/mcpp.toml b/mcpp.toml index b83f57a..cddbe9f 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -92,7 +92,7 @@ default = [] # build system (`instance`, `chain`, `detected`) # mcpp.plugins.fs deterministic file generation and placement [features.plugins-core] -sources = ["src/declare.cppm", "src/toolset.cppm", "src/fs.cppm", "src/tool.cppm", "src/accel.cppm", +sources = ["src/declare.cppm", "src/toolset.cppm", "src/fs.cppm", "src/tool.cppm", "src/accel.cppm", "src/progress.cppm", "src/compat/detected_toolset.cppm"] # `mcpp.plugins.toolchain` -- the builders a root build program states its @@ -308,6 +308,13 @@ implies = ["plugins-core"] sources = ["deps/vcpkg.cppm"] implies = ["deps"] +[features.plugins-progress] +sources = ["src/progress_writer.cppm"] +implies = ["plugins-core"] + +[feature-deps.plugins-progress.mcpplibs] +os = ">=0.2.3" + [features.deps-cmake] sources = ["deps/cmake.cppm"] implies = ["deps"] diff --git a/src/progress.cppm b/src/progress.cppm new file mode 100644 index 0000000..bad549a --- /dev/null +++ b/src/progress.cppm @@ -0,0 +1,35 @@ +// Optional action telemetry; useful in worker programs as well as adapters. +export module mcpp.plugins.progress; +import std; + +export namespace mcpp::plugins::progress { +struct snapshot { + std::string phase; + std::optional done, total; + std::string detail; +}; + +// Compile against older mcpp build-program APIs without requiring the field. +// The worker naturally becomes a no-op when the engine supplies no channel. +template bool enable(Action& action, bool requested = true) { + if constexpr (requires(Action& a) { a.progress = true; }) { + action.progress = requested; + return requested; + } else { + if (requested) std::cerr << "mcpp.plugins.progress: this engine does not support action progress; continuing without telemetry\n"; + return false; + } +} + +inline bool valid_text(std::string_view value, std::size_t limit) { + return value.size() <= limit && std::ranges::none_of(value, [](unsigned char c) { + return c < 32 || c == 127; + }); +} +inline std::string quote(std::string_view value) { + std::string out = "\""; + for (char c : value) { if (c == '"' || c == '\\') out += '\\'; out += c; } + return out + '"'; +} + +} diff --git a/src/progress_writer.cppm b/src/progress_writer.cppm new file mode 100644 index 0000000..78db250 --- /dev/null +++ b/src/progress_writer.cppm @@ -0,0 +1,38 @@ +// Atomic latest-state writer for action worker programs. +export module mcpp.plugins.progress.writer; +import std; +import mcpp.plugins.progress; +import mcpplibs.os.fs; +export namespace mcpp::plugins::progress { +class writer { +public: + writer() { + if (const auto* file = std::getenv("MCPP_ACTION_PROGRESS_FILE"); file && *file) + file_ = mcpplibs::os::fs::from_utf8(file); + } + bool enabled() const { return !file_.empty(); } + // At most five atomic latest-state replacements per second. Reporting + // failure, missing telemetry, and rate limiting do not affect the work. + bool report(const snapshot& state) { + if (!enabled() || !valid_text(state.phase, 128) || state.phase.empty() + || !valid_text(state.detail, 1024) + || (state.done && state.total && *state.done > *state.total)) return false; + const auto now = std::chrono::steady_clock::now(); + if (last_ && now - *last_ < std::chrono::milliseconds(200)) return false; + std::string text = "{\"v\":1,\"seq\":" + std::to_string(seq_) + + ",\"phase\":" + quote(state.phase); + if (state.done) text += ",\"done\":" + std::to_string(*state.done); + if (state.total) text += ",\"total\":" + std::to_string(*state.total); + if (!state.detail.empty()) text += ",\"detail\":" + quote(state.detail); + text += '}'; + // fs's replacement has the same semantics on Linux, macOS and Windows. + if (!mcpplibs::os::fs::write_atomic(file_, text)) return false; + ++seq_; last_ = now; + return true; + } +private: + std::filesystem::path file_; + std::uint64_t seq_ = 0; + std::optional last_; +}; +} diff --git a/tests/cmake-consumer/build.mcpp b/tests/cmake-consumer/build.mcpp index 55cab6f..7164df8 100644 --- a/tests/cmake-consumer/build.mcpp +++ b/tests/cmake-consumer/build.mcpp @@ -5,6 +5,7 @@ import mcpp.deps.cmake; int main() { mcpp::deps::cmake::options o; o.source = "greet"; + o.progress = true; o.libraries = { "greet" }; o.shared = true; return mcpp::deps::cmake::use(o) ? 0 : 1; diff --git a/tests/plugin-logic/build.mcpp b/tests/plugin-logic/build.mcpp index 8aa26cd..dfdbb0a 100644 --- a/tests/plugin-logic/build.mcpp +++ b/tests/plugin-logic/build.mcpp @@ -15,6 +15,8 @@ import mcpp.plugins.tool; import mcpp.plugins.platform; import mcpp.plugins; import mcpp.plugins.accel; +import mcpp.plugins.progress; +import mcpp.plugins.progress.writer; import mcpp.rules.cuda; import mcpp.rules.sycl; import mcpp.rules.hip; @@ -144,6 +146,43 @@ mcpp::plugins::tool::found tool_resolve(mcpp::plugins::tool::choice c = {}) { int main(int argc, char** argv) { return t::run(argc, argv, { + { "progress: no channel leaves work independent of telemetry", + t::row::linux_gcc(), + [] { + mcpp::plugins::progress::writer writer; + return !writer.enabled() && !writer.report({.phase="configure"}) ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { c.expect(r.exit_code == 0, "absence is a no-op"); } }, + { "progress: snapshots replace atomically and publishing is rate limited", + t::row::linux_gcc().set("MCPP_ACTION_PROGRESS_FILE", "{root}/telemetry/state.json") + .file("telemetry/keep"), + [] { + mcpp::plugins::progress::writer writer; + if (!writer.enabled() || !writer.report({.phase="configure"})) return 1; + if (writer.report({.phase="too-soon"})) return 2; + std::this_thread::sleep_for(std::chrono::milliseconds(210)); + return writer.report({.phase="build",.done=3,.total=9}) ? 0 : 3; + }, + [](const t::result& r, t::checker& c) { + c.expect(r.exit_code == 0, "two bounded reports succeeded"); + const auto text=r.file("telemetry/state.json"); + c.expect(text.find("\"seq\":1") != std::string::npos + && text.find("\"phase\":\"build\"") != std::string::npos + && text.find("configure") == std::string::npos, "only the latest snapshot remains"); + } }, + { "progress: invalid counts and terminal controls are refused", + t::row::linux_gcc().set("MCPP_ACTION_PROGRESS_FILE", "{root}/telemetry/state.json") + .file("telemetry/keep"), + [] { + mcpp::plugins::progress::writer writer; + return !writer.report({.phase="bad\nphase"}) + && !writer.report({.phase="build",.done=4,.total=3}) ? 0 : 1; + }, + [](const t::result& r, t::checker& c) { + c.expect(r.exit_code == 0, "invalid optional data was refused"); + c.expect(r.file("telemetry/state.json").empty(), "invalid telemetry wrote no document"); + } }, + { "accel: targets stay on their implementation across internal commas", t::row::linux_gcc(), [] { diff --git a/tests/plugin-logic/mcpp.toml b/tests/plugin-logic/mcpp.toml index f46cab0..9474215 100644 --- a/tests/plugin-logic/mcpp.toml +++ b/tests/plugin-logic/mcpp.toml @@ -18,7 +18,7 @@ modules = true import_std = true [build-dependencies.mcpp] -plugins = { path = "../..", features = ["deps-vcpkg", "deps-cmake", "rules-qt", "plugins-platform", "plugins-testing", "rules-cuda", "rules-sycl", "rules-hip", "rules-spirv"], host-module = true } +plugins = { path = "../..", features = ["plugins-progress", "deps-vcpkg", "deps-cmake", "rules-qt", "plugins-platform", "plugins-testing", "rules-cuda", "rules-sycl", "rules-hip", "rules-spirv"], host-module = true } [targets.plugin-logic] kind = "bin" From 3e9fc367af18cb347b80711dfc71979c685a922b Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 08:31:17 +0900 Subject: [PATCH 6/8] fix: propagate embedded payload changes through object storage --- .github/workflows/ci.yml | 8 +++++ docs/rules.md | 5 +++ docs/tools-embed.md | 3 ++ src/plugins.cppm | 39 ++++++++++++++++++---- tests/multi-accel-consumer/build.mcpp | 5 +++ tests/multi-accel-consumer/mcpp.toml | 20 +++++++++++ tests/multi-accel-consumer/src/kernel.cu | 2 ++ tests/multi-accel-consumer/src/kernel.sycl | 6 ++++ tests/multi-accel-consumer/src/main.cpp | 4 +++ tests/plugin-logic/build.mcpp | 25 ++++++++++++++ tests/sycl-consumer/verify-images.py | 13 ++++++++ 11 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 tests/multi-accel-consumer/build.mcpp create mode 100644 tests/multi-accel-consumer/mcpp.toml create mode 100644 tests/multi-accel-consumer/src/kernel.cu create mode 100644 tests/multi-accel-consumer/src/kernel.sycl create mode 100644 tests/multi-accel-consumer/src/main.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 732032b..8ab955e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1479,6 +1479,14 @@ jobs: "$MCPP" run --no-accel | tee run.log grep -q '^12 24 36 48' run.log + - name: CUDA and SYCL coexist with independent code targets + working-directory: tests/multi-accel-consumer + run: | + "$MCPP" build + python3 ../sycl-consumer/verify-images.py sm_89 --cuda-target=sm_90 + # Both host objects execute; this does not submit work to a GPU. + "$MCPP" run + # EVERY RULE COMPILED FOR THIS HOST, which is a different claim from any # step above. Each of those exercises one rule and needs that rule's # payload, so a rule is only ever compiled on the platforms its payload diff --git a/docs/rules.md b/docs/rules.md index 42fc379..5ac01e0 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -14,6 +14,8 @@ Module `mcpp.rules.cuda`; engine floor: 2026.10.1.3 (0.19.0, mcpp#755; 2026.9.6. **Needs and behaviour.** `[build] accel = "cuda…"`, a constrained glob for `*.cu`; the clang route with an LLVM toolchain, the nvcc route with a GCC one +The rule supplies toolkit search directories. The consumer selects the CUDA runtime link policy; for the Linux static-runtime arrangement used by the fixtures, the CUDA configuration states `ldflags = ["-lcudart_static", "-lrt", "-lpthread", "-ldl"]`. + ## `rules-hip` Module `mcpp.rules.hip`; engine floor: 2026.10.1.3 (0.19.0, mcpp#755; 2026.9.6.6 before). @@ -58,3 +60,6 @@ Module `mcpp.rules.sycl`; engine floor: 2026.10.1.3 (0.19.0, mcpp#755; 2026.9.6. Each selected implementation owns its targets. A repository may retain CUDA and SYCL source groups while selecting only `sycl+{nvptx64-sm_89}`; the CUDA group is excluded by its source constraint and the CUDA rule need not be imported. Selecting `cuda+{sm_90},sycl+{nvptx64-sm_89}` selects both implementations with different targets. The rule passes the same target vector to compilation and device linking. Its CUDA compiler payload is requested only for a SYCL NVIDIA target; `options::cuda` accepts the same explicit choice as other rule tools. The older `sycl,cuda+{sm_89}` form still inherits CUDA targets when the SYCL member has no targets, with a migration warning. Both named implementations remain selected. Explicit SYCL targets are never replaced by a CUDA member. Move the device target to the SYCL member to select only SYCL. GPU execution still requires a compatible device and driver; CI verifies the emitted images and the CPU fallback separately. + + +The `tests/multi-accel-consumer` fixture selects `cuda+{sm_90},sycl+{nvptx64-sm_89}` and imports both rules. CI inspects each rule's actual image target and runs both host objects without submitting GPU work. This establishes build/link coexistence with one C++ runtime; device execution, interoperation and synchronization require their own runtime checks. diff --git a/docs/tools-embed.md b/docs/tools-embed.md index cb08b3b..9f48ef5 100644 --- a/docs/tools-embed.md +++ b/docs/tools-embed.md @@ -75,3 +75,6 @@ there is always exactly one name to give: `table_options::identifier` (default the array and the struct. `out_dir`, `name_space`, `elem` and `width` mean what they mean in `options`, `element::word32`'s "not a multiple of 4" refusal included -- checked per input, since a table has several. + + +For object surfaces, the execution-time body generator records a payload-content checksum in its `.S` output. A payload edit, including an equal-size edit, changes that source and rebuilds its embedding object. Identical payload bytes preserve the generated assembly timestamp; the separately generated interface remains stable. Planning reads no payload bytes. diff --git a/src/plugins.cppm b/src/plugins.cppm index ed8f95f..da3b34e 100644 --- a/src/plugins.cppm +++ b/src/plugins.cppm @@ -1307,14 +1307,31 @@ inline emitted outputs(const options& opt) { } +// The assembly's meaning includes the bytes named by .incbin. Put a content +// checksum in its source so restat cannot prune the assembler after a payload +// changes while the .incbin path stays the same. This runs in the body action, +// after its declared payload inputs exist; planning still reads no payload. +inline std::optional payload_checksum(const std::string& file) { + std::ifstream input(file, std::ios::binary); + if (!input) return std::nullopt; + std::uint64_t sum = 14695981039346656037ULL; + std::array bytes; + while (input) { + input.read(bytes.data(), static_cast(bytes.size())); + for (std::streamsize i = 0; i < input.gcount(); ++i) { + sum ^= static_cast(bytes[static_cast(i)]); + sum *= 1099511628211ULL; + } + } + return input.bad() ? std::nullopt : std::optional(sum); +} + // Writes ONE half of the surface. The names come from `outputs`, which the // caller has already asked; this produces the content. // -// Nothing here reads a payload's BYTES. Under object storage the assembly names -// each payload in `.incbin` and the assembler reads it later, which is why this -// can be an action whose command runs before, after or independently of the -// payload's own producer -- what matters is that the payload is a declared -// INPUT of that action, and the graph then holds the edge. +// The interface reads no payload. The object body runs after its declared +// payload inputs, records their content checksum and names them in .incbin; +// the assembler reads the bytes after that body action has completed. inline bool write(std::span items, const options& opt, half which) { if (items.empty()) return true; if (opt.module_name.empty()) { @@ -1520,8 +1537,16 @@ blob& load(const char* path, blob& b) {{ it.identifier); return false; } - if (!write_if_different(out.assembly_file, - assembly_for(items, opt, opt.target_os))) { + auto assembly = assembly_for(items, opt, opt.target_os); + for (const auto& it : items) { + const auto checksum = payload_checksum(it.payload_path); + if (!checksum) { + std::cerr << std::format("mcpp.plugins.surface: cannot read payload {}\n", it.payload_path); + return false; + } + assembly += std::format("// payload content: {:016x}\n", *checksum); + } + if (!write_if_different(out.assembly_file, assembly)) { std::cerr << std::format("mcpp.plugins.surface: cannot write {}\n", out.assembly_file); return false; diff --git a/tests/multi-accel-consumer/build.mcpp b/tests/multi-accel-consumer/build.mcpp new file mode 100644 index 0000000..4796f2b --- /dev/null +++ b/tests/multi-accel-consumer/build.mcpp @@ -0,0 +1,5 @@ +import mcpp.rules.cuda; +import mcpp.rules.sycl; +int main() { + return mcpp::rules::cuda::compile() && mcpp::rules::sycl::compile() ? 0 : 1; +} diff --git a/tests/multi-accel-consumer/mcpp.toml b/tests/multi-accel-consumer/mcpp.toml new file mode 100644 index 0000000..ff0cbbb --- /dev/null +++ b/tests/multi-accel-consumer/mcpp.toml @@ -0,0 +1,20 @@ +[package] +name = "multi-accel-consumer" +version = "0.1.0" +accelerators = ["cuda", "sycl"] +[toolchain] +default = "llvm@22.1.8" +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["rules-cuda", "rules-sycl"], host-module = true } +[dependencies.compat] +cuda-runtime = "2026.09.05" +sycl-runtime = "2026.09.07" +[dependencies.gcc] +libstdcxx = "16.1.0.1" +[build] +accel = "cuda+{sm_90},sycl+{nvptx64-sm_89}" +ldflags = ["-lcudart_static", "-lrt", "-lpthread", "-ldl"] +sources = ["src/main.cpp", { glob = "src/*.cu", accel = "cuda" }, { glob = "src/*.sycl", accel = "sycl" }] +[targets.multi-accel-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/multi-accel-consumer/src/kernel.cu b/tests/multi-accel-consumer/src/kernel.cu new file mode 100644 index 0000000..347e6f3 --- /dev/null +++ b/tests/multi-accel-consumer/src/kernel.cu @@ -0,0 +1,2 @@ +__global__ void cuda_kernel(int* value){*value=1;} +extern "C" int cuda_anchor(){return 1;} diff --git a/tests/multi-accel-consumer/src/kernel.sycl b/tests/multi-accel-consumer/src/kernel.sycl new file mode 100644 index 0000000..0a552a0 --- /dev/null +++ b/tests/multi-accel-consumer/src/kernel.sycl @@ -0,0 +1,6 @@ +#include +extern "C" void sycl_kernel(int* value){ + sycl::queue queue; + queue.single_task([=]{*value=2;}).wait_and_throw(); +} +extern "C" int sycl_anchor(){return 2;} diff --git a/tests/multi-accel-consumer/src/main.cpp b/tests/multi-accel-consumer/src/main.cpp new file mode 100644 index 0000000..f3e3dad --- /dev/null +++ b/tests/multi-accel-consumer/src/main.cpp @@ -0,0 +1,4 @@ +extern "C" int cuda_anchor(); +extern "C" int sycl_anchor(); +// Exercise both host objects without requiring a physical GPU or a queue. +int main(){return cuda_anchor()+sycl_anchor()==3?0:1;} diff --git a/tests/plugin-logic/build.mcpp b/tests/plugin-logic/build.mcpp index dfdbb0a..405369d 100644 --- a/tests/plugin-logic/build.mcpp +++ b/tests/plugin-logic/build.mcpp @@ -146,6 +146,31 @@ mcpp::plugins::tool::found tool_resolve(mcpp::plugins::tool::choice c = {}) { int main(int argc, char** argv) { return t::run(argc, argv, { + { "surface: equal-size payload changes reach assembly and identical bytes keep its timestamp", + t::row::linux_gcc().file("pkg/payload.bin", "abcd"), + [] { + namespace sf = mcpp::plugins::surface; + const auto root=std::filesystem::path(mcpp::manifest_dir()); + sf::options options; + options.module_name="fixture.embed"; + options.out_dir=mcpp::out_dir(); + options.store=sf::storage::object; + options.target_os="linux"; + options.has_gas_assembler=true; + const std::vector items{{.identifier="data",.payload_path=(root/"payload.bin").string()}}; + if (!sf::write(items,options,sf::half::body)) return 1; + const auto file=sf::outputs(options).assembly_file; + auto read=[&]{std::ifstream in(file);return std::string(std::istreambuf_iterator(in),{});}; + const auto before=read(); + std::ofstream(root/"payload.bin",std::ios::binary|std::ios::trunc)<<"abce"; + if (!sf::write(items,options,sf::half::body) || read()==before) return 2; + const auto sentinel=std::filesystem::file_time_type::clock::now()-std::chrono::hours(1); + std::filesystem::last_write_time(file,sentinel); + if (!sf::write(items,options,sf::half::body)) return 3; + return std::filesystem::last_write_time(file)==sentinel ? 0 : 4; + }, + [](const t::result& r,t::checker& c){c.expect(r.exit_code==0,"the body tracks payload content without rewriting unchanged source");} }, + { "progress: no channel leaves work independent of telemetry", t::row::linux_gcc(), [] { diff --git a/tests/sycl-consumer/verify-images.py b/tests/sycl-consumer/verify-images.py index 8b554a5..a36e19e 100644 --- a/tests/sycl-consumer/verify-images.py +++ b/tests/sycl-consumer/verify-images.py @@ -7,6 +7,8 @@ import tempfile expected = sys.argv[1].split(',') +cuda_target = next((arg.split('=', 1)[1] for arg in sys.argv[2:] + if arg.startswith('--cuda-target=')), None) graph = max(Path('target').glob('*/*/build.ninja'), key=lambda p: p.stat().st_mtime) text = graph.read_text() assert 'src/cuda/unused.cu' not in text, 'a pure SYCL request compiled the retained CUDA group' @@ -36,4 +38,15 @@ else: assert any(target in name and magic == bytes.fromhex('50ed55ba') for name, magic in images), (target, images) + if cuda_target: + cuda_commands = [line.strip().removeprefix('command = ') for line in text.splitlines() + if line.strip().startswith('command = ') and ' -x cuda ' in line] + assert len(cuda_commands) == 1, 'expected a separate CUDA compilation action' + cuda = shlex.split(cuda_commands[0]) + cuda.insert(1, '-save-temps=obj') + cuda[cuda.index('-o') + 1] = str(work / 'cuda.o') + subprocess.run(cuda, cwd=work, check=True, timeout=180) + ptx = [file.read_text() for file in work.glob('*.s')] + assert any('.target ' + cuda_target in file for file in ptx), cuda_target + print('PASS: the independent CUDA action emits', cuda_target) print('PASS: actual device images:', ', '.join(expected)) From aa2c16e38024d383c721fcf4f358be580ddcf84c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 08:57:02 +0900 Subject: [PATCH 7/8] fix: keep the progress writer usable by standalone workers --- .github/workflows/ci.yml | 12 ++++++++++++ docs/plugin-development.md | 2 ++ mcpp.toml | 4 ++-- src/progress_writer.cppm | 2 +- tests/progress-worker/mcpp.toml | 8 ++++++++ tests/progress-worker/src/main.cpp | 12 ++++++++++++ 6 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 tests/progress-worker/mcpp.toml create mode 100644 tests/progress-worker/src/main.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ab955e..f7156c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1479,6 +1479,18 @@ jobs: "$MCPP" run --no-accel | tee run.log grep -q '^12 24 36 48' run.log + - name: an ordinary worker imports the progress writer without build-program modules + working-directory: tests/progress-worker + run: | + "$MCPP" run + MCPP_ACTION_PROGRESS_FILE="$PWD/target/snapshot.json" "$MCPP" run + python3 - <<'PYCODE' + import json + from pathlib import Path + snapshot = json.loads(Path("target/snapshot.json").read_text()) + assert snapshot == dict(v=1, seq=1, phase="compile", done=1, total=1), snapshot + PYCODE + - name: CUDA and SYCL coexist with independent code targets working-directory: tests/multi-accel-consumer run: | diff --git a/docs/plugin-development.md b/docs/plugin-development.md index b5a25c9..0b52bd1 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -221,3 +221,5 @@ A project's own plugin is a module in the project (a path dependency with `host- `mcpp.plugins.progress` is part of `plugins-core`. `enable(action, requested)` sets the optional action flag when the engine API provides it. With older APIs it reports that telemetry is unavailable and leaves the action usable. Engine support starts at mcpp 2026.11.5.1 (protocol 18); this optional path does not raise the package's 2026.10.30.1 floor. Worker programs select `plugins-progress` and import `mcpp.plugins.progress.writer`. `writer` reads `MCPP_ACTION_PROGRESS_FILE`; an absent channel is a no-op. `report({.phase="compile", .done=3, .total=9})` atomically replaces the latest schema-1 snapshot, assigns increasing sequence numbers and publishes at most five times per second. Omit counts when the total is unknown. `report()` returns whether it published; this result does not determine whether the worker's actual task succeeded. The optional writer feature uses `mcpplibs.os.fs` for portable atomic replacement. The general helper and CMake adapter do not add this dependency to ordinary consumers. + +The writer feature is usable by an ordinary standalone worker with an ordinary dependency; it imports no build-program-only `mcpp` module. Importing `mcpp.plugins.progress.writer` also exposes the `snapshot` type. `tests/progress-worker` compiles and runs this configuration. diff --git a/mcpp.toml b/mcpp.toml index cddbe9f..e80b220 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -309,8 +309,8 @@ sources = ["deps/vcpkg.cppm"] implies = ["deps"] [features.plugins-progress] -sources = ["src/progress_writer.cppm"] -implies = ["plugins-core"] +# Standalone workers need no build-program-only mcpp module. +sources = ["src/progress.cppm", "src/progress_writer.cppm"] [feature-deps.plugins-progress.mcpplibs] os = ">=0.2.3" diff --git a/src/progress_writer.cppm b/src/progress_writer.cppm index 78db250..1e30206 100644 --- a/src/progress_writer.cppm +++ b/src/progress_writer.cppm @@ -1,7 +1,7 @@ // Atomic latest-state writer for action worker programs. export module mcpp.plugins.progress.writer; import std; -import mcpp.plugins.progress; +export import mcpp.plugins.progress; import mcpplibs.os.fs; export namespace mcpp::plugins::progress { class writer { diff --git a/tests/progress-worker/mcpp.toml b/tests/progress-worker/mcpp.toml new file mode 100644 index 0000000..52599c2 --- /dev/null +++ b/tests/progress-worker/mcpp.toml @@ -0,0 +1,8 @@ +[package] +name = "progress-worker" +version = "0.1.0" +[dependencies.mcpp] +plugins = { path = "../..", features = ["plugins-progress"] } +[targets.progress-worker] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/progress-worker/src/main.cpp b/tests/progress-worker/src/main.cpp new file mode 100644 index 0000000..ff4afaf --- /dev/null +++ b/tests/progress-worker/src/main.cpp @@ -0,0 +1,12 @@ +import std; +import mcpp.plugins.progress.writer; +int main(int argc, char**) { + const bool probe = argc > 1; + mcpp::plugins::progress::writer writer; + if (!writer.enabled()) return probe ? 3 : 0; + const mcpp::plugins::progress::snapshot start{.phase="configure"}; + const bool began = writer.report(start); + std::this_thread::sleep_for(std::chrono::milliseconds(210)); + const bool finished = writer.report({.phase="compile",.done=1,.total=1}); + return probe && (!began || !finished) ? 2 : 0; +} From 26ff5430eda7a957bffe39ca6869801ad5d1b672 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 12:07:55 +0900 Subject: [PATCH 8/8] fix: preserve Unicode progress channel paths on Windows Read the channel through the UTF-8 environment API instead of the CRT narrow environment. Exercise disabled channels, Unicode paths and optional write failure on Linux, macOS and Windows, recording the Windows ANSI code page. --- .github/workflows/ci.yml | 14 ++++------- docs/plugin-development.md | 2 +- src/progress_writer.cppm | 5 ++-- tests/progress-worker/verify.py | 44 +++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 tests/progress-worker/verify.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7156c6..724aa16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1481,15 +1481,7 @@ jobs: - name: an ordinary worker imports the progress writer without build-program modules working-directory: tests/progress-worker - run: | - "$MCPP" run - MCPP_ACTION_PROGRESS_FILE="$PWD/target/snapshot.json" "$MCPP" run - python3 - <<'PYCODE' - import json - from pathlib import Path - snapshot = json.loads(Path("target/snapshot.json").read_text()) - assert snapshot == dict(v=1, seq=1, phase="compile", done=1, total=1), snapshot - PYCODE + run: python3 verify.py "$MCPP" - name: CUDA and SYCL coexist with independent code targets working-directory: tests/multi-accel-consumer @@ -2558,6 +2550,10 @@ jobs: # The test kit on this host: the same stated contexts, so a decision that # depends on the host the build program runs on is caught here. + - name: standalone progress uses Unicode channels on this host + working-directory: tests/progress-worker + run: python3 verify.py "$MCPP" + - name: the deps members' decisions hold against stated build contexts (mcpp.plugins.testing) run: bash .github/scripts/check-deps-and-qt.sh plugin-logic diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 0b52bd1..69658c9 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -222,4 +222,4 @@ A project's own plugin is a module in the project (a path dependency with `host- Worker programs select `plugins-progress` and import `mcpp.plugins.progress.writer`. `writer` reads `MCPP_ACTION_PROGRESS_FILE`; an absent channel is a no-op. `report({.phase="compile", .done=3, .total=9})` atomically replaces the latest schema-1 snapshot, assigns increasing sequence numbers and publishes at most five times per second. Omit counts when the total is unknown. `report()` returns whether it published; this result does not determine whether the worker's actual task succeeded. The optional writer feature uses `mcpplibs.os.fs` for portable atomic replacement. The general helper and CMake adapter do not add this dependency to ordinary consumers. -The writer feature is usable by an ordinary standalone worker with an ordinary dependency; it imports no build-program-only `mcpp` module. Importing `mcpp.plugins.progress.writer` also exposes the `snapshot` type. `tests/progress-worker` compiles and runs this configuration. +The writer feature is usable by an ordinary standalone worker with an ordinary dependency; it imports no build-program-only `mcpp` module. Importing `mcpp.plugins.progress.writer` also exposes the `snapshot` type. Channel paths preserve UTF-8 on every host, including Windows with a non-UTF-8 ANSI code page. `tests/progress-worker` checks disabled channels, Unicode paths and optional write failure on Linux, macOS and Windows. diff --git a/src/progress_writer.cppm b/src/progress_writer.cppm index 1e30206..b82ebc2 100644 --- a/src/progress_writer.cppm +++ b/src/progress_writer.cppm @@ -3,12 +3,13 @@ export module mcpp.plugins.progress.writer; import std; export import mcpp.plugins.progress; import mcpplibs.os.fs; +import mcpplibs.os.env; export namespace mcpp::plugins::progress { class writer { public: writer() { - if (const auto* file = std::getenv("MCPP_ACTION_PROGRESS_FILE"); file && *file) - file_ = mcpplibs::os::fs::from_utf8(file); + if (auto file = mcpplibs::os::env::get("MCPP_ACTION_PROGRESS_FILE"); file && !file->empty()) + file_ = mcpplibs::os::fs::from_utf8(*file); } bool enabled() const { return !file_.empty(); } // At most five atomic latest-state replacements per second. Reporting diff --git a/tests/progress-worker/verify.py b/tests/progress-worker/verify.py new file mode 100644 index 0000000..b1d641c --- /dev/null +++ b/tests/progress-worker/verify.py @@ -0,0 +1,44 @@ +"""Exercise the standalone writer's environment and file contract on this host.""" + +import ctypes +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +compiler = str(Path(sys.argv[1]).resolve()) +project = Path(__file__).resolve().parent +environment = os.environ.copy() +environment.pop("MCPP_ACTION_PROGRESS_FILE", None) +environment.pop("MCPP_ACTION_PROGRESS_ROOT", None) + +if os.name == "nt": + code_page = ctypes.windll.kernel32.GetACP() + print(f"Windows ACP: {code_page}", flush=True) + if code_page == 65001: + print("UTF-8 ACP: this run validates paths without reproducing ANSI loss.", flush=True) + + +def run(env, probe=False): + command = [compiler, "run"] + if probe: + command += ["--", "probe"] + subprocess.run(command, cwd=project, env=env, check=True, timeout=600) + + +run(environment) +with tempfile.TemporaryDirectory(prefix="mcpp-progress-") as directory: + root = Path(directory) / "channel-通道 Ω" + root.mkdir() + channel = root / "snapshot.json" + run(dict(environment, MCPP_ACTION_PROGRESS_FILE=str(channel)), probe=True) + snapshot = json.loads(channel.read_text(encoding="utf-8")) + assert snapshot == dict(v=1, seq=1, phase="compile", done=1, total=1), snapshot + # A directory cannot be replaced with the snapshot file. Telemetry failure + # remains optional for a worker whose actual task succeeds. + run(dict(environment, MCPP_ACTION_PROGRESS_FILE=str(root))) + +print("PASS: disabled, Unicode channel and optional write failure", flush=True)