From 692a7c85210c1dfc384f4dca3df69278cae482b3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 8 Aug 2026 15:50:57 +0000 Subject: [PATCH 1/4] CI: build the prebuilts from this repository, not from a fetched tarball The pipeline resolved an upstream release tag, fetched that tree, applied the patch set in patches/, and built the result. That made every published binary depend on a foreign repository at build time and left three fixes living as diffs that had to be kept applying to a tree we do not control. All three are already commits here, merged as PRs 2, 3 and 4. So build this checkout. patches/ is deleted; there is nothing left to re-apply. The tag still names the upstream release the tree descends from, read from our own history rather than from an API, with the head sha as the -u suffix: master-813-bfbef5b-u22e2879 Two details worth keeping. HIGHEST reachable release, not nearest. git describe answers "nearest", and on a merge-shaped history that is wrong: this tree reaches master-813 through a merge 73 commits back and master-811 on its own line 13 commits back, so describe names the build after 811 and understates what it contains. The -u suffix is now always present, which is what it should have been. Studio's installer treats a -u tag as mirror-only and goes straight to this repository's releases instead of trying an upstream download that is guaranteed to 404. A build of ours is never a stock upstream build, so it should never carry a bare upstream tag. Supply-chain aging is kept and re-pointed: it now guards the age of the upstream release the tree descends from rather than the moment a release appeared. Our own commits on top are reviewed here, so they are not what the delay is for. assemble_metadata keeps its --patches flag, always empty, so existing manifest readers do not have to change. --- .github/workflows/unsloth-sd-prebuilt.yml | 136 ++++++++---------- ...e-1d-norm-weights-from-blanket-quant.patch | 34 ----- ...02-h3-cfg-scale-and-audio-vae-on-cpu.patch | 121 ---------------- patches/0003-h3-img-gen-mode-guard.patch | 31 ---- patches/README.md | 41 ------ scripts/unsloth/assemble_metadata.py | 14 +- 6 files changed, 70 insertions(+), 307 deletions(-) delete mode 100644 patches/0001-spare-1d-norm-weights-from-blanket-quant.patch delete mode 100644 patches/0002-h3-cfg-scale-and-audio-vae-on-cpu.patch delete mode 100644 patches/0003-h3-img-gen-mode-guard.patch delete mode 100644 patches/README.md diff --git a/.github/workflows/unsloth-sd-prebuilt.yml b/.github/workflows/unsloth-sd-prebuilt.yml index 56ce42609..2ac8585b1 100644 --- a/.github/workflows/unsloth-sd-prebuilt.yml +++ b/.github/workflows/unsloth-sd-prebuilt.yml @@ -9,28 +9,34 @@ name: Unsloth SD prebuilt (CPU/Apple) # # 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. +# resolve -- stamp a source tarball from THIS repository at the requested ref, +# 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). # 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). # -# 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 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: 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 +50,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 +65,61 @@ 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 }} 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. # - # --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 - 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 + # 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 + } + + # 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. + HEAD_SHA7="$(git rev-parse --short=7 HEAD)" + TAG="${UPSTREAM_TAG}-u${HEAD_SHA7}" + AHEAD="$(git rev-list --count "refs/tags/${UPSTREAM_TAG}..HEAD")" + echo "building this repository at ${HEAD_SHA7} (${AHEAD} commits past ${UPSTREAM_TAG}) -> ${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, which carries the head sha, so a new commit 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 +140,7 @@ jobs: { echo "tag=$TAG" echo "upstream_tag=$UPSTREAM_TAG" - echo "patch_list=$PATCH_LIST" + echo "ahead=$AHEAD" echo "commit=$COMMIT" echo "exists=$EXISTS" echo "source_artifact=$SRC_ARTIFACT" @@ -246,7 +231,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 @@ -311,7 +296,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 @@ -367,8 +352,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 '' \ + --source-repo "$GITHUB_REPOSITORY" \ --commit '${{ needs.resolve.outputs.commit }}' \ --dist dist --out dist \ --publish-repo "$GITHUB_REPOSITORY" @@ -403,11 +388,12 @@ 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)." + 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 [ "$(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/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") From 1b6010222b1c5349a2eed7546e29c10edb9a4c08 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 8 Aug 2026 16:26:39 +0000 Subject: [PATCH 2/4] CI: carry pinned PRs into the prebuilts, like unslothai/llama.cpp does The prebuilts now build this tree, but that alone only ships what is already merged. The llama.cpp pipeline exists to ship a reviewed mix: an aged upstream base plus a set of pull requests pinned to exact commits, merged at build time. This adds the same mechanism here. scripts/unsloth/pr-set.json lists PRs to merge, each pinned to a 40-hex commit copied from the PR's commits tab. Only that commit is built, so an author pushing more commits cannot change what the nightly ships. Non-open required pins fail the build rather than silently publishing without them, because dropping a pin changes the tag and would ship a quietly different binary under a new name. additive_merge.py, vendored from unslothai/llama.cpp, resolves the one conflict shape that is mechanical (both sides only added, at a place the merge base had nothing) and refuses to guess at anything else. One deliberate difference from llama.cpp. There the base is a pristine upstream release, so every Unsloth change has to stay pinned and open, and merging one into fork master drops it from the nightly. Here the base is our own tree, so a merged fix is simply in it and its pin is deleted. That is why the three MiniMax-H3 fixes need no pins. Only PRs in this repository may be pinned. To carry a fix that exists as an upstream pull request, vendor it here as a PR first and pin that. The build fetches from nowhere else. The tag suffix absorbs the set: with no pins it is the head sha, with pins it hashes the pinned number:sha pairs together with the head sha, so a repin or a reorder yields a new tag and a rebuild while an unchanged set still matches an existing release and skips. The existing -u shape is kept rather than llama.cpp's -mix-, because Studio's installer keys mirror-only resolution on it. Verified locally against this tree: the schema gate passes, an empty set yields master-813-bfbef5b-u692a7c8, a one-pin set yields a different suffix, and both match the installer's mirror-only pattern. --- .github/workflows/unsloth-sd-prebuilt.yml | 114 +++++++++++-- scripts/unsloth/additive_merge.py | 196 ++++++++++++++++++++++ scripts/unsloth/pr-set.json | 24 +++ 3 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 scripts/unsloth/additive_merge.py create mode 100644 scripts/unsloth/pr-set.json diff --git a/.github/workflows/unsloth-sd-prebuilt.yml b/.github/workflows/unsloth-sd-prebuilt.yml index 2ac8585b1..20b60ffa2 100644 --- a/.github/workflows/unsloth-sd-prebuilt.yml +++ b/.github/workflows/unsloth-sd-prebuilt.yml @@ -9,9 +9,10 @@ name: Unsloth SD prebuilt (CPU/Apple) # # Shape mirrors unslothai/llama.cpp's prebuilt pipeline, simplified for a 5-way # CPU/Apple matrix: -# resolve -- stamp a source tarball from THIS repository at the requested ref, -# name it after the upstream release the tree descends from -# (supply-chain aged), decide if our release already exists. +# 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). # assemble -- fingerprint gate, sha256 + manifest, coverage gate, atomic @@ -20,10 +21,18 @@ name: Unsloth SD prebuilt (CPU/Apple) # # Asset names match the Studio installer's resolve_release_asset. # -# We build THIS repository's tree, not a tarball fetched from anywhere else. Our 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. +# 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: @@ -66,6 +75,7 @@ jobs: tag: ${{ steps.r.outputs.tag }} upstream_tag: ${{ steps.r.outputs.upstream_tag }} 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 }} @@ -110,16 +120,89 @@ jobs: exit 1 } - # Always -u: the tree is ours, never a stock upstream one, and the suffix is what + # 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. + # + # 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 + 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. + # 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)" - TAG="${UPSTREAM_TAG}-u${HEAD_SHA7}" + 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")" - echo "building this repository at ${HEAD_SHA7} (${AHEAD} commits past ${UPSTREAM_TAG}) -> ${TAG}" + 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 tag, which carries the head sha, so a new commit republishes instead of matching. + # 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 @@ -141,6 +224,7 @@ jobs: echo "tag=$TAG" echo "upstream_tag=$UPSTREAM_TAG" echo "ahead=$AHEAD" + echo "pins=$PIN_LIST" echo "commit=$COMMIT" echo "exists=$EXISTS" echo "source_artifact=$SRC_ARTIFACT" @@ -352,7 +436,7 @@ jobs: python3 tooling/scripts/unsloth/assemble_metadata.py \ --tag '${{ needs.resolve.outputs.tag }}' \ --upstream-tag '${{ needs.resolve.outputs.upstream_tag }}' \ - --patches '' \ + --patches '${{ needs.resolve.outputs.pins }}' \ --source-repo "$GITHUB_REPOSITORY" \ --commit '${{ needs.resolve.outputs.commit }}' \ --dist dist --out dist \ @@ -392,9 +476,13 @@ jobs: COMMIT='${{ needs.resolve.outputs.commit }}' REPO="$GITHUB_REPOSITORY" 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 fi 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/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": [] +} From 994faa9b214b9209b9a5d7ec9df03b5d1b21393a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 8 Aug 2026 17:55:36 +0000 Subject: [PATCH 3/4] CI: publish a Linux CUDA prebuilt The CPU/Apple matrix rests on the assumption that a GPU host runs the diffusers path instead. MiniMax-H3 breaks that assumption: its diffusers path wants ~68.5 GB of VRAM, so every consumer card falls back to the GGUF engine, and on Linux that engine had no accelerated build to fall back to. Measured on one box, 65 s/step at 320x192 across 96 CPU threads, against 21.5 s/step at 960x544 from a local CUDA build of the same tag: four hours per clip versus eleven minutes. The new leg is continue-on-error and is not in the coverage gate, which still lists exactly the five CPU/Apple assets. assemble collects bundles by the sd-*-bin-* pattern, so the CUDA asset is published when it built and simply absent when it did not; a broken CUDA toolchain can never hold back the assets Studio falls back to. sm_75 through sm_120, which is the first toolkit able to emit sm_100 and sm_120 and covers everything from Turing up. The CUDA runtime libraries are copied in beside the binaries with an $ORIGIN rpath, because a host with an NVIDIA driver does not necessarily have a CUDA runtime installed and we must not lean on the copies torch keeps. package_bundle matched runtime libraries on Path.suffix, which reads ".12" for libcudart.so.12 and dropped it. It now matches the ".so." infix as well, so a versioned soname ships under the exact name DT_NEEDED spells. --- .github/workflows/unsloth-sd-prebuilt.yml | 125 ++++++++++++++++++++-- scripts/unsloth/package_bundle.py | 12 ++- 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/.github/workflows/unsloth-sd-prebuilt.yml b/.github/workflows/unsloth-sd-prebuilt.yml index 20b60ffa2..6528a2ccf 100644 --- a/.github/workflows/unsloth-sd-prebuilt.yml +++ b/.github/workflows/unsloth-sd-prebuilt.yml @@ -1,23 +1,29 @@ # 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: +# 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. # @@ -326,6 +332,111 @@ 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: '["nvcc", "cudart", "cublas", "cublas_dev", "thrust"]' + + - 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_LIB="${{ steps.cuda-toolkit.outputs.CUDA_PATH }}/lib64" + sudo apt-get update -qq && sudo apt-get install -y -qq patchelf + for so in libcudart.so.12 libcublas.so.12 libcublasLt.so.12; do + src="$(find "$CUDA_LIB" -maxdepth 1 -name "${so}*" | sort | tail -1)" + [ -n "$src" ] || { echo "ERROR: $so not found under $CUDA_LIB" >&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 @@ -393,7 +504,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: 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 From dec5bf42e361a559494abf4186b5729a6ffc19c5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 8 Aug 2026 18:10:19 +0000 Subject: [PATCH 4/4] CI: name the cuBLAS packages the way apt does cuda-toolkit installs sub-packages as cuda--12-8, and cuBLAS does not use that prefix, so apt could not find cuda-cublas-12-8 or cuda-cublas_dev-12-8 and the leg died before it compiled anything. They belong in non-cuda-sub-packages as libcublas and libcublas-dev. cudart-dev joins the list too, since cudart on its own is the runtime and carries no headers to compile against. The libcublas debs land in the system multiarch directory rather than under the toolkit root, so the bundling step now searches both. --- .github/workflows/unsloth-sd-prebuilt.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unsloth-sd-prebuilt.yml b/.github/workflows/unsloth-sd-prebuilt.yml index 6528a2ccf..06c181ee6 100644 --- a/.github/workflows/unsloth-sd-prebuilt.yml +++ b/.github/workflows/unsloth-sd-prebuilt.yml @@ -381,7 +381,12 @@ jobs: with: cuda: "12.8.1" method: "network" - sub-packages: '["nvcc", "cudart", "cublas", "cublas_dev", "thrust"]' + # 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 @@ -407,11 +412,13 @@ jobs: # 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_LIB="${{ steps.cuda-toolkit.outputs.CUDA_PATH }}/lib64" + 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_LIB" -maxdepth 1 -name "${so}*" | sort | tail -1)" - [ -n "$src" ] || { echo "ERROR: $so not found under $CUDA_LIB" >&2; exit 1; } + 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