Skip to content

Commit e17fc88

Browse files
compat.sycl-runtime: a SYCL artifact can reach its runtime (#354)
* feat(pkg): compat.sycl-runtime, so a SYCL artifact can reach its runtime An mcpp binary on Linux runs under mcpp's own loader, so a soname that is not on its runtime search path is not found at all. A program whose device half was compiled by the `xim:dpcpp` payload links `-lsycl`, which the rule package satisfies at link time from the payload's own directory, and then fails mcpp's runtime closure check: libsycl.so.9 not found on the search path this artifact will actually use. Its PT_INTERP is a private loader, so the host's /usr/lib is NOT consulted This is the same shape compat.cuda-driver and compat.vulkan-runtime have, for the same class of library: one a program reaches through the loader rather than through a header. It declares the payload as an install-time edge, reads the directory back, and links the versioned sonames into a package-owned farm that `runtime.library_dirs` puts on the artifact's search path. Three things it does that the CUDA adapter does not, each measured rather than assumed: EVERY VERSIONED SONAME, NOT JUST libsycl. SYCL's device support is a chain of dlopens -- `libsycl.so.9` loads `libur_loader.so.0`, which loads one `libur_adapter_*.so.0` per back end, and those need `libumf.so.1`. The loader finds an adapter beside ITSELF, and `$ORIGIN` is the symlink's directory rather than the target's, so a farm holding `libsycl.so.9` alone loads and then enumerates nothing -- the failure that reads as "this machine has no GPU". THE C++ RUNTIME IS FARMED, WHICH compat.cudart DELIBERATELY DOES NOT DO. Its comment says libstdc++ is already loaded on the executable's own behalf by the time the component asks, and for a CUDA program that is true. It is not true here: `libsycl.so.9` needs libstdc++ itself and an mcpp artifact links libc++, so unless the consumer named libstdc++ on its own link line nothing has loaded it and the farm's RUNPATH is the only place the loader looks. Measured, before the fix: `FAIL libsycl.so.9: libstdc++.so.6: cannot open shared object file`. Farming it is safe for the reason farming libc would not be -- there is exactly one libstdc++ in this ecosystem and a consumer that also links it resolves the same file under the same soname. THE STUB SEARCH STARTS FROM THE PAYLOAD, NOT FROM THE ENVIRONMENT. This package installs into a PROJECT-LOCAL store while its payload resolves from the shared registry, and $HOME may be remapped, so `install_dir`'s grandparent holds this package alone. With the environment-derived roots alone the search found nothing and the program died on `libdl.so.2` with a farm that otherwise looked complete. The payload's own directory cannot be wrong: glibc is its sibling by construction. Verified by a new workspace member, `tests/examples/sycl-runtime`, which asserts what this package is responsible for and not what the machine answers: the runtime loads by soname and carries `__sycl_register_lib`, and so do `libur_loader.so.0` and `libumf.so.1` -- the leaf that separates "the farm has libsycl" from "the farm has the chain". Which devices exist is the machine's answer and is not asserted. * fix(compat.sycl-runtime): the loader needs libz, and a developer machine had it CI reported what this machine could not: `libur_loader.so.0` needs `libz.so.1`, the payload does not carry it, and the farm's RUNPATH is the only place the loader looks. On a machine with zlib installed for unrelated reasons the omission was invisible; on a fresh runner every library in the chain failed on it. `xim:zlib` is declared and farmed, and the stub search now tries `lib/` as well as `lib64/` because zlib ships under the former. --------- Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
1 parent 7394c1c commit e17fc88

4 files changed

Lines changed: 346 additions & 0 deletions

File tree

‎mcpp.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ members = [
130130
"tests/examples/tinyhttps",
131131
"tests/examples/usockets",
132132
"tests/examples/opencl",
133+
"tests/examples/sycl-runtime",
133134
"tests/examples/vulkan",
134135
"tests/examples/websocket",
135136
"tests/examples/uwebsockets",

‎pkgs/c/compat.sycl-runtime.lua‎

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
-- compat.sycl-runtime -- put the SYCL runtime on an mcpp binary's runtime
2+
-- search path.
3+
--
4+
-- WHAT IT FIXES. An mcpp-built program runs under mcpp's OWN loader, so a
5+
-- soname that is not in its RPATH is not found at all: the host's /usr/lib is
6+
-- never consulted. A program whose device half was compiled by the dpcpp
7+
-- payload links `-lsycl`, which the rule package satisfies at LINK time from
8+
-- the payload's own directory, and then fails the runtime closure check:
9+
--
10+
-- libsycl.so.9 not found on the search path this artifact will actually use.
11+
-- Its PT_INTERP is a private loader, so the host's /usr/lib is NOT consulted
12+
--
13+
-- `runtime.library_dirs` below puts a package-owned directory on that path --
14+
-- the same mechanism compat.cuda-runtime and compat.vulkan-runtime use for the
15+
-- same reason, and for the same class of library: one a program reaches
16+
-- through the loader rather than through a header.
17+
--
18+
-- WHY EVERY VERSIONED SONAME AND NOT JUST libsycl.
19+
--
20+
-- SYCL's device support is a chain of dlopens: `libsycl.so.9` loads
21+
-- `libur_loader.so.0`, which loads one `libur_adapter_*.so.0` per back end,
22+
-- and those need `libumf.so.1`. The loader finds an adapter by looking beside
23+
-- ITSELF, and `$ORIGIN` resolves to the directory of the symlink rather than
24+
-- of its target -- so a farm holding `libsycl.so.9` alone would load, then
25+
-- enumerate no devices, which is the failure that looks like "this machine has
26+
-- no GPU". Every versioned soname in the payload is linked, so the whole chain
27+
-- resolves inside one directory.
28+
--
29+
-- ONLY VERSIONED SONAMES. mcpp puts `runtime.library_dirs` on the LINK line as
30+
-- well as the runtime path, so an unversioned `libsycl.so` here would be found
31+
-- by `-lsycl` and would bind the build to this farm instead of to the payload
32+
-- the project declared. A versioned soname is invisible to the linker and is
33+
-- exactly what dlopen asks for -- the reasoning compat.cuda-runtime records
34+
-- for `libcuda.so.1`.
35+
--
36+
-- THE PROBE IS NOT REPEATED HERE. Where the SYCL runtime is, is a question the
37+
-- `xim:dpcpp` payload already answers by existing; this package declares the
38+
-- edge and reads the directory back. `xpm.<platform>.deps` rather than the
39+
-- package's own `[xlings]`, because mcpp materialises `[xlings] deps` for the
40+
-- ROOT project only and this must resolve when the package itself installs.
41+
--
42+
-- NOTHING IS REQUIRED. A machine with no GPU is a legitimate configuration --
43+
-- every runner in this repository is one. The SYCL runtime still loads, still
44+
-- enumerates whatever it finds, and a program that needs a device says so
45+
-- itself.
46+
package = {
47+
spec = "1",
48+
namespace = "compat",
49+
name = "sycl-runtime",
50+
description = "SYCL runtime adapter for mcpp Linux applications (Intel DPC++ payload)",
51+
licenses = {"Apache-2.0"}, -- the recipe; the payload is Apache-2.0 WITH LLVM-exception
52+
repo = "https://github.com/mcpp-community/mcpp-index",
53+
type = "package",
54+
55+
xpm = {
56+
linux = {
57+
-- The install-time edge. Materialised when THIS package installs,
58+
-- which is what makes the payload's directory exist by the time
59+
-- install() below reads it.
60+
-- `xim:zlib` is not decoration. `libur_loader.so.0` needs
61+
-- `libz.so.1`, which the payload does not carry, and the farm's
62+
-- RUNPATH is the only place the loader looks for it. On a
63+
-- developer machine that had zlib installed for other reasons the
64+
-- omission was invisible; a fresh runner reported
65+
-- `FAIL libsycl.so.9: libz.so.1: cannot open shared object file`.
66+
deps = { "xim:dpcpp@7.1.0", "xim:zlib" },
67+
["2026.09.06"] = {
68+
-- Nothing downloaded matters: the content is the set of
69+
-- symlinks install() creates. A stable, tiny anchor keeps the
70+
-- xpm entry well-formed, the same trick compat.cuda-runtime
71+
-- and compat.vulkan-runtime use.
72+
url = "https://raw.githubusercontent.com/intel/llvm/v7.1.0/sycl/LICENSE.TXT",
73+
sha256 = "410f3a23b4bbacbd246310d8c014a20af18cfc8c0d740ddf0f673ea20894da9c",
74+
},
75+
["latest"] = { ref = "2026.09.06" },
76+
},
77+
},
78+
79+
mcpp = {
80+
language = "c++23",
81+
import_std = false,
82+
c_standard = "c11",
83+
sources = { "mcpp_generated/sycl_runtime_empty.c" },
84+
targets = { ["sycl_runtime"] = { kind = "lib" } },
85+
deps = {},
86+
runtime = {
87+
library_dirs = { "mcpp_generated/sycl_runtime/lib" },
88+
capabilities = { "sycl.runtime" },
89+
provides = { "sycl.runtime" },
90+
},
91+
},
92+
}
93+
94+
import("xim.libxpkg.pkginfo")
95+
import("xim.libxpkg.log")
96+
97+
-- The payload's install directory.
98+
--
99+
-- `pkginfo.install_dir` scans only the member-local xpkgs roots; a dependency
100+
-- installed into the shared registry cache is invisible to it and comes back
101+
-- nil, so the known roots are tried before giving up. The same fallback
102+
-- compat.cuda-runtime needs, for the same reason.
103+
local function payload_dir()
104+
local dir = pkginfo.install_dir("xim:dpcpp", "7.1.0")
105+
if dir then return dir end
106+
local roots = {}
107+
local pfx = pkginfo.install_dir()
108+
if pfx then roots[#roots + 1] = path.directory(path.directory(pfx)) end
109+
local home = (os.getenv and os.getenv("XLINGS_HOME")) or ""
110+
if home == "" then home = ((os.getenv and os.getenv("HOME")) or "") .. "/.xlings" end
111+
roots[#roots + 1] = path.join(home, "data/xpkgs")
112+
for _, root in ipairs(roots) do
113+
for _, ns in ipairs({ "xim-x-dpcpp", "local-x-dpcpp" }) do
114+
local cand = path.join(root, ns, "7.1.0")
115+
if os.isdir(cand) then return cand end
116+
end
117+
end
118+
return nil
119+
end
120+
121+
-- THE C LIBRARY'S COMPATIBILITY STUBS HAVE TO BE FARMED TOO.
122+
--
123+
-- The payload's shared objects carry `RUNPATH = $ORIGIN`, and a non-empty
124+
-- RUNPATH on an intermediate library TURNS OFF the executable's inherited
125+
-- DT_RPATH for that library's own dependencies (mcpp#460). So the artifact's
126+
-- search path, which reaches mcpp's glibc payload, is not consulted when the
127+
-- loader looks for what `libsycl.so.9` needs, and the program dies before
128+
-- `main`:
129+
--
130+
-- error while loading shared libraries: libdl.so.2: cannot open shared
131+
-- object file: No such file or directory
132+
--
133+
-- `$ORIGIN` is the SYMLINK's directory, not the target's, so linking the stubs
134+
-- into this farm beside the payload's own symlinks is what makes a farm of
135+
-- links sufficient and a copy of the payload unnecessary.
136+
--
137+
-- Only the libraries glibc 2.34 MERGED INTO libc are farmed. They are empty
138+
-- compatibility stubs, so which payload version answers does not matter, and
139+
-- nothing else is loading them: `libc.so.6`, `libm.so.6`, `libgcc_s.so.1` and
140+
-- `libstdc++.so.6` are already loaded on the executable's own behalf by the
141+
-- time `libsycl.so.9` asks, and are found by soname without a search. Farming
142+
-- those would risk a second C library in one address space, which is the one
143+
-- failure worse than this one.
144+
--
145+
-- This is the third copy of this helper in this index (compat.cudart and
146+
-- compat.curand carry the other two) and it is duplicated rather than shared
147+
-- for the reason those record: a recipe is loaded in a sandbox that has no
148+
-- module of this index's own to import.
149+
local function farm_libc_stubs(dst, payload)
150+
-- The three glibc 2.34 merged stubs, AND the two GCC runtime libraries.
151+
--
152+
-- compat.cudart farms the stubs alone and says why the C++ runtime is not
153+
-- among them: for a CUDA program `libstdc++.so.6` is already loaded on the
154+
-- executable's own behalf by the time the component asks. That reasoning
155+
-- does not carry here. `libsycl.so.9` needs libstdc++ itself, and an mcpp
156+
-- artifact links libc++ -- so unless the consumer happens to have named
157+
-- libstdc++ on its own link line, nothing has loaded it and the farm's
158+
-- RUNPATH is the only place the loader looks:
159+
--
160+
-- FAIL libsycl.so.9: libstdc++.so.6: cannot open shared object file
161+
--
162+
-- Farming it is safe for the reason farming libc would not be: there is
163+
-- exactly one libstdc++ in this ecosystem, `xim:gcc-runtime`'s, so a
164+
-- consumer that also links it resolves the same file under the same soname
165+
-- and it is loaded once.
166+
local roots = {}
167+
-- FIRST, THE STORE THE PAYLOAD ITSELF CAME FROM. The other roots below are
168+
-- derived from the environment, and this package installs into a
169+
-- PROJECT-LOCAL store while its payload is resolved from the shared
170+
-- registry -- so `install_dir`'s grandparent holds this package alone and
171+
-- $HOME may be remapped. Measured: with the environment-derived roots
172+
-- alone the stub search found nothing, and the program died on
173+
-- `libdl.so.2` with a farm that otherwise looked complete. The payload's
174+
-- own directory cannot be wrong: glibc is its sibling by construction.
175+
if payload then roots[#roots + 1] = path.directory(path.directory(payload)) end
176+
local pfx = pkginfo.install_dir()
177+
if pfx then roots[#roots + 1] = path.directory(path.directory(pfx)) end
178+
local home = (os.getenv and os.getenv("XLINGS_HOME")) or ""
179+
if home == "" then home = ((os.getenv and os.getenv("HOME")) or "") .. "/.xlings" end
180+
roots[#roots + 1] = path.join(home, "data/xpkgs")
181+
roots[#roots + 1] = path.join((os.getenv and os.getenv("HOME")) or "",
182+
".mcpp/registry/data/xpkgs")
183+
local wanted = {
184+
{ pkg = "xim-x-glibc", names = { "librt.so.1", "libpthread.so.0", "libdl.so.2" } },
185+
{ pkg = "xim-x-gcc-runtime", names = { "libstdc++.so.6", "libgcc_s.so.1" } },
186+
-- zlib ships under lib/ rather than lib64/, so the search below tries
187+
-- both rather than assuming one layout per package.
188+
{ pkg = "xim-x-zlib", names = { "libz.so.1" } },
189+
}
190+
local found = 0
191+
for _, group in ipairs(wanted) do
192+
for _, name in ipairs(group.names) do
193+
if not os.isfile(path.join(dst, name)) then
194+
for _, root in ipairs(roots) do
195+
local f = io.popen(string.format(
196+
[[ls -1d "%s"/%s/*/lib64/%s "%s"/%s/*/lib/%s 2>/dev/null ]]
197+
.. [[| sort -V | tail -1]],
198+
root, group.pkg, name, root, group.pkg, name))
199+
local hit = f and (f:read("l") or "") or ""
200+
if f then f:close() end
201+
if hit ~= "" then
202+
os.exec(string.format([[ln -sfn "%s" "%s"]], hit, path.join(dst, name)))
203+
found = found + 1
204+
break
205+
end
206+
end
207+
end
208+
end
209+
end
210+
return found
211+
end
212+
213+
function install()
214+
os.tryrm(pkginfo.install_dir())
215+
os.mkdir(pkginfo.install_dir())
216+
217+
local generated = path.join(pkginfo.install_dir(), "mcpp_generated")
218+
os.mkdir(generated)
219+
io.writefile(path.join(generated, "sycl_runtime_empty.c"),
220+
"int mcpp_compat_sycl_runtime_anchor(void) { return 0; }\n")
221+
222+
local outdir = path.join(generated, "sycl_runtime", "lib")
223+
os.mkdir(outdir)
224+
225+
local src = payload_dir()
226+
if not src then
227+
-- Reported, not fatal. The farm is empty, the link still succeeds
228+
-- because the rule package puts the payload's own directory on the
229+
-- link line, and the runtime closure check reports the missing soname
230+
-- by name -- which is a better message than this package could write.
231+
log.warn("compat.sycl-runtime: the xim:dpcpp payload was not found; "
232+
.. "the runtime library directory will be empty")
233+
return true
234+
end
235+
236+
-- `io.popen` rather than `os.files`: the latter is not available in the
237+
-- recipe sandbox (`attempt to call a nil value`), which the llvm and
238+
-- cuda-cccl recipes record the same way.
239+
local n = 0
240+
local p = io.popen("ls -1 " .. path.join(src, "lib") .. " 2>/dev/null")
241+
if p then
242+
for line in p:lines() do
243+
-- `libfoo.so.N` and `libfoo.so.N.M.P`, never a bare `libfoo.so`:
244+
-- see the header for why an unversioned name here would reach the
245+
-- linker.
246+
-- `libfoo.so.N`, `libfoo.so.N.M.P` and upstream's `.so.9.0.0-0`,
247+
-- but not the `-gdb.py` sidecars that sit beside them and match a
248+
-- looser test for a versioned soname.
249+
if line:match("%.so%.%d") and not line:match("%.py$") then
250+
os.exec("ln -sf " .. path.join(src, "lib", line) .. " "
251+
.. path.join(outdir, line))
252+
n = n + 1
253+
end
254+
end
255+
p:close()
256+
end
257+
if n == 0 then
258+
log.warn("compat.sycl-runtime: the payload at %s has no versioned "
259+
.. "library in lib/; the farm is empty", src)
260+
return true
261+
end
262+
263+
local stubs = farm_libc_stubs(outdir, src)
264+
log.info("compat.sycl-runtime: %d versioned sonames from %s, %d C-library stubs",
265+
n, src, stubs)
266+
return true
267+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# compat.sycl-runtime is a Linux-only adapter, and for the same reason
2+
# compat.cuda-driver is: an mcpp binary on Linux runs under mcpp's own loader,
3+
# so a soname that is not on its runtime search path is not found at all. macOS
4+
# and Windows have neither that loader arrangement nor this payload, so the
5+
# dependency is conditioned rather than the member being excluded -- the member
6+
# still builds on all three, which is what keeps the test source from rotting.
7+
[package]
8+
name = "sycl-runtime-tests"
9+
version = "0.1.0"
10+
11+
[target.'cfg(linux)'.dependencies.compat]
12+
sycl-runtime = "2026.09.06"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// What compat.sycl-runtime is asserted to do.
2+
//
3+
// A machine with no GPU is a legitimate configuration and is what every runner
4+
// in this repository is, so the test cannot require a device. It asserts the
5+
// properties that hold on both kinds of machine.
6+
//
7+
// 1. The package resolves, builds and links. The failure this package exists
8+
// to prevent is a RUNTIME one -- `libsycl.so.9 not found on the search
9+
// path this artifact will actually use` -- so linking alone is not the
10+
// whole assertion, which is why (2) exists.
11+
//
12+
// 2. `libsycl.so.9` loads from inside mcpp's own loader. That is the entire
13+
// job of the farm, and it is a property of this package rather than of the
14+
// machine: the payload is installed by the dependency edge, so a runner
15+
// with no GPU must still get this far.
16+
//
17+
// 3. Its chain loads too. `libsycl.so.9` needs `libur_loader.so.0`, which
18+
// needs `libumf.so.1`, and both are in the payload. A farm holding
19+
// `libsycl.so.9` alone would satisfy (2) and then enumerate no devices --
20+
// the failure that looks like "this machine has no GPU". dlopen of the
21+
// leaf is what separates the two.
22+
//
23+
// Which devices exist is deliberately NOT asserted: that is the machine's
24+
// answer, not this package's.
25+
#include <cstdio>
26+
27+
#ifndef __linux__
28+
int main() {
29+
std::printf("not applicable on this platform\n");
30+
return 0;
31+
}
32+
#else
33+
#include <dlfcn.h>
34+
35+
namespace {
36+
37+
// Returns 0 when the library loads and carries the symbol, 1 otherwise. The
38+
// symbol matters: a farm that linked a stale or wrong-class file would resolve
39+
// the library and fail here, which is the difference between "found something"
40+
// and "found the runtime".
41+
int must_load(const char* soname, const char* symbol) {
42+
void* h = dlopen(soname, RTLD_LAZY);
43+
if (!h) {
44+
std::printf("FAIL %s: %s\n", soname, dlerror());
45+
return 1;
46+
}
47+
void* sym = symbol ? dlsym(h, symbol) : reinterpret_cast<void*>(h);
48+
std::printf("%s %s%s%s\n", sym ? "ok " : "FAIL", soname,
49+
symbol ? " symbol=" : "", symbol ? symbol : "");
50+
return sym ? 0 : 1;
51+
}
52+
53+
} // namespace
54+
55+
int main() {
56+
int bad = 0;
57+
// The SYCL runtime, by soname, and the C entry point every SYCL program
58+
// reaches it through.
59+
bad += must_load("libsycl.so.9", "__sycl_register_lib");
60+
// The chain. `libumf.so.1` is the leaf the adapters need, and the one a
61+
// farm of `libsycl.so.9` alone would leave unreachable.
62+
bad += must_load("libur_loader.so.0", nullptr);
63+
bad += must_load("libumf.so.1", nullptr);
64+
return bad ? 1 : 0;
65+
}
66+
#endif

0 commit comments

Comments
 (0)