Skip to content

Commit d7adf36

Browse files
committed
Ship natives as one jar per backend, declared once in natives.csv
net.ladenthin:llama is now the Java classes only. Every native build ships as its own jar of the same artifact, classifier <backend>-<os>-<arch>, holding exactly one directory net/ladenthin/llama/<OS>/<ARCH>/<backend>/. The directories never overlap, so any combination of natives jars shares one classpath: LlamaLoader tries the backends it finds in a fixed order (GPU backends, metal, msvc, cpu) through its ClassLoader and falls through to the CPU library when a GPU runtime is missing. A GPU jar next to the CPU jar now has a CPU fallback; before, each GPU classifier was a complete replacement jar with its own compile pass and none. - llama-platform: new pom-packaging module (nothing to upload besides the pom) naming the classes jar plus the CPU/Metal jars of every desktop. - .github/natives.csv is the single list of natives jars. The merge and fat-jar scripts read it; check-natives.py (code-style job) fails when the generated pom executions, llama-platform, the workflow artifact names or BACKEND_PRIORITY disagree with it. The 16 classifier profiles (17 compile passes) became one generated `natives` profile: pom 2278 -> ~1570 lines. - CMake writes to src/main/natives/<OS>/<ARCH>/<backend>/; build jobs upload natives-<classifier>, and the package/publish jobs fetch them with one glob instead of ~20 download steps each. The merge checks every listed artifact arrived holding only its own directory. - The all-backends fat jars are plain merges; the jllama-backends.txt manifest is gone. Sibling files a backend needs first are listed in a per-directory jllama-extras.txt. - Module path: each natives jar declares a unique Automatic-Module-Name. Without it every natives jar derives the module name `llama` and the JVM silently keeps only the first (measured); package-fatjars.sh checks the manifest of every built jar. module-info now requires Jackson and SLF4J, without which the classes jar never worked on the module path. - smoke-natives-jars.sh loads the classes jar with all 26 natives jars at once, on the classpath and on the module path, in the package job. - Three test classes checked for the library at a hard-coded path and would have skipped silently on the new layout; one shared NativeLibraryPresence helper replaces them. - The Atmosphere agent depends on llama-platform (opt out with -Dllama.natives=none, as CI does); the langchain4j and agent integration jobs point the loader at the downloaded library via lib.path. Verified locally with a real Linux x86-64 build plus placeholder libraries for the other 25 directories: 1823 tests green, all 26 natives jars and 4 all-backends jars built and checked, the fat jar and the natives jars load on classpath and module path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AytmJF9faEiQEVt6eetQS2
1 parent 8db4298 commit d7adf36

50 files changed

