Skip to content

adj_tile_dot is missing a block-wide broadcast of adj_ret #1374

Closed

Description

@shi-eric

adj_tile_dot (warp/native/tile_reduce.h:729-756) uses each thread's local adj_ret directly, but in reverse mode adj_ret is only non-zero on the thread(s) that consumed the forward scalar. When the idiomatic "one thread writes the result" pattern is used, only that thread receives a gradient, and the tile elements owned by every other thread get zero.

Repro

import warp as wp

BLOCK_DIM = 64
N = 16

@wp.kernel
def compute(a_in: wp.array(dtype=float),
            b_in: wp.array(dtype=float),
            out: wp.array(dtype=float)):
    i, j = wp.tid()
    a = wp.tile_load(a_in, shape=N, offset=0, storage="register")
    b = wp.tile_load(b_in, shape=N, offset=0, storage="register")
    result = wp.tile_dot(a, b)
    if j == 0:
        out[i] = result

With a = [1..16], b = [16..1], after tape.backward():

a_in.grad: [16.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.]
expected:  [16. 15. 14. 13. 12. 11. 10.  9.  8.  7.  6.  5.  4.  3.  2.  1.]

Forward value is correct (816.0); only the gradient is wrong.

Why the existing test_tile_dot_grad* tests don't catch it

They use 1-D i = wp.tid() in a tiled launch. launch_coord divides the linear index by block_dim before unraveling, so wp.tid() returns the tile index, which is the same for every thread in the block. With dim=[1], i == 0 is true on all 64 threads, every thread executes adj_array_store, and every thread pulls adj_out[0] into its own adj_ret. The gate never narrows execution to a single thread, so the tests only validate the uniform-adj_ret case.

Why this is structural to tile_dot and not other reductions

  • adj_tile_sum (whole-tile, tile_reduce.h:627) already broadcasts adj_ret from thread 0 through shared memory.
  • adj_tile_sum<Axis> (tile_reduce.h:805) broadcasts via tile_broadcast + tile_add_inplace.
  • adj_tile_reduce_impl, adj_tile_max, adj_tile_min, adj_tile_arg{max,min} are empty stubs.
  • adj_tile_axpy handles the symmetric scalar-input case via a single-thread accumulate guard on adj_alpha.

tile_dot is the only tile primitive whose forward returns a raw scalar broadcast to every thread (via __shared__ ScalarT scratch at tile_reduce.h:696 and :713-717). The forward path has a broadcast; the reverse path does not.

Context

Originally flagged during review of MR !2216 (the MR that added wp.tile_dot / wp.tile_axpy — GH-1363, GH-1364) but deferred to a follow-up so the primitives could land.

Activity

  1. added this to the 1.13.0 milestone on Apr 19, 2026
  2. added theissue type on Apr 19, 2026
  3. shi-eric commented on Apr 20, 2026

    @shi-eric
    ContributorAuthor

    Fixed by b9af381.

    Rather than patching adj_tile_dot to broadcast adj_ret (as the issue title suggested), the fix restructures tile_dot itself to match the tile_sum / tile_reduce convention, which side-steps the "which thread holds the canonical gradient" question entirely.

    Forward change: tile_dot now returns a single-element register tile (tile(dtype=T, shape=(1,))) instead of a raw scalar broadcast to all threads. The shared-memory broadcast that previously happened inside tile_dot is gone; only the reducing thread writes to output.data[0].

    Adjoint change: adj_tile_dot now takes a 1-element tile for adj_ret and reuses the same __shared__ scratch broadcast pattern as adj_tile_sum, so every thread sees the correct gradient regardless of which thread wrote the forward result.

    User-facing API change (vs. the originally-merged wp.tile_dot): Because tile_dot had not shipped in a release yet (still in Unreleased), we took the opportunity to change the return type rather than keep the scalar-returning signature. Callers must now index the result:

    result = wp.tile_dot(a, b)   # shape=(1,) tile, not a scalar
    if j == 0:
        out[i] = result[0]       # was: out[i] = result

    The documented equivalent long-form also simplifies, from wp.tile_extract(wp.tile_sum(wp.tile_map(wp.tensordot, a, b)), 0) to wp.tile_sum(wp.tile_map(wp.tensordot, a, b)).

    tile_axpy, which internally calls tile_dot, was updated to read dot_result.data[0].

    New regression tests test_tile_dot_grad_extract and test_tile_dot_grad_extract_shared cover the single-thread-writes-the-scalar pattern from the repro in this issue.

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions