Skip to content

FoldQuant W8A8/W4A4 inference and FoldQuantVLA quantized-model converter - #33

Merged
hungho77 merged 12 commits into
mainfrom
feat/quantized-inference
Oct 5, 2026
Merged

hungho77 merged 12 commits into
mainfrom
feat/quantized-inference

Conversation

@hungho77

@hungho77 hungho77 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

FoldQuant integer inference for GR00T N1.5 / N1.6 / N1.7 and π0.5, and a converter that turns a FoldQuantVLA quantized model into a FoldQuant GGUF.

Runtime

  • A FoldQuant GGUF carries the language backbone and the action module (DiT or Gemma expert) as INT8 or INT4 codes with per-row scales, in a block-Hadamard, SmoothQuant-folded frame. Activations are quantized per token (INT8, or INT4 with a clip ratio), and the projections run as integer GEMMs.
  • Each site is two GGML_OP_CUSTOM nodes (src/layers/fq_linear.h):
    • CPU: reference implementation in src/foldquant_ref.cpp.
    • CUDA: src/kernels/foldquant/, with a fused RMSNorm + butterfly + quant prologue, an mma.sync INT8/INT4 GEMM with per-shape tiling, and residual add and head layout fused into the epilogue. The CUDA ops are reached through one extension dispatcher (src/cuda/vla_cuda_ext.cu).
    • Other backends refuse a FoldQuant file at load.
  • WeightLoader gains typed optional and fused declares. A float gemm() declare of an INT8 tensor fails with the FoldQuant site's name.
  • GR00T N1.7 precomputes the DiT adaLN conditions per denoising step at load.
  • The file format and the arithmetic are specified in docs/QUANTIZATION.md. scripts/inspect_gguf_quant.py checks a file against it.

Converter

  • scripts/convert_quantized_model_to_gguf.py reads a FoldQuantVLA quantized model and writes the GGUF with no calibration and nothing re-rounded. It accepts both FoldQuantVLA formats:
    • the quantized checkpoint (foldquant-quantized-checkpoint): the base checkpoint with .qweight / .weight_scale in place of each quantized .weight;
    • the earlier fake-quant state (foldquant-quant-state).
  • A W4A4 DiT's INT4 adaLN is written dequantized, since vla.cpp runs adaLN in float.
  • Every quantized projection must end up as a GGUF site or that adaLN, or the conversion fails.
  • --check-onnx byte-compares every site and folded norm gain against the plugin ONNX graphs the TensorRT engines were built from.
  • The family converters expose convert(ckpt, out, writer_factory=...), and scripts/gguf_quant_writer.py turns their output into a FoldQuant file. convert_pi05_to_gguf.py also accepts an OpenPI checkpoint without config.json.
  • Tooling: scripts/foldquant_fake_export.py (uncalibrated file for bring-up), scripts/foldquant_ref.py (numpy reference), scripts/foldquant_dequant.py (dequantizing exporter for diagnostics).

Why

The stock Q8_0 / Q4_0 repack keeps activations in float and dequantizes inside ggml_mul_mat, so it saves memory but not compute. FoldQuant runs the LLM and the action head on the integer tensor cores. The converter makes a model calibrated in FoldQuantVLA usable in vla.cpp without recalibrating, and keeps the GGUF bit-exact to the TensorRT deployment of the same arm.

Verified

  • Builds clean under -Wall -Wextra (first-party code)
  • ctest passes
  • Numeric output unchanged (vla_predict_check diff), or the change is
    meant to move it and a LIBERO sweep is below

Archs and backends tested:

  • Build and tests: Jetson AGX Orin, CUDA build (Release). First-party objects recompiled with no warnings, and ctest passes 11/11, including the FoldQuant CPU-op, CUDA-op and GEMM checks. Python unit tests pass (tests/py/test_quantized_model_converter.py, test_foldquant_ref.py, test_converters.py).
  • GR00T N1.7, SO101, CUDA: FoldQuantVLA W8A8, W4A4 and W4A4 + o/down INT8 arms. --check-onnx found all 304 sites and every folded gain byte-identical to the TensorRT graphs.
    • Open loop against the dataset's actions (6 episodes, 200 steps, horizon 16, raw units):

      Arm MSE MAE
      W8A8 7.41 1.36
      W4A4 + o/down INT8 13.54 1.64
      W4A4 10.89 1.61
    • The W8A8 GGUF was run on a real SO101 arm and behaved correctly.

  • GR00T N1.5 / N1.6 / N1.7 and π0.5, LIBERO: FoldQuantVLA fake-quant states converted with --check-onnx byte-identical to their TensorRT graphs.
  • Not yet run on a real checkpoint:
    • the new quantized-checkpoint format (unit-tested only; the first real conversion is π0.5 SO101 W4A4);
    • the vla_predict_check float diff. The branch touches shared float code (DiT head, Qwen3 LM, N1.7 adaLN precompute), so that box is left open until it is run.

Runtime
- A FoldQuant GGUF carries the GR00T N1.5 / N1.6 / N1.7 and pi0.5 language
  backbone and action module as INT8 or INT4 codes with per-row scales in a
  block-Hadamard, SmoothQuant-folded frame; activations are quantized per
  token and the projections run as integer GEMMs. The format and arithmetic
  are specified in docs/QUANTIZATION.md.
- Each site is two GGML_OP_CUSTOM nodes (src/layers/fq_linear.h). The CPU
  backend runs the reference in src/foldquant_ref.cpp; CUDA runs
  src/kernels/foldquant/: a fused RMSNorm + butterfly + quant prologue and an
  mma.sync INT8/INT4 GEMM with per-shape tiling, residual add and head layout
  in the epilogue. The CUDA extension ops go through one dispatcher
  (src/cuda/vla_cuda_ext.cu). Other backends refuse a FoldQuant file at load.
- WeightLoader gains typed optional / fused declares; a float gemm() declare
  of an INT8 tensor fails with the FoldQuant site's name.
- GR00T N1.7 precomputes the DiT adaLN conditions per denoising step at load.

Converter
- scripts/convert_quantized_model_to_gguf.py converts a FoldQuantVLA
  quantized model, either its quantized checkpoint (.qweight / .weight_scale
  in place of each quantized .weight) or its earlier fake-quant state, with no
  calibration and nothing re-rounded. A W4A4 DiT's INT4 adaLN is written
  dequantized; every quantized projection must become a GGUF site or that
  adaLN. --check-onnx byte-compares every site and folded norm gain against
  the TensorRT plugin graphs.
- The family converters expose convert(ckpt, out, writer_factory=...);
  scripts/gguf_quant_writer.py is the quantizing writer.
  convert_pi05_to_gguf.py accepts an OpenPI checkpoint without config.json.
- Tooling: foldquant_fake_export.py (uncalibrated file for bring-up),
  inspect_gguf_quant.py (contract check), foldquant_ref.py (numpy reference),
  foldquant_dequant.py (dequantizing exporter).

Tests: CPU-op, CUDA-op and GEMM checks for FoldQuant; Python tests for the
reference and the converter.
@hungho77
hungho77 force-pushed the feat/quantized-inference branch from 999cb88 to 16c0ce1 Compare September 30, 2026 07:34
main's performance review (#32) moved pi0.5's Gemma layers into the shared
modules/gemma_expert.h and precomputes the DiT adaLN modulation per
denoising step (FlowTimes, ActionExpert::denoise).

- GR00T: main's per-step adaLN precompute replaces this branch's adaLN
  cache (same idea); the FoldQuant LLM / DiT sites and the GEMM prefetch
  chain are kept on top.
- pi0.5: the FoldQuant paths move into gemma_attn / gemma_mlp /
  gemma_layer. Prefix layers fuse the RMSNorm and folded gamma into the act
  node and the residual into the o / down epilogues; expert layers quantize
  the adaRMS output (gated residuals stay out of the epilogue). pi0 has no
  FoldQuant sites, so its float path is unchanged.
- loader: main's resident-type fuse check, plus the same source type for a
  tensor copied raw (INT8 codes).
- convert_pi05_to_gguf.py: convert() and the OpenPI config merge, plus
  main's QUANTILES-only check.
- Docs: FoldQuant under [Unreleased] in the CHANGELOG, its section in
  docs/MODELS.md, docs/QUANTIZATION.md linked from the README.
…ernels

Metal, SYCL, OpenVINO, Hexagon and OpenCL have no implementation of the two
FoldQuant custom nodes and vla.cpp drives one backend with no per-op
fallback, so a FoldQuant GGUF was refused there. Now foldquant_check_backend
switches such a backend (or CUDA/CPU with VLA_FQ_DEQUANT=1) to dequant mode:
every site is registered with the loader as a float GEMM weight, rebuilt at
upload in the resident type, and the arch takes its stock float path.

The activation path is x' = R(x / a) (fold before) or R(x) / a (after), R the
block-normalised Sylvester-Hadamard butterfly (symmetric, orthonormal), so
row n of the float weight is R(w_n) / a or R(w_n / a) (fq_dequant_rows). The
LLM sites' SmoothQuant vector is already folded into the norm gains the file
carries, which the float path reads as its norm weights.

- WeightLoader::as_float registers a tensor of another file type as a float
  [K, N] weight; gemm/opt_gemm, fuse_gemm (DiT fused q/k/v, k/v) and upload
  honour it, converting to the resident type.
- The four arch callers hold the spec mutable so the check can set the mode.
- test_foldquant_dequant checks the rebuilt weights against the dense product
  (W8/W4, both fold orders, with and without ascale, a narrowed block).

Weight-only quantization on those backends: weights keep FoldQuant's
rounding, activations stay float. pi0.5 LIBERO W4A4 (vrfai/pi05-libero-w4a4):
action cosine vs bf16 0.99986 dequant, 0.99703 integer path; dequant CUDA vs
CPU 1.00000. GR00T N1.7 LIBERO W4A4: integer vs dequant 0.99930, dequant CUDA
vs CPU 1.00000.
ggml's OpenVINO backend now runs a FoldQuant GGUF natively on the CPU and GPU
plugins instead of reading the sites back as float weights.

- src/openvino/foldquant_ov.cpp, compiled into ggml-openvino: fq_act becomes
  the CPU reference's arithmetic in OpenVINO ops (RMSNorm with the folded
  gamma, the ascale divide, the block rotation as a MatMul with the
  normalised Hadamard matrix, per-token amax scale, round-half-even, clamp)
  and passes codes and scale as floats to fq_gemm, which multiplies them with
  the weight kept as an i8 / i4 constant (W4 bytes reinterpreted in place),
  dequantized by wscale in the decompression pattern the plugins keep
  compressed.
- patch_ggml_openvino.py hunk 14: register the translator for GGML_OP_CUSTOM,
  let supports_op accept FoldQuant's nodes (only those) ahead of the type
  checks, keep INT8 weights as i8 constants.
- foldquant_check_backend: native on OpenVINO CPU/GPU, dequant on the NPU or
  with VLA_FQ_DEQUANT=1; the head-laid-out epilogue is off there
  (FqModuleSpec::no_heads), since a translated graph has no raw layout.
- test_foldquant_ov_op: one site through the backend against the CPU
  reference (W8A8, W4A8, W4A4; gamma, ascale before/after, bias, narrowed and
  no rotation): every code agrees, outputs within 1e-6 relative, on an Intel
  Core Ultra X7 358H CPU and its Arc B390 iGPU.

Whole model, against the CUDA integer path: W8A8 pi0.5 1.00000 (CPU),
0.99999 (GPU). W4A4 pi0.5 / GR00T N1.7 0.9993 / 0.9987 (CPU): with 15
activation levels a code on a rounding tie flips by one when the reductions
run in a different float order, and the flips compound. pi0.5 W4A4 on the CPU
plugin: 3926 ms vs 6595 ms bf16, 6.7 GB vs 16.5 GB peak RSS; on the iGPU
651 ms vs 560 ms, 3.8 GB vs 11.6 GB.
fq_act now emits the integer-valued codes already multiplied by their
per-token scale, and fq_gemm feeds that straight into the MatMul with the
decompressed weight. The scale factors out of the GEMM, so this drops the
Concat, the two Slices and the scale Multiply at every site with the same
arithmetic.

Measured on an Intel Core Ultra X7 358H, pi0.5 LIBERO W4A4, against the
codes+scale form: iGPU 666 -> 645 ms, CPU 4.69 -> 4.15 s (CPU timings on that
machine drift by ~20% between runs). Accuracy against the CUDA integer path is
unchanged: W8A8 1.00000 (CPU) / 0.99999 (GPU), W4A4 + o/down INT8 0.99962 /
0.99971, W4A4 0.99936 / 0.99928. test_foldquant_ov_op passes on the Intel CPU,
the Arc B390 iGPU and OpenVINO's ARM CPU plugin.

Tried and dropped: the plugins' dynamic activation quantization (on by default
on the CPU, group 32, and the fastest setting there; no effect on the iGPU),
and a group-quantized [N, K/g, g] weight layout (no faster on the iGPU). The
iGPU stays behind bf16 because its compressed-weight matmul decompresses to
F16 rather than running int8.
…sion hook

pi0.5 W4A4 on an Arc B390 iGPU runs in 291 ms (bf16: 326 ms), W4A4 with
INT8 o/down in 290 ms, W8A8 in 330 ms; every node bit-identical to the CPU
reference.

- scripts/patch_ggml_sycl_ext_hook.py: the SYCL counterpart of the CUDA hook,
  two exported pointers (compute, supports_op for GGML_OP_CUSTOM) consulted
  first by ggml-sycl; null by default, so a hooked ggml behaves as stock.
- src/sycl/vla_sycl_foldquant.cpp:
  - fq_act follows the reference's reduction tree (lane l of a 32-wide
    sub-group owns chunks l, l+32, ...). Rows up to K = 8192 spread over a
    work-group (the CUDA act_row_kernel, ported); longer ones run one
    sub-group per row.
  - fq_gemm runs oneDNN's int8 matmul (INT4 weights as s4, INT4 activations
    unpacked to s8) into an int32 buffer, then the reference epilogue.
    Integer sums are exact in any order. Without oneDNN, or with
    VLA_FQ_SYCL_GEMM=native, a GEMV (M <= 32) and an int8 joint_matrix (XMX)
    kernel take over; =simple keeps a plain tiled one.
- Exactness: -ffp-contract=off for the source, and the device JIT option
  -cl-fp32-correctly-rounded-divide-sqrt (a link option); without it the
  driver's division and sqrt move codes across rounding ties. The CPU
  reference gets -fp-model=precise under icpx, whose host default
  (-fp-model=fast) reassociates its sums.
- foldquant_check_backend registers the kernels on a SYCL backend (plain
  [N][T] outputs, so no head layout); VLA_FQ_DEQUANT=1 still selects dequant.
- test_foldquant_sycl_op: W8A8/W4A8/W4A4/W8A4 with gamma, ascale
  before/after, bias, residual, narrowed and no rotation, over every GEMM
  path and production shapes (K = 16384 / 6144 / 2048, M up to 300): 168
  cases, activation bytes and outputs bit-identical.
- Docs: QUANTIZATION.md SYCL section, backend/sycl.md numbers and the Level
  Zero loader workaround, MODELS.md, CHANGELOG, the foldquant.h overview.

Whole model vs the CUDA integer path: W8A8 1.00000 action cosine, W4A4 +
o/down INT8 0.99966, W4A4 0.99912 (pi0.5) / 0.9993 (GR00T N1.7); the gap is
the float layers between the sites landing codes on other rounding ties.
- build-sycl: oneAPI 2026.1 (DPC++, oneMKL, oneDNN) on ubuntu-24.04, full
  build with -Werror, then ctest on the OpenCL CPU device the compiler
  package ships. ggml-sycl will not start without a GPU, so
  test_foldquant_sycl_op now hands its two nodes straight to the extension
  hook on any SYCL device, tensors in USM shared memory, and checks them
  against the CPU reference as before (GPU: still through ggml-sycl).
  VLA_FQ_TEST_REQUIRE_DEVICE turns a missing device into a failure.
- build-openvino: OpenVINO 2026.4 archive (cached), full build with -Werror,
  ctest; the CPU plugin runs test_foldquant_ov_op on the runner.
- build-gate checks the SYCL hook patch still applies, and is idempotent.
- icpx compiles first-party code with -fp-model=precise (its default is fast,
  which broke test_vision_common's exact float compare); ggml keeps its own
  flags. The intended -ffp-contract=off override is no longer warned about.
- The oneDNN GEMM path falls back to the native kernels, once per queue, on a
  device oneDNN cannot drive.
The pi0.5 W8A8 rows came from scripts/foldquant_fake_export.py (rotation and
per-row RTN, no calibration). Re-measured with FoldQuantVLA's W8A8 arm
(w8a8_sr + w8a8_sh) calibrated on the same 128 LIBERO frames as
vrfai/pi05-libero-w4a4, converted byte-identical to its TensorRT graphs:
SYCL 305 ms (bf16 326), OpenVINO GPU 655 ms / 4.7 GB, CPU 3653 ms / 8.6 GB;
actions vs the CUDA integer path 0.99999 (SYCL), 1.00000 (OpenVINO).
The tables now name the checkpoints they were measured on.
- FoldQuant SYCL: oneDNN only on GPU devices. On the CI runner's OpenCL CPU
  device (an AMD EPYC, no VNNI) oneDNN's int8 matmul sums pairs in saturating
  16-bit lanes and the outputs were off; the native kernels (and oneDNN with
  s4 weights) were exact there. vla.cpp runs ggml-sycl on GPUs only.
- test_graph_names: an OpenVINO build already defines GGML_USE_OPENVINO;
  redefining it broke the -Werror build.
The CI runner's OpenCL CPU compiler crashed (free(): invalid pointer in
sycl-kernel-reduce-cross-barrier-values) JIT-compiling the module, which
holds every kernel, the XMX one included. With per-kernel images a device
compiles only the kernels it launches. No change on the Arc GPU (W4A4 p50
291 ms); 3/3 CPU-device runs pass on the Intel PC.

QUANTIZATION.md: W4A4 SYCL vs CUDA is 0.9995 now that host code is built
with precise floating point under icpx (bf16 moved by 6e-4 with it).
The CUDA path's diagnostic, ported: after each node, recompute it with the
CPU reference on host copies of the same inputs and report byte
differences. On an Arc B390, GR00T N1.5 W4A4 (732 nodes) and N1.6 W4A4
(1376 nodes) are byte-identical node for node.
@hungho77
hungho77 merged commit 7240d27 into main Oct 5, 2026
7 checks passed
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