Repository navigation
ggml-cpu : select the NEON fp32->fp16 conversion - #30505
Conversation
|
/bot review |
Automated code reviewI 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 Verification of the F16C move: the selection Verification of the guard: I grepped the tree; nothing outside NaN behavior change: the software encoder emits the canonical quiet NaN Findings:
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 |
|
Needs a rebase, and can you check the 2 points from the bot? |
d28369e to
e8992fa
Compare
|
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 On the two points:
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 |
|
Hey a few things,
Edit: By reducing verbosity in code comments, I mean, try to reduce them to 1 line and be concise about the details. |
|
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 |
|
Benchmarks: master x86 — no regression is possible here, because the build is bit-identical. Intel Sapphire Rapids ( ARM — Qwen3-0.6B,
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 —
The V2 numbers were taken twice, in two regions, and agree: 112.97 → 24.29 us in
Benchmarks run and written up with assistance from Claude Opus 5. |
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.
e8992fa to
ae9ed25
Compare
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.
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.
ggml-cpu: select the NEON fp32->fp16 conversion
The
__ARM_NEONbranch ofsimd-mappings.hdefines bothGGML_CPU_COMPUTE_FP16_TO_FP32andGGML_CPU_COMPUTE_FP32_TO_FP16, but selects only the first. The second therefore falls through toGGML_COMPUTE_FP32_TO_FP16at the end of that section -- ggml-base's portable software bitmanipulation, the one
ggml.cuses everywhere because ggml-base may not assume CPU features --while
neon_compute_fp32_to_fp16, a singlefcvt, stays defined and referenced by nothing. TheGGML_CPU_*pair exists to be the accelerated variant; on ARM it is defined to be theunaccelerated one.
__POWER9_VECTOR__and__riscv_zfhminselect both macros. The comment on that fallback alreadysaid NEON does too:
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-- thefrom_floatofGGML_TYPE_F16-- has vector paths for F16C, RISC-V and s390x (the last added in #30157) andnone 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_fp16and searching for anyfcvtof any form finds none inllama-b11576-bin-ubuntu-arm64(all 8 CPU variants) orllama-b11576-bin-macos-arm64, nor in the17 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
vcvtps2pheach and the three without (x64,sse42,sandybridge) emit none -- exactlymatching the hardware. Only ARM has the instruction everywhere and uses it nowhere.
What it costs
In the shipped
libggml-cpu-armv8.0_1.sothe loop is 25 instructions per element, with asix-operation serial float dependency chain (
fabs,fmul,fmov,fmadd,fmov) and adata-dependent branch for the inf/NaN case. With the selection it is a load, one
fcvtand 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.
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
perfon an F16pp512run attributes 83.9 % of cycles totinyBLAS::gemm, which convertsinside 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 correctnessand 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
#errorpair checks exactly that state.The FP16->FP32 half carries a
&& !defined(__F16C__)carve-out, with the issue link on thecondition 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: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 variantmodules, 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_fp16contains
fcvt,fcvtnandfcvtn2-- six occurrences -- in every one of them. Reverting thepatch in the same build tree and rebuilding puts that count back to 0.
ctest: 52 of 54 pass. The two failures,test-tokenizers-ggml-vocabsandtest-jinja-py, arethe same two on unpatched
masterin that same tree -- they need test vocabularies and a Pythonjinja2 the machine does not have. The suite is unchanged by the patch.
That build was made before the rebase. Both
ggml_cpu_fp32_to_fp16and the__ARM_NEONbranch ofthis header are byte-identical between that base and current master, so the result still describes
the tree as it stands.
Testing summary
clang -Eresolution matrix over five configurations (x86 +F16C,x86 +F16C+AVX512,x86 -F16C,x86plain, NEON) formaster,master+guard and the final tree.violated, at both candidate positions.
ctest, and the revert round trip in the same tree.llama-bench(f16 and Q4_K_M, interleaved,-r 5) plusperf record.Not claimed
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.
ggml-cpu.dllbuilt with it showsthe same absence of
fcvt.ggml_cpu_fp32_to_fp16; see the thread.Requirements