Skip to content

imatrix: calculate activation-based statistics for new format (GGUF) imatrices #14891

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 61 commits into
base: master
Choose a base branch
from

Conversation

EAddario
Copy link
Contributor

Following up from #9400 and #12718, I've started tinkering with activation-based statistics, in addition to what's currently available via --show-statistics.

At the moment, I'm exploring three options going from from easy to implement and OK approximation, to some assembly required but fairly accurate:

  1. L2 norm of activation difference: where larger values would suggest the tensor has significantly transformed the input with respect to the previous layer.
  2. KL Divergence reduction using a pre-computed logit file: using a similar approach as described by nostalgebraist in logit lens, and based on a pre-computed logit file (e.g. from a previous llama-perplexity --save-all-logits run)
  3. Given that llama-imatrix already generates the actual logits to compute PPL, use Thông T. Nguyễn's logit prism approach to calculate the exact contribution of each layer to the final logit scores

Sharing with the readers, and in particular @compilade and @jukofyork, in case anyone's willing to double check assumptions and/or suggest alternative approaches I haven't considered.

@EAddario EAddario marked this pull request as draft July 26, 2025 16:47
@jukofyork
Copy link
Collaborator

jukofyork commented Jul 29, 2025

L2 norm of activation difference: where larger values would suggest the tensor has significantly transformed the input with respect to the previous layer.

If we had access to some numerical linear algebra routines then it would likely be possible to get much more interesting stats from this.

If you think about it:

  • The L2 norm of the activation difference is just measuring the Euclidean distance of the tip of the input vector vs the tip of the output vector.
  • The mean of these norms probably isn't that interesting (but could be used to test if a quant is systematically biasing or scaling the activations).
  • The variance of these norms is likely much more interesting and tells you about the "richness" of the transformation (indirectly - see below).

If instead of using the L2 norms of the differences, we construct the cross-covariance matrix of the paired samples, and then take the SVD of this:

  • The "richness" of the transformation (measured indirectly above) is actually to do with the distribution of the singular values, eg: there are many sets of activation differences with the same L2-norm, but those with a flat(er) distribution of singular values (vs a couple of large singular values) are likely to be much more important and interesting.
  • If you convert the SVD into a polar decomposition, then the scaling and rotational components will likely lead to other interesting insights, eg:

I suspect that the scaling part of the transformation is quite well handled by the current scaler quants, but the rotational component is likely not.

IIRC, some of the 1-2bit quants use vector quantization, and if so; these will likely handle the rotational components better and/or show quite different properties.

I'm on my phone ATM so can't easily link them, but there have been several papers showing:

  1. Outlier activations in LLMs matter much more than simple rate–distortion theory would suggest/measure. This is likely related to the "flatness" of the singular values, where only rarely do some singular vector directions give a high dot-product with an input activation, but when they do; they add a significant/important contribution to the output.
  2. LLMs are much more rotational than people first realised, eg: there was [IIRC] a Microsoft paper where they constrained everything to be on the surface of a unit ball, and there are several PEFT methods that purely alter the rotational directions via orthogonal transformations.

@jukofyork
Copy link
Collaborator

jukofyork commented Jul 29, 2025

If it's any use, then there is code here to analyse the symmetrised cross-covaraince matrix I used for the control vectors:

https://github.com/jukofyork/control-vectors/blob/main/direction_analyzer.py

The symmetrised version deliberately gets rid of the rotational components as there can't be made use of if we are just looking for a single direction... You can actually do the same on the anti-symmetrised version (to look at the rotational components only), but Eigen-decompostion is less useful for this as it will return all complex vectors (hence why SVD makes more sense).

I should also add that from my experiments using SVD on the tensors (ie: ignoring the activations!) of LLMs, it often appears that the early/final tensors (which actually appear to be very important and are bumped in bits in the quant routines here!), actually tend to have a less flat distribution of singular values themselves! So when you ignore the distribution of input activations - they generally appear to be doing something inherently "lower dimensional" than the middle tensors!? It would be interesting to investigate this whilst also looking at the activations...

@EAddario
Copy link
Contributor Author

I'd be lying if I were to claim I understand everything in there 🥴, but I think I got the gist.

Implementing the l2 norm seems straightforward without having to introduce additional 3rd party dependencies, but completely agree that a "light" BLAS lib will be a godsend.

For now, I'll focus on l2 norm, but will add activation variance as well (good shout!)

For a later version, I'd like to try the logit prism approach but that's for another day.

Thanks for the steer @jukofyork! more weekend reading 😁

@jukofyork
Copy link
Collaborator

I'd be lying if I were to claim I understand everything in there 🥴, but I think I got the gist.

Implementing the l2 norm seems straightforward without having to introduce additional 3rd party dependencies, but completely agree that a "light" BLAS lib will be a godsend.

For now, I'll focus on l2 norm, but will add activation variance as well (good shout!)

For a later version, I'd like to try the logit prism approach but that's for another day.

Thanks for the steer @jukofyork! more weekend reading 😁

If you want to learn more about Linear Algebra then Gilbert Strang's video lectures are amazing:

https://www.youtube.com/playlist?list=PLE7DDD91010BC51F8

(IIRC, the first lecture only is bad resolution, so don't be put off by that!)

or if you like books:

https://www.amazon.co.uk/Practical-Linear-Algebra-Textbooks-Mathematics/dp/0367507846

(or one of the earlier editions of this same book)

gives a really solid foundation in terms of 2D and 3D.

The biggest problem breaking into it is for some reason American Universities decided to make it much more abstract and proof-based than it needs to be (probably to weed out potential math-majors!).

If you look at some much older pre-1980s books, or books not aimed at Westerners, then it's surprising how approachable it is:

https://mirtitles.org/?s=linear+algebra

@jukofyork
Copy link
Collaborator

but completely agree that a "light" BLAS lib will be a godsend.

I have tried to bring this up before:

#8831 (reply in thread)
#8831 (comment)

I think it would be fairly straightforward to port the non-complex routines and then open up all that GSL has to offer:

https://www.gnu.org/software/gsl/doc/html/linalg.html

instead of trying to rewrite numerical routines that have had 1000's and 1000's of thought and testing put into them! :)

@EAddario EAddario marked this pull request as draft August 16, 2025 09:56
@EAddario EAddario marked this pull request as ready for review August 16, 2025 10:01
Copy link
Collaborator

@compilade compilade left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comments starting with (style) are (potentially subjective) formatting and/or code layout comments, while the (few) other ones are about correctness.

@CISC
Copy link
Collaborator

CISC commented Aug 16, 2025

Including the activations during file generation will double the size of the imatrix so I have added a new flag --activation-statistics to make it optional.

Doubling the size of the imatrix isn't really that concerning, I would rather have the activation data stored by default. @compilade what's your viewpoint?

@EAddario
Copy link
Contributor Author

EAddario commented Aug 16, 2025

The imatrix for some of the recent models, specially with multi-modal support, can be quite big. The Kimi-K2-Instruct for example is 1.5 GB in legacy format, and GLM-4.5 is almost 700 MB

Happy to reverse the change (one less option to worry about 😁), but thought of being mindful of the user's disk space.

@EAddario EAddario requested a review from compilade August 17, 2025 10:29
@jukofyork
Copy link
Collaborator

@EAddario @compilade here is what I failed to post to discord and pastebin:

Idea

If you apply a single permutation of the model (hidden) dimension consistently to every parameter that reads from or writes to that dimension, you get a functionally identical Transformer/LLM (up to floating‑point noise). The permutation must be applied everywhere the d_model axis appears (embeddings, norms, attention and MLP projections, residual outputs, final LM head).

P does not need to satisfy P = P^T. It only needs to be a permutation matrix, which implies P^T = P^{-1}. P = P^T holds only for involutive permutations (made of 1- and 2-cycles) and is not required.

How to transform the weights

Let P ∈ R^{d×d} be a permutation matrix and use the same P across the whole model. Using row‑vector notation (token state x ∈ 1×d):

General rule

  • Any linear map W: hidden → S (e.g., q/k/v, up/gate in MLP):
    W' = P^T W; bias stays the same.
  • Any linear map W: S → hidden (e.g., o_proj, down in MLP, residual projections):
    W' = W P; bias b' = b P.
  • Any linear map W: hidden → hidden:
    W' = P^T W P; bias b' = b P.
  • LayerNorm/RMSNorm parameters on the hidden axis:
    γ' = P γ, β' = P β.
  • Embeddings that produce hidden states (token, positional):
    E' = E P.
  • Final LM head:
    • If tied to the input embedding, just tie to E' and you're done (because P is orthogonal: P^T = P^{-1}).
    • If untied and logits = x W_out, then W_out' = P^T W_out.

Why this works (sketch)

Inductively assume the hidden state becomes x' = x P. LayerNorm is permutation‑equivariant if you also permute γ, β, so LN(x') = LN(x) P. Then:

  • Attention: Q' = LN(x) P · (P^T W_Q) = LN(x) W_Q = Q (same for K, V), so attention weights and outputs are unchanged; only the post‑projection to hidden becomes H' = H P, preserving the residual x' + H' = (x + H) P.
  • MLP: preactivations are unchanged (W_1' = P^T W_1), and the post‑projection gives the same hidden update multiplied by P (W_2' = W_2 P), preserving the residual.

RoPE and other position encodings

For models using RoPE (Rotary Positional Embedding), this doesn't matter because RoPE is applied after the Q and K projections. Since the projections themselves are unchanged in the transformed model (as shown above), RoPE operates on the same Q and K values and produces identical results.

Caveats and notes

  • You must apply the same permutation P everywhere the model dimension appears, including all biases on the hidden axis and all normalization parameters.
  • This works with both LayerNorm and RMSNorm for permutations. More general basis changes (arbitrary orthogonal or invertible matrices) do not preserve the form of normalization because the learned scale is diagonal; permutations are the safe symmetry.
  • If you use tied embeddings, the orthogonality of P (true for any permutation) ensures logits are unchanged when you set E' = E P and tie to E'.
  • Training randomness (dropout) or quantization/per‑channel calibration may make bit‑for‑bit equality hard; functionally it's the same.
  • There are other independent symmetries too (e.g., permuting attention heads; permuting and rescaling neurons in the FFN), but those are separate from this global d_model permutation.

Properties of P

  • Permutation matrix: entries in {0,1}, exactly one 1 per row/column; P^T = P^{-1}.
  • Not necessary: P = P^T. That holds only for involutions (P^2 = I), which is a special case and not required.

Again sorry for the LLM-written output - I'm on my phone and on holiday so can't easily write all that out!

For our specific case the order within each block (and then the order within each sub-block for the K-quants) won't matter, but there will still be an astronomically high number of block permutations and it will need some greedy algorithm to work...

I'm still unsure on what the best metric would be to to optimise too...

@jukofyork
Copy link
Collaborator

jukofyork commented Aug 23, 2025

I also forgot to ask Claude to add the bit about the intermediate dimension of the MLP blocks allowing for a separate permutation matrix for each.

Edit: Actually he decided to add this himself lol:

There are other independent symmetries too (e.g., permuting attention heads; permuting and rescaling neurons in the FFN), but those are separate from this global d_model permutation.

I didn't think of the attention heads, but if they are smaller than the block size (which most are), then these can also be permuted.

@EAddario
Copy link
Contributor Author

@CISC / @compilade, polite nudge to check if OK to merge?

const std::string in_sum2_suffix{ ".in_sum2" };
const std::string counts_suffix{ ".counts" };

// Could re-use m_stats instead, but this allows
// checking for completeness of *each* loaded imatrix file
// and also makes it easier to re-use a similar implementation in quantize.cpp
// Using an ordered map to get a deterministic iteration order.
std::map<std::string, std::pair<struct ggml_tensor *, struct ggml_tensor *>> sums_counts_for;
std::map<std::string, std::tuple<struct ggml_tensor *, struct ggml_tensor *, struct ggml_tensor *>> sums_counts_for;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a tuple, a small struct with struct ggml_tensor * fields might be more convenient.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think keeping it as a tuple simplifies the code and aids maintainability, but if this approach would be a blocker for merging, happy to change.

};

class IMatrixCollector {
public:
IMatrixCollector() = default;
void set_params(common_params params) { m_params = std::move(params); }
bool activation_statistics() const { return m_params.activation_statistics; }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the optionality of the sums of activations, I'm not yet sure how they could be used in the quantization algorithms.

I'm not against doubling the file size if it avoids having to recalculate the imatrix because the data ended up being necessary.

The importance in the quantization algorithms cannot really handle ranges of input values specifically (e.g from mean and std dev), because matmuls are linear.

The only use I see is informational (unless I'm missing something).

Also currently the displayed statistics completely ignores the sums of squared activations when the non-squared ones are available (see #14891 (comment)), and so there's a reason to let both paths be possible.

Copy link
Contributor Author

@EAddario EAddario Aug 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The driver for adding statistics functionality was informational, and to support identifying and ranking which tensors/layers are most influential during inference.

Before #9400, only mean squared activations were accessible so the stats, whilst useful, ignored the direction of change (no minus signs) making them less than ideal, but better than nothing :)

Post #9400, including the mean activations not only yields better statistics, but also opens the door to other possibilities, like being able to estimate quantisation error to programatically choose the best quant types (PR #15550)

To keep the report output manageable, I opted to display the L2 Norm instead of the squared activations (more useful IMO).

Comment on lines +179 to 195
if (e.activations.empty()) {
activations.reserve(e.values.size());

for (int i = 0; i < n_mat; ++i) {
for (int j = 0; j < row_size; ++j) {
activations.push_back(e.values[i*row_size + j] / e.counts[i]);
}
}
} else {
activations.reserve(e.activations.size());

for (int i = 0; i < n_mat; ++i) {
for (int j = 0; j < row_size; ++j) {
activations.push_back(e.activations[i*row_size + j] / e.counts[i]);
}
}
}
Copy link
Collaborator

@compilade compilade Aug 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the sums of activations are available, this is completely ignoring the sums of squared activations????

All of the new statistics are done over the per-channel means.

This doesn't seem right.

The sums of squared activations are used for quantization importance, and if they're completely ignored, then the statistics are possibly meaningless for importance purposes.

The mean and mean of squared activations together should allow calculating per-channel variance and stuff like that. Not sure how to turn that into per-tensor stats, though it's likely possible somehow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, and by design. When available, using mean activations instead yields better statistics since the direction of the change (minus sign) is now available. The ECS stat (dot product of cossim and l2 norm), for example, correctly identifies attn_output and ffn_down as the most sensitive to quantisation. This is not possible with mean of squared activations.

The idea of deriving the per-channel variance through mean and mean of squared activations is quite interesting. I'll look into for a future release.

@EAddario
Copy link
Contributor Author

@CISC / @compilade, ready for round 3 :)

I left some comments explaining the rationale behind design decision. Let me know if acceptable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants