Skip to content

Implied-bias affine quantized_matmul / dequantize (0-d biases factor) - #6

Merged
jeethu merged 6 commits into
prism-0.32.2-fixesfrom
fork/190-implied-bias
Oct 4, 2026
Merged

jeethu merged 6 commits into
prism-0.32.2-fixesfrom
fork/190-implied-bias

Conversation

@jeethu

@jeethu jeethu commented Oct 4, 2026

Copy link
Copy Markdown
Member

For numen-tech/gemma4-qat#190 (epic numen-tech/gemma4-qat#178, Phase 2).

What

quantized_matmul, dequantize and gather_qmm in mode "affine" now accept a 0-d biases array: an implied-bias factor f. Some checkpoints store a per-group bias that is always a fixed multiple of the group's scale:

Checkpoint Bits Group size f
Gemma 4 mobile-QAT 2 / 4 / 8 64 −2 / −8 / −128
Bonsai-27B 1 128 −0.5
Ternary Bonsai 2 2 128 −1

For those, the factor can stand in for the whole bias array, so the bias never takes memory. On E2B/E4B that saves 131.6 / 211.4 MB resident.

Contract (ops.h, Python docstrings): a factor f means the materialized bias scales * T(f), formed in the scales' dtype T with f cast to T first. A factor that is not exactly representable in T is outside the contract. The op is lazy, so this isn't checked at runtime.

Dispatch. implied_bias_kernels_exact decides when the kernels can honor the contract bit-for-bit. All of these must hold:

  • the stream is a CPU stream (QuantizedMatmul::eval_cpu expands the factor into a temporary) or a Metal GPU stream;
  • the kernel sees the scales in T (no promotion from the activations);
  • bits ∈ {1, 2, 4, 8}.

When it holds on Metal, an _ib kernel variant forms T(f) * T(scale) where the per-group bias used to be loaded. This is bit-identical to a materialized bias, including fp16 subnormal scales, which Bonsai-27B carries.

In every other case, materialize_implied_bias forms scales * T(f) in T up front and the ordinary path runs. That covers CUDA, promoted scales, 3/5/6-bit, gather_qmm, and CPU dequantize.

Commits

  • 0e7a5b0 Implied-bias affine qmv/qvm/qmm/dequantize kernels.

    • Covers quad, fast, generic, wide, split-k, qmm_t/qmm_n, the NAX variants and the block loader.
    • Each gets a trailing implied_bias template bool, and the kernel name takes an _ib suffix.
    • The 1/2/4/8-bit variants are instantiated for the non-JIT library.
    • The vjp treats the factor as a constant.
  • 5a7aeac Pins the rounding contract after Codex round 1. It fixes three parity breaks, each from the bias being formed outside T:

    • promoted scales;
    • CPU dequantize in the factor's dtype;
    • an inexact factor.

    It also fixes one dispatch gap: no 3/5/6-bit _ib in a prebuilt metallib. All four now take the materialize path.

  • 6fa3f0c CUDA streams materialize (no _ib CUDA kernels).

  • 477f91a /simplify pass:

    • one is_implied_bias predicate in backend/common/quantized.h;
    • an affine_bias METAL_FUNC as the single bias selector;
    • gather_qmm materializes instead of throwing;
    • shared test helpers.

    The kernel-name set of the built metallib is identical before and after.

  • a84b8d0 Passes the implied_bias template arg only on the implied branch. affine_sym_* and fp_* have no such parameter. The JIT build, which is what mlx-swift ships, failed affine_sym_qmv pipeline creation without this.

  • 499fc3e Codex round 3 on the cleanup. It undoes two cleanup changes that weren't behaviour-preserving:

    • Routing CPU through materialization made the factor gradient succeed and reordered the scales gradient. It also broke CPU graphs exported before the change, whose QuantizedMatmul holds a 0-d bias. The CPU factor expansion in eval_cpu is restored.
    • The floating-dtype check had been narrowed to implied biases, so int32 per-group biases got through. The check covers every biases dtype again, with a new test.

Verification

  • C++ tests at 499fc3e:

    • non-JIT: 289/289 test cases (5228 assertions);
    • MLX_METAL_JIT=ON: -tc="*quantiz*" 3/3 (609 assertions).

    New cases:

    • "test implied-bias affine quantized_matmul and dequantize": all five factors, fp16/fp32, K ∈ {128, 512, 1536}, M ∈ {1…128}, transposed or not, CPU and GPU. Bitwise equality against the materialized bias.
    • "test implied-bias factor rounding contract": the four Codex repros, exact values.
  • Python: test_quantized + test_fast, 85 passed and 1 skipped. With DEVICE=cpu, test_quantized -k implied passes 11/11. The other CPU failures are the existing Metal-only bias-free affine path from the Thank you very much for using CMAKE! ml-explore/mlx#86 work. The new test_implied_bias_* cover:

    • every GPU dispatch path, including split-k and batched;
    • gathered dequantize;
    • subnormal fp16 scales;
    • grads;
    • gather_qmm;
    • the four contract repros.
  • Downstream (gemma4-qat):

    • greedy output byte-identical on 6 configurations;
    • Metal dispatch counts unchanged;
    • full Swift suite green.

    See the gemma4-qat PR.

  • Metallib size: unchanged (2,401,016 B). The JIT build keeps quantized kernels out of the prebuilt library.

