Files
SimurgArray/rust/simurg-core/src/lib.rs
T
kemal 6524a6d1b0 P9 (WIP): compiled kernels (rust/simurg-core, PyO3) with bit-identical pure-Python fallback
Rust ports of the hot paths found by profiling: fused per-locus GenCall calling
and scoring, BAF/LRR, libm maps with CPython math semantics, both nearest-neighbour
searches of the normalization fit, and the BPM LocusEntry parser. Address
coverage is memoised per IDAT layout. SIMURG_PURE_PYTHON=1 forces the pure paths.
On the Pi: genotype_sample 3.4 s -> 1.1 s, 36-sample GSA run 131 s -> 46 s, BPM
read 4x faster; 36 GSA + 6 Omni2.5 GTCs and QC tables byte-identical to the pure
path; tests/unit/test_core_kernels.py checks every kernel on edge cases.
bench/profile_genotype.py, bench/scaling.py, cloud task p9 (thread scaling).
2026-09-28 21:28:55 +01:00

227 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Compiled kernels for SimurgArray (plan §18 P9, §19).
//!
//! Every kernel is a drop-in replacement for a pure-Python/NumPy reference in the `simurg` package and returns
//! bit-identical results (checked by `tests/unit/test_core_kernels.py`):
//!
//! * `exp`, `log10`, `log2`, `pow` — element-wise glibc libm calls with the error semantics of CPython's `math`
//! module as used by `simurg.genotype.gencall._c_call` (NaN input returned as is, a domain error gives the
//! canonical +NaN, `log10(0)` = −inf, overflow = +inf). The libm symbols are the ones CPython's `math` calls, so
//! results equal the C reference implementations (gtc2vcf, MIT) that the Python port follows.
//! * `closest_binned` — the per-bin candidate scan of the vendor's binned nearest-neighbour search
//! (`simurg.signal.normalization.fit._closest_binned`): float32 squared distances, first minimum wins.
//!
//! * `call_genotypes`, `baf_lrr` — fused per-locus GenCall calling/scoring and BAF/LRR (`gencall.rs`).
//! * `bpm_locus_entries` — the BPM LocusEntry parser (`simurg.formats.bpm._parse_locus_entries`).
//!
//! No floating-point contraction (FMA) happens: Rust never fuses `a*b+c` implicitly.
mod bpm;
mod gencall;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
#[link(name = "m")]
unsafe extern "C" {
#[link_name = "exp"]
fn c_exp(x: f64) -> f64;
#[link_name = "log10"]
fn c_log10(x: f64) -> f64;
#[link_name = "log2"]
fn c_log2(x: f64) -> f64;
#[link_name = "pow"]
fn c_pow(x: f64, y: f64) -> f64;
}
/// CPython's `float('nan')`.
const PY_NAN: f64 = f64::from_bits(0x7ff8_0000_0000_0000);
#[inline]
pub(crate) fn math_exp(v: f64) -> f64 {
if v.is_nan() {
return v;
}
let r = unsafe { c_exp(v) };
if r.is_nan() { PY_NAN } else if r.is_infinite() && v.is_finite() { f64::INFINITY } else { r }
}
/// CPython `m_log10`/`m_log2` + `_c_call`: x > 0 → libm; 0 → −inf (log10) or NaN (log2); x < 0, −inf → NaN.
#[inline]
pub(crate) fn math_log(v: f64, ten: bool) -> f64 {
if v.is_nan() {
return v;
}
if v.is_infinite() {
return if v > 0.0 { v } else { PY_NAN };
}
if v > 0.0 {
return unsafe { if ten { c_log10(v) } else { c_log2(v) } };
}
if v == 0.0 && ten { f64::NEG_INFINITY } else { PY_NAN }
}
/// CPython `math.pow(v, e)` for a finite exponent e > 0 that is not an integer, + `_c_call`.
#[inline]
pub(crate) fn math_pow(v: f64, e: f64) -> f64 {
if v.is_nan() {
return v;
}
if v.is_infinite() {
return f64::INFINITY;
}
if v == 0.0 {
return 0.0;
}
let r = unsafe { c_pow(v, e) };
if r.is_nan() { PY_NAN } else if r.is_infinite() { f64::INFINITY } else { r }
}
fn map_f64<'py>(py: Python<'py>, x: PyReadonlyArray1<'py, f64>, f: impl Fn(f64) -> f64) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = x.as_slice()?;
let out: Vec<f64> = s.iter().map(|&v| f(v)).collect();
Ok(out.into_pyarray(py))
}
#[pyfunction]
fn exp<'py>(py: Python<'py>, x: PyReadonlyArray1<'py, f64>) -> PyResult<Bound<'py, PyArray1<f64>>> {
map_f64(py, x, math_exp)
}
#[pyfunction]
fn log10<'py>(py: Python<'py>, x: PyReadonlyArray1<'py, f64>) -> PyResult<Bound<'py, PyArray1<f64>>> {
map_f64(py, x, |v| math_log(v, true))
}
#[pyfunction]
fn log2<'py>(py: Python<'py>, x: PyReadonlyArray1<'py, f64>) -> PyResult<Bound<'py, PyArray1<f64>>> {
map_f64(py, x, |v| math_log(v, false))
}
#[pyfunction]
fn pow<'py>(py: Python<'py>, x: PyReadonlyArray1<'py, f64>, e: f64) -> PyResult<Bound<'py, PyArray1<f64>>> {
if !(e.is_finite() && e > 0.0 && e.fract() != 0.0) {
return Err(PyValueError::new_err("pow kernel: exponent must be finite, positive and not an integer"));
}
map_f64(py, x, |v| math_pow(v, e))
}
/// For each sweep point `p` searching bin `choice[p]` (0..11 regular, 12..22 shifted; any other value: none), the
/// index of the closest candidate among `eligible[i]` with `b1[i] == choice` (regular) or `b2[i] == choice − 12`
/// (shifted), in ascending order; squared distance in float32; −1 if no candidate or the best is not < 1e20.
#[pyfunction]
#[allow(clippy::too_many_arguments)]
fn closest_binned<'py>(
py: Python<'py>,
raw_x: PyReadonlyArray1<'py, f32>,
raw_y: PyReadonlyArray1<'py, f32>,
axis_x: PyReadonlyArray1<'py, f32>,
axis_y: PyReadonlyArray1<'py, f32>,
choice: PyReadonlyArray1<'py, i64>,
eligible: PyReadonlyArray1<'py, i64>,
b1: PyReadonlyArray1<'py, i64>,
b2: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let (rx, ry) = (raw_x.as_slice()?, raw_y.as_slice()?);
let (ax, ay, ch) = (axis_x.as_slice()?, axis_y.as_slice()?, choice.as_slice()?);
let (el, b1, b2) = (eligible.as_slice()?, b1.as_slice()?, b2.as_slice()?);
if ax.len() != ay.len() || ax.len() != ch.len() || el.len() != b1.len() || el.len() != b2.len() || rx.len() != ry.len() {
return Err(PyValueError::new_err("closest_binned: length mismatch"));
}
let mut groups: Vec<Vec<usize>> = vec![Vec::new(); 23];
for (i, &e) in el.iter().enumerate() {
let e = usize::try_from(e).map_err(|_| PyValueError::new_err("closest_binned: negative index"))?;
if e >= rx.len() {
return Err(PyValueError::new_err("closest_binned: index out of range"));
}
if (0..12).contains(&b1[i]) {
groups[b1[i] as usize].push(e);
}
if (0..11).contains(&b2[i]) {
groups[12 + b2[i] as usize].push(e);
}
}
let out: Vec<i64> = (0..ax.len())
.map(|p| {
let c = ch[p];
if !(0..23).contains(&c) {
return -1;
}
let cand = &groups[c as usize];
let mut best_j = usize::MAX;
let mut best = f32::INFINITY;
for (j, &e) in cand.iter().enumerate() {
let dx = rx[e] - ax[p];
let dy = ry[e] - ay[p];
let d = dx * dx + dy * dy;
// numpy argmin: the first NaN wins, otherwise the first strict minimum
if d.is_nan() {
best_j = j;
best = d;
break;
}
if best_j == usize::MAX || d < best {
best_j = j;
best = d;
}
}
if best_j == usize::MAX || !((best as f64) < 1e20) { -1 } else { cand[best_j] as i64 }
})
.collect();
Ok(out.into_pyarray(py))
}
/// Exact nearest neighbour (`simurg.signal.normalization.fit._closest_slow`): for each sweep point (x, y) the
/// index of the reference point with the smallest float32 squared distance `(x-xr)²+(y-yr)²`; first minimum wins,
/// a NaN distance wins as in `numpy.argmin`. `xref` must not be empty.
#[pyfunction]
fn closest_exact<'py>(
py: Python<'py>,
xref: PyReadonlyArray1<'py, f32>,
yref: PyReadonlyArray1<'py, f32>,
x: PyReadonlyArray1<'py, f32>,
y: PyReadonlyArray1<'py, f32>,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let (xr, yr, xs, ys) = (xref.as_slice()?, yref.as_slice()?, x.as_slice()?, y.as_slice()?);
if xr.is_empty() || xr.len() != yr.len() || xs.len() != ys.len() {
return Err(PyValueError::new_err("closest_exact: empty reference or length mismatch"));
}
let out: Vec<i64> = xs
.iter()
.zip(ys)
.map(|(&px, &py_)| {
let mut best_j = 0usize;
let mut best = f32::INFINITY;
for (j, (&rx, &ry)) in xr.iter().zip(yr).enumerate() {
let dx = px - rx;
let dy = py_ - ry;
let d = dx * dx + dy * dy;
if d.is_nan() {
return j as i64;
}
if j == 0 || d < best {
best_j = j;
best = d;
}
}
best_j as i64
})
.collect();
Ok(out.into_pyarray(py))
}
#[pymodule]
fn simurg_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
m.add_function(wrap_pyfunction!(exp, m)?)?;
m.add_function(wrap_pyfunction!(log10, m)?)?;
m.add_function(wrap_pyfunction!(log2, m)?)?;
m.add_function(wrap_pyfunction!(pow, m)?)?;
m.add_function(wrap_pyfunction!(closest_binned, m)?)?;
m.add_function(wrap_pyfunction!(closest_exact, m)?)?;
m.add_function(wrap_pyfunction!(bpm::bpm_locus_entries, m)?)?;
m.add_function(wrap_pyfunction!(gencall::call_genotypes, m)?)?;
m.add_function(wrap_pyfunction!(gencall::baf_lrr, m)?)?;
Ok(())
}