Skip to main content

taconite_adaface/
lib.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 Brishen Hawkins
2// SPDX-License-Identifier: Apache-2.0
3
4//! Embeddable Rust runtime for the AdaFace IR face embedders: NPU
5//! convolutions + CPU glue/head.
6//!
7//! [`IrEmbedder`] replays a bundle exported by
8//! `iron/applications/adaface_ir18/export_ir18.py` (or the IR-101 wrapper
9//! `export_ir101.py`): the shared IRON bundle format (read with
10//! `taconite-bundle`), whose manifest lists the network's steps in order. The
11//! steps encode the architecture, so this one runtime serves every exported
12//! IR backbone; the `run_ir18` / `run_ir101` binaries are thin wrappers over
13//! [`cli`], which is itself a consumer of the public API:
14//!
15//! ```no_run
16//! use taconite_adaface::IrEmbedder;
17//!
18//! # fn main() -> Result<(), taconite_adaface::Error> {
19//! let mut emb = IrEmbedder::load("/path/to/bundle")?;
20//! // An aligned 112x112 face crop, CHW f32, cvlface preprocessing
21//! // ((rgb/255) - 0.5) / 0.5. Channels beyond the image's (the bundle pads
22//! // the stem to a multiple of 8) are zero-filled internally.
23//! let chw = vec![0f32; 3 * 112 * 112];
24//! let embedding = emb.embed(&chw)?; // 512-d
25//! # let _ = embedding;
26//! # Ok(())
27//! # }
28//! ```
29//!
30//! Loading reads every weight into RAM and checks every step against them.
31//! The first forward creates one hardware context per distinct conv kernel
32//! (16 for IR-18/IR-101) and the session keeps them ALL resident (an LRU
33//! cache sized to the NPU2's 16-concurrent-context cap), so every later
34//! forward reloads nothing. Keep ONE `IrEmbedder` alive for the process and
35//! reuse it; a second simultaneous session would fight over the context cap.
36//!
37//! Kernels run through [`taconite`]: over XRT (feature `xrt`, the default),
38//! or straight through the amdxdna driver's ioctls with no XRT at all
39//! (feature `direct`, which wins when both are on; build with
40//! `--no-default-features --features direct`). [`BACKEND`] says which.
41//!
42//! Pipeline per forward (all in Rust):
43//!   * every convolution on the NPU, in the Conv2d
44//!     channel-tiled [H, C/8, W, 8] bf16 layout; wide convs are summed over
45//!     input-channel groups sized to fit L1, and the width%4 stages (14x14 /
46//!     7x7) zero-pad the input width to a multiple of 4 and crop the extra
47//!     output columns;
48//!   * the per-channel glue -- leading BN0 affine, fused conv-bias + PReLU
49//!     (prelu(x + bias)), plain conv bias -- and the residual add and stride-2
50//!     subsample on the CPU (these are tiny and dispatch-bound, and float32 on
51//!     the host is if anything more accurate);
52//!   * the embedding head (both head BatchNorms folded into the Linear) as an
53//!     exact f32 mat-vec on the CPU (M=1) -- keeping it off the NPU lets all
54//!     16 conv kernels stay resident within the concurrent-context cap.
55//!
56//! Threading: an [`IrEmbedder`] is `Send` (move it to a worker thread) but
57//! deliberately not `Sync` -- all methods take `&mut self`, one forward at a
58//! time. Share it behind a `Mutex` if several threads need embeddings.
59
60use std::collections::{HashMap, VecDeque};
61use std::fmt;
62use std::path::{Path, PathBuf};
63use std::time::{Duration, Instant};
64
65use taconite_bundle::{DType, Manifest, Record, Store};
66// The NPU path: XRT (feature `xrt`, the default), or the driver's ioctls
67// with no XRT (feature `direct`, which wins when both are on).
68#[cfg(feature = "direct")]
69use taconite::direct::{Buffer, Kernel, Session};
70#[cfg(all(feature = "xrt", not(feature = "direct")))]
71use taconite::{Buffer, Kernel, Session};
72#[cfg(not(any(feature = "xrt", feature = "direct")))]
73compile_error!("no NPU path: enable feature `xrt` (the default) or `direct`");
74
75/// Which NPU path this build runs kernels through: `"xrt"` or `"direct"`.
76pub const BACKEND: &str = if cfg!(feature = "direct") {
77    "direct"
78} else {
79    "xrt"
80};
81
82/// The bundle format version this runtime reads (`export_ir18.VERSION`).
83pub const BUNDLE_VERSION: u32 = 1;
84
85// ----------------------------------------------------------------------------
86// Errors
87// ----------------------------------------------------------------------------
88
89/// Everything that can go wrong loading a bundle or running a forward. The
90/// library never prints or exits; the host application decides.
91#[derive(Debug)]
92pub enum Error {
93    /// A bundle file could not be read.
94    Io(PathBuf, std::io::Error),
95    /// The bundle is malformed or inconsistent: a bad manifest record, a
96    /// missing or mis-sized tensor, a step reading an undefined buffer.
97    Plan(String),
98    /// The NPU runtime (XRT, or the amdxdna driver with feature `direct`)
99    /// reported a device/kernel error. Named for the crate's first backend.
100    Xrt(String),
101    /// The caller-supplied input tensor has the wrong shape.
102    Input(String),
103}
104
105impl fmt::Display for Error {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            Error::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
109            Error::Plan(s) => write!(f, "bad bundle: {s}"),
110            Error::Xrt(s) => write!(f, "NPU ({BACKEND}): {s}"),
111            Error::Input(s) => write!(f, "bad input: {s}"),
112        }
113    }
114}
115
116impl std::error::Error for Error {}
117
118impl From<taconite_bundle::Error> for Error {
119    fn from(e: taconite_bundle::Error) -> Self {
120        match e {
121            taconite_bundle::Error::Io(p, e) => Error::Io(p, e),
122            taconite_bundle::Error::Format(m) => Error::Plan(m),
123        }
124    }
125}
126
127// ----------------------------------------------------------------------------
128// NPU session: taconite kernels behind an LRU cache of resident contexts
129// ----------------------------------------------------------------------------
130
131/// Resident conv kernels, most-recently-used first. A taconite [`Kernel`]
132/// holds its hardware context until dropped, so evicting one frees a slot
133/// of the driver's concurrent-context cap (16 on NPU2 -- exactly the IR
134/// networks' distinct conv kernels, so a forward that fits reloads nothing
135/// after the first).
136struct Npu {
137    session: Session,
138    cache: VecDeque<Resident>,
139    cap: usize,
140    timing: Option<NpuTiming>,
141}
142
143struct Resident {
144    key: String,
145    kernel: Kernel,
146    /// (input, weights, output) argument buffers, reused across runs.
147    args: Option<[Buffer; 3]>,
148}
149
150/// `TACONITE_TIMING` instrumentation, printed when the session drops.
151#[derive(Default)]
152struct NpuTiming {
153    load: Duration,
154    run: Duration,
155    wait: Duration,
156    loads: usize,
157    hits: usize,
158    evictions: usize,
159    runs: usize,
160}
161
162/// NPU2's concurrent hardware-context limit for these col8 kernels.
163const DEFAULT_CTX_CACHE: usize = 16;
164
165impl Npu {
166    fn open() -> Result<Npu, Error> {
167        let session = Session::open(0).map_err(npu_err("open"))?;
168        let cap = std::env::var("TACONITE_CTX_CACHE")
169            .ok()
170            .and_then(|v| v.parse().ok())
171            .filter(|&v: &usize| v >= 1)
172            .unwrap_or(DEFAULT_CTX_CACHE);
173        let timing = std::env::var_os("TACONITE_TIMING").map(|_| NpuTiming::default());
174        Ok(Npu {
175            session,
176            cache: VecDeque::new(),
177            cap,
178            timing,
179        })
180    }
181
182    /// Ensure `key`'s kernel is resident (promoted to most-recently-used if
183    /// it already is); otherwise load it, evicting least-recently-used
184    /// kernels while the cache is full or the driver refuses another
185    /// context.
186    fn ensure(
187        &mut self,
188        key: &str,
189        xclbin: &Path,
190        insts: &Path,
191        kernel: &str,
192    ) -> Result<(), Error> {
193        if let Some(i) = self.cache.iter().position(|r| r.key == key) {
194            let r = self.cache.remove(i).expect("position is in range");
195            self.cache.push_front(r);
196            if let Some(t) = &mut self.timing {
197                t.hits += 1;
198            }
199            return Ok(());
200        }
201        let t0 = Instant::now();
202        let mut evicted = 0;
203        while self.cache.len() >= self.cap {
204            self.cache.pop_back();
205            evicted += 1;
206        }
207        let kernel = loop {
208            match self.session.load_kernel(xclbin, insts, Some(kernel), 0) {
209                Ok(k) => break k,
210                Err(e) => {
211                    if self.cache.pop_back().is_none() {
212                        return Err(npu_err(&format!("load({key})"))(e));
213                    }
214                    evicted += 1;
215                }
216            }
217        };
218        self.cache.push_front(Resident {
219            key: key.to_string(),
220            kernel,
221            args: None,
222        });
223        if let Some(t) = &mut self.timing {
224            t.load += t0.elapsed();
225            t.loads += 1;
226            t.evictions += evicted;
227        }
228        Ok(())
229    }
230
231    /// Run the resident kernel `key` with the (input, weights, output) ABI
232    /// (Conv2d or GEMM).
233    fn run(
234        &mut self,
235        key: &str,
236        input: &[u16],
237        weights: &[u16],
238        out_len: usize,
239    ) -> Result<Vec<u16>, Error> {
240        let t0 = Instant::now();
241        let err = npu_err(key);
242        let r = self
243            .cache
244            .iter_mut()
245            .find(|r| r.key == key)
246            .ok_or_else(|| Error::Xrt(format!("{key}: kernel not loaded")))?;
247        let sizes = [input.len(), weights.len(), out_len];
248        if r.args
249            .as_ref()
250            .map(|a| a.each_ref().map(|b| b.len_bytes() / 2))
251            != Some(sizes)
252        {
253            let s = &self.session;
254            r.args = Some([
255                s.alloc_of::<u16>(sizes[0]).map_err(&err)?,
256                s.alloc_of::<u16>(sizes[1]).map_err(&err)?,
257                s.alloc_of::<u16>(sizes[2]).map_err(&err)?,
258            ]);
259        }
260        let [a, w, c] = r.args.as_mut().expect("allocated above");
261        a.write(input).map_err(&err)?;
262        w.write(weights).map_err(&err)?;
263        let wait = r.kernel.run(&[a, w, c]).map_err(&err)?;
264        c.sync_from_device().map_err(&err)?;
265        let out = c.as_slice::<u16>().to_vec();
266        if let Some(t) = &mut self.timing {
267            t.run += t0.elapsed();
268            t.wait += wait;
269            t.runs += 1;
270        }
271        Ok(out)
272    }
273}
274
275impl Drop for Npu {
276    fn drop(&mut self) {
277        if let Some(t) = &self.timing {
278            eprintln!(
279                "[{BACKEND}] loads={} hits={} evict={} runs={}  load={:.2}s run={:.2}s (wait={:.2}s)",
280                t.loads,
281                t.hits,
282                t.evictions,
283                t.runs,
284                t.load.as_secs_f64(),
285                t.run.as_secs_f64(),
286                t.wait.as_secs_f64()
287            );
288        }
289    }
290}
291
292fn npu_err(what: &str) -> impl Fn(taconite::Error) -> Error + '_ {
293    move |e| Error::Xrt(format!("{what}: {e}"))
294}
295
296// ----------------------------------------------------------------------------
297// bf16 helpers
298// ----------------------------------------------------------------------------
299
300#[inline]
301fn bf16_to_f32(x: u16) -> f32 {
302    f32::from_bits((x as u32) << 16)
303}
304
305#[inline]
306fn f32_to_bf16(f: f32) -> u16 {
307    let bits = f.to_bits();
308    if (bits & 0x7fff_ffff) > 0x7f80_0000 {
309        return 0x7fc0; // NaN
310    }
311    let rounding_bias = 0x7fff + ((bits >> 16) & 1); // round to nearest even
312    ((bits + rounding_bias) >> 16) as u16
313}
314
315// ----------------------------------------------------------------------------
316// Tiled buffers: flat [H, C/8, W, 8] of bf16
317// ----------------------------------------------------------------------------
318
319#[derive(Clone)]
320struct Buf {
321    v: Vec<u16>,
322    c: usize,
323    h: usize,
324    w: usize,
325}
326
327#[inline]
328fn tidx(c: usize, w: usize, ch: usize, y: usize, x: usize) -> usize {
329    (((y * (c / 8)) + ch / 8) * w + x) * 8 + (ch % 8)
330}
331
332/// Extract input channel-groups [g0, g0+ngroups) into a fresh tiled buffer,
333/// zero-padding the width from `src.w` up to `wp` (a multiple of 4) so the
334/// width%4 conv shapes are feasible. Columns x >= src.w stay zero.
335fn slice_groups_wpad(src: &Buf, g0: usize, ngroups: usize, wp: usize) -> Vec<u16> {
336    let icg = ngroups * 8;
337    let mut out = vec![0u16; src.h * icg * wp];
338    for y in 0..src.h {
339        for gg in 0..ngroups {
340            for x in 0..src.w {
341                for lane in 0..8 {
342                    let s = (((y * (src.c / 8)) + (g0 + gg)) * src.w + x) * 8 + lane;
343                    let d = (((y * ngroups) + gg) * wp + x) * 8 + lane;
344                    out[d] = src.v[s];
345                }
346            }
347        }
348    }
349    out
350}
351
352/// Leading BN0 as a per-channel affine scale*x + shift (tiled -> new tiled).
353fn affine_tiled(src: &Buf, scale: &[u16], shift: &[u16]) -> Buf {
354    let mut v = vec![0u16; src.v.len()];
355    for y in 0..src.h {
356        for ch in 0..src.c {
357            for x in 0..src.w {
358                let i = tidx(src.c, src.w, ch, y, x);
359                let r = bf16_to_f32(src.v[i]) * bf16_to_f32(scale[ch]) + bf16_to_f32(shift[ch]);
360                v[i] = f32_to_bf16(r);
361            }
362        }
363    }
364    Buf {
365        v,
366        c: src.c,
367        h: src.h,
368        w: src.w,
369    }
370}
371
372/// Fused per-channel bias add + PReLU in place: y = prelu(x + bias). The bias
373/// add rounds to bf16 before the PReLU, matching the recorder's two host ops.
374fn prelu_bias_tiled(buf: &mut Buf, alpha: &[u16], bias: &[u16]) {
375    for y in 0..buf.h {
376        for ch in 0..buf.c {
377            for x in 0..buf.w {
378                let i = tidx(buf.c, buf.w, ch, y, x);
379                let t = bf16_to_f32(f32_to_bf16(bf16_to_f32(buf.v[i]) + bf16_to_f32(bias[ch])));
380                let r = if t < 0.0 {
381                    bf16_to_f32(alpha[ch]) * t
382                } else {
383                    t
384                };
385                buf.v[i] = f32_to_bf16(r);
386            }
387        }
388    }
389}
390
391/// Per-channel bias add in place.
392fn bias_tiled(buf: &mut Buf, bias: &[u16]) {
393    for y in 0..buf.h {
394        for ch in 0..buf.c {
395            for x in 0..buf.w {
396                let i = tidx(buf.c, buf.w, ch, y, x);
397                buf.v[i] = f32_to_bf16(bf16_to_f32(buf.v[i]) + bf16_to_f32(bias[ch]));
398            }
399        }
400    }
401}
402
403fn add_tiled(a: &Buf, b: &Buf) -> Buf {
404    let v =
405        a.v.iter()
406            .zip(&b.v)
407            .map(|(&x, &y)| f32_to_bf16(bf16_to_f32(x) + bf16_to_f32(y)))
408            .collect();
409    Buf {
410        v,
411        c: a.c,
412        h: a.h,
413        w: a.w,
414    }
415}
416
417fn subsample_tiled(src: &Buf, stride: usize) -> Buf {
418    let (oh, ow) = (src.h / stride, src.w / stride);
419    let mut out = vec![0u16; oh * (src.c / 8) * ow * 8];
420    for y in 0..oh {
421        for g in 0..(src.c / 8) {
422            for x in 0..ow {
423                for lane in 0..8 {
424                    let s = (((stride * y) * (src.c / 8) + g) * src.w + stride * x) * 8 + lane;
425                    let d = ((y * (src.c / 8) + g) * ow + x) * 8 + lane;
426                    out[d] = src.v[s];
427                }
428            }
429        }
430    }
431    Buf {
432        v: out,
433        c: src.c,
434        h: oh,
435        w: ow,
436    }
437}
438
439/// Convert a tiled [h, c/8, w, 8] bf16 buffer to channel-major f32 [c, h, w].
440fn tiled_to_nchw_f32(b: &Buf) -> Vec<f32> {
441    let mut out = vec![0f32; b.c * b.h * b.w];
442    for ch in 0..b.c {
443        for y in 0..b.h {
444            for x in 0..b.w {
445                out[(ch * b.h + y) * b.w + x] = bf16_to_f32(b.v[tidx(b.c, b.w, ch, y, x)]);
446            }
447        }
448    }
449    out
450}
451
452/// Cosine similarity between two embeddings (0.0 if either is all-zero).
453pub fn cosine(a: &[f32], b: &[f32]) -> f64 {
454    let (mut dot, mut na, mut nb) = (0f64, 0f64, 0f64);
455    for (&x, &y) in a.iter().zip(b) {
456        dot += x as f64 * y as f64;
457        na += x as f64 * x as f64;
458        nb += y as f64 * y as f64;
459    }
460    if na == 0.0 || nb == 0.0 {
461        0.0
462    } else {
463        dot / (na.sqrt() * nb.sqrt())
464    }
465}
466
467// ----------------------------------------------------------------------------
468// The bundle's steps
469// ----------------------------------------------------------------------------
470
471/// One NPU dispatch: the input-channel group `[8 * c0, 8 * c0 + icg)` of a
472/// convolution, accumulated into `out` from the `first` dispatch to the
473/// `last` (which crops the padded width `owp` back to `ow`).
474struct Conv {
475    input: u32,
476    out: u32,
477    c0: usize,
478    icg: usize,
479    wp: usize,
480    oc: usize,
481    oh: usize,
482    ow: usize,
483    owp: usize,
484    ctx: String,
485    xclbin: PathBuf,
486    kernel: String,
487    insts: PathBuf,
488    wt: String,
489    first: bool,
490    last: bool,
491}
492
493/// One manifest step, parsed and checked against the tensor store at load.
494/// Buffer ids name tiled bf16 buffers; `String`s name tensors.
495enum Step {
496    Input {
497        buf: u32,
498        c: usize,
499        h: usize,
500        w: usize,
501        tensor: String,
502    },
503    Conv(Conv),
504    Affine {
505        input: u32,
506        out: u32,
507        scale: String,
508        shift: String,
509    },
510    PreluBias {
511        buf: u32,
512        alpha: String,
513        bias: String,
514    },
515    Bias {
516        buf: u32,
517        bias: String,
518    },
519    Subsample {
520        input: u32,
521        out: u32,
522        stride: usize,
523    },
524    Add {
525        a: u32,
526        b: u32,
527        out: u32,
528    },
529    Head {
530        input: u32,
531        k: usize,
532        n: usize,
533        bt: String,
534        b2: String,
535    },
536}
537
538/// (channels, height, width) of a buffer.
539type Shape = (usize, usize, usize);
540
541/// A buffer a step reads: `key=<id>`, which an earlier step must define.
542fn read(shapes: &HashMap<u32, Shape>, r: &Record, key: &str) -> Result<(u32, Shape), Error> {
543    let id: u32 = r.get(key)?;
544    match shapes.get(&id) {
545        Some(&s) => Ok((id, s)),
546        None => Err(r
547            .error(format!("{key}={id} is not a defined buffer here"))
548            .into()),
549    }
550}
551
552fn dims(r: &Record) -> Result<Shape, Error> {
553    Ok((r.get("c")?, r.get("h")?, r.get("w")?))
554}
555
556fn same(r: &Record, what: &str, got: Shape, want: Shape) -> Result<(), Error> {
557    if got != want {
558        return Err(r
559            .error(format!(
560                "{what} is {got:?} (c, h, w), the step says {want:?}"
561            ))
562            .into());
563    }
564    Ok(())
565}
566
567/// Tensor `key=<name>`, checked to be `elems` x `dtype`.
568fn tensor(
569    store: &Store,
570    r: &Record,
571    key: &str,
572    dtype: DType,
573    elems: usize,
574) -> Result<String, Error> {
575    let name = r.str(key)?;
576    store.expect(name, dtype, elems).map_err(|e| r.error(e))?;
577    Ok(name.to_string())
578}
579
580/// Parse the manifest's steps, checking each against the buffers the steps
581/// before it define and against the tensors, so a forward cannot fail on
582/// a malformed bundle.
583fn parse_steps(m: &Manifest, store: &Store) -> Result<Vec<Step>, Error> {
584    let mut shapes: HashMap<u32, Shape> = HashMap::new();
585    let mut steps = Vec::new();
586    for r in m.records() {
587        let step = match r.tag.as_str() {
588            "input" => {
589                let (c, h, w) = dims(r)?;
590                if c == 0 || c % 8 != 0 || h == 0 || w == 0 {
591                    return Err(r
592                        .error("c must be a positive multiple of 8, h and w positive")
593                        .into());
594                }
595                let tensor = tensor(store, r, "tensor", DType::Bf16, c * h * w)?;
596                let buf = r.get("buf")?;
597                shapes.insert(buf, (c, h, w));
598                Step::Input {
599                    buf,
600                    c,
601                    h,
602                    w,
603                    tensor,
604                }
605            }
606            "conv" => {
607                let (input, s) = read(&shapes, r, "in")?;
608                let (ict, h, w): Shape = (r.get("ict")?, r.get("h")?, r.get("w")?);
609                same(r, "the input", s, (ict, h, w))?;
610                let (c0, icg, wp): (usize, usize, usize) =
611                    (r.get("c0")?, r.get("icg")?, r.get("wp")?);
612                let (oc, oh, ow, owp): (usize, usize, usize, usize) =
613                    (r.get("oc")?, r.get("oh")?, r.get("ow")?, r.get("owp")?);
614                if icg == 0
615                    || icg % 8 != 0
616                    || 8 * c0 + icg > ict
617                    || oc % 8 != 0
618                    || wp < w
619                    || owp < ow
620                {
621                    return Err(r
622                        .error("inconsistent channel group or padded widths")
623                        .into());
624                }
625                let x = m.xclbin(r.str("ctx")?).map_err(|e| r.error(e))?;
626                let wt = r.str("wt")?;
627                let e = store.entry(wt).map_err(|e| r.error(e))?;
628                if e.dtype != DType::Bf16 {
629                    return Err(r
630                        .error(format!("tensor {wt} is {:?}, wanted Bf16", e.dtype))
631                        .into());
632                }
633                let (out, last) = (r.get("out")?, r.flag("last")?);
634                if last {
635                    shapes.insert(out, (oc, oh, ow));
636                }
637                Step::Conv(Conv {
638                    input,
639                    out,
640                    c0,
641                    icg,
642                    wp,
643                    oc,
644                    oh,
645                    ow,
646                    owp,
647                    ctx: x.key.clone(),
648                    xclbin: x.path.clone(),
649                    kernel: x.kernel.clone(),
650                    insts: m.path(r.str("insts")?),
651                    wt: wt.to_string(),
652                    first: r.flag("first")?,
653                    last,
654                })
655            }
656            "affine" => {
657                let (input, s) = read(&shapes, r, "in")?;
658                let d = dims(r)?;
659                same(r, "the input", s, d)?;
660                let scale = tensor(store, r, "scale", DType::Bf16, d.0)?;
661                let shift = tensor(store, r, "shift", DType::Bf16, d.0)?;
662                let out = r.get("out")?;
663                shapes.insert(out, d);
664                Step::Affine {
665                    input,
666                    out,
667                    scale,
668                    shift,
669                }
670            }
671            "prelu_bias" => {
672                let (buf, s) = read(&shapes, r, "buf")?;
673                same(r, "the buffer", s, dims(r)?)?;
674                let alpha = tensor(store, r, "alpha", DType::Bf16, s.0)?;
675                let bias = tensor(store, r, "bias", DType::Bf16, s.0)?;
676                Step::PreluBias { buf, alpha, bias }
677            }
678            "bias" => {
679                let (buf, s) = read(&shapes, r, "buf")?;
680                same(r, "the buffer", s, dims(r)?)?;
681                let bias = tensor(store, r, "bias", DType::Bf16, s.0)?;
682                Step::Bias { buf, bias }
683            }
684            "subsample" => {
685                let (input, s) = read(&shapes, r, "in")?;
686                let (c, h, w) = dims(r)?;
687                same(r, "the input", s, (c, h, w))?;
688                let stride: usize = r.get("stride")?;
689                if stride == 0 {
690                    return Err(r.error("stride must be positive").into());
691                }
692                let out = r.get("out")?;
693                shapes.insert(out, (c, h / stride, w / stride));
694                Step::Subsample { input, out, stride }
695            }
696            "add" => {
697                let (a, sa) = read(&shapes, r, "a")?;
698                let (b, sb) = read(&shapes, r, "b")?;
699                let d = dims(r)?;
700                same(r, "a", sa, d)?;
701                same(r, "b", sb, d)?;
702                let out = r.get("out")?;
703                shapes.insert(out, d);
704                Step::Add { a, b, out }
705            }
706            "head" => {
707                let (input, s) = read(&shapes, r, "in")?;
708                let d = dims(r)?;
709                same(r, "the input", s, d)?;
710                let (k, n): (usize, usize) = (r.get("k")?, r.get("n")?);
711                if d.0 * d.1 * d.2 != k {
712                    return Err(r
713                        .error(format!("k={k} is not the input's c * h * w"))
714                        .into());
715                }
716                let bt = tensor(store, r, "bT", DType::Bf16, k * n)?;
717                let b2 = tensor(store, r, "b2", DType::F32, n)?;
718                Step::Head {
719                    input,
720                    k,
721                    n,
722                    bt,
723                    b2,
724                }
725            }
726            other => return Err(r.error(format!("unknown step {other}")).into()),
727        };
728        steps.push(step);
729    }
730    Ok(steps)
731}
732
733// ----------------------------------------------------------------------------
734// The embedder: replay the recorded IR steps on the NPU
735// ----------------------------------------------------------------------------
736
737/// A resident NPU session replaying one exported IR bundle.
738///
739/// Construct once with [`IrEmbedder::load`], then call [`IrEmbedder::embed`]
740/// per face. The first forward creates the hardware contexts; every later
741/// forward reuses them (warm path).
742pub struct IrEmbedder {
743    npu: Npu,
744    store: Store,
745    steps: Vec<Step>,
746    /// Each head's `bT`, widened to f32 once.
747    head_bt: HashMap<String, Vec<f32>>,
748    model: Option<String>,
749    in_c: usize, // stem channels as recorded (image channels padded to %8)
750    in_h: usize,
751    in_w: usize,
752    embed_dim: usize,
753}
754
755// Send but deliberately not Sync (taconite's session, kernels and buffers
756// are both): every method takes &mut self; wrap it in a Mutex to share
757// across threads.
758const _: () = {
759    const fn send<T: Send>() {}
760    send::<IrEmbedder>()
761};
762
763impl IrEmbedder {
764    /// Read and check the bundle (manifest, every tensor), then open the NPU
765    /// device. Hardware contexts load lazily on the first forward.
766    pub fn load(bundle_dir: impl AsRef<Path>) -> Result<Self, Error> {
767        let dir = bundle_dir.as_ref();
768        if !dir.join(Manifest::FILE).exists() && dir.join("plan.txt").exists() {
769            return Err(Error::Plan(format!(
770                "{} is an old plan.txt bundle; re-export it with export_ir18.py / export_ir101.py",
771                dir.display()
772            )));
773        }
774        let m = Manifest::load(dir, BUNDLE_VERSION)?;
775        let store = Store::load(dir)?;
776        let steps = parse_steps(&m, &store)?;
777        let inputs: Vec<Shape> = steps
778            .iter()
779            .filter_map(|s| match s {
780                Step::Input { c, h, w, .. } => Some((*c, *h, *w)),
781                _ => None,
782            })
783            .collect();
784        let [(in_c, in_h, in_w)] = inputs[..] else {
785            return Err(Error::Plan(
786                "the bundle needs exactly one input step".into(),
787            ));
788        };
789        let mut head_bt = HashMap::new();
790        let mut embed_dim = None;
791        for s in &steps {
792            if let Step::Head { bt, n, .. } = s {
793                embed_dim = Some(*n);
794                if !head_bt.contains_key(bt) {
795                    let v: Vec<f32> = store.bf16(bt)?.iter().map(|&x| bf16_to_f32(x)).collect();
796                    head_bt.insert(bt.clone(), v);
797                }
798            }
799        }
800        if !matches!(steps.last(), Some(Step::Head { .. })) {
801            return Err(Error::Plan("the bundle's last step is not a head".into()));
802        }
803        Ok(IrEmbedder {
804            npu: Npu::open()?,
805            store,
806            steps,
807            head_bt,
808            model: m.param("model").ok().map(str::to_string),
809            in_c,
810            in_h,
811            in_w,
812            embed_dim: embed_dim.unwrap_or(0),
813        })
814    }
815
816    /// Stem input dims as recorded in the bundle: (channels-padded-to-%8, h, w).
817    pub fn input_dims(&self) -> (usize, usize, usize) {
818        (self.in_c, self.in_h, self.in_w)
819    }
820
821    /// Output embedding dimension (512 for the AdaFace IR models).
822    pub fn embed_dim(&self) -> usize {
823        self.embed_dim
824    }
825
826    /// The network the bundle holds (its `model` param, e.g. `IR-101`).
827    pub fn model(&self) -> Option<&str> {
828        self.model.as_deref()
829    }
830
831    /// NPU conv dispatches per forward (a fixed property of the bundle).
832    pub fn num_conv_dispatches(&self) -> usize {
833        self.steps
834            .iter()
835            .filter(|s| matches!(s, Step::Conv(_)))
836            .count()
837    }
838
839    /// Embed a caller-supplied image: channel-major (CHW) f32, spatial size
840    /// exactly the bundle's h*w, 1..=in_c channels (an RGB face is 3; the
841    /// remaining stem channels are zero -- the recorded conv weights for them
842    /// are zero too, so this is exact). For the AdaFace models the input is
843    /// an aligned 112x112 face with cvlface preprocessing
844    /// `((rgb/255) - 0.5) / 0.5`. Values round to bf16 (round-to-nearest-even,
845    /// bit-identical to torch) before hitting the NPU.
846    pub fn embed(&mut self, chw: &[f32]) -> Result<Vec<f32>, Error> {
847        let plane = self.in_h * self.in_w;
848        if chw.is_empty() || chw.len() % plane != 0 {
849            return Err(Error::Input(format!(
850                "length {} is not a multiple of h*w = {plane}",
851                chw.len()
852            )));
853        }
854        let ch = chw.len() / plane;
855        if ch > self.in_c {
856            return Err(Error::Input(format!(
857                "{ch} channels, but the bundle's stem takes at most {}",
858                self.in_c
859            )));
860        }
861        let mut tiled = vec![0u16; self.in_h * (self.in_c / 8) * self.in_w * 8];
862        for c in 0..ch {
863            for y in 0..self.in_h {
864                for x in 0..self.in_w {
865                    tiled[tidx(self.in_c, self.in_w, c, y, x)] =
866                        f32_to_bf16(chw[(c * self.in_h + y) * self.in_w + x]);
867                }
868            }
869        }
870        self.run(Some(&tiled))
871    }
872
873    /// Embed the bundle's own recorded input (`ref.input`) -- a self-check
874    /// that must match `ref.npu_embedding` exactly, and the benchmark workload.
875    pub fn embed_recorded(&mut self) -> Result<Vec<f32>, Error> {
876        self.run(None)
877    }
878
879    fn run(&mut self, input: Option<&[u16]>) -> Result<Vec<f32>, Error> {
880        let Self {
881            npu,
882            store,
883            steps,
884            head_bt,
885            ..
886        } = self;
887        forward(npu, store, steps, head_bt, input)
888    }
889}
890
891/// One forward pass over the parsed steps. NPU convs dispatch through the
892/// resident-context session `npu`; the per-channel glue and residual add run
893/// on the host. `input` overrides the input step's recorded tensor with a
894/// caller-built tiled buffer. Every buffer a step reads exists (checked at
895/// load), so the `bufs[..]` lookups cannot fail.
896fn forward(
897    npu: &mut Npu,
898    store: &Store,
899    steps: &[Step],
900    head_bt: &HashMap<String, Vec<f32>>,
901    input: Option<&[u16]>,
902) -> Result<Vec<f32>, Error> {
903    let mut bufs: HashMap<u32, Buf> = HashMap::new();
904    let mut acc: HashMap<u32, Vec<f32>> = HashMap::new();
905    let mut embed: Vec<f32> = Vec::new();
906
907    for step in steps {
908        match step {
909            Step::Input {
910                buf,
911                c,
912                h,
913                w,
914                tensor,
915            } => {
916                let (c, h, w) = (*c, *h, *w);
917                let v = match input {
918                    Some(t) => {
919                        if t.len() != h * (c / 8) * w * 8 {
920                            return Err(Error::Input(format!(
921                                "tiled input length {} != {}",
922                                t.len(),
923                                h * (c / 8) * w * 8
924                            )));
925                        }
926                        t.to_vec()
927                    }
928                    None => store.bf16(tensor)?.to_vec(),
929                };
930                bufs.insert(*buf, Buf { v, c, h, w });
931            }
932            Step::Conv(cv) => {
933                let (oc, oh, ow, owp) = (cv.oc, cv.oh, cv.ow, cv.owp);
934                npu.ensure(&cv.ctx, &cv.xclbin, &cv.insts, &cv.kernel)?;
935                let inp = slice_groups_wpad(&bufs[&cv.input], cv.c0, cv.icg / 8, cv.wp);
936                let part = npu.run(&cv.ctx, &inp, store.bf16(&cv.wt)?, oc * oh * owp)?;
937                let a = acc
938                    .entry(cv.out)
939                    .or_insert_with(|| vec![0f32; oc * oh * owp]);
940                if cv.first {
941                    a.iter_mut().for_each(|x| *x = 0.0);
942                }
943                for (dst, &p) in a.iter_mut().zip(&part) {
944                    *dst += bf16_to_f32(p);
945                }
946                if cv.last {
947                    // Crop the padded output width owp -> ow (tiled layout).
948                    let mut v = vec![0u16; oh * (oc / 8) * ow * 8];
949                    for y in 0..oh {
950                        for g in 0..(oc / 8) {
951                            for x in 0..ow {
952                                for lane in 0..8 {
953                                    let si = (((y * (oc / 8)) + g) * owp + x) * 8 + lane;
954                                    let di = (((y * (oc / 8)) + g) * ow + x) * 8 + lane;
955                                    v[di] = f32_to_bf16(a[si]);
956                                }
957                            }
958                        }
959                    }
960                    bufs.insert(
961                        cv.out,
962                        Buf {
963                            v,
964                            c: oc,
965                            h: oh,
966                            w: ow,
967                        },
968                    );
969                }
970            }
971            Step::Affine {
972                input,
973                out,
974                scale,
975                shift,
976            } => {
977                let r = affine_tiled(&bufs[input], store.bf16(scale)?, store.bf16(shift)?);
978                bufs.insert(*out, r);
979            }
980            Step::PreluBias { buf, alpha, bias } => {
981                let buf = bufs.get_mut(buf).expect("checked at load");
982                prelu_bias_tiled(buf, store.bf16(alpha)?, store.bf16(bias)?);
983            }
984            Step::Bias { buf, bias } => {
985                let buf = bufs.get_mut(buf).expect("checked at load");
986                bias_tiled(buf, store.bf16(bias)?);
987            }
988            Step::Subsample { input, out, stride } => {
989                let r = subsample_tiled(&bufs[input], *stride);
990                bufs.insert(*out, r);
991            }
992            Step::Add { a, b, out } => {
993                let r = add_tiled(&bufs[a], &bufs[b]);
994                bufs.insert(*out, r);
995            }
996            Step::Head {
997                input,
998                k,
999                n,
1000                bt,
1001                b2,
1002            } => {
1003                // Embedding head on the CPU: an exact f32 mat-vec with the
1004                // folded head weights (both head BatchNorms folded into the
1005                // Linear). M=1, so this is far cheaper than the padded M=32 NPU
1006                // GEMM, and it keeps the head off the NPU so all 16 conv kernels
1007                // stay resident (the NPU2 concurrent-context cap is 16).
1008                let (k, n) = (*k, *n);
1009                let flat = tiled_to_nchw_f32(&bufs[input]); // [k]
1010                let bt = &head_bt[bt]; // [k * n], row-major [k][n]
1011                let mut e = store.f32(b2)?.to_vec(); // [n], folded bias
1012                for i in 0..k {
1013                    let f = flat[i];
1014                    let row = &bt[i * n..(i + 1) * n];
1015                    for j in 0..n {
1016                        e[j] += f * row[j];
1017                    }
1018                }
1019                embed = e;
1020            }
1021        }
1022    }
1023    Ok(embed)
1024}
1025
1026// ----------------------------------------------------------------------------
1027// CLI (the run_ir18 / run_ir101 binaries)
1028// ----------------------------------------------------------------------------
1029
1030/// Full CLI entry point, shared by the per-network binaries (run_ir18,
1031/// run_ir101). `net_name` labels the output when the bundle has no `model`
1032/// param; the replayed network is whatever the bundle contains. Prints
1033/// per-forward latency (cold vs warm) and the embedding's cosine to the CPU
1034/// reference (`ref.cpu_embedding`) and the recorder's embedding
1035/// (`ref.npu_embedding`); when the bundle has the raw f32 input
1036/// (`ref.input_chw`), also exercises the public [`IrEmbedder::embed`] API and
1037/// checks it reproduces the recorded replay.
1038pub fn cli(net_name: &str) {
1039    let a: Vec<String> = std::env::args().collect();
1040    if a.len() < 2 {
1041        let prog = a
1042            .first()
1043            .map(|p| p.rsplit('/').next().unwrap_or(p).to_string())
1044            .unwrap_or_else(|| "run_ir".to_string());
1045        eprintln!("usage: {prog} <bundle_dir> [reps]");
1046        std::process::exit(2);
1047    }
1048    let dir = PathBuf::from(&a[1]);
1049    // Repeated forwards demonstrate steady-state throughput once every kernel's
1050    // hw-context is resident (reps from arg 2 or $NREPS, default 1).
1051    let reps: usize = a
1052        .get(2)
1053        .cloned()
1054        .or_else(|| std::env::var("NREPS").ok())
1055        .and_then(|s| s.parse().ok())
1056        .unwrap_or(1)
1057        .max(1);
1058    if let Err(e) = cli_run(net_name, &dir, reps) {
1059        eprintln!("{e}");
1060        std::process::exit(1);
1061    }
1062}
1063
1064fn cli_run(net_name: &str, dir: &Path, reps: usize) -> Result<(), Error> {
1065    use std::time::Instant;
1066
1067    let mut emb = IrEmbedder::load(dir)?;
1068    let mut embed = Vec::new();
1069    let mut cold = 0f64;
1070    let mut warm_sum = 0f64;
1071    for i in 0..reps {
1072        let t = Instant::now();
1073        embed = emb.embed_recorded()?;
1074        let dt = t.elapsed().as_secs_f64() * 1e3;
1075        if i == 0 {
1076            cold = dt;
1077        } else {
1078            warm_sum += dt;
1079        }
1080    }
1081
1082    println!(
1083        "{} forward: {} conv dispatches on the NPU + CPU head",
1084        emb.model().unwrap_or(net_name),
1085        emb.num_conv_dispatches()
1086    );
1087    println!("embedding: {} dims", embed.len());
1088    if reps == 1 {
1089        println!("latency: {cold:.0} ms");
1090    } else {
1091        println!(
1092            "latency: cold {:.0} ms, warm {:.0} ms (mean of {} reps; contexts resident)",
1093            cold,
1094            warm_sum / (reps - 1) as f64,
1095            reps - 1
1096        );
1097    }
1098
1099    // Self-check against the recorder's own embedding (should be ~identical).
1100    if let Ok(exp) = emb.store.f32("ref.npu_embedding") {
1101        println!(
1102            "cosine(Rust, recorder embedding)     = {:.5}",
1103            cosine(&embed, exp)
1104        );
1105    }
1106    // Exercise the public embed() API: feeding the raw f32 image through the
1107    // library's pad + bf16-round + tile path must reproduce the recorded
1108    // replay bit-for-bit (torch and the library both round to nearest even).
1109    if let Ok(chw) = emb.store.f32("ref.input_chw") {
1110        let chw = chw.to_vec();
1111        let e2 = emb.embed(&chw)?;
1112        println!(
1113            "cosine(embed() API, recorded replay) = {:.5}",
1114            cosine(&e2, &embed)
1115        );
1116    }
1117    // Ground-truth check against the CPU reference embedding.
1118    if let Ok(r) = emb.store.f32("ref.cpu_embedding") {
1119        let cos = cosine(&embed, r);
1120        println!("cosine(Rust, CPU reference)          = {cos:.5}");
1121        println!(
1122            "{}",
1123            if cos > 0.99 {
1124                "RESULT: MATCH (Rust embedding agrees with the CPU reference)"
1125            } else {
1126                "RESULT: embedding differs from the CPU reference"
1127            }
1128        );
1129    }
1130    Ok(())
1131}