Skip to content

Commit 2bfabe6

Browse files
committed
feat(frontend-python): experimental synthesis extension
1 parent 2afc3d2 commit 2bfabe6

16 files changed

Lines changed: 1760 additions & 9 deletions

File tree

frontends/concrete-python/.pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ disable=raw-checker-failed,
450450
wrong-import-order,
451451
unsubscriptable-object,
452452
no-else-continue,
453+
no-else-return,
453454
unnecessary-comprehension
454455

455456
# Enable the message, report, category or checker with the given id(s). You can

frontends/concrete-python/.ruff.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ select = [
77
]
88
ignore = [
99
"A", "D", "FBT", "T20", "ANN", "N806", "ARG001", "S101", "BLE001", "RUF100", "ERA001", "SIM105",
10-
"RET504", "TID252", "PD011", "I001", "UP015", "C901", "A001", "SIM118", "PGH003", "PLW2901",
10+
"RET504", "RET505", "TID252", "PD011", "I001", "UP015", "C901", "A001", "SIM118", "PGH003", "PLW2901",
1111
"PLR0915", "C416", "PLR0911", "PLR0912", "PLR0913", "RUF005", "PLR2004", "S110", "PLC1901",
12-
"E731", "RET507", "SIM102"
12+
"E731", "RET507", "SIM102", "SIM108",
13+
"Q000",
1314
]
1415

1516
[per-file-ignores]

frontends/concrete-python/concrete/fhe/compilation/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
MultiParameterStrategy,
2020
MultivariateStrategy,
2121
ParameterSelectionStrategy,
22+
SynthesisConfig,
2223
)
2324
from .keys import Keys
2425
from .module import FheFunction, FheModule

frontends/concrete-python/concrete/fhe/compilation/configuration.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,31 @@ class ApproximateRoundingConfig:
122122
"""
123123

124124

125+
@dataclass
126+
class SynthesisConfig:
127+
"""
128+
Controls the behavior of synthesis.
129+
"""
130+
131+
start_tlu_at_precision: int = 7
132+
"""
133+
Starting synthesis at the given TLU input precision, but keep the original TLU when it's faster.
134+
Used to make high precision TLU faster by rewritting them with several lower precisions TLU.
135+
"""
136+
137+
force_tlu_at_precision: int = 17
138+
"""
139+
Force synthesis at the given TLU input precision, even if it's slower than the original TLU.
140+
Used to replace any high precision TLU by several lower precisions TLU.
141+
"""
142+
143+
maximal_tlu_input_bit_width: int = 8
144+
"""
145+
Maximal bit_width for TLU generated by synthesis.
146+
Used if you want guarantees on the maximum input bit_width of TLU after synthesis.
147+
"""
148+
149+
125150
class ComparisonStrategy(str, Enum):
126151
"""
127152
ComparisonStrategy, to specify implementation preference for comparisons.
@@ -994,6 +1019,7 @@ class Configuration:
9941019
dynamic_assignment_check_out_of_bounds: bool
9951020
simulate_encrypt_run_decrypt: bool
9961021
composable: bool
1022+
synthesis_config: SynthesisConfig
9971023

