Skip to content

⚡ Thunderbolt: Softmax — AVX2 8x unroll with single-FMA exp256#67

Open
bugparty wants to merge 1 commit into
mainfrom
thunderbolt/softmax-avx2-8x-unroll-13459054292008055690
Open

⚡ Thunderbolt: Softmax — AVX2 8x unroll with single-FMA exp256#67
bugparty wants to merge 1 commit into
mainfrom
thunderbolt/softmax-avx2-8x-unroll-13459054292008055690

Conversation

@bugparty

@bugparty bugparty commented Jun 30, 2026

Copy link
Copy Markdown
Owner

💡 What: Added softmax_v6 to ml_kernels using AVX2. This implementation simplifies the exp256 approximation by using a single FMA for r calculation, and unrolls all phases (max, exp+sum, scaling) 8x.
🎯 Why: To improve throughput over softmax_v5 by reducing instruction count and register pressure during the exponential calculation, allowing deeper unrolling to better saturate execution ports.
🏗️ How: Replaced the two-part subtraction of ln(2) in the range reduction step of exp256 with a single FMA operation using a combined constant. Applied 8x unrolling to the entire Softmax kernel.
📊 Impact:

  • N=16384 (Fixed Memory): 6.20 GFLOP/s vs softmax_v5 at 5.55 GFLOP/s (~12% gain)
  • N=1048576 (Fixed Memory): 4.58 GFLOP/s vs softmax_v5 at 4.06 GFLOP/s (~13% gain)
    🖥️ Tested on: GCC 13.3.0, Ubuntu Linux container.
    🔬 How to reproduce:
DISABLE_CPU_BINDING=1 ./build/ml_kernels/ml_kernel_bench --filter softmax_v6
DISABLE_CPU_BINDING=1 ./build/ml_kernels/ml_kernel_bench --filter softmax_v5

PR created automatically by Jules for task 13459054292008055690 started by @bugparty

Summary by CodeRabbit

  • New Features

    • Added a new, faster softmax option for AVX2-capable systems with wider unrolling and improved vector math.
    • Added benchmark coverage for the new softmax variant.
  • Bug Fixes

    • Improved numerical handling so outputs remain accurate and continue to sum to approximately 1.
    • Added validation for edge cases with non-aligned input sizes.
  • Tests

    • Expanded automated tests to compare the new softmax output against the reference implementation.

Co-authored-by: bugparty <1510776+bugparty@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new AVX2 softmax kernel variant (softmax_v6) using an 8-way unrolled exp256_ps_v3 exponential approximation with a single-FMA reduction step. Registers a corresponding benchmark, adds a correctness unit test against the naive implementation, and documents the optimization in a changelog file.

Changes

Softmax v6 AVX2 kernel

Layer / File(s) Summary
exp256_ps_v3 and softmax_v6 implementation
ml_kernels/include/ml_kernels/softmax.h
Adds exp256_ps_v3, a single-FMA AVX2 exp approximation, and softmax_v6, an 8-way unrolled AVX2 softmax using vectorized max/exp/sum/normalize phases with scalar tail handling.
Benchmark wiring and correctness test
ml_kernels/src/kernel_bench.cpp, ml_kernels/src/test_naive_ops.cpp, .jules/thunderbolt.md
Registers SoftmaxV6Benchmark for "softmax_v6", adds test_softmax_v6 comparing against softmax_naive (including a remainder case) and wires it into main(), and documents the optimization with throughput/correctness notes.

Sequence Diagram(s)

sequenceDiagram
  participant Test as test_softmax_v6
  participant V6 as softmax_v6
  participant Exp as exp256_ps_v3
  participant Naive as softmax_naive
  Test->>V6: run on input vector
  V6->>Exp: compute exponentials (8x unrolled)
  Exp-->>V6: exp values
  V6-->>Test: output probabilities
  Test->>Naive: run on same input
  Naive-->>Test: reference output
  Test->>Test: compare outputs, check sum ≈ 1
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