Lines changed: 1925 additions & 2731 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/check-natives.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
3+
#
4+
# SPDX-License-Identifier: MIT
5+
"""Fail when anything that names the natives jars disagrees with .github/natives.csv.
6+
7+
The list is the one place a natives jar is declared. Everything else either reads it (the merge,
8+
the fat-jar assembly) or must repeat it, and is checked here:
9+
* llama/pom.xml one jar execution per row: classifier, directory, Automatic-Module-Name
10+
* llama-platform depends on exactly the rows marked platform=yes
11+
* publish.yml a build job uploads natives-<classifier> for every row, and no other
12+
* LlamaLoader BACKEND_PRIORITY tries every backend (else a jar ships and never loads)
13+
14+
Usage:
15+
check-natives.py check, exit 1 on any disagreement
16+
check-natives.py pom print the natives jar executions for llama/pom.xml
17+
"""
18+
19+
import csv
20+
import os
21+
import re
22+
import sys
23+
import xml.etree.ElementTree as ET
24+
25+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26+
NS = {"m": "http://maven.apache.org/POM/4.0.0"}
27+
28+
29+
def rows():
30+
with open(os.path.join(ROOT, ".github", "natives.csv"), encoding="utf-8") as f:
31+
lines = [line for line in f if line.strip() and not line.startswith("#")]
32+
return list(csv.DictReader(lines))
33+
34+
35+
def module_name(classifier):
36+
"""Each natives jar needs its own: without one, every jar derives the module name `llama`
37+
from its file name, and the module path silently keeps only the first."""
38+
return "net.ladenthin.llama.natives." + classifier.replace("-", "_")
39+
40+
41+
def pom_execution(row):
42+
return f"""\t\t\t\t\t\t\t<execution>
43+
\t\t\t\t\t\t\t\t<id>natives-{row['classifier']}</id>
44+
\t\t\t\t\t\t\t\t<phase>package</phase>
45+
\t\t\t\t\t\t\t\t<goals>
46+
\t\t\t\t\t\t\t\t\t<goal>jar</goal>
47+
\t\t\t\t\t\t\t\t</goals>
48+
\t\t\t\t\t\t\t\t<configuration>
49+
\t\t\t\t\t\t\t\t\t<classifier>{row['classifier']}</classifier>
50+
\t\t\t\t\t\t\t\t\t<classesDirectory>${{project.basedir}}/src/main/natives</classesDirectory>
51+
\t\t\t\t\t\t\t\t\t<includes>
52+
\t\t\t\t\t\t\t\t\t\t<include>net/ladenthin/llama/{row['directory']}/**</include>
53+
\t\t\t\t\t\t\t\t\t</includes>
54+
\t\t\t\t\t\t\t\t\t<archive>
55+
\t\t\t\t\t\t\t\t\t\t<addMavenDescriptor>false</addMavenDescriptor>
56+
\t\t\t\t\t\t\t\t\t\t<manifestEntries>
57+
\t\t\t\t\t\t\t\t\t\t\t<Automatic-Module-Name>{module_name(row['classifier'])}</Automatic-Module-Name>
58+
\t\t\t\t\t\t\t\t\t\t</manifestEntries>
59+
\t\t\t\t\t\t\t\t\t</archive>
60+
\t\t\t\t\t\t\t\t</configuration>
61+
\t\t\t\t\t\t\t</execution>"""
62+
63+
64+
def pom_natives(path):
65+
"""classifier -> (include, module name) for every jar execution of the natives profile."""
66+
tree = ET.parse(path)
67+
found = {}
68+
for profile in tree.getroot().iterfind("m:profiles/m:profile", NS):
69+
if profile.findtext("m:id", namespaces=NS) != "natives":
70+
continue
71+
for ex in profile.iterfind(".//m:plugin/m:executions/m:execution", NS):
72+
conf = ex.find("m:configuration", NS)
73+
classifier = conf.findtext("m:classifier", namespaces=NS) if conf is not None else None
74+
if classifier:
75+
found[classifier] = (conf.findtext("m:includes/m:include", namespaces=NS),
76+
conf.findtext("m:archive/m:manifestEntries/m:Automatic-Module-Name",
77+
namespaces=NS))
78+
return found
79+
80+
81+
def compare(what, expected, actual, failures):
82+
for name in sorted(expected - actual):
83+
failures.append(f"{what}: missing {name}")
84+
for name in sorted(actual - expected):
85+
failures.append(f"{what}: {name} is not in .github/natives.csv")
86+
87+
88+
def main(argv):
89+
natives = rows()
90+
if len(argv) > 1 and argv[1] == "pom":
91+
print("\n".join(pom_execution(r) for r in natives))
92+
return 0
93+
failures = []
94+
by_classifier = {r["classifier"]: r for r in natives}
95+
if len(by_classifier) != len(natives):
96+
failures.append("natives.csv lists a classifier twice")
97+
for r in natives:
98+
backend, rest = r["directory"].rsplit("/", 1)[1], r["classifier"]
99+
if not rest.startswith(backend + "-") or r["platform"] not in ("yes", "no"):
100+
failures.append(f"natives.csv: row {r['classifier']} -- classifier must start with its "
101+
f"directory's backend '{backend}', platform must be yes or no")
102+
103+
pom = pom_natives(os.path.join(ROOT, "llama", "pom.xml"))
104+
compare("llama/pom.xml natives profile", set(by_classifier), set(pom), failures)
105+
for classifier in sorted(set(pom) & set(by_classifier)):
106+
want = (f"net/ladenthin/llama/{by_classifier[classifier]['directory']}/**", module_name(classifier))
107+
if pom[classifier] != want:
108+
failures.append(f"llama/pom.xml: {classifier} has {pom[classifier]}, expected {want} "
109+
f"(check-natives.py pom prints the executions)")
110+
111+
platform = ET.parse(os.path.join(ROOT, "llama-platform", "pom.xml"))
112+
deps = {d.findtext("m:classifier", namespaces=NS)
113+
for d in platform.getroot().iterfind("m:dependencies/m:dependency", NS)} - {None}
114+
compare("llama-platform/pom.xml", {r["classifier"] for r in natives if r["platform"] == "yes"}, deps, failures)
115+
116+
with open(os.path.join(ROOT, ".github", "workflows", "publish.yml"), encoding="utf-8") as f:
117+
uploads = set(re.findall(r"upload-artifact@\S+\s+with:\s+name:\s*natives-(\S+)", f.read()))
118+
compare("publish.yml natives-* uploads", set(by_classifier), uploads, failures)
119+
120+
loader = os.path.join(ROOT, "llama", "src", "main", "java", "net", "ladenthin", "llama", "loader",
121+
"LlamaLoader.java")
122+
with open(loader, encoding="utf-8") as f:
123+
block = re.search(r"BACKEND_PRIORITY\s*=(.*?);", f.read(), re.S)
124+
priority = set(re.findall(r'"([^"]+)"', block.group(1))) if block else set()
125+
for backend in sorted({r["directory"].rsplit("/", 1)[1] for r in natives} - priority):
126+
failures.append(f"LlamaLoader.BACKEND_PRIORITY does not try '{backend}' -- its jars would never load")
127+
128+
for f in failures:
129+
print(f"::error::{f}", file=sys.stderr)
130+
print(f"{len(natives)} natives jars, {len(failures)} disagreements")
131+
return 1 if failures else 0
132+
133+
134+
if __name__ == "__main__":
135+
sys.exit(main(sys.argv))

‎.github/merge-native-artifacts.sh‎

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,41 @@
44
#
55
# SPDX-License-Identifier: MIT OR Apache-2.0
66

7-
# Merges the per-artifact native-library trees downloaded by the `*-libraries` glob into the
8-
# single default-JAR resource tree — and FAILS LOUD if two artifacts claim the same file path.
7+
# Merges the per-build native-library artifacts downloaded by the `natives-*` glob into the one
8+
# natives tree (llama/src/main/natives/net/ladenthin/llama/) that the `natives` Maven profile
9+
# packages — and FAILS LOUD when an artifact does not hold exactly what its name promises.
910
#
10-
# Why this exists: the `package` / `publish-snapshot` / `publish-release` jobs pull every build
11-
# job whose artifact name ends in `-libraries` with one globbed `actions/download-artifact`.
12-
# That is convenient (a new CPU platform ships in the default JAR by naming its artifact
13-
# `<Something>-libraries`, no packaging change) but it is silently unsafe: an artifact name says
14-
# nothing about which `{OS}/{ARCH}` subdirectory the job's CMake run actually wrote. When two
15-
# artifacts carry the same relative path, `merge-multiple: true` extracts both onto that one
16-
# path and the survivor can be a byte-level hybrid of the two, not either input.
11+
# Every shipped build uploads its tree as `natives-<classifier>`, one per row of
12+
# .github/natives.csv, which names the one directory it may write. Checked before anything is
13+
# merged:
14+
# * every listed artifact arrived, with its library, and no unlisted one did;
15+
# * every file lies below the artifact's own directory. A build whose CMake picked another
16+
# backend name or platform (the routing lives in llama/CMakeLists.txt) would otherwise land in
17+
# some other natives jar, or in none;
18+
# * no relative path is claimed by two artifacts. This follows from the second check, and is
19+
# kept anyway because it is the failure that actually shipped: all three macOS build jobs
20+
# wrote Mac/aarch64 and used to share one glob, the merge produced a byte-level hybrid of two
21+
# dylibs, and macOS SIGKILLed every process that loaded it (5.0.6 and several 5.0.7
22+
# snapshots). A check on the merged tree cannot see it — the collision leaves exactly one
23+
# file on the path, a corrupt one — so it has to run before the merge.
1724
#
18-
# That is exactly what happened to macOS arm64: all three macOS build jobs write
19-
# `Mac/aarch64/libjllama.dylib` (none of them passes -DOS_NAME/-DOS_ARCH, so CMakeLists
20-
# auto-detects the same subdir) and all three used to upload under a `*-libraries` name. The
21-
# published dylib became a hybrid whose ad-hoc linker signature no longer matched its own
22-
# __TEXT pages, so macOS SIGKILLed every process that loaded it — shipped broken in 5.0.6 and
23-
# several 5.0.7 snapshots. The immediate fix renamed those artifacts out of the glob; this
24-
# script is the backstop that stops the same hole from being reopened by a future job.
25-
#
26-
# NOTE ON WHAT *CANNOT* WORK AS A GUARD: asserting "exactly one library per {OS}/{ARCH}" on the
27-
# merged tree does not detect this. The collision overwrites one path, so the merged tree still
28-
# holds exactly one file there — a corrupt one. The collision is only observable BEFORE the
29-
# merge, which is why this script does the merge itself instead of checking afterwards.
25+
# After the merge, a backend directory holding files beside its library gets a
26+
# jllama-extras.txt listing them; LlamaLoader loads those first (e.g. the OpenCL ICD loader that
27+
# OpenVINO ships on Windows).
3028
#
3129
# Usage: merge-native-artifacts.sh <staging-dir> <dest-dir>
32-
# <staging-dir> output of `actions/download-artifact` with `pattern: "*-libraries"` and
30+
# <staging-dir> output of `actions/download-artifact` with `pattern: "natives-*"` and
3331
# `merge-multiple: false`, i.e. one subdirectory per artifact name.
34-
# <dest-dir> the tree the artifacts are merged into, e.g.
35-
# llama/src/main/resources/net/ladenthin/llama/
32+
# <dest-dir> the tree the artifacts are merged into,
33+
# llama/src/main/natives/net/ladenthin/llama/
3634
#
37-
# Fail-loud: aborts when the staging directory holds no artifacts (a silently empty default JAR
38-
# is worse than a red job) and when any relative path is claimed by more than one artifact.
35+
# Fail-loud: also aborts when the staging directory holds no artifacts.
3936

4037
set -euo pipefail
4138

4239
STAGING="${1:?usage: merge-native-artifacts.sh <staging-dir> <dest-dir>}"
4340
DEST="${2:?usage: merge-native-artifacts.sh <staging-dir> <dest-dir>}"
41+
LIST="$(dirname "$0")/natives.csv"
4442

4543
if [ ! -d "$STAGING" ]; then
4644
echo "::error::staging directory '$STAGING' does not exist — the globbed download did not run." >&2
@@ -52,12 +50,31 @@ artifacts=()
5250
while IFS= read -r d; do artifacts+=("$(basename "$d")"); done < <(find "$STAGING" -mindepth 1 -maxdepth 1 -type d | sort)
5351

5452
if [ "${#artifacts[@]}" -eq 0 ]; then
55-
echo "::error::no '*-libraries' artifacts found in '$STAGING' — the default JAR would ship without native libraries." >&2
53+
echo "::error::no 'natives-*' artifacts found in '$STAGING' — there would be no natives jars." >&2
5654
exit 1
5755
fi
5856

5957
echo "Merging ${#artifacts[@]} native-library artifact(s) into $DEST"
60-
for a in "${artifacts[@]}"; do echo " - $a"; done
58+
listed=0
59+
while IFS=, read -r classifier dir lib _; do
60+
listed=$((listed + 1))
61+
a="natives-$classifier"
62+
if [ ! -d "$STAGING/$a" ]; then
63+
echo "::error::no artifact '$a' -- the build job for this row of $LIST did not upload it" >&2
64+
exit 1
65+
fi
66+
stray="$(cd "$STAGING/$a" && find . -type f | sed 's|^\./||' | grep -v "^$dir/" || true)"
67+
if [ -n "$stray" ] || [ ! -f "$STAGING/$a/$dir/$lib" ]; then
68+
echo "::error::artifact '$a' must hold $dir/$lib and nothing outside $dir/; outside it:" >&2
69+
printf '%s\n' "$stray" | sed 's|^|::error:: |' >&2
70+
exit 1
71+
fi
72+
echo " - $a -> $dir/"
73+
done < <(grep -v -e '^#' -e '^classifier,' -e '^$' "$LIST")
74+
if [ "$listed" -ne "${#artifacts[@]}" ]; then
75+
echo "::error::$STAGING holds ${#artifacts[@]} natives-* artifacts but $LIST lists $listed: $(printf '%s ' "${artifacts[@]}")" >&2
76+
exit 1
77+
fi
6178

6279
# relpath -> space-separated list of artifacts that carry it. Bash 3.2 (macOS) has no
6380
# associative arrays, so this stays a sorted "<relpath>\t<artifact>" stream processed by awk.
@@ -71,16 +88,11 @@ collisions="$(
7188
)"
7289
7390
if [ -n "$collisions" ]; then
74-
echo "::error::two or more '*-libraries' artifacts write the same path — merging them would produce a hybrid, corrupt native library." >&2
91+
echo "::error::two or more 'natives-*' artifacts write the same path — merging them would produce a hybrid, corrupt native library." >&2
7592
while IFS=$'\t' read -r path owners; do
7693
echo "::error:: $path <- claimed by:$owners" >&2
7794
done <<< "$collisions"
78-
cat >&2 <<'EOF'
79-
::error::Fix: only ONE artifact per {OS}/{ARCH} may be named `*-libraries`. Rename the extra
80-
::error::build jobs' artifacts outside the glob (as the macOS jobs do: macos-15-metal /
81-
::error::macos-14-metal / macos-15-no-metal) and download the variant that ships explicitly by
82-
::error::name. See CLAUDE.md, "macOS arm64: three build jobs, one shipped dylib".
83-
EOF
95+
echo "::error::Fix: only one shipped build per <backend>-<os>-<arch>; name a test-only variant outside the glob (see CLAUDE.md, \"macOS arm64\")." >&2
8496
exit 1
8597
fi
8698
@@ -89,5 +101,14 @@ for a in "${artifacts[@]}"; do
89101
cp -R "$STAGING/$a/." "$DEST/"
90102
done
91103
104+
# Sibling files of a backend's library are loaded before it, in name order.
105+
find "$DEST" -mindepth 3 -maxdepth 3 -type d | sort | while IFS= read -r dir; do
106+
extras="$(cd "$dir" && find . -maxdepth 1 -type f ! -name 'libjllama.*' ! -name 'jllama.dll' ! -name '*.metal' ! -name jllama-extras.txt | sed 's|^\./||' | sort)"
107+
if [ -n "$extras" ]; then
108+
printf '%s\n' "$extras" > "$dir/jllama-extras.txt"
109+
echo "extras for ${dir#"$DEST"}: $(echo "$extras" | tr '\n' ' ')"
110+
fi
111+
done
112+
92113
echo "Merged native tree:"
93114
find "$DEST" -type f | sort | sed 's|^| |'

‎.github/natives.csv‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
#
5+
# The natives jars: the single list every packaging step reads (see CLAUDE.md, "Natives jars").
6+
# One row per jar, net.ladenthin:llama:<classifier>, holding exactly one directory,
7+
# net/ladenthin/llama/<directory>/, whose name ends in the backend LlamaLoader tries.
8+
# classifier <backend>-<os>-<arch>; the build job uploads its tree as artifact natives-<classifier>
9+
# directory <OS>/<ARCH>/<backend>, as llama/CMakeLists.txt writes it and OSInfo resolves it
10+
# library the file that must be in it
11+
# platform yes = a dependency of net.ladenthin:llama-platform (the CPU natives of a desktop)
12+
# Adding a row: add the natives jar execution to llama/pom.xml (`check-natives.py pom` prints it),
13+
# a build job uploading natives-<classifier>, and, for a new backend, its name in CMakeLists.txt
14+
# and LlamaLoader.BACKEND_PRIORITY. .github/check-natives.py fails until all of them agree.
15+
classifier,directory,library,platform
16+
cpu-linux-x86-64,Linux/x86_64/cpu,libjllama.so,yes
17+
cpu-linux-aarch64,Linux/aarch64/cpu,libjllama.so,yes
18+
cpu-linux-s390x,Linux/s390x/cpu,libjllama.so,yes
19+
cpu-android-aarch64,Linux-Android/aarch64/cpu,libjllama.so,no
20+
cpu-android-x86-64,Linux-Android/x86_64/cpu,libjllama.so,no
21+
cpu-windows-x86-64,Windows/x86_64/cpu,jllama.dll,yes
22+
cpu-windows-x86,Windows/x86/cpu,jllama.dll,yes
23+
cpu-windows-aarch64,Windows/aarch64/cpu,jllama.dll,yes
24+
metal-macos-aarch64,Mac/aarch64/metal,libjllama.dylib,yes
25+
msvc-windows-x86-64,Windows/x86_64/msvc,jllama.dll,no
26+
msvc-windows-x86,Windows/x86/msvc,jllama.dll,no
27+
cuda13-linux-x86-64,Linux/x86_64/cuda13,libjllama.so,no
28+
cuda13-windows-x86-64,Windows/x86_64/cuda13,jllama.dll,no
29+
vulkan-linux-x86-64,Linux/x86_64/vulkan,libjllama.so,no
30+
vulkan-linux-aarch64,Linux/aarch64/vulkan,libjllama.so,no
31+
vulkan-windows-x86-64,Windows/x86_64/vulkan,jllama.dll,no
32+
opencl-android-aarch64,Linux-Android/aarch64/opencl,libjllama.so,no
33+
opencl-windows-x86-64,Windows/x86_64/opencl,jllama.dll,no
34+
opencl-windows-aarch64,Windows/aarch64/opencl,jllama.dll,no
35+
rocm-linux-x86-64,Linux/x86_64/rocm,libjllama.so,no
36+
rocm-windows-x86-64,Windows/x86_64/rocm,jllama.dll,no
37+
sycl-fp16-linux-x86-64,Linux/x86_64/sycl-fp16,libjllama.so,no
38+
sycl-fp32-linux-x86-64,Linux/x86_64/sycl-fp32,libjllama.so,no
39+
sycl-windows-x86-64,Windows/x86_64/sycl,jllama.dll,no
40+
openvino-linux-x86-64,Linux/x86_64/openvino,libjllama.so,no
41+
openvino-windows-x86-64,Windows/x86_64/openvino,jllama.dll,no

0 commit comments

Comments
 (0)