Skip to content

breeze: enable bf16 activation rounding on Metal - #554

Open
gqf2008 wants to merge 3 commits into
0xShug0:mainfrom
gqf2008:breeze-bf16-metal
Open

gqf2008 wants to merge 3 commits into
0xShug0:mainfrom
gqf2008:breeze-bf16-metal

Conversation

@gqf2008

@gqf2008 gqf2008 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

BreezeTTS rounds activations to bf16 on CUDA/HIP/Vulkan to match the reference implementation. Metal was excluded from that policy; this enables it there and uses a bf16 KV cache as well.

The branch also adds the two missing bf16<->f16 copy kernels to the Metal backend, so a graph that copies between those types can be scheduled on Metal instead of falling back.

Changes

ggml-metal: add bf16<->f16 copy kernels

  • CPY, SET, DUP and CONT accepted f32 conversions and same-type copies on Metal, but not the two cross combinations of bf16 and f16.
  • Instantiate kernel_cpy_f16_bf16 and kernel_cpy_bf16_f16 (contiguous and strided) under GGML_METAL_HAS_BF16, and accept the F16<->BF16 pairs in ggml_metal_device_supports_op.

breeze: enable bf16 activation rounding on Metal

  • Include Metal in the activation-cast policy and use a bf16 KV cache.
  • Unlike CUDA/HIP/Vulkan, Metal has no fused round-to-bf16 unary op, so fused_round stays disabled and the cast is a separate graph node.

Verification

On macOS (Apple M4, Metal 4) with both targets built (audiocpp_cli, audiocpp_server):

  • audiocpp_cli --task tts --family breeze_tts --model breeze-tts-2-q8_0.gguf --backend metal runs to completion and produces audio.
  • The bf16 path is active in the run: the Metal library loads kernel_cpy_bf16_bf16, kernel_cpy_bf16_f32, kernel_cpy_f32_bf16 and kernel_set_rows_bf16_i32.
  • The two new kernels are not on the breeze path — the graph converts through f32 rather than directly between bf16 and f16. They are included as a backend capability (same shape as the Vulkan change in ggml-vulkan: add bf16<->f32/f16 cpy pipelines), not as a requirement of the BreezeTTS change: enabling the policy alone builds and runs identically.

CPY, SET, DUP and CONT accepted f32 conversions and same-type copies on
Metal, but not the two cross combinations of bf16 and f16, so a graph
copying between them could not be scheduled on the backend.

Instantiate kernel_cpy_f16_bf16 and kernel_cpy_bf16_f16 (contiguous and
strided) under GGML_METAL_HAS_BF16, and accept the F16<->BF16 pairs in
ggml_metal_device_supports_op.
BreezeTTS rounds activations to bf16 on CUDA/HIP/Vulkan to match the
reference implementation. Enable the same policy on Metal and use a bf16
KV cache there as well.

Unlike CUDA/HIP/Vulkan, Metal has no fused round-to-bf16 unary op, so
fused_round stays disabled and the cast is a separate graph node.
@0xShug0 0xShug0 added the ggml label Sep 15, 2026
@0xShug0

0xShug0 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

@gqf2008 In my test the policy change in breeze tts caused 22% to 25% regression in the AR compoent (mac mini M4). Could you do a performacne regression check?

@gqf2008

gqf2008 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — the regression reproduces on Metal here, at about the same magnitude you measured.

Setup

  • MacBook Air M4 (10-core, 16 GB), macOS 26.5.2, --backend metal
  • BreezeTTS 2 Q8_0, offline tts, fixed text, --max-tokens 100 --seed 0
  • 10 interleaved A/B runs: the two binaries alternate within one session, so thermal drift hits both arms equally; reported as the median of the paired per-run ratios (run 1 of the PR arm is a cold-start outlier and is called out separately)
  • Both arms are built from the same tree; the only difference is this PR's three files (generator.cpp, ggml-metal-device.m, ggml-metal.metal)

Result (median, 10 runs per arm)

metric main (policy off) this PR (policy on) delta
AR total (breeze_tts.ar.total_ms) 21.46 s 26.45 s +23%
backbone cond decode 2.58 s 3.14 s +22%
backbone cond prefill 51 ms 65 ms +29%
Mimi speech decoder 2.59 s 2.69 s +4%
RTF (wall / audio) 3.0 3.7 +22%

Per-iteration AR ratio (head/base): 1.72, 0.95, 1.40, 1.22, 1.07, 1.20, 1.72, 1.42, 1.58, 0.76 → median 1.31 (1.22 excluding the cold run 1). AR ms per run, main: 21493, 24378, 19772, 19300, 21434, 17569, 20381, 21661, 22294, 33026; this PR: 36945, 23101, 27734, 23612, 22922, 21154, 35062, 30854, 35324, 25160.

Mechanism

The cost is concentrated in the backbone (prefill +29%, decode +22%) — exactly the part that picks up the per-cell bf16 activation casts and the bf16 KV cache. The Mimi speech decoder is untouched (+4%, inside the noise).

Metal has no fused round-to-bf16 unary op: GGML_UNARY_OP_ROUND_BF16 is implemented for CPU, CUDA and Vulkan only, so on Metal fused_round stays off and every cast point becomes a f32→bf16→f32 cast pair on top of the graph. With the policy enabling ~14 cast points per layer per decode step, that is a lot of extra small kernels — the same effect #393 measured on CUDA, where the fused op was worth ~19–29% of GPU time.

Options

  1. Keep the policy off on Metal until Metal has the fused round op (i.e. drop the generator.cpp part of this PR, or gate it behind a session option).
  2. Land GGML_UNARY_OP_ROUND_BF16 for Metal first (same shape as the Vulkan/CUDA implementations, already precedented by the f16/bf16 copy kernels in this PR) and re-measure — I expect this to pay back most of the 22%.
  3. Split the measurement further (activation casts vs bf16 KV cache — the latter is what CUDA/Vulkan deliberately avoid on pre-sm80 because flash attention bf16 KV is slower without native bf16 MMA) so we know which half costs what before deciding.

Happy to do (2) or (3); tell me which you prefer.

Metal was the only GPU backend without GGML_UNARY_OP_ROUND_BF16, so Breeze's
bf16 activation policy fell back to an f32 -> bf16 -> f32 cast pair at every
rounding point (~14 per layer per decode step). That is the regression the
maintainer measured on a Mac mini M4 (+22-25% on the AR component, reproduced
here at +20% per generated frame with paired interleaved runs).

Add the fused op for Metal:

- kernel_unary_impl gains OP_UNARY_NUM_ROUND_BF16. The rounding mirrors
  ggml_compute_fp32_to_bf16 (round-to-nearest-even at bit 16, NaNs forced
  quiet) using integer math, so it does not depend on the device exposing the
  bfloat type; float4 shares the same helper.
- New kernel_unary_f16_f32(_4) / kernel_unary_bf16_f32(_4) instantiations: the
  op accepts f32/f16/bf16 sources and always produces f32. Reading a bf16
  source still requires hardware bf16 support; f32/f16 sources work anywhere.
- breeze_tts's activation policy now enables fused_round on Metal as well.

Verified on macOS 26.5.2 (M4, Metal), BreezeTTS 2 Q8_0, fixed text and seed:
the generated audio is byte-identical to the unfused policy arm (3/3 runs), so
the fused op is numerically the same rounding. Paired interleaved runs against
the unfused build give a median AR ratio of 0.86 over 14 pairs (0.80 over the
first 7, before the machine picked up background load) - i.e. the fused op
takes back most of the ~20% the cast pairs cost, matching what the same op was
worth on CUDA (19-29% of GPU time in 0xShug0#393).
@gqf2008

gqf2008 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: I implemented option 2 from my earlier analysis — GGML_UNARY_OP_ROUND_BF16 for Metal — and pushed it as a commit on this branch (cd969cd6). With it, this PR no longer costs what it cost before.

What it adds

  • kernel_unary_impl gains OP_UNARY_NUM_ROUND_BF16. The rounding mirrors ggml_compute_fp32_to_bf16 (round-to-nearest-even at bit 16, NaNs forced quiet) with integer math, so it does not depend on the device exposing the bfloat type.
  • New kernel_unary_f16_f32(_4) / kernel_unary_bf16_f32(_4) instantiations: the op takes f32/f16/bf16 sources and always produces f32. Reading a bf16 source still needs hardware bf16 support; f32/f16 sources work everywhere.
  • breeze_tts now enables fused_round on Metal like the other GPU backends.

Verification (macOS 26.5.2, M4, Metal, BreezeTTS 2 Q8_0)

  • Numerics: with a fixed text and seed the generated audio is byte-identical to the unfused policy arm (3/3 runs), i.e. the fused op really is the same rounding, just in one kernel. The Metal pipeline log confirms both new kernels are in use (op=122 for f32 and bf16 sources).

  • Performance: paired interleaved runs against the unfused build (same output length, 51 frames each):

    pairs median AR ratio (fused / unfused) range
    7 (quiet machine) 0.80 (-20%) 0.62 – 2.03
    14 (combined) 0.86 (-14%) 0.45 – 2.03

    So the fused op takes back most of the ~20% the cast pairs were costing. For reference, the same op was worth 19–29% of GPU time on CUDA in Breeze-TTS 2 performance: weight packing + fused bf16 rounding #393, so this is in line.

    Caveat: this is a fanless 16 GB M4 Air and run-to-run noise is large (I see ±50% swings, and the two high ratios above are runs where the machine was busy), so treat the median as the signal. On your Mac mini the numbers should come out cleaner.

What is left

With the fused op in place, the remaining cost of the policy on Metal is the bf16 KV cache plus the extra graph structure, not the casts: a per-generated-frame comparison against the policy-off arm still shows roughly +20%. That is the same knob CUDA/Vulkan deliberately keep at f16 on non-sm80 parts, so if you want I can look at whether Metal should keep an f16 KV cache and only use the bf16 activation rounding (that would isolate the two effects).

Also happy to split the fused op into its own PR if you would rather land the backend feature independently of the breeze policy change.

@gqf2008

gqf2008 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

One more data point while the measurement rig was set up: I tried keeping the f16 KV cache on Metal (the way CUDA and Vulkan do) while keeping the bf16 activation rounding, to see whether the KV cache type explains the residual gap against the policy-off arm.

Result: no measurable difference on this machine. Same text/seed, same output length (51 frames), 3 paired interleaved runs:

arm median AR per generated frame per-run
bf16 KV cache (current PR) 250 ms 200 / 281 / 250
f16 KV cache 259 ms 233 / 259 / 338

Paired ratios (f16/bf16) were 0.93 / 1.29 / 1.20 — i.e. inside the noise floor (this fanless M4 Air swings ±50% run to run). So I did not include that change: the data does not support it, and the bf16 KV cache is what matches the reference implementation anyway.

One thing the logs do show, which may be useful if you want to chase the rest: the AR loop is dominated by the depth decoder and sampling, not the backbone — backbone decode is ~32 ms of the ~250 ms per generated frame, the remainder is depth decoding/sampling. That is also why the fused op pays off as much as it does: the cast pairs were per-layer/per-step on both stacks.

I'll leave it here rather than push a change the numbers don't support; if you want the residual gap chased properly, it needs a quieter machine (or a much longer interleaved run) — happy to do that on request.

@0xShug0

0xShug0 commented Sep 18, 2026

Copy link
Copy Markdown
Owner

@gqf2008 On the other backends, the additional optimizations amortize the BF16 cast cost to some extent, while on Mac the regression is more visible. I can confirm the 20% regression on my Mac Mini. For now, I’d prefer to add an option and update the docs so Mac users can explicitly opt in until the performance gap is fully addressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants