Skip to content

Commit 38549b4

Browse files
committed
tools-island: a seam has two halves, and scan is where they are compared
The generator served one island. A seam has two implementations of one `extern "C"` boundary -- a device island and a host fallback -- and exactly one of them is in any link, which is the arrangement every example under `examples/09-heterogeneous` has. Handed only the device half, the generator produced an empty boundary in a `--no-accel` build and the C++ side failed on an unresolved name. `scan` now merges entries by name, so a build program hands it both files unconditionally: both exist on disk in either build, and which one is compiled is the manifest's decision rather than a condition the build program repeats. Two definitions of one name that declare it differently are refused there, naming both files and both signatures. Nothing else catches that. The two halves are never in one translation unit and never in one link, and C language linkage does not mangle, so a build with disagreeing halves is clean and the artifact reads its arguments by whichever signature it was compiled with. `scan` is the only point at which both texts exist at once. The fixture gains the host half under `cfg(not(accelerator = "vulkan"))`, so both legs are built and run, and a CI step perturbs one signature and asserts the refusal names both files. The README paragraph claiming the two could not disagree under `scan` was true only while `scan` took one file.
1 parent c530131 commit 38549b4

6 files changed

Lines changed: 208 additions & 16 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,16 +243,14 @@ jobs:
243243
# island reads the generated header through the compiler's
244244
# forced-include flag -- so a project using this generator has no
245245
# header in its source tree and no line naming one.
246-
if grep -rn '#include' src/main.cpp src/kernels/saxpy.c; then
246+
if grep -rn '#include' src/main.cpp src/kernels/saxpy.c src/cpu/saxpy.c; then
247247
echo "FAIL: a source names an include; the generator exists to remove it"
248248
exit 1
249249
fi
250250
# AND THE CHECK THAT INCLUDE USED TO DO IS STILL THERE. The compiler
251-
# sees the declarations, so a definition whose signature drifted fails
252-
# where it was written. Verified by hand against this fixture with a
253-
# hand-written entry list declaring `double n` where the definition
254-
# says `unsigned n`: `saxpy.c:22: error: conflicting types for
255-
# 'scale_device'`, at the definition rather than at the link.
251+
# sees the declarations, so a definition whose signature drifted from
252+
# the generated header fails where it was written rather than at the
253+
# link.
256254
grep -q 'include' build.mcpp \
257255
|| { echo "FAIL: the build program no longer forces the header in"; exit 1; }
258256
# THE MARKER SELECTS, AND THE UNMARKED MUST NOT TRAVEL. The island has
@@ -275,6 +273,77 @@ jobs:
275273
"$d/island_interface.kernels.h"
276274
echo "ok: one declaration, two generated artefacts, and only what was marked"
277275
276+
# THE SEAM'S OTHER HALF, AND THE DISAGREEMENT THAT NOTHING ELSE CATCHES.
277+
#
278+
# A seam has two implementations of one `extern "C"` boundary -- a device
279+
# island and a host fallback -- and exactly one of them is in any link.
280+
# `build.mcpp` hands `scan` both files unconditionally, because both exist
281+
# on disk in either build and the manifest decides which is compiled. A
282+
# build program that asked `mcpp::accel()` instead would carry a second
283+
# copy of a decision the manifest already states.
284+
#
285+
# The CPU leg is not a variant of the step above. It is the build in which
286+
# the device half is ABSENT, so a generator that read only the file it was
287+
# given first would produce an empty boundary and the C++ side would fail
288+
# on an unresolved name.
289+
- name: the same boundary, generated from the half the build selected
290+
working-directory: tests/island-interface
291+
run: |
292+
rm -rf target
293+
"$MCPP" build --no-accel > cpu.log 2>&1 || {
294+
echo "FAIL: the CPU leg does not build"; tail -25 cpu.log; exit 1; }
295+
"$MCPP" run --no-accel | tee cpurun.log
296+
grep -q '^out\[0\]=6 out\[1\]=12 out\[2\]=18 out\[3\]=24' cpurun.log
297+
grep -q '^all ok' cpurun.log
298+
# ONE set of declarations, not two. Both files mark the same two entry
299+
# points; a merge that appended would declare each twice, which the
300+
# module rejects as a redefinition and the header silently accepts.
301+
d=target/.build-mcpp/out/island
302+
n=$(grep -c '^export using ::' "$d/island_interface.kernels.cppm")
303+
[ "$n" = 2 ] || {
304+
echo "FAIL: the module re-exports $n names; the two halves declare 2"
305+
cat "$d/island_interface.kernels.cppm"; exit 1; }
306+
echo "ok: the CPU leg reaches the same generated boundary"
307+
308+
# AND THE REVERSE LEG, WHICH IS THE POINT OF SCANNING BOTH.
309+
#
310+
# C language linkage does not mangle and the two halves are never in one
311+
# link, so two declarations of one name that disagree produce a clean
312+
# build and an artifact that reads its arguments by whichever signature it
313+
# happened to be compiled with. `scan` is the only place both texts exist
314+
# at once, so it is the only place that can refuse.
315+
- name: two halves that disagree are refused where both texts exist
316+
working-directory: tests/island-interface
317+
run: |
318+
cp src/cpu/saxpy.c /tmp/cpu_saxpy.bak
319+
sed -i 's/^int scale_device(float a, float\* out, unsigned n) {/int scale_device(float a, float* out, double n) {/' \
320+
src/cpu/saxpy.c
321+
grep -q 'double n' src/cpu/saxpy.c || {
322+
echo "FAIL: the fixture was not perturbed; this step would assert nothing"
323+
cp /tmp/cpu_saxpy.bak src/cpu/saxpy.c; exit 1; }
324+
rm -rf target
325+
set +e
326+
"$MCPP" build > neg.log 2>&1
327+
rc=$?
328+
set -e
329+
cp /tmp/cpu_saxpy.bak src/cpu/saxpy.c
330+
[ "$rc" != 0 ] || { echo "FAIL: the disagreement was accepted"; tail -20 neg.log; exit 1; }
331+
grep -q 'declare it differently' neg.log || {
332+
echo "FAIL: refused, but not for this reason"; tail -20 neg.log; exit 1; }
333+
# Both file names, because a diagnostic naming one leaves the reader
334+
# to find the other.
335+
grep -q 'src/kernels/saxpy.c' neg.log
336+
grep -q 'src/cpu/saxpy.c' neg.log
337+
echo "ok: refused, naming both definitions and both signatures"
338+
# And it builds again once they agree, so this step cannot leave a
339+
# fixture that refuses everything.
340+
rm -rf target
341+
"$MCPP" build > restored.log 2>&1 || {
342+
echo "FAIL: the fixture no longer builds after the perturbation was undone"
343+
tail -20 restored.log; exit 1; }
344+
rm -f neg.log restored.log cpu.log cpurun.log
345+
echo "ok: and it builds again once the two agree"
346+
278347
# NO BUILD PROGRAM AT ALL, WHICH IS WHAT THE TWO KEYS BUY.
279348
#
280349
# `spirv-consumer` and `spirv-module-consumer` both write a `build.mcpp`,

