Chapters ยท 07
Model Compression for the ANE: Palettization, Mixed-Bit Recipes, Outlier Decomposition
Part of the ANE Knowledge Base. Source:
argmaxtools.compress(references/argmaxtools-0.1.23-pypi-snapshot/argmaxtools/compress/), as used by whisperkittools'--generate-quantized-variantspipeline. Complements the WhisperKit case study (doc 06).
1. Why compression is a latency optimization on the ANE#
Apple's Principle 4 (doc 02): small-batch transformer inference on the ANE is bandwidth-bound โ latency is dominated by fetching weights, not by arithmetic. Shrinking the weights therefore reduces latency, not just app size. KV-cached decoding (one token per call) is the extreme case. This is why argmaxtools' test suite asserts that a 1-bit palettized model must run at โฅ 0.95ร the FP16 speed (TEST_COMPRESSION_MIN_SPEEDUP) โ compression should be free or better at runtime.
2. Palettization (the Core ML-native technique)#
Palettization = k-means-cluster each weight tensor into a 2^nbits-entry lookup table (LUT) and store per-weight indices. Supported bit widths: {1, 2, 3, 4, 6, 8} (SUPPORTED_NBITS); activations stay FP16 โ only weight storage shrinks, decompression is hardware/runtime-handled. Applied post-training on the converted model:
config = ct.optimize.coreml.OptimizationConfig(op_name_configs={
op_name: ct.optimize.coreml.OpPalettizerConfig(
mode="kmeans", nbits=nbits,
granularity="per_tensor", # or "per_grouped_channel" + group_size
) ...
})
mlmodel = ct.optimize.coreml.palettize_weights(mlmodel, config=config)
OS gates (asserted in the code): 3-bit palettization and per_grouped_channel granularity (group sizes 4โ256) require iOS 18 / macOS 15; the whisperkittools CLI exposes --palettization-group-size {4,16,32,64,128,256}.
Not everything gets palettized (_get_compressible_modules, _find_nbits_in_recipe):
- tensors with
numel < 1e5(insignificant), - tensors with sparsity > 0.8 (better served by pruning),
- non-FP16 tensors,
- optionally the top-K most sensitive layers (
DONT_PALETTIZE_TOP_K = 3) stay FP16.
3. Mixed-bit recipes: per-layer sensitivity profiling#
Uniform low-bit compression degrades quality unevenly โ some layers tolerate 2 bits, others break below 8. The Palettizer class (compress/palettize.py) automates finding a per-layer bit assignment ("recipe"):
The machinery#
- Fake palettization (
_fake_palettize): run k-means exactly as coremltools would, but write the dequantized values back into the FP16 torch tensor โ simulating compression while staying in PyTorch for fast evaluation. Results are cached to disk per(layer, nbits)for warm restarts. - Divergence metric: an abstract
divergence_fn(reference, proxy)compares end-to-end model outputs against the uncompressed reference on a held test batch (TEST_BATCH_SIZE = 32). Model-specific subclasses define what "output" means (e.g., decoder logits). - Per-layer response (
profile_per_layer_response): palettize one layer at a time at each nbits; record divergence. This is the sensitivity map. - Sanity check (
_sanity_check_per_layer_results): divergence must decrease with more bits; if > 10% of (layer, bit-pair) results are inverted, the test batch is too small โ abort. - Recipe generation (
profile_mixed_bit_response): sweepnp.geomspacedivergence thresholds; for each threshold assign every layer the lowest nbits whose per-layer divergence is under it. Each recipe is keyed by its average bit precision (e.g."3.7"), then evaluated end-to-end. - Cumulative response (
profile_cumulative_response): palettize layers cumulatively in ascending-divergence order to visualize the size-vs-quality frontier (plot()produces response curves).
Applying a recipe to the Core ML model#
Recipes are computed on torch modules but must be applied to MIL ops whose names differ. The bridge is a content hash: 4 fixed elements of each FP16 weight tensor are bit-packed into a float64 key (get_tensor_hash), letting apply_recipe_coreml match every Core ML weight back to its torch module โ robust to name mangling, with explicit hash-collision detection.
Validation ladder (from CoreMLPalettizerTestsMixin)#
Every recipe passes three PSNR checks (threshold 35 dB) before an asset is saved:
- fake-palettized torch vs fake-palettized Core ML (conversion is faithful),
- fake-palettized Core ML vs real-palettized Core ML (real LUT โ simulation),
- fake-palettized torch vs real-palettized Core ML (strictest, end-to-end).
What ships#
For each Whisper release, only the smallest and largest recipe variants are published, and folders are named by total artifact size in MB (openai_whisper-tiny_216MB), not bits โ size is what users care about (generate_model.py::rearrange_quantized_variants). Full profiling artifacts go to hf.co/argmaxinc/compression_artifacts.
4. Sparse outlier decomposition#
Low-bit palettization suffers when a weight tensor has a few extreme values: outliers stretch the k-means palette and waste LUT entries. compress/sparse_outlier.py implements the classic inlier + outlier decomposition (cf. LLM.int8()-style ideas), ANE-adapted:
outlier_inds = (w - w.mean()).abs() > w.std() * 3 # OUTLIER_NUM_STD = 3
w_inlier = w with outliers zeroed โ palettized (dense, low-bit)
w_outlier = w with inliers zeroed โ kept FP16, stored SPARSE
DecomposedModulereplaces the original layer with two parallel layers whose outputs are summed (inlier_module(x) + outlier_module(x)) โ graph-level decomposition, no custom kernels.- The outlier branch is compressed in Core ML with
prune_weights+OpThresholdPrunerConfig(threshold=1e-6)โ since it's already zero-masked, thresholding converts it to a sparse representation losslessly. - Estimated overhead is logged as
bits_overhead = 1 + outlier_fraction ร 16extra bits/parameter โ with 3ฯ outliers (~0.3% of weights) that's โ 1.05 bits, cheap insurance for palette quality. - Toggled via
SPARSE_OUTLIER_DECOMPOSITION/ the--outlier-decompCLI flag;AudioEncoder's conv stem handles the decomposed weights explicitly (audio_encoder.py::pre_transformer_proj).
5. Compression decision guide (ANE targets)#
| Situation | Technique |
|---|---|
| Model fits, latency bandwidth-bound | Uniform palettization (4โ6 bit is usually safe) |
| Quality drops unevenly at low bits | Mixed-bit recipe from per-layer divergence profiling |
| A few layers dominate the error | Keep top-K sensitive layers FP16 (DONT_PALETTIZE_TOP_K) |
| Heavy-tailed weight distributions | Sparse outlier decomposition + palettize the inlier |
| Highly sparse tensors (>80% zeros) | Prune, don't palettize |
| iOS 18+/macOS 15+ available | 3-bit palettes, per_grouped_channel granularity for finer LUTs |
| Verifying any of the above | PSNR ladder โฅ 35 dB + speed test โฅ 0.95ร (doc 06 ยง8) |
Related upstream docs: coremltools palettization guide (cited in the source).