perf: run large CPU matmuls on Accelerate on Apple Silicon by danielhanchen · Pull Request #29 · unslothai/stable-diffusion.cpp · GitHub
Skip to content

perf: run large CPU matmuls on Accelerate on Apple Silicon - #29

Merged
danielhanchen merged 22 commits into
masterfrom
perf/mac-cpu-blas
Oct 12, 2026
Merged

danielhanchen merged 22 commits into
masterfrom
perf/mac-cpu-blas

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Stacked on #28 (base perf/cpu-isa): the speedups below include #28's weight repacking.

Problem

When Studio runs a GGUF image model through sd.cpp on a Mac on the CPU backend (the fallback after Metal aborts on an op it cannot run, or --backend cpu), it uses the arm64 prebuilt's CPU path. #28 adds runtime CPU variants and weight repacking, but on Apple Silicon the apple_m1 variant ran about 40% slower than the current static binary, so the macOS arm64 leg was left alone. Macs therefore got almost nothing from #28.

There were two reasons:

  • ggml's apple_m1 variant is built with DOTPROD and no FP16_VECTOR_ARITHMETIC. The static build already gets NEON | ARM_FMA | FP16_VA | DOTPROD from Apple clang's default target, so the variant was a downgrade. No -mcpu change is needed.
  • Apple builds of ggml already compile in the Accelerate BLAS backend (GGML_BLAS is on by default on Apple), but sd.cpp only ever scheduled the CPU backend, so Accelerate was never used for matmuls.

Change

  • src/core/compute_workspace.*, src/core/ggml_extend_backend.*, src/core/ggml_runner.cpp: a CPU-only workspace now schedules with ggml's BLAS backend ahead of the CPU backend whenever the build includes it. The scheduler gives BLAS the MUL_MATs it supports: large products with F32 activations whose weights sit in host buffers. Weights that CPU bundles: runtime-selected ggml CPU variants and repacked quantized weights (1.3x to 3x faster on CPU) #28 repacked are not in host buffers, so their matmuls stay on the repack kernels. SD_CPU_BLAS=0 turns this off. Builds without GGML_BLAS (Linux, Windows) are unchanged. The first few graphs log cpu blas: N of M graph nodes run on BLAS so you can see when it engages.
  • unsloth-sd-prebuilt.yml: the macOS arm64 leg stays a single static binary with no CPU variants and now sets -DGGML_BLAS=ON -DGGML_BLAS_VENDOR=Apple explicitly. The macOS Intel leg gets -DGGML_BLAS=OFF, which keeps its CPU path as it is today, because the BLAS path was only measured on Apple Silicon.
  • With CPU bundles: runtime-selected ggml CPU variants and repacked quantized weights (1.3x to 3x faster on CPU) #28 in the tree, weight repacking also works in the static macOS binary: 321 tensors are repacked for q8_0 and q4_0, and 251 for q4_K.

Results

Hardware: GitHub macos-15 runners (Apple M1, virtual, 3 cores). Backend: CPU (--backend cpu). Base is fork master afac7a7c, built exactly like the arm64 release leg (static, Metal embedded, GGML_NATIVE=OFF). Head is this branch built the same way. Each measurement is a fresh process, arms run in ABBA order, threads are left at sd.cpp's default (3). Ratios are base/head computed per repeat on the same runner; the table shows the median with [min, max].

SD-Turbo, 512x512, 2 steps. 9 paired repeats across 3 runners:

type sampling base s sampling head s sampling speedup generate speedup decode speedup PSNR vs f32 ref, base -> head
q8_0 19.58 15.60 1.27 [1.17, 1.56] 1.08 1.00 40.11 -> 38.89
q4_K 25.36 17.70 1.26 [1.13, 2.03] 1.11 1.00 23.84 -> 24.09
q4_0 21.24 17.22 1.25 [1.13, 1.40] 1.09 1.02 21.24 -> 21.13
f16 24.75 17.74 1.17 [0.98, 1.96] 1.11 1.02 47.48 -> 50.22

Noise floor: on f16 with SD_CPU_BLAS=0, head produces an image bit-identical to base. That arm's timing ratio was 1.13 [0.67, 1.46], so the f16 timing is a tie. The quantized types have minimums above 1.1 on every repeat.

Contribution of each part to sampling speedup (median):

type repack only BLAS only both
q8_0 1.09 1.09 1.27
q4_0 1.14 1.02 1.25
q4_K 1.09 1.36 1.26

Z-Image-Turbo Q4_K_M, with Qwen3-4B Q4_K_M as the text encoder and the ae VAE, 256x256, 2 steps. 3 paired repeats:

