Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BGV: Count Add/Keyswitch in one level for OpenFHE #1254

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

ZenithalHourlyRate
Copy link
Collaborator

Part of #1168.

This is a baseline of parameter selection for BGV. While concrete param (like moduli) is still selected by the OpenFHE library, the information of the circuit needed by the OpenFHE is now provided by the compiler.

Note that providing EvalAddCount/KeySwitchCount is crucial under the Application-Aware security model, where the compiler/library should reject circuit whose parameter can not be generated, or generate larger parameters. Cite

In the case of the attack in [11] the proper OpenFHE use of
BGV/BFV for this scenario would require the user to supply
the number of additions before generating the parameters
using SETEVALADDCOUNT, or an equivalent multiplicative
depth using SETMULTIPLICATIVEDEPTH. One can check
that, when this is done correctly, a larger parameter set is
generated by OpenFHE than the one used for the attack.

When further optimization on parameter selection is landed, this can be kept as a comparable baseline for evaluation purpose. For end-user, they could then select different param generation mechanism based on their needs.

Example

For the dot_product_8.mlir, we get the following counting result.

module {
  func.func @func(%arg0: !secret.secret<tensor<8xi16>>, %arg1: !secret.secret<tensor<8xi16>>) -> !secret.secret<i16> attributes {mgmt.openfhe_params = #mgmt.openfhe_params<evalAddCount = 8, keySwitchCount = 15>} {
    %c1 = arith.constant 1 : index
    %c2 = arith.constant 2 : index
    %c4 = arith.constant 4 : index
    %c7 = arith.constant 7 : index
    %0 = secret.generic ins(%arg0, %arg1 : !secret.secret<tensor<8xi16>>, !secret.secret<tensor<8xi16>>) attrs = {arg0 = {addCount = 1 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>}, arg1 = {addCount = 1 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>}} {
    ^bb0(%input0: tensor<8xi16>, %input1: tensor<8xi16>):
      %1 = arith.muli %input0, %input1 {addCount = 1 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2, dimension = 3>} : tensor<8xi16>
      %2 = mgmt.relinearize %1 {addCount = 1 : i64, keySwitchCount = 1 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>} : tensor<8xi16>
      %3 = tensor_ext.rotate %2, %c4 {addCount = 1 : i64, keySwitchCount = 2 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>} : tensor<8xi16>, index
      %4 = arith.addi %2, %3 {addCount = 2 : i64, keySwitchCount = 3 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>} : tensor<8xi16>
      %5 = tensor_ext.rotate %4, %c2 {addCount = 2 : i64, keySwitchCount = 4 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>} : tensor<8xi16>, index
      %6 = arith.addi %4, %5 {addCount = 4 : i64, keySwitchCount = 7 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>} : tensor<8xi16>
      %7 = tensor_ext.rotate %6, %c1 {addCount = 4 : i64, keySwitchCount = 8 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>} : tensor<8xi16>, index
      %8 = arith.addi %6, %7 {addCount = 8 : i64, keySwitchCount = 15 : i64, mgmt.mgmt = #mgmt.mgmt<level = 2>} : tensor<8xi16>
      %9 = mgmt.modreduce %8 {mgmt.mgmt = #mgmt.mgmt<level = 1>} : tensor<8xi16>
      %extracted = tensor.extract %9[%c7] {addCount = 1 : i64, keySwitchCount = 1 : i64, mgmt.mgmt = #mgmt.mgmt<level = 1>} : tensor<8xi16>
      %10 = mgmt.modreduce %extracted {mgmt.mgmt = #mgmt.mgmt<level = 0>} : i16
      secret.yield %10 : i16
    } -> !secret.secret<i16>
    return %0 : !secret.secret<i16>
  }
}

A very counter-intuitive result is that instead of KeySwitchCount being 4 (1 mul-relin + 3 rotate), it is 15 as it has been accumulated via additions. Detailed definition of KeySwitchCount could be found in KPZ21, section 4. Though I want to ask for review of relevent people whether I understand them correctly.

As a result, the generated param becomes

Element Parameters: ILDCRTParams [m=32768 n=16384 q=748300257136160271476183612726042708089465225445377 ru=0 bigq=0 bigru=0]
  m_params:
    0: ILParams [m=32768 n=16384 q=140737488486401 ru=3152167261 bigq=0 bigru=0]
    1: ILParams [m=32768 n=16384 q=9007199255560193 ru=523433461118 bigq=0 bigru=0]
    2: ILParams [m=32768 n=16384 q=9007199255658497 ru=785944929279 bigq=0 bigru=0]
    3: ILParams [m=32768 n=16384 q=65537 ru=9 bigq=0 bigru=0]

where the moduli chain has [47, 53, 53, 16] bits for each level and 169 bits in total.

When they are not specified, the generated param is

Element Parameters: ILDCRTParams [m=32768 n=16384 q=187075064281998384050658167829053521773423705948161 ru=0 bigq=0 bigru=0]
  m_params:
    0: ILParams [m=32768 n=16384 q=140737488486401 ru=3152167261 bigq=0 bigru=0]
    1: ILParams [m=32768 n=16384 q=4503599627763713 ru=51902047037 bigq=0 bigru=0]
    2: ILParams [m=32768 n=16384 q=4503599627796481 ru=38602712989 bigq=0 bigru=0]
    3: ILParams [m=32768 n=16384 q=65537 ru=9 bigq=0 bigru=0]
  m_originalModulus: 0

With moduli chain having [47, 52, 52, 16] bits and 167 bits in total.

@j2kun j2kun self-requested a review January 15, 2025 05:27
@j2kun
Copy link
Collaborator

j2kun commented Jan 15, 2025

Still taking a look at this, but I noticed in your dot_product_8.mlir example the addCount parameter is doubling. Is that the right thing to do? I'd expect 3 adds, not 8 = 2^3.

for (auto i = 0; i != body->getNumArguments(); ++i) {
auto blockArg = body->getArgument(i);
// one Vfresh
propagate(blockArg, CountState(1, 0));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my other comment, naively I would expect zero adds here, not 1, and below (line 53) it appears you're adding the countState once per operand, rather than per op.

@ZenithalHourlyRate
Copy link
Collaborator Author

Still taking a look at this, but I noticed in your dot_product_8.mlir example the addCount parameter is doubling. Is that the right thing to do? I'd expect 3 adds, not 8 = 2^3.

This indeed is quite a less understood thing until recently. KPZ21 does not illustrate the difference but Application Aware, Introduction, Attacks by Guo et al. points out the difference:

Let's say we have the following two circuit

%0 = add %arg0, %arg1
%1 = add %0, %arg2
%2 = add %1, %arg3

And

%0 = add %arg0, %arg0
%1 = add %0, %0
%2 = add %1, %1

Suppose for each %argi the noise is ei, the noise for %2 in the first circuit is e0 + e1 + e2 + e3 whereas in the second circuit the noise is 8 * e0.

Note that in KPZ21 section 4, we have the formula (n_add + 1) || v_fresh || + n_ks || v_ks ||. From this equation we know that for the first circuit n_add is indeed 3, but for the second circuit n_add is 7, which I then use 8 as a close estimation.

Related to my other comment, naively I would expect zero adds here, not 1, and below (line 53) it appears you're adding the countState once per operand, rather than per op.

This answers why we count on operands (counting v_fresh) instead of ops, as different combination of op gives different result.

@j2kun
Copy link
Collaborator

j2kun commented Jan 15, 2025

Note that in KPZ21 section 4, we have the formula (n_add + 1) || v_fresh || + n_ks || v_ks ||. From this equation we know that for the first circuit n_add is indeed 3, but for the second circuit n_add is 7, which I then use 8 as a close estimation.

I get that repeated addition blows up the noise faster. But neither KPZ21 nor ABMP24 seems to give a precise definition of n_add. Both just call it "the number of additions" without specifying the distinction of what is being added. From that I would infer (perhaps wrongly) that this formula using n_add actually gives the worst-case guarantee in the sense that it assumes repeated additions.

My main concerns are:

  1. We have code comment docs that allows someone to verify the implementation by referring to something that gives a clear definition (a paper or source code).
  2. The parameter should be named more directly to what it is doing. If it's called "add count" then it should count adds (and a different transformation should be made later if necessary to pass it along to openFHE's API); and if it's doing a sort of coarse noise estimate, then it should be named something that reflects that.

This link still only says "max number of additions" and browsing through their code I don't see anywhere that is more specific...

[Edit]: We should be able to clear this up by asking Yuriy and the OpenFHE devs on their discourse.

@ZenithalHourlyRate
Copy link
Collaborator Author

ZenithalHourlyRate commented Jan 16, 2025

2. The parameter should be named more directly to what it is doing. If it's called "add count" then it should count adds (and a different transformation should be made later if necessary to pass it along to openFHE's API); and if it's doing a sort of coarse noise estimate, then it should be named something that reflects that.

Then it should be better called AddNoiseFactor and KeySwitchNoiseFactor, as these are coefficients for (|| v_fresh || / || v_mult ||) and || v_ks ||.

From that I would infer (perhaps wrongly) that this formula using n_add actually gives the worst-case guarantee in the sense that it assumes repeated additions.

Typically, noise analysis starts with numerial input, e.g. initial noise level being 2^16. Then for the analysis to carry out, each op is visited, propagating the noise to each ciphertext. For the first circuit, the result noise is 4 * 2^16 and for the second circuit it is 8 * 2^16. Either worst-case or average-case will give similar result.

The trick for KPZ21 is that instead of detailed numerical analysis, they view each level as composition of add and keyswitch, thus they only need to count the equivalent addition number (not the same as add operation number) and equivalent key switch number.

[Edit]: We should be able to clear this up by asking Yuriy and the OpenFHE devs on their discourse.

I'll open a thread on openfhe discourse.

[Edit]: Opened in https://openfhe.discourse.group/t/question-on-definition-of-evaladdcount-and-keyswitchcount/1835

@j2kun
Copy link
Collaborator

j2kun commented Jan 19, 2025

Based on https://openfhe.discourse.group/t/question-on-definition-of-evaladdcount-and-keyswitchcount/1835/3 let's go ahead with what you have here and cite that thread for followup. I still suggest that we don't need the attributes and can compute the desired values on the fly, using op interfaces to support the same code applying to multiple dialects.

@ZenithalHourlyRate ZenithalHourlyRate marked this pull request as draft January 21, 2025 15:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants