Skip to content

Commit 1aaede2

Browse files
authored
Reuse cuBLASMp's all-gathered grad_output in the wgrad GEMM (#3629)
* [PyTorch] Reuse cuBLASMp's all-gathered grad_output for wgrad With the cuBLASMp comm+GEMM overlap backend, the dgrad AllGather + GEMM gathers grad_output and the wgrad GEMM gathered it a second time, while Userbuffers wgrad reads the tensor that its dgrad all-gather left in the communication buffer. cuBLASMp 0.11.0 leaves the gathered B at the start of its workspace (a documented cublasMpMatmul workspace property), so wgrad now reuses it the same way: - nvte_comm_gemm_workspace() (comm_gemm.h, TE-internal) returns the comm-gemm context's workspace. - With the cuBLASMp backend, CommOverlap/CommOverlapP2P::get_buffer() returns a view of that workspace with the communicator's buffer shape and dtype, like the Userbuffers buffer view. - get_cublasmp_all_gather_output() builds the gathered tensor from it for unquantized and per-tensor FP8 data. Linear, LayerNormLinear, LayerNormMLP (fc2) and the fused te.ops backward use it instead of gather_along_first_dim; MXFP8 and other formats, a delayed wgrad and a skipped dgrad GEMM still gather. This needs cuBLASMp 0.11.0 or newer, since older versions keep the gathered B elsewhere in the workspace; the next commit checks the version. Tested on 4x H100 with cuBLASMp 0.11.0: the layer and multi-layer tests of test_comm_gemm_overlap.py with the cuBLASMp backend pass (39 passed, 13 MXFP8 cases skipped on H100), and test_fusible_ops_with_userbuffers.py --use-cublasmp passes in BF16 and FP8 delayed and current scaling. All-gathers per backward: row-parallel Linear 1 -> 0, LayerNormMLP 2 -> 1 (fc1 still re-gathers its input, as with Userbuffers). Signed-off-by: Almog Segal <asegal@nvidia.com> * [PyTorch] Fall back to the all-gather with cuBLASMp before 0.11.0 Reusing the gathered grad_output relies on cuBLASMp 0.11.0 leaving the gathered B at the start of its workspace. Earlier versions keep it after their cuBLASLt workspaces, so the view held other data. nvte_comm_gemm_workspace() now checks the loaded library with cublasMpGetVersion() and returns nullptr before 0.11.0; get_buffer() then reports that no gathered input is available, and get_cublasmp_all_gather_output() returns None, so the callers all-gather as before. Tested on 4x H100: with cuBLASMp 0.11.0, the row-parallel Linear backward issues no all-gather and the Linear and LayerNormMLP overlap tests with the cuBLASMp backend pass (9 passed). With cuBLASMp 0.10.0 it all-gathers once per backward and the same tests pass, as they do on TE main. Signed-off-by: Almog Segal <asegal@nvidia.com> * [PyTorch] Check the cuBLASMp version instead of catching the get_buffer() error get_cublasmp_all_gather_output() fell back to the all-gather for cuBLASMp versions before 0.11.0 by catching the error that get_buffer() raises and matching its message. That breaks silently if the message changes, and with an older cuBLASMp it raises and catches an exception in every backward pass of every layer that overlaps an all-gather. nvte_cublasmp_version() now returns the version of the loaded cuBLASMp library, read once with cublasMpGetVersion() (0 without cuBLASMp), and Python exposes it as tex.nvte_cublasmp_version(). get_cublasmp_all_gather_output() checks a cached version >= 0.11.0 before calling get_buffer(), so get_buffer() errors are no longer caught. nvte_comm_gemm_workspace() uses the same function. Signed-off-by: Almog Segal <asegal@nvidia.com> * [PyTorch] Test the fused te.ops comm+GEMM overlap with cuBLASMp test_fuser_ops_with_userbuffers only ran the Userbuffers backend, although the script supports --use-cublasmp and the fused te.ops backward reuses cuBLASMp's gathered grad_output. It is now parametrized over both backends and skips the cuBLASMp cases when Transformer Engine is built without cuBLASMp. Signed-off-by: Almog Segal <asegal@nvidia.com> --------- Signed-off-by: Almog Segal <asegal@nvidia.com>
1 parent fd7d03b commit 1aaede2