base head speedup
sampling s 221.1 101.5 2.41 [1.99, 2.66]
generate s 248.5 124.8 2.13 [1.83, 2.35]
decode s 7.9 10.1 0.88 [0.71, 1.00]

The Z-Image head image is 35.7 dB PSNR against base.

The VAE decode at 512 (about 30 s of the roughly 55 s SD-Turbo total) is unchanged, so the end-to-end gain on SD-Turbo is smaller than the sampling gain.

Tests

  • f32 reference: SD-Turbo with --type f32, same prompt and seed. f16 and q4_K move closer to it. q4_0 moves -0.1 dB. q8_0 moves -1.2 dB, but the BLAS-only q8_0 arm, which does strictly more precise math (f32 activations), also scores lower than base (39.57 vs 40.11). At this level, single-image PSNR is reacting to small perturbations, not to precision.
  • Engagement: every head log shows REPACK tensors and cpu blas: N of M graph nodes. With SD_CPU_BLAS=0 the count is 0 and the f16 output matches base bit for bit.
  • Images are deterministic across runners: the three runners produced identical PSNRs.

Not covered

  • Real M-series hardware with performance and efficiency cores, and M2 and later (i8mm). Only the 3-core virtual M1 runner was measured.
  • Metal: on GitHub runners the Metal path segfaults after compiling the embedded library, so it was not measured. The Metal build also initializes the Metal device and compiles its library at startup even with --backend cpu. That took about 35 s on the first run and was cached afterwards.
  • Peak RSS with BLAS. The BLAS backend keeps an F32 dequantization buffer sized to the largest non-repacked weight it handles.
  • Z-Image decode was a little slower with BLAS (median 0.88, 3 repeats, 1 runner).
  • Qwen-Image Q2_K: it does not fit in the 7 GB runner.
  • Intel Macs: they keep today's CPU path.
  • The release workflow change itself has not run in the release workflow; the bench used the same flags. The arm64 leg still names the macos-14 runner, which this PR does not change.

The Linux x64, Linux arm64, macOS x86_64 and Windows CPU bundles were static
-DGGML_NATIVE=OFF builds: AVX2/FMA on x86 (no AVX512, VNNI or AMX, and SIGILL
on pre-AVX2 CPUs) and plain Armv8.0 on Linux arm64 (no dotprod or i8mm).
Build them with GGML_BACKEND_DL + GGML_CPU_ALL_VARIANTS so ggml loads the best
libggml-cpu-<variant> for the host at startup (Linux arm64 with GCC 14, which
accepts +sme). Linux libraries get DT_RPATH $ORIGIN like the CUDA bundles,
macOS ones @loader_path only. A gate checks every finished zip ships the
floor variant, keeps no build-tree paths, and loads a variant from itself.
ggml's CPU backend converts Q4_0 / Q4_K / Q2_K / Q5_K / Q6_K / IQ4_NL weights to
interleaved layouts (and Q4_0 / Q4_K / Q8_0 ... to AMX tiles on AMX hosts) when
they are uploaded into its extra buffer types, so every matmul reading them runs
a wider GEMM kernel. sd.cpp put all weights in the plain CPU buffer, so these
kernels never ran. Linear tags its quantized weight as matmul-only, and the
model manager places tagged weights in the first extra buffer type whose
supports_op accepts the matmul. LoRA on such a weight is added to the output
instead of folded into the weight; 'immediately' LoRA mode turns repacking off.
SD_CPU_REPACK=0 disables it.
The auto LoRA mode looked only at the file's weight types, so an f16 file
converted with --type q4_0 chose 'immediately', which turned CPU repacking off
for every such run even with no LoRA. On CPU, count the conversion target as
quantized and apply LoRAs at runtime, which works on repacked weights.
Measured on GitHub's Azure EPYC 9V74 (Zen 4: AVX512 without AVX-VNNI), the
AVX512 variants ran sd.cpp 15 to 50% slower than the AVX2 one (MSVC and
clang alike), while on Zen 5 (Strix Halo) and Emerald Rapids (AVX512 + AMX),
both with AVX-VNNI, they were the fastest. Score an AVX512 variant 0 without
AVX-VNNI so those hosts load the AVX2 variant, as the static build ran.
AMX steps the shared 2D weight per activation batch (wrong batches, out-of-bounds writes) and
both AMX and CPU_REPACK read strided views as contiguous rows. Make the activation contiguous and
flatten every batch dim into rows before the matmul on a possibly repacked weight.
…uffer