Review

Codex adversarial review ran three rounds:

  1. Round 1: three P1s and one P2, all fixed in 5a7aeac.
  2. Round 2: the CUDA gap, fixed in 6fa3f0c.
  3. Round 3: on the cleanup diff. Three P2s, all fixed in 499fc3e.

Then a /simplify pass.

Downstream: numen-tech/mlx-swift prism-190-implied-bias regenerates from this branch.

jeethu added 6 commits October 3, 2026 19:48
…-tech/gemma4-qat#190)

An affine checkpoint whose per-group bias is a fixed multiple of its scale
(bias = f * scale; f = -2/-8/-128 for the Gemma 4 mobile-QAT 2/4/8-bit
tensors at group size 64, -0.5 for Bonsai-27B 1-bit and -1 for Ternary
Bonsai 2 2-bit at group size 128) can now pass the factor instead of a
materialized bias array: a 0-d `biases` argument to `quantized_matmul` and
`dequantize` in mode "affine" selects kernels that form the bias in place
where the per-group bias used to be loaded.

- ops: validation accepts a 0-d floating `biases` (dtype promotion from
  `scales`); `quantized_matmul` keeps the scalar out of `broadcast_arrays`;
  `dequantize` casts it to the scales dtype before the GPU kernel (which
  reads biases as the scales type) and multiplies it out in the CPU
  fallback; `gather_qmm` throws rather than broadcasting a constant bias.
- Metal: every affine qmv/qvm/qmm/dequantize kernel (quad, fast, generic,
  wide, split-k, qmm_t/qmm_n and their NAX variants, the block loader) gets
  a trailing `implied_bias` template bool; the host appends `_ib` to the
  kernel name and the .metal files instantiate the 1/2/4/8-bit variants for
  the non-JIT library (3/5/6-bit implied kernels exist in JIT builds only).
  The bias is formed as `T(f) * T(scale)` and then widened, i.e. rounded
  through the scales dtype exactly as a materialized bias would be, so the
  output is bit-identical to the rebuilt-bias path even for fp16 subnormal
  scales where `f * scale` is not representable (Bonsai-27B-1bit carries a
  handful).
- CPU: `QuantizedMatmul::eval_cpu` materializes `f * scales` as a temporary
  and dispatches the existing kernels.
- vjp: scales gradient uses the factor as the derived-bias constant; a
  gradient wrt the factor itself is an error. jvp is unchanged.

