mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:37:11 -06:00
387 lines
18 KiB
Rust
387 lines
18 KiB
Rust
//! Single-thread CPU execution of the checked denoise network.
|
|
//!
|
|
//! Convolutions lower at most 256 spatial positions at a time, then use faer's safe views over pure-Rust GEMM.
|
|
//! No inner thread pool: `run::denoise_bayer` already runs tiles on the application's Rayon pool. Tensor lifetimes
|
|
//! are planned once; buffers and im2col storage are recycled between calls, independently for concurrent callers.
|
|
|
|
use crate::net::{Net, NetError, Op};
|
|
use crate::run::{Error, TileRunner};
|
|
use faer::{Parallelism, mat};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
const BLOCK: usize = 256;
|
|
/// Cap all tensor buffers and convolution scratch for one tile together (512 MiB).
|
|
const MAX_VALUES: usize = 128 * 1024 * 1024;
|
|
const MAX_TILE: usize = 2048;
|
|
const MAX_IDLE: usize = 1;
|
|
|
|
type Result<T> = std::result::Result<T, NetError>;
|
|
fn bad(why: &str) -> NetError {
|
|
NetError::Invalid(why.into())
|
|
}
|
|
fn count(c: usize, side: usize) -> Result<usize> {
|
|
c.checked_mul(side)
|
|
.and_then(|n| n.checked_mul(side))
|
|
.filter(|&n| n > 0 && n <= MAX_VALUES)
|
|
.ok_or_else(|| bad("a tensor has no size or is too large"))
|
|
}
|
|
fn zeros(n: usize) -> Result<Vec<f32>> {
|
|
let mut v = Vec::new();
|
|
v.try_reserve_exact(n).map_err(|_| bad("not enough memory for a denoise tile"))?;
|
|
v.resize(n, 0.0);
|
|
Ok(v)
|
|
}
|
|
fn sources(op: &Op) -> (usize, Option<usize>) {
|
|
match op {
|
|
Op::Conv(c) => (c.src, c.src2),
|
|
Op::ConvT2(c) => (c.src, None),
|
|
Op::MaxPool2 { src } | Op::DepthToSpace2 { src } => (*src, None),
|
|
}
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
struct Tensor {
|
|
slot: usize,
|
|
side: usize,
|
|
channels: usize,
|
|
}
|
|
struct Plan {
|
|
net: Net,
|
|
tensors: Vec<Tensor>,
|
|
sizes: Vec<usize>,
|
|
lower: usize,
|
|
product: usize,
|
|
up_weights: Vec<Option<Vec<f32>>>,
|
|
}
|
|
struct Workspace {
|
|
buffers: Vec<Vec<f32>>,
|
|
lower: Vec<f32>,
|
|
product: Vec<f32>,
|
|
}
|
|
|
|
/// A checked Net on square CHW tiles. Clones share immutable weights and the idle workspace cache.
|
|
#[derive(Clone)]
|
|
pub struct NetRunner {
|
|
plan: Arc<Plan>,
|
|
idle: Arc<Mutex<Vec<Workspace>>>,
|
|
}
|
|
|
|
impl NetRunner {
|
|
pub fn new(net: &Net, tile: usize) -> Result<Self> {
|
|
if tile == 0
|
|
|| tile > MAX_TILE
|
|
|| !net.fits_tile(tile)
|
|
|| net.in_channels != 4
|
|
|| net.out_channels() != 3
|
|
|| net.shape(0).is_none_or(|s| s.channels != net.in_channels)
|
|
{
|
|
return Err(bad("the tile or Bayer-to-RGB contract does not fit the network"));
|
|
}
|
|
let mut last = vec![0; net.ops().len() + 1];
|
|
for (i, op) in net.ops().iter().enumerate() {
|
|
let (a, b) = sources(op);
|
|
for id in std::iter::once(a).chain(b) {
|
|
*last.get_mut(id).ok_or_else(|| bad("missing tensor"))? = i + 1;
|
|
}
|
|
}
|
|
let mut tensors = vec![Tensor { slot: 0, side: tile, channels: net.in_channels }];
|
|
let mut sizes = vec![count(net.in_channels, tile)?];
|
|
let mut busy = vec![true];
|
|
let (mut lower, mut product) = (0, 0);
|
|
let mut up_weights = Vec::new();
|
|
for (i, op) in net.ops().iter().enumerate() {
|
|
let (src, src2) = sources(op);
|
|
let a = *tensors.get(src).ok_or_else(|| bad("missing tensor"))?;
|
|
let (channels, side) = match op {
|
|
Op::Conv(c) => {
|
|
if let Some(s) = src2
|
|
&& tensors.get(s).is_none_or(|b| b.side != a.side)
|
|
{
|
|
return Err(bad("a join has different sizes"));
|
|
}
|
|
(c.cout, a.side)
|
|
}
|
|
Op::ConvT2(c) => (c.cout, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
|
Op::MaxPool2 { .. } => {
|
|
if a.side < 2 || !a.side.is_multiple_of(2) {
|
|
return Err(bad("pooling does not divide the tile"));
|
|
}
|
|
(a.channels, a.side / 2)
|
|
}
|
|
Op::DepthToSpace2 { .. } => (a.channels / 4, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
|
};
|
|
let n = count(channels, side)?;
|
|
let slot = busy
|
|
.iter()
|
|
.zip(&sizes)
|
|
.enumerate()
|
|
.filter(|(_, (used, _))| !**used)
|
|
.min_by_key(|(_, (_, size))| (n.saturating_sub(**size), **size))
|
|
.map_or(busy.len(), |(i, _)| i);
|
|
if slot == sizes.len() {
|
|
sizes.push(n);
|
|
busy.push(true);
|
|
} else {
|
|
let sz = sizes.get_mut(slot).ok_or_else(|| bad("missing buffer"))?;
|
|
*sz = (*sz).max(n);
|
|
*busy.get_mut(slot).ok_or_else(|| bad("missing buffer"))? = true;
|
|
}
|
|
tensors.push(Tensor { slot, side, channels });
|
|
// Release sources only after the output has its own buffer (also handles reading the same tensor twice).
|
|
for id in std::iter::once(src).chain(src2) {
|
|
if last.get(id) == Some(&(i + 1)) {
|
|
let t = tensors.get(id).ok_or_else(|| bad("missing tensor"))?;
|
|
*busy.get_mut(t.slot).ok_or_else(|| bad("missing buffer"))? = false;
|
|
}
|
|
}
|
|
let (k, m, positions) = match op {
|
|
Op::Conv(c) => (c.cin * c.k * c.k, c.cout, a.side * a.side),
|
|
Op::ConvT2(c) => (c.cin, c.cout * 4, a.side * a.side),
|
|
_ => (0, 0, 0),
|
|
};
|
|
lower = lower.max(k.saturating_mul(BLOCK.min(positions)));
|
|
product = product.max(m.saturating_mul(BLOCK.min(positions)));
|
|
let packed = if let Op::ConvT2(c) = op {
|
|
let mut w = zeros(c.weight.len())?;
|
|
// [cin][cout][dy][dx] -> [cout*4][cin], so an up-convolution is one GEMM and a shuffle.
|
|
for (ci, row) in c.weight.chunks_exact(c.cout * 4).enumerate() {
|
|
for (co, &v) in row.iter().enumerate() {
|
|
*w.get_mut(co * c.cin + ci).ok_or_else(|| bad("invalid transposed weights"))? = v;
|
|
}
|
|
}
|
|
Some(w)
|
|
} else {
|
|
None
|
|
};
|
|
up_weights.push(packed);
|
|
}
|
|
let total = sizes
|
|
.iter()
|
|
.try_fold(lower.saturating_add(product), |n, &s| n.checked_add(s))
|
|
.filter(|&n| n <= MAX_VALUES)
|
|
.ok_or_else(|| bad("the tile needs too much working memory"))?;
|
|
let _ = total;
|
|
Ok(Self { plan: Arc::new(Plan { net: net.clone(), tensors, sizes, lower, product, up_weights }), idle: Arc::new(Mutex::new(Vec::new())) })
|
|
}
|
|
/// Immutable data for the GPU runner, without re-reading the model.
|
|
pub fn net(&self) -> &Net {
|
|
&self.plan.net
|
|
}
|
|
pub fn bytes_per_tile(&self) -> usize {
|
|
(self.plan.sizes.iter().sum::<usize>() + self.plan.lower + self.plan.product) * 4
|
|
}
|
|
fn workspace(&self) -> Result<Workspace> {
|
|
if let Some(w) = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() {
|
|
return Ok(w);
|
|
}
|
|
Ok(Workspace {
|
|
buffers: self.plan.sizes.iter().map(|&n| zeros(n)).collect::<Result<_>>()?,
|
|
lower: zeros(self.plan.lower)?,
|
|
product: zeros(self.plan.product)?,
|
|
})
|
|
}
|
|
fn execute(&self, input: &[f32], w: &mut Workspace) -> Result<Vec<f32>> {
|
|
let first = self.plan.tensors.first().ok_or_else(|| bad("no input"))?;
|
|
let want = count(first.channels, first.side)?;
|
|
w.buffers.first_mut().and_then(|b| b.get_mut(..want)).ok_or_else(|| bad("missing input buffer"))?.copy_from_slice(input);
|
|
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
|
for (i, op) in self.plan.net.ops().iter().enumerate() {
|
|
let started = web_time::Instant::now();
|
|
let out = *self.plan.tensors.get(i + 1).ok_or_else(|| bad("missing output tensor"))?;
|
|
let (before, rest) = w.buffers.split_at_mut_checked(out.slot).ok_or_else(|| bad("missing output buffer"))?;
|
|
let (dest, after) = rest.split_first_mut().ok_or_else(|| bad("missing output buffer"))?;
|
|
let get = |id: usize| -> Result<(&[f32], Tensor)> {
|
|
let t = *self.plan.tensors.get(id).ok_or_else(|| bad("missing source tensor"))?;
|
|
let data = if t.slot < out.slot {
|
|
before.get(t.slot)
|
|
} else {
|
|
after.get(t.slot.checked_sub(out.slot + 1).ok_or_else(|| bad("aliased buffers"))?)
|
|
}
|
|
.and_then(|b| b.get(..count(t.channels, t.side).ok()?))
|
|
.ok_or_else(|| bad("missing source buffer"))?;
|
|
Ok((data, t))
|
|
};
|
|
let (src, src2) = sources(op);
|
|
let (a, shape) = get(src)?;
|
|
let b = src2.map(get).transpose()?.map(|v| v.0);
|
|
let dest = dest.get_mut(..count(out.channels, out.side)?).ok_or_else(|| bad("short output buffer"))?;
|
|
match op {
|
|
Op::Conv(c) => conv(c, a, b, shape.side, dest, &mut w.lower, &mut w.product)?,
|
|
Op::ConvT2(c) => up(
|
|
c,
|
|
a,
|
|
shape.side,
|
|
dest,
|
|
self.plan.up_weights.get(i).and_then(Option::as_deref).ok_or_else(|| bad("missing up weights"))?,
|
|
&mut w.lower,
|
|
&mut w.product,
|
|
)?,
|
|
Op::MaxPool2 { .. } => pool(a, shape.side, dest)?,
|
|
Op::DepthToSpace2 { .. } => shuffle(a, shape.side, dest)?,
|
|
}
|
|
if dest.iter().any(|v| !v.is_finite()) {
|
|
return Err(bad("the model produced non-finite values"));
|
|
}
|
|
if profile {
|
|
eprintln!("[denoise-cpu] layer {i}: {}x{}x{} {:.2} ms", out.channels, out.side, out.side, started.elapsed().as_secs_f64() * 1e3);
|
|
}
|
|
}
|
|
let t = self.plan.tensors.last().ok_or_else(|| bad("no output"))?;
|
|
let data = w.buffers.get(t.slot).and_then(|b| b.get(..count(t.channels, t.side).ok()?)).ok_or_else(|| bad("missing result"))?;
|
|
let mut out = zeros(data.len())?;
|
|
out.copy_from_slice(data);
|
|
Ok(out)
|
|
}
|
|
}
|
|
impl TileRunner for NetRunner {
|
|
fn run(&self, input: &[f32]) -> std::result::Result<Vec<f32>, Error> {
|
|
let first = self.plan.tensors.first().ok_or_else(|| Error::Runtime("missing input shape".into()))?;
|
|
let want = count(first.channels, first.side).map_err(|e| Error::Runtime(e.to_string()))?;
|
|
if input.len() != want || input.iter().any(|v| !v.is_finite()) {
|
|
return Err(Error::Input("the tile has the wrong size or non-finite values".into()));
|
|
}
|
|
let mut w = self.workspace().map_err(|e| Error::Runtime(e.to_string()))?;
|
|
let result = self.execute(input, &mut w).map_err(|e| Error::Runtime(e.to_string()));
|
|
let mut idle = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if idle.len() < MAX_IDLE {
|
|
idle.push(w);
|
|
}
|
|
result
|
|
}
|
|
}
|
|
fn activate(x: f32, bias: f32, alpha: Option<f32>) -> f32 {
|
|
let x = x + bias;
|
|
if x < 0.0 { x * alpha.unwrap_or(1.0) } else { x }
|
|
}
|
|
|
|
/// All views are over exact, checked slices. The matrix library's assertions therefore never see file-derived sizes.
|
|
fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) -> Result<()> {
|
|
if m.checked_mul(k) != Some(w.len()) || k.checked_mul(n) != Some(x.len()) || m.checked_mul(n) != Some(y.len()) || m == 0 || k == 0 || n == 0 {
|
|
return Err(bad("invalid matrix dimensions"));
|
|
}
|
|
faer::mul::matmul(
|
|
mat::from_row_major_slice_mut::<f32>(y, m, n),
|
|
mat::from_row_major_slice::<f32>(w, m, k),
|
|
mat::from_row_major_slice::<f32>(x, k, n),
|
|
None,
|
|
1.0,
|
|
Parallelism::None,
|
|
);
|
|
Ok(())
|
|
}
|
|
fn lower(a: &[f32], b: Option<&[f32]>, side: usize, k: usize, start: usize, n: usize, dest: &mut [f32]) -> Result<()> {
|
|
let pad = k / 2;
|
|
let pixels = side * side;
|
|
// Whole row segments are copied, so bounds checks and edge branches stay out of the multiply loop.
|
|
for (ci, plane) in a.chunks_exact(pixels).chain(b.into_iter().flat_map(|b| b.chunks_exact(pixels))).enumerate() {
|
|
for ky in 0..k {
|
|
for kx in 0..k {
|
|
let row = (ci * k * k + ky * k + kx) * n;
|
|
let out = dest.get_mut(row..row + n).ok_or_else(|| bad("short im2col buffer"))?;
|
|
let mut at = 0;
|
|
while at < n {
|
|
let p = start + at;
|
|
let (y, x) = (p / side, p % side);
|
|
let len = (side - x).min(n - at);
|
|
if let Some(yy) = y.checked_add(ky).and_then(|v| v.checked_sub(pad)).filter(|&v| v < side) {
|
|
let lo = x.max(pad.saturating_sub(kx));
|
|
let hi = (x + len).min((side + pad).saturating_sub(kx));
|
|
if lo < hi {
|
|
let from = yy * side + lo + kx - pad;
|
|
let to = at + lo - x;
|
|
out.get_mut(at..to).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
|
out.get_mut(to..to + hi - lo)
|
|
.ok_or_else(|| bad("short im2col row"))?
|
|
.copy_from_slice(plane.get(from..from + hi - lo).ok_or_else(|| bad("short source row"))?);
|
|
out.get_mut(to + hi - lo..at + len).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
|
} else {
|
|
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
|
}
|
|
} else {
|
|
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
|
}
|
|
at += len;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn conv(c: &crate::net::Conv, a: &[f32], b: Option<&[f32]>, side: usize, dest: &mut [f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
|
let pixels = side * side;
|
|
let k = c.cin * c.k * c.k;
|
|
for start in (0..pixels).step_by(BLOCK) {
|
|
let n = BLOCK.min(pixels - start);
|
|
let x = scratch.get_mut(..k * n).ok_or_else(|| bad("short im2col storage"))?;
|
|
lower(a, b, side, c.k, start, n, x)?;
|
|
let y = product.get_mut(..c.cout * n).ok_or_else(|| bad("short product storage"))?;
|
|
multiply(&c.weight, x, y, c.cout, k, n)?;
|
|
for ((row, &bias), out) in y.chunks_exact(n).zip(&c.bias).zip(dest.chunks_exact_mut(pixels)) {
|
|
let to = out.get_mut(start..start + n).ok_or_else(|| bad("short convolution output"))?;
|
|
for (d, &v) in to.iter_mut().zip(row) {
|
|
*d = activate(v, bias, c.leaky);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn up(c: &crate::net::ConvT2, a: &[f32], side: usize, dest: &mut [f32], weights: &[f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
|
let pixels = side * side;
|
|
let out_side = 2 * side;
|
|
for start in (0..pixels).step_by(BLOCK) {
|
|
let n = BLOCK.min(pixels - start);
|
|
let x = scratch.get_mut(..c.cin * n).ok_or_else(|| bad("short up input storage"))?;
|
|
for (to, from) in x.chunks_exact_mut(n).zip(a.chunks_exact(pixels)) {
|
|
to.copy_from_slice(from.get(start..start + n).ok_or_else(|| bad("short up input"))?);
|
|
}
|
|
let y = product.get_mut(..c.cout * 4 * n).ok_or_else(|| bad("short up product storage"))?;
|
|
multiply(weights, x, y, c.cout * 4, c.cin, n)?;
|
|
for (co, (&bias, out)) in c.bias.iter().zip(dest.chunks_exact_mut(4 * pixels)).enumerate() {
|
|
for d in 0..4 {
|
|
let row = y.get((4 * co + d) * n..(4 * co + d + 1) * n).ok_or_else(|| bad("short up product"))?;
|
|
for (j, &v) in row.iter().enumerate() {
|
|
let p = start + j;
|
|
let at = (2 * (p / side) + d / 2) * out_side + 2 * (p % side) + d % 2;
|
|
*out.get_mut(at).ok_or_else(|| bad("short up output"))? = activate(v, bias, c.leaky);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn pool(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
|
let s = side / 2;
|
|
for (input, out) in a.chunks_exact(side * side).zip(dest.chunks_exact_mut(s * s)) {
|
|
for (y, row) in out.chunks_exact_mut(s).enumerate() {
|
|
let lo = input.get(2 * y * side..(2 * y + 1) * side).ok_or_else(|| bad("short pool input"))?;
|
|
let hi = input.get((2 * y + 1) * side..(2 * y + 2) * side).ok_or_else(|| bad("short pool input"))?;
|
|
for ((d, lo), hi) in row.iter_mut().zip(lo.as_chunks::<2>().0).zip(hi.as_chunks::<2>().0) {
|
|
let [a, b] = *lo;
|
|
let [c, e] = *hi;
|
|
*d = a.max(b).max(c).max(e);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn shuffle(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
|
let pixels = side * side;
|
|
for (input, out) in a.chunks_exact(4 * pixels).zip(dest.chunks_exact_mut(4 * pixels)) {
|
|
for d in 0..4 {
|
|
let plane = input.get(d * pixels..(d + 1) * pixels).ok_or_else(|| bad("short shuffle input"))?;
|
|
for (y, row) in plane.chunks_exact(side).enumerate() {
|
|
let at = (2 * y + d / 2) * 2 * side;
|
|
let to = out.get_mut(at..at + 2 * side).ok_or_else(|| bad("short shuffle output"))?;
|
|
for (to, &v) in to.as_chunks_mut::<2>().0.iter_mut().zip(row) {
|
|
if let Some(t) = to.get_mut(d % 2) {
|
|
*t = v;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|