PyTorch #188031 — Why Did the Existing 64-Bit Test Miss Spatial Softmax Backward?
PyTorch Spatial Softmax, Part 3 of 3 — The test boundary between inner_size == 1 and inner_size != 1, and between INT_MAX and UINT32_MAX
PyTorch already had a test named:
test_softmax_backward_64bit_indexingAt first glance, that sounds as though 64-bit indexing in softmax backward had already been covered.
The tensor used by the test was certainly large:
roughly 20GB of GPU memory
more than 2,147,483,650 FP16 elements
an input beyond INT_MAXYet the spatial softmax backward path still contained address calculations fixed to uint32_t.
How could a large-tensor 64-bit test already exist while this path remained untested?
The answer is not that the existing tensor was too small.
The existing test never entered the kernel that contained the defect.
The same API call does not imply the same CUDA kernel
Both the existing test and the new regression test call the same internal API:
torch._softmax_backward_data(...)But PyTorch examines the tensor shape and the softmax dimension before selecting a kernel.
It first reduces the tensor logically into three regions:
outer_size
dim_size
inner_sizeSuppose the tensor has the shape:
[A, B, C, D]and softmax is applied along dimension B.
Then:
outer_size = A
dim_size = B
inner_size = C × DThe key branch in the backward implementation is:
if (inner_size == 1) {
// last-dimension-oriented softmax backward
} else {
// spatial softmax backward
}The selected implementation therefore depends on more than the total number of elements.
It depends on how many elements remain after the softmax dimension.
The shape used by the existing test
The existing 64-bit backward test creates a tensor like this:
x = torch.ones(
[1, 1, numel],
device=device,
dtype=torch.float16,
)It applies softmax backward along dimension 2:
out = torch._softmax_backward_data(
x,
x,
2,
x.dtype,
)For:
shape = [1, 1, numel]
softmax dimension = 2the logical decomposition is:
outer_size
=
1 × 1
=
1dim_size
=
numelThere are no dimensions after dimension 2, so:
inner_size
=
1The final values are:
outer_size = 1
dim_size = numel
inner_size = 1That sends execution into the first branch:
inner_size == 1
→ last-dimension softmax backward pathFor a large dim_size, the call proceeds through the general host dispatch and eventually reaches the ordinary softmax backward kernel family.
Conceptually:
test_softmax_backward_64bit_indexing
↓
inner_size == 1
↓
dispatch_host_softmax_backward()
↓
cunn_SoftMaxBackwardThe defective kernel was not on this path.
The spatial path required a different shape and axis
Spatial softmax backward is selected when:
inner_size != 1That happens when softmax is applied to a non-final dimension and one or more dimensions remain after it.
The new regression test uses:
inner_size = 2147483649
out = torch.empty(
[1, 2, inner_size],
device=device,
dtype=torch.float16,
)It applies softmax backward along dimension 1:
gI = torch._softmax_backward_data(
grad,
out,
1,
out.dtype,
)For:
shape = [1, 2, inner_size]
softmax dimension = 1the decomposition becomes:
outer_size = 1
dim_size = 2
inner_size = 2,147,483,649Now:
inner_size != 1so PyTorch enters the other branch:
spatial softmax backward
→ cunn_SpatialSoftMaxBackwardBoth tests invoke the same Python-facing operation.
They do not execute the same CUDA kernel.
The two tests side by side
Existing test
Shape:
[1, 1, N]
Softmax dimension:
2
Decomposition:
outer = 1
dim = N
inner = 1
Kernel path:
cunn_SoftMaxBackwardNew regression test
Shape:
[1, 2, N]
Softmax dimension:
1
Decomposition:
outer = 1
dim = 2
inner = N
Kernel path:
cunn_SpatialSoftMaxBackwardAt the API level, both are enormous softmax backward operations.
At the implementation level, they are almost opposite workloads.
Existing test:
one extremely long softmax group
New test:
an enormous number of two-element softmax groupsThe spatial kernel exists specifically for the second memory pattern.
Why PyTorch keeps separate kernel paths
When softmax operates along the last dimension, the values in each softmax group are laid out contiguously:
[..., x0, x1, x2, x3]The kernel can read adjacent values while reducing one long group.
When softmax operates along a non-final dimension, the values belonging to one group are separated by a stride.
For the shape:
[1, 2, N]with softmax along dimension 1, each column contains two values at:
Column i:
first value → offset i
second value → offset i + NEach reduction contains only two elements.
But those elements can be separated by billions of positions.
The spatial implementation treats the tensor as:
outer × dim × innerand uses a two-dimensional block and grid to parallelize:
the outer dimension
the inner dimension
the reduction across dimThe separate path is therefore not incidental duplication.
The memory layout and parallelization strategy are fundamentally different.
The existing test did cross INT_MAX
The first large value used by the existing test is:
2,147,483,650The largest signed 32-bit integer is:
INT_MAX
=
2,147,483,647
=
2**31 - 1So the test does cross the signed 32-bit boundary:
numel
=
INT_MAX + 3That makes the existing test meaningful.
It verifies that the last-dimension backward path can operate beyond the signed 32-bit indexing range.
The test was not pointless or incorrectly constructed.
It covered the boundary of the implementation it actually reached.
But the spatial kernel used uint32_t
The defective spatial backward kernel did not use signed int32_t.
It used uint32_t.
Those types have different limits:
Maximum signed int32 value:
2**31 - 1
=
2,147,483,647Maximum uint32_t value:
2**32 - 1
=
4,294,967,295The element count in the existing test exceeded INT_MAX, but it remained well below UINT32_MAX:
2,147,483,650
<
4,294,967,295Even if the same number had somehow been passed through the spatial kernel, it would not yet have crossed the direct unsigned wraparound boundary.
To expose the actual spatial defect, two conditions were required:
1. Execution must reach cunn_SpatialSoftMaxBackward.
2. A calculated element offset must exceed 2**32 - 1.The existing test satisfied neither condition.
The new test crosses both boundaries
The new test chooses:
inner_size
=
2,147,483,649
=
2**31 + 1and:
dim_size = 2The total number of elements is:
2 × (2**31 + 1)
=
2**32 + 2
=
4,294,967,298That is three values beyond UINT32_MAX:
UINT32_MAX
=
4,294,967,295The test therefore exercises both relevant boundaries:
inner_size > INT_MAX
→ repaired dispatch must choose 64-bit index_ttotal spatial offset > UINT32_MAX
→ old uint32_t code would actually wrapThe existing test verified a signed 32-bit transition in one kernel family.
The new test reaches the unsigned 32-bit wraparound boundary in another.
One large tensor does not cover every large-tensor path
This case demonstrates an important distinction:
Large input
≠
coverage of every large-input implementationNo matter how large the tensor becomes, applying softmax along the last dimension still produces:
inner_size = 1The spatial kernel remains unreachable.
Using the same total number of elements but moving the softmax dimension into the middle can produce:
inner_size > 1and select a completely different kernel.
Actual test reachability depended on the combination of:
total element count
+
tensor shape
+
softmax axis
+
inner_size
+
selected branch
+
launched kernelElement count alone could not describe the coverage.
A test name does not establish implementation coverage
The name:
test_softmax_backward_64bit_indexingsounds broad.
But a test name is written by a person.
Actual coverage is determined by control flow.
Test name:
softmax backward 64-bit indexing
Actual path:
one backward implementation under inner_size == 1The following two statements are therefore not equivalent:
A 64-bit softmax backward test exists.Every softmax backward kernel has been exercised at a 64-bit boundary.The first can be confirmed by looking at the test file.
The second requires following:
shape
→ dimension selection
→ branch condition
→ dispatch
→ actual kernel launchThe existing PASS was still valid
The existing test passed.
That PASS correctly established:
The large last-dimension softmax backward path produced the expected result under that input.
The new regression test asks a different question:
Can the non-last-dimension spatial backward path address every element after its offsets exceed the old unsigned 32-bit range?
These are separate implementation contracts:
Existing test
→ 64-bit boundary of the last-dimension kernel
New test
→ 64-bit boundary of the spatial kernelThe weakness was not that the earlier test was wrong.
It was treating one passing implementation path as evidence for the whole operation name.
Why the new test uses dim_size = 2
The regression test fixes the softmax dimension at two elements:
dim_size = 2That makes each reduction as simple as possible.
The softmax output in every column is:
[0.25, 0.75]The incoming gradient is:
[1.0, 0.0]The expected result is exactly:
[+0.1875, -0.1875]for every column.
This removes several unrelated variables:
long reductions
complex value distributions
position-dependent expected values
floating-point tolerance ambiguityThe logical computation stays small.
Only the address space becomes enormous.
Why the test also fixes outer_size = 1
The first dimension is kept at 1:
outer_size = 1That removes another component of the address calculation.
The kernel no longer needs a meaningful movement across outer groups.
The central offset reduces to:
inner_index
+
d × inner_sizeFor the second softmax value:
d = 1so the test needs only to determine whether:
inner_index + inner_sizecan cross 2**32.
The shape is not merely large.
It is deliberately arranged to isolate the spatial address calculation.
No outer movement
+
two-element reduction
+
enormous inner dimensionThis is close to a minimal counterexample for the indexing contract, even though the allocation itself is huge.
The test compares the beginning and end of the tensor
The regression test checks gradients near the start:
gI[0, 0, 0]
gI[0, 1, 0]It also checks the final column:
gI[0, 0, -1]
gI[0, 1, -1]The beginning uses low offsets:
well within uint32_tThe final element of the second row has the logical offset:
2**32 + 1which exceeds the old range.
Every column contains the same input values, so both ends must produce the same gradients:
Beginning:
+0.1875 / -0.1875
End:
+0.1875 / -0.1875This gives one test both a normal-address reference and an overflow-address probe.
Why the new test needs approximately 30GB
The existing test is marked as requiring roughly 20GB.
The new spatial regression is marked for roughly 30GB.
That difference follows from the shape and number of tensors involved.
The spatial test contains:
2**32 + 2 FP16 elementsin each large tensor.
At two bytes per FP16 element:
one tensor
≈ 8 GiBThe test needs at least:
softmax output
incoming gradient
result gradientThe raw tensor storage is therefore approximately:
8 GiB × 3
≈ 24 GiBThe CUDA context, allocator, kernel execution, and safety margin raise the practical requirement to around 30GB.
The memory cost comes directly from testing the real pointer path beyond the unsigned 32-bit element boundary.
The 64-bit kernel introduced another boundary: launch viability
Changing the spatial backward kernel from fixed uint32_t offsets to an index_t template creates a 64-bit kernel variant for large tensors.
A 64-bit variant can consume more registers than the 32-bit version.
Higher register pressure can reduce:
the usable block size
the number of blocks that can reside simultaneously
occupancy on a particular GPU
A launch configuration that works for the 32-bit variant may not be valid for the 64-bit instantiation.
The patch therefore had to reinforce the launch-selection logic as well.
Correct address type
does not automatically imply
a launchable kernel configurationThe old launch helper asked the occupancy question only once
The spatial helper chooses a block shape and queries the driver for occupancy.
Conceptually, the previous flow was:
Select block dimensions
↓
Query occupancy once
↓
Construct the grid from that resultBut the 64-bit kernel may have a lower practical thread limit because of its register usage.
The patch notes a further backend-specific behavior observed on gfx950.
For an oversized block, the occupancy query can return:
error code:
cudaSuccess
resident block count:
0The API call itself reports success.
But the configuration cannot keep even one block resident.
Checking only:
err == cudaSuccesswould therefore be insufficient.
A valid result also requires:
max_active_blocks > 0The repaired launch helper shrinks the block until it is viable
The updated logic repeatedly probes the selected configuration:
Query occupancy for the current block
↓
No error and at least one resident block?
↓
Yes → accept
↓
No → reduce the block
↓
query againIt first reduces block.y.
When block.y can no longer be reduced, it reduces block.x.
if (block.y > 1) {
block.y /= 2;
} else {
block.x /= 2;
}After every reduction, it recalculates:
total threads
shared-memory requirements
occupancy
The helper accepts a configuration only when:
the driver accepts it
+
at least one block can actually resideThis was not an unrelated cleanup.
It was part of making the newly introduced 64-bit kernel variant executable across devices with different register and occupancy limits.
Address correctness and launch viability had to be closed together
The full repair crosses several layers:
Layer 1:
old uint32_t offsets wrap
Layer 2:
runtime selects a 64-bit index variant
Layer 3:
the 64-bit variant uses more registers
Layer 4:
the original block shape may no longer be resident
Layer 5:
the launch helper finds a viable configurationCorrect offsets are not useful if the kernel cannot launch.
A successful launch is not useful if the offsets are still wrong.
The complete correctness chain is:
correct branch
→ sufficient index width
→ correct kernel variant
→ valid launch configuration
→ correct gradient valuesTest presence and test execution are different facts
The new test is guarded by:
@largeTensorTest("30GB", "cuda")An environment without sufficient GPU memory skips it.
The change author also stated that their local GPU was not large enough to execute the approximately 30GB path.
They verified the expected numerical values with a smaller CPU example, and the index dispatch mirrors the already-deployed forward implementation.
The public evidence therefore establishes:
Regression-test code exists
✓
The exact boundary shape is encoded
✓
The source change landed in main
✓It does not automatically establish:
Every CI environment executed the 30GB case
✗A test being committed and a test being exercised on the required hardware are separate claims.
A stronger test matrix starts with implementation branches
A complete large-index softmax backward matrix would need to distinguish more than tensor size.
Relevant dimensions include:
Softmax axis:
last / non-last
inner_size:
1 / greater than 1
Signed index range:
at or below INT_MAX / above INT_MAX
Unsigned spatial offset:
at or below 2**32 - 1 / above it
Selected implementation:
ordinary softmax / spatial softmax
Observed location:
beginning / end of tensor
Index variant:
32-bit / 64-bit
Launch configuration:
immediately resident / requires block reductionThis does not mean testing every possible combination exhaustively.
It means selecting a minimal set of inputs that actually crosses each implementation and type boundary.
API coverage and implementation coverage are not the same
The structure of this issue can be reduced to one statement:
A test for the softmax backward API exists
≠
Every softmax backward kernel is coveredAt the API level, the operation is the same.
Inside PyTorch, the route splits:
Python API
↓
Analyze shape and softmax dimension
↓
Calculate outer / dim / inner
↓
Is inner_size equal to 1?
↙ ↘
ordinary path spatial pathWithout following the actual arrow taken by the test, the implementation coverage remains unknown.
A PASS speaks only for the path that was reached
The existing test passed.
That result proved:
The
inner_size == 1large softmax backward implementation produced the expected result for that input.
It did not prove:
The
inner_size != 1spatial backward implementation can represent the same address range safely.
The scope of test evidence does not automatically expand beyond the executed control flow.
Test PASS
→ evidence for the reached path
Unreached path
→ no evidence yetThe patch added more than another test
At the surface, #188031 added one approximately 30GB regression test.
The deeper contract is broader:
Regardless of the softmax axis, forward and backward must interpret the same tensor size consistently, use 64-bit addressing when required, and launch the resulting kernel under a valid device configuration.
The previous test covered an operation name.
The new test pins down an implementation branch.
That is the important difference.
Did the test actually reach the defect?
The final lesson from PyTorch #188031 is not about the number of tests.
It is not even about whether a test already existed.
The important question is:
Did the test pass through the producer, branch, kernel, and address boundary where the defect actually lived?
The existing test was large enough.
Its name explicitly mentioned 64-bit indexing.
It passed correctly.
But its shape and axis selected another CUDA kernel.
The defective path was never reached.
A test does not gain coverage from its name.
Coverage exists only along the control flow it actually executes.
Previous articles
PyTorch #188031 — Why Did Forward Use 64-Bit Indexing While Backward Still Used
uint32_t?PyTorch Softmax Backward — How an Offset Beyond
2**32Wrapped Back to the Start of the Tensor
https://resoneticlab.blogspot.com/2026/08/pytorch-188031-why-did-forward-use-64.html
https://resoneticlab.blogspot.com/2026/08/pytorch-softmax-backward-how-offset.html
Related material
Patch status: Landed in PyTorch main
Evidence: Actual branch dispatch, source diff, and existing and new large-tensor tests
Boundary note: The approximately 30GB regression test is skipped on devices without sufficient memory, and the change author stated that they could not execute that path on their local GPU.
This is Part 3 and the final article in the PyTorch spatial softmax 64-bit indexing series.
Part 1 examined the asymmetry between forward and backward index widths.
Part 2 followed the exact regression shape and calculated how offsets beyond 2**32 wrapped to positions 0 and 1.
This final article showed why an existing large 64-bit test still missed the defect: inner_size == 1 and inner_size != 1 selected different CUDA kernel paths.
#PyTorch #CUDA #Softmax #GPUProgramming #TestCoverage #64BitIndexing #GPUCorrectness #CodeAnalysis #SoftwareArchitecture