flybrain/services/flysim/crates/flybrain-core/cuda/lif.cu
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

434 lines
19 KiB
Text

// Per-millisecond LIF tick on CUDA, bit-exact with the CPU kernel `lif-1ms-f64-v2`.
//
// Mirrors steps 1-4 and 6 of `LifNetwork::step_one` in `src/lif.rs`: the noise kicks, the visual
// drive, the stimulation drive, the integrate-and-fire sweep, and spike propagation. Plasticity
// `observe`, the role tally and the rate EMAs stay on the host.
//
// Bit-exactness rules, all of which the host side re-checks against the CPU kernel:
//
// * every intermediate is `double`; every store to a membrane rounds once with `(float)`, which
// is IEEE round-to-nearest-even and therefore `Math.fround` / Rust's `as f32`;
// * no FMA contraction (compiled with `--fmad=false`), no fast math, no reassociation;
// * the only transcendental, `exp(-1/decayMs)`, is evaluated on the host at config time and
// arrives as the already-frounded `decay` argument;
// * propagation is a thread-per-target pull over a transposed CSR whose incoming edges are
// ordered by base edge index, which is the source-major order the CPU push visits them in.
// Skipping non-spiking sources leaves a subsequence in that same order, so each target sees
// exactly the CPU's sequence of additions, with the f32 rounding and the per-edge floor clamp
// at the same points.
#define LIF_BLOCK 256u
extern "C" {
// `Math.max`, which propagates NaN (unlike fmax) and returns +0 for max(-0, +0).
// Mirrors `jsmath::js_max`.
__device__ __forceinline__ double js_max(double a, double b) {
if (!(a == a) || !(b == b)) {
return __longlong_as_double(0x7ff8000000000000LL);
}
if (a > b) {
return a;
}
if (b > a) {
return b;
}
if (a == 0.0 && b == 0.0) {
return (__double_as_longlong(a) >= 0LL) ? a : b;
}
return a;
}
// `lif::floor_clamp`: the common case first, `js_max` for the rest.
__device__ __forceinline__ double floor_clamp(double value, double floor) {
return (value > floor) ? value : js_max(floor, value);
}
// Step 1. One thread per *distinct* neuron drawn this tick, applying that neuron's kicks in draw
// order. The host groups the 300 draws by neuron, so repeats (which the CPU applies in sequence
// through one f32 store each) stay sequential here too, and distinct neurons cannot interact.
__global__ void lif_noise(float *membrane, const unsigned int *neuron, const unsigned int *count,
unsigned int groups, double amount) {
unsigned int group = blockIdx.x * blockDim.x + threadIdx.x;
if (group >= groups) {
return;
}
unsigned int index = neuron[group];
unsigned int kicks = count[group];
double value = (double)membrane[index];
for (unsigned int kick = 0; kick < kicks; ++kick) {
value = (double)(float)(value + amount);
}
membrane[index] = (float)value;
}
// Step 2. One thread per distinct visual target; `ptr`/`pos` list that neuron's columns in
// ascending column order, so a neuron that appears twice in `visual_indices` is still summed in
// the CPU's order.
__global__ void lif_visual(float *membrane, const unsigned int *neuron, const unsigned int *ptr,
const unsigned int *pos, const float *drive, unsigned int groups) {
unsigned int group = blockIdx.x * blockDim.x + threadIdx.x;
if (group >= groups) {
return;
}
unsigned int index = neuron[group];
double value = (double)membrane[index];
for (unsigned int slot = ptr[group]; slot < ptr[group + 1]; ++slot) {
value = (double)(float)(value + (double)drive[pos[slot]]);
}
membrane[index] = (float)value;
}
// Step 3. Same shape as the noise kicks: the drive is one constant, so a repeated target only
// needs its count.
__global__ void lif_stimulate(float *membrane, const unsigned int *neuron,
const unsigned int *count, unsigned int groups, double drive) {
unsigned int group = blockIdx.x * blockDim.x + threadIdx.x;
if (group >= groups) {
return;
}
unsigned int index = neuron[group];
unsigned int hits = count[group];
double value = (double)membrane[index];
for (unsigned int hit = 0; hit < hits; ++hit) {
value = (double)(float)(value + drive);
}
membrane[index] = (float)value;
}
// Step 4. Elementwise integrate-and-fire, one thread per neuron, in the same `double` expression
// `lif::sweep_one` uses. Spikes leave a per-warp ballot word in `spiked_bits` (which propagation
// reads directly) plus a per-block count for the compaction scan; the ascending spike *list* is
// built by `lif_compact` from those bits.
__global__ void lif_sweep(float *membrane, unsigned char *refractory, const float *baseline,
unsigned int neurons, double decay, double threshold,
unsigned int refractory_reset, unsigned int *spiked_bits,
unsigned int *block_counts) {
unsigned int index = blockIdx.x * blockDim.x + threadIdx.x;
bool spiked = false;
if (index < neurons) {
unsigned char left = refractory[index];
if (left > 0) {
// A refractory neuron still leaks but receives no baseline drive.
refractory[index] = (unsigned char)(left - 1);
membrane[index] = (float)((double)membrane[index] * decay);
} else {
double voltage = (double)membrane[index] * decay + (double)baseline[index];
if (voltage >= threshold) {
// A spiking neuron resets to exactly 0 rather than subtracting the threshold.
membrane[index] = 0.0f;
refractory[index] = (unsigned char)refractory_reset;
spiked = true;
} else {
membrane[index] = (float)voltage;
}
}
}
unsigned int mask = __ballot_sync(0xffffffffu, spiked);
unsigned int lane = threadIdx.x & 31u;
unsigned int warp = threadIdx.x >> 5;
__shared__ unsigned int per_warp[LIF_BLOCK / 32u];
if (lane == 0u) {
unsigned int word = (blockIdx.x * blockDim.x + warp * 32u) >> 5;
if (word < ((neurons + 31u) >> 5)) {
spiked_bits[word] = mask;
}
per_warp[warp] = (unsigned int)__popc(mask);
}
__syncthreads();
if (threadIdx.x == 0u) {
unsigned int total = 0u;
for (unsigned int slot = 0u; slot < (blockDim.x >> 5); ++slot) {
total += per_warp[slot];
}
block_counts[blockIdx.x] = total;
}
}
// Exclusive scan of the per-block spike counts, in one block, so `lif_compact` can write straight
// into the batch's flat spike buffer. Also records this tick's spike count and advances the shared
// write cursor, which keeps the whole batch's spike lists packed for a single download.
__global__ void lif_scan(const unsigned int *block_counts, unsigned int *block_offsets,
unsigned int blocks, unsigned int *tick_counts, unsigned int *tick_base,
unsigned int tick, unsigned int *cursor) {
extern __shared__ unsigned int partial[];
unsigned int thread = threadIdx.x;
unsigned int threads = blockDim.x;
unsigned int chunk = (blocks + threads - 1u) / threads;
unsigned int from = thread * chunk;
unsigned int to = from + chunk;
if (from > blocks) {
from = blocks;
}
if (to > blocks) {
to = blocks;
}
unsigned int sum = 0u;
for (unsigned int block = from; block < to; ++block) {
sum += block_counts[block];
}
partial[thread] = sum;
__syncthreads();
for (unsigned int offset = 1u; offset < threads; offset <<= 1) {
unsigned int carry = (thread >= offset) ? partial[thread - offset] : 0u;
__syncthreads();
partial[thread] += carry;
__syncthreads();
}
unsigned int base = *cursor;
unsigned int running = base + (partial[thread] - sum);
for (unsigned int block = from; block < to; ++block) {
block_offsets[block] = running;
running += block_counts[block];
}
__syncthreads();
if (thread == threads - 1u) {
tick_counts[tick] = partial[threads - 1u];
tick_base[tick] = base;
*cursor = base + partial[threads - 1u];
}
}
// The ascending spike list. Block offsets ascend with the neuron index and the rank inside a block
// is a ballot prefix, so the list is exactly the sequential kernel's: `observe` and the role tally
// consume it in this order.
__global__ void lif_compact(const unsigned int *spiked_bits, const unsigned int *block_offsets,
unsigned int neurons, unsigned int *spikes) {
unsigned int index = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int lane = threadIdx.x & 31u;
unsigned int warp = threadIdx.x >> 5;
unsigned int word = (blockIdx.x * blockDim.x + warp * 32u) >> 5;
unsigned int mask = (word < ((neurons + 31u) >> 5)) ? spiked_bits[word] : 0u;
__shared__ unsigned int per_warp[LIF_BLOCK / 32u];
if (lane == 0u) {
per_warp[warp] = (unsigned int)__popc(mask);
}
__syncthreads();
if (index < neurons && ((mask >> lane) & 1u) != 0u) {
unsigned int rank = (unsigned int)__popc(mask & ((1u << lane) - 1u));
for (unsigned int slot = 0u; slot < warp; ++slot) {
rank += per_warp[slot];
}
spikes[block_offsets[blockIdx.x] + rank] = index;
}
}
// Step 6, part 1 of 4: how many of this tick's edges land on each target.
//
// The pull formulation this replaced — a warp per target over a transposed CSR, skipping sources
// that did not spike — is bit-exact and was measured at 320 us per tick, because it has to read
// all 2,700,513 incoming edges every tick while the CPU push reads only the ~1.2 % that leave a
// spiking neuron (about 33,000 edges). That asymmetry, not the arithmetic, is what made the GPU
// slower than four CPU cores. So: push the spiking rows into per-target buckets, then let one
// thread per target sum its own bucket in the CPU's order.
//
// Bucket *placement* uses atomics and is therefore in an arbitrary order; `lif_bucket_apply`
// restores the order by sorting each bucket on the base edge index, which is exactly the CPU's
// order (the sequential kernel walks spiking sources ascending and each row ascending, and the
// base edge index is source-major then slot). Buckets hold a handful of entries, so the sort is
// free and determinism costs nothing.
//
// A grid-stride loop over the spiking sources, one warp each, so the row reads are coalesced and
// the grid does not have to be sized for the worst case.
__global__ void lif_bucket_count(const unsigned int *__restrict__ spikes,
const unsigned int *__restrict__ tick_counts,
const unsigned int *__restrict__ tick_base, unsigned int tick,
const unsigned int *__restrict__ indptr,
const unsigned int *__restrict__ targets, unsigned int *count) {
unsigned int spiking = tick_counts[tick];
unsigned int base = tick_base[tick];
unsigned int warps = (gridDim.x * blockDim.x) >> 5;
unsigned int warp = (blockIdx.x * blockDim.x + threadIdx.x) >> 5;
unsigned int lane = threadIdx.x & 31u;
for (unsigned int index = warp; index < spiking; index += warps) {
unsigned int source = spikes[base + index];
unsigned int from = indptr[source];
unsigned int to = indptr[source + 1u];
for (unsigned int edge = from + lane; edge < to; edge += 32u) {
atomicAdd(&count[targets[edge]], 1u);
}
}
}
// Step 6, part 2 of 4: the same walk again, writing each edge into its target's bucket.
//
// A bucket entry is one `unsigned long long`: the base edge index in the top 32 bits, the edge's
// `short` weight in the bottom 16, and the plastic gain slot plus one (0 for the immutable
// majority) in bits 16-30. The edge index being the most significant field means sorting the
// entries as integers sorts them by edge index, which is the CPU's order.
//
// Resolving the weight and the gain slot *here* rather than in `lif_bucket_apply` is worth 80 us
// per tick: here the reads follow a CSR row, so a warp's 32 lanes read consecutive weights and
// share a plastic-bitset word; there they would be 97,000 scattered reads by whichever thread owns
// the target. The arithmetic is unaffected — the same `short` and the same `f32` gain reach the
// same f64 expression.
__global__ void lif_bucket_place(const unsigned int *__restrict__ spikes,
const unsigned int *__restrict__ tick_counts,
const unsigned int *__restrict__ tick_base, unsigned int tick,
const unsigned int *__restrict__ indptr,
const unsigned int *__restrict__ targets,
const short *__restrict__ weights,
const unsigned long long *__restrict__ plastic_words,
const unsigned int *__restrict__ plastic_prefix,
unsigned int *cursor, unsigned long long *bucket) {
unsigned int spiking = tick_counts[tick];
unsigned int base = tick_base[tick];
unsigned int warps = (gridDim.x * blockDim.x) >> 5;
unsigned int warp = (blockIdx.x * blockDim.x + threadIdx.x) >> 5;
unsigned int lane = threadIdx.x & 31u;
for (unsigned int index = warp; index < spiking; index += warps) {
unsigned int source = spikes[base + index];
unsigned int from = indptr[source];
unsigned int to = indptr[source + 1u];
for (unsigned int edge = from + lane; edge < to; edge += 32u) {
unsigned long long word = plastic_words[edge >> 6];
unsigned int slot = 0u;
if (((word >> (edge & 63u)) & 1ULL) != 0ULL) {
unsigned long long below = word & ((1ULL << (edge & 63u)) - 1ULL);
slot = plastic_prefix[edge >> 6] + (unsigned int)__popcll(below) + 1u;
}
unsigned long long entry = ((unsigned long long)edge << 32) |
(unsigned long long)((unsigned int)(unsigned short)weights[edge]) |
((unsigned long long)slot << 16);
bucket[atomicAdd(&cursor[targets[edge]], 1u)] = entry;
}
}
}
// Step 6, part 3 of 4: an exclusive scan of the bucket sizes, in two kernels plus the small
// single-block scan below. `offset` holds the block-local exclusive scan on the way in and the
// global one on the way out.
__global__ void lif_bucket_scan(const unsigned int *__restrict__ count, unsigned int *offset,
unsigned int *block_sums, unsigned int neurons) {
__shared__ unsigned int partial[LIF_BLOCK];
unsigned int index = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int thread = threadIdx.x;
unsigned int mine = (index < neurons) ? count[index] : 0u;
partial[thread] = mine;
__syncthreads();
for (unsigned int step = 1u; step < blockDim.x; step <<= 1) {
unsigned int carry = (thread >= step) ? partial[thread - step] : 0u;
__syncthreads();
partial[thread] += carry;
__syncthreads();
}
if (index < neurons) {
offset[index] = partial[thread] - mine;
}
if (thread == blockDim.x - 1u) {
block_sums[blockIdx.x] = partial[thread];
}
}
// Exclusive scan of up to `blockDim.x` * chunk values, in one block. Used for the bucket scan's
// block sums.
__global__ void lif_exscan(const unsigned int *__restrict__ input, unsigned int *output,
unsigned int count) {
extern __shared__ unsigned int partial[];
unsigned int thread = threadIdx.x;
unsigned int threads = blockDim.x;
unsigned int chunk = (count + threads - 1u) / threads;
unsigned int from = thread * chunk;
unsigned int to = from + chunk;
if (from > count) {
from = count;
}
if (to > count) {
to = count;
}
unsigned int sum = 0u;
for (unsigned int index = from; index < to; ++index) {
sum += input[index];
}
partial[thread] = sum;
__syncthreads();
for (unsigned int step = 1u; step < threads; step <<= 1) {
unsigned int carry = (thread >= step) ? partial[thread - step] : 0u;
__syncthreads();
partial[thread] += carry;
__syncthreads();
}
unsigned int running = partial[thread] - sum;
for (unsigned int index = from; index < to; ++index) {
output[index] = running;
running += input[index];
}
}
// Add each block's base to its block-local scan, and seed the placement cursor.
__global__ void lif_bucket_rebase(unsigned int *offset, unsigned int *cursor,
const unsigned int *__restrict__ block_bases,
unsigned int neurons) {
unsigned int index = blockIdx.x * blockDim.x + threadIdx.x;
if (index >= neurons) {
return;
}
unsigned int value = offset[index] + block_bases[blockIdx.x];
offset[index] = value;
cursor[index] = value;
}
// Step 6, part 4 of 4: one thread per target sums its own bucket, in the CPU's order.
//
// Every target's membrane depends on nothing but its own previous value and its own sequence of
// addends, so this is where bit-exactness is won or lost: the bucket is sorted on the base edge
// index, each addend is the same `weight * gain * synapse_scale` product evaluated in the same
// order, and the f32 rounding and the per-edge floor clamp happen at the same points.
__global__ void lif_bucket_apply(float *membrane, const unsigned int *__restrict__ count,
const unsigned int *__restrict__ offset,
unsigned long long *bucket, const float *__restrict__ gains,
unsigned int neurons, double synapse_scale,
double membrane_floor) {
unsigned int target = blockIdx.x * blockDim.x + threadIdx.x;
if (target >= neurons) {
return;
}
unsigned int entries = count[target];
if (entries == 0u) {
return;
}
unsigned int from = offset[target];
// Sort the bucket, whose placement order is whatever the atomics produced.
//
// Shell sort with Ciura's gaps, not plain insertion sort. Buckets average well under one entry,
// so the gap loop degenerates to insertion sort for almost every target — but the neuron with
// the maximum in-degree (5,080 on fafb-v783) collects a couple of hundred entries in a tick, one
// thread owns all of them, and that thread's k^2 was measured as 100 us of the tick. Any correct
// sort preserves bit-exactness, because the keys are distinct edge indices.
const unsigned int gaps[8] = {701u, 301u, 132u, 57u, 23u, 10u, 4u, 1u};
for (unsigned int step = 0u; step < 8u; ++step) {
unsigned int gap = gaps[step];
if (gap >= entries) {
continue;
}
for (unsigned int index = gap; index < entries; ++index) {
unsigned long long key = bucket[from + index];
unsigned int at = index;
while (at >= gap && bucket[from + at - gap] > key) {
bucket[from + at] = bucket[from + at - gap];
at -= gap;
}
bucket[from + at] = key;
}
}
double value = (double)membrane[target];
for (unsigned int index = 0u; index < entries; ++index) {
unsigned long long entry = bucket[from + index];
short weight = (short)(unsigned short)(entry & 0xffffULL);
unsigned int slot = (unsigned int)((entry >> 16) & 0x7fffULL);
double gain = (slot == 0u) ? 1.0 : (double)gains[slot - 1u];
// The floor is applied per edge, not once per tick: the clamp order is part of the numerics.
double next = value + (double)weight * gain * synapse_scale;
value = (double)(float)floor_clamp(next, membrane_floor);
}
membrane[target] = (float)value;
}
} // extern "C"