Skip to content

Fix out-of-bounds row access in inference layer_norm/rms_norm kernels for hidden sizes <= 128 - #8830

Open
jayden1711 wants to merge 2 commits into
deepspeedai:masterfrom
jayden1711:fix/inference-norm-row-guard
Open

jayden1711 wants to merge 2 commits into
deepspeedai:masterfrom
jayden1711:fix/inference-norm-row-guard

Conversation

@jayden1711

Copy link
Copy Markdown

Fixes #8828

Summary

For hidden sizes <= 128 the inference norm launchers pack 256 / threadsPerGroup rows into one thread block, but the kernels had no row bound. When rows is larger than that and not a multiple of it, the last block read its inputs and wrote its outputs for rows past the end of the tensors (e.g. fp32 hidden 64 or fp16/bf16 hidden 128 with 17 or 33 rows; see #8828).

This PR passes rows to fused_ln, fused_residual_ln (used by both residual launchers) and rms_norm / pre_rms_norm, computes the row index from the same expression the kernels already use for block_offset, and masks the global loads and stores of rows past the end. Those threads still take part in the reductions (masked loads return zeros), so the warp/block reductions are unchanged; there is no early return. Launch shapes (grid/block) are unchanged, and the launch macros just pass rows through.

Why this is not a duplicate

Tests

Hardware: 1x NVIDIA GeForce RTX 5080 Laptop GPU (sm_120), driver 595.99.02, CUDA 12.8, torch 2.11.0+cu128, Python 3.12.3. transformer_inference JIT-built separately for master and this branch (TORCH_CUDA_ARCH_LIST=12.0, MAX_JOBS=4).

Test changes (tests/unit/ops/transformer/inference/):

  • test_layer_norm.py, test_rms_norm.py: seq_len [1, 128] -> [1, 17, 33, 128] and channels gains 32, 64 in all five parametrizations, so fp32/fp16/bf16 at 4-, 8- and 16-thread sizes run with partially filled blocks.
  • test_layer_norm.py: new test_layer_norm_residual_cuda calling the CUDA residual bindings directly (_layer_norm_residual, layer_norm_residual_store_pre_ln_res) for rows 17/33 at fp32 32/64, fp16 64/128 and bf16 128, since LayerNormOp.layer_norm_residual* are PyTorch implementations.

Commands (from tests/): pytest -m inference_ops unit/ops/transformer/inference/test_layer_norm.py and ... test_rms_norm.py.

master (5f266e2) this PR
test_layer_norm.py 8 failed, 1096 passed 1104 passed
test_rms_norm.py 360 passed 360 passed

The 8 failures on master are test_layer_norm_residual_cuda[store_pre_ln_res=True] at exactly the partially filled shapes (fp32 32 / rows 33, fp32 64 / rows 17 and 33, fp16 64 / rows 33, fp16 and bf16 128 / rows 17 and 33): the overrun of the normalized output lands in the adjacent res_output. The other new cases (including all of test_rms_norm.py) pass on master too even though they overrun, because the stray accesses happen to hit unused memory; the memcheck table below is what shows the overrun.

memcheck (PYTORCH_NO_CUDA_MEMORY_CACHING=1 compute-sanitizer --tool memcheck --padding 65536, one process per case, direct calls to the five bindings; number = invalid accesses reported, 0 = clean; rows 9/13/17/33):

Counts are for the first faulting launch in each process (the CUDA context is lost afterwards). For layer_norm, _layer_norm_residual and rms_norm the count is 240/248; for layer_norm_residual_store_pre_ln_res and pre_rms_norm it is 238/246.

config (threads per row, rows per block) master: rows 9 / 13 / 17 / 33 this PR
fp32 H=32 (8, 32) 0 / 0 / 0 / 248 or 246 (all 5 bindings) 0 everywhere
fp32 H=64 (16, 16) 0 / 0 / 240 or 238 / 240 or 238 0 everywhere
fp16 H=64 (8, 32) 0 / 0 / 0 / 248 or 246 0 everywhere
fp16 H=128 (16, 16) 0 / 0 / 240 or 238 / 240 or 238 0 everywhere
bf16 H=128 (16, 16) 0 / 0 / 240 or 238 / 240 or 238 0 everywhere
fp32 H=96, 128 (32-thread case) 0 (no kernel is launched on master, #8826) 0 (still no kernel without #8827)
fp16 H=256 (> 128 path, 1 row per block; control) 0 0

Speed (CUDA events, 50 warm-up calls, then 200 calls captured in one CUDA graph and replayed 30 times; median per-call GPU time, mean of two passes alternating master/this branch, fresh process per configuration):

op dtype, H rows master (us) this PR (us) change
layer_norm fp16, 128 17 / 1024 / 4096 1.052 / 1.238 / 1.618 1.012 / 1.276 / 1.611 -3.8% / +3.1% / -0.4%
layer_norm + residual fp16, 128 17 / 1024 / 4096 1.140 / 1.389 / 1.905 1.104 / 1.379 / 1.890 -3.2% / -0.7% / -0.8%
rms_norm fp16, 128 17 / 1024 / 4096 0.951 / 1.063 / 1.494 0.901 / 1.085 / 1.491 -5.3% / +2.1% / -0.2%
layer_norm fp32, 64 17 / 1024 / 4096 1.002 / 1.115 / 1.555 0.956 / 1.152 / 1.554 -4.5% / +3.3% / -0.0%
layer_norm + residual fp32, 64 17 / 1024 / 4096 0.976 / 1.329 / 1.901 0.981 / 1.263 / 1.893 +0.5% / -4.9% / -0.4%
rms_norm fp32, 64 17 / 1024 / 4096 0.931 / 1.023 / 1.526 0.889 / 1.063 / 1.524 -4.5% / +3.9% / -0.1%

The two passes agreed to within 1% for every entry. The differences are within about +/-5% (at most ~0.04 us per call). At 1024 rows layer_norm and rms_norm are 2-4% slower in both passes, at 4096 rows the two are equal, and at 17 rows this branch is 3-5% faster. Called from Python (eager, including the binding and output allocation) every configuration takes 2.5-2.7 us on both branches, so the kernel-time differences are not visible at that level.

Compatibility matrix: only the inference (v1, kernel-injection) layer_norm/rms_norm CUDA kernels change. Validated: fp32/fp16/bf16 via the unit tests above and memcheck, on sm_120. Not applicable: ZeRO stages, offload, training. Not validated: other GPUs/architectures, ROCm, end-to-end model inference.

Formatting: pre-commit run --files <changed files> (yapf reformatted the new test once; all hooks pass afterwards, clang-format 23.1.1).

AI assistance was used to prepare this change (code, tests and this description). I reviewed every changed line and ran the tests above.

(Reminder for the committer: DeepSpeed requires DCO sign-off, git commit -s.)

… for hidden sizes <= 128

Fixes deepspeedai#8828

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T15:46:21.899456Z 31b0247 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31b0247e75

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const int block_offset = (tb.group_index().x * (maxThreads / threadsPerGroup) * elems_per_row) +
(tb.thread_index().y * elems_per_row);
// Rows past the end only take part in the reductions; they skip all global loads/stores.
const int row = tb.group_index().x * (maxThreads / threadsPerGroup) + tb.thread_index().y;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard the inference-v2 sibling kernels too

Under inference-v2, elems_per_row <= 128 with more than one packed block and a non-multiple row count (for example fp16 H=128 with 17 rows) still corrupts memory: I checked deepspeed/inference/v2/kernels/core_ops/cuda_layer_norm/layer_norm_cuda.cu and deepspeed/inference/v2/kernels/core_ops/cuda_rms_norm/rms_norm_cuda.cu, and their identical launchers create 16 groups in the final block but their kernels receive no rows value or guard, so row indices 17–31 load and store past the tensors. This is reachable through public CUDARMSNorm, including EXAONE4's per-head Q/K normalization in model.py:101-130; apply this guard to both sibling implementations and add v2 coverage rather than leaving the same production OOB in place.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed: the inference-v2 core-op kernels have the same unguarded sub-block launch.

  • Kernels: rms_norm / pre_rms_norm in deepspeed/inference/v2/kernels/core_ops/cuda_rms_norm/rms_norm_cuda.cu, and fused_ln / fused_residual_ln in cuda_layer_norm/layer_norm_cuda.cu.
  • Overrun: for elems_per_row <= 128 they pack several rows per block with no row bound, so when rows > rows-per-block and is not a multiple of it, the last block reads and writes past the end.
  • Reachability: through the public CUDARMSNorm, for example EXAONE4's per-head Q/K RMSNorm (model_implementations/exaone4/model.py). There, channels = head_size and Q/K are normalized as [tokens * n_heads, head_size].
  • Evidence on master (5f266e2): compute-sanitizer memcheck through CUDARMSNorm reports out-of-bounds accesses for fp16/bf16 hidden 128 at 17 and 33 rows, and for hidden 64 at 33 rows; 16 rows is clean.

This PR is intentionally limited to the v1 / kernel-injection kernels, so I'll keep the v2 kernels out of scope here and open a separate issue for them, with the same row-guard fix offered.

Comment on lines +35 to +36
@pytest.mark.parametrize("seq_len", [1, 17, 33, 128])
@pytest.mark.parametrize("channels", [32, 64, 384, 512, 768, 1024, 2048, 8192, 14432])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make the small-shape tests detect the overrun

On a normal pytest run, these added RMS shapes and the analogous base layer-norm shapes do not detect the defect being fixed: the tests only compare values inside the returned logical tensors, while the surplus groups write after those tensors, so the parent implementation passes all of these RMS cases unless it happens to corrupt another allocation (the reported baseline is 360/360). Consequently fused_ln, rms_norm, or pre_rms_norm can lose its valid_row guard while CI remains green; use a canary/guard allocation or a sanitizer-backed regression that reliably fails on the old kernels instead of only broadening the parameter matrix.

AGENTS.md reference: AGENTS.md:L76-L76

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. The new seq_len/channels cases pass on master under plain pytest; only compute-sanitizer memcheck catches the overrun, and memcheck isn't part of the default CI.

I've added canary tests that fail under plain pytest:

  • New tests: test_layer_norm_no_write_past_last_row and test_rms_norm_no_write_past_last_row.
  • Helper: run_with_output_canaries in inference_test_utils.py.
  • Coverage: all five bindings (layer_norm, _layer_norm_residual, layer_norm_residual_store_pre_ln_res, rms_norm, pre_rms_norm), rows 17 and 33, at fp32 hidden 32/64 and fp16/bf16 hidden 64/128.
  • How it works:
    • The outputs are allocated inside the bindings, so the helper uses a private torch.cuda.MemPool to place each output directly in front of a caller-owned canary tensor filled with a finite sentinel (1000.0).
    • It asserts that the layout holds, and then that the canary is unchanged after the call.
    • The tests skip when MemPool isn't available.

Results:

  • Master (unguarded kernels): 40 failed / 10 passed in each of 20 runs. The 10 passes are rows 17 at the 8-thread sizes (fp32 32, fp16 64), which fit in one 32-row block and don't overrun.
  • This branch: 50 passed in each of 20 runs.
  • Full files on this branch: test_layer_norm.py 1134 passed, test_rms_norm.py 380 passed.

Adds a MemPool-based canary that places each kernel's output directly
in front of a sentinel-filled guard tensor and asserts it stays
untouched. Catches every overrunning case deterministically (40
failed/10 passed on master across 20 runs; 50/50 on this branch).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

This branch has not been deployed

No deployments
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.

[BUG] Inference layer_norm / rms_norm kernels read and write past the last row when rows is not a multiple of the rows per block (hidden size <= 128)

1 participant