Skip to content

Migrate pybind11 to TORCH_LIBRARY - #393

Open
cleonard530 wants to merge 23 commits into
deepseek-ai:nv_devfrom
cleonard530:migrate_pybind_to_torch_library
Open

Migrate pybind11 to TORCH_LIBRARY#393
cleonard530 wants to merge 23 commits into
deepseek-ai:nv_devfrom
cleonard530:migrate_pybind_to_torch_library

Conversation

@cleonard530

@cleonard530 cleonard530 commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Migrates DeepGEMM's C++ extension from pybind11 (PYBIND11_MODULE + per-header register_apis) to TORCH_LIBRARY / TORCH_LIBRARY_IMPL registration. Ops are now registered as torch.ops.deep_gemm.*.

To preserve the existing Python API (import deep_gemm; deep_gemm._C.fp8_gemm_nt(...)), a new deep_gemm/_C.py shim loads the extension and re-exports the legacy surface. The compiled module is renamed to deep_gemm._C_extension and built as a limited-API (abi3) extension.

Also updates .pyi stub generation to read TORCH_LIBRARY schemas from csrc/ and overlay defaults/types from _C.py, and splits mega MoE symm-buffer sizing/slicing into two separate ops (replacing the old pybind callback return value).

Note: Bumped the abi3 floor to Python 3.10 which is supported by every torch release already in the build matrix (2.4–2.8), and it gets ahead of a floor bump we'll need once DeepGEMM moves to the LibTorch Stable ABI, which requires torch 2.10+ and Python 3.10+.

PR vllm-project/vllm#48962 is used to test this on vLLM.

WARNING:

Before, get_symm_buffer_size_for_mega_moe returned an int and a function (std::tuple<int64_t, std::function...>) but TORCH_LIBRARY doesn't allow returning function objects/closures. Now, it returns an int and the computed buffer layout as an int[] (std::tuple<int64_t, c10::List<int64_t>>), and a separate function, _slice_symm_buffer_for_mega_moe, consumes that int[] instead of the closure. This breaks backwards compatibility for get_symm_buffer_size_for_mega_moe, but this method is not re-exported from deep_gemm/__init__.py (only used internally by SymmBuffer.__init__), and a quick look on GitHub shows no other public repo calls this function directly either.

New files

deep_gemm/_C.py

Python compatibility shim for the old deep_gemm._C module. Responsibilities:

torch.ops.load_library(...) on _C_extension*.so at import time
Re-export torch.ops.deep_gemm.* under the legacy _C names
Restore pybind-era conveniences that are not expressible in TORCH_LIBRARY schemas alone: tuple unpacking (q/kv/(weight, scale) pairs), default arguments, and guarded op binding for #if DG_*_COMPATIBLE build variants
Previously _C was the compiled .so itself; now _C is pure Python and the .so is _C_extension.

csrc/torch_library_utils.hpp

(namespace deep_gemm::torch_utils) Conversion helpers for bridging PyTorch schema types to the C++ types the existing kernel implementations expect — e.g. c10::List<int64_t>std::tuple<int,int,int> for recipe/head_splits, and optional list → std::vector<int>. Without this, every TORCH_LIBRARY wrapper would duplicate the same list/tuple parsing logic.

csrc/utils/registration.h

Defines the REGISTER_EXTENSION(NAME) macro used by csrc/python_api.cpp. Under TORCH_LIBRARY, ops self-register via static initializers when the .so loads, so there's no real pybind11 module to build — this macro just emits an empty PyInit_ function so the compiled .so still satisfies Python's import machinery (which requires that symbol to exist).

Significantly changed files

csrc/apis/mega.hpp

  • Added build_symm_buffer_layout, which computes the full SymmBufferLayoutInfo (every sub-buffer's offset/size) once. get_symm_buffer_size_for_mega_moe and fp8_fp4_mega_moe/bf16_mega_moe call it instead of duplicating layout math — the kernel entry points build the layout and slice the buffer directly rather than going through a second registered op. SymmBufferLayoutInfo also gained to_int_list/from_int_list so the layout computed by get_symm_buffer_size_for_mega_moe can be flattened into a plain int[] and handed to _slice_symm_buffer_for_mega_moe, instead of that op recomputing the same layout (including the num_sms-dependent ring sizing) from scratch.
  • get_symm_buffer_size_for_mega_moe used to return (int64_t, std::function<...>) under pybind; TORCH_LIBRARY can't return closures. It now returns the int plus the computed layout as an int[], and slicing moved to a separate op, _slice_symm_buffer_for_mega_moe(Tensor buffer, int[] layout_info) (leading underscore since it's an internal implementation detail — only deep_gemm/mega/__init__.py's SymmBuffer calls it, it isn't part of the public API).

csrc/python_api.cpp

Replaces the old PYBIND11_MODULE(...) entry point. Ops now self-register via TORCH_LIBRARY_FRAGMENT/STABLE_TORCH_LIBRARY_IMPL static initializers inside each csrc/apis/*.hpp, which run automatically when the .so is loaded — so this file no longer needs to call per-header register_apis(m) functions. It just #includes every apis/*.hpp (to trigger their static registration) and defines an empty PyInit_ symbol via the REGISTER_EXTENSION macro, purely so the compiled .so is still a valid, importable Python extension module — no actual bindings go through the Python C API anymore.

scripts/generate_pyi.py

Previously generated .pyi type hints by regex-parsing the C++ function declarations in csrc/ — but under pybind, the real Python-callable keyword names come from py::arg("...") strings in PYBIND11_MODULE, which pybind doesn't require to match the C++ parameter names. That divergence caused real bugs: e.g. fp8_fp4_paged_mqa_logits's C++ parameter is named fused_kv_cache, but it's bound as py::arg("kv_cache"), so the old generated stub advertised a keyword (fused_kv_cache) that would actually raise TypeError if used — the true kwarg is kv_cache. The script now parses TORCH_LIBRARY schema strings from csrc/ and overlays defaults from an AST parse of deep_gemm/_C.py, so the stub is generated from the real registration contract and entry point instead of an incidental C++ signature.

Test plan/Results

  1. To help verify that each kernel op is still functioning correctly, I created a script, smoke_test_all_ops.py, that performs a minimal call to every op and verifies that any tensor inputs expected to be immutable remain bit-for-bit identical before and after the call.

Checkout commit a813b41 to see the smoke_test_all_ops.py script that exercises every TORCH_LIBRARY op. I added the results in the comment at the bottom of the commit (they all passed or were skipped because I didn't have the right SM).

  1. I also compared the generated .pyi file before (using C++ signatures directly) and after (using TORCH_LIBRARY schemas) to make sure types are mapped appropriately. The differences can be found here Gen pyi test cleonard530/DeepGEMM#5 .

  pytest tests/test_bf16.py
  pytest tests/test_fp8_fp4.py
  pytest tests/test_layout.py
  pytest tests/test_einsum.py
  pytest tests/test_attention.py
  pytest tests/test_hyperconnection.py
  pytest tests/test_legacy.py
  pytest tests/test_coverage_gaps.py  # (local test that fills in the kernel gaps)
  pytest tests/test_lazy_init.py

All test pass!

Signed-off-by: Chris Leonard <chleonar@redhat.com>
… match what was in the legacy code

Signed-off-by: Chris Leonard <chleonar@redhat.com>
…helper code from generate_pyi.py

Signed-off-by: Chris Leonard <chleonar@redhat.com>
…updates so I updated it to inlcude the type hints

Signed-off-by: Chris Leonard <chleonar@redhat.com>
Signed-off-by: Chris Leonard <chleonar@redhat.com>
…ybind default arguments are no longer there

Signed-off-by: Chris Leonard <chleonar@redhat.com>
Signed-off-by: Chris Leonard <chleonar@redhat.com>
…ther minor formatting issues. Also added comments with examples to help describe each step. These will be removed (as well as main()) in a followup commit, but wanted them here for reference

Signed-off-by: Chris Leonard <chleonar@redhat.com>
Signed-off-by: Chris Leonard <chleonar@redhat.com>
Signed-off-by: Chris Leonard <chleonar@redhat.com>
Signed-off-by: Chris Leonard <chleonar@redhat.com>
…TORCH_LIBRARY; deep_gemm/_C.py can keep using torch.ops.load_library.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
… function that was leftover from migration, replaces torch_compat with torch/all.h, and updated the sm120 files to use torch/all.h instead of torch/python.h
…_library_macros

- Drop torch_library_macros.hpp; register ops via TORCH_FN directly and rename
  torch_library_utils namespace to torch_utils.
- Use at::ScalarType/torch.dtype for logits_dtype instead of int + _SCALAR_TYPE dict.
- Align schema/wrapper param names with C++ impl (fused_kv_cache,
  activation_clamp_opt, *_tuple/*_tuple_opt), propagated to _C.py, mega/__init__.py,
  and tests.
- Prefix slice_symm_buffer_for_mega_moe with _ to mark it private.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
Signed-off-by: Chris Leonard <chleonar@redhat.com>
…o align it with the C++ names

Signed-off-by: Chris Leonard <chleonar@redhat.com>
fp8_fp4_mega_moe and bf16_mega_moe both write into this tensor via a device-side red.add reduction (accumulating per-expert recv counts across calls), but the TORCH_LIBRARY schema declared it as immutable (Tensor?). Fix by annotating it Tensor(cumulative_local_expert_recv_stats!)?.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
Comment thread scripts/generate_pyi.py
def adjust_for_c_py_wrapper(
name: str,
parameters: list[dict],
wrapper_defaults: dict[str, dict[str, str]] | None = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 critical: Defer evaluation of modern type annotations: On supported Python 3.9, setup.py imports this module and immediately raises TypeError because | unions require Python 3.10; Python 3.8 fails earlier on annotations such as list[str]. This prevents installation and wheel builds for two versions still present in the release matrix; add postponed annotation evaluation or use compatible typing forms.

🤖 v6

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.

Updated setup.py‎ to build with python 3.10

Comment thread setup.py Outdated
},
ext_modules=get_ext_modules(),
zip_safe=False,
options={'bdist_wheel': {'py_limited_api': 'cp39'}},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 critical: Align the abi3 tag with the release matrix: With the existing per-Python release matrix, every Python 3.9+ job for a given Torch/ABI combination now produces the same cp39-abi3 wheel filename, so concurrent release uploads collide and fail; Python 3.8 cannot produce this tag at all. Collapse the matrix to one compatible Python build per binary combination or retain per-version wheel tags.

🤖 v6

Comment thread csrc/apis/einsum.hpp Outdated
const torch::Tensor& b, const torch::Tensor& sfb,
const torch::Tensor& d, const c10::optional<torch::Tensor>& c,
const c10::optional<c10::List<int64_t>>& recipe) {
einsum::fp8_einsum(expr, {a, sfa}, {b, sfb}, d, c, list_to_tuple3(recipe.value()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 suggestion: fp8_einsum's TORCH_LIBRARY impl calls list_to_tuple3(recipe.value()) unconditionally, but the schema declares int[]? recipe=None. Calling the raw op torch.ops.deep_gemm.fp8_einsum(..., recipe=None) (or omitting it, since the schema default is None) will throw from c10::optional::value(). In practice the deep_gemm/_C.py wrapper always supplies a recipe (default (1, 128, 128), passing list(recipe)), so the public path is safe and this matches the pre-migration behavior. Consider either dropping the ?/default from the schema to make the contract explicit, or guarding with recipe.has_value() and asserting a clear error, so direct torch.ops callers get a meaningful message rather than a bad-optional-access.

🤖 v3

Comment thread deep_gemm/_C.py
'fp8_paged_mqa_logits': fp8_paged_mqa_logits,
})

# DG_TENSORMAP_COMPATIBLE — gemm.hpp (BF16 impl conditional)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 suggestion: _bind_guarded_ops groups 'einsum', 'tf32_hc_prenorm_gemm', and 'get_paged_mqa_logits_metadata' as one guard group. This is currently correct (all three are under #if DG_FP8_COMPATIBLE and DG_TENSORMAP_COMPATIBLE in einsum.hpp/hyperconnection.hpp/attention.hpp), but the grouping relies on an implicit cross-file invariant that isn't obvious from the Python side. Since _bind_guarded_ops is all-or-nothing, if any one of these later moves to a different guard the whole group silently fails to bind. Consider adding a short comment noting the three source files/guard each name comes from (as done for the mega/layout groups) to make the coupling auditable.

🤖 v3

Comment thread csrc/apis/mega.hpp
m.def(
"get_symm_buffer_size_for_mega_moe(int num_ranks, int num_experts, int num_max_tokens_per_rank, int num_topk, int hidden, int intermediate_hidden, str mma_type, str activation, int num_shared_experts=0) -> int",
TORCH_FN(deep_gemm::torch_registration::get_symm_buffer_size_for_mega_moe));
m.def(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 suggestion: _slice_symm_buffer_for_mega_moe and get_symm_buffer_size_for_mega_moe both call build_symm_buffer_layout independently, so the size op and the slice op each recompute the full layout (including the num_sms-dependent ring sizing). SymmBuffer.init calls both back-to-back, doubling the layout computation per buffer allocation. This is functionally fine and matches the stated 'compute layout once per kernel launch' goal within each op, but if buffer construction ever becomes hot it may be worth exposing a single op that returns both size and slices. Non-blocking.

🤖 v3

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.

Fixed: get_symm_buffer_size_for_mega_moe now returns (int64_t, int[]), where the int[] is the fully-computed SymmBufferLayoutInfo flattened via a new to_int_list()/from_int_list() pair. _slice_symm_buffer_for_mega_moe's schema changed to (Tensor buffer, int[] layout_info), so it reconstructs the layout directly from what was already computed instead of calling build_symm_buffer_layout a second time. SymmBuffer.__init__ computes the layout exactly once per allocation now.

Comment thread csrc/apis/mega.hpp
num_ranks, num_experts, num_max_tokens_per_rank, num_topk,
hidden, intermediate_hidden, mma_type, activation, num_shared_experts).num_bytes;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 suggestion: build_symm_buffer_layout notes 'we temporarily assume the SM count is consistent with the runtime value' and derives num_sms from device_runtime->get_num_sms(). Because get_symm_buffer_size_for_mega_moe and _slice_symm_buffer_for_mega_moe are now separate ops called at different times (allocation vs. slicing) and the mega_moe kernel entry builds its own layout at launch, an intervening deep_gemm.set_num_sms() between SymmBuffer construction and the kernel call could make the sizing/slicing layout diverge from the launch-time layout. The kernel does assert sym_buffer.nbytes() >= layout_info.num_bytes, which catches undersizing, but the slice views captured in SymmBuffer would be stale. Worth documenting the assumption that num_sms must not change across a SymmBuffer's lifetime.

🤖 v3

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.

This divergence risk existed before this PR. num_sms is expected to stay constant for the lifetime of a process/run in practice, and a proper fix (caching the layout across launches, or asserting num_sms hasn't drifted) is more involved than this PR's scope. Non-blocking, as noted.

Comment thread scripts/generate_pyi.py Outdated
def sanitize_param_name(name: str) -> str:
if name in {'def', 'class', 'from', 'import', 'None', 'True', 'False'}:
return f'{name}_'
return name

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 suggestion: _promote_int_list_tuple_types hardcodes per-parameter-name promotions (head_splits -> tuple[int,int,int], recipe -> tuple[int,int,int], recipe for transform_sf_into_required_layout -> tuple[int,int] | tuple[int,int,int], etc.). These implicit name-based rules must be kept in sync by hand with both the TORCH_LIBRARY schemas and the _C.py wrapper unpacking logic; a future schema rename (e.g. a new recipe-like arg) will silently fall back to list[int] in the stub without any warning. Consider driving these promotions from the _C.py wrapper signatures/annotations (which are already AST-parsed for defaults) rather than a hardcoded name table, to reduce the risk of stub/runtime drift like the fused_kv_cache/kv_cache bug this MR set out to eliminate.

🤖 v3

@ds-review-bot

Copy link
Copy Markdown
Collaborator

🤖 ds-review-bot Code Review

v6

The migration breaks supported Python 3.8/3.9 builds and makes the current release matrix generate conflicting stable-ABI wheel assets.

v4

⚠️ 未完成评审(no_result_file:模型未产出结果文件)

v3

This MR migrates DeepGEMM's C++ extension from pybind11 (PYBIND11_MODULE + per-header register_apis) to TORCH_LIBRARY / TORCH_LIBRARY_IMPL registration. Ops now self-register as torch.ops.deep_gemm.* via TORCH_LIBRARY_FRAGMENT/TORCH_LIBRARY_IMPL static initializers in each csrc/apis/.hpp. A new pure-Python deep_gemm/_C.py shim loads the renamed _C_extension.so (built as limited-API/abi3), re-exports the legacy C surface, and restores pybind-era conveniences (tuple unpacking, default args, guarded op binding). The overall design is sound and consistent: all 7 apis headers use the new registration pattern, no pybind11/register_apis/py::arg references remain, the empty PyInit symbol keeps the .so importable, and the mega MoE symm-buffer sizing/slicing is cleanly split into get_symm_buffer_size_for_mega_moe (int) + _slice_symm_buffer_for_mega_moe, both consumed correctly by deep_gemm/mega/init.py's SymmBuffer. The build (setup.py), stub generation (scripts/generate_pyi.py, now parsing TORCH_LIBRARY schemas + _C.py AST defaults), and init.py wiring are all consistent. The #if guard groups in _C.py's _bind_guarded_ops calls correctly match the C++ conditional-compilation guards (verified for DG_TENSORMAP_COMPATIBLE and DG_FP8_COMPATIBLE and DG_TENSORMAP_COMPATIBLE groups). No pybind11/register_apis/py::arg references remain anywhere in csrc/, and mega/init.py correctly consumes the split size/slice ops. I found no blocking defects; the notes below are minor robustness/maintainability suggestions, not correctness regressions relative to the pybind baseline.

Files reviewed: 36
Issues found: 🔴 2 critical | 🔵 5 suggestion
Inline comments posted: 7

⚠️ Parse warning: [v4] no_result_file:模型未产出结果文件

…filenames on release uploads. Bump the floor to cp310, which every torch version in the matrix (2.4-2.8) already supports.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
…as. These were declared optional but the C++ impl always called unconditionally, so would have crashed anyway; the Python wrappers already default to a concrete tuple.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
…n by having get_symm_buffer_size_for_mega_moe return the computed layout as an int[] alongside num_bytes, which _slice_symm_buffer_for_mega_moe now reuses instead of rederiving it (and its num_sms-dependent ring sizing) from the original args a second time.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
…params like recipe/head_splits, so the schema itself is the source of truth for stub generation instead of a hand-maintained per-name promotion table in generate_pyi.py. Add an _as_int_list wrapper helper so int[N]'s scalar-broadcasting behavior can't silently convert a bad scalar argument into a repeated list.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
…le-factor pairs like q/q_sf, replacing the special-cased _apply_q_qsf_merge, and sort the csrc/ file scan so op ordering in the generated stub no longer shuffles across machines/checkouts. Also drop sanitize_param_name, since it could only keep the .pyi syntactically valid and not guarantee the stub's keyword name actually matches the real wrapper at runtime.

Signed-off-by: Chris Leonard <chleonar@redhat.com>
…atter for this migration but it makes the migration to torch abi stable easier

Signed-off-by: Chris Leonard <chleonar@redhat.com>
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