Compiler optimization modes
Starting with release 1.17, SambaFlow fully supports two new compiler modes, o0 and o1, that fundamentally change how the compiler prepares your model for execution on the RDU. The biggest difference between the two modes is in how they fuse operators together into basic RDU execution units, called sections.
o0 compiles each operator as a seperate section (safe mode).
o1 fuses operators. You can control o1 with customizable operator fusion rules and prespecified heuristics.
One of the fundamental differences between an RDU and a GPU is that the RDU organizes its compute resources around a dataflow architecture. In a dataflow architecture, the output of one computation can flow to the next (and the next and so on) without wasting a round trip back to memory between every operation. It’s an ideal setup for systems that need pipelines for an immense amount of data, such as machine learning.
To make the best use of our architecture, the compiler has to decide how to lay out these pipelines - in other words, which operators in a model are executed where and when. A single one of our chips, while large, does not have enough compute fabric (PMUs and PCUs) to contain an entire model.
Instead, our compiler divides a model into sections that then serve as the fundamental execution units. Contained within one section is a subgraph of the entire model where a number of operators are fused into one continuous, compound block that executes in its entirely before going back to memory. The term operator fusion comes from the world of GPUs, where multiple discrete operators are fused together into a single compound kernel (see this Medium article). On a GPU, operator fusion is limited in scope and compound kernels are typically written by hand. The RDU, on the other hand, is naturally suited for automatic operator fusion.
There are currently 3 compiler modes:
o3. With each mode, the compiler extends its optimization scope to a wider range:
o0 is operator mode, which compiles the model graph with each operator as a seperate section. In other words, it performs no operator fusion at all. o0 is the best option when you first bring up a model for debugging, but performance is not optimal.
o1 is module mode, in which the compiler intentionally fuses operators to be in the same section. Operator fusion can be done automatically via an operator fusion rules file or defined manually in your app. o1 enables the best performance, but may require extra customization by you if used with a model that is not supported out of the box.
o3 is full graph mode and is the current default. The compiler tries to make all optimization decisions automatically based on the entire graph. o3 achieves better performance than o0, but worse performance than o1 when the model has fusion rules defined.
The model graph is a given model’s computation graph. In other words, what you’d get if you took all the operators in a model and connected them together in a big flowchart. Let’s use Llama2 in its 7B parameter configuration, which has 32 encoder layers, as an example.
Each encoder layer in the forward graph contains 51 operators; thus, Llama2 7B inference contains 51*32 = 1632 operators, plus a handful of operators at the beginning and end (to handle the embeddings and loss, etc). The backwards graph (used for training) is generated by the compiler using the Autograd algorithm, and has even more operators (the Pytorch documentation includes a discussion of Autograd).
Let’s now look at just one of the modules in the encoder, the Feed Forward Network (FFN). Simplifying some unnecessary details, here’s the PyTorch code for the Llama2 FFN and the computation graph for this FFN:
import torch.nn as nn from torch.nn.functional import silu from transformers import AutoConfig class Llama2FFN(nn.Module): def __init__(self, config: AutoConfig) -> None: super().__init__() self.h_size = config.hidden_size self.im_size = config.intermediate_size self.gate_proj = nn.Linear(self.h_size, self.im_size, bias=False) self.up_proj = nn.Linear(self.h_size, self.im_size, bias=False) self.down_proj = nn.Linear(self.im_size, self.h_size, bias=False) def forward(self, inputs: torch.Tensor) -> torch.Tensor: residual = inputs gate_proj_result = self.gate_proj(inputs) act_result = silu(gate_proj_result) up_proj_result = self.up_proj(inputs) down_proj_result = self.down_proj(act_result * up_proj_results) outputs = residual + down_proj_result return outputs
|Models typically compile more slowly with o3 than with o0 or o1 because o3 uses the full graph optimization scope. In contrast, both o0 and o1 have limited optimization scopes and are able to leverage tools in our compiler that fold identical scopes together. That means repeated elements do not have to be processed again and again.|
For example, consider an NLP model with 32 encoders. Each encoder layer has a Multi-Headed Attention (MHA) module, and each of these MHAs has identical sizes and dtypes. (For details about MHA, this external article is a great explanatory resource.)
With o1, the compile detects the MHA’s pattern of operators 32 times (once in each encoder layer) and then folds them together into a single operator fusion group. Now, instead of applying optimizations and transformations directly on these operators in-situ on the original graph, the compiler applies them to this folded operator fusion group to essentially do the same thing on all 32 instances of the operator pattern at once.
The following diagram shows an example of operator fusion on LLaMA2 7B. The graph on the left represents a snippet of Llama2’s compute graph, showing just one encoder layer. (weights are not shown; only the operations.) The full graph has many such layers (e.g. 32 layers for Llama 7B) and thousands of operators. Meanwhile, the graph on the right shows how the compiler fuses these operators in o1 mode.
To use o0, just add
-o0 to your compile command.
To use o1, you have two choices: automatic fusion or manual fusion.
To use automatic fusion, add
-o1 --optimization-rules PATH/TO/YOUR/RULES/FILE.yaml to your compile command. You do not need to make any modifications to your app or your model. The operator fusion rules file matches particular patterns of operators in the graph and then fuses them into sections. Currently, we have fusion rules files defined for many different NLP models available in
sambaflow/o1_optimizations/opfusion_rules/. For details about defining your own patterns, see Operator fusion rule yaml syntax (Beta).
If you don’t specify an operator fusion rule with
Manual fusion is done by tagging PyTorch modules in your model with a Python decorator. Here’s an example:
from functools import partial from sambaflow.samba.directives import op_fusion from sambaflow.o1_optimizations.modules.opfusionheuristics import OpFusionHeuristics @partial(op_fusion, func_name="my_fused_attn_proj", heuristic=OpFusionHeuristics.SAFE_GEMM) class AttentionProjection(nn.Module): def __init__(self, args: ModelArgs): ...
func_name can be whatever you want as long as each op_fusion has a unique name. The heuristic is optional, but recommended where possible. For details, see Operator fusion heuristics.
With manual fusion, the optimal fusion groupings for runtime performance may not align with your modules as-is, and thus you may need to move some operators around to adjacent (or new) modules. A detailed guide on optimizing a model to achieve the best performance on RDU is currently in progress.
Tagging an operator fusion rule with a heuristic lets the compiler know it should use a specific strategy for optimizing this module. Our heuristics are plug-and-play and can be used to optimize any module that meets each heuristic’s constraints. The trade-off is that each heuristic can be applied only to certain types of modules; see Supported heuristics. If no heuristic is specified for a module, the compiler applies general optimizations to that portion of the compute graph, akin to what it would do in o3 mode.
Each heuristic is a full package of optimizations, including tiling, sharding, internal section-cuts, and par factors, and, if applicable, even co-optimizes both the forward sections and the autograd-generated backward sections.
The optimizations performed by o3 are done using generic algorithms that need to be able to handle any sort of graph fed into it. In contrast, operator fusion heuristics use specific algorithms that are especially well suited to particular kinds of modules. By way of analogy, think of the optimizations done by o3 as akin to buying a suit off the racks at a department store, while the optimizations done by the operator fusion heuristics are more like going to a fine tailor and being fitted for a custom suit.
Heuristics can be used with both automatic fusion rules and manual fusion rules. For an example with manual fusion rules, see Manual fusion above. For automatic fusion with a fusion rule, you can specify a
heuristic label like this:
example_pattern: heuristic: SAFE_GEMM pattern: linear_a: op_type: linear ...
Currently, 3 operator fusion heuristics are available:
SAFE_GEMM and AGGRESSIVE_GEMM. These two heuristics are variations of each other, and are both applicable only to patterns that are dominated by a single large matrix multiply (GEMM) operation. (e.g.
matmuloperators). NLP apps typically have multiple modules that fit this description, such as the QKV, the Attention Projection, or the Feed Forward Network. The difference between SAFE_GEMM and AGGRESSIVE_GEMM is that AGGRESSIVE_GEMM generally yields better performance, but may sometimes may result in a compile error.
GPT3_MHAheuristic is for any sort of Multi-Headed Attention block. (And not limited to GPT3, despite what the name might otherwise lead you to believe.) The pattern constraint is that the module have 2
softmaxbetween them. It is fine if the module also contains other operators, as long as they are not other types of matrix multiply.