Concrete supports native Python and NumPy operations as much as possible, but not everything in Python or NumPy is available. Therefore, we provide some extensions ourselves to improve your experience.
Allows you to wrap any univariate function into a single table lookup:
The wrapped function:
shouldn't have any side effects (e.g., no modification of global state)
should be deterministic (e.g., no random numbers)
should have the same output shape as its input (i.e., output.shape
should be the same with input.shape
)
each output element should correspond to a single input element (e.g., output[0]
should only depend on input[0]
)
If any of these constraints are violated, the outcome is undefined.
Allows you to wrap any multivariate function into a table lookup:
The wrapped function:
shouldn't have any side effects (e.g., no modification of global state)
should be deterministic (e.g., no random numbers)
should have input shapes which are broadcastable to the output shape (i.e., input.shape
should be broadcastable to output.shape
for all inputs)
each output element should correspond to a single input element (e.g., output[0]
should only depend on input[0]
of all inputs)
If any of these constraints are violated, the outcome is undefined.
Multivariate functions cannot be called with rounded inputs.
Allows you to perform a convolution operation, with the same semantic as onnx.Conv:
Only 2D convolutions without padding and with one group are currently supported.
Allows you to perform a maxpool operation, with the same semantic as onnx.MaxPool:
Only 2D maxpooling without padding and up to 15-bits is currently supported.
Allows you to create encrypted arrays:
Currently, only scalars can be used to create arrays.
Allows you to create an encrypted scalar zero:
Allows you to create an encrypted tensor of zeros:
Allows you to create an encrypted scalar one:
Allows you to create an encrypted tensor of ones:
Allows you to hint properties of a value. Imagine you have this circuit:
You'd expect all of a
, b
, and c
to be 8-bits, but because inputset is very small, this code could print:
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:
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.
you'll always see:
regardless of the bounds.
Alternatively, you can use it to make sure a value can store certain integers:
Allows you to perform ReLU operation, with the same semantic as x if x >= 0 else 0
:
ReLU extension can be converted in two different ways:
With a single TLU on the original bit-width.
With multiple TLUs on smaller bit-widths.
For small bit-widths, the first one is better as it'll have a single TLU on a small bit-width. For big bit-widths, the second one is better as it won't have a TLU on a big bit-width.
The decision between the two can be controlled with relu_on_bits_threshold: int = 7
configuration option:
relu_on_bits_threshold=5
means:
1-bit to 4-bits would be converted using the first way (i.e., using TLU)
5-bits and more would be converted using the second way (i.e., using bits)
There is another option to customize the implementation relu_on_bits_chunk_size: int = 2
:
relu_on_bits_chunk_size=4
means:
When using the second implementation:
The input would be split to 4-bit chunks using fhe.bits, and then the ReLU would be applied to those chunks, which are then combined back.
Here is a script showing how execution cost is impacted when changing these values:
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 (i.e., 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.
Allows you to perform ternary if operation, with the same semantic as x if condition else y
:
fhe.if_then_else
is just an alias for np.where.
Allows you to copy the value:
Identity extension can be used to clone an input while changing its bit-width. Imagine you have return x**2, x+100
where x
is 2-bits. Because of x+100
, x
will be assigned 7-bits and x**2
would be more expensive than it needs to be. If return x**2, fhe.identity(x)+100
is used instead, x
will be assigned 2-bits as it should and fhe.identity(x)
will be assigned 7-bits as necessary.
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.
Used for creating a random inputset with the given specifications:
The result will have 100 inputs by default which can be customized using the size keyword argument:
As explained in the , the challenge for developers is to adapt their code to fit FHE constraints. In this document we have collected some common examples to illustrate the kind of optimization one can do to get better performance.
All code snippets provided here are temporary workarounds. In future versions of Concrete, some functions described here could be directly available in a more generic and efficient form. These code snippets are coming from support answers in our
In this first example, we compute a minimum by creating the difference between two numbers y
and x
and conditionally remove this diff from y
to either get x
if y>x
or y
if x>y
:
The companion example of above with the maximum value of two integers instead of the minimum:
And an extension for more than two values:
This example shows how to deal with an array and an encrypted index. It will create a "selection" array filled with 0
except for the requested index that will be 1
, and sum the products of all array values by this selection array:
This example filters an encrypted array with an encrypted condition, here a greater than
with an encrypted value. It packs all values with a selection bit, resulting from the comparison that allow the unpacking of only the filtered values:
In this example Matrix operation, we are introducing a key concept when using Concrete: trying to maximize the parallelization. Here instead of sequentially summing all values to create a mean value, we split the values in sub-groups, and do the mean of the sub-group means:
Some applications require directly manipulating bits of integers. Concrete provides a bit extraction operation for such applications.
Bit extraction is capable of extracting a slice of bits from an integer. Index 0 corresponds to the lowest significant bit. The cost of this operation is proportional to the highest significant bit index.
Bit extraction only works in the Native
encoding, which is usually selected when all table lookups in the circuit are less than or equal to 8 bits.
Slices can be used for indexing fhe.bits(value)
as well.
Even slices with negative steps are supported!
Signed integers are supported as well.
Lastly, here is a practical use case of bit extraction.
prints
Bits cannot be extracted using a negative index.
Which means fhe.bits(x)[-1]
or fhe.bits(x)[-4:-1]
is not supported for example.
The reason for this is that we don't know in advance (i.e., before inputset evaluation) how many bits x
has.
For example, let's say you have x == 10 == 0b_000...0001010
, and you want to do fhe.bits(x)[-1]
. If the value is 4-bits (i.e., 0b_1010
), the result needs to be 1
, but if it's 6-bits (i.e., 0b_001010
), the result needs to be 0
. Since we don't know the bit-width of x
before inputset evaluation, we cannot calculate fhe.bits(x)[-1]
.
When extracting bits using slices in reverse order (i.e., step < 0), the start bit needs to be provided explicitly.
Which means fhe.bits(x)[::-1]
or fhe.bits(x)[:2:-1]
is not supported for example.
The reason is the same as above.
When extracting bits of signed values using slices, the stop bit needs to be provided explicitly.
Which means fhe.bits(x)[1:]
or fhe.bits(x)[1::2]
is not supported for example.
The reason is similar to above.
Bits of floats cannot be extracted.
Floats are partially supported but extracting their bits is not supported at all.
Key Concept: Extracting a specific bit requires clearing all the preceding lower bits. This involves extracting these previous bits as intermediate values and then subtracting them from the input.
Implications:
Bits are extracted sequentially, starting from the least significant bit to the more significant ones. The cost is proportional to the index of the highest extracted bit plus one.
No parallelization is possible. The computation time is proportional to the cost, independent of the number of CPUs.
Examples:
Extracting fhe.bits(x)[4]
is approximately five times costlier than extracting fhe.bits(x)[0]
.
Extracting fhe.bits(x)[4]
takes around five times more wall clock time than fhe.bits(x)[0]
.
The cost of extracting fhe.bits(x)[0:5]
is almost the same as that of fhe.bits(x)[5]
.
Key Concept: Common sub-expression elimination is applied to intermediate extracted bits.
Implications:
The overall cost for a series of fhe.bits(x)[m:n]
calls on the same input x
is almost equivalent to the cost of the single most computationally expensive extraction in the series, i.e. fhe.bits(x)[n]
.
The order of extraction in that series does not affect the overall cost.
Example:
The combined operation fhe.bit(x)[3] + fhe.bit(x)[2] + fhe.bit(x)[1]
has almost the same cost as fhe.bits(x)[3]
.
Each extracted bit incurs a cost of approximately one TLU of 1-bit input precision. Therefore, fhe.bits(x)[0]
is generally faster than any other TLU operation.
To explain a bit more, signed integers use representation. In this representation, negative values have their most significant bits set to 1 (e.g., -1 == 0b_11111
, -2 == 0b_11110
, -3 == 0b_11101
). Extracting bits always returns a positive value (e.g., fhe.bits(-1)[1:3] == 0b_11 == 3
) This means if you were to do fhe.bits(x)[1:]
where x == -1
, if x
is 4 bits, the result would be 0b_111 == 7
, but if x
is 5 bits the result would be 0b_1111 == 15
. Since we don't know the bit-width of x
before inputset evaluation, we cannot calculate fhe.bits(x)[1:]
.