Getting Started
Getting HEIR
Note: the HEIR team is working on having an end-to-end binary for certain
workflows (such as converting pre-compiled torch models to a specific backend).
In the mean time, the way to use HEIR involves the ‘kitchen sink’ binaries
heir-opt (for running compiler passes) and heir-translate (for backend
codegen).
Using bazel and rules_heir
If you use the bazel build system, you can use
rules_heir to hide many of
the details of connecting HEIR to your application.
The rules_heir GitHub repository has
examples.
First add rules_heir and the desired backend to your MODULE.bazel
# MODULE.bazel
module(name = "hello_heir", version = "0.0.0")
bazel_dep(name = "rules_heir", version = "0.1.1")
# gazelle automatically generates BUILD overlays of golang projects
bazel_dep(name = "gazelle", version = "0.47.0")
bazel_dep(name = "platforms", version = "1.1.0")
bazel_dep(name = "rules_go", version = "0.60.0")
# Go setup
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.24.2")
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
# All *direct* Go dependencies of the module have to be listed explicitly.
use_repo(
go_deps,
"com_github_tuneinsight_lattigo_v6",
)
And a go.mod file
// go.mod
module github.com/<your_username>/hello_heir
go 1.24.2
require github.com/tuneinsight/lattigo/v6 v6.1.0
require (
github.com/ALTree/bigfloat v0.0.0-20220102081255-38c8b72a9924 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Then, for an input IR that computes a dot product:
// hello_world.mlir
func.func @dot_product(%arg0: tensor<8xf32> {secret.secret}, %arg1: tensor<8xf32> {secret.secret}) -> f32 {
%c0 = arith.constant 0 : index
%c0_sf32 = arith.constant 0.0 : f32
%0 = affine.for %arg2 = 0 to 8 iter_args(%iter = %c0_sf32) -> (f32) {
%1 = tensor.extract %arg0[%arg2] : tensor<8xf32>
%2 = tensor.extract %arg1[%arg2] : tensor<8xf32>
%3 = arith.mulf %1, %2 : f32
%4 = arith.addf %iter, %3 : f32
affine.yield %4 : f32
}
return %0 : f32
}
The heir_lattigo_lib macro invokes heir-opt and heir-translate with
specified flags
# BUILD.bazel
load("@rules_go//go:def.bzl", "go_test")
load("@rules_heir//heir:lattigo.bzl", "heir_lattigo_lib")
heir_lattigo_lib(
name = "hello_world",
go_library_name = "helloworld",
heir_opt_flags = [
"--annotate-module=backend=lattigo scheme=ckks",
"--mlir-to-ckks=min-slot-count=2048 first-mod-bits=0",
"--scheme-to-lattigo",
],
mlir_src = "hello_world.mlir",
split_preprocessing = False,
)
go_test(
name = "hello_world_test",
srcs = ["hello_world_test.go"],
embed = [":helloworld"],
)
and this puts the generated artifacts in bazel-bin/ (which you can inspect to
see the generated API, more on that later in this document).
A main harness invokes the generated code:
// hello_world_test.go
package helloworld
import (
"math"
"testing"
)
func TestHelloWorld(t *testing.T) {
evaluator, params, ecd, enc, dec := Dot_product__configure()
// Vector of plaintext values
arg0 := []float32{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8}
arg1 := []float32{0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9}
expected := float32(2.40)
ct0 := Dot_product__encrypt__arg0(evaluator, params, ecd, enc, arg0)
ct1 := Dot_product__encrypt__arg1(evaluator, params, ecd, enc, arg1)
resultCt := Dot_product(evaluator, params, ecd, ct0, ct1)
result := Dot_product__decrypt__result0(evaluator, params, ecd, dec, resultCt)
errorThreshold := float64(0.0001)
if math.Abs(float64(result-expected)) > errorThreshold {
t.Errorf("Decryption error %.2f != %.2f", result, expected)
}
}
And then you can bazel test -c opt :hello_world_test to run the example.
Using a pre-built nightly binary
HEIR releases a nightly
binary. This is intended for testing compiler passes and not for production use.
Select a platform from the
list of assets, e.g.,
heir-opt-manylinux_2_28_x86_64
wget -O heir-opt https://github.com/google/heir/releases/download/nightly/heir-opt-manylinux_2_28_x86_64
chmod +x heir-opt
./heir-opt --help
Then you can run the examples below. In the rest of this document, you can
replace bazel run //tools:heir-opt -- with ./heir-opt. HEIR also publishes
heir-translate and heir-lsp in the same way.
Via pip
We publish a python package heir_py that
includes the heir-opt and heir-translate binaries and, optionally, front-
and backends.
python -m venv venv
source venv/bin/activate
pip install "heir_py[python,openfhe]"
heir-opt --help
heir-translate --help
While this gives access to the underlying binaries, if you install a supported backend (e.g., OpenFHE) on your system, you can also run an example through the Python package:
from heir import compile
from heir.mlir import I64, Secret
@compile() # defaults to scheme="bgv", OpenFHE backend, and debug=False
def func(x: Secret[I64], y: Secret[I64]):
sum = x + y
diff = x - y
mul = x * y
expression = sum * diff + mul
deadcode = expression * mul
return expression
func.setup()
enc_x = func.encrypt_x(7)
enc_y = func.encrypt_y(8)
result_enc = func.eval(enc_x, enc_y)
result = func.decrypt_result(result_enc)
print(
f"Expected result for `func`: {func.original(7,8)}, FHE result:"
f" {result}"
)
This will compile the function above using the BGV scheme to machine code via
the OpenFHE backend.
Then calling the function will encrypt the inputs, run the function, and return
the decrypted result. The function call foo(7, 8) runs the entire
encrypt-run-decrypt flow for ease of testing.
Note: we require the user install OpenFHE directly, because the Python frontend invokes a C++ compiler on the HEIR-generated code, and links against OpenFHE. The OpenFHE installation is specific to each system (e.g., for OpenFHE-specific configurations like OpenMP).
Note: Support for backends besides OpenFHE is in progress.
Building From Source
Prerequisites
Bazel manages all the other dependencies needed (such as the C++ compiler, Go compiler, Rust compiler, Python interpreter, and all dependent packages).
Detailed Instructions
You can download the latest Bazelisk release, e.g., for linux-amd64 (see the [Bazelisk Release Page](https://github.com/bazelbuild/bazelisk/releases/latest) for a list of available binaries):wget -c https://github.com/bazelbuild/bazelisk/releases/latest/download/bazelisk-linux-amd64
mv bazelisk-linux-amd64 bazel
chmod +x bazel
You will then likely want to move bazel to a location on your PATH, or add its
location to your PATH, e.g.:
mkdir -p ~/bin
echo 'export PATH=$PATH:~/bin' >> ~/.bashrc
mv bazel ~/bin/bazel
Note that on linux systems, your OS user must not be root as bazel might
refuse to work if run as root.
On macOS, you can install bazelisk via Homebrew.
Clone and build the project
You can clone and build HEIR from the terminal as described below. Please see Development for information on IDE configuration if you want to use an IDE to build HEIR.
git clone git@github.com:google/heir.git && cd heir
bazel build -c opt @heir//tools:heir-opt
Optional: Run the tests
bazel test -c opt @heir//...
Using HEIR
Run the dot-product example
The dot-product program computes the dot product of two length-8 vectors of
16-bit integers (i16 in MLIR parlance). This example will showcase the OpenFHE
backend by manually calling the relevant compiler passes and setting up a C++
harness to call into the HEIR-generated functions.
The input program is in tests/Examples/common/dot_product_8.mlir. Support for
standard input languages like C and C++ are currently experimental at best,
but eventually we would use an MLIR-based tool to convert an input language to
MLIR like in that file. The program is below:
func.func @dot_product(%arg0: tensor<8xi16> {secret.secret}, %arg1: tensor<8xi16> {secret.secret}) -> i16 {
%c0 = arith.constant 0 : index
%c0_si16 = arith.constant 0 : i16
%0 = affine.for %arg2 = 0 to 8 iter_args(%iter = %c0_si16) -> (i16) {
%1 = tensor.extract %arg0[%arg2] : tensor<8xi16>
%2 = tensor.extract %arg1[%arg2] : tensor<8xi16>
%3 = arith.muli %1, %2 : i16
%4 = arith.addi %iter, %3 : i16
affine.yield %4 : i16
}
return %0 : i16
}
For an introduction to MLIR syntax, see the official docs or this blog post.
Now we run the heir-opt command to optimize and compile the program. If you
fetched a pre-built binary instead of building from source, then all commands
below should have bazel run //tools:heir-opt -- replaced with heir-opt, and
similarly for heir-translate.
bazel run //tools:heir-opt -- \
--mlir-to-bgv='min-slot-count=8'\
--scheme-to-openfhe='entry-function=dot_product' \
$PWD/tests/Examples/common/dot_product_8.mlir > output.mlir
This produces a file in the openfhe exit dialect (part of HEIR).
!Z1005037682689_i64_ = !mod_arith.int<1005037682689 : i64>
!Z1032955396097_i64_ = !mod_arith.int<1032955396097 : i64>
!Z1095233372161_i64_ = !mod_arith.int<1095233372161 : i64>
#polynomial_evaluation_encoding = #lwe.polynomial_evaluation_encoding<cleartext_start = 16, cleartext_bitwidth = 16>
!rns_L0_ = !rns.rns<!Z1095233372161_i64_>
!rns_L1_ = !rns.rns<!Z1095233372161_i64_, !Z1032955396097_i64_>
!rns_L2_ = !rns.rns<!Z1095233372161_i64_, !Z1032955396097_i64_, !Z1005037682689_i64_>
#ring_rns_L0_1_x8_ = #polynomial.ring<coefficientType = !rns_L0_, polynomialModulus = <1 + x**8>>
#ring_rns_L1_1_x8_ = #polynomial.ring<coefficientType = !rns_L1_, polynomialModulus = <1 + x**8>>
#ring_rns_L2_1_x8_ = #polynomial.ring<coefficientType = !rns_L2_, polynomialModulus = <1 + x**8>>
!rlwe_pt_L0_ = !lwe.rlwe_plaintext<encoding = #polynomial_evaluation_encoding, ring = #ring_rns_L0_1_x8_, underlying_type = i16>
!rlwe_pt_L1_ = !lwe.rlwe_plaintext<encoding = #polynomial_evaluation_encoding, ring = #ring_rns_L1_1_x8_, underlying_type = tensor<8xi16>>
!rlwe_pt_L2_ = !lwe.rlwe_plaintext<encoding = #polynomial_evaluation_encoding, ring = #ring_rns_L2_1_x8_, underlying_type = tensor<8xi16>>
#rlwe_params_L0_ = #lwe.rlwe_params<ring = #ring_rns_L0_1_x8_>
#rlwe_params_L1_ = #lwe.rlwe_params<ring = #ring_rns_L1_1_x8_>
#rlwe_params_L2_ = #lwe.rlwe_params<ring = #ring_rns_L2_1_x8_>
#rlwe_params_L2_D3_ = #lwe.rlwe_params<dimension = 3, ring = #ring_rns_L2_1_x8_>
!rlwe_ct_L0_ = !lwe.rlwe_ciphertext<encoding = #polynomial_evaluation_encoding, rlwe_params = #rlwe_params_L0_, underlying_type = i16>
!rlwe_ct_L1_ = !lwe.rlwe_ciphertext<encoding = #polynomial_evaluation_encoding, rlwe_params = #rlwe_params_L1_, underlying_type = tensor<8xi16>>
!rlwe_ct_L1_1 = !lwe.rlwe_ciphertext<encoding = #polynomial_evaluation_encoding, rlwe_params = #rlwe_params_L1_, underlying_type = i16>
!rlwe_ct_L2_ = !lwe.rlwe_ciphertext<encoding = #polynomial_evaluation_encoding, rlwe_params = #rlwe_params_L2_, underlying_type = tensor<8xi16>>
!rlwe_ct_L2_D3_ = !lwe.rlwe_ciphertext<encoding = #polynomial_evaluation_encoding, rlwe_params = #rlwe_params_L2_D3_, underlying_type = tensor<8xi16>>
module {
func.func @dot_product(%arg0: !openfhe.crypto_context, %arg1: !rlwe_ct_L2_, %arg2: !rlwe_ct_L2_) -> !rlwe_ct_L0_ {
%cst = arith.constant dense<[0, 0, 0, 0, 0, 0, 0, 1]> : tensor<8xi64>
%0 = openfhe.mul_no_relin %arg0, %arg1, %arg2 : (!openfhe.crypto_context, !rlwe_ct_L2_, !rlwe_ct_L2_) -> !rlwe_ct_L2_D3_
%1 = openfhe.relin %arg0, %0 : (!openfhe.crypto_context, !rlwe_ct_L2_D3_) -> !rlwe_ct_L2_
%2 = openfhe.rot %arg0, %1 {static_shift = 4 : index} : (!openfhe.crypto_context, !rlwe_ct_L2_) -> !rlwe_ct_L2_
%3 = openfhe.add %arg0, %1, %2 : (!openfhe.crypto_context, !rlwe_ct_L2_, !rlwe_ct_L2_) -> !rlwe_ct_L2_
%4 = openfhe.rot %arg0, %3 {static_shift = 2 : index} : (!openfhe.crypto_context, !rlwe_ct_L2_) -> !rlwe_ct_L2_
%5 = openfhe.add %arg0, %3, %4 : (!openfhe.crypto_context, !rlwe_ct_L2_, !rlwe_ct_L2_) -> !rlwe_ct_L2_
%6 = openfhe.rot %arg0, %5 {static_shift = 1 : index} : (!openfhe.crypto_context, !rlwe_ct_L2_) -> !rlwe_ct_L2_
%7 = openfhe.add %arg0, %5, %6 : (!openfhe.crypto_context, !rlwe_ct_L2_, !rlwe_ct_L2_) -> !rlwe_ct_L2_
%8 = openfhe.mod_reduce %arg0, %7 : (!openfhe.crypto_context, !rlwe_ct_L2_) -> !rlwe_ct_L1_
%9 = openfhe.make_packed_plaintext %arg0, %cst : (!openfhe.crypto_context, tensor<8xi64>) -> !rlwe_pt_L1_
%10 = openfhe.mul_plain %arg0, %8, %9 : (!openfhe.crypto_context, !rlwe_ct_L1_, !rlwe_pt_L1_) -> !rlwe_ct_L1_
%11 = openfhe.rot %arg0, %10 {static_shift = 7 : index} : (!openfhe.crypto_context, !rlwe_ct_L1_) -> !rlwe_ct_L1_
%12 = openfhe.mod_reduce %arg0, %11 : (!openfhe.crypto_context, !rlwe_ct_L1_1) -> !rlwe_ct_L0_
return %12 : !rlwe_ct_L0_
}
func.func @dot_product__encrypt__arg0(%arg0: !openfhe.crypto_context, %arg1: tensor<8xi16>, %arg2: !openfhe.public_key) -> !rlwe_ct_L2_ {
...
}
func.func @dot_product__encrypt__arg1(%arg0: !openfhe.crypto_context, %arg1: tensor<8xi16>, %arg2: !openfhe.public_key) -> !rlwe_ct_L2_ {
...
}
func.func @dot_product__decrypt__result0(%arg0: !openfhe.crypto_context, %arg1: !rlwe_ct_L0_, %arg2: !openfhe.private_key) -> i16 {
...
}
func.func @dot_product__generate_crypto_context() -> !openfhe.crypto_context {
...
}
func.func @dot_product__configure_crypto_context(%arg0: !openfhe.crypto_context, %arg1: !openfhe.private_key) -> !openfhe.crypto_context {
...
}
}
Next, we use the heir-translate tool to run code generation for the OpenFHE
pke API.
bazel run //tools:heir-translate -- --emit-openfhe-pke-header --openfhe-include-type=source-relative $PWD/output.mlir > heir_output.h
bazel run //tools:heir-translate -- --emit-openfhe-pke --openfhe-include-type=source-relative $PWD/output.mlir > heir_output.cpp
The openfhe-include-type indicates which include path for OpenFHE is used. It
has three possible values: install-relative, source-relative and embedded.
In this example we use source-relative as we are compiling against an
(unoptimized) OpenFHE managed by bazel in HEIR source. To compile against an
installed (and possibly optimized) OpenFHE, you could use install-relative and
compile it on your own. Or you could just put the generated file in OpenFHE
source directory src/pke/examples and let OpenFHE find and compile it for you
with the embedded option.
The results:
// heir_output.h
#include "src/pke/include/openfhe.h" // from @openfhe
using namespace lbcrypto;
using CiphertextT = ConstCiphertext<DCRTPoly>;
using CCParamsT = CCParams<CryptoContextBGVRNS>;
using CryptoContextT = CryptoContext<DCRTPoly>;
using EvalKeyT = EvalKey<DCRTPoly>;
using PlaintextT = Plaintext;
using PrivateKeyT = PrivateKey<DCRTPoly>;
using PublicKeyT = PublicKey<DCRTPoly>;
CiphertextT dot_product(CryptoContextT v0, CiphertextT v1, CiphertextT v2);
CiphertextT dot_product__encrypt__arg0(CryptoContextT v18, std::vector<int16_t> v19, PublicKeyT v20);
CiphertextT dot_product__encrypt__arg1(CryptoContextT v24, std::vector<int16_t> v25, PublicKeyT v26);
int16_t dot_product__decrypt__result0(CryptoContextT v30, CiphertextT v31, PrivateKeyT v32);
CryptoContextT dot_product__generate_crypto_context();
CryptoContextT dot_product__configure_crypto_context(CryptoContextT v37, PrivateKeyT v38);
// heir_output.cpp
#include "src/pke/include/openfhe.h" // from @openfhe
using namespace lbcrypto;
using CiphertextT = ConstCiphertext<DCRTPoly>;
using CryptoContextT = CryptoContext<DCRTPoly>;
using EvalKeyT = EvalKey<DCRTPoly>;
using PlaintextT = Plaintext;
using PrivateKeyT = PrivateKey<DCRTPoly>;
using PublicKeyT = PublicKey<DCRTPoly>;
CiphertextT dot_product(CryptoContextT v0, CiphertextT v1, CiphertextT v2) {
std::vector<int64_t> v3 = {0, 0, 0, 0, 0, 0, 0, 1};
const auto& v4 = v0->EvalMultNoRelin(v1, v2);
const auto& v5 = v0->Relinearize(v4);
const auto& v6 = v0->EvalRotate(v5, 4);
const auto& v7 = v0->EvalAdd(v5, v6);
const auto& v8 = v0->EvalRotate(v7, 2);
const auto& v9 = v0->EvalAdd(v7, v8);
const auto& v10 = v0->EvalRotate(v9, 1);
const auto& v11 = v0->EvalAdd(v9, v10);
const auto& v12 = v0->ModReduce(v11);
auto v3_filled_n = v0->GetCryptoParameters()->GetElementParams()->GetRingDimension() / 2;
auto v3_filled = v3;
v3_filled.clear();
v3_filled.reserve(v3_filled_n);
for (auto i = 0; i < v3_filled_n; ++i) {
v3_filled.push_back(v3[i % v3.size()]);
}
const auto& v13 = v0->MakePackedPlaintext(v3_filled);
const auto& v14 = v0->EvalMult(v12, v13);
const auto& v15 = v0->EvalRotate(v14, 7);
const auto& v16 = v15;
const auto& v17 = v0->ModReduce(v16);
return v17;
}
CiphertextT dot_product__encrypt__arg0(CryptoContextT v24, std::vector<int16_t> v25, PublicKeyT v26) {
...
}
CiphertextT dot_product__encrypt__arg1(CryptoContextT v29, std::vector<int16_t> v30, PublicKeyT v31) {
...
}
int16_t dot_product__decrypt__result0(CryptoContextT v34, CiphertextT v35, PrivateKeyT v36) {
...
}
CryptoContextT dot_product__generate_crypto_context() {
...
}
CryptoContextT dot_product__configure_crypto_context(CryptoContextT v37, PrivateKeyT v38) {
...
}
At this point we can compile the program as we would a normal OpenFHE program. Note that the above two files just contain the compiled function and encryption/decryption helpers, and does not include any code that provides specific inputs or calls these functions.
Next we’ll create a harness that provides sample inputs, encrypts them, runs the compiled function, and decrypts the result. Once you have the generated header and cpp files, you can do this with any build system. We will use bazel for consistency.
Create a file called BUILD in the same directory as the header and cpp files
above, with the following contents:
# A library build target that encapsulates the HEIR-generated code.
cc_library(
name = "dot_product_codegen",
srcs = ["heir_output.cpp"],
hdrs = ["heir_output.h"],
deps = ["@openfhe//:pke"],
)
# An executable build target that contains your main function and links
# against the above.
cc_binary(
name = "dot_product_main",
srcs = ["dot_product_main.cpp"],
deps = [
":dot_product_codegen",
"@openfhe//:pke",
"@openfhe//:core",
],
)
Where dot_product_main.cpp is a new file containing
#include <cstdint>
#include <vector>
#include "src/pke/include/openfhe.h" // from @openfhe
#include "heir_output.h"
int main(int argc, char *argv[]) {
CryptoContext<DCRTPoly> cryptoContext = dot_product__generate_crypto_context();
KeyPair<DCRTPoly> keyPair;
keyPair = cryptoContext->KeyGen();
cryptoContext = dot_product__configure_crypto_context(cryptoContext, keyPair.secretKey);
std::vector<int16_t> arg0 = {1, 2, 3, 4, 5, 6, 7, 8};
std::vector<int16_t> arg1 = {2, 3, 4, 5, 6, 7, 8, 9};
int64_t expected = 240;
auto arg0Encrypted =
dot_product__encrypt__arg0(cryptoContext, arg0, keyPair.publicKey);
auto arg1Encrypted =
dot_product__encrypt__arg1(cryptoContext, arg1, keyPair.publicKey);
auto outputEncrypted =
dot_product(cryptoContext, arg0Encrypted, arg1Encrypted);
auto actual = dot_product__decrypt__result0(cryptoContext, outputEncrypted,
keyPair.secretKey);
std::cout << "Expected: " << expected << "\n";
std::cout << "Actual: " << actual << "\n";
return 0;
}
Then run and show the results:
$ bazel run dot_product_main
Expected: 240
Actual: 240
If you fetched a pre-built binary instead of building from source, then you will
have to use your build system of choice to compile the generated files. If you
use heir_py’s heir.compile decorator with debug=True, then the compilation
commands will be printed to stdout so you can see how to compile the generated
code manually.
Optional: Run a custom heir-opt pipeline
HEIR comes with two central binaries, heir-opt for running optimization passes
and dialect conversions, and heir-translate for backend code generation. To
see the list of available passes in each one, run the binary with --help:
bazel run //tools:heir-opt -- --help
bazel run //tools:heir-translate -- --help
Once you’ve chosen a pass or --pass-pipeline to run, execute it on the desired
file. For example, you can run a test file through heir-opt to see its output.
Note that when the binary is run via bazel, you must pass absolute paths to
input files. You can also access the underlying binary at
bazel-bin/tools/heir-opt, provided it has already been built.
bazel run //tools:heir-opt -- \
--secret-to-cggi -cse \
$PWD/tests/Dialect/Secret/Conversions/secret_to_cggi/add_one.mlir
To convert an existing lit test to a bazel run command for manual tweaking and
introspection (e.g., adding --debug or --mlir-print-ir-after-all to see how
he IR changes with each pass), use python scripts/lit_to_bazel.py.
# after pip install -r requirements.txt
python scripts/lit_to_bazel.py tests/simd/box_blur_64x64.mlir
Which outputs
bazel run --noallow_analysis_cache_discard //tools:heir-opt -- \
--secretize --wrap-generic --canonicalize --cse --full-loop-unroll \
--insert-rotate --cse --canonicalize --collapse-insertion-chains \
--canonicalize --cse /path/to/heir/tests/simd/box_blur_64x64.mlir
Optional: Graphviz visualization of the IR
Getting a visualization of the IR during optimization/transformation might help you understand what is going on more easily.
Still taking the dot_product_8.mlir as an example:
bazel run --ui_event_filters=-info,-debug,-warning,-stderr,-stdout --noshow_progress --logging=0 //tools:heir-opt -- --wrap-generic --heco-simd-vectorizer $PWD/tests/Examples/common/dot_product_8.mlir --view-op-graph 2> dot_product_8.dot
dot -Tpdf dot_product_8.dot > dot_product_8.pdf
# open pdf in your favorite pdf viewer
The diagram is also shown below. It demonstrates that the HEIR SIMD vectorizer
would vectorize the dot-product program (tensor<8xi16>) then use
rotate-and-reduce technique to compute the sum.