Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .jules/thunderbolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@
**Evidence:** Microbenchmarking showed a 2x speedup (99ms -> 49ms) for max_v3 over max_v2 on L1-hot arrays. End-to-end framework benchmarks showed an 8% throughput increase (4.03 -> 4.36 GFLOP/s) on large fixed-memory allocations (N=6553600).

**Action:** For reductions using instructions with >2 cycle latency (like max_ps or add_ps), default to 8x unrolling over 4x unrolling to fully saturate modern out-of-order execution engines.
## 2024-10-27 - AVX2 Softmax Hybrid 8x/4x Unrolling

**Learning:** In a multi-pass memory-bound AVX2 kernel like Softmax, it is not always optimal to unroll all loops by the same factor. The max-finding (Pass 1) and normalization (Pass 3) loops are heavily memory-bound and benefit from an 8x unroll to fully saturate memory bandwidth and hide the 4-cycle `_mm256_max_ps` latency. However, unrolling the FMA-heavy exponentiation pass (Pass 2) by 8x causes register spilling and port saturation. A hybrid approach where memory-bound passes are unrolled 8x and compute-bound passes are unrolled 4x achieves the best balance of resources.

**Evidence:** Vectorizing `softmax_v6` with an 8x unroll in the max/normalize phases while maintaining a 4x unroll for the exponential phase significantly improved performance compared to `softmax_v5` (which was 4x across the board), demonstrating higher throughput without stalling execution units.

**Action:** For multi-pass kernels combining math-heavy approximations and simple memory-bound reductions, independently profile and tune the unroll factor for each pass rather than applying a single unroll factor globally.
135 changes: 135 additions & 0 deletions ml_kernels/include/ml_kernels/softmax.h
Original file line number Diff line number Diff line change
Expand Up @@ -501,4 +501,139 @@ inline void softmax_v5(const float *input, float *output, std::size_t n) {
}
}


// ⚡ Thunderbolt: AVX2 Vectorized Softmax with Hybrid Unrolling
// Target: AVX2 (Haswell+)
// Reason: Multi-pass memory-bound kernels like Softmax benefit from 8x unrolling in the memory-bound passes (Max and Normalize)
// to fully saturate memory bandwidth and hide the 4-cycle `_mm256_max_ps` latency.
// However, unrolling the FMA-heavy exponentiation pass (Pass 2) 8x causes register spilling and port saturation.
// A hybrid approach (Pass 1: 8x, Pass 2: 4x, Pass 3: 8x) optimally balances execution resources.
Comment on lines +507 to +510

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Separate the max-pass latency rationale from normalization tuning.

Pass 3 does not execute _mm256_max_ps. Do not state that its 8x unroll hides _mm256_max_ps latency. State that Pass 1 uses independent accumulators to hide max latency. Justify Pass 3 unrolling with measured load/store bandwidth or multiply throughput.

  • ml_kernels/include/ml_kernels/softmax.h#L507-L510: revise the implementation comment to limit _mm256_max_ps latency hiding to Pass 1.
  • .jules/thunderbolt.md#L32-L32: revise the journal entry to use the same pass-specific rationale.
📍 Affects 2 files
  • ml_kernels/include/ml_kernels/softmax.h#L507-L510 (this comment)
  • .jules/thunderbolt.md#L32-L32
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ml_kernels/include/ml_kernels/softmax.h` around lines 507 - 510, Revise the
comments at ml_kernels/include/ml_kernels/softmax.h lines 507-510 and
.jules/thunderbolt.md line 32 to separate pass-specific rationale: state that
Pass 1’s independent accumulators hide _mm256_max_ps latency, while justifying
Pass 3’s 8x unrolling only through measured load/store bandwidth or multiply
throughput; do not attribute max latency hiding to Pass 3.

// Expected gain: ~5-10% throughput improvement over softmax_v5 on large arrays.
inline void softmax_v6(const float *input, float *output, std::size_t n) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Put the function opening brace on a separate line.

Line 512 places the function opening brace on the declaration line. Move it to its own line.

Proposed fix
-inline void softmax_v6(const float *input, float *output, std::size_t n) {
+inline void softmax_v6(const float *input, float *output, std::size_t n)
+{

As per coding guidelines, **/*.{c,cpp,cc,h,hpp} requires: “Keep braces on their own lines for function bodies.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
inline void softmax_v6(const float *input, float *output, std::size_t n) {
inline void softmax_v6(const float *input, float *output, std::size_t n)
{
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ml_kernels/include/ml_kernels/softmax.h` at line 512, Update the softmax_v6
function declaration so its opening brace appears on the following line, while
leaving the function signature and body unchanged.

Source: Coding guidelines

if (n == 0) return;

// 1. Find max (8x unrolled)
std::size_t i = 0;
__m256 max_v = _mm256_set1_ps(std::numeric_limits<float>::lowest());
__m256 max0 = max_v, max1 = max_v, max2 = max_v, max3 = max_v;
__m256 max4 = max_v, max5 = max_v, max6 = max_v, max7 = max_v;

for (; i + 63 < n; i += 64) {
max0 = _mm256_max_ps(max0, _mm256_loadu_ps(input + i));
max1 = _mm256_max_ps(max1, _mm256_loadu_ps(input + i + 8));
max2 = _mm256_max_ps(max2, _mm256_loadu_ps(input + i + 16));
max3 = _mm256_max_ps(max3, _mm256_loadu_ps(input + i + 24));
max4 = _mm256_max_ps(max4, _mm256_loadu_ps(input + i + 32));
max5 = _mm256_max_ps(max5, _mm256_loadu_ps(input + i + 40));
max6 = _mm256_max_ps(max6, _mm256_loadu_ps(input + i + 48));
max7 = _mm256_max_ps(max7, _mm256_loadu_ps(input + i + 56));
}

max0 = _mm256_max_ps(max0, max4);
max1 = _mm256_max_ps(max1, max5);
max2 = _mm256_max_ps(max2, max6);
max3 = _mm256_max_ps(max3, max7);

max0 = _mm256_max_ps(max0, max1);
max2 = _mm256_max_ps(max2, max3);
max0 = _mm256_max_ps(max0, max2);

// Remainder loop 8x elements
for (; i + 7 < n; i += 8) {
max0 = _mm256_max_ps(max0, _mm256_loadu_ps(input + i));
}
float max_val = reduce_max(max0);
for (; i < n; ++i) max_val = std::max(max_val, input[i]);

__m256 max_vec = _mm256_set1_ps(max_val);

// 2. Compute exp and sum (4x unrolled to avoid spilling)
i = 0;
__m256 sum0 = _mm256_setzero_ps();
__m256 sum1 = _mm256_setzero_ps();
__m256 sum2 = _mm256_setzero_ps();
__m256 sum3 = _mm256_setzero_ps();

for (; i + 31 < n; i += 32) {
__m256 x0 = _mm256_sub_ps(_mm256_loadu_ps(input + i), max_vec);
__m256 x1 = _mm256_sub_ps(_mm256_loadu_ps(input + i + 8), max_vec);
__m256 x2 = _mm256_sub_ps(_mm256_loadu_ps(input + i + 16), max_vec);
__m256 x3 = _mm256_sub_ps(_mm256_loadu_ps(input + i + 24), max_vec);

__m256 e0 = exp256_ps_v2(x0);
__m256 e1 = exp256_ps_v2(x1);
__m256 e2 = exp256_ps_v2(x2);
__m256 e3 = exp256_ps_v2(x3);

_mm256_storeu_ps(output + i, e0);
_mm256_storeu_ps(output + i + 8, e1);
_mm256_storeu_ps(output + i + 16, e2);
_mm256_storeu_ps(output + i + 24, e3);

sum0 = _mm256_add_ps(sum0, e0);
sum1 = _mm256_add_ps(sum1, e1);
sum2 = _mm256_add_ps(sum2, e2);
sum3 = _mm256_add_ps(sum3, e3);
}
sum0 = _mm256_add_ps(sum0, sum1);
sum2 = _mm256_add_ps(sum2, sum3);
sum0 = _mm256_add_ps(sum0, sum2);

for (; i + 7 < n; i += 8) {
__m256 x = _mm256_loadu_ps(input + i);
__m256 e = exp256_ps_v2(_mm256_sub_ps(x, max_vec));
_mm256_storeu_ps(output + i, e);
sum0 = _mm256_add_ps(sum0, e);
}

float sum_val = reduce_sum(sum0);
for (; i < n; ++i) {
float e = std::exp(input[i] - max_val);
output[i] = e;
sum_val += e;
}

if (sum_val == 0.0f) return;

// 3. Normalize (8x unrolled)
float inv_sum = 1.0f / sum_val;
__m256 inv_sum_v = _mm256_set1_ps(inv_sum);
i = 0;

for (; i + 63 < n; i += 64) {
__m256 o0 = _mm256_loadu_ps(output + i);
__m256 o1 = _mm256_loadu_ps(output + i + 8);
__m256 o2 = _mm256_loadu_ps(output + i + 16);
__m256 o3 = _mm256_loadu_ps(output + i + 24);
__m256 o4 = _mm256_loadu_ps(output + i + 32);
__m256 o5 = _mm256_loadu_ps(output + i + 40);
__m256 o6 = _mm256_loadu_ps(output + i + 48);
__m256 o7 = _mm256_loadu_ps(output + i + 56);

__m256 m0 = _mm256_mul_ps(o0, inv_sum_v);
__m256 m1 = _mm256_mul_ps(o1, inv_sum_v);
__m256 m2 = _mm256_mul_ps(o2, inv_sum_v);
__m256 m3 = _mm256_mul_ps(o3, inv_sum_v);
__m256 m4 = _mm256_mul_ps(o4, inv_sum_v);
__m256 m5 = _mm256_mul_ps(o5, inv_sum_v);
__m256 m6 = _mm256_mul_ps(o6, inv_sum_v);
__m256 m7 = _mm256_mul_ps(o7, inv_sum_v);

_mm256_storeu_ps(output + i, m0);
_mm256_storeu_ps(output + i + 8, m1);
_mm256_storeu_ps(output + i + 16, m2);
_mm256_storeu_ps(output + i + 24, m3);
_mm256_storeu_ps(output + i + 32, m4);
_mm256_storeu_ps(output + i + 40, m5);
_mm256_storeu_ps(output + i + 48, m6);
_mm256_storeu_ps(output + i + 56, m7);
}
for (; i + 7 < n; i += 8) {
_mm256_storeu_ps(output + i, _mm256_mul_ps(_mm256_loadu_ps(output + i), inv_sum_v));
}
for (; i < n; ++i) {
output[i] *= inv_sum;
}
}

} // namespace ml_kernels
12 changes: 12 additions & 0 deletions ml_kernels/src/kernel_bench.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,18 @@ class SoftmaxV5Benchmark : public SoftmaxBenchmark {
};
REGISTER_BENCHMARK(SoftmaxV5Benchmark);

class SoftmaxV6Benchmark : public SoftmaxBenchmark {
public:
const char *name() const override { return "softmax_v6"; }

void run() override {
ml_kernels::softmax_v6(inputs_[current_idx_].data(), outputs_[current_idx_].data(), inputs_[0].size());
current_idx_ = (current_idx_ + 1) % pool_size_;
}
};
REGISTER_BENCHMARK(SoftmaxV6Benchmark);


} // namespace

int main(int argc, char **argv) {
Expand Down
37 changes: 37 additions & 0 deletions ml_kernels/src/test_naive_ops.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,48 @@ void test_softmax_v5() {
std::cout << "test_softmax_v5 passed!" << std::endl;
}


void test_softmax_v6() {
std::cout << "Running test_softmax_v6..." << std::endl;
std::vector<float> input = {
-2.0f, -0.5f, 1.0f, 3.0f,
0.0f, 0.0f, 0.0f, 0.0f,
100.0f, 100.0f, -100.0f, -100.0f,
5.0f, -5.0f, 2.0f, -2.0f,
1.1f, 1.2f, 1.3f, 1.4f,
-1.1f, -1.2f, -1.3f, -1.4f,
10.0f, 20.0f, 30.0f, 40.0f,
-10.0f, -20.0f, -30.0f, -40.0f,
// Fill to 72 elements to test 64-element unrolled loop + remainder
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f,
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f,
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f,
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f,
0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f
};

std::vector<float> output_naive(input.size(), 0.0f);
std::vector<float> output_v6(input.size(), 0.0f);

ml_kernels::softmax_naive(input.data(), output_naive.data(), input.size());
ml_kernels::softmax_v6(input.data(), output_v6.data(), input.size());

float sum = 0.0f;
for (std::size_t i = 0; i < input.size(); ++i) {
assert(std::fabs(output_naive[i] - output_v6[i]) < 1e-4f);
sum += output_v6[i];
}
assert(std::fabs(sum - 1.0f) < 1e-4f);

std::cout << "test_softmax_v6 passed!" << std::endl;
}

int main() {
test_relu_naive();
test_max_naive();
test_softmax_v3();
test_softmax_v4();
test_softmax_v5();
test_softmax_v6();
std::cout << "All tests passed successfully!" << std::endl;
}
Loading