‎README.md‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,8 @@ The entry points are marked where they are defined, and the signature exists
274274
once:
275275
276276
```c
277-
// src/kernels/saxpy.cu
278-
#include "myapp.kernels.h" // generated; defines the marker as nothing
277+
// src/kernels/saxpy.cu -- no include: the generated header arrives through the
278+
// compiler's forced-include flag, which is what defines the marker as nothing.
279279
280280
MCPP_EXPORT_C
281281
int saxpy_device(float a, const float* x, const float* y, float* out, unsigned n) { ... }
@@ -327,17 +327,44 @@ until mcpp writes it.
327327
328328
**The check that include used to do is still there.** The compiler sees the
329329
declarations, so a definition whose signature drifted from its declaration fails
330-
where it was written rather than at the link. Verified against the fixture with
331-
a hand-written entry list declaring `double n` where the definition says
332-
`unsigned n`:
330+
where it was written rather than at the link:
333331
334332
```
335333
src/kernels/saxpy.c:22:5: error: conflicting types for 'scale_device'
336334
```
337335
338-
That check is only reachable on the `emit` path, where the list and the
339-
definition are separate things. Under `scan` the header is generated *from* the
340-
definition, so the two cannot disagree at all.
336+
**A seam has two halves, and `scan` is where they are compared.** A device
337+
island and a host fallback implement one `extern "C"` boundary, and exactly one
338+
of them is in any link. A build program hands `scan` both, unconditionally --
339+
both files exist on disk in either build, and which one is compiled is the
340+
manifest's decision rather than a condition the build program repeats:
341+
342+
```cpp
343+
const std::vector<std::string> islands{
344+
std::string(mcpp::manifest_dir()) + "/src/kernels/saxpy.cu",
345+
std::string(mcpp::manifest_dir()) + "/src/cpu/saxpy.cpp",
346+
};
347+
```
348+
349+
Entries are merged by name, so the two halves produce one set of declarations.
350+
Two definitions of one name that declare it **differently** are refused, naming
351+
both files and both signatures:
352+
353+
```
354+
mcpp.tools.island: two definitions of `scale_device` declare it differently.
355+
src/kernels/saxpy.c
356+
int scale_device(float a, float* out, unsigned n)
357+
src/cpu/saxpy.c
358+
int scale_device(float a, float* out, double n)
359+
C language linkage does not mangle, so these never meet at the link:
360+
whichever one is in the artifact reads its arguments by its own signature.
361+
```
362+
363+
Nothing else in the toolchain catches that. The two halves are never in one
364+
translation unit and never in one link, and C linkage does not mangle, so a
365+
build with disagreeing halves is clean and the artifact reads its arguments by
366+
whichever signature it was compiled with. `scan` is the only point at which both
367+
texts exist at once.
341368

342369
**The declaration still exists once.** Without this, a project writes it twice --
343370
in a header, and again wherever the C++ side reaches it. C language linkage does

‎tests/island-interface/build.mcpp‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@ import mcpp.tools.island;
1616
// linkage does not mangle, so two copies that disagree are one symbol: the link
1717
// is clean and each side reads the arguments by its own ABI.
1818
int main() {
19+
// BOTH HALVES, UNCONDITIONALLY. Which one the build compiles is the
20+
// manifest's decision and this program does not repeat it: the declarations
21+
// are the same either way, and `scan` merges them by entry name. A build
22+
// program that asked `mcpp::accel()` here would be a second copy of a
23+
// decision the manifest already states -- and the copy that goes stale.
1924
const std::vector<std::string> islands{
2025
std::string(mcpp::manifest_dir()) + "/src/kernels/saxpy.c",
26+
std::string(mcpp::manifest_dir()) + "/src/cpu/saxpy.c",
2127
};
2228
for (auto const& f : islands) mcpp::rerun_if_changed(f.c_str());
2329

@@ -28,6 +34,10 @@ int main() {
2834

2935
const auto entries = mcpp::tools::island::scan(islands, opt);
3036
if (!entries) return 1;
37+
// TWO, not four. Both files mark the same two entry points, and a merge
38+
// that appended instead would declare each one twice -- which the module
39+
// rejects as a redefinition and the header does not, so the count is the
40+
// criterion that catches it in both surfaces.
3141
if (entries->size() != 2) {
3242
std::cerr << std::format("expected two marked entry points, found {}\n",
3343
entries->size());

‎tests/island-interface/mcpp.toml‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ name = "island-interface"
2121
namespace = "example"
2222
version = "0.1.0"
2323
description = "An extern \"C\" island reached through a generated module"
24+
accelerators = ["vulkan"]
2425

2526
[language]
2627
standard = "c++23"
@@ -30,8 +31,21 @@ import_std = true
3031
[build-dependencies.mcpp]
3132
plugins = { path = "../..", features = ["tools-island"], host-module = true }
3233

34+
# ONE BOUNDARY, TWO IMPLEMENTATIONS, AND EXACTLY ONE OF THEM IN ANY LINK.
35+
#
36+
# This is the arrangement every seam in `examples/09-heterogeneous` has, and it
37+
# is what the generator has to serve without the project writing a condition:
38+
# `build.mcpp` hands `scan` both files unconditionally, because both exist on
39+
# disk in either build, and the manifest decides which one is compiled.
3340
[build]
34-
sources = ["src/*.cpp", "src/kernels/*.c"]
41+
accel = "vulkan1.2"
42+
sources = ["src/*.cpp"]
43+
44+
[target.'cfg(accelerator = "vulkan")'.build]
45+
sources = ["src/kernels/*.c"]
46+
47+
[target.'cfg(not(accelerator = "vulkan"))'.build]
48+
sources = ["src/cpu/*.c"]
3549

3650
[targets.island-interface]
3751
kind = "bin"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* The host half of the same boundary.
2+
*
3+
* The signatures here and in `../kernels/saxpy.c` are the same declarations,
4+
* and that is a property nothing else in the toolchain checks: C language
5+
* linkage does not mangle, exactly one of these files is in any link, and two
6+
* that disagreed would each read the arguments its own way.
7+
*
8+
* `mcpp::tools::island::scan` is handed both, so it is the one place where both
9+
* texts exist at once -- and it refuses a disagreement there, with both file
10+
* names, instead of leaving it to the run.
11+
*
12+
* No include here either. The generated boundary header arrives through the
13+
* compiler's forced-include flag, the same way it reaches the device half. */
14+
15+
MCPP_EXPORT_C
16+
int saxpy_device(float a, const float* x, const float* y, float* out, unsigned n) {
17+
for (unsigned i = 0; i < n; ++i) out[i] = a * x[i] + y[i];
18+
return 0;
19+
}
20+
21+
MCPP_EXPORT_C
22+
int scale_device(float a, float* out, unsigned n) {
23+
for (unsigned i = 0; i < n; ++i) out[i] = a * out[i];
24+
return 0;
25+
}
26+
27+
/* The host half has its own internals, and they must not reach the boundary
28+
* any more than the device half's do. */
29+
static int host_only_helper(int x) { return x - 1; }
30+
int internal_device(int x) { return host_only_helper(x); }

‎tools/island.cppm‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,25 @@ inline std::string entry_name(std::string_view decl) {
159159
// point that produced no declaration would leave the island defining a function
160160
// nothing declares, and the consumer's failure would be an unresolved name in a
161161
// different file.
162+
//
163+
// SEVERAL SOURCES MAY DEFINE THE SAME ENTRY POINT, AND THEY MUST AGREE.
164+
//
165+
// That is the ordinary shape of a seam: a device island and a host fallback
166+
// define one boundary, and exactly one of them is in any given link. A build
167+
// program hands both to this function and gets one set of declarations back,
168+
// so it does not have to ask which build it is in.
169+
//
170+
// Two definitions of one name whose declarations differ are REFUSED here,
171+
// naming both files. Nothing else in the toolchain catches that: C language
172+
// linkage does not mangle, so the two never meet at the link, and whichever
173+
// one is present reads its arguments by its own idea of the signature. This is
174+
// the only point at which both texts exist at once.
162175
inline std::optional<std::vector<std::string>>
163176
scan(std::span<const std::string> sources, const options& opt) {
164177
std::vector<std::string> entries;
178+
// Where each entry was found, for the disagreement diagnostic. Parallel to
179+
// `entries`, which is the return value and cannot carry it.
180+
std::vector<std::string> origin;
165181
for (auto const& src : sources) {
166182
std::ifstream in(src);
167183
if (!in) {
@@ -226,7 +242,33 @@ scan(std::span<const std::string> sources, const options& opt) {
226242
flat += c;
227243
}
228244
}
229-
if (!flat.empty()) entries.push_back(std::move(flat));
245+
if (flat.empty()) continue;
246+
247+
// MERGED BY ENTRY NAME. A second definition of a name already seen
248+
// is either the same declaration -- the seam's two halves agreeing,
249+
// which is the expected case -- or a disagreement that has to stop
250+
// the build here.
251+
const auto name = entry_name(flat);
252+
std::size_t seen = entries.size();
253+
for (std::size_t k = 0; k < entries.size(); ++k)
254+
if (entry_name(entries[k]) == name) { seen = k; break; }
255+
256+
if (seen == entries.size()) {
257+
entries.push_back(std::move(flat));
258+
origin.push_back(src);
259+
continue;
260+
}
261+
if (entries[seen] == flat) continue; // both halves agree
262+
263+
std::cerr << std::format(
264+
"mcpp.tools.island: two definitions of `{}` declare it differently.\n"
265+
" {}\n {}\n"
266+
" {}\n {}\n"
267+
" C language linkage does not mangle, so these never meet at the "
268+
"link:\n whichever one is in the artifact reads its arguments by its "
269+
"own signature.\n",
270+
name, origin[seen], entries[seen], src, flat);
271+
return std::nullopt;
230272
}
231273
}
232274
return entries;

0 commit comments

Comments
 (0)