9981024
def __init__(
9991025
self,
@@ -1063,6 +1089,7 @@ def __init__(
10631089
dynamic_indexing_check_out_of_bounds: bool = True,
10641090
dynamic_assignment_check_out_of_bounds: bool = True,
10651091
simulate_encrypt_run_decrypt: bool = False,
1092+
synthesis_config: Optional[SynthesisConfig] = None,
10661093
):
10671094
self.verbose = verbose
10681095
self.compiler_debug_mode = compiler_debug_mode
@@ -1170,6 +1197,8 @@ def __init__(
11701197

11711198
self.simulate_encrypt_run_decrypt = simulate_encrypt_run_decrypt
11721199

1200+
self.synthesis_config = synthesis_config or SynthesisConfig()
1201+
11731202
self._validate()
11741203

11751204
class Keep:
@@ -1245,6 +1274,7 @@ def fork(
12451274
dynamic_indexing_check_out_of_bounds: Union[Keep, bool] = KEEP,
12461275
dynamic_assignment_check_out_of_bounds: Union[Keep, bool] = KEEP,
12471276
simulate_encrypt_run_decrypt: Union[Keep, bool] = KEEP,
1277+
synthesis_config: Union[Keep, Optional[SynthesisConfig]] = KEEP,
12481278
) -> "Configuration":
12491279
"""
12501280
Get a new configuration from another one specified changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Provide synthesis main entry points."""
2+
3+
from .fhe_function import lut, verilog_expression
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# pylint: disable=missing-module-docstring,missing-function-docstring
2+
3+
from dataclasses import dataclass
4+
5+
import numpy as np
6+
7+
from concrete.fhe.extensions.synthesis.verilog_source import Ty
8+
9+
10+
class EvalContext:
11+
"""
12+
This is a reduced context with similar method as `concrete.fhe.mlir.Context`.
13+
14+
It provides a clear evaluation backend for tlu_circuit_to_mlir.
15+
Until the all internal_api are used directly by concrete-python,
16+
this helps to keep all tests for previous backend and api.
17+
For now only synthesis of TLU is supported by concrete-python.
18+
"""
19+
20+
# For all `EvalContext` method look at `Context` documentation.
21+
22+
@dataclass
23+
class Ty:
24+
"""Equivalent for `ConversionType`."""
25+
26+
bit_width: int
27+
is_tensor: bool = False
28+
shape: tuple = () # not used
29+
30+
@dataclass
31+
class Val:
32+
"""Equivalent for `Conversion`. Contains the evaluation result."""
33+
34+
value: 'int | np.ndarray'
35+
type: Ty
36+
37+
def __init__(self, value, type_):
38+
try:
39+
value = int(value)
40+
except TypeError:
41+
pass
42+
self.value = value
43+
self.type = type_
44+
45+
def fork_type(self, type_, bit_width=None, shape=None):
46+
return self.Ty(bit_width=bit_width or type_.bit_width, shape=shape or type_.shape)
47+
48+
def i(self, size):
49+
return self.Ty(size)
50+
51+
def constant(self, type_: Ty, value: int):
52+
return self.Val(value, type_)
53+
54+
def mul(self, type_: Ty, a: Val, b: Val):
55+
assert isinstance(b.value, int)
56+
assert a.type == type_
57+
return self.Val(a.value * b.value, type_)
58+
59+
def add(self, type_: Ty, a: Val, b: Val):
60+
assert a.type == b.type == type_
61+
return self.Val(a.value + b.value, type_)
62+
63+
def sub(self, type_: Ty, a: Val, b: Val):
64+
assert isinstance(b.value, int)
65+
assert a.type == type_
66+
return self.Val(a.value - b.value, type_)
67+
68+
def tlu(self, type_: Ty, arg: Val, tlu_content, **_kwargs):
69+
if isinstance(arg, int):
70+
v = self.Val(tlu_content[arg.value], type_)
71+
else:
72+
v = np.vectorize(lambda v: int(tlu_content[v]))(arg.value)
73+
return self.Val(v, type_)
74+
75+
def extract_bits(self, type_: Ty, arg: Val, bit_index, **_kwargs):
76+
return self.Val((arg.value >> bit_index) & 1, type_)
77+
78+
def to_unsigned(self, arg: Val):
79+
def aux(value):
80+
if value < 0:
81+
return 2**arg.type.bit_width + value
82+
return value
83+
84+
if isinstance(arg.value, int):
85+
v = aux(arg.value)
86+
else:
87+
v = np.vectorize(aux)(arg.value)
88+
return self.Val(v, arg.type)
89+
90+
def to_signed(self, arg: Val):
91+
def aux(value):
92+
assert value >= 0
93+
negative = value >= 2 ** (arg.type.bit_width - 1)
94+
if negative:
95+
return -(2**arg.type.bit_width - arg.value)
96+
return value
97+
98+
if isinstance(arg.value, int):
99+
v = aux(arg.value)
100+
else:
101+
v = np.vectorize(aux)(arg.value)
102+
return self.Val(v, arg.type)
103+
104+
def index(self, type_: Ty, tensor: Val, index):
105+
assert isinstance(tensor.value, list), type(tensor.value)
106+
assert len(index) == 1
107+
(index,) = index
108+
return self.Val(tensor.value[index], self.Ty(type_.bit_width, is_tensor=False))
109+
110+
def reinterpret(self, arg, bit_width=None):
111+
arg_bit_width = arg.type.bit_width
112+
if bit_width is None:
113+
bit_width = arg_bit_width
114+
if bit_width == arg_bit_width:
115+
return arg
116+
shift = 2 ** (bit_width - arg_bit_width)
117+
if isinstance(arg, int):
118+
v = arg.value * shift
119+
else:
120+
v = np.vectorize(lambda v: v * shift)(arg.value)
121+
return self.Val(v, self.Ty(bit_width=bit_width))
122+
123+
def safe_reduce_precision(self, arg, bit_width):
124+
if arg.type.bit_width == bit_width:
125+
return arg
126+
assert arg.type.bit_width > bit_width
127+
shift = arg.type.bit_width - bit_width
128+
shifted = self.mul(arg.type, arg, self.constant(self.i(bit_width + 1), 2**shift))
129+
return self.reinterpret(shifted, bit_width)
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
INTERNAL extension to synthesize a fhe compatible function from verilog code.
3+
"""
4+
5+
from collections import Counter
6+
from typing import Optional
7+
8+
import concrete.fhe.dtypes as fhe_dtypes
9+
import concrete.fhe.tracing.typing as fhe_typing
10+
from concrete.fhe.dtypes.integer import Integer
11+
from concrete.fhe.extensions.synthesis.eval_context import EvalContext
12+
from concrete.fhe.extensions.synthesis.luts_to_fhe import tlu_circuit_to_mlir
13+
from concrete.fhe.extensions.synthesis.luts_to_graph import to_graph
14+
from concrete.fhe.extensions.synthesis.verilog_source import (
15+
Ty,
16+
verilog_from_expression,
17+
verilog_from_tlu,
18+
)
19+
from concrete.fhe.extensions.synthesis.verilog_to_luts import yosys_lut_synthesis
20+
from concrete.fhe.values.value_description import ValueDescription
21+
22+
23+
class FheFunction:
24+
"""Main class to synthesize verilog."""
25+
26+
def __init__(
27+
self,
28+
*,
29+
verilog,
30+
name,
31+
params=None,
32+
result_name="result",
33+
yosys_dot_file=False,
34+
verbose=False,
35+
):
36+
assert params
37+
self.name = name
38+
self.verilog = verilog
39+
if verbose:
40+
print()
41+
print(f"Verilog, {name}:")
42+
print(verilog)
43+
print()
44+
if verbose:
45+
print("Synthesis")
46+
self.circuit = yosys_lut_synthesis(
47+
verilog, yosys_dot_file=yosys_dot_file, circuit_name=name
48+
)
49+
if verbose:
50+
print()
51+
print(f"TLUs counts, {self.tlu_counts()}:")
52+
print()
53+
self.params = params
54+
self.result_name = result_name
55+
56+
self.mlir = tlu_circuit_to_mlir(self.circuit, self.params, result_name, verbose)
57+
58+
def __call__(self, **kwargs):
59+
"""
60+
Evaluate using mlir generation with a direct evaluation context.
61+
62+
This is useful for testing purpose.
63+
"""
64+
args = []
65+
for name, type_ in self.params.items():
66+
if name == "result":
67+
continue
68+
if isinstance(type_, list):
69+
val = EvalContext.Val(
70+
kwargs[name], EvalContext.Ty(type_[0].dtype.bit_width, is_tensor=True)
71+
)
72+
else:
73+
val = EvalContext.Val(kwargs[name], EvalContext.Ty(type_.dtype.bit_width))
74+
args.append(val)
75+
result_ty = self.params["result"]
76+
if isinstance(result_ty, list):
77+
eval_ty = EvalContext.Ty(result_ty, is_tensor=True)
78+
else:
79+
eval_ty = EvalContext.Ty(result_ty, is_tensor=False)
80+
result = self.mlir(EvalContext(), eval_ty, args)
81+
if isinstance(result_ty, list):
82+
return [r.value for r in result]
83+
else:
84+
return result.value
85+
86+
def tlu_counts(self):
87+
"""Count the number of tlus in the synthesized tracer keyed by input precision."""
88+
counter = Counter()
89+
for node in self.circuit.nodes:
90+
if len(node.arguments) == 1:
91+
print(node)
92+
counter.update({len(node.arguments): 1})
93+
return dict(sorted(counter.items()))
94+
95+
def is_faster_than_1_tlu(self, reference_costs):
96+
"""Verify that synthesis is faster than the original tlu."""
97+
costs = 0
98+
for node in self.circuit.nodes:
99+
zero_cost = len(node.arguments) <= 1
100+
if zero_cost:
101+
# constant or inversion (converted to substraction)
102+
continue
103+
else:
104+
costs += reference_costs[len(node.arguments)]
105+
try:
106+
return costs <= reference_costs[self.params["a"].dtype.bit_width]
107+
except KeyError:
108+
return True
109+
110+
def graph(self, *, filename=None, view=True, **kwargs):
111+
"""Render the synthesized tracer as a graph."""
112+
graph = to_graph(self.name, self.circuit.nodes)
113+
graph.render(filename=filename, view=view, cleanup=filename is None, **kwargs)
114+
115+
116+
def lut(table: 'list[int]', out_type: Optional[ValueDescription] = None, **kwargs):
117+
"""Synthesize a lookup function from a table."""
118+
# assert not signed # TODO signed case
119+
if isinstance(out_type, list):
120+
msg = "Multi-message output is not supported"
121+
raise TypeError(msg)
122+
if out_type:
123+
assert isinstance(out_type.dtype, Integer)
124+
v_out_type = Ty(
125+
bit_width=out_type.dtype.bit_width,
126+
is_signed=out_type.dtype.is_signed,
127+
)
128+
verilog, v_out_type = verilog_from_tlu(table, signed_input=False, out_type=v_out_type)
129+
if "name" not in kwargs:
130+
kwargs.setdefault("name", "lut")
131+
if "params" not in kwargs:
132+
dtype = fhe_dtypes.Integer.that_can_represent(len(table) - 1)
133+
a_ty = getattr(fhe_typing, f"uint{dtype.bit_width}")
134+
assert a_ty
135+
kwargs["params"] = {"a": a_ty, "result": out_type}
136+
return FheFunction(verilog=verilog, **kwargs)
137+
138+
139+
def _uniformize_as_list(v):
140+
return v if isinstance(v, (list, tuple)) else [v]
141+
142+
143+
def verilog_expression(
144+
in_params: 'dict[str, ValueDescription]', expression: str, out_type: ValueDescription, **kwargs
145+
):
146+
"""Synthesize a lookup function from a verilog function."""
147+
result_name = "result"
148+
if result_name in in_params:
149+
result_name = f"{result_name}_{hash(expression)}"
150+
in_params = dict(in_params)
151+
in_params[result_name] = out_type
152+
verilog_params = {
153+
name: Ty(
154+
bit_width=sum(ty.dtype.bit_width for ty in _uniformize_as_list(type_list)),
155+
is_signed=any(ty.dtype.is_signed for ty in _uniformize_as_list(type_list)),
156+
)
157+
for name, type_list in in_params.items()
158+
}
159+
verilog = verilog_from_expression(verilog_params, expression, result_name)
160+
if "name" not in kwargs:
161+
kwargs.setdefault("name", expression)
162+
return FheFunction(verilog=verilog, params=in_params, result_name=result_name, **kwargs)

0 commit comments

Comments
 (0)