Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@ jobs:
"$MCPP" run --no-accel | tee run.log
grep -q '^12 24 36 48' run.log

# TWO RULES IN ONE BUILD PROGRAM, which every step above has exactly
# one of. With one, a rule can take the whole of `device_sources()` and
# be right by accident; with two, that list holds a `.cu` AND a `.comp`,
# and a rule that takes all of it hands its compiler a file the compiler
# does not accept. Measured on the unfixed rules: the CUDA rule compiled
# `scale.comp` as CUDA and the shader rule then failed on the `.cu`.
#
# Both device products are asserted to exist, because a rule that
# submitted no action would otherwise leave a build that "succeeded".
# The `--no-accel` leg is asserted separately: it is what proves a rule
# whose backend is absent does nothing rather than complaining.
- name: two rules in one build program
working-directory: tests/multi-rule-consumer
run: |
"$MCPP" build
test -f target/.build-mcpp/out/saxpy.cu.o
test -f target/.build-mcpp/out/spirv/scale_comp.h
test ! -f target/.build-mcpp/out/scale.cu.o
"$MCPP" run --no-accel | tee run.log
grep -q '^magic=(no shader in this build)' run.log
grep -q '^12 24 36 48' run.log

# THE HOST-LEAK ASSERTION, and what it measures was corrected once.
#
# Its first form grepped a verbose build for `/usr`. That reported
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ The build plugins the mcpp project maintains, published as one package,
imports each one from `build.mcpp` under the module name the member declares.

```toml
[dependencies.mcpp]
plugins = { version = "0.2.0", features = ["rules-spirv"], host-module = true }
[build-dependencies.mcpp]
plugins = { version = "0.2.2", features = ["rules-spirv"], host-module = true }
```

`[build-dependencies]`, not `[dependencies]`. The two keys answer separate
questions: `host-module = true` says which build-time product is wanted, and
the section says whether the package reaches the target. A rule package answers
"no" to the second -- its library must never be linked into the artifact while
its rule is still needed -- which is the case docs/05 section 2.6.1 exists for.
Writing it under `[dependencies]` also works, which is precisely why the
distinction has to be stated rather than left to a failure to teach.