Tests: `tests/ops_tests.cpp` "test implied-bias affine quantized_matmul and
dequantize" (all five factors, fp16/fp32, K in {128, 512, 1536}, M in
{1,2,3,5,8,33,128}, transposed and not, CPU and GPU, bitwise equality
against the materialized bias; unsupported paths throw) and
`python/tests/test_quantized.py::test_implied_bias_*` (matmul, every GPU
dispatch path incl. split-k and batched, dequantize incl. gathered rows,
subnormal fp16 scales, grads, error cases).
…re the kernels cannot honor it (numen-tech/gemma4-qat#190)

Codex review of the implied-bias op found three parity breaks and one
dispatch gap, all from the bias being formed somewhere other than the
scales' storage dtype:

- `quantized_matmul` promoted the scales to the activations' dtype and the
  `_ib` kernel then formed the bias in the promoted dtype (fp32 x over fp16
  scales holding 2^-24 with f = -0.5: materialized 0, implied -2^-25).
- the CPU `dequantize` fallback multiplied `scales * f` in the factor's
  dtype (an fp32 factor promotes it), skipping the fp16 rounding.
- an arbitrary floating factor was accepted but `T(f) * scale` is not
  `T(f * scale)` (fp16 scale 0.10009765625, f = 1.0004).
- non-JIT builds (a prebuilt metallib, as mlx-swift ships) have no 3/5/6-bit
  `_ib` instantiations, so a 3-bit implied dequantize threw on kernel lookup.

Contract (ops.h, the Python docstrings): a 0-d `biases` factor f stands for
the materialized bias `scales * T(f)` formed in the scales' dtype T, f cast
to T first; a factor not exactly representable in T is outside the contract
(no runtime value check -- the op is lazy). One predicate,
`implied_bias_kernels_exact`, says when the `_ib` kernels honor that exactly:
the kernel sees the scales in T (no promotion) and bits is in {1, 2, 4, 8}.
Otherwise `materialize_implied_bias` forms `scales * T(f)` in T up front and
the ordinary per-group-bias path runs; the CPU dequantize fallback uses the
same helper. The 3/5/6-bit kernels stay JIT-only so the metallib does not grow.

Tests: `tests/ops_tests.cpp` "test implied-bias factor rounding contract"
(the four repros on CPU and GPU, exact values asserted) and
`python/tests/test_quantized.py::test_implied_bias_{dtype_promotion,
cpu_dequantize_rounding,non_representable_factor,non_power_of_two_bits}`;
the existing tests' reference is now spelled `scales * T(f)`.
The CUDA quantized kernels have no implied-bias variant: dequantize would index the 0-d factor as a per-group bias. implied_bias_kernels_exact now also requires a CPU stream or Metal, so CUDA takes the materialized-bias path (numen-tech/gemma4-qat#190).
- One `is_implied_bias` predicate (mlx/backend/common/quantized.h) shared by
  ops.cpp, primitives.cpp and the Metal/CPU backends; `implied_bias_suffix`
  derives the "_ib" kernel-name suffix and every launch site passes the flag
  computed once as the trailing template arg.
- `affine_bias` is the single METAL_FUNC bias selector for the affine kernels
  (bias-free / implied / stored), reused by `QuantizedBlockLoader::load_bias`.
  Rounding order is unchanged: the implied bias is still `f * scale` in T.
- `implied_bias_kernels_exact` is Metal-only; the CPU QuantizedMatmul rejects
  a 0-d bias instead of carrying its own dequant branch (quantized_matmul
  materializes `scales * T(f)` before the op reaches the CPU).
- `gather_qmm` materializes the implied bias instead of throwing.
- Tests: shared helpers in ops_tests.cpp and test_quantized.py; new gather_qmm
  bitwise-equality tests on CPU and GPU.
- Reverted reformat-only hunks; kernel-name set of the built metallib is
  identical before/after.
Collapsing the two get_quantized_kernel_wrapped calls into one that always
appends `implied` fed a seventh template argument to kernels that have no
implied_bias parameter (affine_sym_*, fp_*). Non-JIT builds never saw it;
the JIT build in mlx-swift failed to compile affine_sym_qmv at pipeline
creation. Restore the per-branch dispatch.
Codex review of the cleanup pass: routing CPU streams through
materialize_implied_bias changed behaviour the cleanup was meant to keep.

- Gradients: the materialized bias is a differentiable multiply, so on CPU
  a gradient wrt the factor no longer threw and the scales gradient summed
  in a different order than on Metal.
- Exported graphs: a CPU QuantizedMatmul with a 0-d bias exported before
  the cleanup re-imports the primitive directly, and eval_cpu now threw.
- Validation: restricting the floating-dtype check to implied biases let
  int32 per-group biases through quantized_matmul/dequantize/gather_qmm.

Restore QuantizedMatmul::eval_cpu's factor expansion and the CPU clause of
implied_bias_kernels_exact, and check every biases dtype. Test: int32
per-group biases raise. DEVICE=cpu test_quantized -k implied: 11 passed.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 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-04T08:59:42.408292Z 499fc3e Manual request
ℹ️ 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: 499fc3e27e

ℹ️ 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 thread mlx/ops.cpp
Comment on lines +4792 to +4796
// Implied bias (a 0-d `biases` array holding a factor f): the contract is that
// it stands for the materialized per-group bias `scales * T(f)`, formed in the
// scales' storage dtype T -- f is cast to T first, so a factor that is not
// exactly representable in T is outside the contract (the reference is still
// `scales * T(f)`, not `T(f * scales)`). The `_ib` kernels form `T(f) *

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 Condense the implied-bias dispatch comment

Condense this 17-line block to the short reason for the predicate and keep the detailed rounding contract in the public documentation or tests. The block duplicates implementation details and exceeds the repository's explicit limit of concise, usually 1–2 line comments.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Comment thread mlx/ops.cpp
Comment on lines +4882 to +4884
auto b = (is_implied_bias(*biases) &&
!implied_bias_kernels_exact(scales, dtype, bits, to_stream(s)))
? materialize_implied_bias(scales, *biases, s)

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 Preserve no-gradient semantics during bias materialization

When this fallback runs—for example with fp32 activations and fp16 scales, 3/5/6-bit weights, or CUDA—it replaces the scalar factor with a differentiable multiply(scales, factor). QuantizedMatmul::vjp then sees a full bias array and propagates a factor gradient through that multiply, whereas configurations using the scalar kernel throw no gradient wrt an implied-bias factor; the same API therefore has device-, dtype-, and bit-width-dependent autodiff behavior and can silently train a value intended to be constant. Preserve the scalar factor's no-gradient semantics when materializing it.

Useful? React with 👍 / 👎.

Comment thread mlx/ops.cpp
Comment on lines +5687 to +5690
// The gather kernels have no implied-bias variant: materialize the factor.
std::optional<array> biases = biases_;
if (is_implied_bias(biases_)) {
biases = materialize_implied_bias(scales, *biases_, s);

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 Document the scalar factor in gather_qmm

This adds implied-bias-factor support to gather_qmm, but its public Python documentation at python/src/ops.cpp:4845-4853 still says that biases must have the same batch dimensions as w and describes only per-group bias arrays. Unlike the updated quantized_matmul and dequantize docs, it therefore tells users that the newly accepted scalar form is invalid and leaves the memory-saving API undiscoverable; update the binding documentation and the C++ API comment with the scalar contract.

Useful? React with 👍 / 👎.

@jeethu

jeethu commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 499fc3e27e

ℹ️ 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".

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