Eight lanes wide, I hop and FMA,
One log-two constant, no delay~
Exp curves bend with single fuse,
Softmax sums I cannot lose 🐇
Benchmarked fast, tested true,
Carrots compiled — review, please do!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: an AVX2 softmax kernel with 8x unrolling and a single-FMA exp256 range reduction.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch thunderbolt/softmax-avx2-8x-unroll-13459054292008055690

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
ml_kernels/src/test_naive_ops.cpp (1)

184-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move these function-body braces onto their own lines.

The changed function definitions still keep { on the signature line. As per coding guidelines, "Keep braces on their own lines for function bodies."

Also applies to: 222-228

🤖 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/src/test_naive_ops.cpp` around lines 184 - 220, The function-body
brace style in test_softmax_v6 (and the related test functions noted in the
comment) still places the opening brace on the signature line, which violates
the project’s brace convention. Update the affected function definitions so the
opening and closing braces are on their own lines, matching the existing style
used elsewhere in ml_kernels/src/test_naive_ops.cpp, and keep the function
bodies unchanged.

Source: Coding guidelines

ml_kernels/src/kernel_bench.cpp (1)

337-342: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reformat these function bodies to match the C/C++ style guide.

The opening braces for name() and run() are still on the signature line. As per coding guidelines, "Keep braces on their own lines for function bodies."

🤖 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/src/kernel_bench.cpp` around lines 337 - 342, The function bodies
in the benchmark kernel class are not following the C/C++ brace style guide
because the opening braces for name() and run() are on the same lines as their
signatures. Reformat these overrides in kernel_bench.cpp so the braces for the
softmax_v6 benchmark’s name() and run() methods are placed on their own lines,
matching the project’s function-body style used elsewhere in the class.

Source: Coding guidelines

ml_kernels/include/ml_kernels/softmax.h (1)

504-531: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the new function-body braces onto their own lines.

Lines 504 and 537 keep the opening brace on the signature line, which does not match the repo's C/C++ style rule. As per coding guidelines, "Keep braces on their own lines for function bodies."

Also applies to: 537-627

🤖 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 504 - 531, The new
function bodies in softmax.h do not follow the repo’s brace style, since the
opening brace is kept on the signature line for exp256_ps_v3 and the other
affected functions. Move the function-body opening braces onto their own lines
for exp256_ps_v3 and the related functions in the same block, keeping the
existing function names and structure unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@ml_kernels/src/kernel_bench.cpp`:
- Around line 335-344: SoftmaxV6Benchmark still inherits SoftmaxBenchmark’s
stricter verify() behavior, so it can fail even though softmax_v6 now only
guarantees the looser 1e-4f contract. Update SoftmaxV6Benchmark (or its verify
override in kernel_bench.cpp) to match the new kernel accuracy tolerance used by
softmax_v6, keeping the change scoped to the benchmark class and its
verification logic.

In `@ml_kernels/src/test_naive_ops.cpp`:
- Around line 184-220: The test_softmax_v6() input currently leaves an 8-element
tail, so softmax_v6 only exercises the vectorized remainder path and never the
scalar cleanup loop. Adjust the test data in test_softmax_v6 so the total input
size leaves a remainder smaller than the vector-tail width after the unrolled
body, ensuring the final scalar for (; i < n; ++i) path in softmax_v6 is
actually hit while still comparing against softmax_naive.

---

Nitpick comments:
In `@ml_kernels/include/ml_kernels/softmax.h`:
- Around line 504-531: The new function bodies in softmax.h do not follow the
repo’s brace style, since the opening brace is kept on the signature line for
exp256_ps_v3 and the other affected functions. Move the function-body opening
braces onto their own lines for exp256_ps_v3 and the related functions in the
same block, keeping the existing function names and structure unchanged.

In `@ml_kernels/src/kernel_bench.cpp`:
- Around line 337-342: The function bodies in the benchmark kernel class are not
following the C/C++ brace style guide because the opening braces for name() and
run() are on the same lines as their signatures. Reformat these overrides in
kernel_bench.cpp so the braces for the softmax_v6 benchmark’s name() and run()
methods are placed on their own lines, matching the project’s function-body
style used elsewhere in the class.

In `@ml_kernels/src/test_naive_ops.cpp`:
- Around line 184-220: The function-body brace style in test_softmax_v6 (and the
related test functions noted in the comment) still places the opening brace on
the signature line, which violates the project’s brace convention. Update the
affected function definitions so the opening and closing braces are on their own
lines, matching the existing style used elsewhere in
ml_kernels/src/test_naive_ops.cpp, and keep the function bodies unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3f922d4-e064-404b-9e30-0371e7e886af

📥 Commits

Reviewing files that changed from the base of the PR and between acca01e and 0186f18.

📒 Files selected for processing (4)
  • .jules/thunderbolt.md
  • ml_kernels/include/ml_kernels/softmax.h
  • ml_kernels/src/kernel_bench.cpp
  • ml_kernels/src/test_naive_ops.cpp

Comment on lines +335 to +344
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Relax softmax_v6's benchmark verifier to the new kernel contract.

Because Line 335 subclasses SoftmaxBenchmark unchanged, this benchmark still inherits the 1e-5f verify() threshold, while the new kernel/test/docs only claim 1e-4f accuracy. That can make the benchmark report a false failure for a kernel that is behaving as designed.

Proposed fix
 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_;
     }
+
+    bool verify() override {
+        current_idx_ = 0;
+        run();
+        constexpr float tol = 1e-4f;
+        for (std::size_t i = 0; i < outputs_[0].size(); ++i) {
+            if (std::fabs(outputs_[0][i] - output_ref_[i]) > tol) {
+                return false;
+            }
+        }
+        return true;
+    }
 };
📝 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
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);
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_;
}
bool verify() override {
current_idx_ = 0;
run();
constexpr float tol = 1e-4f;
for (std::size_t i = 0; i < outputs_[0].size(); ++i) {
if (std::fabs(outputs_[0][i] - output_ref_[i]) > tol) {
return false;
}
}
return true;
}
};
REGISTER_BENCHMARK(SoftmaxV6Benchmark);
🤖 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/src/kernel_bench.cpp` around lines 335 - 344, SoftmaxV6Benchmark
still inherits SoftmaxBenchmark’s stricter verify() behavior, so it can fail
even though softmax_v6 now only guarantees the looser 1e-4f contract. Update
SoftmaxV6Benchmark (or its verify override in kernel_bench.cpp) to match the new
kernel accuracy tolerance used by softmax_v6, keeping the change scoped to the
benchmark class and its verification logic.

Comment on lines +184 to +220
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,
-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,
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f // To test remainder
};

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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This test never reaches softmax_v6's scalar remainder loop.

Line 203 adds exactly 8 trailing values, so after the 64-element unrolled body the i + 7 < n vector-tail loop consumes the remainder too. The final scalar for (; i < n; ++i) path is still untested even though the comment says otherwise.

Proposed fix
-        1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f // To test remainder
+        1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f // Forces a scalar tail after the 8-lane remainder loop
📝 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
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,
-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,
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f // To test remainder
};
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;
}
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f // Forces a scalar tail after the 8-lane remainder loop
🤖 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/src/test_naive_ops.cpp` around lines 184 - 220, The
test_softmax_v6() input currently leaves an 8-element tail, so softmax_v6 only
exercises the vectorized remainder path and never the scalar cleanup loop.
Adjust the test data in test_softmax_v6 so the total input size leaves a
remainder smaller than the vector-tail width after the unrolled body, ensuring
the final scalar for (; i < n; ++i) path in softmax_v6 is actually hit while
still comparing against softmax_naive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant