Skip to content

perf(hip): adaptive 2D-grid row-split for act_and_mul kernels#265

Merged
demandal25 merged 2 commits into
amd-integrationfrom
perf-silu-and-mul-adaptive-grid
Jun 24, 2026
Merged

perf(hip): adaptive 2D-grid row-split for act_and_mul kernels#265
demandal25 merged 2 commits into
amd-integrationfrom
perf-silu-and-mul-adaptive-grid

Conversation

@demandal25

Copy link
Copy Markdown
Collaborator

Summary

The silu_and_mul / gelu / gelu_tanh activation kernels launched one block per token (gridDim = num_tokens). For decode and small-batch serving — few tokens, large hidden dim — only num_tokens blocks launch on a 304-CU MI300X, so the GPU sits mostly idle and most of HBM bandwidth (the bottleneck for this memory-bound kernel) goes unused. This PR makes the launch adaptive: it splits each row across multiple blocks to fill the CU array when num_tokens is small, while leaving the already-saturated prefill path unchanged.

What changed

Kernel

  • include/flashinfer/attention/generic/activation.cuh — generalized act_and_mul_kernel to a 2D grid: blockIdx.x selects the token (row), blockIdx.y selects a column-tile of that row. Output elements are independent (no cross-element reduction), so a row can be split across gridDim.y blocks with no atomics. When gridDim.y == 1 the kernel collapses to one block per token, byte-for-byte identical to the original — so prefill and the unchanged CUDA 1D-launch path are unaffected. The intra-row column index stays 32-bit (row-base addresses are 64-bit) to keep the inner-loop address math identical to the original hot loop.

Launch heuristic

  • include/flashinfer/attention/generic/activation.cuh — added a single shared act_and_mul_launch_dims helper that picks blocks_per_row to oversubscribe the CU count by 2x, but only when num_tokens is below that threshold (so prefill stays at blocks_per_row == 1). It is called from both the AOT launcher and the JIT template, so the two paths cannot drift. Also fixes a latent blockDim == 0 launch crash for d < vec_size that the AOT path had (the JIT path already guarded it).
  • include/gpu_iface/gpu_runtime_compat.hpp — added getMultiProcessorCount, a cached cross-platform CU/SM-count query (the heuristic needs it on every launch; the cache avoids a per-launch driver call).
  • flashinfer/csrc_rocm/activation.cu — all three AOT launchers now call the shared helper.
  • flashinfer/jit/activation.py — the HIP JIT template (the runtime path, since auto resolves to native) calls the shared helper.

Tests / benchmark

  • tests/rocm_tests/test_activation_hip.py — added two shapes: (2, 14336) exercises blocks_per_row > 1 (row split across blocks) and (4096, 4096) locks the prefill blocks_per_row == 1 path.
  • benchmarks/rocm_benchmarks/bench_silu_and_mul.py — new benchmark sweeping num_tokens x d x dtype through the existing RocmProfiler harness.

Design notes

The oversubscription factor only needs to cross ~1x CU coverage; this kernel is bandwidth-bound, so once enough blocks are resident to saturate the HBM controllers, adding more concurrent blocks cannot move data faster. A sweep of the factor over {1, 2, 4, 8, 16, 32} at d=14336 was flat within noise, so 2x is chosen as the minimal value that reliably crosses the fill threshold while avoiding pointless extra blocks (and tail overhead) once num_tokens alone fills the GPU. CU count is a runtime device property (varies by SKU), so it is queried at runtime rather than templated.

Benchmark results

MI300X (gfx942), median kernel time, optimized vs. baseline (silu, both dtypes), measured back-to-back through bench_silu_and_mul.py:

shape (num_tokens x d) dtype baseline (us) optimized (us) speedup
1 x 14336 f16 7.5 6.3 1.19x
8 x 14336 f16 7.8 6.3 1.24x
32 x 14336 f16 8.2 6.3 1.29x
64 x 14336 f16 8.5 6.5 1.31x
128 x 14336 f16 9.9 8.5 1.16x
256 x 14336 f16 12.1 10.4 1.16x
32 x 14336 bf16 8.6 6.2 1.39x
64 x 14336 bf16 9.0 6.5 1.38x
4096 x 4096 f16 37.4 36.3 ~1.0x (no regression)
4096 x 14336 f16 111.3 108.3 ~1.0x (no regression)

Largest wins land at large d with small/medium num_tokens (the decode / small-batch regime). Shapes that are launch-overhead-bound (small d) or already GPU-saturated (prefill) are flat, as expected.

Test plan

  • pytest tests/rocm_tests/test_activation_hip.py — 73 passed (includes the two new shapes x 3 activations x 2 dtypes)
  • pytest tests/rocm_tests/test_activation_aiter_hip.py — passed (AITER backend unaffected)
  • pytest tests/utils/test_activation.py — 1890 passed (broader dim/batch sweep)
  • python benchmarks/rocm_benchmarks/bench_silu_and_mul.py --timing-only — before/after comparison above
  • pre-commit run -a (clang-format / ruff / mypy on the changed files)

Copilot AI review requested due to automatic review settings June 24, 2026 14:02
silu_and_mul / gelu / gelu_tanh launched one block per token, which
underfills the GPU for small num_tokens (decode / small-batch serving)
with large hidden dim, leaving HBM bandwidth idle. Output elements are
independent, so split each row across blocks on gridDim.y (no atomics):
blockIdx.x = token, blockIdx.y = column-tile. blocks_per_row is chosen
host-side to oversubscribe the CU count by 2x only when num_tokens is
below that threshold, so prefill (gridDim.y == 1) is byte-for-byte
unchanged and the CUDA 1D-launch path is unaffected.

Measured 1.16-1.39x on MI300X for large-d, small/medium-token shapes;
flat elsewhere. The oversubscription factor past ~1x CU coverage is
bandwidth-neutral (swept 1-32), so 2x is the minimal safe choice.

The launch heuristic lives in one shared inline helper in activation.cuh
called by both the AOT launcher and the JIT template (no drift), and CU
count is queried via a new cached getMultiProcessorCount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@demandal25 demandal25 force-pushed the perf-silu-and-mul-adaptive-grid branch from 20e244a to 12cf818 Compare June 24, 2026 14:08

Copilot AI 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.

Pull request overview

This PR improves ROCm performance for silu_and_mul / gelu / gelu_tanh activation-and-multiply kernels in small-num_tokens, large-d (decode/small-batch) regimes by switching the kernel to a 2D grid and adding a shared launch heuristic that splits each row across multiple blocks to better fill the CU array.

Changes:

  • Generalize act_and_mul_kernel to support a 2D grid (blockIdx.y column-tiling) and add a shared act_and_mul_launch_dims heuristic.
  • Add a cached SM/CU count query (getMultiProcessorCount) used by the heuristic, and route ROCm AOT + HIP JIT launchers through the shared helper.
  • Extend ROCm activation tests with shapes that exercise both row-split and prefill-scale paths; add a new ROCm benchmark script.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
include/flashinfer/attention/generic/activation.cuh Implements 2D-grid kernel + adaptive launch-dims helper for ROCm activation kernels.
include/gpu_iface/gpu_runtime_compat.hpp Adds cached multiprocessor-count query used by the new launch heuristic.
flashinfer/csrc_rocm/activation.cu Updates ROCm AOT launchers to use the shared adaptive launch helper.
flashinfer/jit/activation.py Updates HIP JIT template launcher to call the shared adaptive launch helper.
tests/rocm_tests/test_activation_hip.py Adds test shapes to cover row-split and no-split (prefill-scale) behavior.
benchmarks/rocm_benchmarks/bench_silu_and_mul.py Adds a benchmark sweep to measure decode vs. prefill regimes for silu_and_mul.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread include/gpu_iface/gpu_runtime_compat.hpp
Comment thread include/flashinfer/attention/generic/activation.cuh Outdated
Comment thread flashinfer/csrc_rocm/activation.cu
Comment thread flashinfer/csrc_rocm/activation.cu
Comment thread flashinfer/csrc_rocm/activation.cu
Comment thread flashinfer/jit/activation.py
- getMultiProcessorCount: make the per-device cache thread_local so
  concurrent callers (multi-threaded Python) cannot race on it.
- silu/gelu/gelu_tanh AOT launchers + HIP JIT template: early-return on
  num_tokens == 0 so empty tensors are a no-op instead of a 0-sized
  (invalid) grid launch.
- Reword the kernel's "byte-for-byte identical" comment to "same
  memory-access pattern" (codegen differs; behavior does not).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 24, 2026 14:15
@demandal25 demandal25 merged commit fa4c604 into amd-integration Jun 24, 2026
2 checks passed
@demandal25 demandal25 deleted the perf-silu-and-mul-adaptive-grid branch June 24, 2026 14:17

Copilot AI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Comment on lines 35 to +38
void silu_and_mul(at::Tensor& out, at::Tensor& input, bool enable_pdl) {
int d = input.size(-1) / 2;
int64_t num_tokens = input.numel() / input.size(-1);
if (num_tokens == 0) return; // empty input → no-op (a 0-sized grid is an invalid launch)
Comment on lines 60 to +63
void gelu_tanh_and_mul(at::Tensor& out, at::Tensor& input, bool enable_pdl) {
int d = input.size(-1) / 2;
int64_t num_tokens = input.numel() / input.size(-1);
if (num_tokens == 0) return; // empty input → no-op (a 0-sized grid is an invalid launch)
Comment on lines 84 to 88
void gelu_and_mul(at::Tensor& out, at::Tensor& input, bool enable_pdl) {
int d = input.size(-1) / 2;
int64_t num_tokens = input.numel() / input.size(-1);
if (num_tokens == 0) return; // empty input → no-op (a 0-sized grid is an invalid launch)
const c10::hip::OptionalHIPGuardMasqueradingAsCUDA device_guard(out.device());
Comment on lines 28 to 32
void {{ func_name }}(at::Tensor& out, at::Tensor& input, bool enable_pdl) {
int d = input.size(-1) / 2;
int64_t num_tokens = input.numel() / input.size(-1);
if (num_tokens == 0) return; // empty input → no-op (a 0-sized grid is an invalid launch)

Comment on lines +84 to +87
// Scalar remainder over [num_vec*vec_size, d), column-tiled the same way.
// Always empty for the fp16/bf16 dispatch (16-byte alignment forces d % vec_size
// == 0); kept defensive. Do NOT key this off d % (blockDim.x * vec_size) — that
// assumes blockDim.x is the global stride, which is false under column-tiling.
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.

2 participants