Skip to content

Extended n-to-1 kernel fusion via KernelConfig #45666

Open
michaelbenayoun wants to merge 58 commits into
huggingface:mainfrom
michaelbenayoun:extended_kernels_api
Open

Extended n-to-1 kernel fusion via KernelConfig #45666
michaelbenayoun wants to merge 58 commits into
huggingface:mainfrom
michaelbenayoun:extended_kernels_api

Conversation

@michaelbenayoun

@michaelbenayoun michaelbenayoun commented Apr 27, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Extends the KernelConfig API with two orthogonal capabilities:

  1. Module fusion: specify how Transformers modules should be fused together before a custom kernel is applied (n-to-1 replacement).
  2. Weight transformation in the live model: handle cases where a kernel expects weights in a different layout than the original modeling (e.g. fused linears).

These two capabilities are independent and can be combined.

Module fusion

Users can now specify a tuple of layers as a KernelConfig key to fuse multiple modules into a single kernel target:

  kernel_config = KernelConfig(
      {
          (
              ("RMSNorm", "model.layers.*.post_attention_layernorm"),
              ("MLP",     "model.layers.*.mlp"),
          ): "michaelbenayoun/dummy-rmsnorm-mlp:RMSNormMLP",
      },
  )
  model = AutoModelForCausalLM.from_pretrained(model_id, use_kernels=True, kernel_config=kernel_config)

Internally, before model instantiation, register_kernel_fusions:

  1. Meta-instantiates the model to discover parent classes containing all target children.
  2. Creates a FusedModuleBase subclass representing the fused layer and a fused parent class that instantiates it.
  3. Registers monkey patches via the patch API so the model is built with the fused structure from the start.
  4. Resolves the tuple key to a scalar kernel_layer_name so the downstream kernelize pipeline is unchanged.

Weight transformation in the live model

Some kernels require the actual live parameters to be restructured (e.g. concatenating weights). This PR extends WeightTransform with a transform_model method that subclasses can override to restructure the live model graph before weights are loaded. The default is a no-op.

Kernels that require weight manipulation should provide a conversion_mapping class attribute; register_kernel_fusions picks it up automatically and appends it to the inferred transforms.

Related PRs

@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Comment on lines +222 to +238
kernel_config = KernelConfig(
{
(
("RMSNorm", "model.layers.*.post_attention_layernorm"),
("MLP", "model.layers.*.mlp"),
): "michaelbenayoun/dummy-rmsnorm-mlp-with-transformations:RMSNormMLP",
}
)
else:
kernel_config = KernelConfig(
{
(
("RMSNorm", "model.layers.*.post_attention_layernorm"),
("MLP", "model.layers.*.mlp"),
): "michaelbenayoun/dummy-rmsnorm-mlp:RMSNormMLP",
}
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I can upload and use my test repo somewhere more official.

Comment on lines +4620 to +4627
if self.kernel_config is not None:
from kernels import use_kernel_mapping

inherit_mapping = not self.kernel_config.use_local_kernel
with use_kernel_mapping(self.kernel_config.kernel_mapping, inherit_mapping=inherit_mapping):
kernelize(self, device=Device(type=self.device.type), mode=mode)
else:
kernelize(self, device=Device(type=self.device.type), mode=mode)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These changes are required to make model.eval() work. Otherwise it wont apply the kernels.

@michaelbenayoun michaelbenayoun marked this pull request as ready for review May 13, 2026 14:33

@Cyrilvallez Cyrilvallez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hey @michaelbenayoun! Thanks for the PR!

In general, we do not want to add such complexity in core_model_loading.py. All model manipulations/module changes should happen before, and register proper ConversionOps so that the loader can create the correct weights. Model loading being a critical part of the lib, and having a lot of non trivial tweaks and tricks for perfs, adding such model operations at the same time will quickly become hard to understand/debug/profile.
The workflow we want to aim for here is:
initialize meta model -> identify which modules need to be fused/unfused etc -> monkey patch the modules and create WeightConversion for them -> defer to loader to create proper weights
This way we keep a clean loading flow, that is easy to understand and maintain.

cc @vasqu @IlyasMoutawwakil as well here, for kernels and monkey-patching API, if you can have an eye as well! This is non trivial and we need to make sure we have a clean integration for fused kernels!

Comment thread src/transformers/core_model_loading.py Outdated
Comment on lines +874 to +910
def transform_model(self, model: PreTrainedModel, layer_name: str) -> None:
source_names = list(self.layer_targets.get(layer_name, []))
if not source_names:
return
source_name = source_names[0]
if source_name == layer_name:
return

source_mod_path, _, source_attr = source_name.rpartition(".")
target_mod_path, _, target_attr = layer_name.rpartition(".")

# Idempotency guard: if the target already holds the parameter/buffer, the
# model is already in the correct shape (e.g. from monkey-patching before
# from_pretrained). Nothing to move.
try:
target_mod = model.get_submodule(target_mod_path) if target_mod_path else model
if target_attr in target_mod._parameters or target_attr in target_mod._buffers:
return
except AttributeError:
pass

try:
source_mod = model.get_submodule(source_mod_path) if source_mod_path else model
except AttributeError:
return # already moved

if source_attr in source_mod._parameters:
obj = source_mod._parameters.pop(source_attr)
_get_submodule_or_create(
model, target_mod_path, original_module=source_mod, future_weight=obj
).register_parameter(target_attr, obj)
elif source_attr in source_mod._buffers:
obj = source_mod._buffers.pop(source_attr)
_get_submodule_or_create(model, target_mod_path).register_buffer(target_attr, obj)

# Cleanup any empty modules that may be left after moving the parameter/buffer.
self.post_transformation_cleanup(model, source_names)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All these transforms should happen on the meta model, upstream of loading weights into it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is already the case.
The function invoking the transform_model methods is called here: https://github.com/huggingface/transformers/pull/45666/changes#diff-6b72b98c4c2dcfc6cc606843917733f5d858374fbc22a735ff483bbc0c1e63eaR4257

@dacorvo

dacorvo commented May 18, 2026

Copy link
Copy Markdown
Contributor

@michaelbenayoun do you have benchmark numbers comparing the full graphs obtained using torch.compile with and without these fused NKI kernels (and without any NKI kernels) ? My experience with NKI so far is that it does not produce faster graphs, although it can help working around compiler failures.

@vasqu

vasqu commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Sorry got a bit delayed. Lmk when I should review seeing a lot of wip commits so assuming it's more of a draft for now?

@michaelbenayoun

Copy link
Copy Markdown
Member Author

Sorry got a bit delayed. Lmk when I should review seeing a lot of wip commits so assuming it's more of a draft for now?

I just addressed @Cyrilvallez's comment. We do not change anything besides on the meta device, at instantiation-time and without transform_model methods.

@michaelbenayoun

Copy link
Copy Markdown
Member Author

Gently pinging

@ArthurZucker ArthurZucker left a comment

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.

My main concern with this approach is how non explicit it is, hiding a lot of stuff + it does not follow the standard contract:

  1. model layers should be changes post meta init. (you are not running with all context manager etc)
  2. weight conversion should not be Outside, but passed to the main conversion loading script. If you change conversion, be explicit
  3. You need to supports distributed, quantization etc. Or not but that has to be explicit.
    There should be a single point where model is modified, and where weights are modified.

Also to help review would help a fully documented example of what you put on the hub in just pure python:

conversion_mapping = [
--
WeightConverter(
["MLP.gate_proj", "MLP.up_proj"],
"MLP.gate_up_proj",
[Concatenate(dim=0)],
),
]
 
class RMSNormMLP(nn.Module):
"""
Fused RMSNorm + MLP kernel.
 
The fused module container exposes the source modules as named children:
- self.RMSNorm  — the RMSNorm layer (Qwen3RMSNorm / LlamaRMSNorm)
- self.MLP      — the MLP layer (Qwen3MLP / LlamaMLP)
 
This kernel runs them sequentially, making it a drop-in correctness
reference. A real kernel would fuse the two operations into a single
CUDA kernel.
"""
 
conversion_mapping = conversion_mapping
 
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
hidden_states = self.RMSNorm(hidden_states)
gate, up = self.MLP.gate_up_proj(hidden_states).chunk(2, dim=-1)
hidden_states = self.MLP.down_proj(self.MLP.act_fn(gate) * up)
return hidden_states
 
 
class layers:
RMSNormMLP = RMSNormMLP

if _torch_distributed_available and device_mesh is not None: # add hooks to nn.Modules: no weights
model = distribute_model(model, tp_plan, distributed_config, device_mesh, tp_size)

# Prepare the full device map

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.

why don't you call the model changes here, once everything is quantized and distributed ?

Comment on lines +1008 to +1030
for module in meta_model.modules():
module_cls = type(module)
if module_cls in seen:
continue
if not all(hasattr(module, name) for name in child_names):
continue
seen.add(module_cls)

if kernel_conversion_mapping:
for source_name, child_name in zip(source_names, child_names):
child_cls = type(getattr(module, child_name))
if child_cls in seen_children:
continue
seen_children.add(child_cls)
prefix_dot = source_name + "."
relevant = [
c
for c in kernel_conversion_mapping
if any(p.startswith(prefix_dot) for p in c._original_source_patterns)
]
if relevant:
stripped = [_strip_converter_prefix(c, source_name) for c in relevant]
patch_mapping[child_cls.__name__] = _make_converted_child_class(child_cls, stripped)

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.

for me it really does not make sense to have to do the conversions first before anything

return parent_mod._modules[mod_name]


def _apply_weight_conversions(module: "nn.Module", conversion_mapping: list) -> 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.

should never be done

return f"{self.__class__.__name__}(fused=({names}))"


@functools.cache

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.

this is the core part of your changes we need a documented example etc

# 1. Load the kernel class to get any conversion_mapping it declares.
kernel_cls = _try_load_kernel_class(repo_str, use_local=kernel_config.use_local_kernel)
kernel_conversion_mapping = (
list(kernel_cls.conversion_mapping)

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.

This I like!

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.

but why does the conversion mapping not include the fusing itself?


# 2. Meta-device scan — find parent classes that contain all target children.
with torch.device("meta"):
meta_model = cls(config)

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.

for every single kernel mapped we re-init a meta model?

seen: set[type] = set()
seen_children: set[type] = set()
patch_mapping: dict[str, type] = {}
for module in meta_model.modules():

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.

pretty sure you can avoid the double for loop or the order is just weird since you can iterate just once on all meta modules, collect into layer_groups, then fuse the inners groups.

Comment on lines +831 to +836
def make_fused_parent_class(
parent_cls: type,
child_names: list[str],
source_names: list[str],
kernel_layer_name: str,
) -> type:

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.

this is somewhat fine, but IDK how you are gonna setup a "contract" that the forward is always properly "kept" or something?

return WeightConverter(new_src, new_tgt, operations=converter.operations)


def _apply_converter_to_module(module: "nn.Module", converter) -> 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.

mmm not sure we should do that

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.

7 participants