diff --git a/llama/src/test/java/net/ladenthin/llama/loader/CpuModuleIsolationTest.java b/llama/src/test/java/net/ladenthin/llama/loader/CpuModuleIsolationTest.java index 3c3617ea5..e45c362cb 100644 --- a/llama/src/test/java/net/ladenthin/llama/loader/CpuModuleIsolationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/loader/CpuModuleIsolationTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.io.BufferedReader; import java.io.IOException; import java.net.URI; import java.net.URL; @@ -49,9 +50,15 @@ * its runtime, because a module that cannot even be loaded is a different and cheaper failure to * find, and because its per-module table records which levels a given CPU accepts at all. * - *

So each module gets a directory of its own holding the library, {@code ggml}, {@code ggml-base}, - * {@code ggml-rpc} and that single module, and a JVM of its own -- which also exercises the - * one-module layout a platform without a variant list ships (Windows arm64). A module whose + *

So each module gets a directory of its own holding the library, everything the real directory's + * {@code jllama-extras.txt} and {@code jllama-files.txt} name except the other CPU modules (so + * {@code ggml}, {@code ggml-base}, {@code ggml-rpc}, and since the shared upstream libraries also + * {@code llama} and {@code mtmd}, which {@code jllama} imports), and that single module -- with the + * two lists rewritten for exactly that content -- and a JVM of its own, which also exercises the + * one-module layout a platform without a variant list ships (Windows arm64). The set is read from + * the lists rather than written down here on purpose: a fixed list went stale the moment + * {@code llama.dll} and {@code mtmd.dll} became files of their own, and every Windows probe then + * died with "Can't find dependent libraries" (run 38814976429). A module whose * instruction set the CPU lacks must decline, i.e. register no CPU backend, and must not * crash: loading a DLL executes none of its kernels, ggml's score function only reads CPUID. * @@ -73,18 +80,43 @@ public class CpuModuleIsolationTest { /** Separator of the manifest {@code Class-Path} entries. */ private static final java.util.regex.Pattern WHITESPACE = java.util.regex.Pattern.compile("[ \\t\\r\\n]+"); - /** Files a module directory needs besides the module itself; absent ones are skipped. */ - private static final List SUPPORT = List.of( - System.mapLibraryName("jllama"), - libraryName("ggml"), - libraryName("ggml-base"), - libraryName("ggml-rpc"), - "jllama-build.txt"); + /** Files a module directory needs besides the module itself and the lists; absent ones are skipped. */ + private static final List SUPPORT = List.of(System.mapLibraryName("jllama"), "jllama-build.txt"); + + /** The stem every CPU module name carries. */ + private static final String MODULE_STEM = "ggml-cpu"; + + /** The one-module file name of a platform without a variant list ({@code ggml-cpu.dll}). */ + private static final String SINGLE_MODULE = System.mapLibraryName(MODULE_STEM); + + /** Where the stem ends inside {@link #SINGLE_MODULE} ({@code lib} precedes it on ELF and Mach-O). */ + private static final int STEM_END = SINGLE_MODULE.indexOf(MODULE_STEM) + MODULE_STEM.length(); + + /** The platform's prefix of a levelled module name ({@code libggml-cpu-} on ELF, {@code ggml-cpu-} on Windows). */ + private static final String MODULE_FILE_PREFIX = SINGLE_MODULE.substring(0, STEM_END) + "-"; + + /** The platform's library suffix ({@code .so}, {@code .dll}, {@code .dylib}). */ + private static final String MODULE_FILE_SUFFIX = SINGLE_MODULE.substring(STEM_END); private static String libraryName(String stem) { return System.mapLibraryName(stem); } + /** + * Whether a file of the natives directory is a CPU module. + * + *