```cpp
// build.mcpp
import std;
Expand Down Expand Up @@ -44,6 +52,29 @@ engine's own module family and is not used here.
| `rules-sycl` | `mcpp.rules.sycl` | 2026.9.6.1 | `xim:dpcpp` (the compiler), `xim:gcc` (the C++ standard library the unit compiles against, not a second toolchain) and `xim:cuda-nvcc` for an NVIDIA target; `[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. The floor is the release whose device-source table carries `.sycl` |
| `tools-embed` | `mcpp.tools.embed` | 2026.9.5.4 | nothing beyond mcpp: it reads a file and writes a header while the build program runs. The floor is the release whose fast path compares a declared file input, without which an edit to the data does not reach the binary |

### Each rule takes the extensions it claims

`mcpp::device_sources()` is the package's WHOLE device set, not one rule's
share of it, and every rule in a build program reads the same variable. A
project with two backends puts a `.cu` and a `.comp` in that one list.

Each rule therefore selects the extensions it claims and leaves the rest:

| feature | claims |
|---|---|
| `rules-cuda` | `.cu` |
| `rules-hip` | `.hip` |
| `rules-sycl` | `.sycl` |
| `rules-spirv` | `.comp .vert .frag .geom .tesc .tese .mesh .task .rgen .rint .rahit .rchit .rmiss .rcall`, and `.glsl` / `.hlsl` so that a stage-less name is refused by name rather than by absence |

A rule whose backend this build does not name returns immediately, so a build
program may call every rule it imports unconditionally and `--no-accel`
compiles nothing.

A device source that NO rule claims is not silently dropped: mcpp refuses a
device source that reached no action, naming the file. That is the engine's
half of this rule and it needs 2026.9.6.5.

The floor is the mcpp release whose engine carries what the member relies on:
`rules-spirv` needs the device-source table that classifies shader extensions,
which 2026.9.5.3 introduced; `tools-embed` needs the fast path to compare a
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "plugins"
namespace = "mcpp"
version = "0.2.1"
version = "0.2.2"
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"]
Expand Down
39 changes: 36 additions & 3 deletions rules/cuda.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,36 @@ inline target parse_target(std::string_view accel) {
return t;
}

// ─── This rule's share of the device sources ───────────────────────────────
//
// `mcpp::device_sources()` is the package's WHOLE device set, not this rule's
// share of it. A project with two backends puts a `.cu` and a `.comp` in one
// list, and every rule in that build program reads the same variable. Taking
// all of it works for exactly as long as a build has one rule in it, and then
// fails on the second -- not by dropping anything, but by handing a compiler a
// file it does not accept, with a message about that file's contents rather
// than about the rule that should have had it.
//
// So each rule takes the extensions it CLAIMS and leaves the rest to whoever
// claims those. A file no rule claims is not silently dropped either: the
// engine refuses a device source that reached no action, which is the one
// place that can see every rule's share at once.
constexpr std::string_view kClaimed[] = { ".cu" };

inline bool claims_extension(std::string_view path) {
const auto slash = path.find_last_of("/\\");
const auto name = slash == std::string_view::npos ? path : path.substr(slash + 1);
const auto dot = name.rfind('.');
if (dot == std::string_view::npos) return false;
const auto ext = name.substr(dot);
for (auto e : kClaimed) if (e == ext) return true;
return false;
}

inline std::vector<std::string> device_sources() {
std::vector<std::string> out;
for (auto part : split(std::string_view(mcpp::device_sources()), '\n'))
if (auto s = trim(part); !s.empty()) out.emplace_back(s);
if (auto s = trim(part); !s.empty() && claims_extension(s)) out.emplace_back(s);
return out;
}

Expand Down Expand Up @@ -641,10 +667,17 @@ inline bool submit(std::span<const edge> edges) {
// the CPU-only variant, and the seam's fallback carries it.
inline bool compile(options opt = {}) {
if (!*mcpp::accel()) return true;
// Several rules in one build program is the ordinary shape for a project
// with several backends, and each is called unconditionally -- the build
// program cannot know which backends this build named without parsing
// `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 sources = device_sources();
if (sources.empty()) {
mcpp::warning("[build] accel names a device but no constrained glob matched a device "
"source; nothing was compiled for it");
mcpp::warning("[build] accel names cuda but no constrained glob matched a `.cu`; "
"nothing was compiled for it");
return true;
}
auto edges = plan(sources, std::move(opt));
Expand Down
39 changes: 36 additions & 3 deletions rules/hip.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,36 @@ struct edge {
std::vector<std::string> command, inputs, outputs;
};

// ─── This rule's share of the device sources ───────────────────────────────
//
// `mcpp::device_sources()` is the package's WHOLE device set, not this rule's
// share of it. A project with two backends puts a `.hip` and a `.comp` in one
// list, and every rule in that build program reads the same variable. Taking
// all of it works for exactly as long as a build has one rule in it, and then
// fails on the second -- not by dropping anything, but by handing a compiler a
// file it does not accept, with a message about that file's contents rather
// than about the rule that should have had it.
//
// So each rule takes the extensions it CLAIMS and leaves the rest to whoever
// claims those. A file no rule claims is not silently dropped either: the
// engine refuses a device source that reached no action, which is the one
// place that can see every rule's share at once.
constexpr std::string_view kClaimed[] = { ".hip" };

inline bool claims_extension(std::string_view path) {
const auto slash = path.find_last_of("/\\");
const auto name = slash == std::string_view::npos ? path : path.substr(slash + 1);
const auto dot = name.rfind('.');
if (dot == std::string_view::npos) return false;
const auto ext = name.substr(dot);
for (auto e : kClaimed) if (e == ext) return true;
return false;
}

inline std::vector<std::string> device_sources() {
std::vector<std::string> out;
for (auto part : split(std::string_view(mcpp::device_sources()), '\n'))
if (auto s = trim(part); !s.empty()) out.emplace_back(s);
if (auto s = trim(part); !s.empty() && claims_extension(s)) out.emplace_back(s);
return out;
}

Expand Down Expand Up @@ -393,10 +419,17 @@ inline bool submit(std::span<const edge> edges) {
// the same reason.
inline bool compile(options opt = {}) {
if (!*mcpp::accel()) return true;
// Several rules in one build program is the ordinary shape for a project
// with several backends, and each is called unconditionally -- the build
// program cannot know which backends this build named without parsing
// `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 sources = device_sources();
if (sources.empty()) {
mcpp::warning("[build] accel names hip but no constrained glob matched a device "
"source; nothing was compiled for it");
mcpp::warning("[build] accel names hip but no constrained glob matched a `.hip`; "
"nothing was compiled for it");
return true;
}
auto edges = plan(sources, std::move(opt));
Expand Down
41 changes: 40 additions & 1 deletion rules/spirv.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -321,14 +321,46 @@ inline std::string symbol_of(std::string_view stem, std::string_view stage) {
// contain a newline, which is why the engine chose it — and why a splitter
// that guesses wrong still works for exactly one shader and silently produces
// one impossible path for two.
// ─── This rule's share of the device sources ───────────────────────────────
//
// `mcpp::device_sources()` is the package's WHOLE device set, not this rule's
// share of it. A project with two backends puts a `.comp` and a `.cu` in one
// list, and every rule in that build program reads the same variable. Taking
// all of it works for exactly as long as a build has one rule in it, and then
// fails on the second -- not by dropping anything, but by handing a compiler a
// file it does not accept, with a message about that file's contents rather
// than about the rule that should have had it.
//
// So each rule takes the extensions it CLAIMS and leaves the rest to whoever
// claims those. A file no rule claims is not silently dropped either: the
// engine refuses a device source that reached no action, which is the one
// place that can see every rule's share at once.
constexpr std::string_view kClaimed[] = { ".comp", ".vert", ".frag", ".geom", ".tesc", ".tese", ".mesh", ".task",
".rgen", ".rint", ".rahit", ".rchit", ".rmiss", ".rcall",
// Stage-less, and claimed on purpose: `stage_of` refuses them by name
// and says which extensions carry a stage. Left unclaimed they would
// reach the engine's "no action compiles it" instead, which is true
// but says nothing about stages.
".glsl", ".hlsl", };

inline bool claims_extension(std::string_view path) {
const auto slash = path.find_last_of("/\\");
const auto name = slash == std::string_view::npos ? path : path.substr(slash + 1);
const auto dot = name.rfind('.');
if (dot == std::string_view::npos) return false;
const auto ext = name.substr(dot);
for (auto e : kClaimed) if (e == ext) return true;
return false;
}

inline std::vector<std::string> device_shaders() {
std::vector<std::string> out;
std::string_view all(mcpp::device_sources());
for (std::size_t i = 0; i <= all.size();) {
auto sep = all.find('\n', i);
auto one = trim(all.substr(i, sep == std::string_view::npos ? all.size() - i : sep - i));
i = sep == std::string_view::npos ? all.size() + 1 : sep + 1;
if (!one.empty()) out.emplace_back(one);
if (!one.empty() && claims_extension(one)) out.emplace_back(one);
}
return out;
}
Expand Down Expand Up @@ -485,6 +517,13 @@ inline bool compile(std::span<const std::string> shaders, options opt = {}) {
// reason.
inline bool compile(options opt = {}) {
if (!*mcpp::accel()) return true;
// Several rules in one build program is the ordinary shape for a project
// with several backends, and each is called unconditionally -- the build
// program cannot know which backends this build named without parsing
// `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 shaders = device_shaders();
if (shaders.empty()) {
mcpp::warning("[build] accel names vulkan but no constrained glob matched a "
Expand Down
39 changes: 36 additions & 3 deletions rules/sycl.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,36 @@ struct edge {
std::vector<std::string> command, inputs, outputs;
};

// ─── This rule's share of the device sources ───────────────────────────────
//
// `mcpp::device_sources()` is the package's WHOLE device set, not this rule's
// share of it. A project with two backends puts a `.sycl` and a `.comp` in one
// list, and every rule in that build program reads the same variable. Taking
// all of it works for exactly as long as a build has one rule in it, and then
// fails on the second -- not by dropping anything, but by handing a compiler a
// file it does not accept, with a message about that file's contents rather
// than about the rule that should have had it.
//
// So each rule takes the extensions it CLAIMS and leaves the rest to whoever
// claims those. A file no rule claims is not silently dropped either: the
// engine refuses a device source that reached no action, which is the one
// place that can see every rule's share at once.
constexpr std::string_view kClaimed[] = { ".sycl" };

inline bool claims_extension(std::string_view path) {
const auto slash = path.find_last_of("/\\");
const auto name = slash == std::string_view::npos ? path : path.substr(slash + 1);
const auto dot = name.rfind('.');
if (dot == std::string_view::npos) return false;
const auto ext = name.substr(dot);
for (auto e : kClaimed) if (e == ext) return true;
return false;
}

inline std::vector<std::string> device_sources() {
std::vector<std::string> out;
for (auto part : split(std::string_view(mcpp::device_sources()), '\n'))
if (auto s = trim(part); !s.empty()) out.emplace_back(s);
if (auto s = trim(part); !s.empty() && claims_extension(s)) out.emplace_back(s);
return out;
}

Expand Down Expand Up @@ -424,10 +450,17 @@ inline bool submit(std::span<const edge> edges) {
// the same reason.
inline bool compile(options opt = {}) {
if (!*mcpp::accel()) return true;
// Several rules in one build program is the ordinary shape for a project
// with several backends, and each is called unconditionally -- the build
// program cannot know which backends this build named without parsing
// `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 sources = device_sources();
if (sources.empty()) {
mcpp::warning("[build] accel names sycl but no constrained glob matched a device "
"source; nothing was compiled for it");
mcpp::warning("[build] accel names sycl but no constrained glob matched a `.sycl`; "
"nothing was compiled for it");
return true;
}
auto edges = plan(sources, std::move(opt));
Expand Down
6 changes: 5 additions & 1 deletion tests/cuda-consumer/mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import_std = true
[toolchain]
default = "llvm@22.1.8"

[dependencies.mcpp]
# `[build-dependencies]`, not `[dependencies]`: a rule package's library must
# never reach the target while its rule is still wanted, which is the case
# docs/05 section 2.6.1 exists for. `host-module = true` says which build-time
# product is wanted; the section says whether the package reaches the target.
[build-dependencies.mcpp]
plugins = { path = "../..", features = ["rules-cuda"], host-module = true }

# The driver's userspace library, reached through an index package that owns
Expand Down
6 changes: 5 additions & 1 deletion tests/embed-consumer/mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ standard = "c++23"
modules = true
import_std = true

[dependencies.mcpp]
# `[build-dependencies]`, not `[dependencies]`: a rule package's library must
# never reach the target while its rule is still wanted, which is the case
# docs/05 section 2.6.1 exists for. `host-module = true` says which build-time
# product is wanted; the section says whether the package reaches the target.
[build-dependencies.mcpp]
plugins = { path = "../..", features = ["tools-embed"], host-module = true }

[targets.embed-consumer]
Expand Down
6 changes: 5 additions & 1 deletion tests/hip-consumer/mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import_std = true
[toolchain]
default = "llvm@22.1.8"

[dependencies.mcpp]
# `[build-dependencies]`, not `[dependencies]`: a rule package's library must
# never reach the target while its rule is still wanted, which is the case
# docs/05 section 2.6.1 exists for. `host-module = true` says which build-time
# product is wanted; the section says whether the package reaches the target.
[build-dependencies.mcpp]
plugins = { path = "../..", features = ["rules-hip"], host-module = true }

# The driver's userspace library. HIP reaches the device through the CUDA
Expand Down
30 changes: 30 additions & 0 deletions tests/multi-rule-consumer/build.mcpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import std;
import mcpp;
import mcpp.rules.cuda;
import mcpp.rules.spirv;

// TWO RULES IN ONE BUILD PROGRAM. Every other fixture here has exactly one,
// and one is the case where a rule can take the whole of
// `mcpp::device_sources()` and be right by accident.
//
// With two, that list holds a `.cu` AND a `.comp`, and a rule that takes all
// of it hands its compiler a file the compiler does not accept -- glslc
// refusing a `.cu` for having no shader stage, or nvcc refusing a `.comp`.
// Each rule therefore takes the extensions it CLAIMS and leaves the rest.
//
// Both are called unconditionally and neither is told which backends this
// build named: each parses `[build] accel` itself and returns immediately when
// its own is absent. That is what makes `--no-accel` work with two rules
// present, and why the order here carries no meaning.
int main() {
mcpp::rerun_if_changed_glob("src/kernels/**/*.cu");
mcpp::rerun_if_changed_glob("shaders/**/*.comp");

mcpp::rules::cuda::options cu;
cu.includes = { "include" };
if (!mcpp::rules::cuda::compile(cu)) return 1;

mcpp::rules::spirv::options sp;
sp.includes = { "shaders" };
return mcpp::rules::spirv::compile(sp) ? 0 : 1;
}
Loading
Loading