Scalar Product.

This demo shows how to compute scalar product.

To compute the scalar product between two vectors of size l, we will encrypt each vector into one (if l <= n_slots) or several ciphertexts (l>n), then compute the SIMD multiplication and cumulative sum (via rotations and sums), to finally decrypt the result.

#  %%
# ---------------------------------------------------------------------------- #
# A) SCALAR PRODUCT WITH BFV
# ---------------------------------------------------------------------------- #

A1. Vector Generation

We will define a pair of 1D integer vectors of size l and elementsize <= |v_max|.

For the purpose of this demo, we can study three cases: #1: small l (l <= n) and high v_max. –> encoding with trailing zeros #2: fitting l (l = n) and medium v_max. –> fits the encoding perfectly #3: large l (l > n) and low v_max. –> several ciphertexts per vector

@User: You can modify the case selection below

CASE_SELECTOR = 2          # 1, 2 or 3

import warnings
import numpy as np
rng = np.random.default_rng(42)

case_params = {
    1: {'l': 256, 'v_max': 2**24-1},         # small l, high v_max
    2: {'l': 4096, 'v_max': 2**16-1},        # fitting l, medium v_max
    3: {'l': 65536, 'v_max': 2**8-1},        # large l, low v_max
}[CASE_SELECTOR]
l, v_max = case_params['l'], case_params['v_max']

