This document introduces some extensions of Concrete, including functions for wrapping univariate and multivariate functions, performing convolution and maxpool operations, creating encrypted arrays, and more.
fhe.univariate(function)
Wraps any univariate function into a single table lookup:
import numpy as npfrom concrete import fhedefcomplex_univariate_function(x):defper_element(element): result =0for i inrange(element): result += ireturn resultreturn np.vectorize(per_element)(x)@fhe.compiler({"x": "encrypted"})deff(x):return fhe.univariate(complex_univariate_function)(x)inputset = [np.random.randint(0, 5, size=(3, 2))for _ inrange(10)]circuit = f.compile(inputset)sample = np.array([ [0, 4], [2, 1], [3, 0],])assert np.array_equal(circuit.encrypt_run_decrypt(sample), complex_univariate_function(sample))
The wrapped function must follow these criteria:
No side effects: For example, no modification of global state
Deterministic: For example, no random number generation.
Shape consistency: output.shape should be the same with input.shape
Element-wise mapping: Each output element must correspond to a single input element, for example. output[0] should only depend on input[0] of all inputs.
Violating these constraints may result in undefined outcome.
fhe.multivariate(function)
Wraps any multivariate function into a table lookup:
No side effects: For example, avoid modifying global state.
Deterministic: For example, no random number generation.
Broadcastable shapes: input.shape should be broadcastable to output.shape for all inputs.
Element-wise mapping: Each output element must correspond to a single input element, for example, output[0] should only depend on input[0] of all inputs.
Violating these constraints may result in undefined outcome.
Multivariate functions cannot be called with rounded inputs.
fhe.conv(...)
Perform a convolution operation, with the same semantic as onnx.Conv:
Currently, only scalars can be used to create arrays.
fhe.zero()
Create an encrypted scalar zero:
from concrete import fheimport numpy as np@fhe.compiler({"x": "encrypted"})deff(x): z = fhe.zero()return x + zinputset =range(10)circuit = f.compile(inputset)for x inrange(10):assert circuit.encrypt_run_decrypt(x)== x
fhe.zeros(shape)
Create an encrypted tensor of zeros:
from concrete import fheimport numpy as np@fhe.compiler({"x": "encrypted"})deff(x): z = fhe.zeros((2, 3))return x + zinputset =range(10)circuit = f.compile(inputset)for x inrange(10):assert np.array_equal(circuit.encrypt_run_decrypt(x), np.array([[x, x, x], [x, x, x]]))
fhe.one()
Create an encrypted scalar one:
from concrete import fheimport numpy as np@fhe.compiler({"x": "encrypted"})deff(x): z = fhe.one()return x + zinputset =range(10)circuit = f.compile(inputset)for x inrange(10):assert circuit.encrypt_run_decrypt(x)== x +1
fhe.ones(shape)
Create an encrypted tensor of ones:
from concrete import fheimport numpy as np@fhe.compiler({"x": "encrypted"})deff(x): z = fhe.ones((2, 3))return x + zinputset =range(10)circuit = f.compile(inputset)for x inrange(10):assert np.array_equal(circuit.encrypt_run_decrypt(x), np.array([[x, x, x], [x, x, x]]) +1)
fhe.constant(value)
Allows you to create an encrypted constant of a given value.
from concrete import fheimport numpy as np@fhe.compiler({"x": "encrypted", "a":"clear"})deff(x,a): z = fhe.constant(a)return x + zinputset =range(10)circuit = f.compile(inputset)for x inrange(10):assert circuit.encrypt_run_decrypt(x, 5)== x +5
This extension is also compatible with constant arrays.
fhe.hint(value, **kwargs)
Hint properties of a value. Imagine you have this circuit:
from concrete import fheimport numpy as np@fhe.compiler({"x": "encrypted"})deff(x,y,z): a = x | y b = y & z c = a ^ breturn cinputset = [ (np.random.randint(0, 2**8), np.random.randint(0, 2**8), np.random.randint(0, 2**8))for _ inrange(3)]circuit = f.compile(inputset)print(circuit)
You'd expect all of a, b, and c to be 8-bits, but because inputset is very small, this code could print:
%0 = x # EncryptedScalar<uint8> ∈ [173, 240]
%1 = y # EncryptedScalar<uint8> ∈ [52, 219]
%2 = z # EncryptedScalar<uint8> ∈ [36, 252]
%3 = bitwise_or(%0, %1) # EncryptedScalar<uint8> ∈ [243, 255]
%4 = bitwise_and(%1, %2) # EncryptedScalar<uint7> ∈ [0, 112]
^^^^^ this can lead to bugs
%5 = bitwise_xor(%3, %4) # EncryptedScalar<uint8> ∈ [131, 255]
return %5
The first solution in these cases should be to use a bigger inputset, but it can still be tricky to solve with the inputset. That's where the hint extension comes into play. Hints are a way to provide extra information to compilation process:
Bit-width hints are for constraining the minimum number of bits in the encoded value. If you hint a value to be 8-bits, it means it should be at least uint8 or int8.
To fix f using hints, you can do:
@fhe.compiler({"x": "encrypted", "y": "encrypted", "z": "encrypted"})deff(x,y,z):# hint that inputs should be considered at least 8-bits x = fhe.hint(x, bit_width=8) y = fhe.hint(y, bit_width=8) z = fhe.hint(z, bit_width=8)# hint that intermediates should be considered at least 8-bits a = fhe.hint(x | y, bit_width=8) b = fhe.hint(y & z, bit_width=8) c = fhe.hint(a ^ b, bit_width=8)return c
Hints are only applied to the value being hinted, and no other value. If you want the hint to be applied to multiple values, you need to hint all of them.
Alternatively, you can use it to make sure a value can store certain integers:
@fhe.compiler({"x": "encrypted", "y": "encrypted"})defis_vectors_same(x,y):assert x.ndim !=1assert y.ndim !=1assertlen(x)==len(y) n =len(x) number_of_same_elements = np.sum(x == y) fhe.hint(number_of_same_elements, can_store=n)# hint that number of same elements can go up to n is_same = number_of_same_elements == nreturn is_same
fhe.relu(value)
Perform ReLU operation, with the same semantic as x if x >= 0 else 0:
The ReLU operation can be implemented in two ways:
Single TLU (Table Lookup Unit) on the original bit-width: Suitable for small bit-widths, as it requires fewer resources.
Multiple TLUs on smaller bit-widths: Better for large bit-widths, avoiding the high cost of a single large TLU.
Configuration options
The method of conversion is controlled by the relu_on_bits_threshold: int = 7 option. For example, setting relu_on_bits_threshold=5 means:
Bit-widths from 1 to 4 will use a single TLU.
Bit-widths of 5 and above will use multiple TLUs.
Another option to fine-tune the implementation is relu_on_bits_chunk_size: int = 2. For example, setting relu_on_bits_chunk_size=4 means that when using second implementation (using chunks), the input is split to 4-bit chunks using fhe.bits, and then the ReLU is applied to those chunks, which are then combined back.
Here is a script showing how execution cost is impacted when changing these values:
from concrete import fheimport numpy as npimport matplotlib.pyplot as pltchunk_sizes = np.array(range(1, 6), dtype=int)bit_widths = np.array(range(5, 17), dtype=int)data = []for bit_width in bit_widths: title =f"{bit_width=}:"print(title)print("-"*len(title)) inputset =range(-2**(bit_width-1), 2**(bit_width-1)) configuration = fhe.Configuration(relu_on_bits_threshold=17) compiler = fhe.Compiler(lambdax: fhe.relu((fhe.relu(x) - (2**(bit_width-2))) *2), {"x": "encrypted"}) circuit = compiler.compile(inputset, configuration)print(f" Complexity: {circuit.complexity} # tlu") data.append((bit_width, 0, circuit.complexity))for chunk_size in chunk_sizes: configuration = fhe.Configuration( relu_on_bits_threshold=1, relu_on_bits_chunk_size=int(chunk_size), ) circuit = compiler.compile(inputset, configuration)print(f" Complexity: {circuit.complexity} # {chunk_size=}") data.append((bit_width, chunk_size, circuit.complexity))print()data = np.array(data)plt.title(f"ReLU using TLU vs using bits")plt.xlabel("Input/Output precision")plt.ylabel("Cost")for i, chunk_size inenumerate([0] +list(chunk_sizes)): costs = [ costfor _, candidate_chunk_size, cost in dataif candidate_chunk_size == chunk_size ]assertlen(costs)==len(bit_widths) label ="Single TLU"if i ==0elsef"Bits extract + multiples {chunk_size +1} bits TLUs" width_bar =0.8/ (len(chunk_sizes)+1)if i ==0: plt.hlines( costs, bit_widths -0.45, bit_widths +0.45, label=label, linestyle="--", )else: plt.bar( np.array(bit_widths) + width_bar * (i - (len(chunk_sizes) +1) /2), height=costs, width=width_bar, label=label, )plt.xticks(bit_widths)plt.legend(loc="upper left")plt.show()
You might need to run the script twice to avoid crashing when plotting.
The script will show the following figure:
The default values of these options are set based on simple circuits. How they affect performance will depend on the circuit, so play around with them to get the most out of this extension.
Conversion with the second method (using chunks) only works in Native encoding, which is usually selected when all table lookups in the circuit are below or equal to 8 bits.
fhe.if_then_else(condition, x, y)
Perform ternary if operation, with the same semantic as x if condition else y:
The fhe.identity extension is useful for cloning an input with a different bit-width.
Identity extension only works in Native encoding, which is usually selected when all table lookups in the circuit are below or equal to 8 bits.
fhe.refresh(value)
It is similar to fhe.identity but with the extra guarantee that encryption noise is refreshed.
Refresh is useful when you want to control precisely where encryption noise is refreshed in your circuit. For instance if your are using modules, sometimes compilation rejects the module because it's not composable. This happens because a function of the module never refresh the encryption noise. Adding a return fhe.refresh(result) on the function result solves the issue.
Refresh extension only works in Native encoding, which is usually selected when all table lookups in the circuit are below or equal to 8 bits.
fhe.inputset(...)
Create a random inputset with the given specifications: