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, ¶ms, 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 ¶ms,
548 PenaltyWindow::new(&[], &[0, 1]),
549 );
550 let untouched = scores == logits;
551 let inert = step_effect(ChainStep::Penalties, ¶ms) == 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}