Skip to content

ggml-cpu : select the NEON fp32->fp16 conversion - #30505

Merged
ggerganov merged 1 commit into
ggml-org:masterfrom
bernardladenthin:neon-select-fp32-to-fp16
Oct 20, 2026
Merged

ggerganov merged 1 commit into
ggml-org:masterfrom
bernardladenthin:neon-select-fp32-to-fp16

Conversation

@bernardladenthin

@bernardladenthin bernardladenthin commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

ggml-cpu: select the NEON fp32->fp16 conversion

The __ARM_NEON branch of simd-mappings.h defines both GGML_CPU_COMPUTE_FP16_TO_FP32 and
GGML_CPU_COMPUTE_FP32_TO_FP16, but selects only the first. The second therefore falls through to
GGML_COMPUTE_FP32_TO_FP16 at the end of that section -- ggml-base's portable software bit
manipulation, the one ggml.c uses everywhere because ggml-base may not assume CPU features --
while neon_compute_fp32_to_fp16, a single fcvt, stays defined and referenced by nothing. The
GGML_CPU_* pair exists to be the accelerated variant; on ARM it is defined to be the
unaccelerated one.

__POWER9_VECTOR__ and __riscv_zfhmin select both macros. The comment on that fallback already
said NEON does too:

so we define GGML_CPU_FP16_TO_FP32 and GGML_CPU_FP32_TO_FP16 elsewhere for NEON. This is also
true for POWER9.

Same shape of omission #30270 fixed for F16C, in the other direction. Not a regression -- the
NEON selection is absent in all 25 most recent commits touching the file, back to 2025-04-10.

