Extended n-to-1 kernel fusion via KernelConfig #45666
Conversation
|
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. |
| 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", | ||
| } | ||
| ) |
There was a problem hiding this comment.
I can upload and use my test repo somewhere more official.
| 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) |
There was a problem hiding this comment.
These changes are required to make model.eval() work. Otherwise it wont apply the kernels.
Cyrilvallez
left a comment
There was a problem hiding this comment.
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!
| 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) |
There was a problem hiding this comment.
All these transforms should happen on the meta model, upstream of loading weights into it
There was a problem hiding this comment.
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
|
@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. |
|
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 |
|
Gently pinging |
ArthurZucker
left a comment
There was a problem hiding this comment.
My main concern with this approach is how non explicit it is, hiding a lot of stuff + it does not follow the standard contract:
- model layers should be changes post meta init. (you are not running with all context manager etc)
- weight conversion should not be Outside, but passed to the main conversion loading script. If you change conversion, be explicit
- 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 |
There was a problem hiding this comment.
why don't you call the model changes here, once everything is quantized and distributed ?
| 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) |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
should never be done
| return f"{self.__class__.__name__}(fused=({names}))" | ||
|
|
||
|
|
||
| @functools.cache |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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(): |
There was a problem hiding this comment.
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.
| def make_fused_parent_class( | ||
| parent_cls: type, | ||
| child_names: list[str], | ||
| source_names: list[str], | ||
| kernel_layer_name: str, | ||
| ) -> type: |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
mmm not sure we should do that
What does this PR do?
Extends the
KernelConfigAPI with two orthogonal capabilities: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:
Internally, before model instantiation,
register_kernel_fusions:FusedModuleBasesubclass representing the fused layer and a fused parent class that instantiates it.kernelizepipeline 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
WeightTransformwith atransform_modelmethod 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_mappingclass attribute;register_kernel_fusionspicks it up automatically and appends it to the inferred transforms.Related PRs
KernelConfig#45363 — Related open PR