diff --git a/.github/workflows/unsloth-sd-prebuilt.yml b/.github/workflows/unsloth-sd-prebuilt.yml index 56ce42609..06c181ee6 100644 --- a/.github/workflows/unsloth-sd-prebuilt.yml +++ b/.github/workflows/unsloth-sd-prebuilt.yml @@ -1,36 +1,57 @@ # SPDX-License-Identifier: MIT # Copyright 2026-present the Unsloth AI Inc. team. -name: Unsloth SD prebuilt (CPU/Apple) +name: Unsloth SD prebuilt (CPU/Apple/CUDA) # Build and publish OUR OWN stable-diffusion.cpp (sd-cli + sd-server) prebuilts for -# the platforms where the native engine is the FASTER choice: CPU (Linux/WSL/Windows) -# and Apple (Metal). GPU hosts use diffusers/torch, so no CUDA/ROCm/Vulkan here. +# the platforms where the native engine is the FASTER choice: CPU (Linux/WSL/Windows), +# Apple (Metal), and now Linux CUDA. The last one is a correction: this pipeline was +# built on the premise that a GPU host runs diffusers/torch instead, and MiniMax-H3 +# breaks that premise by needing more VRAM than a consumer card has, which sends those +# hosts to the GGUF engine after all. # # Shape mirrors unslothai/llama.cpp's prebuilt pipeline, simplified for a 5-way -# CPU/Apple matrix: -# resolve -- pick the leejet release tag (supply-chain aged), stamp a source -# tarball from that tag, decide if our release already exists. +# CPU/Apple matrix plus one best-effort CUDA leg: +# resolve -- take THIS repository at the requested ref, merge the pinned PR set +# in scripts/unsloth/pr-set.json, stamp a source tarball, name it +# after the upstream release the tree descends from (supply-chain +# aged), decide if our release already exists. # build-unix -- macOS arm64 (Metal) + x64, Linux x64 + arm64 (matrix). # build-windows-- Windows x64 (MSVC + Ninja). +# build-linux-cuda -- Linux x64, CUDA 12.8, best effort (continue-on-error). # assemble -- fingerprint gate, sha256 + manifest, coverage gate, atomic -# draft->publish. If ANY build leg fails, assemble is skipped and -# nothing is published (the Studio needs the full asset set). +# draft->publish. If any of the five CPU/Apple legs fails, assemble +# is skipped and nothing is published (the Studio needs the full +# asset set). The CUDA leg is exempt: it ships when it built and is +# absent when it did not, so it can never hold back the fallbacks. # -# Asset names match the Studio installer's resolve_release_asset (leejet-compatible). +# Asset names match the Studio installer's resolve_release_asset. +# +# We build THIS repository's tree, not a tarball fetched from anywhere else. Our own fixes are +# commits here, so there is nothing to re-apply at build time and nothing to keep in step with a +# foreign tag. The published tag still names the upstream release the tree descends from, so a box +# can say what it is built on without the build depending on that repository. +# +# On top of that tree, scripts/unsloth/pr-set.json can pin pull requests in THIS repository to +# exact reviewed commits, merged in listed order at build time. That is the same mechanism as +# unslothai/llama.cpp, with one deliberate difference: there the base is a pristine upstream +# release and every Unsloth change stays pinned, whereas here the base is our own tree, so a +# merged fix is simply in it and its pin is deleted. To carry a fix that exists as an upstream +# pull request, vendor it here as a PR first and pin that; the build never fetches from another +# repository. on: schedule: - cron: '17 20 * * *' # ~1pm PT-ish; daily workflow_dispatch: inputs: - tag: - description: 'leejet release tag (master--) or "latest"' - default: 'latest' + ref: + description: 'Ref in THIS repository to build (branch, tag or sha)' + default: 'master' required: true type: string min_age_hours: - description: 'For "latest": only build a release public for >= this many hours (blank = 6)' + description: 'Refuse a tree whose base upstream release is younger than this many hours (blank = 6)' default: '' required: false type: string @@ -44,7 +65,7 @@ permissions: contents: read concurrency: - group: sd-prebuilt-${{ github.event.inputs.tag || 'scheduled' }} + group: sd-prebuilt-${{ github.event.inputs.ref || 'scheduled' }} cancel-in-progress: false env: @@ -59,82 +80,135 @@ jobs: outputs: tag: ${{ steps.r.outputs.tag }} upstream_tag: ${{ steps.r.outputs.upstream_tag }} - patch_list: ${{ steps.r.outputs.patch_list }} + ahead: ${{ steps.r.outputs.ahead }} + pins: ${{ steps.r.outputs.pins }} commit: ${{ steps.r.outputs.commit }} exists: ${{ steps.r.outputs.exists }} source_artifact: ${{ steps.r.outputs.source_artifact }} env: GH_TOKEN: ${{ github.token }} steps: - - name: Checkout mirror (for tooling; upstream remote added below) + - name: Checkout this repository with full history, which the tag name is derived from uses: actions/checkout@v4 with: - fetch-depth: 1 + ref: ${{ github.event.inputs.ref || 'master' }} + fetch-depth: 0 + fetch-tags: true - id: r run: | set -euo pipefail - REQ='${{ github.event.inputs.tag || 'latest' }}' AGE_H='${{ github.event.inputs.min_age_hours }}' [ -n "$AGE_H" ] || AGE_H="${UNSLOTH_SD_MIN_RELEASE_AGE_HOURS:-6}" - if [ "$REQ" = "latest" ]; then - CUTOFF="$(date -u -d "-${AGE_H} hours" +%s)" - TAG="$(gh api 'repos/leejet/stable-diffusion.cpp/releases?per_page=100' \ - --jq "[.[] | select(.draft==false and .prerelease==false) | select((.published_at|fromdateiso8601) <= ${CUTOFF})] | max_by(.published_at|fromdateiso8601) | .tag_name")" - [ -n "$TAG" ] && [ "$TAG" != "null" ] || { echo "no leejet release older than ${AGE_H}h" >&2; exit 1; } - echo "selected leejet $TAG (aged >= ${AGE_H}h)" - else - TAG="$REQ" - fi - printf '%s' "$TAG" | grep -qE '^master-[0-9]+-[0-9a-f]+$' \ - || { echo "refusing non-release tag '$TAG'" >&2; exit 1; } - UPSTREAM_TAG="$TAG" - - # Stash patches/ before the upstream checkout: it is tracked HERE and absent from - # leejet's tree, so checking out their tag in this worktree would remove it. - PATCHES="${RUNNER_TEMP}/patches" - rm -rf "$PATCHES"; mkdir -p "$PATCHES" - cp -a patches/. "$PATCHES"/ 2>/dev/null || true - - # Fetch leejet's source AT THAT TAG and tar it (no .git); the build legs - # extract this identical tree. Our own scripts come from the tooling checkout. - git remote add upstream https://github.com/leejet/stable-diffusion.cpp.git || true - git fetch -q --depth 1 upstream "refs/tags/${TAG}:refs/tags/${TAG}" - git checkout -q --detach "refs/tags/${TAG}" - - # Apply the local patch set (see patches/README.md). Each one is a fix that is open - # upstream but not yet in a release; the set is normally empty. A non-empty set moves - # the published tag to -u, so the tag says what the box carries and a - # changed set republishes instead of matching an existing release and skipping. + # The tree we build is this checkout. The tag still has to say which upstream release + # it descends from, so read that from the history rather than from a foreign API. + # + # HIGHEST reachable release, not nearest. `git describe` answers "nearest", and on a + # merge-shaped history that is the wrong answer: this tree reaches master-813 through a + # merge 73 commits back and master-811 on its own line 13 commits back, so describe + # names it after 811 and understates what it actually contains. The strict regex also + # keeps out our own published tags, which live in this repository and carry a -u suffix. + UPSTREAM_TAG="$(git tag -l --merged HEAD 'master-*' \ + | grep -E '^master-[0-9]+-[0-9a-f]+$' | sort -t- -k2,2n | tail -1)" + [ -n "$UPSTREAM_TAG" ] || { + echo "no upstream release tag is reachable from this ref; cannot name the build" >&2 + exit 1 + } + + # Supply-chain aging, kept but re-pointed: it now guards the upstream release the tree + # descends from, not the moment we merged it. A tree whose base is hours old is the + # thing worth waiting on; our own commits on top are reviewed here. + BASE_TS="$(git log -1 --format=%ct "refs/tags/${UPSTREAM_TAG}")" + CUTOFF="$(date -u -d "-${AGE_H} hours" +%s)" + [ "$BASE_TS" -le "$CUTOFF" ] || { + echo "base release ${UPSTREAM_TAG} is younger than ${AGE_H}h; refusing" >&2 + exit 1 + } + + # Resolve the PR mix set (scripts/unsloth/pr-set.json). Same mechanism as + # unslothai/llama.cpp: an exact, reviewed commit per entry, so an author pushing more + # commits cannot change what the nightly ships. Only PRs in THIS repository may be + # pinned; to carry an upstream fix, vendor it here as a PR first and pin that. # - # --check runs over ALL of them first, so a stale patch stops the run before the tree - # is half-modified. That failure is the intended signal: upstream either merged the fix - # (delete the patch) or moved the code under it (refresh and re-verify it). - PATCH_LIST="" - FILES=() - while IFS= read -r f; do FILES+=("$f"); done < <(find "$PATCHES" -maxdepth 1 -name '*.patch' | sort) - if [ "${#FILES[@]}" -gt 0 ]; then - for p in "${FILES[@]}"; do - git apply --check "$p" || { - echo "ERROR: $(basename "$p") no longer applies to ${UPSTREAM_TAG}." >&2 - echo " Upstream merged it (delete the file) or moved the code under it" >&2 - echo " (refresh it). See patches/README.md." >&2 + # The gate lives here rather than only in a lint, because a red lint does not stop the + # schedule. + jq -e '.prs | type == "array" and all(.[]; + type == "string" + or (type == "object" and (.url | type == "string") + and ((if .required == null then true else .required end) | type == "boolean")))' \ + scripts/unsloth/pr-set.json >/dev/null \ + || { echo "scripts/unsloth/pr-set.json: .prs must be an array of PR url strings, or {url, required} objects" >&2; exit 1; } + PRS='[]' + URL_RE='^https://github\.com/unslothai/stable-diffusion\.cpp/pull/([0-9]+)/commits/([0-9a-f]{40})/?$' + while read -r url REQUIRED; do + [[ "$url" =~ $URL_RE ]] || { echo "refusing malformed PR url '$url' (expected https://github.com/unslothai/stable-diffusion.cpp/pull//commits/<40-hex-sha>)" >&2; exit 1; } + NUM="${BASH_REMATCH[1]}"; SHA="${BASH_REMATCH[2]}" + PR_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${NUM}")" \ + || { echo "refusing #${NUM}: could not fetch PR metadata (nonexistent number in '$url', or a transient API failure); fix the pin or retry" >&2; exit 1; } + STATE="$(jq -r '.state' <<<"$PR_JSON")" + HEAD_PR="$(jq -r '.head.sha' <<<"$PR_JSON")" + N_COMMITS="$(jq -r '.commits' <<<"$PR_JSON")" + TITLE="$(jq -r '.title' <<<"$PR_JSON")" + if [ "$STATE" != "open" ]; then + # Dropping a pin changes SETHASH and the tag, so the release would ship without that + # change under a new name. Refuse unless the entry is explicitly optional. + if [ "$REQUIRED" != "false" ]; then + echo "refusing #${NUM}: pin is ${STATE} and required. If it merged, delete the pin (its commits are already in this tree). Otherwise mark it \"required\": false or repin it." >&2 exit 1 - } - done - for p in "${FILES[@]}"; do - git apply "$p" - PATCH_LIST="${PATCH_LIST}${PATCH_LIST:+,}$(basename "$p")" - echo "applied $(basename "$p")" - done - PATCH_ID="$(cat "${FILES[@]}" | sha256sum | cut -c1-7)" - TAG="${UPSTREAM_TAG}-u${PATCH_ID}" - echo "patched build: ${UPSTREAM_TAG} + ${PATCH_LIST} -> ${TAG}" + fi + echo "::warning::skipping optional pin #${NUM} (${STATE}): $url" + continue + fi + # A pin pasted from the wrong PR would build arbitrary code while the manifest blames + # #. The commits listing is capped at 250; past that, skip rather than false-fail. + if [ "$N_COMMITS" -gt 250 ]; then + echo "note: #${NUM} has ${N_COMMITS} commits (over the API listing cap); skipping pin membership check" + elif ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${NUM}/commits" --paginate --jq '.[].sha' | grep -qx "$SHA"; then + echo "refusing #${NUM}: pinned commit ${SHA} is not a commit of that PR (wrong paste, or force-pushed away)" >&2 + exit 1 + fi + [ "$SHA" = "$HEAD_PR" ] || echo "note: #${NUM} is pinned to ${SHA} but its head has moved to ${HEAD_PR}" + echo "including #${NUM} @ ${SHA}" + PRS="$(jq -c --arg n "$NUM" --arg s "$SHA" --arg u "$url" --arg t "$TITLE" '. + [{number: ($n|tonumber), sha: $s, url: $u, title: $t}]' <<<"$PRS")" + done < <(jq -r '.prs[] | if type == "string" then {url: ., required: true} else . end + | "\(.url)\t\(if .required == null then true else .required end)"' scripts/unsloth/pr-set.json | tr '\t' ' ') + + # Merge the pinned commits onto this tree, in listed order (merge order matters for + # conflicts). diff3 so additive_merge.py can see the merge base and refuse to guess on + # anything that is not a provable add/add. + for row in $(jq -r '.[] | "\(.number):\(.sha)"' <<<"$PRS"); do + NUM="${row%%:*}"; SHA="${row##*:}" + git fetch -q --no-tags origin "$SHA" \ + || git fetch -q --no-tags origin "refs/pull/${NUM}/head" \ + || { echo "could not fetch commit ${SHA} for #${NUM}" >&2; exit 1; } + git -c user.name='Unsloth CI' -c user.email='ci@unsloth.ai' \ + -c merge.conflictStyle=diff3 merge --no-edit "$SHA" && continue + python3 scripts/unsloth/additive_merge.py \ + || { echo "merging #${NUM} conflicts in a way that needs a human; fix the pin" >&2; exit 1; } + git -c user.name='Unsloth CI' -c user.email='ci@unsloth.ai' commit --no-edit -q + echo "merged #${NUM} with additive conflict resolution" + done + + # Always -u: the tree is ours, never a stock upstream one, and the suffix is what + # tells Studio's installer to go straight to this repository's releases instead of + # trying an upstream download that is guaranteed to 404. With no pins the id is the head + # sha; with pins it also hashes the pinned number:sha pairs, so a repin or a reorder + # yields a new tag and a rebuild while an unchanged set still matches and skips. + HEAD_SHA7="$(git rev-parse --short=7 HEAD)" + if [ "$(jq length <<<"$PRS")" = 0 ]; then + TAG_ID="$HEAD_SHA7" + else + TAG_ID="$(jq -r 'map("\(.number):\(.sha)") | join("\n")' <<<"$PRS" \ + | { cat; echo "$HEAD_SHA7"; } | sha256sum | cut -c1-7)" fi + TAG="${UPSTREAM_TAG}-u${TAG_ID}" + AHEAD="$(git rev-list --count "refs/tags/${UPSTREAM_TAG}..HEAD")" + PIN_LIST="$(jq -r 'map("#\(.number)") | join(",")' <<<"$PRS")" + echo "building this repository at ${HEAD_SHA7} (${AHEAD} commits past ${UPSTREAM_TAG}), pins [${PIN_LIST}] -> ${TAG}" # Does OUR published release already exist? (drafts don't count.) Checked against the - # final, patch-aware tag so a patch-set change is never mistaken for an existing build. + # final tag, so a new commit or a repin republishes instead of matching. EXISTS=false if [ "$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft 2>/dev/null || true)" = "false" ]; then EXISTS=true @@ -155,7 +229,8 @@ jobs: { echo "tag=$TAG" echo "upstream_tag=$UPSTREAM_TAG" - echo "patch_list=$PATCH_LIST" + echo "ahead=$AHEAD" + echo "pins=$PIN_LIST" echo "commit=$COMMIT" echo "exists=$EXISTS" echo "source_artifact=$SRC_ARTIFACT" @@ -246,7 +321,7 @@ jobs: TAG: ${{ needs.resolve.outputs.tag }} LABEL: ${{ matrix.label }} COMMIT: ${{ needs.resolve.outputs.commit }} - SOURCE_REPO: leejet/stable-diffusion.cpp + SOURCE_REPO: ${{ github.repository }} LICENSE_FILE: ${{ github.workspace }}/src/LICENSE run: python3 tooling/scripts/unsloth/package_bundle.py @@ -257,6 +332,118 @@ jobs: path: dist/sd-${{ needs.resolve.outputs.tag }}-bin-${{ matrix.label }}.zip if-no-files-found: error + # Linux CUDA. The rest of this pipeline is CPU and Apple on the premise that GPU hosts use + # diffusers/torch, and that premise fails for MiniMax-H3: its Diffusers path needs about + # 68.5 GB of VRAM, so every consumer card falls back to the GGUF engine, which on Linux had + # no accelerated build at all. Measured on one box: 65 s/step at 320x192 on 96 CPU threads, + # against 21.5 s/step at 960x544 from a CUDA build. That is the difference between a render + # taking four hours and taking eleven minutes. + # + # continue-on-error, and deliberately so. The CPU and Apple assets are what Studio falls back + # to, and a CUDA toolchain failure must never stop them publishing. assemble collects bundles + # by the sd-*-bin-* pattern, so this asset appears when it built and is simply absent when it + # did not; the coverage gate lists only the five CPU/Apple assets and must keep doing so. + build-linux-cuda: + name: Linux-Ubuntu-22.04-x86_64-cuda12 + needs: resolve + continue-on-error: true + if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-22.04 + steps: + - name: Checkout mirror (tooling) + uses: actions/checkout@v4 + with: + path: tooling + fetch-depth: 1 + + - name: Free disk for the toolkit + run: | + set -eux + # The hosted image ships ~25 GB free; the toolkit plus a seven-architecture ggml-cuda + # build does not fit beside the preinstalled Android and .NET trees. + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost + df -h / + + - name: Download source @ ${{ needs.resolve.outputs.tag }} + uses: actions/download-artifact@v4 + with: + name: ${{ needs.resolve.outputs.source_artifact }} + path: srcpkg + - name: Extract source + run: | + set -eux + mkdir -p src + tar -xzf "srcpkg/sd-source-${{ needs.resolve.outputs.tag }}.tar.gz" -C src + + - name: Install CUDA toolkit + id: cuda-toolkit + uses: Jimver/cuda-toolkit@v0.2.22 + with: + cuda: "12.8.1" + method: "network" + # sub-packages are installed as cuda--12-8. cuBLAS is not under that + # prefix (it ships as libcublas / libcublas-dev), so it has to go in the + # other list or apt cannot find it. cudart-dev is what carries the headers; + # cudart alone is the runtime and does not compile anything. + sub-packages: '["nvcc", "cudart", "cudart-dev", "thrust"]' + non-cuda-sub-packages: '["libcublas", "libcublas-dev"]' + + - name: Build sd-cli + sd-server (CUDA) + working-directory: src + run: | + set -euo pipefail + # Turing through Blackwell. 12.8 is the first toolkit that can emit sm_100 (B200) and + # sm_120 (RTX 50), and anything older than Turing is not a realistic host for a 20 GB + # video denoiser. build.yml's Windows leg uses the same list plus 61 and 70. + cmake -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DSD_BUILD_EXAMPLES=ON \ + -DSD_SERVER_BUILD_FRONTEND=OFF \ + -DSD_WEBP=OFF -DSD_WEBM=OFF \ + -DGGML_NATIVE=OFF \ + -DSD_CUDA=ON \ + -DCMAKE_CUDA_ARCHITECTURES='75;80;86;89;90;100;120' + cmake --build build --config Release -j "$(nproc)" --target sd-cli sd-server + + - name: Bundle the CUDA runtime beside the binaries + run: | + set -euo pipefail + # A Studio host has an NVIDIA driver but not necessarily a CUDA runtime, and Studio's + # torch install keeps its own copies somewhere we must not depend on. Ship ours and + # point the binaries at their own directory, or sd-cli dies on a missing libcublas. + BIN="${GITHUB_WORKSPACE}/src/build/bin" + CUDA_ROOT="${{ steps.cuda-toolkit.outputs.CUDA_PATH }}" + sudo apt-get update -qq && sudo apt-get install -y -qq patchelf + # cudart lands under the toolkit root; the libcublas debs land in the system + # multiarch dir. Search both rather than guess which is which. + for so in libcudart.so.12 libcublas.so.12 libcublasLt.so.12; do + src="$(find "$CUDA_ROOT" /usr/lib/x86_64-linux-gnu -name "${so}*" -type f 2>/dev/null | sort | tail -1)" + [ -n "$src" ] || { echo "ERROR: $so not found under $CUDA_ROOT or /usr/lib/x86_64-linux-gnu" >&2; exit 1; } + cp -L "$src" "$BIN/$so" + done + for exe in sd-cli sd-server; do + patchelf --set-rpath '$ORIGIN' "$BIN/$exe" + done + ldd "$BIN/sd-cli" | sed -n '1,40p' + + - name: Package bundle + env: + BIN_DIR: ${{ github.workspace }}/src/build/bin + OUT_DIR: ${{ github.workspace }}/dist + TAG: ${{ needs.resolve.outputs.tag }} + LABEL: Linux-Ubuntu-22.04-x86_64-cuda12 + COMMIT: ${{ needs.resolve.outputs.commit }} + SOURCE_REPO: ${{ github.repository }} + LICENSE_FILE: ${{ github.workspace }}/src/LICENSE + run: python3 tooling/scripts/unsloth/package_bundle.py + + - name: Upload bundle + uses: actions/upload-artifact@v4 + with: + name: sd-${{ needs.resolve.outputs.tag }}-bin-Linux-Ubuntu-22.04-x86_64-cuda12 + path: dist/sd-${{ needs.resolve.outputs.tag }}-bin-Linux-Ubuntu-22.04-x86_64-cuda12.zip + if-no-files-found: error + build-windows: name: win-cpu-x64 needs: resolve @@ -311,7 +498,7 @@ jobs: TAG: ${{ needs.resolve.outputs.tag }} LABEL: win-cpu-x64 COMMIT: ${{ needs.resolve.outputs.commit }} - SOURCE_REPO: leejet/stable-diffusion.cpp + SOURCE_REPO: ${{ github.repository }} LICENSE_FILE: ${{ github.workspace }}/src/LICENSE run: python tooling/scripts/unsloth/package_bundle.py @@ -324,7 +511,7 @@ jobs: assemble: name: Assemble + publish - needs: [resolve, build-unix, build-windows] + needs: [resolve, build-unix, build-windows, build-linux-cuda] if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-22.04 permissions: @@ -367,8 +554,8 @@ jobs: python3 tooling/scripts/unsloth/assemble_metadata.py \ --tag '${{ needs.resolve.outputs.tag }}' \ --upstream-tag '${{ needs.resolve.outputs.upstream_tag }}' \ - --patches '${{ needs.resolve.outputs.patch_list }}' \ - --source-repo 'leejet/stable-diffusion.cpp' \ + --patches '${{ needs.resolve.outputs.pins }}' \ + --source-repo "$GITHUB_REPOSITORY" \ --commit '${{ needs.resolve.outputs.commit }}' \ --dist dist --out dist \ --publish-repo "$GITHUB_REPOSITORY" @@ -403,11 +590,16 @@ jobs: set -eux TAG='${{ needs.resolve.outputs.tag }}' UPSTREAM_TAG='${{ needs.resolve.outputs.upstream_tag }}' - PATCH_LIST='${{ needs.resolve.outputs.patch_list }}' + AHEAD='${{ needs.resolve.outputs.ahead }}' + COMMIT='${{ needs.resolve.outputs.commit }}' REPO="$GITHUB_REPOSITORY" - NOTES="Automated Unsloth stable-diffusion.cpp CPU + Apple prebuild (sd-cli + sd-server) for upstream [${UPSTREAM_TAG}](https://github.com/leejet/stable-diffusion.cpp/releases/tag/${UPSTREAM_TAG}). GPU hosts use diffusers/torch; this native engine targets CPU (Linux/WSL/Windows) and Apple (Metal)." - if [ -n "$PATCH_LIST" ]; then - NOTES="${NOTES}"$'\n\n'"Not a stock upstream build. It carries the patch set in [patches/](https://github.com/${REPO}/tree/master/patches), each one a fix that is open upstream but not yet in a release, which is what the \`-u\` suffix on the tag marks:"$'\n\n'"$(printf '%s' "$PATCH_LIST" | tr ',' '\n' | sed 's/^/- `/; s/$/`/')"$'\n\n'"\`sd-prebuilt-manifest.json\` records the same list." + NOTES="Automated Unsloth stable-diffusion.cpp CPU + Apple prebuild (sd-cli + sd-server), built from [\`${COMMIT}\`](https://github.com/${REPO}/commit/${COMMIT}) in this repository. GPU hosts use diffusers/torch; this native engine targets CPU (Linux/WSL/Windows) and Apple (Metal)." + PINS='${{ needs.resolve.outputs.pins }}' + if [ "${AHEAD:-0}" -gt 0 ]; then + NOTES="${NOTES}"$'\n\n'"Not a stock build: the tree is ${AHEAD} commits past the ${UPSTREAM_TAG} release it descends from, which is what the \`-u\` suffix on the tag marks. \`sd-prebuilt-manifest.json\` records the commit." + fi + if [ -n "$PINS" ]; then + NOTES="${NOTES}"$'\n\n'"It also carries these pinned pull requests, merged at build time from \`scripts/unsloth/pr-set.json\`: ${PINS}." fi if [ "$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq .isDraft 2>/dev/null || true)" = "true" ]; then gh release delete "$TAG" --repo "$REPO" --yes diff --git a/patches/0001-spare-1d-norm-weights-from-blanket-quant.patch b/patches/0001-spare-1d-norm-weights-from-blanket-quant.patch deleted file mode 100644 index 3c4065dce..000000000 --- a/patches/0001-spare-1d-norm-weights-from-blanket-quant.patch +++ /dev/null @@ -1,34 +0,0 @@ -Spare 1-D norm weights from a blanket --type - -Upstream: https://github.com/leejet/stable-diffusion.cpp/pull/1861 - -A 1-D weight is a per-channel gain, never a matmul weight. These survived a blanket --type -only when their length did not divide the block size; MiniMax-H3's [5376] norms do divide it, -so 106 of them were crushed to 4 bits. The model still loads and still renders something -plausible, which is what makes it worth guarding: measured against a bf16 render of the same -prompt and seed, PSNR 9.87 / SSIM 0.074 / LPIPS 0.981, against 22.22 / 0.841 / 0.292 with the -rule in place. - -diff --git a/src/model_loader.cpp b/src/model_loader.cpp -index 047c9d9..b286f2a 100644 ---- a/src/model_loader.cpp -+++ b/src/model_loader.cpp -@@ -1511,6 +1511,18 @@ bool ModelLoader::tensor_should_be_converted(const TensorStorage& tensor_storage - if (type != GGML_TYPE_COUNT) { - if (ggml_is_quantized(type) && tensor_storage.ne[0] % ggml_blck_size(type) != 0) { - // Pass, do not convert -+ } else if (ggml_is_quantized(type) && tensor_storage.n_dims <= 1) { -+ // Pass, do not convert. A 1-D weight is a per-channel scale (LayerNorm/RMSNorm gain), -+ // never a matmul weight, so quantizing it buys almost nothing and costs a lot: every -+ // channel of the block shares one scale and one min, and a gain vector has no reason -+ // to be locally smooth. Until now these survived only by accident, when their length -+ // did not divide the block size (FLUX q_norm/k_norm are [128] and 128 % 256 != 0) or -+ // when a name rule above happened to match. A model whose norms DO divide the block -+ // size, such as MiniMax-H3 with [5376] and 5376 % 256 == 0, had 106 norm scales -+ // crushed to 4 bits by a blanket --type. The result still loads and still renders a -+ // plausible image, so a "does it run" check passes it, while measured against a bf16 -+ // render of the same prompt and seed it is destroyed: PSNR 9.87 / SSIM 0.074 / -+ // LPIPS 0.981, against 22.22 / 0.841 / 0.292 with this rule in place. - } else if (ends_with(name, ".bias")) { - // Pass, do not convert - } else if (ends_with(name, ".scale")) { diff --git a/patches/0002-h3-cfg-scale-and-audio-vae-on-cpu.patch b/patches/0002-h3-cfg-scale-and-audio-vae-on-cpu.patch deleted file mode 100644 index d3660a889..000000000 --- a/patches/0002-h3-cfg-scale-and-audio-vae-on-cpu.patch +++ /dev/null @@ -1,121 +0,0 @@ -Stop MiniMax-H3 aborting on the default cfg-scale and on --vae-on-cpu - -Upstream: https://github.com/leejet/stable-diffusion.cpp/pull/1862 - -H3 aborts at any --cfg-scale above 1.0, and --cfg-scale defaults to 7.0, so a vid_gen command -that omits the flag crashes rather than rendering. Separately, --vae-on-cpu aborts on the -AUDIO VAE: ggml_conv_1d builds an F16 im2col and the CPU path asserts the kernel is F16, while -the audio VAE kernels arrive as F32. - -diff --git a/src/model/vae/ltx_audio_vae.hpp b/src/model/vae/ltx_audio_vae.hpp -index 3319a5c..e385aad 100644 ---- a/src/model/vae/ltx_audio_vae.hpp -+++ b/src/model/vae/ltx_audio_vae.hpp -@@ -182,6 +182,17 @@ namespace LTXV { - } - }; - -+ // ggml_conv_1d / ggml_conv_1d_dw build an F16 im2col, and the CPU backend -+ // additionally requires the kernel itself to be F16. F32 kernels (as loaded -+ // from F32 checkpoints) work on GPU backends but abort on CPU, so cast them -+ // in-graph here instead of degrading the weights on every backend. -+ static ggml_tensor* conv1d_kernel_for_backend(GGMLRunnerContext* runner_ctx, ggml_tensor* kernel) { -+ if (kernel->type == GGML_TYPE_F32 && sd_backend_is_cpu(runner_ctx->backend)) { -+ return ggml_cast(runner_ctx->ggml_ctx, kernel, GGML_TYPE_F16); -+ } -+ return kernel; -+ } -+ - static ggml_tensor* compute_log_mel_spectrogram(GGMLRunnerContext* runner_ctx, - ggml_tensor* waveform, - ggml_tensor* forward_basis, -@@ -218,7 +229,8 @@ namespace LTXV { - x = ggml_ext_pad_ext(ctx, runner_ctx->backend, x, static_cast(left_pad), 0, 0, 0, 0, 0, 0, 0); - } - -- auto frames = ggml_conv_1d(ctx, forward_basis, x, hop_length, 0, 1); -+ auto frames = ggml_conv_1d(ctx, conv1d_kernel_for_backend(runner_ctx, forward_basis), -+ x, hop_length, 0, 1); - GGML_ASSERT(frames->ne[0] == frame_count); - GGML_ASSERT(frames->ne[1] == stft_channels); - GGML_ASSERT(frames->ne[2] == channels * batch); -@@ -319,7 +331,8 @@ namespace LTXV { - int padding) { - auto ctx = runner_ctx->ggml_ctx; - GGML_ASSERT(x->ne[3] == 1); -- auto tiled = tile_depthwise_filter_1d(runner_ctx, filter, x->ne[1]); -+ auto tiled = conv1d_kernel_for_backend(runner_ctx, -+ tile_depthwise_filter_1d(runner_ctx, filter, x->ne[1])); - auto out = ggml_conv_1d_dw(ctx, tiled, x, stride, padding, 1); - return ggml_reshape_4d(ctx, out, out->ne[0], out->ne[1], 1, 1); - } -@@ -339,10 +352,11 @@ namespace LTXV { - return reversed; - } - -- static ggml_tensor* depthwise_conv_transpose1d(ggml_context* ctx, -+ static ggml_tensor* depthwise_conv_transpose1d(GGMLRunnerContext* runner_ctx, - ggml_tensor* x, - ggml_tensor* filter, - int stride) { -+ auto ctx = runner_ctx->ggml_ctx; - GGML_ASSERT(x->ne[2] == 1 && x->ne[3] == 1); - GGML_ASSERT(filter->ne[1] == 1); - GGML_ASSERT(filter->ne[2] == 1 && filter->ne[3] == 1); -@@ -364,7 +378,8 @@ namespace LTXV { - x_flat = ggml_reshape_3d(ctx, x_flat, time * stride, 1, channels); - - auto reversed_filter = reverse_1d_filter(ctx, filter); -- auto out = ggml_conv_1d(ctx, reversed_filter, x_flat, 1, static_cast(kernel_size - 1), 1); -+ auto out = ggml_conv_1d(ctx, conv1d_kernel_for_backend(runner_ctx, reversed_filter), -+ x_flat, 1, static_cast(kernel_size - 1), 1); - if (out->ne[0] > out_time) { - out = ggml_ext_slice(ctx, out, 0, 0, out_time); - } -@@ -404,7 +419,7 @@ namespace LTXV { - - auto x = ggml_reshape_3d(ctx, waveform, time, channels * batch, 1); - x = replicate_pad_1d(runner_ctx, x, pad, pad); -- x = depthwise_conv_transpose1d(ctx, x, filter, ratio); -+ x = depthwise_conv_transpose1d(runner_ctx, x, filter, ratio); - x = ggml_ext_slice(ctx, x, 0, pad_left, x->ne[0] - pad_right); - return ggml_reshape_3d(ctx, x, x->ne[0], channels, batch); - } -@@ -553,7 +568,7 @@ namespace LTXV { - } - - ggml_tensor* forward(GGMLRunnerContext* ctx, ggml_tensor* x) override { -- x = ggml_conv_1d(ctx->ggml_ctx, params["weight"], x, stride, padding, dilation); -+ x = ggml_conv_1d(ctx->ggml_ctx, conv1d_kernel_for_backend(ctx, params["weight"]), x, stride, padding, dilation); - if (bias) { - auto b = ggml_reshape_4d(ctx->ggml_ctx, params["bias"], 1, params["bias"]->ne[0], 1, 1); - x = ggml_add_inplace(ctx->ggml_ctx, x, b); -@@ -670,7 +685,7 @@ namespace LTXV { - int up_pad_right = up_pad * up_ratio + (up_kernel_size - up_ratio + 1) / 2; - - x = replicate_pad_1d(ctx, x, up_pad, up_pad); -- x = depthwise_conv_transpose1d(ctx->ggml_ctx, x, up_filter, up_ratio); -+ x = depthwise_conv_transpose1d(ctx, x, up_filter, up_ratio); - x = ggml_ext_slice(ctx->ggml_ctx, x, 0, up_pad_left, x->ne[0] - up_pad_right); - - x = act->forward(ctx, x); -diff --git a/src/stable-diffusion.cpp b/src/stable-diffusion.cpp -index f2b69cc..be74ea5 100644 ---- a/src/stable-diffusion.cpp -+++ b/src/stable-diffusion.cpp -@@ -4222,6 +4222,15 @@ struct GenerationRequest { - guidance->img_cfg = 1.f; - } - -+ // MiniMax-H3 is distilled with guidance baked in and has no negative -+ // prompt semantics; its empty uncond prompt cannot even be encoded -+ // (zero tokens), so any cfg != 1 would abort deep inside ggml. -+ if (sd_version_is_minimax_h3(sd_ctx->sd->version) && guidance->txt_cfg != 1.f) { -+ LOG_WARN("MiniMax-H3 is a distilled, CFG-free model; forcing cfg-scale from %.2f to 1.0", -+ guidance->txt_cfg); -+ guidance->txt_cfg = 1.f; -+ } -+ - if (guidance->img_cfg != guidance->txt_cfg) { - *use_uncond = true; - } diff --git a/patches/0003-h3-img-gen-mode-guard.patch b/patches/0003-h3-img-gen-mode-guard.patch deleted file mode 100644 index 811354dc3..000000000 --- a/patches/0003-h3-img-gen-mode-guard.patch +++ /dev/null @@ -1,31 +0,0 @@ -Fail with a message when MiniMax-H3 is run in img_gen mode - -Upstream: https://github.com/leejet/stable-diffusion.cpp/pull/1863 - -Only generate_video computes H3's audio length, so an H3 checkpoint reaching generate_image is -guaranteed to die on GGML_ASSERT(!audio_input_cache.empty()), as a core dump with no hint that ---mode vid_gen is what is missing. - -diff --git a/src/stable-diffusion.cpp b/src/stable-diffusion.cpp -index f2b69cc..5986163 100644 ---- a/src/stable-diffusion.cpp -+++ b/src/stable-diffusion.cpp -@@ -5584,6 +5584,18 @@ SD_API bool generate_image(sd_ctx_t* sd_ctx, - return false; - } - -+ // MiniMax-H3 is video-only. Its denoiser always splits the packed latent into a video and an -+ // audio half, and only generate_video ever computes the audio length, so reaching this -+ // function with an H3 checkpoint is guaranteed to die on -+ // GGML_ASSERT(!audio_input_cache.empty()) with a core dump, after the several minutes it -+ // takes to load the weights, and with nothing in the output pointing at the missing --mode. -+ // (The AnimateDiff path below routes vid_gen back through here, but that is SD1.5 plus a -+ // motion module, never H3.) -+ if (sd_version_is_minimax_h3(sd_ctx->sd->version)) { -+ LOG_ERROR("MiniMax-H3 is a video model and cannot be run in img_gen mode; use --mode vid_gen"); -+ return false; -+ } -+ - sd_ctx->sd->reset_cancel_flag(); - - int64_t t0 = ggml_time_ms(); diff --git a/patches/README.md b/patches/README.md deleted file mode 100644 index 1eae60b65..000000000 --- a/patches/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Patches applied to the published prebuilts - -`.github/workflows/unsloth-sd-prebuilt.yml` builds leejet's source **at a published release -tag**, aged for at least six hours, rather than this fork's `master`. That is deliberate: what we -publish should be traceable to a specific upstream release rather than to whatever a fork happened -to contain that day. - -Every `*.patch` in this directory is applied to that tree, in filename order, before the build. A -release built with a non-empty patch set is tagged `-u`, where `` is the -first seven hex digits of the sha256 of the concatenated patch files. So: - -- an unpatched build keeps the plain upstream tag, exactly as before, -- changing, adding or removing a patch changes the tag, which is what makes the pipeline rebuild - and republish rather than see an existing release and skip, -- the tag on the box says whether the box is stock, and which patch set it carries. - -`sd-prebuilt-manifest.json` in each release records the patch filenames and the id. - -## Rules - -**Every patch must be open upstream.** These exist to close the gap between "fixed" and -"released", not to carry a private fork. Put the upstream pull request in the header comment of -the patch file. - -**A patch that no longer applies fails the build.** This is intentional, not a bug to route -around. `git apply --check` runs on all of them before any is applied, and the run stops with the -patch name. That happens for exactly two reasons: upstream merged it, in which case delete the -file, or upstream changed the surrounding code, in which case refresh the patch against the new -tag and re-verify it. - -**Delete on merge.** Once the fix ships in an upstream release the patch is dead weight, and -leaving it in place means the next release silently carries a duplicate of code upstream already -has. - -## Current set - -| patch | upstream PR | what it fixes | -|---|---|---| -| `0001-spare-1d-norm-weights-from-blanket-quant.patch` | leejet/stable-diffusion.cpp#1861 | a blanket `--type` quantizes 1-D norm gains whose length divides the block size, which silently destroys MiniMax-H3 output (LPIPS 0.981 against its own bf16 render) | -| `0002-h3-cfg-scale-and-audio-vae-on-cpu.patch` | leejet/stable-diffusion.cpp#1862 | H3 aborts on the default `--cfg-scale 7.0`, and aborts again on `--vae-on-cpu` because the audio VAE reaches the CPU conv1d path with F32 kernels | -| `0003-h3-img-gen-mode-guard.patch` | leejet/stable-diffusion.cpp#1863 | an H3 checkpoint run without `--mode vid_gen` core dumps on a raw ggml assert instead of saying which flag is missing | diff --git a/scripts/unsloth/additive_merge.py b/scripts/unsloth/additive_merge.py new file mode 100644 index 000000000..3ecf17c41 --- /dev/null +++ b/scripts/unsloth/additive_merge.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +"""Resolve merge conflicts that are provably pure add/add, and only those. + +The conflict that keeps breaking the nightly is always the same shape: upstream +registers a new architecture in a fallthrough group and one of our pinned PRs +registers another one at the same spot. Neither side changed a line the other +side touched -- both only added, at a place where the merge base had nothing. +The union of the two additions is the resolution, and it is mechanical. + +Anything else is left conflicted and reported. In particular a conflict where +the merge base is non-empty means at least one side *edited* shared text, and +picking a side or unioning them is a guess. This script never guesses. + +Reads a conflicted work tree, writes resolutions in place, exits 0 if every +conflict in every file was resolved and 1 otherwise. `--report` emits JSON +describing what it did for the caller to quote in a PR body. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +OURS = "<<<<<<< " +BASE = "||||||| " +SEP = "=======" +THEIRS = ">>>>>>> " + + +class Unresolvable(Exception): + """A conflict this script is not allowed to decide.""" + + +def parse_conflicts(lines: list[str]) -> list[tuple[int, int, list[str], list[str], list[str]]]: + """Split diff3-style content into (start, end, ours, base, theirs) regions. + + Raises Unresolvable if the markers do not nest as diff3 promises, which + means the file is not in the state we think it is. + """ + regions = [] + i = 0 + n = len(lines) + while i < n: + if not lines[i].startswith(OURS): + i += 1 + continue + start = i + ours: list[str] = [] + base: list[str] = [] + theirs: list[str] = [] + cur = ours + seen_base = False + i += 1 + while True: + if i >= n: + raise Unresolvable(f"unterminated conflict starting at line {start + 1}") + ln = lines[i] + if ln.startswith(OURS): + raise Unresolvable(f"nested conflict marker at line {i + 1}") + if ln.startswith(BASE): + cur = base + seen_base = True + elif ln.rstrip("\n") == SEP: + cur = theirs + elif ln.startswith(THEIRS): + i += 1 + break + else: + cur.append(ln) + i += 1 + if not seen_base: + # Without the base section we cannot tell add/add from edit/edit. + raise Unresolvable( + f"conflict at line {start + 1} has no base section; " + "re-checkout with --conflict=diff3" + ) + regions.append((start, i, ours, base, theirs)) + return regions + + +def nonblank(lines: list[str]) -> list[str]: + return [ln.strip() for ln in lines if ln.strip()] + + +def resolve_region(ours: list[str], base: list[str], theirs: list[str]) -> list[str]: + """Return the union, or raise if this region is not a pure add/add.""" + if nonblank(base): + raise Unresolvable( + "merge base is not empty, so at least one side edited existing text" + ) + if not nonblank(ours) or not nonblank(theirs): + # One side added and the other added nothing: git would not have + # conflicted, so seeing this means the region is not what we expect. + raise Unresolvable("one side of the conflict is empty") + if ours == theirs: + # Both sides added byte-identical text; one copy is the resolution. + return list(ours) + shared = set(nonblank(ours)) & set(nonblank(theirs)) + if shared: + # Overlapping content is the signature of one construct added twice, + # not two independent additions. Unioning it would duplicate code. + raise Unresolvable( + "both sides add the same line(s), so this is one change made twice: " + + ", ".join(sorted(shared)[:3]) + ) + # Upstream first, then ours: the same order a human repin produces. + return list(theirs) + list(ours) + + +def decide_file(path: Path) -> tuple[str, list[dict]]: + """Return the resolved content and a per-hunk record, without writing.""" + lines = path.read_text(encoding="utf-8", errors="surrogateescape").splitlines(keepends=True) + regions = parse_conflicts(lines) + if not regions: + raise Unresolvable("no conflict markers found") + + out: list[str] = [] + prev = 0 + hunks = [] + for start, end, ours, base, theirs in regions: + resolution = resolve_region(ours, base, theirs) + out.extend(lines[prev:start]) + out.extend(resolution) + prev = end + hunks.append( + { + "ours": "".join(ours), + "theirs": "".join(theirs), + "resolution": "".join(resolution), + } + ) + out.extend(lines[prev:]) + return "".join(out), hunks + + +def conflicted_files(repo: Path) -> list[str]: + r = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=U"], + cwd=repo, capture_output=True, text=True, check=True, + ) + return [f for f in r.stdout.splitlines() if f] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo", default=".", help="conflicted work tree") + ap.add_argument("--report", help="write a JSON report here") + ap.add_argument("--dry-run", action="store_true", help="decide, but do not write") + args = ap.parse_args() + + repo = Path(args.repo).resolve() + files = conflicted_files(repo) + report: dict = {"resolved": [], "refused": [], "ok": False} + + if not files: + report["refused"].append({"file": "-", "reason": "no conflicted files"}) + + # Decide every file before writing any of them. A refusal on the second + # file must not leave the first one already rewritten on disk: the caller + # would then be looking at a tree that is neither the conflict nor the + # resolution. + pending: list[tuple[Path, str]] = [] + for f in files: + try: + content, hunks = decide_file(repo / f) + pending.append((repo / f, content)) + report["resolved"].append({"file": f, "hunks": hunks}) + except Unresolvable as e: + report["refused"].append({"file": f, "reason": str(e)}) + except OSError as e: + report["refused"].append({"file": f, "reason": f"cannot read: {e}"}) + + report["ok"] = bool(files) and not report["refused"] + + if report["ok"] and not args.dry_run: + for p, content in pending: + p.write_text(content, encoding="utf-8", errors="surrogateescape") + subprocess.run(["git", "add", "--"] + files, cwd=repo, check=True) + + for r in report["resolved"]: + print(f"resolved {r['file']}") + for r in report["refused"]: + print(f"refused {r['file']}: {r['reason']}", file=sys.stderr) + + if args.report: + Path(args.report).write_text(json.dumps(report, indent=2)) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/unsloth/assemble_metadata.py b/scripts/unsloth/assemble_metadata.py index bb65ed29b..e095bcf4a 100755 --- a/scripts/unsloth/assemble_metadata.py +++ b/scripts/unsloth/assemble_metadata.py @@ -39,15 +39,19 @@ def main() -> int: ap.add_argument("--tag", required = True) ap.add_argument("--source-repo", required = True) ap.add_argument("--commit", required = True) - # A patched build is tagged -u, so the two differ exactly when patches were - # applied. Both are recorded: the tag identifies the artifact, upstream_tag and patches say - # what it is made of, so a consumer can tell a stock build from a patched one without - # unpacking it. See patches/README.md. + # Our build is tagged -u, so the two differ whenever this tree carries + # anything past that release, which it always does. Both are recorded: the tag identifies the + # artifact, upstream_tag says which upstream release the tree descends from, and commit says + # exactly what was compiled, so a consumer can tell what it has without unpacking it. + # + # --patches is retained and always empty. The build no longer applies a patch set at build + # time; the fixes are commits in this repository. Keeping the field means existing manifest + # readers do not have to change. ap.add_argument("--upstream-tag", default = "") ap.add_argument( "--patches", default = "", - help = "comma-separated patch filenames applied to the upstream tree (empty = stock)", + help = "retained for manifest compatibility; always empty now that fixes are commits", ) ap.add_argument("--dist", required = True, help = "dir holding the sd-*.zip bundles") ap.add_argument("--out", required = True, help = "dir to write the metadata json into") diff --git a/scripts/unsloth/package_bundle.py b/scripts/unsloth/package_bundle.py index 4886a4dc2..6c87db466 100755 --- a/scripts/unsloth/package_bundle.py +++ b/scripts/unsloth/package_bundle.py @@ -47,6 +47,16 @@ def _env(name: str) -> str: return val +def _is_runtime_lib(name: str) -> bool: + lowered = name.lower() + if Path(lowered).suffix in _LIB_SUFFIXES: + return True + # Versioned ELF sonames: libcudart.so.12, libcublas.so.12.8.4.1. Path.suffix sees + # ".12" and would drop these, but the name has to stay exactly as DT_NEEDED spells + # it, so match the ".so." infix instead of renaming the file. + return ".so." in lowered + + def _collect(bin_dir: Path) -> list[Path]: """The binaries + sibling runtime libs to ship. Recurse so a nested bin/ layout (some generators emit build/bin/, some build/bin/Release/) is still captured.""" @@ -54,7 +64,7 @@ def _collect(bin_dir: Path) -> list[Path]: for p in sorted(bin_dir.rglob("*")): if not p.is_file(): continue - if p.name in _BINARIES or p.suffix.lower() in _LIB_SUFFIXES: + if p.name in _BINARIES or _is_runtime_lib(p.name): found.append(p) return found diff --git a/scripts/unsloth/pr-set.json b/scripts/unsloth/pr-set.json new file mode 100644 index 000000000..cc0cd9457 --- /dev/null +++ b/scripts/unsloth/pr-set.json @@ -0,0 +1,24 @@ +{ + "_doc": [ + "unslothai/stable-diffusion.cpp PRs to merge into the nightly prebuilds, on top of this", + "repository's own tree. Each entry pins an exact commit -- copy the url of the commit you", + "reviewed from the PR's commits tab:", + " https://github.com/unslothai/stable-diffusion.cpp/pull/8/commits/<40-hex-sha>", + "Only that commit is built, even if the author keeps pushing; update the pin to take newer code.", + "", + "Only PRs in THIS repository may be pinned. To carry a fix that exists as an upstream pull", + "request, vendor it here first: open it as a PR against this repository and pin that. The", + "build never fetches from another repository.", + "", + "An entry is a bare url string (required) or {\"url\": ..., \"required\": false}. A required pin", + "that is no longer open fails the build rather than silently shipping without it, because", + "dropping a pin changes the tag and would publish a quietly different binary under a new name.", + "Mark an entry optional to let it rot away instead.", + "", + "Merging a pinned PR into master drops it from this list's job, not from the build: the base", + "here is this repository's own tree, so a merged fix is already in it. That is the one place", + "this differs from unslothai/llama.cpp, where the base is a pristine upstream release and every", + "one of our changes has to stay pinned. Delete the pin once it is merged." + ], + "prs": [] +}