Canonicalize the path returned by current_binary_dir() - #3958
Open
GoodOlClint wants to merge 1 commit into
Open
Conversation
current_binary_dir() returns path(dli_fname).parent_path() unresolved, and the
two JIT include-path lookups then take parent_path() of that value again:
include_path_args() in cuda/jit_module.cpp and get_preamble() in
cpu/jit_compiler.cpp both compute root_dir = current_binary_dir().parent_path()
and search root_dir/"include". parent_path() is lexical -- it strips the last
component of the spelling, not of the directory -- so two spellings of the same
directory produce different include roots:
dli_fname current_binary_dir() root_dir include root
/p/lib/libmlx.so /p/lib /p /p/include
/p/lib/./libmlx.so /p/lib/. /p/lib /p/lib/include
./tests . "" resolved against CWD
On Linux glibc reports dli_fname for a main executable as argv[0] verbatim, so
which row applies depends on how the process was started. A single-component
path has an empty parent, which drops the include root onto the current working
directory.
This is what made a CUDA build fail to JIT when run from its build tree:
mlx/backend/cuda/device/indexing.cuh(3): catastrophic error:
cannot open source file "cuda/std/tuple"
with the CCCL headers present and readable at the path the canonicalized lookup
computes. Holding the invocation fixed and varying only this commit on a GB10
(sm_121, CUDA 13.0.88): the base FAILs with the error above, the base plus this
commit PASSes.
weakly_canonical() resolves ".", ".." and symlinks and roots a CWD-relative
path. Two guards around it:
- A dli_fname with no directory component (glibc gives a bare argv[0] for a
PATH-launched executable) carries no location to resolve, so it returns the
empty parent unchanged. Without this, libc++ and libstdc++ disagree:
libc++ resolves a bare name against the current working directory and
invents an unrelated absolute answer, where libstdc++ leaves it alone.
- The error_code overload, because the throwing one is reachable. The value
is computed lazily in a function-local static, so a relative dli_fname
whose CWD has since been removed makes weakly_canonical throw where the
old lexical code could not fail. That matters most in delayload.cpp, which
runs inside a Windows delay-load hook where an escaping exception would
terminate rather than fail the load.
Resolving symlinks is a deliberate part of this: for an install reached through
a symlinked lib directory, the sibling include/ tree lives next to the real
path, not next to the link.
The declaration gains MLX_API. The library is built with hidden visibility, so
without it the new test does not link in a shared build.
This is a no-op on macOS, where dyld reports a fully resolved dli_fname in every
one of those cases.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposed changes
Re-submitting #3945 with the demonstration that was missing. Thanks for the quick look — the table in the original PR was the wrong evidence, and your reading of it was right: those three rows all name the same directory, so on its own that table shows nothing but a cosmetic difference.
The problem is not the value
current_binary_dir()returns. It is that the two JIT include-path lookups immediately take.parent_path()of it, andparent_path()is a lexical operation — it strips the last component of the spelling, not of the directory. Two spellings of the same directory therefore have different parents.mlx/backend/cuda/jit_module.cpp(include_path_args()) andmlx/backend/cpu/jit_compiler.cpp(get_preamble()) both do:So:
dli_fnamecurrent_binary_dir()root_dir/p/lib/libmlx.so/p/lib/p/p/include/p/lib/./libmlx.so/p/lib/./p/lib/p/lib/include./tests.""include, resolved against the CWDtests(found onPATH)""""include, resolved against the CWDRow 2 is the direct answer to your question:
/p/lib/.and/p/libare the same directory — that is exactly why the defect is invisible until something takes the parent. The trailing.absorbs the "go up one level", and the include root silently moves into the wrong tree. Nothing relative is involved and both paths are absolute.Rows 3 and 4 are worse:
parent_path()of a single-component path is empty, so the include root degrades to a bare relativeinclude, resolved against whatever the process's working directory happens to be rather than against the binary. The result is also cached in a function-localstaticand consumed lazily, long after startup, so a laterchdir()moves it again.The other two callers don't take the parent, but still consume the raw value:
cuda/delayload.cppdoesfs::absolute(current_binary_dir() / relative)(a relative value roots that at the CWD) andmetal/device.cppappends the metallib name to it.The failure this actually caused
CUDA build on an aarch64 GB10 / sm_121 box, configured
-DMLX_BUILD_CUDA=ON -DMLX_BUILD_TESTS=OFF, run from the build tree. NVRTC:straceshowed NVRTC never received a CCCL--include-pathat all — it only ever probed the toolkit copy:Both branches of the CCCL lookup missed. The
MLX_CCCL_DIRfallback was compiled out because that define sits insideif(MLX_BUILD_TESTS)— that is the separate one-liner in #3944, and with it applied this becomes a silent fallback rather than a hard error. The primary branch missed because of this bug.Isolating it by invocation at the time, with a fresh
MLX_PTX_CACHE_DIRper run (the PTX cache masks the effect and produced one false PASS before we controlled for it):./example1frombuild//tmpbuild/include/ccclbuild/../include/ccclThe two middle rows are exactly complementary. The same binary looks in two different directories depending on how it was invoked, and each invocation fails when only the other one's directory exists — the search path is computed from
argv[0]as typed, not from where the binary lives.And the patch closes it
A second, tighter experiment on a fresh GB10 while preparing this re-submit. The one above varies the invocation; this one holds the invocation fixed and varies only the patch —
beforeismainatfb5133e1,afteris that same commit with this PR applied. Identical install layout (prefix/bin/mlxprobe, CCCL staged only atprefix/include/cccl,prefix/bin/includedeliberately absent), identical relative invocation fromprefix/bin, fresh PTX cache each. The probe is built as MLX's own example target so CMake owns the CUDA link line.-DMLX_BUILD_CUDA=ON -DMLX_BUILD_TESTS=OFF, CUDA 13.0.88, sm_121, aarch64.The headers are present and readable in both runs, at the path the canonicalized lookup computes. Unpatched, MLX doesn't look there.
Reproducing it without a GPU
No CUDA device is needed to watch the include root move. Against a plain CPU-only build of this tree (Ubuntu 24.04, aarch64, gcc 13), with a ten-line program that calls
mlx::core::current_binary_dir()and then computes the sameroot_dir / "include" / "cccl"the two JIT lookups compute.Layout is a normal install tree —
prefix/bin/mlxprobeandprefix/include/cccl. The correct answer in every run is/out/prefix/include/cccl, and it exists the whole time. One binary, one directory, four invocations:Three of the four look for the headers outside the install tree; run 4 leaves it altogether and lands in
/tmp.With the patch, runs 1–3 all report
/out/prefix/include/cccl,exists() = YES.Run 4 is not fixed, deliberately. When a statically-linked executable is found via
PATH, glibc reportsdli_fnameas the bareargv[0](mlxprobe, no separator) — the real location simply isn't in the value, so there is nothing to resolve. Closing it needs/proc/self/exe, which is Linux-specific and a bigger change than belongs here; happy to add it if you'd prefer. It also doesn't arise for the ways MLX ships as a library: when MLX is a shared object or a Python extension,dli_fnameis the loader's path and always contains a separator.That case does need an explicit guard rather than being left to
weakly_canonical, because the two standard libraries disagree about it:""current_path()"""bare-name"(relative, missing)$CWD/bare-name"bare-name"Left ungoverned, libc++ would resolve a bare
argv[0]against the current working directory and hand back a confidently wrong absolute path — worse than the empty result it replaces, becauseexists()no longer fails cleanly. So the patch checks the input for a directory component and returns the empty parent unchanged when there is none. Measured on macOS/libc++ and in agcc:13container.The fix
std::filesystem::weakly_canonicalondli_fnamebefore taking the parent. It normalizes away.and.., roots a CWD-relative path, and resolves symlinks, so the value stops depending on how the process was started. It is computed once inside the existingstaticinitializer, so the addedstattraffic happens at most once per process.Two guards around it, both for cases I could reproduce:
error_codeoverload rather than the throwing one. The throwing overload is reachable: the value is computed lazily in a function-localstatic, so a relativedli_fnamewhose working directory has since been removed makesweakly_canonicalthrow where the old purely-lexical code could not fail. Reproduced on both libc++ and libstdc++. It matters most incuda/delayload.cpp, which runs inside a Windows delay-load hook, where an escaping C++ exception terminates rather than failing the load cleanly. On error the patch keeps the old lexical answer.Symlink resolution is intentional, and is a behavior change worth calling out. For an install reached through a symlinked
libdirectory, the siblinginclude/tree sits next to the real path, not next to the link — so resolving is what makes the lookup find it. If you'd rather keep the old symlink semantics,absolute(...).lexically_normal()fixes the reported bug without that part; I went withweakly_canonicaldeliberately but it is an easy swap.The declaration also gains
MLX_API. The library builds with hidden visibility (mlx/CMakeLists.txt:32-34), so without it the new test fails to link in a shared build — which is what the macOS CI job's-DBUILD_SHARED_LIBS=ONreconfigure does.This is a no-op on macOS, which is why the defect is Linux-only. dyld reports a fully resolved
dli_fnamein all four invocations above — including the bare-PATHone that glibc cannot recover — so on macOSweakly_canonicalis handed an already-canonical path every time. Re-verified on arm64 macOS 15 while preparing this re-submit.Verification
tests/utils_tests.cppgains a case asserting the result is absolute and already in normal form. Both checks are purely lexical — no filesystem access, and it does not re-apply the function under test. It returns early on an empty result, since that is the legal outcome for the bare-argv[0]case above.Applying only that test file to the base, so it runs against unpatched code:
With the patch, absolute and relative invocations both pass, and a bare-
PATHinvocation runs zero assertions via the early return.Being explicit about the limitation: the test only fails on a non-canonical invocation, so CI, which invokes the binary by absolute path, is green either way. It guards against reintroduction rather than catching this in CI. A test that failed regardless of invocation would have to re-exec itself, which seemed worse than the guard is worth — happy to be told otherwise.
Full C++ suite on Linux (aarch64, Ubuntu 24.04, gcc 13, CPU-only), this commit against its base:
Also built with
-DBUILD_SHARED_LIBS=ON -DMLX_BUILD_TESTS=ONto confirm theteststarget links against a hidden-visibilitylibmlx.so— it does not without theMLX_APIdecoration.I did not re-run the Metal suite for this re-submit; the change is a no-op on macOS for the reason given above.
Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changes