diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index df1cb36309..7c78b5d09e 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -22,8 +22,7 @@ ## Compilation -* [Composition](compilation/composition.md) -* [Modules](compilation/modules.md) +* [Composing functions with modules](compilation/composing_functions_with_modules.md) * [Compression](compilation/compression.md) * [Reuse arguments](compilation/reuse_arguments.md) * [Multi precision](compilation/multi_precision.md) diff --git a/docs/compilation/composing_functions_with_modules.md b/docs/compilation/composing_functions_with_modules.md new file mode 100644 index 0000000000..739eddcdf1 --- /dev/null +++ b/docs/compilation/composing_functions_with_modules.md @@ -0,0 +1,285 @@ +# Composing functions with modules + +In various cases, deploying a server that contains many compatible functions is important. `concrete-python` is now able to compile FHE _modules_, which can contain as many functions as needed. More importantly, modules support _composition_ of the different functions. This means the encrypted result of one function execution can be used as input of a different function, without needing to decrypt in between. A module is [deployed in a single artifact](../guides/deploy.md#deployment-of-modules), making as simple to use a single function project. + +Here is a first simple example: +```python +from concrete import fhe + +@fhe.module() +class Counter: + @fhe.function({"x": "encrypted"}) + def inc(x): + return x + 1 % 20 + + @fhe.function({"x": "encrypted"}) + def dec(x): + return x - 1 % 20 +``` + +You can compile the FHE module `Counter` using the `compile` method. To do that, you need to provide a dictionnary of input sets for every function: + +```python +inputset = list(range(20)) +CounterFhe = CounterFhe.compile({"inc": inputset, "dec": inputset}) +``` + +After the module has been compiled, we can encrypt and call the different functions in the following way: + +```python +x = 5 +x_enc = CounterFhe.inc.encrypt(x) +x_inc_enc = CounterFhe.inc.run(x_enc) +x_inc = CounterFhe.inc.decrypt(x_inc_enc) +assert x_inc == 6 + +x_inc_dec_enc = CounterFhe.dec.run(x_inc_enc) +x_inc_dec = CounterFhe.dec.decrypt(x_inc_dec_enc) +assert x_inc_dec == 5 + +for _ in range(10): + x_enc = CounterFhe.inc.run(x_enc) +x_dec = CounterFhe.inc.decrypt(x_enc) +assert x_dec == 15 +``` + +## Multi inputs, multi outputs + +Composition is not limited to single input / single output. Here is an example that computes the 10 first elements of the Fibonacci sequence in FHE: + +```python +from concrete import fhe + +def noise_reset(x): + return fhe.univariate(lambda x: x)(x) + +@fhe.module() +class Fibonacci: + + @fhe.function({"n1th": "encrypted", "nth": "encrypted"}) + def fib(n1th, nth): + return noise_reset(nth), noise_reset(n1th + nth) + +print("Compiling `Fibonacci` module ...") +inputset = list(zip(range(0, 100), range(0, 100))) +FibonacciFhe = Fibonacci.compile({"fib": inputset}) + +print("Generating keyset ...") +FibonacciFhe.keygen() + +print("Encrypting initial values") +n1th = 1 +nth = 2 +(n1th_enc, nth_enc) = FibonacciFhe.fib.encrypt(n1th, nth) + +print(f"| || (n-1)-th | n-th |") +print(f"| iteration || decrypted | cleartext | decrypted | cleartext |") +for i in range(10): + (n1th_enc, nth_enc) = FibonacciFhe.fib.run(n1th_enc, nth_enc) + (n1th, nth) = Fibonacci.fib(n1th, nth) + + # For demo purpose; no decryption is needed. + (n1th_dec, nth_dec) = FibonacciFhe.fib.decrypt(n1th_enc, nth_enc) + print(f"| {i} || {n1th_dec:<9} | {n1th:<9} | {nth_dec:<9} | {nth:<9} |") +``` + +Executing this script will provide the following output: + +```shell +Compiling `Fibonacci` module ... +Generating keyset ... +Encrypting initial values +| || (n-1)-th | n-th | +| iteration || decrypted | cleartext | decrypted | cleartext | +| 0 || 2 | 2 | 3 | 3 | +| 1 || 3 | 3 | 5 | 5 | +| 2 || 5 | 5 | 8 | 8 | +| 3 || 8 | 8 | 13 | 13 | +| 4 || 13 | 13 | 21 | 21 | +| 5 || 21 | 21 | 34 | 34 | +| 6 || 34 | 34 | 55 | 55 | +| 7 || 55 | 55 | 89 | 89 | +| 8 || 89 | 89 | 144 | 144 | +| 9 || 144 | 144 | 233 | 233 | +``` + +## Iteration support + +With the previous example we see that to some extent, modules allows to support iteration with cleartext iterands. That is, loops with the following shape : + +```python +for i in some_cleartext_constant_range: + # Do something in FHE in the loop body, implemented as an FHE function. +``` + +With this pattern, we can also support unbounded loops or complex dynamic condition, as long as this condition is computed in pure cleartext python. Here is an example that computes the [Collatz sequence](https://en.wikipedia.org/wiki/Collatz_conjecture): + +```python +from concrete import fhe + +@fhe.module() +class Collatz: + + @fhe.function({"x": "encrypted"}) + def collatz(x): + + y = x // 2 + z = 3 * x + 1 + + is_x_odd = fhe.bits(x)[0] + + # In a fast way, compute ans = is_x_odd * (z - y) + y + ans = fhe.multivariate(lambda b, x: b * x)(is_x_odd, z - y) + y + + is_one = ans == 1 + + return ans, is_one + + +print("Compiling `Collatz` module ...") +inputset = [i for i in range(63)] +CollatzFhe = collatz.compile({"collatz": inputset}) + +print("Generating keyset ...") +CollatzFhe.keygen() + +print("Encrypting initial value") +x = 19 +x_enc = CollatzFhe.collatz.encrypt(x) +is_one_enc = None + +print(f"| decrypted | cleartext |") +while is_one_enc is None or not CollatzFhe.collatz.decrypt(is_one_enc): + x_enc, is_one_enc = CollatzFhe.collatz.run(x_enc) + x, is_one = Collatz.collatz(x) + + # For demo purpose; no decryption is needed. + x_dec = CollatzFhe.collatz.decrypt(x_enc) + print(f"| {x_dec:<9} | {x:<9} |") +``` + +Which prints: + +```shell +Compiling `Collatz` module ... +Generating keyset ... +Encrypting initial value +| decrypted | cleartext | +| 58 | 58 | +| 29 | 29 | +| 88 | 88 | +| 44 | 44 | +| 22 | 22 | +| 11 | 11 | +| 34 | 34 | +| 17 | 17 | +| 52 | 52 | +| 26 | 26 | +| 13 | 13 | +| 40 | 40 | +| 20 | 20 | +| 10 | 10 | +| 5 | 5 | +| 16 | 16 | +| 8 | 8 | +| 4 | 4 | +| 2 | 2 | +| 1 | 1 | +``` + +Here we use a while loop that keeps iterating as long as the decryption of the running value is different from `1`. Again, the loop body is implemented in FHE, but the iteration control has to be in the clear. + +## Optimizing runtimes with composition policies + +By default when using modules, every inputs and outputs of every functions are compatible: they share the same precision and the same crypto-parameters. This means that the most costly crypto-parameters of all code-paths is used for every code paths. This simplicity comes at a cost, and depending on the use case, it may not be necessary. + +To optimize the runtimes, we provide a finer grained control over the composition policy via the `composition` module attribute. Here is an example: +```python +from concrete import fhe + +@fhe.module() +class Collatz: + + @fhe.function({"x": "encrypted"}) + def collatz(x): + y = x // 2 + z = 3 * x + 1 + is_x_odd = fhe.bits(x)[0] + ans = fhe.multivariate(lambda b, x: b * x)(is_x_odd, z - y) + y + is_one = ans == 1 + return ans, is_one + + composition = fhe.AllComposable() +``` + +By default the attribute is set to `fhe.AllComposable`. This policy ensures that every ciphertexts used in the module are compatible. This is the less restrictive, but most costly policy. + +If one does not need composition at all, but just want to pack multiple functions in a single artifact, it is possible to do so by setting the `composition` attribute to `fhe.NotComposable`. This is the most restrictive, but less costly policy. + +Hopefully there is no need to choose between one of those two extremes. It is also possible to detail custom policies by using `fhe.Wired`. For instance: +```python +from concrete import fhe +from fhe import Wired, Wire, Output, Input + +@fhe.module() +class Collatz: + + @fhe.function({"x": "encrypted"}) + def collatz(x): + y = x // 2 + z = 3 * x + 1 + is_x_odd = fhe.bits(x)[0] + ans = fhe.multivariate(lambda b, x: b * x)(is_x_odd, z - y) + y + is_one = ans == 1 + return ans, is_one + + composition = Wired( + [ + Wire(Output(collatz, 0), Input(collatz, 0) + ] + ) +``` + +In this case, the policy states that the first output of the `collatz` function can be forwarded to the first input of `collatz`, but not the second output (which is decrypted every time, and used for control flow). + +It is possible to use an `fhe.Wire` between any two functions, it is also possible to define wires with `fhe.AllInputs` and `fhe.AllOutputs` ends. For instance in the previous example: +```python + composition = Wired( + [ + Wire(AllOutputs(collatz), AllInputs(collatz)) + ] + ) +``` + +This policy would be equivalent to using the `fhe.AllComposable` policy. + +## Limitations + +Depending on the functions, supporting composition may add a non-negligible overhead when compared to a non-composable version. Indeed, to be composable a function must verify the following condition: Every output which can be forwarded as input (as per the composition policy) must contain a noise refreshing operation. + +Since adding a noise refresh has a non negligeable impact on performance, `concrete-python` does not do it in behalf of the user. For instance, to implement a function that doubles an encrypted value, we would write something like: + +```python +@fhe.module() +class Doubler: + @fhe.compiler({"counter": "encrypted"}) + def double(counter): + return counter * 2 +``` + +This is a valid function with the `fhe.NotComposable` policy, but if compiled with `fhe.AllComposable` policy, a `RuntimeError: Program can not be composed: ...` error is reported, signalling that an extra PBS must be added. To solve this situation, and turn this circuit into a valid one, one can use the following snippet to add a PBS at the end of the circuit: + +```python +def noise_reset(x): + return fhe.univariate(lambda x: x)(x) + +@fhe.module() +class Doubler: + @fhe.compiler({"counter": "encrypted"}) + def double(counter): + return noise_reset(counter * 2) +``` + +## Single function composition without modules. + +It is also possible to compile a single function to be self-composable with the `fhe.AllComposable` policy without using modules. For this one simply has to set the [`composable`](../guides/configure.md#options) configuration setting to `True` when compiling. diff --git a/docs/compilation/composition.md b/docs/compilation/composition.md deleted file mode 100644 index faf1c9db69..0000000000 --- a/docs/compilation/composition.md +++ /dev/null @@ -1,224 +0,0 @@ -# Composition - -`concrete-python` supports circuit __composition__, which allows the output of a circuit execution to be used directly as an input without decryption. We can execute the circuit as many time as we want by forwarding outputs without decrypting intermediate values. This feature enables a new range of applications, including support for control flow in pure (cleartext) python. - -Here is a first simple example that uses composition to implement a simple counter in FHE: - -```python -from concrete import fhe - -@fhe.compiler({"counter": "encrypted"}) -def increment(counter): - return (counter + 1) % 100 - -print("Compiling `increment` function") -increment_fhe = increment.compile(list(range(0, 100)), composable=True) - -print("Generating keyset ...") -increment_fhe.keygen() - -print("Encrypting the initial counter value") -counter = 0 -counter_enc = increment_fhe.encrypt(counter) - -print(f"| iteration || decrypted | cleartext |") -for i in range(10): - counter_enc = increment_fhe.run(counter_enc) - counter = increment(counter) - - # For demo purpose; no decryption is needed. - counter_dec = increment_fhe.decrypt(counter_enc) - print(f"| {i} || {counter_dec:<9} | {counter:<9} |") -``` - -Note the use of the `composable` flag in the `compile` call. It instructs the compiler to ensure the circuit can be called on its own outputs (see [Limitations section](#limitations) for more details). Executing this script should give the following output: - -```shell -Compiling `increment` function -Generating keyset ... -Encrypting the initial counter value -| iteration || decrypted | cleartext | -| 0 || 1 | 1 | -| 1 || 2 | 2 | -| 2 || 3 | 3 | -| 3 || 4 | 4 | -| 4 || 5 | 5 | -| 5 || 6 | 6 | -| 6 || 7 | 7 | -| 7 || 8 | 8 | -| 8 || 9 | 9 | -| 9 || 10 | 10 | -``` - -## Multi inputs, multi outputs - -Composition is not limited to 1-to-1 circuits, it can also be used with circuits with multiple inputs and multiple outputs. Here is an example that computes the 10 first elements of the Fibonacci sequence in FHE: - -```python -from concrete import fhe - -def noise_reset(x): - return fhe.univariate(lambda x: x)(x) - -@fhe.compiler({"n1th": "encrypted", "nth": "encrypted"}) -def fib(n1th, nth): - return noise_reset(nth), noise_reset(n1th + nth) - -print("Compiling `fib` function ...") -inputset = list(zip(range(0, 100), range(0, 100))) -fib_fhe = fib.compile(inputset, composable=True) - -print("Generating keyset ...") -fib_fhe.keygen() - -print("Encrypting initial values") -n1th = 1 -nth = 2 -(n1th_enc, nth_enc) = fib_fhe.encrypt(n1th, nth) - -print(f"| || (n-1)-th | n-th |") -print(f"| iteration || decrypted | cleartext | decrypted | cleartext |") -for i in range(10): - (n1th_enc, nth_enc) = fib_fhe.run(n1th_enc, nth_enc) - (n1th, nth) = fib(n1th, nth) - - # For demo purpose; no decryption is needed. - (n1th_dec, nth_dec) = fib_fhe.decrypt(n1th_enc, nth_enc) - print(f"| {i} || {n1th_dec:<9} | {n1th:<9} | {nth_dec:<9} | {nth:<9} |") -``` - -Executing this script will provide the following output: - -```shell -Compiling `fib` function ... -Generating keyset ... -Encrypting initial values -| || (n-1)-th | n-th | -| iteration || decrypted | cleartext | decrypted | cleartext | -| 0 || 2 | 2 | 3 | 3 | -| 1 || 3 | 3 | 5 | 5 | -| 2 || 5 | 5 | 8 | 8 | -| 3 || 8 | 8 | 13 | 13 | -| 4 || 13 | 13 | 21 | 21 | -| 5 || 21 | 21 | 34 | 34 | -| 6 || 34 | 34 | 55 | 55 | -| 7 || 55 | 55 | 89 | 89 | -| 8 || 89 | 89 | 144 | 144 | -| 9 || 144 | 144 | 233 | 233 | -``` - -Though it is not visible in this example, there is no limitations on the number of inputs and outputs. There is also no need for a specific logic regarding how we forward values from outputs to inputs; those could be switched for instance. - -{% hint style="info" %} -See below in the [Limitations section](#limitations), for explanations about the use of `noise_reset`. -{% endhint %} - -## Iteration support - -With the previous example we see that to some extent, composition allows to support iteration with cleartext iterands. That is, loops with the following shape : - -```python -for i in some_cleartext_constant_range: - # Do something in FHE in the loop body, implement as an FHE circuit. -``` - -With this pattern, we can also support unbounded loops or complex dynamic condition, as long as this condition is computed in pure cleartext python. Here is an example that computes the [Collatz sequence](https://en.wikipedia.org/wiki/Collatz_conjecture): - -```python -from concrete import fhe - -@fhe.compiler({"x": "encrypted"}) -def collatz(x): - - y = x // 2 - z = 3 * x + 1 - - is_x_odd = fhe.bits(x)[0] - - # In a fast way, compute ans = is_x_odd * (z - y) + y - ans = fhe.multivariate(lambda b, x: b * x)(is_x_odd, z - y) + y - - is_one = ans == 1 - - return ans, is_one - - -print("Compiling `collatz` function ...") -inputset = [i for i in range(63)] -collatz_fhe = collatz.compile(inputset, composable=True) - -print("Generating keyset ...") -collatz_fhe.keygen() - -print("Encrypting initial value") -x = 19 -x_enc = collatz_fhe.encrypt(x) -is_one_enc = None - -print(f"| decrypted | cleartext |") -while is_one_enc is None or not collatz_fhe.decrypt(is_one_enc): - x_enc, is_one_enc = collatz_fhe.run(x_enc) - x, is_one = collatz(x) - - # For demo purpose; no decryption is needed. - x_dec = collatz_fhe.decrypt(x_enc) - print(f"| {x_dec:<9} | {x:<9} |") -``` - -Which prints: - -```shell -Compiling `collatz` function ... -Generating keyset ... -Encrypting initial value -| decrypted | cleartext | -| 58 | 58 | -| 29 | 29 | -| 88 | 88 | -| 44 | 44 | -| 22 | 22 | -| 11 | 11 | -| 34 | 34 | -| 17 | 17 | -| 52 | 52 | -| 26 | 26 | -| 13 | 13 | -| 40 | 40 | -| 20 | 20 | -| 10 | 10 | -| 5 | 5 | -| 16 | 16 | -| 8 | 8 | -| 4 | 4 | -| 2 | 2 | -| 1 | 1 | -``` - -Here we use a while loop that keeps iterating as long as the decryption of the running value is different from `1`. Again, the loop body is implemented in FHE, but the iteration control has to be in the clear. - -## Limitations - -Depending on the circuit, supporting composition may add a non-negligible overhead when compared to a non-composable version. Indeed, to be composable a circuit must verify two conditions: -1) All inputs and outputs must share the same precision and the same crypto-parameters: the most expensive parameters that would otherwise be used for a single input or output, are generalized to all inputs and outputs. -2) There must be a noise refresh in every path between an input and an output: some circuits will need extra PBSes to be added to allow composability. - -The first point is handled automatically by the compiler, no change to the circuit is needed to ensure the right precisions are used. - -For the second point, since adding a PBS has an impact on performance, we do not ade them on behalf of the user. For instance, to implement a circuit that doubles an encrypted value, we would write something like: - -```python -@fhe.compiler({"counter": "encrypted"}) -def double(counter): - return counter * 2 -``` - -This is a valid circuit when `composable` is not used, but when compiled with composition activated, a `RuntimeError: Program can not be composed: ...` error is reported, signalling that an extra PBS must be added. To solve this situation, and turn this circuit into a composable one, one can use the following snippet to add a PBS at the end of your circuit: - -```python -def noise_reset(x): - return fhe.univariate(lambda x: x)(x) - -@fhe.compiler({"counter": "encrypted"}) -def double(counter): - return noise_reset(counter * 2) -``` diff --git a/docs/compilation/modules.md b/docs/compilation/modules.md deleted file mode 100644 index 6fdab976a9..0000000000 --- a/docs/compilation/modules.md +++ /dev/null @@ -1,51 +0,0 @@ -# Modules - -{% hint style="warning" %} -Modules are still experimental. They are only compatible with [composition](../compilation/composition.md), which means the outputs of every functions can be used directly as inputs for other functions. The crypto-parameters used in this mode are large and thus, the execution is likely to slow. -{% endhint %} - -In some cases, deploying a server that can execute different functions is useful. *Concrete* can compile FHE _modules_, that can contain many different functions to execute at once. All the functions are compiled in a single step and can be [deployed with the same artifacts](../guides/deploy.md#deployment-of-modules). Here is an example: - -```python -from concrete import fhe - -@fhe.module() -class MyModule: - @fhe.function({"x": "encrypted"}) - def inc(x): - return x + 1 % 20 - - @fhe.function({"x": "encrypted"}) - def dec(x): - return x - 1 % 20 -``` - -You can compile the FHE module `MyModule` using the `compile` method. To do that, you need to provide a dictionnary of input sets for every function: - -```python -inputset = list(range(20)) -my_module = MyModule.compile({"inc": inputset, "dec": inputset}) -``` - -{% hint style="warning" %} -Note that here we can see a current limitation of modules: The configuration must use the `parameter_selection_strategy` of `v0`, and activate the `composable` flag. -{% endhint %} - -After the module has been compiled, we can encrypt and call the different functions in the following way: - -```python -x = 5 -x_enc = my_module.inc.encrypt(x) -x_inc_enc = my_module.inc.run(x_enc) -x_inc = my_module.inc.decrypt(x_inc_enc) -assert x_inc == 6 - -x_inc_dec_enc = my_module.dec.run(x_inc_enc) -x_inc_dec = my_module.dec.decrypt(x_inc_dec_enc) -assert x_inc_dec == 5 - -for _ in range(10): - x_enc = my_module.inc.run(x_enc) -x_dec = my_module.inc.decrypt(x_enc) -assert x_dec == 15 -``` diff --git a/docs/guides/deploy.md b/docs/guides/deploy.md index 8d84d627de..7349560c63 100644 --- a/docs/guides/deploy.md +++ b/docs/guides/deploy.md @@ -134,7 +134,7 @@ assert decrypted_result == 49 # Deployment of modules -Deploying a [module](../compilation/modules.md#modules) follows the same logic as the deployment of circuits. Assuming a module compiled in the following way: +Deploying a [module](../compilation/composing_functions_with_modules.md) follows the same logic as the deployment of circuits. Assuming a module compiled in the following way: ```python