Platform-aware on purpose: the first version matched {@code ggml-cpu-} literally, which is + * the Windows spelling only -- on Linux and macOS the files are {@code libggml-cpu-.so} + * / {@code .dylib}, so the test found no modules there and skipped itself. + * + * @param fileName a file name of the natives directory + * @return true for {@code ggml-cpu-} and for the single {@code ggml-cpu} module + */ + static boolean isCpuModule(String fileName) { + return fileName.equals(SINGLE_MODULE) + || (fileName.startsWith(MODULE_FILE_PREFIX) && fileName.endsWith(MODULE_FILE_SUFFIX)); + } + @Test void everyCpuModuleLoadsOnItsOwn(@TempDir Path tempDir) throws Exception { assumeTrue(NativeLibraryPresence.onClasspath(), "no jllama library on the classpath"); @@ -185,47 +217,68 @@ private static Path backendDirectoryOnDisk() { private static List modulesIn(Path backendDir) throws IOException { try (Stream files = Files.list(backendDir)) { - return files.filter(p -> p.getFileName().toString().startsWith(MODULE_PREFIX)) + return files.filter(p -> isCpuModule(p.getFileName().toString())) .sorted() .collect(Collectors.toList()); } } - /** A natives root holding only {@code module} beside the library, with matching file lists. */ + /** + * A natives root holding only {@code module} beside the library, with matching file lists. + * + *

The support files are whatever the real directory's two lists name, minus the other CPU + * modules: {@code jllama-extras.txt} is copied as it is (the files {@code LlamaLoader} loads by + * full path before the library, in dependency order -- on Windows {@code ggml-base}, {@code ggml}, + * {@code llama}, {@code mtmd}), and {@code jllama-files.txt} is rewritten to its non-module + * entries plus this one module. A listed file that is not there is skipped, exactly as the + * loader reports it rather than fails on it. + */ private static Path stageSingleModule(Path tempDir, Path backendDir, Path module) throws IOException { String moduleName = module.getFileName().toString(); Path root = tempDir.resolve(moduleName.replace('.', '_')); Path target = root.resolve( "net/ladenthin/llama/" + OSInfo.getNativeLibFolderPathForCurrentOS() + "/" + backendDir.getFileName()); Files.createDirectories(target); - for (String name : SUPPORT) { - Path source = backendDir.resolve(name); - if (Files.exists(source)) { - Files.copy(source, target.resolve(name)); - } - } - Files.copy(module, target.resolve(moduleName)); + List preloaded = listedFiles(backendDir, LlamaLoader.BACKEND_EXTRAS_FILE); List extracted = new ArrayList<>(); extracted.add("# CpuModuleIsolationTest: this module only"); - extracted.add(moduleName); - if (Files.exists(target.resolve(libraryName("ggml-rpc")))) { - extracted.add(libraryName("ggml-rpc")); + for (String name : listedFiles(backendDir, LlamaLoader.BACKEND_FILES_FILE)) { + if (!isCpuModule(name)) { + extracted.add(name); + } } - Files.write(target.resolve("jllama-files.txt"), extracted, StandardCharsets.UTF_8); + extracted.add(moduleName); - List preloaded = new ArrayList<>(); - for (String stem : List.of("ggml-base", "ggml")) { - if (Files.exists(target.resolve(libraryName(stem)))) { - preloaded.add(libraryName(stem)); + List support = new ArrayList<>(SUPPORT); + support.addAll(preloaded); + support.addAll(extracted.subList(1, extracted.size() - 1)); + for (String name : support) { + Path source = backendDir.resolve(name); + if (Files.exists(source) && !Files.exists(target.resolve(name))) { + Files.copy(source, target.resolve(name)); } } + Files.copy(module, target.resolve(moduleName)); + + Files.write(target.resolve(LlamaLoader.BACKEND_FILES_FILE), extracted, StandardCharsets.UTF_8); if (!preloaded.isEmpty()) { - Files.write(target.resolve("jllama-extras.txt"), preloaded, StandardCharsets.UTF_8); + Files.write(target.resolve(LlamaLoader.BACKEND_EXTRAS_FILE), preloaded, StandardCharsets.UTF_8); } return root; } + /** The names a list file of the real directory carries, in order; empty when there is no such list. */ + private static List listedFiles(Path backendDir, String listFile) throws IOException { + Path file = backendDir.resolve(listFile); + if (!Files.isRegularFile(file)) { + return List.of(); + } + try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + return LlamaLoader.parseExtras(reader); + } + } + /** Forks a JVM whose classpath carries {@code root} instead of the real natives directory. */ private static Result runChild(Path root) throws IOException, InterruptedException { String separator = System.getProperty("path.separator"); diff --git a/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java b/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java index 540cf550d..978771edf 100644 --- a/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java +++ b/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java @@ -850,8 +850,9 @@ public void loadFromDirectoryReportsListedFilesThatAreNotThereAndAnIgnoredBacken String lib = System.mapLibraryName("jllama"); String module = System.mapLibraryName("ggml-cpu-x64"); Path dir = nativesDirectory(tempDir, null, module + "\n", "llama.cpp=b11639\nbackend=cpu\n", lib); - String previous = System.getProperty(LlamaSystemProperties.PREFIX + ".backend"); - System.setProperty(LlamaSystemProperties.PREFIX + ".backend", "vulkan"); + String backendProp = LlamaSystemProperties.PREFIX + ".backend"; + String previous = System.getProperty(backendProp); + System.setProperty(backendProp, "vulkan"); try { String err = captureStderr(() -> assertTrue(LlamaLoader.loadFromDirectory(dir, "lib.path", path -> true))); assertTrue(err.contains("names 1 file(s) that are not there"), err); @@ -859,9 +860,9 @@ public void loadFromDirectoryReportsListedFilesThatAreNotThereAndAnIgnoredBacken assertTrue(err.contains(".backend=vulkan has no effect on a natives directory"), err); } finally { if (previous == null) { - System.clearProperty(LlamaSystemProperties.PREFIX + ".backend"); + System.clearProperty(backendProp); } else { - System.setProperty(LlamaSystemProperties.PREFIX + ".backend", previous); + System.setProperty(backendProp, previous); } } }