Getting Started

Getting HEIR

Using a pre-built nightly binary

HEIR releases a nightly binary for Linux x86-64. This is intended for testing compiler passes and not for production use.

wget https://github.com/google/heir/releases/download/nightly/heir-opt
chmod +x heir-opt
./heir-opt --help

Then you can run the examples below, replacing bazel run //tools:heir-opt -- with ./heir-opt. HEIR also publishes heir-translate and heir-lsp in the same way.

Running the nightly binary from a notebook

We publish an ipython extension heir-play that can be used in Jupyter or Colab notebooks.

%pip install heir-play
%load_ext heir_play

This will download the nightly release binaries to the system the notebook server is running on, then:

%%heir_opt --flag1 --flag2

# MLIR code here

Runs heir-opt with the given command line flags on the MLIR code in the cell.

A cell magic is also available for heir-translate as %%heir_translate.

Building From Source

Prerequisites

  • Git
  • A C++ compiler and linker (clang and lld are recommended).
  • Bazel via bazelisk, or version >=5.5
  • See Development for additional prerequisites for active development
Detailed Instructions The first two requirements are frequently pre-installed or can be installed via the system package manager. For example, on Ubuntu, these can be installed with
sudo apt-get update && sudo apt-get install clang lld

You can download the latest Bazelisk release, e.g., for linux-amd64 (see the Bazelisk Release Page 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 ~/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.

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 @heir//tools:heir-opt

Some passes in this repository require Yosys as a dependency (--yosys-optimizer). If you would like to skip Yosys and ABC compilation to speed up builds, use the following build setting:

bazel build --//:enable_yosys=0 @heir//tools:heir-opt

Optional: Run the tests

bazel test @heir//...

Like above, run the following to skip tests that depend on Yosys:

bazel test --//:enable_yosys=0 --test_tag_filters=-yosys @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.

Note: other backends are similar, but the different backends are in varying stages of development.

The input program is in tests/Examples/openfhe/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.

bazel run //tools:heir-opt -- \
--mlir-to-openfhe-bgv='entry-function=dot_product ciphertext-degree=8' \
$PWD/tests/Examples/openfhe/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 {index = 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 {index = 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 {index = 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 {index = 7 : index} : (!openfhe.crypto_context, !rlwe_ct_L1_) -> !rlwe_ct_L1_
    %12 = lwe.reinterpret_underlying_type %11 : !rlwe_ct_L1_ to !rlwe_ct_L1_1
    %13 = openfhe.mod_reduce %arg0, %12 : (!openfhe.crypto_context, !rlwe_ct_L1_1) -> !rlwe_ct_L0_
    return %13 : !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 $PWD/output.mlir > heir_output.h
bazel run //tools:heir-translate -- --emit-openfhe-pke $PWD/output.mlir > heir_output.cpp

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

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 installing requirements-dev.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