Announcing Concrete Numpy v0.9

January 11, 2023
Umut Sahin

Today, Zama announces the release of a new version of Concrete-Numpy. This blog post will review all updates within Concrete-Numpy v0.9.

Updated compiler

Concrete-Numpy v0.9 is using Concrete-Compiler v0.23, which comes with a lot of improvements compared to the old v0.19 used in Concrete-Numpy v0.8, especially in terms of performance.

Table lookups on up to 16-bit integers

Zama is constantly trying to support larger and larger integers, and this release now supports up to 16-bit table lookups. This increases the table lookup range from [0, 255] to [0, 65535] for unsigned values, and from [-128, 127] to [-32768, 32767] for signed values:

import concrete.numpy as cnp
import numpy as np

@cnp.compiler({"x": "encrypted"})
def f(x):
    return np.sqrt(x).astype(np.int64)

inputset = [np.random.randint(0, 2 ** 16) for _ in range(100)]
circuit = f.compile(inputset)

assert circuit.encrypt_run_decrypt(400) == 20
assert circuit.encrypt_run_decrypt(10000) == 100
assert circuit.encrypt_run_decrypt(40401) == 201

Direct circuits

Before this release, Concrete-Numpy offered a single way of compilation, which worked by defining the function, preparing an inputset to determine bit widths, and compiling the function with the provided inputset. This model is great for certain use cases (e.g., Machine Learning Algorithms), but it's clumsy and nonintuitive for other use cases (e.g., Bit Manipulation Algorithms). Now, Concrete-Numpy officially provides another way of compilation called "Direct Definition":

import concrete.numpy as cnp

@cnp.circuit({"x": "encrypted"})
def circuit(x: cnp.tensor[cnp.uint8, 2, 3]):
    return x + 42

assert (circuit.encrypt_run_decrypt([[1, 2, 3], [4, 5, 6]]) == [[43, 44, 45], [46, 47, 48]]).all()

Here’s how the new interface works:

  • It's accessed with cnp.circuit decorator
  • Encryption status of all arguments must be provided in the decorator
  • Optional configuration and debug artifacts can be specified in the decorator as well
  • All arguments must be annotated with Concrete-Numpy types (i.e., cnp.int{1-64} and cnp.uint{1-64}) or Concrete-Numpy tensor specification (i.e., cnp.tensor[UNDERLYING_TYPE, *SHAPE])
  • All supported operations of traditional compilation are also supported in direct definition
  • There are a few more minor differences which are detailed in the documentation

Signed inputs without hidden table lookups

Concrete-Numpy has supported signed inputs since v0.7.0 but, until this release, signed inputs introduced additional table lookups. Now, they're basically free and you don't need to do anything to benefit from this change!

Local error probability in virtual circuits

Virtual circuits are great for prototyping and determining requirements. However, they had a major downside: they were always 100% accurate. This might sound ideal, but it makes virtual circuits disconnected from homomorphic execution. With this release, virtual circuits will now simulate execution with consideration of a p_error configuration option:

import concrete.numpy as cnp
import numpy as np

bit_width = 3
sample_size = 1000
p_error = 0.05

@cnp.compiler({"x": "encrypted"})
def function(x):
    return x**2

inputset = [np.random.randint(0, 2**bit_width, size=(sample_size,)) for _ in range(100)]
circuit = function.compile(inputset, enable_unsafe_features=True, virtual=True, p_error=p_error)

sample = np.random.randint(0, 2**bit_width, size=(sample_size,))
output = circuit.encrypt_run_decrypt(sample)

errors = 0
for i in range(sample_size):
    if output[i] != sample[i] ** 2:
        errors += 1

print(f"Expected Errors: {round(sample_size * p_error)}")
print(f"Actual Errors: {errors}")

When run a few times, it looks like this:

Expected Errors: 50
Actual Errors: 52

Expected Errors: 50
Actual Errors: 41

Expected Errors: 50
Actual Errors: 48

Global error probability

The ability to specify the desired error probability for each PBS is useful in many cases, but sometimes it is hard to understand the behavior of the entire circuit. Hence,  global_p_error has been introduced as a configuration option. This is enabled by default and is set to 1 / 100_000, which means there will be a single error in 100.000 executions on average. You can learn more about it in the documentation.

Keep in mind that global_p_error is not yet simulated in virtual circuits (unlike p_error).

Tagging

Debugging large circuits was hard, until now! This release introduces tagging, which helps you group certain operations in a region:

import concrete.numpy as cnp
import numpy as np

def g(z):
    with cnp.tag("def"):
        a = 120 - z
        b = a // 4
    return b


def f(x):
    with cnp.tag("abc"):
        x = x * 2
        with cnp.tag("foo"):
            y = x + 42
        z = np.sqrt(y).astype(np.int64)

    return g(z + 3) * 2

compiler = cnp.Compiler(f, {"x": "encrypted"})
circuit = compiler.compile(inputset=range(10))

print(circuit)

prints

 %0 = x                            # EncryptedScalar        ∈ [0, 9]
 %1 = 2                            # ClearScalar            ∈ [2, 2]            @ abc
 %2 = multiply(%0, %1)             # EncryptedScalar        ∈ [0, 18]           @ abc
 %3 = 42                           # ClearScalar            ∈ [42, 42]          @ abc.foo
 %4 = add(%2, %3)                  # EncryptedScalar        ∈ [42, 60]          @ abc.foo
 %5 = subgraph(%4)                 # EncryptedScalar        ∈ [6, 7]            @ abc
 %6 = 3                            # ClearScalar            ∈ [3, 3]
 %7 = add(%5, %6)                  # EncryptedScalar        ∈ [9, 10]
 %8 = 120                          # ClearScalar            ∈ [120, 120]        @ def
 %9 = subtract(%8, %7)             # EncryptedScalar        ∈ [110, 111]        @ def
%10 = 4                            # ClearScalar            ∈ [4, 4]            @ def
%11 = floor_divide(%9, %10)        # EncryptedScalar        ∈ [27, 27]          @ def
%12 = 2                            # ClearScalar            ∈ [2, 2]
%13 = multiply(%11, %12)           # EncryptedScalar        ∈ [54, 54]
return %13

Subgraphs:

    %5 = subgraph(%4):

        %0 = input                         # EncryptedScalar          @ abc.foo
        %1 = sqrt(%0)                      # EncryptedScalar        @ abc
        %2 = astype(%1, dtype=int_)        # EncryptedScalar          @ abc
        return %2

Within this representation, it's much easier to see which operation occurs in which region.

A real-world example for this feature is tagging each layer of a neural network. It is much easier to read error messages if tagging is used in this manner.

You can learn more about tagging in the documentation.

Better error messages

Dealing with errors in large circuits is a pain, even with the presence of tagging. The tracing logic has been improved to obtain the precise location of the operation to address this issue:

import concrete.numpy as cnp
import numpy as np

@cnp.compiler({"x": "encrypted"})
def f(x):
    return np.sin(x)

inputset = range(10)
circuit = f.compile(inputset)

This code will fail to compile because floating point results are not supported, but with this release, the error message will have the exact location of the operation:

RuntimeError: Function you are trying to compile cannot be converted to MLIR

%0 = x              # EncryptedScalar          ∈ [0, 9]
%1 = sin(%0)        # EncryptedScalar        ∈ [-0.9589242746631385, 0.9893582466233818]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ only integer operations are supported
                                                                                                  /path/to/your/python/file.py:6
return %1

New operations for virtual circuits

Encrypted multiplication.

import concrete.numpy as cnp
import numpy as np


@cnp.compiler({"x": "encrypted", "y": "encrypted"})
def f(x, y):
    return x * y


inputset = [(np.random.randint(0, 10), np.random.randint(0, 10)) for _ in range(100)]
circuit = f.compile(inputset, enable_unsafe_features=True, virtual=True)


assert circuit.encrypt_run_decrypt(3, 5) == 15

Maxpool

import concrete.numpy as cnp
import concrete.onnx as connx
import numpy as np


@cnp.compiler({"x": "encrypted"})
def f(x):
    return connx.maxpool(x, kernel_shape=(3,))


inputset = [np.random.randint(0, 10, size=(1, 1, 12)) for _ in range(100)]
circuit = f.compile(inputset, enable_unsafe_features=True, virtual=True)


sample = [[[1, 2, 2, 3, 2, 2, 2, 4, 1, 5, 2, 6]]]
assert (circuit.encrypt_run_decrypt(sample) == [2, 3, 3, 3, 2, 4, 4, 5, 5, 6]).all()

Round bit pattern.

This extension is introduced as a building block for rounded table lookups. Even though homomorphic execution of rounded table lookups are not yet available, they can be simulated in the virtual library with this extension. You can read about this in detail in the documentation.

Better numpy support

np.expand_dims.

import concrete.numpy as cnp
import concrete.onnx as connx
import numpy as np


@cnp.compiler({"x": "encrypted"})
def f(x):
    return np.expand_dims(x, axis=2)


inputset = [np.random.randint(0, 10, size=(5, 4, 3, 2)) for _ in range(100)]
circuit = f.compile(inputset, enable_unsafe_features=True, virtual=True)


sample = np.random.randint(0, 10, size=(5, 4, 3, 2))
assert circuit.encrypt_run_decrypt(sample).shape == (5, 4, 1, 3, 2)

np.transpose with axes kwarg.

import concrete.numpy as cnp
import concrete.onnx as connx
import numpy as np


@cnp.compiler({"x": "encrypted"})
def f(x):
    return np.transpose(x, axes=(3, 0, 2, 1))


inputset = [np.random.randint(0, 10, size=(5, 4, 3, 2)) for _ in range(100)]
circuit = f.compile(inputset, enable_unsafe_features=True, virtual=True)


sample = np.random.randint(0, 10, size=(5, 4, 3, 2))
assert circuit.encrypt_run_decrypt(sample).shape == (2, 5, 3, 4)

Bug fixes

On top of the new features, several bugs have been fixed as well:

  • in np.sum, explicitly setting axis to None now works as expected
  • in coonx.conv, bias is now set to the appropriate type instead of floating-point every time
  • in coonx.conv, proper padding argument is now forwarded to torch
  • in coonx.conv, parameter kernel_shape now adheres to ONNX spec

Additional Links

Read more related posts