Skip to content

Commit f2de22b

Browse files
committed
feat: carry the accelerator dimension from descriptor to refusal
The comparison added earlier had no reader: nothing populated the field, so it was empty everywhere and accepted everything. This connects both ends. An artifact declares what device code it carries in a separate descriptor field, in one wire form that is also the diagnostic form, so what a publisher writes is what a refusal prints back. A build declares what it targets through [build] accel, overridden for one build by --accel, which is the relationship --target already has with [toolchain]. --no-accel is not the absence of --accel: it is an explicit request for none, which is what a user needs in order to take the CPU-only variant of a package that also publishes device builds. A build that asks for no accelerator is satisfied by every artifact vacuously. That is correct rather than permissive, and it is why a descriptor lists its CPU-only variant first: the first accepted artifact wins, so an older mcpp that does not know this field at all still selects the variant that runs anywhere.
1 parent 68e147a commit f2de22b

9 files changed

Lines changed: 143 additions & 2 deletions

File tree

modules/manifest/src/toml.cppm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1524,6 +1524,10 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
15241524
return std::unexpected(error(origin, *err));
15251525
m.buildConfig.moduleExtensions = *v;
15261526
}
1527+
// [build] accel — the accelerator backends and device architectures this
1528+
// build targets. One spelling with the descriptor field and the diagnostic,
1529+
// so what a user writes is what a refusal prints back at them.
1530+
if (auto v = doc->get_string("build.accel")) m.buildConfig.accel = *v;
15271531
// [build] build_program_timeout — seconds a build.mcpp may run; 0 = no
15281532
// limit. `optional` is load-bearing: with a plain int, "absent" and
15291533
// "explicitly 0" would be the same value, and every project that never

modules/manifest/src/types.cppm

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,11 @@ struct BuildConfig : BuildInputs {
570570
// Scoped to the declaring package — a dependency is classified by its own
571571
// manifest, never by its consumer's.
572572
std::vector<std::string> moduleExtensions;
573+
// [build] accel — which accelerator backends and device architectures this
574+
// build targets, in the wire form mcpp.pack.abi_tag reads. Empty means the
575+
// build asks for none, and then every prebuilt artifact satisfies it
576+
// vacuously; ordering in the descriptor is what makes the CPU variant win.
577+
std::string accel;
573578
// [build] build_program_timeout — seconds this package's build.mcpp may
574579
// run before mcpp kills it. 0 = no limit; nullopt = use the built-in 600.
575580
//
@@ -752,6 +757,14 @@ struct RuntimeArtifact {
752757
std::filesystem::path path;
753758
std::string provenance;
754759
std::string abi;
760+
// What device code this artifact carries, in the wire form
761+
// mcpp.pack.abi_tag reads: `cuda12.8+{sm_80,sm_90f} ptx>=90`.
762+
//
763+
// A separate field rather than a segment of `abi`, because an architecture
764+
// list is a set and the tag is a dash-joined string whose triple already
765+
// carries a variable number of dashes. Empty means the artifact carries no
766+
// device code, which constrains nothing.
767+
std::string accel;
755768
std::string digest;
756769
std::string hostFingerprint;
757770
};

modules/manifest/src/xpkg.cppm

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1941,6 +1941,7 @@ synthesize_from_xpkg_lua(std::string_view luaContent,
19411941
else if (field == "path") path = std::move(value);
19421942
else if (field == "provenance") artifact.provenance = std::move(value);
19431943
else if (field == "abi") artifact.abi = std::move(value);
1944+
else if (field == "accel") artifact.accel = std::move(value);
19441945
else if (field == "digest") artifact.digest = std::move(value);
19451946
else if (field == "host_fingerprint")
19461947
artifact.hostFingerprint = std::move(value);

src/build/prepare.cppm

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,10 @@ export struct BuildOverrides {
839839
std::shared_ptr<const mcpp::platform::runtime::RuntimeBinding>
840840
inherited_runtime_binding;
841841
std::string target_triple; // empty = host triple, fall through to [toolchain]
842+
// --accel: the device backends and architectures this build targets, in
843+
// the wire form mcpp.pack.abi_tag reads. Overrides `[build] accel`, the
844+
// same relationship --target has with [toolchain].
845+
std::string accel;
842846
bool force_static = false; // --static (or implied by musl target)
843847
std::string package_filter; // -p <name>: only build this workspace member
844848
// --profile <name>. Empty = fall through to `[build] default-profile`, then
@@ -8994,8 +8998,14 @@ prepare_build(bool print_fingerprint,
89948998
auto t = mcpp::toolchain::triple::parse(tc->targetTriple);
89958999
return t ? t->str() : tc->targetTriple;
89969000
}();
8997-
const auto currentTag = mcpp::pack::cxx_surface_tag(
9001+
auto currentTag = mcpp::pack::cxx_surface_tag(
89989002
*tc, canonicalTriple, m->cppStandard.level);
9003+
// What THIS build targets on the device axis. Absent means it asks for
9004+
// no accelerator, and every artifact then satisfies it vacuously —
9005+
// which is correct, and is why a descriptor lists its CPU-only variant
9006+
// first: the first accepted artifact wins.
9007+
currentTag.accel = mcpp::pack::parse_accel(
9008+
overrides.accel.empty() ? m->buildConfig.accel : overrides.accel);
89999009
for (std::size_t i = 1; i < packages.size(); ++i) {
90009010
auto const& pkg = packages[i];
90019011
if (!mcpp::pack::is_distribution_package(pkg.manifest)) continue;

src/cli.cppm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@ int run(int argc, char** argv) {
367367
.help("Deprecated alias for --cache=off (also clears the build dir)"))
368368
.option(cl::Option("target").takes_value().help(
369369
"Build for <triple> (e.g. x86_64-linux-musl); looks up [target.<triple>] in mcpp.toml"))
370+
.option(cl::Option("accel").takes_value().value_name("SPEC")
371+
.help("Device backends and architectures, e.g. 'cuda12.8+{sm_89}'; overrides [build] accel"))
372+
.option(cl::Option("no-accel")
373+
.help("Target no accelerator, ignoring [build] accel"))
370374
.option(cl::Option("static").help(
371375
"Force static linking (-static). On Linux, prefer pairing with --target <arch>-linux-musl"))
372376
.option(cl::Option("package").short_name('p').takes_value().value_name("NAME")

src/cli/cmd_build.cppm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) {
9090

9191
mcpp::build::BuildOverrides ov;
9292
if (auto t = parsed.value("target")) ov.target_triple = *t;
93+
// --accel / --no-accel stand to `[build] accel` exactly as --target stands
94+
// to [toolchain]: the manifest declares, the command line overrides for one
95+
// build. --no-accel is not the absence of --accel; it is an explicit
96+
// request for none, which is what a user needs in order to take a CPU-only
97+
// variant of a package that also publishes device builds.
98+
if (parsed.is_flag_set("no-accel")) ov.accel = "(none)";
99+
else if (auto a = parsed.value("accel")) ov.accel = *a;
93100
if (auto p = parsed.value("package")) ov.package_filter = *p;
94101
// --cache global|local|off. --no-cache is the deprecated alias for off; the
95102
// old flag only ever cleared target/, which says nothing about a cache, so

src/pack/abi_tag.cppm

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,22 @@ struct TagMismatch {
143143
// is compared as a floor, not for equality.
144144
std::vector<TagMismatch> tag_check(const AbiTag& published, const AbiTag& current);
145145

146-
// One backend rendered for a diagnostic: `cuda12.8+{sm_80,sm_90} ptx>=80`.
146+
// The wire and diagnostic form of the device dimension:
147+
//
148+
// cuda12.8+{sm_80,sm_90f} ptx>=90, rocm6.4+{gfx942}
149+
//
150+
// ONE form for both, so what a descriptor stores is what a refusal prints and
151+
// a reader never has to hold two spellings of the same fact in their head.
152+
// The backend is the leading run of letters and the version is what follows,
153+
// which parses because every backend name is alphabetic and every version
154+
// starts with a digit.
147155
std::string accel_str(std::span<const AccelSet> sets);
148156

157+
// The inverse. Unparseable input yields an empty vector, which the comparison
158+
// reads as "carries no device code" — the same answer as a descriptor that
159+
// never mentioned the dimension, and the safe one.
160+
std::vector<AccelSet> parse_accel(std::string_view s);
161+
149162
// The `c++NN` segment as its numeric level, or 0 when unparseable.
150163
int standard_level(std::string_view standardSegment);
151164

@@ -224,6 +237,64 @@ std::string accel_str(std::span<const AccelSet> sets) {
224237
return out;
225238
}
226239

240+
namespace {
241+
242+
std::string_view trim_sv(std::string_view s) {
243+
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
244+
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1);
245+
return s;
246+
}
247+
248+
} // namespace
249+
250+
std::vector<AccelSet> parse_accel(std::string_view s) {
251+
std::vector<AccelSet> out;
252+
for (std::size_t i = 0; i <= s.size(); ) {
253+
auto comma = s.find(',', i);
254+
// A comma inside `{...}` separates architectures, not backends.
255+
auto open = s.find('{', i);
256+
auto close = s.find('}', i);
257+
if (open != std::string_view::npos && close != std::string_view::npos
258+
&& comma != std::string_view::npos && comma > open && comma < close)
259+
comma = s.find(',', close);
260+
auto chunk = trim_sv(comma == std::string_view::npos
261+
? s.substr(i)
262+
: s.substr(i, comma - i));
263+
i = comma == std::string_view::npos ? s.size() + 1 : comma + 1;
264+
if (chunk.empty() || chunk == "(none)") continue;
265+
266+
AccelSet a;
267+
auto plus = chunk.find('+');
268+
auto head = trim_sv(plus == std::string_view::npos ? chunk : chunk.substr(0, plus));
269+
std::size_t n = 0;
270+
while (n < head.size() && std::isalpha(static_cast<unsigned char>(head[n]))) ++n;
271+
if (n == 0) continue; // no backend: not our form
272+
a.backend = std::string(head.substr(0, n));
273+
a.version = std::string(head.substr(n));
274+
275+
if (plus != std::string_view::npos) {
276+
auto tail = chunk.substr(plus + 1);
277+
if (auto o = tail.find('{'); o != std::string_view::npos) {
278+
auto c = tail.find('}', o);
279+
auto archs = tail.substr(o + 1,
280+
c == std::string_view::npos ? std::string_view::npos : c - o - 1);
281+
for (std::size_t j = 0; j <= archs.size(); ) {
282+
auto k = archs.find(',', j);
283+
auto one = trim_sv(k == std::string_view::npos
284+
? archs.substr(j) : archs.substr(j, k - j));
285+
if (!one.empty()) a.archs.emplace_back(one);
286+
if (k == std::string_view::npos) break;
287+
j = k + 1;
288+
}
289+
}
290+
if (auto pf = tail.find("ptx>="); pf != std::string_view::npos)
291+
a.ptxFloor = std::string(trim_sv(tail.substr(pf + 5)));
292+
}
293+
out.push_back(std::move(a));
294+
}
295+
return out;
296+
}
297+
227298
int accel_arch_level(std::string_view arch) {
228299
auto digits = arch;
229300
if (digits.starts_with("sm_")) digits.remove_prefix(3);

src/pack/prebuilt.cppm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ check_prebuilt(const mcpp::manifest::Manifest& m, const PrebuiltCheck& in)
107107
publishedTags.push_back(a.abi);
108108
auto published = parse_abi_tag(a.abi);
109109
if (!published) { accepted = true; continue; } // unreadable → lenient
110+
// The device dimension travels beside the tag rather than inside it.
111+
published->accel = parse_accel(a.accel);
110112
auto bad = tag_check(*published, in.current);
111113
if (bad.empty()) { accepted = true; break; }
112114
// Keep the CLOSEST refusal to show: the one that disagrees least is

tests/unit/test_abi_tag_accel.cpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,32 @@ TEST(AbiTagAccel, AccelDoesNotMaskTheCxxDimensions) {
130130
ASSERT_EQ(bad.size(), 1u);
131131
EXPECT_EQ(bad[0].dimension, "compiler");
132132
}
133+
134+
// ─── The wire form round-trips ─────────────────────────────────────────────
135+
136+
TEST(AbiTagAccel, TheWireFormRoundTrips) {
137+
std::vector<mcpp::pack::AccelSet> sets{
138+
cuda({"sm_80", "sm_90f"}, "90"),
139+
mcpp::pack::AccelSet{ .backend = "rocm", .version = "6.4",
140+
.archs = {"gfx942", "gfx10-3-generic"} },
141+
};
142+
auto text = mcpp::pack::accel_str(sets);
143+
auto back = mcpp::pack::parse_accel(text);
144+
ASSERT_EQ(back.size(), 2u);
145+
EXPECT_EQ(back[0].backend, "cuda");
146+
EXPECT_EQ(back[0].version, "12.8");
147+
EXPECT_EQ(back[0].archs, (std::vector<std::string>{"sm_80", "sm_90f"}));
148+
EXPECT_EQ(back[0].ptxFloor, "90");
149+
EXPECT_EQ(back[1].backend, "rocm");
150+
EXPECT_EQ(back[1].archs, (std::vector<std::string>{"gfx942", "gfx10-3-generic"}));
151+
EXPECT_TRUE(back[1].ptxFloor.empty());
152+
EXPECT_EQ(mcpp::pack::accel_str(back), text);
153+
}
154+
155+
TEST(AbiTagAccel, UnparseableTextMeansNoDeviceCode) {
156+
// The safe answer, and the same one a descriptor that never mentioned the
157+
// dimension gives: an artifact that states nothing constrains nothing.
158+
EXPECT_TRUE(mcpp::pack::parse_accel("").empty());
159+
EXPECT_TRUE(mcpp::pack::parse_accel("(none)").empty());
160+
EXPECT_TRUE(mcpp::pack::parse_accel("12.8+{sm_90}").empty()); // no backend
161+
}

0 commit comments

Comments
 (0)