Skip to content

Fix/rocmfpx encoder review - #31

Open
LaurentZuijdwijk wants to merge 5 commits into
masterfrom
fix/rocmfpx-encoder-review
Open

Fix/rocmfpx encoder review#31
LaurentZuijdwijk wants to merge 5 commits into
masterfrom
fix/rocmfpx-encoder-review

Conversation

@LaurentZuijdwijk

Copy link
Copy Markdown
Member

Overview

Measurements

Device:     Ryzen AI Max+ 395 / <mini-PC or laptop model>
Memory:     128 GB LPDDR5X-8000
Power:      <sustained TDP / platform profile / power governor>
BIOS:       UMA split <n> GB
Kernel:     <uname -r>, amdgpu params: <gttsize / ttm.pages_limit, or none>
Backend:    Vulkan RADV, Mesa <version>   (or: ROCm <version>, HIP_LAUNCH_BLOCKING=<0|1>)
Build:      <cmake flags>
Baseline:   <merge-base commit sha, built and run in this same session>
Change:     <your branch commit sha>
Model:      <HF repo / file>, <quant>

Baseline:

After:

Correctness:

Additional information

Requirements

  • I have read and agree with the contributing guidelines
  • This change is Strix Halo specific, or justified by measurements on Strix Halo. General llama.cpp improvements
    belong in halo-box/llama.cpp instead
  • AI usage disclosure:
  • What was NOT verified:

Every check in test_rocmfpx.c is a plain assert(), including the
rocmfpx_validate_row_data_*() calls and the mse ordering comparisons. The
Release configuration the PR builds defines NDEBUG, so all of them compiled
away and the test still exited 0 - it validated nothing.

Undefine NDEBUG before including <assert.h>, the way test-quantize-fns.cpp
and test-quantize-perf.cpp already do. test_rocmfp2_reference.c does not need
this: it uses its own always-active check machinery rather than assert().

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
rocmfpx.c is already a source of ggml-base, and everything test_rocmfpx.c
calls is declared GGML_API. Compiling it into the test as well breaks MSVC
shared-library builds: with BUILD_SHARED_LIBS=ON and GGML_BACKEND_DL=OFF the
test inherits GGML_SHARED through llama -> ggml -> ggml-base but not
GGML_BUILD, so the codec declarations become __declspec(dllimport) in the
same translation unit that defines them, which MSVC rejects.

Drop the extra source and let the test link the library implementation.
test-rocmfp2-reference keeps compiling rocmfp2_reference.c directly: it is a
standalone reference that is not part of ggml-base and carries no GGML_API.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
rocmfpx_choose_scale_fp2_mse() only searched scale bytes 1..126, returning 0
solely when the block was all zeros. ROCmFP2's S40 codebook is
{-4, -1, +1, +4}, which has no zero code, so at any nonzero scale the smallest
magnitude it can emit is one scale step. A block that sits below half of the
smallest UE4M3 scale is then amplified rather than approximated.

A block filled with 2^-14 reconstructed as 2^-10, 16x too large, for a squared
error 225x greater than encoding it as zero. rocmfp2_p1_quantize_block_ref(),
which searches every legal byte 0x00..0x7e, picked scale byte 0 for the same
input - so the two encoders in this tree disagreed.

Seed the search with the zero-scale candidate so bytes 1..126 have to beat it.
Ties keep byte 0, matching both the lower-byte rule the rest of the search uses
and the standalone reference.

  before: e=(1,1)  sse=2.68e-05
  after:  e=(0,0)  sse=1.19e-07   (reference: 5.96e-08 per 16-weight group)

FP3, FP6 and FP8 all have a zero code and so can already match the zero scale
by rounding; only FP2 needs this.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
The FP6 scale search stops descending once the unavoidable clipping error at a
scale already exceeds the best error found so far. That bound was computed as
max_abs - 31*scale, but rocmfpx_decode_fp6_code() maps code 32 to -32: the
representable range is the asymmetric [-32, 31]. For a block whose largest
magnitude is negative the bound therefore overestimates the error that cannot
be avoided, and the search gives up before reaching the scale that wins.

Sweeping 20000 half-blocks with a single dominant outlier:

  before: 324 suboptimal with a negative outlier, 0 with a positive one
  after:    0 suboptimal in either case

worst observed case: byte 12 at sse 1.783e-04 where byte 11 gives 1.367e-04.
The asymmetry between the two columns is exactly what the wrong bound predicts.

The Vulkan encoder carried the same bound, so fix both together - the two
encoders have to agree on the scale they choose.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
rocmfpx_quantize_fp6_code(), rocmfpx_fp6_decoded_value() and
rocmfpx_quantize_fp8_code() rounded to int first and clamped afterwards.
The isfinite() guard keeps NaN and inf out, but a large finite weight still
overflows the conversion, so the value that gets clamped is already wrong:

  1e12 -> fp6 -0.03125    sign flipped
  1e30 -> fp6 0, fp8 0    magnitude lost entirely

Clamp the float to the representable range first, then convert. For in-range
values this is identical to the old order - rounding cannot push a value past
a bound it was not already past - so ordinary weights encode bit for bit as
before, and the extremes now saturate:

  1e12 -> fp6 31*scale, fp8 127*scale
  1e30 -> fp6 31*scale, fp8 127*scale

The Vulkan encoder had the same shape, and int() of an out-of-range float is
undefined in GLSL, so fix it in the same commit to keep the two encoders in
agreement.

This is extreme-input robustness, not a defect seen on ordinary weights.

Assisted-by: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01VcXid9TGNfxqxxNWFQGES8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant