candding 0.1.0

Pure candle embeddings for Rust with no ONNX: dense, sparse, multi-vector and rerank models today, image models on the roadmap
Documentation
use candle_core::Device;

use super::*;

fn t(data: &[f32], shape: (usize, usize, usize, usize)) -> Tensor {
  Tensor::from_slice(data, shape, &Device::Cpu).unwrap()
}

fn max_diff(a: &Tensor, b: &Tensor) -> f32 {
  (a - b)
    .unwrap()
    .abs()
    .unwrap()
    .flatten_all()
    .unwrap()
    .max(0)
    .unwrap()
    .to_scalar::<f32>()
    .unwrap()
}

fn values(t: &Tensor) -> Vec<f32> {
  t.flatten_all().unwrap().to_vec1::<f32>().unwrap()
}

/// One query against keys scoring `0` and `ln 3`: softmax is `[0.25, 0.75]`.
#[test]
fn attention_matches_hand_computation() {
  let q = t(&[1.0, 0.0], (1, 1, 1, 2));
  let k = t(&[0.0, 0.0, 3f32.ln(), 0.0], (1, 1, 2, 2));
  let v = t(&[1.0, 0.0, 0.0, 1.0], (1, 1, 2, 2));
  let out = values(&scaled_dot_product(&q, &k, &v, &AttentionMask::none(), 1.0).unwrap());
  assert!(
    (out[0] - 0.25).abs() < 1e-6 && (out[1] - 0.75).abs() < 1e-6,
    "{out:?}"
  );
}

#[test]
fn masked_keys_get_no_weight() {
  let q = t(&[1.0, 0.0], (1, 1, 1, 2));
  let k = t(&[0.0, 0.0, 3f32.ln(), 0.0], (1, 1, 2, 2));
  let v = t(&[1.0, 0.0, 0.0, 1.0], (1, 1, 2, 2));
  let attention_mask = Tensor::new(&[[1u32, 0]], &Device::Cpu).unwrap();
  let mask = AttentionMask::padding(&attention_mask, &[1], 1, 2, DType::F32).unwrap();
  let out = values(&scaled_dot_product(&q, &k, &v, &mask, 1.0).unwrap());
  assert!(
    (out[0] - 1.0).abs() < 1e-6 && out[1].abs() < 1e-6,
    "{out:?}"
  );
}

/// Scores `[0, 2·ln 3]` at scale 0.5 plus `[0, ln 3]` give weights `[0.1, 0.9]`; adding before the scale gives `[0.1612, 0.8388]`.
/// Pins the matmul path only: candle's Metal kernel applies its own order, which the Metal golden runs cover.
#[test]
fn the_additive_term_is_added_after_the_scale() {
  let q = t(&[1.0, 0.0, 0.0, 0.0], (1, 1, 2, 2));
  let k = t(&[0.0, 0.0, 2.0 * 3f32.ln(), 0.0], (1, 1, 2, 2));
  let v = t(&[1.0, 0.0, 0.0, 1.0], (1, 1, 2, 2));
  let bias = t(&[0.0, 3f32.ln(), 0.0, 0.0], (1, 1, 2, 2));
  let attention_mask = Tensor::new(&[[1u32, 1]], &Device::Cpu).unwrap();
  let mask =
    AttentionMask::padding_with_bias(&attention_mask, &[2], &bias, 1, 2, DType::F32).unwrap();
  let out = values(&scaled_dot_product(&q, &k, &v, &mask, 0.5).unwrap());
  assert!(
    (out[0] - 0.1).abs() < 1e-6 && (out[1] - 0.9).abs() < 1e-6,
    "{out:?}"
  );
}

#[test]
fn repeat_kv_tiles_each_kv_head_for_its_group() {
  let k = t(&[1.0, 2.0], (1, 2, 1, 1));
  assert_eq!(values(&repeat_kv(&k, 2).unwrap()), vec![1.0, 1.0, 2.0, 2.0]);
  assert_eq!(repeat_kv(&k, 1).unwrap().dims(), &[1, 2, 1, 1]);
}

#[test]
fn metal_sdpa_applies_only_for_metal_and_listed_head_sizes() {
  assert!(!metal_sdpa_applies(&Device::Cpu, 128, DType::F32));
  assert!(!metal_sdpa_applies(&Device::Cpu, 256, DType::F16));
}

/// EmbeddingGemma's 256-wide heads: the kernel takes them in a half dtype and cannot build an F32 pipeline for them.
#[test]
fn the_kernel_takes_wide_heads_only_in_a_half_dtype() {
  assert!(sdpa_accepts(256, DType::F16) && sdpa_accepts(256, DType::BF16));
  assert!(!sdpa_accepts(256, DType::F32));
  assert!(sdpa_accepts(128, DType::F32) && sdpa_accepts(64, DType::F32));
  assert!(!sdpa_accepts(512, DType::BF16) && !sdpa_accepts(40, DType::F16));
}

#[test]
fn grouped_query_matches_explicitly_repeated_heads() {
  let q = Tensor::randn(0f32, 1.0, (2, 4, 3, 8), &Device::Cpu).unwrap();
  let k = Tensor::randn(0f32, 1.0, (2, 2, 3, 8), &Device::Cpu).unwrap();
  let v = Tensor::randn(0f32, 1.0, (2, 2, 3, 8), &Device::Cpu).unwrap();
  let grouped = scaled_dot_product(&q, &k, &v, &AttentionMask::none(), 0.5).unwrap();
  let full = scaled_dot_product(
    &q,
    &repeat_kv(&k, 2).unwrap(),
    &repeat_kv(&v, 2).unwrap(),
    &AttentionMask::none(),
    0.5,
  )
  .unwrap();
  assert!(max_diff(&grouped, &full) < 1e-6);
  assert_eq!(grouped.dims(), &[2, 4, 3, 8]);
}

#[test]
fn scaled_dot_product_rejects_head_counts_that_do_not_divide() {
  let q = Tensor::randn(0f32, 1.0, (1, 4, 2, 8), &Device::Cpu).unwrap();
  let k = Tensor::randn(0f32, 1.0, (1, 3, 2, 8), &Device::Cpu).unwrap();
  let v = Tensor::randn(0f32, 1.0, (1, 3, 2, 8), &Device::Cpu).unwrap();
  let err = scaled_dot_product(&q, &k, &v, &AttentionMask::none(), 1.0).unwrap_err();
  let message = err.to_string();
  assert!(
    message.contains("4 query heads") && message.contains("3 kv heads"),
    "{message}"
  );
}

/// Values are one-hot per position, so the output shows which keys each position could see.
#[test]
fn causal_attention_sees_only_the_past() {
  let q = Tensor::randn(0f32, 1.0, (1, 1, 3, 4), &Device::Cpu).unwrap();
  let k = Tensor::randn(0f32, 1.0, (1, 1, 3, 4), &Device::Cpu).unwrap();
  let v = t(
    &[1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
    (1, 1, 3, 4),
  );
  let mask = AttentionMask::causal(3, 4, DType::F32, &Device::Cpu).unwrap();
  let rows = scaled_dot_product(&q, &k, &v, &mask, 0.5)
    .unwrap()
    .squeeze(0)
    .unwrap()
    .squeeze(0)
    .unwrap()
    .to_vec2::<f32>()
    .unwrap();
  assert!(
    (rows[0][0] - 1.0).abs() < 1e-6 && rows[0][1].abs() < 1e-6 && rows[0][2].abs() < 1e-6,
    "position 0 attends to key 0 only: {:?}",
    rows[0]
  );
  assert!(
    rows[1][2].abs() < 1e-6,
    "position 1 must not see key 2: {:?}",
    rows[1]
  );
}

/// Rows `0..H` are every head's query, `H..2H` the key and `2H..3H` the value; a per-head read would keep the shape.
#[test]
fn the_fused_projection_splits_into_query_key_value_along_its_rows() {
  let fused = Tensor::from_slice(
    &[1f32, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 3.0],
    (6, 2),
    &Device::Cpu,
  )
  .unwrap();
  let (q, k, v) = split_fused_qkv(&fused, 2).unwrap();
  assert_eq!(
    q.to_vec2::<f32>().unwrap(),
    vec![vec![1.0, 1.0], vec![1.0, 1.0]]
  );
  assert_eq!(
    k.to_vec2::<f32>().unwrap(),
    vec![vec![2.0, 2.0], vec![2.0, 2.0]]
  );
  assert_eq!(
    v.to_vec2::<f32>().unwrap(),
    vec![vec![3.0, 3.0], vec![3.0, 3.0]]
  );
}

mod weights;