11 files changed

Lines changed: 275 additions & 62 deletions

File tree

‎tests/pytorch/distributed/test_fusible_ops_with_userbuffers.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,14 +431,19 @@ def run_parallel_tests(model_config: ModelConfig) -> None:
431431

432432
@pytest.mark.parametrize("world_size", _world_sizes)
433433
@pytest.mark.parametrize("quantization", quantization_list)
434+
@pytest.mark.parametrize("use_cublasmp", (False, True), ids=("userbuffers", "cublasmp"))
434435
def test_fuser_ops_with_userbuffers(
435436
*,
436437
world_size: int,
437438
dtype: torch.dtype = torch.bfloat16,
438439
quantization: Optional[str],
440+
use_cublasmp: bool,
439441
) -> None:
440442
"""Launch parallel job and run tests"""
441443

444+
if use_cublasmp and not tex.nvte_built_with_cublasmp():
445+
pytest.skip("Transformer Engine not built with cuBLASMp (NVTE_WITH_CUBLASMP=0).")
446+
442447
# Parallel job launcher
443448
command = []
444449
if tex.ubuf_built_with_mpi():
@@ -462,6 +467,8 @@ def test_fuser_ops_with_userbuffers(
462467
)
463468
if quantization is not None:
464469
command.extend(("--quantization", quantization))
470+
if use_cublasmp:
471+
command.append("--use-cublasmp")
465472

466473
# Environment
467474
env = dict(os.environ)

‎transformer_engine/common/comm_gemm/comm_gemm.cpp‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,26 @@ int64_t nvte_comm_gemm_numroc(NVTECommGemmCtx* ctx, int64_t global_size) {
602602
return cublasMpNumroc(global_size, block_size(ctx, global_size), ctx->rank, 0, ctx->nranks);
603603
}
604604

605+
void* nvte_comm_gemm_workspace(NVTECommGemmCtx* ctx, size_t* size) {
606+
NVTE_API_CALL(nvte_comm_gemm_workspace);
607+
// The loaded library decides the workspace layout: cuBLASMp 0.11.0 and newer start it with the
608+
// gathered B.
609+
if (nvte_cublasmp_version() < 1100) {
610+
*size = 0;
611+
return nullptr;
612+
}
613+
*size = ctx->workspace_size;
614+
return ctx->workspace;
615+
}
616+
617+
int nvte_cublasmp_version() {
618+
static const int version = [] {
619+
int v = 0;
620+
return cublasMpGetVersion(&v) == CUBLASMP_STATUS_SUCCESS ? v : 0;
621+
}();
622+
return version;
623+
}
624+
605625
#else // NVTE_WITH_CUBLASMP
606626

607627
struct NVTECommGemmCtx {};
@@ -642,4 +662,10 @@ int64_t nvte_comm_gemm_numroc(NVTECommGemmCtx* ctx, int64_t global_size) {
642662
NVTE_ERROR("Transformer Engine has not been built with cuBLASMp support.");
643663
}
644664

665+
void* nvte_comm_gemm_workspace(NVTECommGemmCtx* ctx, size_t* size) {
666+
NVTE_ERROR("Transformer Engine has not been built with cuBLASMp support.");
667+
}
668+
669+
int nvte_cublasmp_version() { return 0; }
670+
645671
#endif // NVTE_WITH_CUBLASMP

‎transformer_engine/common/include/transformer_engine/comm_gemm.h‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,27 @@ void nvte_gemm_all_reduce(NVTECommGemmCtx* ctx, int64_t m, int64_t n, int64_t k,
151151
*/
152152
int64_t nvte_comm_gemm_numroc(NVTECommGemmCtx* ctx, int64_t global_size);
153153

154+
/*! \brief Get the device workspace of a comm-gemm context.
155+
*
156+
* After nvte_all_gather_gemm with transb = false completes on its stream, the workspace begins
157+
* with the gathered B: the local B of every rank, in rank order, which together form the
158+
* gathered (N, K) tensor. The data remains valid until the next operation with the context on
159+
* any rank, since the other ranks write into the workspace. cuBLASMp versions before 0.11.0 keep
160+
* the gathered B elsewhere, so with those this function returns nullptr.
161+
*
162+
* \param[in] ctx Comm-GEMM context.
163+
* \param[out] size Workspace size in bytes (0 when the function returns nullptr).
164+
* \return Workspace pointer, or nullptr before the first operation or with cuBLASMp before 0.11.0.
165+
*/
166+
void* nvte_comm_gemm_workspace(NVTECommGemmCtx* ctx, size_t* size);
167+
168+
/*! \brief Get the version of the cuBLASMp library loaded at run time.
169+
*
170+
* \return MAJOR * 10000 + MINOR * 100 + PATCH (for example, 1100 for 0.11.0), or 0 if
171+
* Transformer Engine has not been built with cuBLASMp support.
172+
*/
173+
int nvte_cublasmp_version(void);
174+
154175
#ifdef __cplusplus
155176
} // extern "C"
156177
#endif

‎transformer_engine/common/util/pybind_helper.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
m.def("ubuf_built_with_mpi", &transformer_engine::ubuf_built_with_mpi, \
164164
py::call_guard<py::gil_scoped_release>()); \
165165
m.def("nvte_built_with_cublasmp", &nvte_built_with_cublasmp, \
166-
py::call_guard<py::gil_scoped_release>());
166+
py::call_guard<py::gil_scoped_release>()); \
167+
m.def("nvte_cublasmp_version", &nvte_cublasmp_version, py::call_guard<py::gil_scoped_release>());
167168

168169
#endif

‎transformer_engine/pytorch/csrc/extensions.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,9 @@ class CommOverlap : torch::CustomClassHolder, public transformer_engine::CommOve
878878
// Keeps the cuBLASMp NCCL communicator alive for the lifetime of this
879879
// instance, independent of the CommOverlapHelper that created it.
880880
CommOverlapHelper::NcclCommSharedPtr _nccl_comm;
881+
// cuBLASMp: shape and dtype of the buffer view returned by get_buffer()
882+
std::vector<size_t> _cublasmp_buffer_shape;
883+
at::ScalarType _cublasmp_buffer_dtype{at::kByte};
881884

882885
public:
883886
CommOverlap(const std::vector<size_t> &buffer_shape, at::ScalarType buffer_dtype,
@@ -891,6 +894,8 @@ class CommOverlap : torch::CustomClassHolder, public transformer_engine::CommOve
891894
// the construction-time warmup matmul that primes cuBLASMp's lazy NCCL
892895
// window registrations and workspace allocation so subsequent matmuls
893896
// (including those captured in CUDA graphs) avoid the unsafe lazy paths.
897+
// get_buffer() returns a view of cuBLASMp's workspace with `buffer_shape`
898+
// and `buffer_dtype`: after an all-gather GEMM, it holds the gathered input.
894899
CommOverlap(CommOverlapHelper *helper, int tp_rank, int tp_size,
895900
transformer_engine::CommOverlapType comm_type,
896901
const std::vector<size_t> &buffer_shape, at::ScalarType buffer_dtype,
@@ -913,6 +918,9 @@ class CommOverlapP2P : torch::CustomClassHolder, public transformer_engine::Comm
913918
// Keeps the cuBLASMp NCCL communicator alive for the lifetime of this
914919
// instance, independent of the CommOverlapHelper that created it.
915920
CommOverlapHelper::NcclCommSharedPtr _nccl_comm;
921+
// cuBLASMp: shape and dtype of the buffer view returned by get_buffer()
922+
std::vector<size_t> _cublasmp_buffer_shape;
923+
at::ScalarType _cublasmp_buffer_dtype{at::kByte};
916924

917925
public:
918926
CommOverlapP2P(const std::vector<size_t> &buffer_shape, at::ScalarType buffer_dtype,

‎transformer_engine/pytorch/csrc/extensions/comm_gemm_overlap.cpp‎

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -307,17 +307,44 @@ void cublasmp_capture_warmup(te::CommOverlapCore *core, int tp_size, te::CommOve
307307
cudaFree(d_ptr);
308308
}
309309

310+
// cuBLASMp has no communication buffer: its all-gather GEMM leaves the gathered input at the
311+
// start of its workspace, the local input of every rank in rank order. Like a Userbuffers
312+
// buffer, the view is valid until the next communicator operation on any rank overwrites it.
313+
at::Tensor cublasmp_get_buffer(NVTECommGemmCtx *ctx, int tp_rank, int tp_size,
314+
const std::vector<size_t> &buffer_shape, at::ScalarType dtype,
315+
bool local_chunk, std::optional<std::vector<int64_t>> shape) {
316+
if (!shape) {
317+
NVTE_CHECK(buffer_shape.size() == 2, "cuBLASMp buffer shape must be 2-dimensional");
318+
int64_t dim0 = static_cast<int64_t>(buffer_shape[0]);
319+
if (local_chunk) {
320+
dim0 /= tp_size;
321+
}
322+
shape = {dim0, static_cast<int64_t>(buffer_shape[1])};
323+
}
324+
const size_t bytes = transformer_engine::pytorch::product(*shape) * at::elementSize(dtype);
325+
const size_t offset = local_chunk ? bytes * tp_rank : 0;
326+
size_t workspace_size = 0;
327+
char *workspace = static_cast<char *>(nvte_comm_gemm_workspace(ctx, &workspace_size));
328+
NVTE_CHECK(workspace != nullptr,
329+
"No gathered input in the cuBLASMp workspace (requires cuBLASMp 0.11.0 or newer and a "
330+
"previous all-gather GEMM)");
331+
NVTE_CHECK(offset + bytes <= workspace_size, "Requested buffer (shape=", *shape,
332+
", local_chunk=", local_chunk, ") exceeds the cuBLASMp workspace (", workspace_size,
333+
" bytes)");
334+
return torch::from_blob(workspace + offset, *shape, at::dtype(dtype).device(torch::kCUDA));
335+
}
336+
310337
} // namespace
311338

312339
CommOverlap::CommOverlap(CommOverlapHelper *helper, int tp_rank, int tp_size,
313340
te::CommOverlapType comm_type, const std::vector<size_t> &buffer_shape,
314341
at::ScalarType buffer_dtype, int num_comm_sm, bool atomic_gemm)
315342
: te::CommOverlapBase(helper->get_nccl_comm("intra").get(), tp_rank, tp_size, num_comm_sm,
316343
atomic_gemm),
317-
_nccl_comm(helper->get_nccl_comm("intra")) {
318-
// buffer_dtype is unused on this path (the warmup runs in BF16); kept in
319-
// the signature for API symmetry with the non-cuBLASMp ctor.
320-
(void)buffer_dtype;
344+
_nccl_comm(helper->get_nccl_comm("intra")),
345+
_cublasmp_buffer_shape(buffer_shape),
346+
_cublasmp_buffer_dtype(buffer_dtype) {
347+
// The warmup runs in BF16; buffer_dtype is the dtype of get_buffer() views.
321348
cublasmp_capture_warmup(this, tp_size, comm_type, buffer_shape);
322349
}
323350

@@ -362,6 +389,11 @@ void CommOverlap::copy_into_buffer(const at::Tensor &input, bool local_chunk) {
362389
}
363390

364391
at::Tensor CommOverlap::get_buffer(bool local_chunk, std::optional<std::vector<int64_t>> shape) {
392+
if (_with_cublasmp) {
393+
return cublasmp_get_buffer(_cublasmp_ctx, _tp_id, _tp_size, _cublasmp_buffer_shape,
394+
_cublasmp_buffer_dtype, local_chunk, shape);
395+
}
396+
365397
// Check buffer shape
366398
const size_t ubuf_size = _ubuf.numel();
367399
if (shape) {
@@ -426,9 +458,10 @@ CommOverlapP2P::CommOverlapP2P(CommOverlapHelper *helper, int tp_rank, int tp_si
426458
int num_comm_sm, bool atomic_gemm)
427459
: te::CommOverlapP2PBase(helper->get_nccl_comm("intra").get(), tp_rank, tp_size, num_comm_sm,
428460
atomic_gemm),
429-
_nccl_comm(helper->get_nccl_comm("intra")) {
430-
// See CommOverlap constructor for the buffer_dtype rationale.
431-
(void)buffer_dtype;
461+
_nccl_comm(helper->get_nccl_comm("intra")),
462+
_cublasmp_buffer_shape(buffer_shape),
463+
_cublasmp_buffer_dtype(buffer_dtype) {
464+
// The warmup runs in BF16; buffer_dtype is the dtype of get_buffer() views.
432465
cublasmp_capture_warmup(this, tp_size, comm_type, buffer_shape);
433466
}
434467

@@ -470,6 +503,11 @@ void CommOverlapP2P::copy_into_buffer(const at::Tensor &input, bool local_chunk)
470503
}
471504

472505
at::Tensor CommOverlapP2P::get_buffer(bool local_chunk, std::optional<std::vector<int64_t>> shape) {
506+
if (_with_cublasmp) {
507+
return cublasmp_get_buffer(_cublasmp_ctx, _tp_id, _tp_size, _cublasmp_buffer_shape,
508+
_cublasmp_buffer_dtype, local_chunk, shape);
509+
}
510+
473511
// Check buffer shape
474512
if (shape) {
475513
const size_t requested_size = transformer_engine::pytorch::product(*shape);

‎transformer_engine/pytorch/module/base.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# See LICENSE for license information.
44

55
"""Base modules and utilities for TransformerEngine PyTorch API"""
6+
import functools
67
import io
78
import math
89
import os
@@ -812,6 +813,63 @@ def fill_userbuffers_buffer_for_all_gather(
812813
raise ValueError(f"Unsupported quantizer for Userbuffers ({quantizer})")
813814

814815

816+
@functools.lru_cache(maxsize=None)
817+
def _cublasmp_keeps_gathered_input() -> bool:
818+
"""Whether the loaded cuBLASMp leaves the gathered all-gather GEMM input at the start of its workspace
819+
820+
cuBLASMp 0.11.0 and newer do; earlier versions keep it elsewhere.
821+
822+
"""
823+
return tex.nvte_cublasmp_version() >= 1100
824+
825+
826+
def get_cublasmp_all_gather_output(
827+
comm,
828+
local_tensor: torch.Tensor | QuantizedTensorStorage,
829+
quantizer: Optional[Quantizer],
830+
process_group,
831+
) -> Optional[torch.Tensor | QuantizedTensorStorage]:
832+
"""Get the tensor gathered by the last cuBLASMp all-gather GEMM
833+
834+
cuBLASMp leaves the gathered input at the start of its workspace,
835+
which ``comm.get_buffer`` views. As with the Userbuffers buffer of
836+
``fill_userbuffers_buffer_for_all_gather``, the returned tensor
837+
uses that memory and is overwritten by the next operation with
838+
``comm`` on any rank. ``local_tensor`` must be the input of that
839+
GEMM.
840+
841+
Returns ``None`` for formats that wgrad cannot use as gathered
842+
(anything but unquantized and per-tensor FP8 data) and with
843+
cuBLASMp versions before 0.11.0, which keep the gathered input
844+
elsewhere; callers all-gather those separately.
845+
846+
"""
847+
if not _cublasmp_keeps_gathered_input():
848+
return None
849+
if isinstance(local_tensor, Float8TensorStorage):
850+
local_data = local_tensor._data
851+
elif isinstance(local_tensor, QuantizedTensorStorage):
852+
return None
853+
else:
854+
local_data = local_tensor
855+
if local_data is None:
856+
return None
857+
global_shape = list(local_data.size())
858+
global_shape[0] *= torch.distributed.get_world_size(process_group)
859+
global_data = comm.get_buffer(shape=global_shape)
860+
if global_data.dtype != local_data.dtype:
861+
return None
862+
if not isinstance(local_tensor, Float8TensorStorage):
863+
return global_data
864+
return Float8TensorStorage(
865+
data=global_data,
866+
fp8_scale_inv=local_tensor._scale_inv,
867+
fp8_dtype=local_tensor._fp8_dtype,
868+
fake_dtype=local_tensor._dtype,
869+
quantizer=quantizer,
870+
)
871+
872+
815873
def _is_weight_workspace_valid(
816874
workspace: QuantizedTensorStorage,
817875
quantizer: Quantizer,

‎transformer_engine/pytorch/module/layernorm_linear.py‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from transformer_engine.pytorch.tensor.utils import clear_columnwise_cache, is_custom
2020
from .base import (
2121
fill_userbuffers_buffer_for_all_gather,
22+
get_cublasmp_all_gather_output,
2223
get_ub,
2324
get_ub_is_fp8,
2425
is_ub_initialized,
@@ -1182,12 +1183,13 @@ def _layernorm_linear_backward_impl(
11821183
# Grad input tensor has been computed...
11831184
# --------------------------------------------------
11841185

1185-
# cuBLASMp's AG+GEMM consumes the gathered grad_output inline and
1186-
# does not preserve it for wgrad. Userbuffers leaves the gathered
1187-
# tensor in its persistent buffer; cuBLASMp does not, so we gather
1188-
# here. Route through the same FP8-aware all-gather as the
1189-
# non-overlap path in
1190-
# ``TransformerEngineBaseModule.grad_output_preprocess`` by passing
1186+
# cuBLASMp's AG+GEMM leaves the gathered grad_output at the start of
1187+
# its workspace, as Userbuffers leaves it in its persistent buffer,
1188+
# so wgrad reuses it for unquantized and per-tensor FP8 data. A
1189+
# delayed wgrad runs after the workspace is reused, and other formats
1190+
# need data the dgrad GEMM did not gather, so those gather here.
1191+
# Route through the same FP8-aware all-gather as the non-overlap path
1192+
# in ``TransformerEngineBaseModule.grad_output_preprocess`` by passing
11911193
# the grad_output quantizer. Columnwise data needed for wgrad is
11921194
# produced by ``update_usage(columnwise_usage=True)`` further below.
11931195
if (
@@ -1196,13 +1198,23 @@ def _layernorm_linear_backward_impl(
11961198
and args.ub_obj_gradout is not None
11971199
and args.ub_obj_gradout.with_cublasmp()
11981200
):
1199-
if args.grad_output_quantizer is not None:
1200-
set_quantizer_usage_for_wgrad_all_gather(args.grad_output_quantizer)
1201-
grad_output, _ = gather_along_first_dim(
1202-
grad_output,
1203-
args.tp_group,
1204-
quantizer=args.grad_output_quantizer,
1205-
)
1201+
grad_output_total = None
1202+
if not (args.wgrad_store is not None and args.wgrad_store.delay_wgrad_compute()):
1203+
grad_output_total = get_cublasmp_all_gather_output(
1204+
args.ub_obj_gradout,
1205+
grad_output,
1206+
args.grad_output_quantizer,
1207+
args.tp_group,
1208+
)
1209+
if grad_output_total is None:
1210+
if args.grad_output_quantizer is not None:
1211+
set_quantizer_usage_for_wgrad_all_gather(args.grad_output_quantizer)
1212+
grad_output_total, _ = gather_along_first_dim(
1213+
grad_output,
1214+
args.tp_group,
1215+
quantizer=args.grad_output_quantizer,
1216+
)
1217+
grad_output = grad_output_total
12061218

12071219
# --------------------------------------------------
12081220
# Compute grad weight

0 commit comments

Comments
 (0)