Why it is felt on ARM and nowhere else: ggml_cpu_fp32_to_fp16 -- the from_float of
GGML_TYPE_F16 -- has vector paths for F16C, RISC-V and s390x (the last added in #30157) and
none for NEON, so there the whole function is the scalar loop through the macro.

It is in the released binaries

Disassembling ggml_cpu_fp32_to_fp16 and searching for any fcvt of any form finds none in
llama-b11576-bin-ubuntu-arm64 (all 8 CPU variants) or llama-b11576-bin-macos-arm64, nor in the
17 ARM artifacts a downstream project ships across Linux, Android, Windows-on-ARM and macOS -- 26
binaries, zero conversion instructions
. In the same object file the other direction carries 9 of
them, which is the asymmetry in one picture.

The x86 side is the comparison: of this project's 14 x86-64 CPU variant modules, the 11 with F16C
emit 5-6 vcvtps2ph each and the three without (x64, sse42, sandybridge) emit none -- exactly
matching the hardware. Only ARM has the instruction everywhere and uses it nowhere.

What it costs

In the shipped libggml-cpu-armv8.0_1.so the loop is 25 instructions per element, with a
six-operation serial float dependency chain (fabs, fmul, fmov, fmadd, fmov) and a
data-dependent branch for the inf/NaN case. With the selection it is a load, one fcvt and a store,
which the compiler then vectorises to fcvtn v0.4h, v0.4s -- four elements per instruction.

Measured on five ARM core types across Linux, macOS and FreeBSD: a 16M-element bulk conversion, best
of 7, with the opposite direction measured in the same binary and the same run as a control the
change cannot touch.

machine OS compiler before after factor
Ampere eMAG (2018) Linux gcc 8.5 82.0 Melem/s 1087.2 13.3x
Ampere eMAG Linux clang 21.1.8 341.9 3031.6 8.9x
Cortex-A55 (in-order, 64K pages) Linux gcc 14.2 265.1 1136.2 4.3x
ARM Neoverse-N1 Linux gcc 14 323.8 1310.1 4.0x
ARM Neoverse-N1 Linux clang 19.1.7 976.2 3030.3 3.1x
ARM Neoverse-N1 FreeBSD 16 clang 21.1.8 886.1 2168.4 2.45x
ARM Neoverse-V2 (Graviton 4) Linux gcc 11 583.0 2442.0 4.2x
ARM Neoverse-V2 Linux clang 15.0.7 1595.7 6127.1 3.8x
Apple M1 macOS Apple clang 21 2751.7 9811.2 3.6x

The factor narrows on wider cores and never vanishes -- 2.45x to 13.3x -- across every ARM
server core generation from 2018 to Graviton 4, an in-order little core, Apple Silicon, and three
operating systems. In all eighteen runs the control direction stays within 3.2 %, against an effect
of 2.45x and up; on the Cortex-A55 it is identical to the digit.

It is bit-exact

Both paths applied to every one of the 2^32 float32 bit patterns: 0 differences for every
finite value, zero, denormal and infinity. They differ only on NaN inputs (16 744 448), where the
software encoder emits a canonical quiet NaN and the instruction propagates the truncated payload.
The identical comparison on x86 with F16C gives the same two numbers, so this brings ARM into
agreement with the behaviour x86 already ships rather than splitting them. The 16M-element output
checksum is byte-identical on all five core types and all nine compiler builds, and the exhaustive
run was repeated on three architectures (Ampere eMAG, Graviton 4, x86-64) with identical counts.

It does not make inference faster

perf on an F16 pp512 run attributes 83.9 % of cycles to tinyBLAS::gemm, which converts
inside its own microkernel, and 0.03 % to ggml_cpu_fp32_to_fp16. End to end the change is
+0.13 % against a run-to-run spread of 0.09 %, i.e. nothing; quantized models never reach the
function at all, since their activations go through a different from_float. This is a correctness
and consistency fix, and the 3x-13x belongs to the function, not to a model.

Also in this change: the guard

Two of the four branches in that chain had this defect at the same time -- F16C until #30270, NEON
until this PR -- and neither was caught by a build, a warning or a test, because the fallback
silently supplies a correct software implementation. The only symptom is a hardware instruction that
is never used. The #error pair checks exactly that state.

The FP16->FP32 half carries a && !defined(__F16C__) carve-out, with the issue link on the
condition line. #30343 landed while this PR was open and puts that direction back on the lookup
table on purpose, so without the carve-out the guard would fire on every x86 F16C build. The
FP32->FP16 half needs no exception: F16C, NEON (with this change), POWER9 and riscv_zfhmin all
select it. If #30342 is ever resolved and the F16C selection comes back, the carve-out becomes dead
weight and should be deleted -- which is what the link on that line is for.

Measured against current master with clang -E:

result
x86 +F16C+AVX2, +F16C+AVX512, sse42, plain x86, NEON all silent
NEON selection removed again (the defect restored) fires
a new chain branch defining both COMPUTE macros, selecting neither fires, both halves
a COMPUTE macro defined after the chain, unselected fires

So 4 of 4 correctness cases are caught on every architecture without the documented exception;
on x86 with F16C the FP16->FP32 direction is unchecked by construction, which is exactly the state
#30343 intends and nothing beyond it.

Its position -- immediately before the fallback rather than directly after the chain -- was chosen
by mutation, not by taste
, and #30343 sharpened the argument: it leaves the F16C selection below
the lookup-table declarations, i.e. after the end of the chain, so the same guard placed directly
after the chain now fails every x86 F16C build. Before the fallback it both compiles and catches
a COMPUTE macro defined after the chain -- the same silent-fallback class this PR is about -- which
a guard directly after the chain would miss.

Also in this change: the fallback comment

The comment above the fallback singled out NEON and POWER9 and pointed at a selection that, after
this change, happens in the branch itself. It now states the fallback plainly, per direction
rather than per branch
, because since #30343 the F16C branch selects one of the two and "when no
accelerated branch selected" would no longer be accurate.

Build and test

Built in the configuration used for the ARM releases -- -DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON -DGGML_NATIVE=OFF, clang 21.1.8 -- i.e. all eight ARM CPU variant
modules, each compiling this header with a different feature set (dotprod, fp16, SVE, i8mm, SVE2,
SME). All eight build, the guard fires nowhere in the whole build, and ggml_cpu_fp32_to_fp16
contains fcvt, fcvtn and fcvtn2 -- six occurrences -- in every one of them. Reverting the
patch in the same build tree and rebuilding puts that count back to 0.

ctest: 52 of 54 pass. The two failures, test-tokenizers-ggml-vocabs and test-jinja-py, are
the same two on unpatched master in that same tree -- they need test vocabularies and a Python
jinja2 the machine does not have. The suite is unchanged by the patch.

That build was made before the rebase. Both ggml_cpu_fp32_to_fp16 and the __ARM_NEON branch of
this header are byte-identical between that base and current master, so the result still describes
the tree as it stands.

Testing summary

  • clang -E resolution matrix over five configurations (x86 +F16C, x86 +F16C+AVX512,
    x86 -F16C, x86 plain, NEON) for master, master+guard and the final tree.
  • Mutation of the guard: four ways the invariant can be violated, two ways it can only look
    violated, at both candidate positions.
  • Full build with all 8 ARM CPU variants plus ctest, and the revert round trip in the same tree.
  • Microbenchmark on five ARM core types and nine compiler builds, with an in-run control.
  • llama-bench (f16 and Q4_K_M, interleaved, -r 5) plus perf record.
  • Exhaustive 2^32 equivalence, on ARM and on x86.
  • Disassembly of 26 released ARM binaries.

Not claimed

  • No inference speed-up, as measured above.
  • Five ARM core types were measured (Ampere eMAG, Cortex-A55, Neoverse-N1, Neoverse-V2, Apple M1)
    on Linux, macOS and FreeBSD with nine compiler builds. Neoverse N2 and Apple M2 and later are
    untested, which is why the factor is given as a range.
  • MSVC on Windows-on-ARM is untested as a compiler, although the ggml-cpu.dll built with it shows
    the same absence of fcvt.
  • No explicit NEON vector path is added to ggml_cpu_fp32_to_fp16; see the thread.

Requirements

  • I have read and agree with the contributing guidelines
  • AI usage disclosure: AI-assisted (Claude Code Opus 5.5), manually reviewed and manually committed. I take full responsibility for the changes.

@taronaeo

Copy link
Copy Markdown
Member

/bot review

@ggml-gh-bot

ggml-gh-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Automated code review

I have verified the diff against the parent commit (1cf7767, PR #30270) and the surrounding usage. Summary of what I checked and what I found.

Verification of the core claim: the parent commit's NEON branch defines both GGML_CPU_COMPUTE_* macros but only selects GGML_CPU_FP16_TO_FP32, so GGML_CPU_FP32_TO_FP16 on ARM falls through to GGML_COMPUTE_FP32_TO_FP16 (the portable software encoder in ggml-impl.h), while neon_compute_fp32_to_fp16 goes unreferenced. The one-line fix matches the pattern already used by the POWER9 and riscv_zfhmin branches. The defect and the fix are both real.

Verification of the F16C move: the selection #defines moved from below the chain (guarded only by defined(__F16C__)) into the #elif defined(__F16C__) branch. This is behavior-equivalent for all real configurations: the only branch preceding it in the COMPUTE chain is the ARM one, and __F16C__/ARM feature macros are mutually exclusive. The removed "pre AVX512 CPUs" comment described the old placement's rationale and is obsolete now that the selection sits beside the macro it selects.

Verification of the guard: I grepped the tree; nothing outside simd-mappings.h defines any GGML_CPU_COMPUTE_* or GGML_CPU_FP16_TO_FP16 macro, so the #error pair cannot false-positive from in-tree includes. All four branches now select both macros, and the per-macro form of the check (each #if is independent) means a future architecture with hardware support in only one direction can still define and select just that one. Placement immediately before the fallback correctly catches a COMPUTE macro defined after the chain, which a guard placed directly after the #elif chain would miss.

NaN behavior change: the software encoder emits the canonical quiet NaN 0x7E00 for NaN inputs (shl1_w > 0xFF000000 ? 0x7E00 in ggml_compute_fp32_to_fp16), while the __fp16 conversion truncates the payload. Nothing in ggml depends on NaN canonicalization through this path, and this brings ARM in line with what x86-F16C already does, as the PR documents. Not a concern.

Findings:

  1. (nit) With all four branches now selecting their macros in place, the comment just below the guard ("On ARM NEON, it's quicker to directly convert x -> x ... so we define GGML_CPU_FP16_TO_FP32 and GGML_CPU_FP32_TO_FP16 elsewhere for NEON. This is also true for POWER9.") is stale in two ways: it no longer needs to single out NEON/POWER9, and "elsewhere" is now "in the branch above". Consider trimming it to something like "fall back to the lookup table / portable software conversion when no accelerated branch selected", or dropping it since the guard comment above already states the invariant.

  2. (observation, not blocking) ggml_cpu_fp32_to_fp16 in ggml-cpu.c has explicit vectorized paths for F16C, riscv zvfh and s390x, but still none for NEON; the scalar loop relies on the compiler auto-vectorizing the __fp16 store. The PR's disassembly data shows that works (fcvtn/fcvtn2 present in all 8 variants). If a maintainer prefers an explicit NEON path for symmetry with the other three, that would be a natural follow-up, not something to block this fix on.

No correctness, security, or scope issues found. The change is minimal, single-purpose, touches only the CPU backend, is well tested per the PR description, and the AI disclosure is filled in. This looks good to merge from my side.

This review was generated automatically by pi coding agent using zai-org/GLM-5.3. It may contain mistakes. Maintainers make the final call.

@taronaeo

Copy link
Copy Markdown
Member

Needs a rebase, and can you check the 2 points from the bot?

@bernardladenthin
bernardladenthin force-pushed the neon-select-fp32-to-fp16 branch from d28369e to e8992fa Compare October 14, 2026 21:01
@bernardladenthin bernardladenthin changed the title ggml-cpu: select the NEON fp32->fp16 conversion ggml-cpu : select the NEON fp32->fp16 conversion Oct 14, 2026
@bernardladenthin

Copy link
Copy Markdown
Contributor Author

Rebased onto master. Two changes against the previous version, both caused by #30343:

The F16C move is gone. With that selection now a documented exception carrying an issue link, moving it up into the branch would only churn those lines, so the block is untouched.

The guard's FP16->FP32 half carries && !defined(__F16C__), with the issue link on the condition line. Without it the guard fires on every x86 F16C build, since that direction is deliberately on the table now. The FP32->FP16 half is unchanged and still catches the omission this PR is about.

On the two points:

  1. Done. The comment now reads "fall back to the lookup table / the portable software conversion for whichever direction no branch above selected" -- per direction rather than per branch, because after ggml-cpu : keep the FP16->FP32 lookup table on x86 with F16C (#30342) #30343 the F16C branch selects one of the two, so "when no accelerated branch selected" would no longer be accurate.

  2. Checked, and kept as the follow-up you suggest. ggml_cpu_fp32_to_fp16 has paths for __F16C__, __riscv_zvfh and __VXE__/__VXE2__ and none for NEON; the scalar loop auto-vectorises -- fcvtn/fcvtn2, six occurrences in each of the eight ARM variant modules in the release configuration. Both that function and the __ARM_NEON branch of this header are byte-identical between the base I built on and current master, so that evidence still applies. An explicit path would have to beat the auto-vectorised form to be worth the code, which I have not measured; happy to look at it separately.

For the record, one line of the bot review is overtaken by #30343: "all four branches now select both macros" no longer holds. The per-macro form of the check is what makes that expressible.

One thing the rebase sharpened: the guard's position matters more than it did. Directly after the #elif chain it would now fail every x86 F16C build, because #30343 leaves the F16C selection below the lookup-table declarations, i.e. after that point. Immediately before the fallback is the only position that both compiles and catches a COMPUTE macro defined after the chain.

@taronaeo

taronaeo commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Hey a few things,

  1. Can we have some benchmarks between upstream/master and this PR? I want to prevent regressions as much as possible since x86's F16C has a couple of reports now.
  2. Try to reduce the verbosity in both your code comments and your replies. Maybe an LLM wrote it but you can write what you understand of this change in a concise message :).

Edit: By reducing verbosity in code comments, I mean, try to reduce them to 1 line and be concise about the details.

@bernardladenthin

Copy link
Copy Markdown
Contributor Author

Hi @taronaeo, I'll try to benchmark master vs this PR now and get the results asap to find out possible impacts. I have access to a lot of cloud services including cfarm, vast, vultr, runpod, aws ... If you need benchmarks on specific hardware for something you're working on, let's get in touch!

This PR is simple: just one line to select the NEON fp32 to fp16 conversion, and two guards that ensure a branch defining an accelerated conversion really uses it instead of the software fallback.

Sure, I'll try to reduce noise but that's always personal taste. Personally I like an all-in-one description containing everything.

Cheers, Bernard

@bernardladenthin

Copy link
Copy Markdown
Contributor Author

Benchmarks: master 711780f against the same tree with this patch cherry-picked, so the patch is the only difference. Five machines, two AWS regions.

x86 — no regression is possible here, because the build is bit-identical. Intel Sapphire Rapids (c7i, gcc build, clang preprocessor) and AMD Genoa (m7a, clang build, gcc preprocessor), both -DGGML_NATIVE=ON: all 17 ggml-cpu object files and the llama-bench binary carry the same SHA-256 with and without the patch, and -E -P of simd-mappings.h is byte-identical for -march=x86-64, -msse4.2, -mavx -mf16c, haswell, x86-64-v3, sapphirerapids, znver3, znver4 and native. After the rebase the diff touches only the __ARM_NEON branch and the two #error guards.

ARM — Qwen3-0.6B, llama-bench -p 512 -n 128 -r 5, 16 threads, ABBA-ordered A/B, n=6 per arm. The null column is master measured against itself in the same pattern, i.e. the drift each machine shows when nothing changes — without it the small numbers cannot be read:

core test master this PR A/B null
Neoverse-V2 (c8g) F16 pp512 323.44 323.96 +0.16 % +0.14 %
Neoverse-V2 F16 tg128 165.02 165.12 +0.06 % −0.93 %
Neoverse-V2 Q4_0 pp512 608.41 609.04 +0.10 % +0.17 %
Neoverse-V2 Q4_0 tg128 306.38 309.15 +0.90 % −0.76 %
Neoverse-V1 (c7g) F16 pp512 145.72 145.74 +0.01 % +0.13 %
Neoverse-V1 F16 tg128 107.87 107.69 −0.17 % +1.25 %
Neoverse-V1 Q4_0 pp512 546.99 547.52 +0.10 % +0.06 %
Neoverse-V1 Q4_0 tg128 295.39 298.97 +1.21 % +3.86 %
Neoverse-N1 (c6g) F16 pp512 414.30 424.26 +2.40 % +0.36 %
Neoverse-N1 F16 tg128 104.83 106.56 +1.65 % −0.30 %
Neoverse-N1 Q4_0 pp512 982.64 995.13 +1.27 % +0.05 %
Neoverse-N1 Q4_0 tg128 195.86 202.09 +3.18 % +0.73 %

On V1 and V2 the change sits inside the machine's own drift: no regression, and no visible end-to-end win either, since tinyBLAS converts inside its own microkernel. On N1 it is a real 1.3–3.2 %, and that is the core where the software conversion costs most — test-backend-ops perf -o CPY, type_src=f32,type_dst=f16:

core master this PR
Neoverse-N1 653.91 us/run, 13.45 GB/s 147.75 us/run, 59.53 GB/s 4.43x
Neoverse-V1 111.03 us/run, 79.22 GB/s 24.61 us/run, 357.47 GB/s 4.51x
Neoverse-V2 112.90 us/run, 77.91 GB/s 24.51 us/run, 358.79 GB/s 4.61x

The V2 numbers were taken twice, in two regions, and agree: 112.97 → 24.29 us in us-east-1, 112.90 → 24.51 us in eu-central-1.

objdump --disassemble=ggml_cpu_fp32_to_fp16: 0 fcvt on master, 1–2 with the patch, on each of the three cores.

Benchmarks run and written up with assistance from Claude Opus 5.

Comment thread ggml/src/ggml-cpu/simd-mappings.h Resolved
Comment thread ggml/src/ggml-cpu/simd-mappings.h Resolved
The __ARM_NEON branch defines both GGML_CPU_COMPUTE_* macros but selects
only FP16_TO_FP32, so FP32_TO_FP16 falls through to the portable
software conversion while neon_compute_fp32_to_fp16, a single fcvt,
stays defined and referenced by nothing. __POWER9_VECTOR__ and
__riscv_zfhmin select both.

Add an #error pair that refuses a branch defining a COMPUTE macro
without selecting it. That is how this stayed unnoticed: the fallback
silently supplies a correct software implementation, so the only
symptom is a hardware instruction nothing ever reaches. The
FP16_TO_FP32 half carries a !defined(__F16C__) carve-out, because PR
30343 keeps that direction on the lookup table on purpose.

Replace the comment above the fallback: it singled out NEON and POWER9
and pointed at a selection that now happens in the branch itself.
@bernardladenthin
bernardladenthin force-pushed the neon-select-fp32-to-fp16 branch from e8992fa to ae9ed25 Compare October 16, 2026 07:55
@taronaeo taronaeo added the merge ready A maintainer can use this label to indicate that they consider the changes final and ready to merge. label Oct 18, 2026
@ggerganov
ggerganov merged commit 119f68d into ggml-org:master Oct 20, 2026
34 of 37 checks passed
@bernardladenthin
bernardladenthin deleted the neon-select-fp32-to-fp16 branch October 20, 2026 12:09
edwardyoon pushed a commit to edwardyoon/focus-llama that referenced this pull request Oct 24, 2026
The __ARM_NEON branch defines both GGML_CPU_COMPUTE_* macros but selects
only FP16_TO_FP32, so FP32_TO_FP16 falls through to the portable
software conversion while neon_compute_fp32_to_fp16, a single fcvt,
stays defined and referenced by nothing. __POWER9_VECTOR__ and
__riscv_zfhmin select both.

Add an #error pair that refuses a branch defining a COMPUTE macro
without selecting it. That is how this stayed unnoticed: the fallback
silently supplies a correct software implementation, so the only
symptom is a hardware instruction nothing ever reaches. The
FP16_TO_FP32 half carries a !defined(__F16C__) carve-out, because PR
30343 keeps that direction on the lookup table on purpose.

Replace the comment above the fallback: it singled out NEON and POWER9
and pointed at a selection that now happens in the branch itself.
auRose94 pushed a commit to auRose94/llama.cpp that referenced this pull request Oct 28, 2026
The __ARM_NEON branch defines both GGML_CPU_COMPUTE_* macros but selects
only FP16_TO_FP32, so FP32_TO_FP16 falls through to the portable
software conversion while neon_compute_fp32_to_fp16, a single fcvt,
stays defined and referenced by nothing. __POWER9_VECTOR__ and
__riscv_zfhmin select both.

Add an #error pair that refuses a branch defining a COMPUTE macro
without selecting it. That is how this stayed unnoticed: the fallback
silently supplies a correct software implementation, so the only
symptom is a hardware instruction nothing ever reaches. The
FP16_TO_FP32 half carries a !defined(__F16C__) carve-out, because PR
30343 keeps that direction on the lookup table on purpose.

Replace the comment above the fallback: it singled out NEON and POWER9
and pointed at a selection that now happens in the branch itself.
@BrewTestBot BrewTestBot mentioned this pull request Oct 28, 2026
1 task done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ggml changes relating to the ggml tensor library for machine learning merge ready A maintainer can use this label to indicate that they consider the changes final and ready to merge.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants