cera 0.5.4

Rust-native LLM inference engine
Documentation
// Softmax in-place, single source for both GPU backends.
//
//   x[i] = exp(x[i] - max(x)) / sum(exp(x - max))
//
// Single workgroup of 256, grid-striding over n. Ported from the handwritten
// pair it is meant to replace (`shaders/softmax.wgsl`, `shaders/softmax.metal`),
// preserving both contracts exactly so no call site changes:
//
//   binding/buffer 0: x       f32, read-write, softmax applied in place
//   binding/buffer 1: params  (n, _pad)
//
// `[[vk::binding(n)]]` drives the WGSL binding index and `register(u0/t1)` the
// Metal buffer index; they are set independently because the two backends number
// their slots from different spaces, and here they happen to agree.
//
// ## Why the reductions differ per target
//
// The handwritten kernels deliberately diverged: Metal reduces with a two-stage
// `simd_max`/`simd_sum` while WGSL walks a shared-memory tree, because cera does
// not request `wgpu::Features::SUBGROUP` and so has no portable wave intrinsic.
// `__target_switch` keeps both, and the untaken branch is eliminated rather than
// compiled and skipped: the emitted MSL contains no trace of the tree, and the
// emitted WGSL contains no subgroup op. Consolidating on one reduction instead
// would either cost Metal its simd path or force a device-capability change, so
// the split stays until `Features::SUBGROUP` is decided on its own merits.
//
// Everything that carries the kernel's meaning (layout, grid-stride loops, the
// max-shift, the exp and normalize passes) is written once; only the two
// reduction helpers branch.

[[vk::binding(0)]] RWStructuredBuffer<float> x_buf   : register(u0);
[[vk::binding(1)]] StructuredBuffer<uint2>   par_buf : register(t1);

// Shared scratch. The Metal path needs only 8 slots (one per simdgroup) but the
// tree path needs all 256; a single declaration keeps the two branches from
// disagreeing about the allocation.
groupshared float scratch[256];

static const uint WG = 256u;

/// Max of `v` across the whole workgroup. Result valid on every thread.
float block_max(uint tid, float v) {
    float result;
    __target_switch {
    case metal:
    {
        float sg = WaveActiveMax(v);
        if ((tid & 31u) == 0u) { scratch[tid >> 5u] = sg; }
        GroupMemoryBarrierWithGroupSync();
        // 256 threads / 32 lanes = 8 partials; lanes past 8 contribute -inf.
        float lane = (tid < 8u) ? scratch[tid] : -3.402823466e+38f;
        float total = WaveActiveMax(lane);
        if (tid == 0u) { scratch[0] = total; }
        GroupMemoryBarrierWithGroupSync();
        result = scratch[0];
        break;
    }
    default:
    {
        scratch[tid] = v;
        GroupMemoryBarrierWithGroupSync();
        for (uint s = WG / 2u; s > 0u; s >>= 1) {
            if (tid < s) { scratch[tid] = max(scratch[tid], scratch[tid + s]); }
            GroupMemoryBarrierWithGroupSync();
        }
        result = scratch[0];
        break;
    }
    }
    return result;
}

/// Sum of `v` across the whole workgroup. Result valid on every thread.
float block_sum(uint tid, float v) {
    float result;
    __target_switch {
    case metal:
    {
        float sg = WaveActiveSum(v);
        if ((tid & 31u) == 0u) { scratch[tid >> 5u] = sg; }
        GroupMemoryBarrierWithGroupSync();
        float lane = (tid < 8u) ? scratch[tid] : 0.0f;
        float total = WaveActiveSum(lane);
        if (tid == 0u) { scratch[0] = total; }
        GroupMemoryBarrierWithGroupSync();
        result = scratch[0];
        break;
    }
    default:
    {
        scratch[tid] = v;
        GroupMemoryBarrierWithGroupSync();
        for (uint s = WG / 2u; s > 0u; s >>= 1) {
            if (tid < s) { scratch[tid] += scratch[tid + s]; }
            GroupMemoryBarrierWithGroupSync();
        }
        result = scratch[0];
        break;
    }
    }
    return result;
}

[shader("compute")]
[numthreads(256, 1, 1)]
void softmax(uint3 lid : SV_GroupThreadID) {
    uint tid = lid.x;
    uint n = par_buf[0].x;

    // Phase 1: max, for numerical stability.
    float local_max = -3.402823466e+38f;
    for (uint i = tid; i < n; i += WG) {
        local_max = max(local_max, x_buf[i]);
    }
    float max_val = block_max(tid, local_max);

    // `block_sum` reuses `scratch`, so there is a WAR between `block_max`'s
    // `scratch[0]` read and `block_sum`'s later write. Whether that needs a
    // barrier here is target-conditional:
    //
    //   - metal: match the handwritten reference (`shaders/softmax.metal`),
    //     which does this exact phase transition with no barrier here. The same
    //     WAR is present in that shipped kernel; its tight post-reduction read
    //     plus the intervening exp loop make a lapping race practically
    //     impossible, and it has run that way in production. Adding a barrier
    //     here only emits a fifth `threadgroup_barrier` the reference never
    //     pays: at small n the kernel is barrier-latency-bound (a few elements
    //     per thread), so that one extra barrier measured as a ~24% regression
    //     (0.76x at n=1024, closing to ~1.00x by n=16384). Drop it to match.
    //   - default (portable tree path): targets arbitrary GPUs with weaker
    //     scheduling guarantees, so keep the guard.
    __target_switch {
    case metal:
    {
        break;
    }
    default:
    {
        GroupMemoryBarrierWithGroupSync();
        break;
    }
    }

    // Phase 2: exp(x - max), keeping the running sum.
    float partial = 0.0f;
    for (uint i = tid; i < n; i += WG) {
        float e = exp(x_buf[i] - max_val);
        x_buf[i] = e;
        partial += e;
    }
    float inv_sum = 1.0f / block_sum(tid, partial);

    // Phase 3: normalize.
    for (uint i = tid; i < n; i += WG) {
        x_buf[i] = x_buf[i] * inv_sum;
    }
}