# Generate two random vectors with l elements of values <= |v_max|
v1 = rng.integers(-v_max, v_max, size=l)
v2 = rng.integers(-v_max, v_max, size=l)
spRes = v1@v2
print("\nA1. Vector generation")
print(f"\tv1 = {str(v1)[:40]+'...'}")
print(f"\tv2 = {str(v2)[:40]+'...'}")
print(f"\tvRes = {spRes}")
A1. Vector generation
        v1 = [-53837  35907  20259 ...  -7218  60271 ...
        v2 = [  4740 -46302  38497 ... -36796 -39281 ...
        vRes = -24667130455

A2. Context and key setup

Parameter selection goes according to vector length and max element size.

from Pyfhel import Pyfhel

# utilities to generate context parameters
bitsize                  = lambda x: np.ceil(np.log2(x))
get_closest_power_of_two = lambda x: int(2**(bitsize(x)))

def get_BFV_context_scalar_prod(
    l: int, v_max: int, sec: int=128,
    use_n_min: bool=True
) ->Pyfhel:
    """
    Returns the best context parameters to compute scalar product in BFV scheme.

    The important context parameters to set are:
    - n: the polynomial modulus degree (= n_slots)
    - t: the plaintext modulus         (plaintext element size)

    *Optimal n*: Chosen among {2**10, 2**11, 2**12, 2**13, 2**14, 2**15}, it is
        the closest power of two to the vector length. However, in order to enable
        relinearization & rotation (which requires at least two qualifying primes),
        the minimum value is:
        - n = 2**12 if sec in {128, 192}
        - n = 2**13 if sec = 256
        The bigger n, the more secure the scheme, but the slower the computations.
        (it might be worth using n<l and have multiple ciphertexts per vector)

        NOTE: the context params are read from seal/utils/globals.cpp, in
        accordance with homomorphicencryption.org standard to get the chosen
        security level.

    *Optimal t*: Choose the smallest t (smaller t yields better performance),
        while being big enough to hold the result of the entire operation:
        --> t_bits = bitxsize(v_max)*2 + bitsize(l)
                              ^      ^           ^
                        max value | mult |  cumul. sum
        There is, however, a minimal size on t_bits for a given `n`:
               t_bits_min = 14     if   n <= 2**11
               t_bits_min = 16     if   n <= 2**12
               t_bits_min = 17     otherwise

    Arguments:
        l: vector length
        v_max: max element value

    Returns:
        Pyfhel: context to perform homomorphic encryption
    """
    #> OPTIMAL t --> minimum for chosen `n`, or big enough to hold v1@v2
    t_bits_min = 17
    t_bits = max(t_bits_min, (v_max).bit_length()*2 + bitsize(l))
    if t_bits > 60:
        raise ValueError(f"t_bits = {t_bits} > 60. Choose a smaller v_max.")

    #> OPTIMAL n
    n_min = 2**12 if sec in [128, 192] else 2**13
    if use_n_min:           n = n_min    # use n_min regardless of l
    elif 2*l < n_min:       n = n_min    # Smallest
    elif 2*l > 2**15:       n = 2**15    # Largest
    else:                   n = get_closest_power_of_two(2*l)

    context_params = {
        'scheme': 'BFV',
        'n': n,          # Poly modulus degree. BFV ptxt is a n//2 by 2 matrix.
        't_bits': t_bits,# Plaintext modulus.
        'sec': sec,      # Security level.
    }

    # Increasing `n` to get enough noise budget for the c1*c2 multiplication.
    #  Since the noise budget in a multiplication consumes t_bits (+...) noise
    #  budget and we start with `total_coeff_modulus_bit_count-t_bits` budget, #  we check if this is high enough to decrypt correctly.
    HE = Pyfhel()
    total_coeff_modulus_bit_count = 0
    while total_coeff_modulus_bit_count - 2*t_bits <= 0:
        context_status = HE.contextGen(**context_params)
        total_coeff_modulus_bit_count = HE.total_coeff_modulus_bit_count
        context_params['n'] *= 2
        if context_params['n'] > 2**15:
            raise ValueError(f"n = {context_params['n']} > 2**15. Valid parameters not found.")

    # Increasing t_bits to maintain coprimality between t and q
    while context_status != 'success: valid':
        context_params['t_bits'] += 1
        if context_params['t_bits'] > 60:
            raise ValueError(f"t_bits= {context_params['t_bits']} > 60. Valid parameters not valid.")
        context_status = HE.contextGen(**context_params)
    return HE

# Setup parameters for HE context
HE = get_BFV_context_scalar_prod(l, v_max, sec=128, use_n_min=True)
HE.keyGen()
HE.relinKeyGen()
HE.rotateKeyGen()

print("\nA2. BFV context generation")
print(f"\t{HE}")
A2. BFV context generation
        <bfv Pyfhel obj at 0x7fecac106f30, [pk:Y, sk:Y, rtk:Y, rlk:Y, contx(n=16384, t=35184371138561, sec=128, qi=[], scale=1.0, )]>

A3. BFV Encryption

# Encrypt each vector into one (if l <= n_slots) or several ciphertexts (l>n)
c1 = [HE.encrypt(v1[j:j+HE.get_nSlots()]) for j in range(0,l,HE.get_nSlots())]
c2 = [HE.encrypt(v2[j:j+HE.get_nSlots()]) for j in range(0,l,HE.get_nSlots())]

print("\nA3. Integer Encryption")
print("->\tv1= ", str(v1)[:40],'...]\n\t--> c1= ', c1)
print("->\tv2= ", str(v2)[:40],'...]\n\t--> c2= ', c2)
A3. Integer Encryption
->      v1=  [-53837  35907  20259 ...  -7218  60271  ...]
        --> c1=  [<Pyfhel Ciphertext at 0x7fecac112720, scheme=bfv, size=2/2, noiseBudget=335>]
->      v2=  [  4740 -46302  38497 ... -36796 -39281  ...]
        --> c2=  [<Pyfhel Ciphertext at 0x7fecac112ae0, scheme=bfv, size=2/2, noiseBudget=336>]

A4. BFV Scalar Product Evaluation

Scalar product requires a multiplication and a cumulative sum of the products.

# If l <= n_slots (one single ciphertext per vector), we can perform the scalar
#  product in one step using the `@` operator (matmul).
if len(c1)==len(c2)==1:
    cRes = c1[0] @ c2[0]
# Otherwise, we should first multiply (and relinearize), then add all ciphertexts,
#  and finally perform the cumulative addition inside the final ciphertext
else:
    cRes = sum([~(c1[i]*c2[i]) for i in range(len(c1))])
    # Cumulative sum
    cRes = HE.cumul_add(cRes)



# Lets decrypt and check the result!
res = HE.decrypt(cRes)[0]

print("\nA4. Scalar Product Evaluation")
print("->\tc1@c2= ", cRes)
print("->\tHE.decrypt(c1@c2)= ", res)
print("->\tExpected:   c1@c2= ", spRes)
assert res == spRes, "Incorrect result!"
print("---------------------------------------")
A4. Scalar Product Evaluation
->      c1@c2=  <Pyfhel Ciphertext at 0x7fecac112680, scheme=bfv, size=2/3, noiseBudget=266>
->      HE.decrypt(c1@c2)=  -24667130455
->      Expected:   c1@c2=  -24667130455
---------------------------------------

—————————————————————————- # B) SCALAR PRODUCT WITH CKKS —————————————————————————- # Let’s repeat the experiment in CKKS!

In this case we don’t have a noise_level operation to check the current noise,

but we can precisely control the maximum # of multiplications by setting qi.

# B1. Vector Generation
# ---------------------------
# CKKS doesn't have the inherent scale limitations of integer-based BFV, so we
#  can use floating-point vectors, and employ a `scale` of up to 60 bits to hold
#  values of that size.
import numpy as np
rng = np.random.default_rng(42)

# We will define a pair of 1D integer vectors of size l.
#  For the purpose of this demo, we can study two cases:
#  #1: small l (l <= n).     --> encoding with trailing zeros
#  #3: large l (l > n).      --> several ciphertexts per vector

# @User: You can modify the case selection below
CASE_SELECTOR = 1          # 1 or 2

case_params = {
    1: {'l': 256},         # small l
    2: {'l': 65536},       # large l
}[CASE_SELECTOR]
l = case_params['l']

# Generate two vectors with l elements drawn from a normal distribution N(0,100)
v1 = rng.normal(loc=0, scale=100, size=l)    # Not bounded!
v2 = rng.normal(loc=0, scale=100, size=l)
spRes = v1@v2
print("\nB1. CKKS Vector generation")
print(f"\tv1 = {str(v1)[:40]+'...'}")
print(f"\tv2 = {str(v2)[:40]+'...'}")
print(f"\tvRes = {spRes}")
B1. CKKS Vector generation
        v1 = [  30.47170798 -103.99841062   75.045119...
        v2 = [ 4.34766074e+01 -3.76156071e+01 -1.3382...
        vRes = 19765.496888157766

B2. CKKS Context setup

Parameter selection goes according to vector length only.

from Pyfhel import Pyfhel

def get_CKKS_context_scalar_prod(
    l: int, sec: int=128,
    use_n_min: bool=True
) ->Pyfhel:
    """
    Returns the best context parameters to compute scalar product in CKKS scheme.

    The important context parameters to set are:
    - n: the polynomial modulus degree (= 2*n_slots)

    *Optimal n*: Chosen among {2**12, 2**13, 2**14, 2**15}.
        The bigger n, the more secure the scheme, but the slower the computations.
        It might be faster to use n<l and have multiple ciphertexts pervector.

    Arguments:
        l: vector length
        v_max: max element value

    Returns:
        Pyfhel: context to perform homomorphic encryption
    """
    #> OPTIMAL n
    n_min =  2**13
    if use_n_min:           n = n_min    # use n_min regardless of l
    elif 2*l < n_min:       n = n_min    # Smallest
    elif 2*l > 2**15:       n = 2**15    # Largest
    else:                   n = get_closest_power_of_two(2*l)

    context_params = {
        'scheme': 'CKKS',
        'n': n,          # Poly modulus degree. BFV ptxt is a n//2 by 2 matrix.
        'sec': sec,      # Security level.
        'scale': 2**20,
        'qi_sizes': [60, 20, 60],   # Max number of multiplications = 1
    }
    HE = Pyfhel(context_params)
    return HE

# Setup parameters for HE context
HE = get_CKKS_context_scalar_prod(l, sec=128, use_n_min=True)
HE.keyGen()
HE.relinKeyGen()
HE.rotateKeyGen()

print("\nB2. CKKS context generation")
print(f"\t{HE}")
B2. CKKS context generation
        <ckks Pyfhel obj at 0x7fecac1063a0, [pk:Y, sk:Y, rtk:Y, rlk:Y, contx(n=8192, t=0, sec=128, qi=[60, 20, 60], scale=1048576.0, )]>

B3. ckks Encryption

# Encrypt each vector into one (if l <= n_slots) or several ciphertexts (l>n)
c1 = [HE.encrypt(v1[j:j+HE.get_nSlots()]) for j in range(0,l,HE.get_nSlots())]
c2 = [HE.encrypt(v2[j:j+HE.get_nSlots()]) for j in range(0,l,HE.get_nSlots())]

print("\nB3. CKKS Fixed-point Encoding & Encryption")
print("->\tv1= ", str(v1)[:40],'...]\n\t--> c1= ', c1)
print("->\tv2= ", str(v2)[:40],'...]\n\t--> c2= ', c2)
B3. CKKS Fixed-point Encoding & Encryption
->      v1=  [  30.47170798 -103.99841062   75.045119 ...]
        --> c1=  [<Pyfhel Ciphertext at 0x7fecac563ea0, scheme=ckks, size=2/2, scale_bits=20, mod_level=0>]
->      v2=  [ 4.34766074e+01 -3.76156071e+01 -1.3382 ...]
        --> c2=  [<Pyfhel Ciphertext at 0x7fecac112720, scheme=ckks, size=2/2, scale_bits=20, mod_level=0>]

B4. CKKS Scalar Product Evaluation

We perform the same ops as in the BFV case

if len(c1)==len(c2)==1:
    cRes = c1[0] @ c2[0]
else:
    cRes = [~(c1[i]*c2[i]) for i in range(len(c1))]
    for i in range(1,len(cRes)):
        cRes[0] += cRes[i]
    cRes = HE.cumul_add(cRes[0])


# Lets decrypt and check the result!
res = HE.decrypt(cRes)[0]

print("\nB4. CKKS Scalar Product Evaluation")
print("->\tc1@c2= ", cRes)
print("->\tHE.decrypt(c1@c2)= ", res)
print("->\tCorrect result: ", spRes)
assert np.allclose(res, spRes, rtol=1/1000), "Incorrect result!"
print("---------------------------------------")
B4. CKKS Scalar Product Evaluation
->      c1@c2=  <Pyfhel Ciphertext at 0x7fecac106810, scheme=ckks, size=2/6, scale_bits=40, mod_level=2>
->      HE.decrypt(c1@c2)=  19766.50940383787
->      Correct result:  19765.496888157766
---------------------------------------

Total running time of the script: (0 minutes 8.772 seconds)

Estimated memory usage: 19 MB

Gallery generated by Sphinx-Gallery