A repack buffer has no read path and holds the repacked layout, so a destination sharing its source
with a repacked first destination is read from the file in its own batch.
ggml_backend_load_best opens every matching libggml-<name>-*.so before scoring it, so the current
directory search ran any such library placed where sd-cli or sd-server is launched. Keep
GGML_BACKEND_DIR, the executable directory and explicit search paths.
A CPU-only workspace now schedules with ggml's BLAS backend (Accelerate on macOS) ahead of the CPU backend when the build has GGML_BLAS. The scheduler gives it the MUL_MATs it supports (large products with F32 activations and host-buffer weights); repacked weights are not host buffers and stay on the CPU repack kernels. SD_CPU_BLAS=0 turns it off. Builds without GGML_BLAS are unchanged.
The arm64 leg stays one static binary (no runtime CPU variants) and now names GGML_BLAS=ON / Apple explicitly, since sd.cpp's CPU path schedules large matmuls onto it. The Intel leg builds without BLAS so its CPU path is unchanged; the BLAS path was only measured on Apple Silicon.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T07:10:13.007150Z ad62867 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a38052cd6b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +55 to 56
if (uses_blas() && node->op == GGML_OP_MUL_MAT && ggml_backend_supports_op(blas_backend_, node)) {
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve eval callbacks when enabling the BLAS scheduler

When BLAS is available and a CPU graph contains a supported MUL_MAT, this branch forces execution through the scheduler, whose path in GGMLRunner::execute_segment explicitly ignores sd_get_backend_eval_callback(). Consequently, the documented --imat-out/enable_imatrix_collection() flow on the new macOS arm64 prebuilt does not invoke collect_imatrix for these graphs and can silently save an empty or incomplete importance matrix; disable BLAS scheduling while an eval callback is installed or add callback support to scheduled execution.

AGENTS.md reference: AGENTS.md:L69-L76

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d6736ba. When the workspace is CPU plus BLAS every tensor stays in host memory, so execute_segment now passes the eval callback to the scheduler through ggml_backend_sched_set_eval_callback; ggml's scheduler uses the same ask/collect protocol as the plain CPU path. Other scheduler setups keep the existing warning. Checked on SD1.5 at 256 px, 2 steps, with OpenBLAS: --imat-out with BLAS on used to save an empty imatrix (0 entries, the warning printed 3 times) and now saves 256 entries over 880 calls, the same tensor names and call counts as SD_CPU_BLAS=0. The rendered image is byte-identical to before.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 12, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a408276de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

segment_end();
release();
ggml_backend_free(cpu_backend_);
ggml_backend_free(blas_backend_);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim BLAS scratch at runner end

When BLAS processes an F16 or unrepacked quantized weight, ggml_backend_blas_mul_mat retains an F32 conversion buffer as large as the biggest weight seen. Because this backend is freed only by the workspace destructor, GGMLRunner::runner_end() releases the scheduler and model weights but leaves that scratch resident; running the conditioner, diffusion model, and VAE sequentially can therefore accumulate one large, untracked buffer per live runner and exhaust RAM on memory-constrained Macs. Reclaim or recreate the BLAS backend at runner-session boundaries rather than retaining it until model destruction.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0fab806. ggml-blas only frees work_data when the backend is freed, so runner_end() now frees and recreates the idle BLAS backend right after releasing the workspace (thread count is set again on every segment). Within a run the buffer is kept: the diffusion runner does not end between sampling steps, so a txt2img run releases it three times (after the conditioner, after sampling, after the VAE). Peak RSS on SD1.5 at 256 px, 2 steps, CPU with OpenBLAS, 5 runs each: 2525 to 2564 MB before, 2475 to 2500 MB after. Rendered images are byte-identical with BLAS on and with SD_CPU_BLAS=0.

With BLAS scheduling a CPU graph goes through ggml_backend_sched, which
ignored sd_get_backend_eval_callback(), so --imat-out collected nothing.
Pass the callback to the scheduler when the workspace is CPU + BLAS only.
ggml-blas keeps its F32 conversion buffer, sized to the largest non-F32
weight it has seen, until the backend is freed. Recreate the BLAS backend
in runner_end() so a finished runner does not keep it resident.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 12, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

@danielhanchen
danielhanchen changed the base branch from perf/cpu-isa to master October 12, 2026 06:56
# Conflicts:
#	scripts/unsloth/cpu_variants_gate.sh
#	scripts/unsloth/ggml-patches/0009-ggml-backend-no-cwd-search.patch
#	src/core/ggml_extend.cpp
#	src/core/ggml_extend_backend.cpp
#	src/core/ggml_extend_backend.h
#	src/model_manager.h
#	src/pipeline/diffusion_engine.cpp
@danielhanchen
danielhanchen merged commit 25ac1fa into master Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant