Skip to main content

ferrox_models/sampling/
greedy_equivalence.rs

1//! When an argmax may stand in for the whole sampler chain, and over
2//! WHICH logits it may stand in.
3//!
4//! # The two questions, which are not the same question
5//!
6//! There are two callers, and they hand an argmax two different vectors:
7//!
8//! * [`super::greedy_choice`] argmaxes scores the penalties have
9//!   ALREADY been applied to ([`super::penalties::apply_history_penalties`]
10//!   runs first, on the greedy path as well as the sampled one). It
11//!   needs to know whether anything LEFT in the chain can move the
12//!   argmax.
13//! * A backend that folds `final_norm + lm_head + argmax` into its
14//!   decode stack argmaxes the **raw** logits on the device and returns
15//!   one token id. Nothing on the host ever sees the vocabulary, so the
16//!   penalties never happen at all. It needs to know whether the WHOLE
17//!   chain, penalties included, can move the argmax.
18//!
19//! Until GitHub issue #170 those were one predicate,
20//! `SamplingParams::greedy_equals_argmax`, whose body answered the first
21//! question and whose name and Metal callers asked the second. It tested
22//! XTC, typical-p and DRY and did not test the penalties, so on Metal at
23//! `--ngl 99 --temp 0` with this project's default `--repeat-penalty
24//! 1.1` the fold silently dropped the repetition penalty and returned a
25//! different token from the host path and from the CPU reference.
26//!
27//! Measured on an M2 Pro, `Llama-3.2-3B-Instruct-Q4_K_M --ngl 99
28//! --temp 0 --no-cnv`, 64 tokens, md5 of the completion:
29//!
30//! | path | `--repeat-penalty` | md5 |
31//! |---|---|---|
32//! | Metal, fold on | 1.1 (default) | `85ef7c43…` |
33//! | Metal, fold off | 1.1 (default) | `36c6ae0d…` |
34//! | CPU reference | 1.1 (default) | `36c6ae0d…` |
35//! | Metal, fold on | 1.0 | `85ef7c43…` |
36//! | Metal, fold off | 1.0 | `85ef7c43…` |
37//!
38//! The folded answer at 1.1 is bit-identical to the answer at 1.0, which
39//! is what "the penalty never ran" looks like, and the unfolded answer
40//! agrees with the CPU reference exactly.
41//!
42//! # Why this is a table and not two `&&` chains
43//!
44//! Because the thing that went wrong is the repo's dominant defect
45//! shape: a predicate hand-listing a SUBSET of the sampler, with nothing
46//! tying the list to the sampler. So the classification is one
47//! EXHAUSTIVE `match` over [`ChainStep`] and one EXHAUSTIVE destructure
48//! of [`SamplingParams`] with no `..`. A step added to the chain, or a
49//! knob added to the params, does not compile until somebody says what
50//! it does to an argmax.
51//!
52//! The two predicates then differ by exactly one clause -- whether
53//! [`ChainStep::Penalties`] is excused because the caller already ran it
54//! -- which is the whole of issue #170 written down in one line.
55
56use super::SamplingParams;
57use crate::sampler_order::ChainStep;
58
59/// What one chain step does to the argmax of the scores it is handed.
60///
61/// Three states rather than a `bool`, because "cannot change any score"
62/// and "changes scores but never which one is largest" are different
63/// facts, and only the first says the step is switched off. Reading them
64/// as one would make the default chain look live and the live chain look
65/// harmless, depending on which way the collapse went.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub(crate) enum StepEffect {
68    /// Configured to its own no-op value: it cannot change any score.
69    Inert,
70    /// Live, and keeps the maximum by construction: it either filters
71    /// candidates by a threshold measured FROM the maximum, or scales
72    /// every score monotonically.
73    KeepsTheMaximum,
74    /// Live, and can change which token the argmax picks -- either by
75    /// removing the maximum from the candidate list or by moving a
76    /// logit past it.
77    MovesTheArgmax,
78}
79
80/// What `step` would do to an argmax, given `params`.
81///
82/// Chain MEMBERSHIP is the caller's question, not this function's: the
83/// callers walk `params.sampler_order.steps()`, and a step a caller left
84/// out of `--samplers` is a step llama.cpp does not run.
85pub(crate) fn step_effect(step: ChainStep, params: &SamplingParams) -> StepEffect {
86    // EXHAUSTIVE, with NO `..`. A knob added to `SamplingParams` stops
87    // this crate compiling here until someone states what it does to an
88    // argmax. That is the enforcement issue #170 was missing: the old
89    // predicate hand-listed three of the chain's nine steps and nothing
90    // noticed that the penalties were not among them.
91    //
92    // A field bound to `_` is one whose verdict is written in the arm
93    // that consumes it; binding it is what keeps the pattern exhaustive.
94    let SamplingParams {
95        temperature,
96        top_p,
97        min_p,
98        top_k,
99        typical_p,
100        top_n_sigma,
101        // Read together through `SamplingParams::xtc_can_fire`, which
102        // exists precisely so the two conditions are not restated. See
103        // the `Xtc` arm.
104        xtc_probability: _,
105        xtc_threshold: _,
106        dry,
107        repetition_penalty,
108        penalty_last_n,
109        presence_penalty,
110        frequency_penalty,
111        // Membership is the caller's loop; see this function's doc.
112        sampler_order: _,
113    } = params;
114
115    match step {
116        // The repetition / presence / frequency penalties move
117        // individual logits, so they can move which logit is the
118        // maximum. This is the arm the old predicate did not have.
119        //
120        // The switched-off conditions restate `apply_history_penalties`'s
121        // own early returns, and
122        // `tests::the_inert_verdict_matches_what_the_penalty_step_actually_does`
123        // walks a matrix asserting the two agree, rather than trusting
124        // this copy of them.
125        ChainStep::Penalties => {
126            let neutral =
127                *repetition_penalty == 1.0 && *presence_penalty == 0.0 && *frequency_penalty == 0.0;
128            if *penalty_last_n == 0 || neutral {
129                StepEffect::Inert
130            } else {
131                StepEffect::MovesTheArgmax
132            }
133        }
134        // DRY subtracts from the logits of tokens that would extend a
135        // repetition, so like the penalties it can move the maximum.
136        ChainStep::Dry => {
137            if dry.is_enabled() {
138                StepEffect::MovesTheArgmax
139            } else {
140                StepEffect::Inert
141            }
142        }
143        // Keeps candidates within `n` standard deviations BELOW the
144        // maximum, so the maximum is always one of them.
145        ChainStep::TopNSigma => {
146            if *top_n_sigma <= 0.0 {
147                StepEffect::Inert
148            } else {
149                StepEffect::KeepsTheMaximum
150            }
151        }
152        // Keeps the `k` most likely, which starts at the maximum.
153        ChainStep::TopK => {
154            if *top_k == 0 {
155                StepEffect::Inert
156            } else {
157                StepEffect::KeepsTheMaximum
158            }
159        }
160        // Selects OUTWARD from the distribution's entropy and can drop
161        // the most likely token -- llama.cpp's own test case
162        // `test_typical({0.4, 0.2, 0.2, 0.2}, {0.2, 0.2, 0.2}, 0.5)`
163        // (`tests/test-sampling.cpp:346`) drops it.
164        ChainStep::TypP => {
165            if *typical_p >= 1.0 {
166                StepEffect::Inert
167            } else {
168                StepEffect::MovesTheArgmax
169            }
170        }
171        // Accumulates probability from the most likely downward and
172        // keeps at least one candidate, so the maximum always survives.
173        ChainStep::TopP => {
174            if *top_p >= 1.0 {
175                StepEffect::Inert
176            } else {
177                StepEffect::KeepsTheMaximum
178            }
179        }
180        // A threshold expressed as a fraction OF the maximum, which the
181        // maximum meets by construction.
182        ChainStep::MinP => {
183            if *min_p <= 0.0 {
184                StepEffect::Inert
185            } else {
186                StepEffect::KeepsTheMaximum
187            }
188        }
189        // Removes the TOP candidates, by construction.
190        ChainStep::Xtc => {
191            if params.xtc_can_fire() {
192                StepEffect::MovesTheArgmax
193            } else {
194                StepEffect::Inert
195            }
196        }
197        // A positive temperature divides every logit by the same
198        // positive number, which is monotone. `temp <= 0` is
199        // llama.cpp's greedy collapse (`src/llama-sampler.cpp:271-286`),
200        // which sets every logit but the maximum to `-inf`. Neither can
201        // change WHICH index is maximal, so temperature is never a
202        // reason to refuse a fold -- and it is never `Inert` either,
203        // since there is no value at which it stops touching scores.
204        ChainStep::Temperature => {
205            debug_assert!(!temperature.is_nan(), "a NaN temperature is not a chain");
206            StepEffect::KeepsTheMaximum
207        }
208    }
209}
210
211impl SamplingParams {
212    /// True when no step LEFT in the chain can move the argmax of the
213    /// scores it is handed -- scores the penalties have ALREADY been
214    /// applied to.
215    ///
216    /// This is [`super::greedy_choice`]'s question and only its
217    /// question. It is called per token, so it is a walk over at most
218    /// [`crate::sampler_order::SamplerName::ALL`]`.len()` steps with no
219    /// allocation, not a candidate list.
220    ///
221    /// A device fold must ask [`Self::greedy_equals_raw_argmax`]
222    /// instead: this one excuses the penalties because its caller
223    /// already ran them, and a device that argmaxes raw logits has not.
224    pub fn chain_keeps_the_argmax(&self) -> bool {
225        self.sampler_order.steps().iter().all(|&step| {
226            step == ChainStep::Penalties || step_effect(step, self) != StepEffect::MovesTheArgmax
227        })
228    }
229
230    /// True when the argmax of the **raw** logits is the token the whole
231    /// sampler chain would choose, penalties included.
232    ///
233    /// This is the question a backend folding `lm_head + argmax` into
234    /// its decode stack has to ask, because the fold returns one token
235    /// id and the host never sees a vocabulary: every step of the chain
236    /// is skipped, not just the candidate-list filters.
237    ///
238    /// **Three readers**, because the alternative is this repo's
239    /// dominant defect: `ferrox_cli::run`'s `needs_vocab_logits`,
240    /// `ferrox_server::generate`'s, and through them the Metal fold's
241    /// own guard in `ferrox_metal::greedy_fold`.
242    ///
243    /// Note what this costs: with the CLI's default `--repeat-penalty
244    /// 1.1` the answer is `false`, so the Metal fold does not fire in
245    /// the default configuration. That is deliberate -- see issue #170
246    /// and this module's header for the numbers. `--repeat-penalty 1.0`
247    /// or `--repeat-last-n 0` gets it back.
248    pub fn greedy_equals_raw_argmax(&self) -> bool {
249        self.sampler_order
250            .steps()
251            .iter()
252            .all(|&step| step_effect(step, self) != StepEffect::MovesTheArgmax)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::dry::{DryBreakers, DryParams};
260    use crate::penalty_window::PenaltyWindow;
261    use crate::sampler_order::SamplerOrder;
262    use crate::sampling::Sampler;
263
264    /// The CLI's defaults, which are the configuration the bug was live
265    /// in. Built here rather than imported because `ferrox-models` does
266    /// not depend on `ferrox-cli`. `ferrox_cli::run::tests::
267    /// the_default_flags_forbid_the_metal_greedy_argmax_fold` asserts
268    /// the real `default_value_t`s resolve to this.
269    fn cli_defaults() -> SamplingParams {
270        SamplingParams {
271            temperature: 0.0,
272            top_p: 0.95,
273            min_p: 0.05,
274            top_k: 40,
275            repetition_penalty: 1.1,
276            penalty_last_n: 64,
277            ..SamplingParams::default()
278        }
279    }
280
281    /// GitHub issue #170, at the predicate: the default repetition
282    /// penalty forbids a raw-logit argmax, and `1.0` permits it.
283    ///
284    /// This is what shipped broken. `--repeat-penalty` defaults to 1.1
285    /// in this project (llama.cpp's is 1.0), the Metal fold gate read a
286    /// predicate that did not test the penalties, and a greedy `--ngl
287    /// 99` run returned a token the host sampler would not have chosen.
288    ///
289    /// Sabotage: drop the `ChainStep::Penalties` arm's
290    /// `MovesTheArgmax` for `Inert`; the first assertion goes red, and
291    /// so does every gate test in `ferrox-cli` and `ferrox-server`.
292    #[test]
293    fn the_default_repetition_penalty_forbids_a_raw_argmax_fold() {
294        let defaults = cli_defaults();
295        assert!(
296            !defaults.greedy_equals_raw_argmax(),
297            "a device argmax over raw logits skips the repetition penalty"
298        );
299        // ... and the chain BEHIND the penalties is still argmax-safe,
300        // which is what makes this assertion about the penalties and not
301        // about top-k or min-p.
302        assert!(defaults.chain_keeps_the_argmax());
303
304        // The proof that the penalties are the whole difference: the
305        // same flags at `--repeat-penalty 1.0` fold again.
306        assert!(SamplingParams {
307            repetition_penalty: 1.0,
308            ..defaults.clone()
309        }
310        .greedy_equals_raw_argmax());
311        // And so does `--repeat-last-n 0`, llama.cpp's other off switch.
312        assert!(SamplingParams {
313            penalty_last_n: 0,
314            ..defaults.clone()
315        }
316        .greedy_equals_raw_argmax());
317        // The OpenAI-shaped penalties are the same statement.
318        for moved in [
319            SamplingParams {
320                repetition_penalty: 1.0,
321                presence_penalty: 0.5,
322                ..defaults.clone()
323            },
324            SamplingParams {
325                repetition_penalty: 1.0,
326                frequency_penalty: 0.5,
327                ..defaults.clone()
328            },
329        ] {
330            assert!(
331                !moved.greedy_equals_raw_argmax(),
332                "{moved:?} moves logits before the argmax"
333            );
334        }
335        // A chain that does not NAME `penalties` does not penalise, so
336        // the fold is sound however the knobs are set.
337        assert!(SamplingParams {
338            sampler_order: SamplerOrder::from_names(["top_k", "top_p", "min_p", "temperature"])
339                .expect("a chain without penalties"),
340            ..defaults
341        }
342        .greedy_equals_raw_argmax());
343    }
344
345    /// The predicate's actual contract, exercised through the sampler
346    /// rather than asserted about itself: when
347    /// [`SamplingParams::greedy_equals_raw_argmax`] says yes, the argmax
348    /// of the RAW logits is the token the chain chooses; when it says
349    /// no, this particular case proves it was right to.
350    ///
351    /// The penalty case is the reproduction: token 0 leads by 0.2, the
352    /// history contains token 0, and `1.1` divides it below token 1. A
353    /// device fold returns 0; the sampler returns 1; the CPU reference
354    /// returns 1.
355    ///
356    /// Sabotage: as above. With `Penalties => Inert` the predicate says
357    /// the fold is sound and the `assert_ne!` below shows it is not.
358    #[test]
359    fn a_penalty_that_moves_the_argmax_is_one_the_fold_must_not_skip() {
360        let logits = vec![4.0f32, 3.8, 1.0];
361        let history = || PenaltyWindow::new(&[], &[0]);
362        let params = SamplingParams {
363            repetition_penalty: 1.1,
364            ..cli_defaults()
365        };
366
367        // 4.0 / 1.1 = 3.636, which is below 3.8.
368        let chosen = Sampler::new(7).sample(&logits, &params, history());
369        assert_eq!(chosen, 1, "the penalty demotes the raw argmax");
370        assert_ne!(
371            chosen,
372            super::super::argmax(&logits),
373            "raw argmax and the chain's answer differ, so a fold is wrong here"
374        );
375        assert!(!params.greedy_equals_raw_argmax());
376
377        // Neutralise the penalty and the two agree, which is the same
378        // proof the `--repeat-penalty 1.0` row of the table in this
379        // module's header carries.
380        let neutral = SamplingParams {
381            repetition_penalty: 1.0,
382            ..params
383        };
384        assert!(neutral.greedy_equals_raw_argmax());
385        assert_eq!(
386            Sampler::new(7).sample(&logits, &neutral, history()),
387            super::super::argmax(&logits)
388        );
389    }
390
391    /// Params under which `step` is LIVE, one per step.
392    ///
393    /// EXHAUSTIVE, no `_` arm: a step added to [`ChainStep`] does not
394    /// compile until someone supplies a configuration that switches it
395    /// on, which is what stops the next sampler being forgotten the way
396    /// the penalties were.
397    fn live_exemplar(step: ChainStep) -> SamplingParams {
398        let base = SamplingParams::default();
399        match step {
400            ChainStep::Penalties => SamplingParams {
401                repetition_penalty: 1.1,
402                ..base
403            },
404            ChainStep::Dry => SamplingParams {
405                dry: DryParams::new(6.0, 1.1, 2, -1, 1024, DryBreakers::none()),
406                ..base
407            },
408            ChainStep::TopNSigma => SamplingParams {
409                top_n_sigma: 1.0,
410                ..base
411            },
412            ChainStep::TopK => SamplingParams { top_k: 40, ..base },
413            ChainStep::TypP => SamplingParams {
414                typical_p: 0.5,
415                ..base
416            },
417            ChainStep::TopP => SamplingParams { top_p: 0.9, ..base },
418            ChainStep::MinP => SamplingParams {
419                min_p: 0.05,
420                ..base
421            },
422            ChainStep::Xtc => SamplingParams {
423                xtc_probability: 1.0,
424                xtc_threshold: 0.1,
425                ..base
426            },
427            // Temperature has no off switch; see its arm in
428            // `step_effect`.
429            ChainStep::Temperature => SamplingParams {
430                temperature: 0.8,
431                ..base
432            },
433        }
434    }
435
436    /// Every step of the chain has a verdict, every step is `Inert` at
437    /// the struct's own defaults, and every exemplar switches its step
438    /// on.
439    ///
440    /// The three halves close the loop the old predicate left open. It
441    /// hand-listed three steps of nine, so a step could be added to the
442    /// chain and never appear in it; here `step_effect`'s `match` and
443    /// `live_exemplar`'s must both name every step, and this walks
444    /// `ChainStep::all()` -- which is derived from the name table -- to
445    /// prove neither list is a private one.
446    ///
447    /// Sabotage: add `top_k: 40` to `SamplingParams::default`; the
448    /// `Inert` half goes red. Or make the `TopK` arm of `step_effect`
449    /// return `Inert` unconditionally; the exemplar half does.
450    #[test]
451    fn every_chain_step_is_classified_and_neutral_at_the_struct_defaults() {
452        let neutral = SamplingParams::default();
453        let steps = ChainStep::all();
454        assert_eq!(steps.len(), 9, "the chain gained or lost a step");
455        for step in steps {
456            // Temperature is the one step with no neutral value: it
457            // always scales, and always monotonically.
458            let expected_at_rest = if step == ChainStep::Temperature {
459                StepEffect::KeepsTheMaximum
460            } else {
461                StepEffect::Inert
462            };
463            assert_eq!(
464                step_effect(step, &neutral),
465                expected_at_rest,
466                "{step:?} is not neutral at the struct's defaults"
467            );
468            assert_ne!(
469                step_effect(step, &live_exemplar(step)),
470                StepEffect::Inert,
471                "{step:?}'s exemplar does not switch it on, so its \
472                 classification is never exercised"
473            );
474        }
475        // A neutral chain folds, which is the statement the whole table
476        // exists to make safely.
477        assert!(neutral.greedy_equals_raw_argmax());
478        assert!(neutral.chain_keeps_the_argmax());
479    }
480
481    /// Every step classified `MovesTheArgmax` refuses the fold, and no
482    /// step classified otherwise refuses it.
483    ///
484    /// This is the join between the table and the two predicates. A
485    /// verdict nothing reads would be a gate that cannot fire.
486    ///
487    /// Sabotage: make `greedy_equals_raw_argmax` ignore
488    /// `ChainStep::Penalties`, i.e. restore the old body; the
489    /// `Penalties` row goes red.
490    #[test]
491    fn a_step_that_moves_the_argmax_is_a_step_that_refuses_the_fold() {
492        for step in ChainStep::all() {
493            let live = live_exemplar(step);
494            let moves = step_effect(step, &live) == StepEffect::MovesTheArgmax;
495            assert_eq!(
496                !live.greedy_equals_raw_argmax(),
497                moves,
498                "{step:?}: the classification and the fold gate disagree"
499            );
500            // And the post-penalty predicate is the same statement with
501            // exactly one step excused, which is the whole of #170.
502            let expected_after_penalties = if step == ChainStep::Penalties {
503                true
504            } else {
505                !moves
506            };
507            assert_eq!(
508                live.chain_keeps_the_argmax(),
509                expected_after_penalties,
510                "{step:?}: the post-penalty predicate excuses the wrong step"
511            );
512        }
513    }
514
515    /// The `Inert` verdict for the penalties is the SAME condition
516    /// `apply_history_penalties` short-circuits on.
517    ///
518    /// Two copies of "the penalties are switched off" is the defect this
519    /// module is a fix for, one level down: if the classification said
520    /// inert where the penalty step still moved a logit, the fold would
521    /// be permitted over scores the host would have changed. Rather than
522    /// derive one from the other -- the penalty step's early returns are
523    /// entangled with its window scan -- this walks a matrix and asserts
524    /// they agree, so a change to either goes red.
525    ///
526    /// Sabotage: drop `|| neutral` from the `Penalties` arm; the
527    /// all-neutral rows go red.
528    #[test]
529    fn the_inert_verdict_matches_what_the_penalty_step_actually_does() {
530        use crate::sampling::penalties::apply_history_penalties;
531
532        let logits = vec![4.0f32, 3.8, -1.0];
533        for &rep in &[1.0f32, 1.1] {
534            for &pres in &[0.0f32, 0.5] {
535                for &freq in &[0.0f32, 0.5] {
536                    for &last_n in &[0usize, 64] {
537                        let params = SamplingParams {
538                            repetition_penalty: rep,
539                            presence_penalty: pres,
540                            frequency_penalty: freq,
541                            penalty_last_n: last_n,
542                            ..SamplingParams::default()
543                        };
544                        let mut scores = logits.clone();
545                        apply_history_penalties(
546                            &mut scores,
547                            &params,
548                            PenaltyWindow::new(&[], &[0, 1]),
549                        );
550                        let untouched = scores == logits;
551                        let inert = step_effect(ChainStep::Penalties, &params) == StepEffect::Inert;
552                        assert_eq!(
553                            inert,
554                            untouched,
555                            "rep={rep} pres={pres} freq={freq} last_n={last_n}: the \
556                             classification says inert={inert} and the penalty step \
557                             {}",
558                            if untouched {
559                                "changed nothing"
560                            } else {
561                                "changed the scores"
562                            }
563                        );
564                    }
565                }
566            }
567        }
568    }
569}