From 3635c20bc90c039682526b083075d4b821e2cefa Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 29 Jan 2024 15:26:32 +0800 Subject: [PATCH] Repo-Sync (#14) --- .bazelrc | 1 + .ci/accuracy_test.py | 22 + .ci/integration_test.py | 1553 ++++++++++------- .ci/test_common.py | 639 +++++++ .ci/test_data/bin_sgb/alice/alice.csv | 20 + .ci/test_data/bin_sgb/alice/s_model.tar.gz | Bin 0 -> 1666 bytes .ci/test_data/bin_sgb/bob/bob.csv | 20 + .ci/test_data/bin_sgb/bob/s_model.tar.gz | Bin 0 -> 1518 bytes .ci/test_data/bin_sgb/predict.csv | 20 + .ci/test_data/xgb/alice/alice.csv | 20 + .ci/test_data/xgb/alice/s_model.tar.gz | Bin 0 -> 1227 bytes .ci/test_data/xgb/bob/bob.csv | 20 + .ci/test_data/xgb/bob/s_model.tar.gz | Bin 0 -> 1036 bytes .ci/test_data/xgb/predict.csv | 20 + CHANGELOG.md | 2 + WORKSPACE | 4 + bazel/repositories.bzl | 4 +- docs/locales/zh_CN/LC_MESSAGES/index.po | 23 +- .../zh_CN/LC_MESSAGES/intro/tutorial.po | 4 +- .../zh_CN/LC_MESSAGES/reference/model.po | 128 +- .../topics/system/observability.po | 334 +++- docs/source/imgs/services.png | Bin 0 -> 152445 bytes docs/source/index.rst | 2 + docs/source/reference/model.md | 3 +- docs/source/topics/deployment/deployment.rst | 1 + docs/source/topics/system/observability.rst | 162 ++ .../config/feature_config.proto | 2 +- .../config/logging_config.proto | 2 + secretflow_serving/config/model_config.proto | 5 +- .../feature_adapter/mock_adapter.cc | 91 +- secretflow_serving/framework/executor.cc | 5 +- secretflow_serving/ops/BUILD.bazel | 78 + secretflow_serving/ops/arrow_processing.cc | 23 +- secretflow_serving/ops/dot_product.cc | 5 +- secretflow_serving/ops/merge_y.cc | 5 +- secretflow_serving/ops/node.cc | 14 +- secretflow_serving/ops/node_def_util.cc | 144 +- secretflow_serving/ops/node_def_util.h | 63 +- secretflow_serving/ops/node_def_util_test.cc | 21 - secretflow_serving/ops/op_def_builder.cc | 13 + secretflow_serving/ops/op_def_builder.h | 2 + secretflow_serving/ops/op_factory.h | 4 + secretflow_serving/ops/op_kernel.h | 14 +- .../ops/tree_ensemble_predict.cc | 114 ++ .../ops/tree_ensemble_predict.h | 42 + .../ops/tree_ensemble_predict_test.cc | 437 +++++ secretflow_serving/ops/tree_merge.cc | 120 ++ secretflow_serving/ops/tree_merge.h | 40 + secretflow_serving/ops/tree_merge_test.cc | 285 +++ secretflow_serving/ops/tree_select.cc | 221 +++ secretflow_serving/ops/tree_select.h | 46 + secretflow_serving/ops/tree_select_test.cc | 643 +++++++ secretflow_serving/ops/tree_utils.h | 109 ++ secretflow_serving/protos/op.proto | 5 + secretflow_serving/server/execution_core.cc | 10 +- .../server/execution_service_impl.cc | 6 +- .../server/kuscia/config_parser.cc | 41 + .../server/kuscia/config_parser_test.cc | 21 +- .../server/metrics/metrics_service.cc | 2 +- .../server/model_service_impl.cc | 2 +- .../server/prediction_service_impl.cc | 6 +- secretflow_serving/util/utils.cc | 2 - secretflow_serving/util/utils.h | 2 + 63 files changed, 4779 insertions(+), 868 deletions(-) create mode 100644 .ci/test_common.py create mode 100644 .ci/test_data/bin_sgb/alice/alice.csv create mode 100644 .ci/test_data/bin_sgb/alice/s_model.tar.gz create mode 100644 .ci/test_data/bin_sgb/bob/bob.csv create mode 100644 .ci/test_data/bin_sgb/bob/s_model.tar.gz create mode 100644 .ci/test_data/bin_sgb/predict.csv create mode 100644 .ci/test_data/xgb/alice/alice.csv create mode 100644 .ci/test_data/xgb/alice/s_model.tar.gz create mode 100644 .ci/test_data/xgb/bob/bob.csv create mode 100644 .ci/test_data/xgb/bob/s_model.tar.gz create mode 100644 .ci/test_data/xgb/predict.csv create mode 100644 docs/source/imgs/services.png create mode 100644 secretflow_serving/ops/tree_ensemble_predict.cc create mode 100644 secretflow_serving/ops/tree_ensemble_predict.h create mode 100644 secretflow_serving/ops/tree_ensemble_predict_test.cc create mode 100644 secretflow_serving/ops/tree_merge.cc create mode 100644 secretflow_serving/ops/tree_merge.h create mode 100644 secretflow_serving/ops/tree_merge_test.cc create mode 100644 secretflow_serving/ops/tree_select.cc create mode 100644 secretflow_serving/ops/tree_select.h create mode 100644 secretflow_serving/ops/tree_select_test.cc create mode 100644 secretflow_serving/ops/tree_utils.h diff --git a/.bazelrc b/.bazelrc index f18b196..901ac3b 100644 --- a/.bazelrc +++ b/.bazelrc @@ -32,6 +32,7 @@ build --copt=-fPIC build --copt=-fstack-protector-strong build:linux --copt=-Wl,-z,noexecstack + test --keep_going test --test_output=errors test --test_timeout=1800 diff --git a/.ci/accuracy_test.py b/.ci/accuracy_test.py index b4ead7b..77fcc36 100644 --- a/.ci/accuracy_test.py +++ b/.ci/accuracy_test.py @@ -315,3 +315,25 @@ def exec(self): query_ids=['1', '2', '3', '4', '5', '6', '7', '8', '9', '15'], score_col_name='pred', ).exec() + + AccuracyTestCase( + service_id="bin_sgb", + parties=['alice', 'bob'], + case_dir='.ci/test_data/bin_sgb', + package_name='s_model.tar.gz', + input_csv_names={'alice': 'alice.csv', 'bob': 'bob.csv'}, + expect_csv_name='predict.csv', + query_ids=['1', '2', '3', '4', '5', '6', '7', '8', '9', '11', '17', '18'], + score_col_name='pred', + ).exec() + + AccuracyTestCase( + service_id="xgb", + parties=['alice', 'bob'], + case_dir='.ci/test_data/xgb', + package_name='s_model.tar.gz', + input_csv_names={'alice': 'alice.csv', 'bob': 'bob.csv'}, + expect_csv_name='predict.csv', + query_ids=['1', '2', '3', '4', '5', '6', '7', '8', '9', '11', '17', '18'], + score_col_name='pred', + ).exec() diff --git a/.ci/integration_test.py b/.ci/integration_test.py index beb0295..2b1cacc 100644 --- a/.ci/integration_test.py +++ b/.ci/integration_test.py @@ -15,30 +15,13 @@ # limitations under the License. -import csv -import hashlib import json import os -import subprocess import sys -import tarfile -import time -from dataclasses import dataclass from typing import Any, Dict, List import pyarrow as pa -from google.protobuf.json_format import MessageToJson -from secretflow_serving_lib import get_op -from secretflow_serving_lib.attr_pb2 import AttrValue, DoubleList, StringList -from secretflow_serving_lib.bundle_pb2 import FileFormatType, ModelBundle, ModelManifest -from secretflow_serving_lib.feature_pb2 import ( - Feature, - FeatureField, - FeatureParam, - FeatureValue, - FieldType, -) from secretflow_serving_lib.graph_pb2 import ( DispatchType, ExecutionDef, @@ -48,13 +31,17 @@ ) from secretflow_serving_lib.link_function_pb2 import LinkFunctionType -# set up global env -g_script_name = os.path.abspath(sys.argv[0]) -g_script_dir = os.path.dirname(g_script_name) -g_repo_dir = os.path.dirname(g_script_dir) - -g_clean_up_service = True -g_clean_up_files = True +from test_common import ( + TestCase, + TestConfig, + PartyConfig, + make_processing_node_def, + make_dot_product_node_def, + make_merge_y_node_def, + make_tree_select_node_def, + make_tree_merge_node_def, + make_tree_ensemble_predict_node_def, +) def global_ip_config(index): @@ -69,467 +56,54 @@ def global_ip_config(index): } -class ModelBuilder: - def __init__(self, name, desc, graph_def: GraphDef): - self.name = name - self.desc = desc - self.bundle = ModelBundle(name=name, desc=desc, graph=graph_def) - - def dump_tar_gz(self, path=".", filename=None): - if filename is None: - filename = "model.tar.gz" - if not os.path.exists(path): - os.makedirs(path, exist_ok=True) - - filename = os.path.join(path, filename) - - model_graph_filename = "model_graph.json" - - # dump manifest - dump_pb_json_file( - ModelManifest( - bundle_path=model_graph_filename, bundle_format=FileFormatType.FF_JSON - ), - os.path.join(path, "MANIFEST"), - ) - # dump model file - dump_pb_json_file(self.bundle, os.path.join(path, model_graph_filename)) - - with tarfile.open(filename, "w:gz") as model_tar: - model_tar.add(os.path.join(path, "MANIFEST"), arcname="MANIFEST") - model_tar.add( - os.path.join(path, model_graph_filename), arcname=model_graph_filename - ) - print( - f'tar: {filename} <- ({os.path.join(path, "MANIFEST")}, {os.path.join(path, model_graph_filename)})' - ) - os.remove(os.path.join(path, "MANIFEST")) - os.remove(os.path.join(path, model_graph_filename)) - with open(filename, "rb") as ifile: - return filename, hashlib.sha256(ifile.read()).hexdigest() - - -def make_processing_node_def( - name, - parents, - input_schema: pa.Schema, - output_schema: pa.Schema, - trace_content=None, -): - op_def = get_op("ARROW_PROCESSING") - attrs = { - "input_schema_bytes": AttrValue(by=input_schema.serialize().to_pybytes()), - "output_schema_bytes": AttrValue(by=output_schema.serialize().to_pybytes()), - "content_json_flag": AttrValue(b=True), - } - if trace_content: - attrs["trace_content"] = AttrValue(by=trace_content) - - return NodeDef( - name=name, - parents=parents, - op=op_def.name, - attr_values=attrs, - op_version=op_def.version, - ) - - -def make_dot_product_node_def( - name, parents, weight_dict, output_col_name, input_types, intercept=None -): - op_def = get_op("DOT_PRODUCT") - attrs = { - "feature_names": AttrValue(ss=StringList(data=list(weight_dict.keys()))), - "feature_weights": AttrValue(ds=DoubleList(data=list(weight_dict.values()))), - "output_col_name": AttrValue(s=output_col_name), - "input_types": AttrValue(ss=StringList(data=input_types)), - } - if intercept: - attrs["intercept"] = AttrValue(d=intercept) - - return NodeDef( - name=name, - parents=parents, - op=op_def.name, - attr_values=attrs, - op_version=op_def.version, - ) - - -def make_merge_y_node_def( - name, - parents, - link_function: LinkFunctionType, - input_col_name: str, - output_col_name: str, - yhat_scale: float = None, -): - op_def = get_op("MERGE_Y") - attrs = { - "link_function": AttrValue(s=LinkFunctionType.Name(link_function)), - "input_col_name": AttrValue(s=input_col_name), - "output_col_name": AttrValue(s=output_col_name), - } - if yhat_scale: - attrs["yhat_scale"] = AttrValue(d=yhat_scale) - - return NodeDef( - name=name, - parents=parents, - op=op_def.name, - attr_values=attrs, - op_version=op_def.version, - ) - - -def dump_pb_json_file(pb_obj, file_name, indent=2): - json_str = MessageToJson(pb_obj) - with open(file_name, "w") as file: - file.write(json_str) - - -def dump_json(obj, filename, indent=2): - with open(filename, "w") as ofile: - json.dump(obj, ofile, indent=indent) - - -@dataclass -class PartyConfig: - id: str - feature_mapping: Dict[str, str] - cluster_ip: str - metrics_port: int - brpc_builtin_service_port: int - channel_protocol: str - model_id: str - graph_def: GraphDef - query_datas: List[str] = None - query_context: str = None - csv_dict: Dict[str, Any] = None - - -class ConfigDumper: +class MockFeatureTest(TestCase): def __init__( self, - party_configs: List[PartyConfig], - log_config_filename: str, - serving_config_filename: str, - tar_name: str, - service_id: str, - ): - self.service_id = service_id - self.party_configs = party_configs - self.parties = [] - self.log_config = log_config_filename - self.serving_config = serving_config_filename - self.tar_name = tar_name - for config in self.party_configs: - self.parties.append({"id": config.id, "address": config.cluster_ip}) - - def _dump_logging_config(self, path: str, logging_path: str): - with open(os.path.join(path, self.log_config), "w") as ofile: - json.dump({"systemLogPath": os.path.abspath(logging_path)}, ofile, indent=2) - - def _dump_model_tar_gz(self, path: str, graph_def: GraphDef): - graph_def_str = MessageToJson(graph_def, preserving_proto_field_name=True) - print(f"graph_def: \n {graph_def_str}") - return ModelBuilder("test_model", "just for test", graph_def).dump_tar_gz( - path, self.tar_name - ) - - def make_csv_config(self, data_dict: Dict[str, List[Any]], path: str): - filename = "feature_source.csv" - file_path = os.path.join(path, filename) - with open(file_path, "w") as ofile: - writer = csv.DictWriter(ofile, fieldnames=list(data_dict.keys())) - writer.writeheader() - rows = [] - for key, value in data_dict.items(): - if len(rows) == 0: - rows = [{} for _ in value] - assert len(value) == len( - rows - ), f"row count {len(value)} of {key} in data_dict is diff with {len(rows)}." - for i in range(len(value)): - rows[i][key] = value[i] - print("CSV Rows: ", rows) - for row in rows: - writer.writerow(row) - return {"csv_opts": {"file_path": file_path, "id_name": "id"}} - - def _dump_serving_config( - self, path: str, config: PartyConfig, model_name: str, model_sha256: str + path: str, + nodes: Dict[str, List[NodeDef]], + executions: Dict[str, List[ExecutionDef]], + feature_mappings: Dict[str, Dict] = None, + specific_party: str = None, ): - config_dict = { - "id": self.service_id, - "serverConf": { - "featureMapping": config.feature_mapping, - "metricsExposerPort": config.metrics_port, - "brpcBuiltinServicePort": config.brpc_builtin_service_port, - }, - "modelConf": { - "modelId": config.model_id, - "basePath": os.path.abspath(path), - "sourcePath": os.path.abspath(model_name), - "sourceSha256": model_sha256, - "sourceType": "ST_FILE", - }, - "clusterConf": { - "selfId": config.id, - "parties": self.parties, - "channel_desc": {"protocol": config.channel_protocol}, - }, - "featureSourceConf": self.make_csv_config(config.csv_dict, path) - if config.csv_dict - else {"mockOpts": {}}, - } - dump_json(config_dict, os.path.join(path, self.serving_config)) - - def dump(self, path="."): - for config in self.party_configs: - config_path = os.path.join(path, config.id) - if not os.path.exists(config_path): - os.makedirs(config_path, exist_ok=True) - self._dump_logging_config(config_path, os.path.join(config_path, "log")) - model_name, model_sha256 = self._dump_model_tar_gz( - config_path, config.graph_def + super().__init__(path) + + # build config + ip_config_idx = 0 + party_configs = [] + for party, node_list in nodes.items(): + graph = GraphDef( + version="0.0.1", + node_list=node_list, + execution_list=executions[party], ) - self._dump_serving_config(config_path, config, model_name, model_sha256) - - -# for every testcase, there should be a TestConfig instance -class TestConfig: - def __init__( - self, - model_path: str, - party_config: List[PartyConfig], - header_dict: Dict[str, str] = None, - service_spec_id: str = None, - predefined_features: Dict[str, List[Any]] = None, - predefined_types: Dict[str, str] = None, - log_config_name=None, - serving_config_name=None, - tar_name=None, - ): - self.header_dict = header_dict - self.service_spec_id = service_spec_id - self.predefined_features = predefined_features - self.predefined_types = predefined_types - self.model_path = os.path.join(g_script_dir, model_path) - self.party_config = party_config - self.log_config_name = ( - log_config_name if log_config_name is not None else "logging.config" - ) - self.serving_config_name = ( - serving_config_name if serving_config_name is not None else "serving.config" - ) - self.tar_name = tar_name if tar_name is not None else "model.tar.gz" - self.background_proc = [] - - def dump_config(self): - ConfigDumper( - self.party_config, - self.log_config_name, - self.serving_config_name, - self.tar_name, - self.service_spec_id, - ).dump(self.model_path) - - def get_server_start_args(self): - def merge_path(dir, party_id, filename): - return os.path.abspath(os.path.join(dir, party_id, filename)) - - return [ - f"--serving_config_file={merge_path(self.model_path, config.id, self.serving_config_name)} " - f"--logging_config_file={merge_path(self.model_path, config.id, self.log_config_name)} " - for config in self.party_config - ] - - def get_party_ids(self): - return [config.id for config in self.party_config] - - def make_request(self): - if self.predefined_features: - pre_features = [] - for name, data_list in self.predefined_features.items(): - pre_features.append( - make_feature(name, data_list, self.predefined_types[name]) + party_configs.append( + PartyConfig( + id=party, + feature_mapping={} + if not feature_mappings + else feature_mappings[party], + **global_ip_config(ip_config_idx), + channel_protocol="baidu_std", + model_id="integration_model", + graph_def=graph, + query_datas=["a"], ) - else: - pre_features = None - - if self.party_config[0].query_datas: - fs_param = {} - for config in self.party_config: - fs_param[config.id] = FeatureParam( - query_datas=config.query_datas, query_context=config.query_context - ) - else: - fs_param = None - - return PredictRequest( - self.header_dict, self.service_spec_id, fs_param, pre_features - ) - - def make_predict_curl_cmd(self, party: str): - url = None - for p_cfg in self.party_config: - if p_cfg.id == party: - url = f"http://{p_cfg.cluster_ip}/PredictionService/Predict" - break - if not url: - raise Exception( - f"{party} is not in TestConfig({self.config.get_party_ids()})" ) - curl_wrapper = CurlWrapper( - url=url, - header="Content-Type: application/json", - data=self.make_request().to_json(), - ) - return curl_wrapper.cmd() + ip_config_idx += 1 - def make_get_model_info_curl_cmd(self, party: str): - url = None - for p_cfg in self.party_config: - if p_cfg.id == party: - url = f"http://{p_cfg.cluster_ip}/ModelService/GetModelInfo" - break - if not url: - raise Exception(f"{party} is not in TestConfig({config.get_party_ids()})") - curl_wrapper = CurlWrapper( - url=url, - header="Content-Type: application/json", - data='{}', + self.config = TestConfig( + path, + service_spec_id="integration_test_mock_feature", + party_config=party_configs, + specific_party=specific_party, ) - return curl_wrapper.cmd() - - def _exe_cmd(self, cmd, background=False): - print("Execute: ", cmd) - if not background: - ret = subprocess.run(cmd, shell=True, check=True, capture_output=True) - ret.check_returncode() - return ret - else: - proc = subprocess.Popen(cmd.split(), shell=False) - self.background_proc.append(proc) - return proc - - def finish(self): - if g_clean_up_service: - for proc in self.background_proc: - proc.kill() - proc.wait() - if g_clean_up_files: - os.system(f"rm -rf {self.model_path}") - - def exe_start_server_scripts(self, start_interval_s=0): - for arg in self.get_server_start_args(): - self._exe_cmd( - f"./bazel-bin/secretflow_serving/server/secretflow_serving {arg}", True - ) - if start_interval_s: - time.sleep(start_interval_s) - - # wait 10s for servers be ready - time.sleep(10) - - def exe_curl_request_scripts(self, party: str): - return self._exe_cmd(self.make_predict_curl_cmd(party)) - - def exe_get_model_info_request_scripts(self, party: str): - return self._exe_cmd(self.make_get_model_info_curl_cmd(party)) - - -def make_feature(name: str, value: List[Any], f_type: str): - assert len(value) != 0 - - field_type = FieldType.Value(f_type) - - if field_type == FieldType.FIELD_BOOL: - f_value = FeatureValue(bs=[bool(v) for v in value]) - elif field_type == FieldType.FIELD_FLOAT: - f_value = FeatureValue(fs=[float(v) for v in value]) - elif field_type == FieldType.FIELD_DOUBLE: - f_value = FeatureValue(ds=[float(v) for v in value]) - elif field_type == FieldType.FIELD_INT32: - f_value = FeatureValue(i32s=[int(v) for v in value]) - elif field_type == FieldType.FIELD_INT64: - f_value = FeatureValue(i64s=[int(v) for v in value]) - else: - f_value = FeatureValue(ss=[str(v) for v in value]) - - return Feature(field=FeatureField(name=name, type=field_type), value=f_value) - - -class PredictRequest: - def __init__( - self, - header_dict: Dict[str, str] = None, - service_spec_id: str = None, - party_param_dict: Dict[str, FeatureParam] = None, - predefined_feature: List[Feature] = None, - ): - self.header_dict = header_dict - self.service_spec_id = service_spec_id - self.party_param_dict = party_param_dict - self.predefined_feature = predefined_feature - - def to_json(self): - ret = {} - if self.header_dict: - ret["header"] = {"data": self.header_dict} - if self.service_spec_id: - ret["service_spec"] = {"id": self.service_spec_id} - if self.party_param_dict: - ret["fs_params"] = { - k: json.loads(MessageToJson(v, preserving_proto_field_name=True)) - for k, v in self.party_param_dict.items() - } - if self.predefined_feature: - ret["predefined_features"] = [ - json.loads(MessageToJson(i, preserving_proto_field_name=True)) - for i in self.predefined_feature - ] - return json.dumps(ret) - - -class CurlWrapper: - def __init__(self, url: str, header: str, data: str): - self.url = url - self.header = header - self.data = data - - def cmd(self): - return f'curl --location "{self.url}" --header "{self.header}" --data \'{self.data}\'' - def exe(self): - return os.popen(self.cmd()) - - -# simple test -class TestCase: - def __init__(self, path: str): - self.path = path - - def exec(self): - config = self.get_config(self.path) - try: - self.test(config) - finally: - config.finish() - - def test(config: TestConfig): - raise NotImplementedError - - def get_config(self, path: str) -> TestConfig: - raise NotImplementedError - - -class SimpleTest(TestCase): def test(self, config: TestConfig): config.dump_config() config.exe_start_server_scripts() for party in config.get_party_ids(): + if config.specific_party and config.specific_party != party: + continue res = config.exe_curl_request_scripts(party) out = res.stdout.decode() print("Result: ", out) @@ -538,175 +112,15 @@ def test(self, config: TestConfig): assert len(res["results"]) == len( config.party_config[0].query_datas ), f"result rows({len(res['results'])}) not equal to query_data({len(config.party_config[0].query_datas)})" + model_info = config.exe_get_model_info_request_scripts(party) out = model_info.stdout.decode() print("Model info: ", out) + res = json.loads(out) + assert res["status"]["code"] == 1, "return status code is not OK(1)" - def get_config(self, path: str): - with open(".ci/simple_test/node_processing_alice.json", "rb") as f: - alice_trace_content = f.read() - - processing_node_alice = make_processing_node_def( - name="node_processing", - parents=[], - input_schema=pa.schema( - [ - ('a', pa.int32()), - ('b', pa.float32()), - ('c', pa.utf8()), - ('x21', pa.float64()), - ('x22', pa.float32()), - ('x23', pa.int8()), - ('x24', pa.int16()), - ('x25', pa.int32()), - ] - ), - output_schema=pa.schema( - [ - ('a_0', pa.int64()), - ('a_1', pa.int64()), - ('c_0', pa.int64()), - ('b_0', pa.int64()), - ('x21', pa.float64()), - ('x22', pa.float32()), - ('x23', pa.int8()), - ('x24', pa.int16()), - ('x25', pa.int32()), - ] - ), - trace_content=alice_trace_content, - ) - # bob run dummy node (no trace) - processing_node_bob = make_processing_node_def( - name="node_processing", - parents=[], - input_schema=pa.schema( - [ - ('x6', pa.int64()), - ('x7', pa.uint8()), - ('x8', pa.uint16()), - ('x9', pa.uint32()), - ('x10', pa.uint64()), - ] - ), - output_schema=pa.schema( - [ - ('x6', pa.int64()), - ('x7', pa.uint8()), - ('x8', pa.uint16()), - ('x9', pa.uint32()), - ('x10', pa.uint64()), - ] - ), - ) - - dot_node_alice = make_dot_product_node_def( - name="node_dot_product", - parents=['node_processing'], - weight_dict={ - "x21": -0.3, - "x22": 0.95, - "x23": 1.01, - "x24": 1.35, - "x25": -0.97, - "a_0": 1.0, - "c_0": 1.0, - "b_0": 1.0, - }, - output_col_name="y", - input_types=[ - "DT_DOUBLE", - "DT_FLOAT", - "DT_INT8", - "DT_INT16", - "DT_INT32", - "DT_INT64", - "DT_INT64", - "DT_INT64", - ], - intercept=1.313, - ) - dot_node_bob = make_dot_product_node_def( - name="node_dot_product", - parents=['node_processing'], - weight_dict={ - "x6": -0.53, - "x7": 0.92, - "x8": -0.72, - "x9": 0.146, - "x10": -0.07, - }, - input_types=["DT_INT64", "DT_UINT8", "DT_UINT16", "DT_UINT32", "DT_UINT64"], - output_col_name="y", - ) - merge_y_node = make_merge_y_node_def( - "node_merge_y", - ["node_dot_product"], - LinkFunctionType.LF_LOGIT, - input_col_name="y", - output_col_name="score", - yhat_scale=1.2, - ) - execution_1 = ExecutionDef( - nodes=["node_processing", "node_dot_product"], - config=RuntimeConfig(dispatch_type=DispatchType.DP_ALL, session_run=False), - ) - execution_2 = ExecutionDef( - nodes=["node_merge_y"], - config=RuntimeConfig( - dispatch_type=DispatchType.DP_ANYONE, session_run=False - ), - ) - - alice_graph = GraphDef( - version="0.0.1", - node_list=[processing_node_alice, dot_node_alice, merge_y_node], - execution_list=[execution_1, execution_2], - ) - bob_graph = GraphDef( - version="0.0.1", - node_list=[processing_node_bob, dot_node_bob, merge_y_node], - execution_list=[execution_1, execution_2], - ) - - alice_config = PartyConfig( - id="alice", - feature_mapping={ - "a": "a", - "b": "b", - "c": "c", - "v24": "x24", - "v22": "x22", - "v21": "x21", - "v25": "x25", - "v23": "x23", - }, - **global_ip_config(0), - channel_protocol="baidu_std", - model_id="integration_model", - graph_def=alice_graph, - query_datas=["a"], - ) - bob_config = PartyConfig( - id="bob", - feature_mapping={ - "v6": "x6", - "v7": "x7", - "v8": "x8", - "v9": "x9", - "v10": "x10", - }, - **global_ip_config(1), - channel_protocol="baidu_std", - model_id="integration_model", - graph_def=bob_graph, - query_datas=["b"], - ) - return TestConfig( - path, - service_spec_id="integration_test", - party_config=[alice_config, bob_config], - ) + def get_config(self, path: str) -> TestConfig: + return self.config class PredefinedErrorTest(TestCase): @@ -1084,7 +498,870 @@ def test(self, config): if __name__ == "__main__": - SimpleTest('model_path').exec() + # glm + with open(".ci/simple_test/node_processing_alice.json", "rb") as f: + alice_trace_content = f.read() + MockFeatureTest( + path='model_path', + nodes={ + "alice": [ + make_processing_node_def( + name="node_processing", + parents=[], + input_schema=pa.schema( + [ + ('a', pa.int32()), + ('b', pa.float32()), + ('c', pa.utf8()), + ('x21', pa.float64()), + ('x22', pa.float32()), + ('x23', pa.int8()), + ('x24', pa.int16()), + ('x25', pa.int32()), + ] + ), + output_schema=pa.schema( + [ + ('a_0', pa.int64()), + ('a_1', pa.int64()), + ('c_0', pa.int64()), + ('b_0', pa.int64()), + ('x21', pa.float64()), + ('x22', pa.float32()), + ('x23', pa.int8()), + ('x24', pa.int16()), + ('x25', pa.int32()), + ] + ), + trace_content=alice_trace_content, + ), + make_dot_product_node_def( + name="node_dot_product", + parents=['node_processing'], + weight_dict={ + "x21": -0.3, + "x22": 0.95, + "x23": 1.01, + "x24": 1.35, + "x25": -0.97, + "a_0": 1.0, + "c_0": 1.0, + "b_0": 1.0, + }, + output_col_name="y", + input_types=[ + "DT_DOUBLE", + "DT_FLOAT", + "DT_INT8", + "DT_INT16", + "DT_INT32", + "DT_INT64", + "DT_INT64", + "DT_INT64", + ], + intercept=1.313, + ), + make_merge_y_node_def( + "node_merge_y", + ["node_dot_product"], + LinkFunctionType.LF_LOGIT, + input_col_name="y", + output_col_name="score", + yhat_scale=1.2, + ), + ], + "bob": [ + # bob run dummy node (no trace) + make_processing_node_def( + name="node_processing", + parents=[], + input_schema=pa.schema( + [ + ('x6', pa.int64()), + ('x7', pa.uint8()), + ('x8', pa.uint16()), + ('x9', pa.uint32()), + ('x10', pa.uint64()), + ] + ), + output_schema=pa.schema( + [ + ('x6', pa.int64()), + ('x7', pa.uint8()), + ('x8', pa.uint16()), + ('x9', pa.uint32()), + ('x10', pa.uint64()), + ] + ), + ), + make_dot_product_node_def( + name="node_dot_product", + parents=['node_processing'], + weight_dict={ + "x6": -0.53, + "x7": 0.92, + "x8": -0.72, + "x9": 0.146, + "x10": -0.07, + }, + input_types=[ + "DT_INT64", + "DT_UINT8", + "DT_UINT16", + "DT_UINT32", + "DT_UINT64", + ], + output_col_name="y", + ), + make_merge_y_node_def( + "node_merge_y", + ["node_dot_product"], + LinkFunctionType.LF_LOGIT, + input_col_name="y", + output_col_name="score", + yhat_scale=1.2, + ), + ], + }, + executions={ + "alice": [ + ExecutionDef( + nodes=["node_processing", "node_dot_product"], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_ALL, session_run=False + ), + ), + ExecutionDef( + nodes=["node_merge_y"], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_ANYONE, session_run=False + ), + ), + ], + "bob": [ + ExecutionDef( + nodes=["node_processing", "node_dot_product"], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_ALL, session_run=False + ), + ), + ExecutionDef( + nodes=["node_merge_y"], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_ANYONE, session_run=False + ), + ), + ], + }, + feature_mappings={ + "alice": { + "a": "a", + "b": "b", + "c": "c", + "v24": "x24", + "v22": "x22", + "v21": "x21", + "v25": "x25", + "v23": "x23", + }, + "bob": { + "v6": "x6", + "v7": "x7", + "v8": "x8", + "v9": "x9", + "v10": "x10", + }, + }, + ).exec() + + # sgb + MockFeatureTest( + path='sgb_model', + nodes={ + "alice": [ + make_tree_select_node_def( + name="node_tree_select_0", + parents=[], + output_col_name="selects", + root_node_id=0, + feature_dict={ + "x1": "DT_FLOAT", + "x2": "DT_INT16", + "x3": "DT_INT8", + "x4": "DT_UINT8", + "x5": "DT_DOUBLE", + "x6": "DT_INT64", + "x7": "DT_INT16", + "x8": "DT_FLOAT", + "x9": "DT_FLOAT", + "x10": "DT_FLOAT", + }, + tree_nodes=[ + { + "nodeId": 0, + "lchildId": 1, + "rchildId": 2, + "isLeaf": False, + "splitFeatureIdx": 3, + "splitValue": -0.154862225, + }, + { + "nodeId": 1, + "lchildId": 3, + "rchildId": 4, + "isLeaf": False, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 2, + "lchildId": 5, + "rchildId": 6, + "isLeaf": False, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 3, + "lchildId": 7, + "rchildId": 8, + "isLeaf": False, + "splitFeatureIdx": 2, + "splitValue": -0.208345324, + }, + { + "nodeId": 4, + "lchildId": 9, + "rchildId": 10, + "isLeaf": False, + "splitFeatureIdx": 2, + "splitValue": 0.301087976, + }, + { + "nodeId": 5, + "lchildId": 11, + "rchildId": 12, + "isLeaf": False, + "splitFeatureIdx": 1, + "splitValue": -0.300848633, + }, + { + "nodeId": 6, + "lchildId": 13, + "rchildId": 14, + "isLeaf": False, + "splitFeatureIdx": 2, + "splitValue": 0.0800122, + }, + { + "nodeId": 7, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": -0.116178043, + }, + { + "nodeId": 8, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": 0.16241236, + }, + { + "nodeId": 9, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": -0.418656051, + }, + { + "nodeId": 10, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": -0.0926064253, + }, + { + "nodeId": 11, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": 0.15993154, + }, + { + "nodeId": 12, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": 0.358381808, + }, + { + "nodeId": 13, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": -0.104386188, + }, + { + "nodeId": 14, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": -1, + "leafWeight": 0.194736511, + }, + ], + ), + make_tree_select_node_def( + name="node_tree_select_1", + parents=[], + output_col_name="selects", + feature_dict={ + "x1": "DT_FLOAT", + "x2": "DT_INT16", + "x3": "DT_INT8", + "x4": "DT_UINT8", + "x5": "DT_DOUBLE", + "x6": "DT_INT64", + "x7": "DT_INT16", + "x8": "DT_FLOAT", + "x9": "DT_FLOAT", + "x10": "DT_FLOAT", + }, + root_node_id=0, + tree_nodes=[ + { + "nodeId": 0, + "lchildId": 1, + "rchildId": 2, + "isLeaf": False, + "splitFeatureIdx": 6, + "splitValue": -0.261598617, + }, + { + "nodeId": 1, + "lchildId": 3, + "rchildId": 4, + "isLeaf": False, + "splitFeatureIdx": 4, + "splitValue": -0.0992445946, + }, + { + "nodeId": 2, + "lchildId": 5, + "rchildId": 6, + "isLeaf": False, + "splitFeatureIdx": 4, + "splitValue": 0.3885355, + }, + { + "nodeId": 3, + "lchildId": 7, + "rchildId": 8, + "isLeaf": False, + "splitFeatureIdx": 2, + "splitValue": 0, + }, + { + "nodeId": 4, + "lchildId": 9, + "rchildId": 10, + "isLeaf": False, + "splitFeatureIdx": 1, + "splitValue": 0.149844646, + }, + { + "nodeId": 5, + "lchildId": 11, + "rchildId": 12, + "isLeaf": False, + "splitFeatureIdx": 1, + "splitValue": 0.0529966354, + }, + { + "nodeId": 6, + "lchildId": 13, + "rchildId": 14, + "isLeaf": False, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 7, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": -0.196025193, + }, + { + "nodeId": 8, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": 0.0978358239, + }, + { + "nodeId": 9, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": -0.381145447, + }, + { + "nodeId": 10, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": -0.0979942083, + }, + { + "nodeId": 11, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": 0.117580406, + }, + { + "nodeId": 12, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": 0.302539676, + }, + { + "nodeId": 13, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": 0.171336576, + }, + { + "nodeId": 14, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + "leafWeight": -0.125806138, + }, + ], + ), + make_tree_merge_node_def( + "node_tree_merge_0", + ["node_tree_select_0"], + input_col_name="selects", + output_col_name="weights", + leaf_node_weights={ + 7: -0.116178043, + 8: 0.16241236, + 9: -0.418656051, + 10: -0.0926064253, + 11: 0.15993154, + 12: 0.358381808, + 13: -0.104386188, + 14: 0.194736511, + }, + ), + make_tree_merge_node_def( + "node_tree_merge_1", + ["node_tree_select_1"], + input_col_name="selects", + output_col_name="weights", + leaf_node_weights={ + 7: -0.196025193, + 8: 0.0978358239, + 9: -0.381145447, + 10: -0.0979942083, + 11: 0.117580406, + 12: 0.302539676, + 13: 0.171336576, + 14: -0.125806138, + }, + ), + make_tree_ensemble_predict_node_def( + "node_tree_ensemble_predict", + ["node_tree_merge_0", "node_tree_merge_1"], + input_col_name="weights", + output_col_name="scores", + num_trees=2, + func_type='LF_SIGMOID_RAW', + ), + ], + "bob": [ + make_tree_select_node_def( + name="node_tree_select_0", + parents=[], + output_col_name="selects", + feature_dict={ + "x11": "DT_FLOAT", + "x12": "DT_INT16", + "x13": "DT_INT8", + "x14": "DT_UINT8", + "x15": "DT_DOUBLE", + "x16": "DT_INT64", + "x17": "DT_INT16", + "x18": "DT_FLOAT", + "x19": "DT_FLOAT", + "x20": "DT_FLOAT", + }, + root_node_id=0, + tree_nodes=[ + { + "nodeId": 0, + "lchildId": 1, + "rchildId": 2, + "isLeaf": False, + "splitFeatureIdx": 9, + "splitValue": 0, + }, + { + "nodeId": 1, + "lchildId": 3, + "rchildId": 4, + "isLeaf": False, + "splitFeatureIdx": 7, + "splitValue": -0.107344508, + }, + { + "nodeId": 2, + "lchildId": 5, + "rchildId": 6, + "isLeaf": False, + "splitFeatureIdx": 7, + "splitValue": 0.210497797, + }, + { + "nodeId": 3, + "lchildId": 7, + "rchildId": 8, + "isLeaf": False, + "splitFeatureIdx": 3, + "splitValue": 0, + }, + { + "nodeId": 4, + "lchildId": 9, + "rchildId": 10, + "isLeaf": False, + "splitFeatureIdx": 3, + "splitValue": 0, + }, + { + "nodeId": 5, + "lchildId": 11, + "rchildId": 12, + "isLeaf": False, + "splitFeatureIdx": 3, + "splitValue": 0, + }, + { + "nodeId": 6, + "lchildId": 13, + "rchildId": 14, + "isLeaf": False, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 7, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 8, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 9, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 10, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 11, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 12, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 13, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 14, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + ], + ), + make_tree_select_node_def( + name="node_tree_select_1", + parents=[], + output_col_name="selects", + feature_dict={ + "x11": "DT_FLOAT", + "x12": "DT_INT16", + "x13": "DT_INT8", + "x14": "DT_UINT8", + "x15": "DT_DOUBLE", + "x16": "DT_INT64", + "x17": "DT_INT16", + "x18": "DT_FLOAT", + "x19": "DT_FLOAT", + "x20": "DT_FLOAT", + }, + root_node_id=0, + tree_nodes=[ + { + "nodeId": 0, + "lchildId": 1, + "rchildId": 2, + "isLeaf": False, + "splitFeatureIdx": 9, + "splitValue": 0, + }, + { + "nodeId": 1, + "lchildId": 3, + "rchildId": 4, + "isLeaf": False, + "splitFeatureIdx": 7, + "splitValue": 0, + }, + { + "nodeId": 2, + "lchildId": 5, + "rchildId": 6, + "isLeaf": False, + "splitFeatureIdx": 7, + "splitValue": 0, + }, + { + "nodeId": 3, + "lchildId": 7, + "rchildId": 8, + "isLeaf": False, + "splitFeatureIdx": 4, + "splitValue": -0.548508525, + }, + { + "nodeId": 4, + "lchildId": 9, + "rchildId": 10, + "isLeaf": False, + "splitFeatureIdx": 3, + "splitValue": 0, + }, + { + "nodeId": 5, + "lchildId": 11, + "rchildId": 12, + "isLeaf": False, + "splitFeatureIdx": 3, + "splitValue": 0, + }, + { + "nodeId": 6, + "lchildId": 13, + "rchildId": 14, + "isLeaf": False, + "splitFeatureIdx": 9, + "splitValue": -0.405750543, + }, + { + "nodeId": 7, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 8, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 9, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 10, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 11, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 12, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 13, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + { + "nodeId": 14, + "lchildId": -1, + "rchildId": -1, + "isLeaf": True, + "splitFeatureIdx": -1, + "splitValue": 0, + }, + ], + ), + make_tree_merge_node_def( + "node_tree_merge_0", + ["node_tree_select_0"], + input_col_name="selects", + output_col_name="weights", + ), + make_tree_merge_node_def( + "node_tree_merge_1", + ["node_tree_select_1"], + input_col_name="selects", + output_col_name="weights", + ), + make_tree_ensemble_predict_node_def( + "node_tree_ensemble_predict", + ["node_tree_merge_0", "node_tree_merge_1"], + input_col_name="weights", + output_col_name="scores", + num_trees=2, + func_type='LF_SIGMOID_RAW', + ), + ], + }, + executions={ + "alice": [ + ExecutionDef( + nodes=["node_tree_select_0", "node_tree_select_1"], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_ALL, session_run=False + ), + ), + ExecutionDef( + nodes=[ + "node_tree_merge_0", + "node_tree_merge_1", + "node_tree_ensemble_predict", + ], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_SPECIFIED, + session_run=False, + specific_flag=True, + ), + ), + ], + "bob": [ + ExecutionDef( + nodes=["node_tree_select_0", "node_tree_select_1"], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_ALL, session_run=False + ), + ), + ExecutionDef( + nodes=[ + "node_tree_merge_0", + "node_tree_merge_1", + "node_tree_ensemble_predict", + ], + config=RuntimeConfig( + dispatch_type=DispatchType.DP_SPECIFIED, session_run=False + ), + ), + ], + }, + specific_party="alice", + ).exec() + PredefinedErrorTest('model_path').exec() PredefineTest('model_path').exec() CsvTest('model_path').exec() diff --git a/.ci/test_common.py b/.ci/test_common.py new file mode 100644 index 0000000..4796083 --- /dev/null +++ b/.ci/test_common.py @@ -0,0 +1,639 @@ +#! python3 + +# Copyright 2024 Ant Group Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import csv +import hashlib +import json +import os +import subprocess +import sys +import tarfile +import time +from dataclasses import dataclass +from typing import Any, Dict, List + +from google.protobuf.json_format import MessageToJson + +import pyarrow as pa +from google.protobuf.json_format import MessageToJson + +from secretflow_serving_lib import get_op +from secretflow_serving_lib.attr_pb2 import ( + AttrValue, + DoubleList, + StringList, + Int32List, + BoolList, +) +from secretflow_serving_lib.bundle_pb2 import FileFormatType, ModelBundle, ModelManifest +from secretflow_serving_lib.feature_pb2 import ( + Feature, + FeatureField, + FeatureParam, + FeatureValue, + FieldType, +) +from secretflow_serving_lib.graph_pb2 import ( + GraphDef, + NodeDef, +) +from secretflow_serving_lib.link_function_pb2 import LinkFunctionType + +# set up global env +g_script_name = os.path.abspath(sys.argv[0]) +g_script_dir = os.path.dirname(g_script_name) +g_repo_dir = os.path.dirname(g_script_dir) +g_clean_up_service = True +g_clean_up_files = True + + +class ModelBuilder: + def __init__(self, name, desc, graph_def: GraphDef): + self.name = name + self.desc = desc + self.bundle = ModelBundle(name=name, desc=desc, graph=graph_def) + + def dump_tar_gz(self, path=".", filename=None): + if filename is None: + filename = "model.tar.gz" + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + + filename = os.path.join(path, filename) + + model_graph_filename = "model_graph.json" + + # dump manifest + dump_pb_json_file( + ModelManifest( + bundle_path=model_graph_filename, bundle_format=FileFormatType.FF_JSON + ), + os.path.join(path, "MANIFEST"), + ) + # dump model file + dump_pb_json_file(self.bundle, os.path.join(path, model_graph_filename)) + + with tarfile.open(filename, "w:gz") as model_tar: + model_tar.add(os.path.join(path, "MANIFEST"), arcname="MANIFEST") + model_tar.add( + os.path.join(path, model_graph_filename), arcname=model_graph_filename + ) + print( + f'tar: {filename} <- ({os.path.join(path, "MANIFEST")}, {os.path.join(path, model_graph_filename)})' + ) + os.remove(os.path.join(path, "MANIFEST")) + os.remove(os.path.join(path, model_graph_filename)) + with open(filename, "rb") as ifile: + return filename, hashlib.sha256(ifile.read()).hexdigest() + + +def make_processing_node_def( + name, + parents, + input_schema: pa.Schema, + output_schema: pa.Schema, + trace_content=None, +): + op_def = get_op("ARROW_PROCESSING") + attrs = { + "input_schema_bytes": AttrValue(by=input_schema.serialize().to_pybytes()), + "output_schema_bytes": AttrValue(by=output_schema.serialize().to_pybytes()), + "content_json_flag": AttrValue(b=True), + } + if trace_content: + attrs["trace_content"] = AttrValue(by=trace_content) + + return NodeDef( + name=name, + parents=parents, + op=op_def.name, + attr_values=attrs, + op_version=op_def.version, + ) + + +def make_dot_product_node_def( + name, parents, weight_dict, output_col_name, input_types, intercept=None +): + op_def = get_op("DOT_PRODUCT") + attrs = { + "feature_names": AttrValue(ss=StringList(data=list(weight_dict.keys()))), + "feature_weights": AttrValue(ds=DoubleList(data=list(weight_dict.values()))), + "output_col_name": AttrValue(s=output_col_name), + "input_types": AttrValue(ss=StringList(data=input_types)), + } + if intercept: + attrs["intercept"] = AttrValue(d=intercept) + + return NodeDef( + name=name, + parents=parents, + op=op_def.name, + attr_values=attrs, + op_version=op_def.version, + ) + + +def make_merge_y_node_def( + name, + parents, + link_function: LinkFunctionType, + input_col_name: str, + output_col_name: str, + yhat_scale: float = None, +): + op_def = get_op("MERGE_Y") + attrs = { + "link_function": AttrValue(s=LinkFunctionType.Name(link_function)), + "input_col_name": AttrValue(s=input_col_name), + "output_col_name": AttrValue(s=output_col_name), + } + if yhat_scale: + attrs["yhat_scale"] = AttrValue(d=yhat_scale) + + return NodeDef( + name=name, + parents=parents, + op=op_def.name, + attr_values=attrs, + op_version=op_def.version, + ) + + +def make_tree_select_node_def( + name, + parents, + root_node_id, + tree_nodes: Dict, + output_col_name, + feature_dict: Dict[str, str], +): + op_def = get_op("TREE_SELECT") + attrs = { + "input_feature_names": AttrValue(ss=StringList(data=list(feature_dict.keys()))), + "input_feature_types": AttrValue( + ss=StringList(data=list(feature_dict.values())) + ), + "output_col_name": AttrValue(s=output_col_name), + "root_node_id": AttrValue(i32=root_node_id), + "node_ids": AttrValue( + i32s=Int32List( + data=[node['nodeId'] for node in tree_nodes if 'nodeId' in node] + ) + ), + "lchild_ids": AttrValue( + i32s=Int32List( + data=[node['lchildId'] for node in tree_nodes if 'lchildId' in node] + ) + ), + "rchild_ids": AttrValue( + i32s=Int32List( + data=[node['rchildId'] for node in tree_nodes if 'rchildId' in node] + ) + ), + "leaf_flags": AttrValue( + bs=BoolList( + data=[node['isLeaf'] for node in tree_nodes if 'isLeaf' in node] + ) + ), + "split_feature_idxs": AttrValue( + i32s=Int32List( + data=[ + node['splitFeatureIdx'] + for node in tree_nodes + if 'splitFeatureIdx' in node + ] + ) + ), + "split_values": AttrValue( + ds=DoubleList( + data=[node['splitValue'] for node in tree_nodes if 'splitValue' in node] + ) + ), + } + + return NodeDef( + name=name, + parents=parents, + op=op_def.name, + attr_values=attrs, + op_version=op_def.version, + ) + + +def make_tree_merge_node_def( + name, + parents, + input_col_name, + output_col_name, + leaf_node_weights: Dict[int, float] = None, +): + op_def = get_op("TREE_MERGE") + attrs = { + "input_col_name": AttrValue(s=input_col_name), + "output_col_name": AttrValue(s=output_col_name), + } + if leaf_node_weights: + attrs['leaf_node_ids'] = AttrValue( + i32s=Int32List(data=list(leaf_node_weights.keys())) + ) + attrs['leaf_weights'] = AttrValue( + ds=DoubleList(data=list(leaf_node_weights.values())) + ) + + return NodeDef( + name=name, + parents=parents, + op=op_def.name, + attr_values=attrs, + op_version=op_def.version, + ) + + +def make_tree_ensemble_predict_node_def( + name, + parents, + input_col_name, + output_col_name, + num_trees, + func_type, +): + op_def = get_op("TREE_ENSEMBLE_PREDICT") + attrs = { + "input_col_name": AttrValue(s=input_col_name), + "output_col_name": AttrValue(s=output_col_name), + "num_trees": AttrValue(i32=num_trees), + "func_type": AttrValue(s=func_type), + } + + return NodeDef( + name=name, + parents=parents, + op=op_def.name, + attr_values=attrs, + op_version=op_def.version, + ) + + +def dump_pb_json_file(pb_obj, file_name, indent=2): + json_str = MessageToJson(pb_obj) + with open(file_name, "w") as file: + file.write(json_str) + + +def dump_json(obj, filename, indent=2): + with open(filename, "w") as ofile: + json.dump(obj, ofile, indent=indent) + + +@dataclass +class PartyConfig: + id: str + feature_mapping: Dict[str, str] + cluster_ip: str + metrics_port: int + brpc_builtin_service_port: int + channel_protocol: str + model_id: str + graph_def: GraphDef + query_datas: List[str] = None + query_context: str = None + csv_dict: Dict[str, Any] = None + + +class ConfigDumper: + def __init__( + self, + party_configs: List[PartyConfig], + log_config_filename: str, + serving_config_filename: str, + tar_name: str, + service_id: str, + ): + self.service_id = service_id + self.party_configs = party_configs + self.parties = [] + self.log_config = log_config_filename + self.serving_config = serving_config_filename + self.tar_name = tar_name + for config in self.party_configs: + self.parties.append({"id": config.id, "address": config.cluster_ip}) + + def _dump_logging_config(self, path: str, logging_path: str): + with open(os.path.join(path, self.log_config), "w") as ofile: + json.dump({"systemLogPath": os.path.abspath(logging_path)}, ofile, indent=2) + + def _dump_model_tar_gz(self, path: str, graph_def: GraphDef): + graph_def_str = MessageToJson(graph_def, preserving_proto_field_name=True) + print(f"graph_def: \n {graph_def_str}") + return ModelBuilder("test_model", "just for test", graph_def).dump_tar_gz( + path, self.tar_name + ) + + def make_csv_config(self, data_dict: Dict[str, List[Any]], path: str): + filename = "feature_source.csv" + file_path = os.path.join(path, filename) + with open(file_path, "w") as ofile: + writer = csv.DictWriter(ofile, fieldnames=list(data_dict.keys())) + writer.writeheader() + rows = [] + for key, value in data_dict.items(): + if len(rows) == 0: + rows = [{} for _ in value] + assert len(value) == len( + rows + ), f"row count {len(value)} of {key} in data_dict is diff with {len(rows)}." + for i in range(len(value)): + rows[i][key] = value[i] + print("CSV Rows: ", rows) + for row in rows: + writer.writerow(row) + return {"csv_opts": {"file_path": file_path, "id_name": "id"}} + + def _dump_serving_config( + self, path: str, config: PartyConfig, model_name: str, model_sha256: str + ): + config_dict = { + "id": self.service_id, + "serverConf": { + "featureMapping": config.feature_mapping, + "metricsExposerPort": config.metrics_port, + "brpcBuiltinServicePort": config.brpc_builtin_service_port, + }, + "modelConf": { + "modelId": config.model_id, + "basePath": os.path.abspath(path), + "sourcePath": os.path.abspath(model_name), + "sourceSha256": model_sha256, + "sourceType": "ST_FILE", + }, + "clusterConf": { + "selfId": config.id, + "parties": self.parties, + "channel_desc": {"protocol": config.channel_protocol}, + }, + "featureSourceConf": self.make_csv_config(config.csv_dict, path) + if config.csv_dict + else {"mockOpts": {}}, + } + dump_json(config_dict, os.path.join(path, self.serving_config)) + + def dump(self, path="."): + for config in self.party_configs: + config_path = os.path.join(path, config.id) + if not os.path.exists(config_path): + os.makedirs(config_path, exist_ok=True) + self._dump_logging_config(config_path, os.path.join(config_path, "log")) + model_name, model_sha256 = self._dump_model_tar_gz( + config_path, config.graph_def + ) + self._dump_serving_config(config_path, config, model_name, model_sha256) + + +# for every testcase, there should be a TestConfig instance +class TestConfig: + def __init__( + self, + model_path: str, + party_config: List[PartyConfig], + header_dict: Dict[str, str] = None, + service_spec_id: str = None, + predefined_features: Dict[str, List[Any]] = None, + predefined_types: Dict[str, str] = None, + log_config_name=None, + serving_config_name=None, + tar_name=None, + specific_party=None, + ): + self.header_dict = header_dict + self.service_spec_id = service_spec_id + self.predefined_features = predefined_features + self.predefined_types = predefined_types + self.model_path = os.path.join(g_script_dir, model_path) + self.party_config = party_config + self.log_config_name = ( + log_config_name if log_config_name is not None else "logging.config" + ) + self.serving_config_name = ( + serving_config_name if serving_config_name is not None else "serving.config" + ) + self.tar_name = tar_name if tar_name is not None else "model.tar.gz" + self.background_proc = [] + self.specific_party = specific_party + + def dump_config(self): + ConfigDumper( + self.party_config, + self.log_config_name, + self.serving_config_name, + self.tar_name, + self.service_spec_id, + ).dump(self.model_path) + + def get_server_start_args(self): + def merge_path(dir, party_id, filename): + return os.path.abspath(os.path.join(dir, party_id, filename)) + + return [ + f"--serving_config_file={merge_path(self.model_path, config.id, self.serving_config_name)} " + f"--logging_config_file={merge_path(self.model_path, config.id, self.log_config_name)} " + for config in self.party_config + ] + + def get_party_ids(self): + return [config.id for config in self.party_config] + + def make_request(self): + if self.predefined_features: + pre_features = [] + for name, data_list in self.predefined_features.items(): + pre_features.append( + make_feature(name, data_list, self.predefined_types[name]) + ) + else: + pre_features = None + + if self.party_config[0].query_datas: + fs_param = {} + for config in self.party_config: + fs_param[config.id] = FeatureParam( + query_datas=config.query_datas, query_context=config.query_context + ) + else: + fs_param = None + + return PredictRequest( + self.header_dict, self.service_spec_id, fs_param, pre_features + ) + + def make_model_info_request(self): + body_dict = {"service_spec": {"id": self.service_spec_id}} + return json.dumps(body_dict) + + def make_predict_curl_cmd(self, party: str): + url = None + for p_cfg in self.party_config: + if p_cfg.id == party: + url = f"http://{p_cfg.cluster_ip}/PredictionService/Predict" + break + if not url: + raise Exception( + f"{party} is not in TestConfig({self.config.get_party_ids()})" + ) + curl_wrapper = CurlWrapper( + url=url, + header="Content-Type: application/json", + data=self.make_request().to_json(), + ) + return curl_wrapper.cmd() + + def make_get_model_info_curl_cmd(self, party: str): + url = None + for p_cfg in self.party_config: + if p_cfg.id == party: + url = f"http://{p_cfg.cluster_ip}/ModelService/GetModelInfo" + break + if not url: + raise Exception( + f"{party} is not in TestConfig({self.config.get_party_ids()})" + ) + curl_wrapper = CurlWrapper( + url=url, + header="Content-Type: application/json", + data=self.make_model_info_request(), + ) + return curl_wrapper.cmd() + + def _exe_cmd(self, cmd, background=False): + print("Execute: ", cmd) + if not background: + ret = subprocess.run(cmd, shell=True, check=True, capture_output=True) + ret.check_returncode() + return ret + else: + proc = subprocess.Popen(cmd.split(), shell=False) + self.background_proc.append(proc) + return proc + + def finish(self): + if g_clean_up_service: + for proc in self.background_proc: + proc.kill() + proc.wait() + if g_clean_up_files: + os.system(f"rm -rf {self.model_path}") + + def exe_start_server_scripts(self, start_interval_s=0): + for arg in self.get_server_start_args(): + self._exe_cmd( + f"./bazel-bin/secretflow_serving/server/secretflow_serving {arg}", True + ) + if start_interval_s: + time.sleep(start_interval_s) + + # wait 10s for servers be ready + time.sleep(10) + + def exe_curl_request_scripts(self, party: str): + return self._exe_cmd(self.make_predict_curl_cmd(party)) + + def exe_get_model_info_request_scripts(self, party: str): + return self._exe_cmd(self.make_get_model_info_curl_cmd(party)) + + +def make_feature(name: str, value: List[Any], f_type: str): + assert len(value) != 0 + + field_type = FieldType.Value(f_type) + + if field_type == FieldType.FIELD_BOOL: + f_value = FeatureValue(bs=[bool(v) for v in value]) + elif field_type == FieldType.FIELD_FLOAT: + f_value = FeatureValue(fs=[float(v) for v in value]) + elif field_type == FieldType.FIELD_DOUBLE: + f_value = FeatureValue(ds=[float(v) for v in value]) + elif field_type == FieldType.FIELD_INT32: + f_value = FeatureValue(i32s=[int(v) for v in value]) + elif field_type == FieldType.FIELD_INT64: + f_value = FeatureValue(i64s=[int(v) for v in value]) + else: + f_value = FeatureValue(ss=[str(v) for v in value]) + + return Feature(field=FeatureField(name=name, type=field_type), value=f_value) + + +class PredictRequest: + def __init__( + self, + header_dict: Dict[str, str] = None, + service_spec_id: str = None, + party_param_dict: Dict[str, FeatureParam] = None, + predefined_feature: List[Feature] = None, + ): + self.header_dict = header_dict + self.service_spec_id = service_spec_id + self.party_param_dict = party_param_dict + self.predefined_feature = predefined_feature + + def to_json(self): + ret = {} + if self.header_dict: + ret["header"] = {"data": self.header_dict} + if self.service_spec_id: + ret["service_spec"] = {"id": self.service_spec_id} + if self.party_param_dict: + ret["fs_params"] = { + k: json.loads(MessageToJson(v, preserving_proto_field_name=True)) + for k, v in self.party_param_dict.items() + } + if self.predefined_feature: + ret["predefined_features"] = [ + json.loads(MessageToJson(i, preserving_proto_field_name=True)) + for i in self.predefined_feature + ] + return json.dumps(ret) + + +class CurlWrapper: + def __init__(self, url: str, header: str, data: str): + self.url = url + self.header = header + self.data = data + + def cmd(self): + return f'curl --location "{self.url}" --header "{self.header}" --data \'{self.data}\'' + + def exe(self): + return os.popen(self.cmd()) + + +# base class +class TestCase: + def __init__(self, path: str): + self.path = path + + def exec(self): + config = self.get_config(self.path) + try: + self.test(config) + finally: + config.finish() + + def test(config: TestConfig): + raise NotImplementedError + + def get_config(self, path: str) -> TestConfig: + raise NotImplementedError diff --git a/.ci/test_data/bin_sgb/alice/alice.csv b/.ci/test_data/bin_sgb/alice/alice.csv new file mode 100644 index 0000000..33e3bc2 --- /dev/null +++ b/.ci/test_data/bin_sgb/alice/alice.csv @@ -0,0 +1,20 @@ +id,a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,y +1,1.0970639814699807,-2.0733350146975935,1.2699336881399383,0.9843749048031144,1.568466329243428,3.2835146709868264,2.652873983743168,2.532475216403245,2.2175150059646405,2.255746885296269,2.4897339267376193,-0.5652650590684639,2.833030865855184,2.4875775569611043,-0.21400164666895383,0 +2,1.8298206075464458,-0.35363240824381126,1.6859547105508974,1.9087082542365938,-0.8269624468508425,-0.48707167257589423,-0.023845855198769264,0.5481441558908369,0.0013923632994608738,-0.8686524574634664,0.49925460067605626,-0.8762436030602548,0.263326965842778,0.7424019483418791,-0.6053508469797809,0 +3,1.5798881149312178,0.4561869517641946,1.5665031298586416,1.5588836327586924,0.9422104400684553,1.05292554434161,1.3634784515699176,2.0372307557008114,0.939684816618985,-0.3980079103689868,1.2286759457296228,-0.7800833765050336,0.8509283007136554,1.1813360556534467,-0.29700501198189755,0 +4,-0.7689092872596208,0.25373211176219296,-0.5926871666544732,-0.7644637923250287,3.283553480279431,3.402908991274548,1.9158971800569968,1.451707356849496,2.867382930831859,4.9109192850190375,0.3263734407153149,-0.1104090440232948,0.28659340454448906,-0.28837814827701536,0.6897016600113287,0 +5,1.7502966326234184,-1.1518164326195182,1.7765731510760563,1.826229278440991,0.2803718299176319,0.5393404523102987,1.3710114342311053,1.4284927727540695,-0.009560466894930265,-0.562449981040552,1.2705427819622865,-0.7902437023297363,1.2731894116191806,1.1903567566057145,1.483067159789666,0 +6,-0.47637466522134253,-0.8353353034209873,-0.38714806746331654,-0.5056504544836544,2.237421483589421,1.2443354863901803,0.8663015959315467,0.8246556464496959,1.005401797785333,1.8900050384577884,-0.25507029351590493,-0.5926616519172156,-0.3213041853640514,-0.28925821666260243,0.1563467021771524,0 +7,1.170907672469935,0.1606494267038018,1.13812504737607,1.0952949067351319,-0.12313622594851477,0.08829524233446058,0.30007239923229057,0.646935108208041,-0.06432461786688697,-0.7623321531499545,0.14988307073451626,-0.8049398878976097,0.1554102927156918,0.2986274649095823,-0.909029826096615,0 +8,-0.11851677806771978,0.3584501324528832,-0.07286683964196806,-0.21896491102859386,1.6040490502192788,1.140102349631058,0.061025749450609096,0.28195025826327874,1.403354628181551,1.6603531811406034,0.6436230014783456,0.29056095727300535,0.49005098553179377,0.23372242147253355,0.588030871174189,0 +9,-0.32016685733682465,0.5888297779724025,-0.1840803802864819,-0.38420727288116363,2.20183876261357,1.6840098087195687,1.2190962838971586,1.150691583078798,1.965599911493639,1.5724617295747656,-0.3568500160815189,-0.3898180042026168,-0.2277433999465317,-0.3524031233284771,-0.43667734156472254,0 +10,-0.4735345232598054,1.105438680046475,-0.329481787129124,-0.5090633776200244,1.5826994176337676,2.563358453378346,1.738872087519092,0.941760326219959,0.7972980240918982,2.783095594691288,-0.3882501432560169,0.6933453024665736,-0.4094196340641493,-0.36076377299155454,0.03600848981581628,0 +11,0.537556015047254,0.9192733099296918,0.4420106633418918,0.40645325371116653,-1.0176858312814026,-0.7135418515343508,-0.700684347306461,-0.40468555131478307,-1.0354755617695854,-0.8261243357380614,-0.09265584261332961,-0.054164383207976445,-0.19804156330604922,0.0038045557379028138,-1.0040336779608279,0 +12,0.4693926079703736,-0.3257076027262936,0.4790818435567294,0.3586723298019898,0.05264241567218774,0.4711151264316007,0.13484897953024616,0.442130888521722,0.1109206652433743,-0.28034677359536736,0.36318738291989877,-0.42084328484590516,0.34550204721477934,0.3041278923195017,-0.4233434676188681,0 +13,1.4322007329313104,1.2822957816574192,1.6653596104315431,1.3313554236673744,0.07399204825769778,2.6808576257249923,1.4777286885979273,1.6219476402159576,2.1371942512057704,2.155096997212811,1.9862491289396356,4.265788436187907,4.061201810939133,1.669113958365099,-1.3007123732560884,0 +14,0.48927360170113043,1.084495075908337,0.4832008635806006,0.36350730424518046,-0.8789132194755842,-0.07847777648013507,0.1328401841539294,0.12176962800483532,0.12917538223402653,-1.3350441923854053,-0.006756644135967547,-0.25192786801022393,0.01828681355879779,-0.08266216314603006,0.9093772332692801,0 +15,-0.11283649414464653,0.7726680809627255,0.06717984116964147,-0.21782726998313734,1.1912894868994104,2.3681582154476257,1.5568250065403952,0.8081474977596147,0.939684816618985,1.9878197184262187,-0.6968375999709101,-0.08682257335880633,-0.3985289606293058,-0.4648318595872295,-0.20400124120956314,0 +16,0.11721500473982457,1.9199121743074004,0.19610516791680038,0.011122990415001428,1.2482218404607712,1.0453449525773104,0.9428869196536189,0.6376492745698704,1.794005571781509,1.1301692636305567,-0.12694333780387335,-0.3335733433872988,0.006406078902604803,-0.17132905299393072,-0.4780123507968712,0 +17,0.15697699220133832,0.19555543360069827,0.11413666944176952,0.08421642758558748,0.16437215953635811,-0.612909495863271,-0.18643273096939825,0.09468594656017092,-0.8237208446780198,-0.5071634227975256,0.24372253125600438,0.041995843347244696,0.16283575187581223,0.11139291587592617,-0.44101085059712525,0 +18,0.5687975766241574,0.3235441255559867,0.664437744630919,0.40929735632480796,1.468834710511046,1.8545731234163139,1.0470931798000451,1.389801799261692,1.2865244394413775,1.5256807956768208,0.5920112981915273,-0.26099958749656577,0.4890609243104445,0.3045679265122953,-0.0049931725676859795,0 +19,1.6139698184696574,0.6656229931455752,1.5665031298586416,1.7209974817362566,0.13875260043374565,-0.03109907795326128,0.7420073820219539,1.188092857454763,-0.8383246182705409,-1.254240761107136,1.2741519919823439,-0.3626028457435921,1.484567482377281,1.5855074617343239,-0.18233369604754976,0 diff --git a/.ci/test_data/bin_sgb/alice/s_model.tar.gz b/.ci/test_data/bin_sgb/alice/s_model.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..a04f57688e493790e637b5b06cc8e1a6c64afecf GIT binary patch literal 1666 zcmV-|27UP-iwFS7`LJaI|Ls|QXd7i1e=nbxw@I7bCQ9729!#*458^qIt%?W z$r#?mx7Xy_M)OhcF59{}R#bF|e@uo&|ItlQ<_4lL*&m86Z0sLJCup4vac&?35eGW; zA6uWhT)H+*u36W0o8*D#-uJ!tmwTV*_dd__UXtFnzV5D2B#LgKctIK)8?_dsu!YH{ z>wWb;=k+%PP^}lOfn;SxkYM@MaB`t@cZux7>`*oxPl^MAGRkhPWmB2Bm?XoAq{u#+ zpX6>SMZlf}JqY@gx`FyLj{Q3~2Md_hXKm2$X-k5RgHGn=(YX>hSP}^> z2z{>O{xQNeBlL@otDQB#H6c`;n@5iu;TjQY)Nx}*xBx;=>$o?Ka198(tmCGQaDIe7 z)Nxmga6W`)bewyxIL@r%xCS(<{28lCX!ett%zxbdDpu2 z9c#Jiw+XvKU410lPCEK~w)ggVOf>RX7*jK1O*F+kgmDs{K*Z;=LK5aEPZi^M0#*+G zb@k#DKuo0}6MwpYZv}|CH00c=Gv**bEX)JgJ|c;NB1*;Pt?-Ddl<=%bWJSux6u_83 z&7c-gD?n^a5IbUmERx-$Vj6DRJa)#~tFokbCR+1%f_f*I?@Xw!*qM5~PA4N0iY_6< zV`D75N$|oQJHY8;+|e48f@#MRuO0K;Wx6%)umR+C;SZI&s?remZ?awG#<`fgCG)PO z++olvP$#GhAk~H3Ro^IgHKn-I3DLRZ;7$#|-R!-1ogS~`899?C!%1O;F?kVt+=iJ% zx>Ecr#lKSgmn;5t@U{i-Tg=yXTrH0b5g(Doq!?3(m#K<|Lm^1To=`{BQ{Gv?@_2nN z^M;|AWF?WLg%qS-$KE!z?5XJs>I3zIHh=~|8$p{un?YN6Gti)3IQ8OFFMjpXz&?oS zG<>NbiMY(H=hyKyh%%WiR-2u6;3}u9n*Eq6TwNI-gVo=n_D)hf-5DiaJ^gLbJE_OM zMq#HsmP{yQmypbYvCm6;`8~NkyL!N&op2rhX-XfrE^b#Y>>Ctja^~bIOvLxd%mIEs zzi%!tlyT36P`S`@dn~ISVZyLh<7H;U3zWM3dIi;mO<*j=Mia@n79l(NC;1&Fi;c$4 z>Qy&qBd*cEUc@pJXO31 z+(eS!$|7x?8FyoaUc<6dMwKg>-M$rN_Zm94Y?7|{C|uCe!BxwW+gvlsuDj*-kZINJ zt`w3RDI_6$)Le_o@r-v6^Y9`v34 zUZd@d{}XNe#fh)fajp6A%v32SeFjeYZpz7$ZoL24x2jpw%|E`V?Yh5XMl*M|MvICg zbn?rEyFcN&HZva`#?+SFEB$04&F0M!C607Mtn`#^xXKSz{olOx%5}{z2 zXtVBxXJ?r|cou$9E_}^iaxkmEMz$9ubV2AB1W6&C7TSCtRryb`A{D7fMJiH}irm@q MAKv-j-vB580F^RAZvX%Q literal 0 HcmV?d00001 diff --git a/.ci/test_data/bin_sgb/bob/bob.csv b/.ci/test_data/bin_sgb/bob/bob.csv new file mode 100644 index 0000000..10adc03 --- /dev/null +++ b/.ci/test_data/bin_sgb/bob/bob.csv @@ -0,0 +1,20 @@ +id,b0,b1,b2,b3,b4,b5,b6,b7,b8,b9,b10,b11,b12,b13,b14 +1,1.3168615683959484,0.72402615808036,0.6608199414286064,1.1487566671861758,0.9070830809973359,1.8866896251792757,-1.3592934737640827,2.3036006236225606,2.0012374893299207,1.3076862710715387,2.616665023512603,2.1095263465722556,2.296076127561788,2.750622244124955,1.9370146123781782 +2,-0.6929262695890712,-0.44078005778479334,0.2601620674590054,-0.8054503802819919,-0.09944374032027478,1.8059274384794277,-0.36920322172940884,1.535125992343437,1.8904889885289908,-0.3756119566608087,-0.4304442186927949,-0.14674896831546966,1.087084295170027,-0.24388966756667946,0.2811899865404747 +3,0.8149735042940163,0.21307643458243766,1.42482746628562,0.2370355353748186,0.29355940411752984,1.5118702458799815,-0.023974383848897503,1.3474752102869065,1.456284548880901,0.5274074050914401,1.0829321669453351,0.8549739441841201,1.9550003461313663,1.1522550000669673,0.2013912093916699 +4,2.7442804054965433,0.8195183841461625,1.115007005037871,4.732680372580089,2.0475108774169515,-0.2814644639166429,0.1339840938605815,-0.2499393042673343,-0.5500212283270541,3.3942746991980925,3.8933974345995,1.9895882583898332,2.175786008218023,6.046041349536007,4.935010337204809 +5,-0.04851987993480875,0.8284707803398315,1.1442047448413237,-0.3610922722145709,0.49932813421778527,1.2985752399803827,-1.4667703761231092,1.3385394587604047,1.2207242455900345,0.22055616566106412,-0.31339451085024933,0.6131787584083571,0.7292592566157908,-0.8683529835650433,-0.39709961922436765 +6,0.44554364864660323,0.16002519787921415,-0.06912355365771157,0.13411880734830423,0.4868458399286153,-0.16549824711686112,-0.31383633263536465,-0.1150094562171625,-0.24432020786226535,2.04851283483916,1.7216164423470515,1.263243196357085,0.905887786285116,1.7540693875058049,2.2418016084326413 +7,-0.6515680104091791,-0.3101413874031051,-0.2280890259209542,-0.8296660809941128,-0.6112178061762416,1.3689833001802503,0.3228828919461442,1.368325297182076,1.2752195396349364,0.5186402268220005,0.02121498004746253,0.5095522502187443,1.196715796344091,0.26247566379986914,-0.014730478719676948 +8,0.2689327040405782,-0.23255395372464063,0.43534850627972166,-0.6880042318282048,0.6116687828203147,0.16376297558251873,0.40104791184361854,0.09944858041887174,0.028859427446694775,1.4479611233825667,0.7247855065358073,-0.021053851900291392,0.6241957346573126,0.4776404851153671,1.7264345060132755 +9,0.533290225555293,0.12056834058119148,0.07524304870380472,0.10748153656497132,-0.01736319908543009,-0.161356596516869,0.8228133317070733,-0.031609108636482475,-0.24836340709785498,1.6627569909838305,1.8183096792604587,1.2800345287026242,1.3916162428757601,2.3898571677839313,1.2886495480441378 +10,2.6095866154647336,1.5098476017468596,0.40939495978776363,-0.3211363660395712,2.3773460477247146,-0.24418960851671317,2.443109056665133,-0.2862780271417735,-0.2974091717382667,2.3202953611917776,5.112877271198196,3.9954328451526617,1.6200152036550601,2.370443800447194,6.846856039728261 +11,-0.9059213043655148,-0.6924418618957103,-0.6821138798646442,-0.7194846427539621,-0.2847868979473433,0.604848764481689,1.3357712747842483,0.4926216475849352,0.4736113433615398,-0.6254765373398288,-0.6308282294015006,-0.6058719698777817,-0.22620972931094682,0.07643089348947567,0.03181880795045893 +12,0.8457127509817739,-0.1320881742179109,0.1660804614256576,-0.05597444324184572,0.1320460810425129,0.8595602763812097,0.2610022511939773,0.870901795540163,0.7355403373192945,0.3169951266248968,1.9506267402998576,0.596387426062818,1.0109513082435937,1.4418377295066225,1.1556515861294627 +13,3.2131936413334268,1.8901586548630942,4.720927870764283,2.941929304918738,3.4213197519098353,0.971384842580999,0.6941667364591471,1.323646539549569,0.7935514567864481,-1.2567133727394586,0.8653723838901689,0.4399881590729394,0.9454769394868612,0.4452848728874726,1.0171120424683429 +14,0.3231455572898961,0.6172605442151224,1.317769087006293,1.1221193964028424,-0.29991695163118565,0.11820481898260471,0.3228828919461442,0.14114875420921197,-0.007177783131385559,-0.8446559940758109,-0.3935481151337316,-0.1918456894720605,-0.041206571079714066,-0.14844061149439136,-1.1679336401548384 +15,1.8936416153371443,0.766467147442939,0.7273259043142488,-0.11288133991533035,1.6257606309798467,-0.2566145603166899,1.031253384767004,0.04583407125986318,-0.321492575880691,1.434810355978408,3.296698380489132,2.025089932491831,1.6169698841780027,1.1247527296732565,3.2780773950178173 +16,0.9457550265655666,0.5144737731026268,-0.14536209647783813,-0.23880298361835964,0.6320943552935019,0.24659598758236326,1.86501359700673,0.5015573991114366,0.11007499470071004,1.553167262615839,2.5664099859062928,2.0649093777683944,0.8617306538677845,2.131012269960775,2.779335037837786 +17,-0.7745249971602093,-0.3950233661282629,-0.11454226001863815,-0.7800238945342648,-0.646773432333271,0.5799988608817356,0.8472399004250337,0.4807073122162669,0.4525163908280294,0.6150791877858333,-0.42726352011011703,0.09216770334391516,0.7048967007993319,0.20747112301244827,-0.09896252126563818 +18,-0.02616406416189413,-0.0004547931480373892,0.19041191126186835,-0.4422148696001764,0.13128957835832067,0.971384842580999,0.9449461752968767,0.8798375470666647,0.7636669406973083,2.0397456565697203,1.0752984903469085,0.9893046029484328,1.411410819476633,1.3027085969266763,1.6765602702952727 +19,-0.36597246391019567,0.0668539634191777,0.55376156214928,-0.8454062864569916,-0.680059550437724,2.28842973337852,0.8472399004250337,2.369129468150238,2.6674864068466255,0.8254914662523765,0.3863591773388819,1.2713989863534898,1.8910486371131627,-0.21476961656157492,-0.43201158422697017 diff --git a/.ci/test_data/bin_sgb/bob/s_model.tar.gz b/.ci/test_data/bin_sgb/bob/s_model.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..01a1754f9de4ab54bbf5484310c07adffc96f839 GIT binary patch literal 1518 zcmVO0V{fA3hPLb{z1i8BZ+GUseecbSGd(^tnM|ZJ=pG^jh(@E0E5LY#$=IE~HqM8J zM{gth^7MHOXOz8gxeRqg@7o0!YitNF4tC#v(@-hQr9%S&ZiD3v8{cWW+LlPjXC z%_fs{Za;_LLKYql-1*%9Rs8c1>%1ZU`S5VeivR5ZepZzfmF6QMK9Y~*hIla&85)U= zi9=%|UlI`{M28 zeL%5wro8l-XpaNM-kEZKd7w|DILMROU6dtJmE?BwPI!g4A|92fqRRE03LG1V9mE0R z1d5Bqu_q@g5ou>3yw{xZa7kI;WT54Q?`o`*dTZ6PXPL3A%_w zuw_Fq;s!d~&US2r%D}W|gVvswR=a<>!f0CI&XiBTb3dw4yz33KVcrLuFc=1dctLzH z?I*GIM)7ZMl!h$=oot~isM~^vkpR$pxW{mx?tk=krBMB)o z^HN2m*%J@~5J-Tr#_(6&RG+bKY?P?xoR9#_bspbE0n=XG}5CYxAHz&o0Uerq>X zu>v-lI61CWP+H{x5(XIt83BoaL_uO8qab5~9sE>lc&!O*&9K&taGX{&iG@aqp^%+t zm_DILHv^-CdkVAq$5O}+K_f6WyTj>nvmWgA`2*Z8+@mMtT&>*L9q|mhn+~aHz9k!c z%jR&g*ca%!rM_g0rczHVfT=!YPM&83_CSUvr)I}9_fxMG$X^=+nZK(*egG}7B_HgZ zAufD$Wo;(-C*fnTNZ5Uf1>3T*q*O)GvY*xwA_iX;~$D5G&BdZ!bqgNdM_#O@HK$D~qm z9-{Pd9B6*0HX+<$Io@PBzWd9CO_mFHVYzRa)_Rcj@}-(dil5B5~+ z6{^Y-xVDY8x7R{fQP(1Pc#FUL8vg<_7IGU72JJ`LxWV5H{$TiCMstD+*1w7@Sg>Hh Uf&~i}EIb7G2U4zH5CA9u07MMZ{kQ0&a;2Th-VY^ee7FyVn(dUp%Bf>YGgTfLo7Lf zY%`l^HUE98+YTXw!{nOGu1ldY)kk%8ef4!gPL59dyjwgb?l>Wc3{(i65S= z9wW-o!6$U^4l*ywC(jpdJ z6GgJiVxhZAsHiKi5+qqNx2zX;phJ9!Z1)7P zHb@IgB!7ELhHuALAr1?ZnYawy$mz`LFE4^190r5n^QBcrWto4CvbiZL6M1}{W=WVP z_2-n5`YDOZ$QdEN$9zxtp7K46-~|c{nl9e<^1gXLydPjOoy_KCI5JT=&rN8zTN@}= z4Rs>7?+Ck=xR$z>xt6dDn?TusN-e9StF{baj61_!9`ao>}^r@>kH zjOjBSF7IZW9X`GcdxNth7n$GUdEumm;np*>K=CbhsDlr82$Bv&y2cT%u|zGN7Axn| zX&KhbFII;K&+cxL)~c)xs#s3ghqbR_60P z+Qmku^U^{po@Qa4?iNipyQ_RJI!kBseJQ(>Cy47$M(HFi?`%{XJa^F{(@b)PH79V% zV5y|j3Z<#yL^F*UVO(rbqC?c6sT{3boqb&=N)Nm*1|U-@e5eOtqRn;GdJla8q0k%nHoD)eUBLGH+xn~EM>>ptAk^nb5Z!8Gp5}Yn0Sk=b}#idYKA}j>LyiOb< zguoP`IwN&;I7}+YMXk9&oLOa^dxStTGR7HYT-vmJ$UVqbiItLI*X8SL=Uzi>?-jvW zZjv-ES0Wgk4ug|F2SIp#5ghlcI&w`gm&o}^!aoMc+GtPaH({9@Q!LkfbUXh(O5rFQ zPs7oC5-$xH^ul5P%gI^)I2>N=A_VJwc(?V;+|JFdiRUHsJo-fy0=oX@@x3*_KCHuk zJcT-RT;m`uW>FbmUqba{H*kC&9t{Td-qx9Ge)epmcivT(R;|FP3bE3LHBN-M3j(n>39VHr%C`Q000jnR3HEV literal 0 HcmV?d00001 diff --git a/.ci/test_data/xgb/bob/bob.csv b/.ci/test_data/xgb/bob/bob.csv new file mode 100644 index 0000000..10adc03 --- /dev/null +++ b/.ci/test_data/xgb/bob/bob.csv @@ -0,0 +1,20 @@ +id,b0,b1,b2,b3,b4,b5,b6,b7,b8,b9,b10,b11,b12,b13,b14 +1,1.3168615683959484,0.72402615808036,0.6608199414286064,1.1487566671861758,0.9070830809973359,1.8866896251792757,-1.3592934737640827,2.3036006236225606,2.0012374893299207,1.3076862710715387,2.616665023512603,2.1095263465722556,2.296076127561788,2.750622244124955,1.9370146123781782 +2,-0.6929262695890712,-0.44078005778479334,0.2601620674590054,-0.8054503802819919,-0.09944374032027478,1.8059274384794277,-0.36920322172940884,1.535125992343437,1.8904889885289908,-0.3756119566608087,-0.4304442186927949,-0.14674896831546966,1.087084295170027,-0.24388966756667946,0.2811899865404747 +3,0.8149735042940163,0.21307643458243766,1.42482746628562,0.2370355353748186,0.29355940411752984,1.5118702458799815,-0.023974383848897503,1.3474752102869065,1.456284548880901,0.5274074050914401,1.0829321669453351,0.8549739441841201,1.9550003461313663,1.1522550000669673,0.2013912093916699 +4,2.7442804054965433,0.8195183841461625,1.115007005037871,4.732680372580089,2.0475108774169515,-0.2814644639166429,0.1339840938605815,-0.2499393042673343,-0.5500212283270541,3.3942746991980925,3.8933974345995,1.9895882583898332,2.175786008218023,6.046041349536007,4.935010337204809 +5,-0.04851987993480875,0.8284707803398315,1.1442047448413237,-0.3610922722145709,0.49932813421778527,1.2985752399803827,-1.4667703761231092,1.3385394587604047,1.2207242455900345,0.22055616566106412,-0.31339451085024933,0.6131787584083571,0.7292592566157908,-0.8683529835650433,-0.39709961922436765 +6,0.44554364864660323,0.16002519787921415,-0.06912355365771157,0.13411880734830423,0.4868458399286153,-0.16549824711686112,-0.31383633263536465,-0.1150094562171625,-0.24432020786226535,2.04851283483916,1.7216164423470515,1.263243196357085,0.905887786285116,1.7540693875058049,2.2418016084326413 +7,-0.6515680104091791,-0.3101413874031051,-0.2280890259209542,-0.8296660809941128,-0.6112178061762416,1.3689833001802503,0.3228828919461442,1.368325297182076,1.2752195396349364,0.5186402268220005,0.02121498004746253,0.5095522502187443,1.196715796344091,0.26247566379986914,-0.014730478719676948 +8,0.2689327040405782,-0.23255395372464063,0.43534850627972166,-0.6880042318282048,0.6116687828203147,0.16376297558251873,0.40104791184361854,0.09944858041887174,0.028859427446694775,1.4479611233825667,0.7247855065358073,-0.021053851900291392,0.6241957346573126,0.4776404851153671,1.7264345060132755 +9,0.533290225555293,0.12056834058119148,0.07524304870380472,0.10748153656497132,-0.01736319908543009,-0.161356596516869,0.8228133317070733,-0.031609108636482475,-0.24836340709785498,1.6627569909838305,1.8183096792604587,1.2800345287026242,1.3916162428757601,2.3898571677839313,1.2886495480441378 +10,2.6095866154647336,1.5098476017468596,0.40939495978776363,-0.3211363660395712,2.3773460477247146,-0.24418960851671317,2.443109056665133,-0.2862780271417735,-0.2974091717382667,2.3202953611917776,5.112877271198196,3.9954328451526617,1.6200152036550601,2.370443800447194,6.846856039728261 +11,-0.9059213043655148,-0.6924418618957103,-0.6821138798646442,-0.7194846427539621,-0.2847868979473433,0.604848764481689,1.3357712747842483,0.4926216475849352,0.4736113433615398,-0.6254765373398288,-0.6308282294015006,-0.6058719698777817,-0.22620972931094682,0.07643089348947567,0.03181880795045893 +12,0.8457127509817739,-0.1320881742179109,0.1660804614256576,-0.05597444324184572,0.1320460810425129,0.8595602763812097,0.2610022511939773,0.870901795540163,0.7355403373192945,0.3169951266248968,1.9506267402998576,0.596387426062818,1.0109513082435937,1.4418377295066225,1.1556515861294627 +13,3.2131936413334268,1.8901586548630942,4.720927870764283,2.941929304918738,3.4213197519098353,0.971384842580999,0.6941667364591471,1.323646539549569,0.7935514567864481,-1.2567133727394586,0.8653723838901689,0.4399881590729394,0.9454769394868612,0.4452848728874726,1.0171120424683429 +14,0.3231455572898961,0.6172605442151224,1.317769087006293,1.1221193964028424,-0.29991695163118565,0.11820481898260471,0.3228828919461442,0.14114875420921197,-0.007177783131385559,-0.8446559940758109,-0.3935481151337316,-0.1918456894720605,-0.041206571079714066,-0.14844061149439136,-1.1679336401548384 +15,1.8936416153371443,0.766467147442939,0.7273259043142488,-0.11288133991533035,1.6257606309798467,-0.2566145603166899,1.031253384767004,0.04583407125986318,-0.321492575880691,1.434810355978408,3.296698380489132,2.025089932491831,1.6169698841780027,1.1247527296732565,3.2780773950178173 +16,0.9457550265655666,0.5144737731026268,-0.14536209647783813,-0.23880298361835964,0.6320943552935019,0.24659598758236326,1.86501359700673,0.5015573991114366,0.11007499470071004,1.553167262615839,2.5664099859062928,2.0649093777683944,0.8617306538677845,2.131012269960775,2.779335037837786 +17,-0.7745249971602093,-0.3950233661282629,-0.11454226001863815,-0.7800238945342648,-0.646773432333271,0.5799988608817356,0.8472399004250337,0.4807073122162669,0.4525163908280294,0.6150791877858333,-0.42726352011011703,0.09216770334391516,0.7048967007993319,0.20747112301244827,-0.09896252126563818 +18,-0.02616406416189413,-0.0004547931480373892,0.19041191126186835,-0.4422148696001764,0.13128957835832067,0.971384842580999,0.9449461752968767,0.8798375470666647,0.7636669406973083,2.0397456565697203,1.0752984903469085,0.9893046029484328,1.411410819476633,1.3027085969266763,1.6765602702952727 +19,-0.36597246391019567,0.0668539634191777,0.55376156214928,-0.8454062864569916,-0.680059550437724,2.28842973337852,0.8472399004250337,2.369129468150238,2.6674864068466255,0.8254914662523765,0.3863591773388819,1.2713989863534898,1.8910486371131627,-0.21476961656157492,-0.43201158422697017 diff --git a/.ci/test_data/xgb/bob/s_model.tar.gz b/.ci/test_data/xgb/bob/s_model.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..fc60b4b13e12d2b62e04c85336cd2299cfbb18c4 GIT binary patch literal 1036 zcmV+n1oQhJiwFRE&aq_z|Ls`aZ=*O6?=yeJisvNq7-QhxdKVO*D&W*7Nz_;5Vz(ZpUh<*@s!0$%FW)x+$b-W zfl#xf5&LU;b!i$u&N})C^myFAD*pi-;%wzVU}AUq|3>+r1xv1STkujaD{^CxhP2Ov z!0D5adVQC=LEo82$RSZYM-Z#R5b-ixZ_*zmb5R5}rTDWO-)B>Q;(wkgW*|iIHApL7)++K!tSZ66JY|g} z$y9S@Evl4NN^UFiTrj1YB~iU5v9NqWdN#?FjF<E! zqoqfpM_Z3nkB%N)J$m)7W^=t8>g`bP2VxrEj33&bG`vmHNSa!%ZQV8mdWO|A?4IHD z46kQ^{0x%OheQ953V0B+I1Lt!*!i~jI7mz0Yw-|8)nZ&5(j3WtoT#UuVvUw)z*J*) zYuKoPj%)Zp){@Irnh5rNBUGYivIjQC2yDkj1i9c~DH;tNz&7$c*K;hFP$HG~CDW!h z5b%Ivxf<6@Og&_IHUM=IMN+*v&f+9X#J#!y9SzVC!x~2MawuT!8LEAv9ip5;j*f-e zQ4hhNM0fi&*^O|}2XR>bPF+tEP28{cC)#kvMw6>^o%FxOdks><%vWDGE1#@=x(e;9 zc5rmHJ0hr6Tw;q**CT{j$g)9&^=1Uh6&3+-<)70ImY!1=*%s~1dAvc=7MIBP+1^^!yd^Id{Mi@V3;*VeuXwAV;F&0E)3?408{c-6 z#M%=to&Ek0`G+TqTfOWRFrYmQc!w}>dl>j#!B`bMN)Ea#2y@vN7fszs%2go3+nH=N%7EcDJD*G% z+jglt-ur9D!)YI*e0uGF9*@TUP@7QKtL}&Hjyme7qmDZ2sH2WL>ZqgtIsFF{9w)c} GC;$Mp&\n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.13.0\n" +"Generated-By: Babel 2.14.0\n" #: ../../source/index.rst:7 msgid "Welcome to SecretFlow-Serving's documentation!" @@ -47,17 +47,21 @@ msgid "" "`" msgstr "**概述**::doc:`系统简介及架构设计 `" -#: ../../source/index.rst:26 +#: ../../source/index.rst:24 +msgid "**Observability**: :doc:`Logs and Metrics `" +msgstr "**可观测性**::doc:`日志和Metrics `" + +#: ../../source/index.rst:28 msgid "Deployment" msgstr "部署" -#: ../../source/index.rst:28 +#: ../../source/index.rst:30 msgid "" "**Guides**: :doc:`How to deploy an SecretFlow-Serving " "cluster`" msgstr "**指南**::doc:`如何部署 SecretFlow-Serving 集群`" -#: ../../source/index.rst:31 +#: ../../source/index.rst:33 msgid "" "**Reference**: :doc:`SecretFlow-Serving service API ` | " ":doc:`SecretFlow-Serving system config ` | :doc" @@ -67,11 +71,11 @@ msgstr "" ":`SecretFlow-Serving 系统配置 ` | :doc:`SecretFlow-Serving" " 特征服务 SPI `" -#: ../../source/index.rst:38 +#: ../../source/index.rst:40 msgid "Graph" msgstr "模型图" -#: ../../source/index.rst:40 +#: ../../source/index.rst:42 msgid "" "**Overview**: :doc:`Introduction to graphs " "` | :doc:`Operators " @@ -80,6 +84,9 @@ msgstr "" "**概述**::doc:`模型图的介绍 ` | :doc:`算子 " "`" -#: ../../source/index.rst:44 +#: ../../source/index.rst:46 msgid "**Reference**: :doc:`SecretFlow-Serving model `" msgstr "**参考**::doc:`SecretFlow-Serving 模型 `" + +#~ msgid "Observability" +#~ msgstr "可观测性" diff --git a/docs/locales/zh_CN/LC_MESSAGES/intro/tutorial.po b/docs/locales/zh_CN/LC_MESSAGES/intro/tutorial.po index bbac4d2..38bce45 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/intro/tutorial.po +++ b/docs/locales/zh_CN/LC_MESSAGES/intro/tutorial.po @@ -77,7 +77,7 @@ msgid "" "check :ref:`TLS Configuration ` for details." msgstr "" "本示例的SecretFlow-Serving 通过 HTTP 协议提供服务。然而,对于生产环境,建议使用 HTTPS 协议来代替。请查看 " -":doc:`TLS 配置 ` 获取详细信息。" +":ref:`TLS 配置 ` 获取详细信息。" #: ../../source/intro/tutorial.rst:33 msgid "" @@ -106,4 +106,4 @@ msgstr "向 ``Bob`` 发送预测请求" msgid "" "Please checkout :ref:`SecretFlow-Serving API ` for the" " Predict API details." -msgstr "请查询 :doc:`SecretFlow-Serving API ` 以获得更多关于预测API的信息。" +msgstr "请查询 :ref:`SecretFlow-Serving API ` 以获得更多关于预测API的信息。" diff --git a/docs/locales/zh_CN/LC_MESSAGES/reference/model.po b/docs/locales/zh_CN/LC_MESSAGES/reference/model.po index 99754ad..a61c23c 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/reference/model.po +++ b/docs/locales/zh_CN/LC_MESSAGES/reference/model.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: SecretFlow-Serving \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-01-04 16:56+0800\n" +"POT-Creation-Date: 2024-01-19 20:00+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -142,7 +142,7 @@ msgstr "" msgid "[Scalar](#scalar)" msgstr "" -#: ../../source/reference/model.md:96 ../../source/reference/model.md:643 +#: ../../source/reference/model.md:96 ../../source/reference/model.md:644 msgid "Enums" msgstr "" @@ -590,6 +590,12 @@ msgstr "" msgid "[repeated IoDef](#iodef )" msgstr "" +#: ../../source/reference/model.md +msgid "" +"If tag variable_inputs is true, the op should have only one `IoDef` for " +"inputs, referring to the parameter list." +msgstr "" + #: ../../source/reference/model.md msgid "output" msgstr "" @@ -644,15 +650,23 @@ msgstr "" msgid "The operator needs to be executed in session." msgstr "" -#: ../../source/reference/model.md:333 -msgid "{#ExecutionDef}" +#: ../../source/reference/model.md +msgid "variable_inputs" +msgstr "" + +#: ../../source/reference/model.md +msgid "Whether this op has variable input argument. default `false`." msgstr "" #: ../../source/reference/model.md:334 -msgid "ExecutionDef" +msgid "{#ExecutionDef}" msgstr "" #: ../../source/reference/model.md:335 +msgid "ExecutionDef" +msgstr "" + +#: ../../source/reference/model.md:336 msgid "" "The definition of a execution. A execution represents a subgraph within a" " graph that can be scheduled for execution in a specified pattern." @@ -677,19 +691,19 @@ msgstr "" msgid "[ RuntimeConfig](#runtimeconfig )" msgstr "" -#: ../../source/reference/model.md ../../source/reference/model.md:423 +#: ../../source/reference/model.md ../../source/reference/model.md:424 msgid "The runtime config of the execution." msgstr "" -#: ../../source/reference/model.md:347 +#: ../../source/reference/model.md:348 msgid "{#GraphDef}" msgstr "" -#: ../../source/reference/model.md:348 +#: ../../source/reference/model.md:349 msgid "GraphDef" msgstr "" -#: ../../source/reference/model.md:349 +#: ../../source/reference/model.md:350 msgid "" "The definition of a Graph. A graph consists of a set of nodes carrying " "data and a set of executions that describes the scheduling of the graph." @@ -715,15 +729,15 @@ msgstr "" msgid "[repeated ExecutionDef](#executiondef )" msgstr "" -#: ../../source/reference/model.md:362 +#: ../../source/reference/model.md:363 msgid "{#GraphView}" msgstr "" -#: ../../source/reference/model.md:363 +#: ../../source/reference/model.md:364 msgid "GraphView" msgstr "" -#: ../../source/reference/model.md:364 +#: ../../source/reference/model.md:365 msgid "" "The view of a graph is used to display the structure of the graph, " "containing only structural information and excluding the data components." @@ -733,15 +747,15 @@ msgstr "" msgid "[repeated NodeView](#nodeview )" msgstr "" -#: ../../source/reference/model.md:377 +#: ../../source/reference/model.md:378 msgid "{#NodeDef}" msgstr "" -#: ../../source/reference/model.md:378 +#: ../../source/reference/model.md:379 msgid "NodeDef" msgstr "" -#: ../../source/reference/model.md:379 +#: ../../source/reference/model.md:380 msgid "The definition of a node." msgstr "" @@ -789,11 +803,11 @@ msgstr "" msgid "The operator version." msgstr "" -#: ../../source/reference/model.md:393 +#: ../../source/reference/model.md:394 msgid "{#NodeDef.AttrValuesEntry}" msgstr "" -#: ../../source/reference/model.md:394 +#: ../../source/reference/model.md:395 msgid "NodeDef.AttrValuesEntry" msgstr "" @@ -809,23 +823,23 @@ msgstr "" msgid "[ op.AttrValue](#attrvalue )" msgstr "" -#: ../../source/reference/model.md:406 +#: ../../source/reference/model.md:407 msgid "{#NodeView}" msgstr "" -#: ../../source/reference/model.md:407 +#: ../../source/reference/model.md:408 msgid "NodeView" msgstr "" -#: ../../source/reference/model.md:408 +#: ../../source/reference/model.md:409 msgid "The view of a node, which could be public to other parties" msgstr "" -#: ../../source/reference/model.md:421 +#: ../../source/reference/model.md:422 msgid "{#RuntimeConfig}" msgstr "" -#: ../../source/reference/model.md:422 +#: ../../source/reference/model.md:423 msgid "RuntimeConfig" msgstr "" @@ -853,15 +867,15 @@ msgstr "" msgid "if dispatch_type is DP_SPECIFIED, only one party should be true" msgstr "" -#: ../../source/reference/model.md:437 +#: ../../source/reference/model.md:438 msgid "{#ModelBundle}" msgstr "" -#: ../../source/reference/model.md:438 +#: ../../source/reference/model.md:439 msgid "ModelBundle" msgstr "" -#: ../../source/reference/model.md:439 +#: ../../source/reference/model.md:440 msgid "" "Represents an exported secertflow model. It consists of a GraphDef and " "extra metadata required for serving." @@ -875,15 +889,15 @@ msgstr "" msgid "[ GraphDef](#graphdef )" msgstr "" -#: ../../source/reference/model.md:452 +#: ../../source/reference/model.md:453 msgid "{#ModelInfo}" msgstr "" -#: ../../source/reference/model.md:453 +#: ../../source/reference/model.md:454 msgid "ModelInfo" msgstr "" -#: ../../source/reference/model.md:454 +#: ../../source/reference/model.md:455 msgid "Represents a secertflow model without private data." msgstr "" @@ -895,15 +909,15 @@ msgstr "" msgid "[ GraphView](#graphview )" msgstr "" -#: ../../source/reference/model.md:466 +#: ../../source/reference/model.md:467 msgid "{#ModelManifest}" msgstr "" -#: ../../source/reference/model.md:467 +#: ../../source/reference/model.md:468 msgid "ModelManifest" msgstr "" -#: ../../source/reference/model.md:468 +#: ../../source/reference/model.md:469 msgid "" "The manifest of the model package. Package format is as follows: " "model.tar.gz ├ MANIFIEST ├ model_file └ some op meta files MANIFIEST " @@ -930,11 +944,11 @@ msgstr "" msgid "The format type of the model bundle file." msgstr "" -#: ../../source/reference/model.md:488 ../../source/reference/model.md:566 +#: ../../source/reference/model.md:489 ../../source/reference/model.md:567 msgid "{#ComputeTrace}" msgstr "" -#: ../../source/reference/model.md:489 ../../source/reference/model.md:567 +#: ../../source/reference/model.md:490 ../../source/reference/model.md:568 msgid "ComputeTrace" msgstr "" @@ -950,11 +964,11 @@ msgstr "" msgid "[repeated FunctionTrace](#functiontrace )" msgstr "" -#: ../../source/reference/model.md:501 ../../source/reference/model.md:579 +#: ../../source/reference/model.md:502 ../../source/reference/model.md:580 msgid "{#FunctionInput}" msgstr "" -#: ../../source/reference/model.md:502 ../../source/reference/model.md:580 +#: ../../source/reference/model.md:503 ../../source/reference/model.md:581 msgid "FunctionInput" msgstr "" @@ -978,11 +992,11 @@ msgstr "" msgid "[ Scalar](#scalar )" msgstr "" -#: ../../source/reference/model.md:514 ../../source/reference/model.md:592 +#: ../../source/reference/model.md:515 ../../source/reference/model.md:593 msgid "{#FunctionOutput}" msgstr "" -#: ../../source/reference/model.md:515 ../../source/reference/model.md:593 +#: ../../source/reference/model.md:516 ../../source/reference/model.md:594 msgid "FunctionOutput" msgstr "" @@ -990,11 +1004,11 @@ msgstr "" msgid "data_id" msgstr "" -#: ../../source/reference/model.md:526 ../../source/reference/model.md:604 +#: ../../source/reference/model.md:527 ../../source/reference/model.md:605 msgid "{#FunctionTrace}" msgstr "" -#: ../../source/reference/model.md:527 ../../source/reference/model.md:605 +#: ../../source/reference/model.md:528 ../../source/reference/model.md:606 msgid "FunctionTrace" msgstr "" @@ -1026,15 +1040,15 @@ msgstr "" msgid "Output of this function." msgstr "" -#: ../../source/reference/model.md:541 ../../source/reference/model.md:619 +#: ../../source/reference/model.md:542 ../../source/reference/model.md:620 msgid "{#Scalar}" msgstr "" -#: ../../source/reference/model.md:542 ../../source/reference/model.md:620 +#: ../../source/reference/model.md:543 ../../source/reference/model.md:621 msgid "Scalar" msgstr "" -#: ../../source/reference/model.md:543 ../../source/reference/model.md:621 +#: ../../source/reference/model.md:544 ../../source/reference/model.md:622 msgid "Represents a single value with a specific data type." msgstr "" @@ -1118,15 +1132,15 @@ msgstr "" msgid "DOUBLE" msgstr "" -#: ../../source/reference/model.md:647 +#: ../../source/reference/model.md:648 msgid "{#AttrType}" msgstr "" -#: ../../source/reference/model.md:648 +#: ../../source/reference/model.md:649 msgid "AttrType" msgstr "" -#: ../../source/reference/model.md:649 +#: ../../source/reference/model.md:650 msgid "Supported attribute types." msgstr "" @@ -1290,15 +1304,15 @@ msgstr "" msgid "BYTES LIST" msgstr "" -#: ../../source/reference/model.md:676 +#: ../../source/reference/model.md:677 msgid "{#DispatchType}" msgstr "" -#: ../../source/reference/model.md:677 +#: ../../source/reference/model.md:678 msgid "DispatchType" msgstr "" -#: ../../source/reference/model.md:678 +#: ../../source/reference/model.md:679 msgid "Supported dispatch type" msgstr "" @@ -1330,15 +1344,15 @@ msgstr "" msgid "Dispatch specified participant." msgstr "" -#: ../../source/reference/model.md:692 +#: ../../source/reference/model.md:693 msgid "{#FileFormatType}" msgstr "" -#: ../../source/reference/model.md:693 +#: ../../source/reference/model.md:694 msgid "FileFormatType" msgstr "" -#: ../../source/reference/model.md:694 +#: ../../source/reference/model.md:695 msgid "Support model file format" msgstr "" @@ -1364,15 +1378,15 @@ msgid "" "method to ensure compatibility" msgstr "" -#: ../../source/reference/model.md:707 +#: ../../source/reference/model.md:708 msgid "{#DataType}" msgstr "" -#: ../../source/reference/model.md:708 +#: ../../source/reference/model.md:709 msgid "DataType" msgstr "" -#: ../../source/reference/model.md:709 +#: ../../source/reference/model.md:710 msgid "" "Mapping arrow::DataType " "`https://arrow.apache.org/docs/cpp/api/datatype.html`." @@ -1494,11 +1508,11 @@ msgstr "" msgid "Variable-length bytes (no guarantee of UTF8-ness)" msgstr "" -#: ../../source/reference/model.md:734 ../../source/reference/model.md:751 +#: ../../source/reference/model.md:735 ../../source/reference/model.md:752 msgid "{#ExtendFunctionName}" msgstr "" -#: ../../source/reference/model.md:735 ../../source/reference/model.md:752 +#: ../../source/reference/model.md:736 ../../source/reference/model.md:753 msgid "ExtendFunctionName" msgstr "" @@ -1550,7 +1564,7 @@ msgid "" "https://arrow.apache.org/docs/cpp/api/table.html#_CPPv4NK5arrow11RecordBatch9SetColumnEiRKNSt10shared_ptrI5FieldEERKNSt10shared_ptrI5ArrayEE" msgstr "" -#: ../../source/reference/model.md:767 +#: ../../source/reference/model.md:768 msgid "Scalar Value Types" msgstr "" diff --git a/docs/locales/zh_CN/LC_MESSAGES/topics/system/observability.po b/docs/locales/zh_CN/LC_MESSAGES/topics/system/observability.po index e2810d3..e06987c 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/topics/system/observability.po +++ b/docs/locales/zh_CN/LC_MESSAGES/topics/system/observability.po @@ -8,15 +8,345 @@ msgid "" msgstr "" "Project-Id-Version: SecretFlow-Serving \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-12-25 11:37+0800\n" +"POT-Creation-Date: 2024-01-19 20:16+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.13.0\n" +"Generated-By: Babel 2.14.0\n" #: ../../source/topics/system/observability.rst:2 msgid "SecretFlow-Serving System Observability" msgstr "SecretFlow-Serving 系统可观测性" + +#: ../../source/topics/system/observability.rst:4 +msgid "" +"Secretflow-Serving currently supports two observation types: logs and " +"metrics." +msgstr "Secretflow-Serving 当前支持两种观测手段:日志和Metrics。" + +#: ../../source/topics/system/observability.rst:7 +msgid "Logs" +msgstr "日志" + +#: ../../source/topics/system/observability.rst:9 +msgid "" +"You can configure the log path, log level, etc. by specifying the " +":ref:`LoggingConfig` when serving is started. You can also view the " +":ref:`example `." +msgstr "" +"你可以在Serving启动的时候,通过 :ref:`LoggingConfig` 配置日志生成的路径,日志打印级别等。可以查看 :ref:`示例 " +"`。" + +#: ../../source/topics/system/observability.rst:13 +msgid "Metrics" +msgstr "Metrics" + +#: ../../source/topics/system/observability.rst:16 +msgid "Format" +msgstr "Metrics格式" + +#: ../../source/topics/system/observability.rst:18 +msgid "" +"Secretflow-Serving uses the `Prometheus `_ " +"standard to generate metrics. The metric service is turned off by " +"default, you may start metric service by specifying " +"``metrics_exposer_port`` of :ref:`ServerConfig`. Then You can obtain the " +"metrics by requesting :ref:`MetricsService ` on this port. That " +"is to say, Serving supports pull mode. You could use `The Prometheus " +"monitoring system `_ to collect metrics, or " +"simply use ``curl`` like this:" +msgstr "" +"Secretflow-Serving 使用 `Prometheus `_ " +"格式来生成metrics。默认metrics服务是关闭的,你可以通过设置启动参数 :ref:`ServerConfig` 的 " +"``metrics_exposer_port`` " +"来打开metrics服务,然后你可以在这个端口上请求获取metrics数据。也就是说,Serving实现的是拉取模式。你可以使用 " +"Prometheus来收集metrics,或者用 ``curl`` 工具像这样: " + +#: ../../source/topics/system/observability.rst:31 +msgid "Metric entries" +msgstr "Metric条目" + +#: ../../source/topics/system/observability.rst:33 +msgid "" +"Serving mainly records the number of interface requests and the request " +"duration time for various services, with some additional labels, such as " +"the error code, party_id, etc." +msgstr "Serving主要记录各种服务请求的个数和持续时间,并附件一些label,比如说错误码,party_id等。" + +#: ../../source/topics/system/observability.rst:37 +msgid "The sevices of Secretflow-Serving are shown below:" +msgstr "Secretflow-Serving的相关服务如下图:" + +#: ../../source/topics/system/observability.rst:42 +msgid "" +":ref:`PredictRequest` first goes to the :ref:`PredictionService`, and the" +" :ref:`PredictionService` will then request the local ``ExecutionCore`` " +"or the remote :ref:`ExecutionService` according to the different " +":ref:`DispatchType` in the Graph. If the :ref:`FeatureSourceType` of " +"request is ``FS_SERVICE``, then the ``ExecutionCore`` will request the " +":ref:`BatchFeatureService`." +msgstr "" +":ref:`PredictRequest` 首先会到达 :ref:`PredictionService` ,然后 " +":ref:`PredictionService` 会根据Graph中的 :ref:`DispatchType` 来决定请求本地的 " +"``ExecutionCore`` 还是远端的 :ref:`ExecutionService` 。如果请求中指定的 " +":ref:`FeatureSourceType` 是 ``FS_SERVICE`` ,那么 ``ExecutionCore`` 将会请求 " +":ref:`BatchFeatureService` 。" + +#: ../../source/topics/system/observability.rst:48 +msgid "" +":ref:`GetModelInfoRequest` goes to :ref:`ModelService` to get model info." +" :ref:`Model info` is public for all parties." +msgstr "" +":ref:`GetModelInfoRequest` 会通过 :ref:`ModelService` 来获取模型信息。 获取的 " +":ref:`Model info` 对每一方来说都是公开信息。" + +#: ../../source/topics/system/observability.rst:50 +msgid "Metrics of Secretflow-Serving have the following parts:" +msgstr "Secretflow-Serving的Metrics有以下几个部分" + +#: ../../source/topics/system/observability.rst:53 +msgid "" +"Prometheus supports a multi-dimensional data model with time series data " +"identified by metric name and key/value pairs, called labels. Secretflow-" +"Serving metrics have some common labels:" +msgstr "" +"Prometheus支持多维度的数据模型,生成的时间序列数据可以由名字和键值对,也就是labels来唯一指定。Secretflow-" +"Serving的metrics有一些共同的label:" + +#: ../../source/topics/system/observability.rst:56 +msgid "handler: the subject providing services" +msgstr "handler: 和这个metrics相关的服务名" + +#: ../../source/topics/system/observability.rst:57 +msgid "action: operation name" +msgstr "action: 和这个metrics相关的操作名称" + +#: ../../source/topics/system/observability.rst:58 +msgid "party id: ``self_id`` of :ref:`ClusterConfig`" +msgstr "party id: :ref:`ClusterConfig` 中的 ``self_id``" + +#: ../../source/topics/system/observability.rst:59 +msgid "service_id: ``id`` of :ref:`ServingConfig`" +msgstr "service_id: :ref:`ServingConfig` 中的 ``id``" + +#: ../../source/topics/system/observability.rst:61 +msgid "" +"If you want to know what is ``Counter`` or ``Summary``, you could check " +"out `this page `_." +msgstr "" +"如果你想了解什么是 ``Counter`` 或者 ``Summary`` , 你可以查看 `这里 " +"`_." + +#: ../../source/topics/system/observability.rst:65 +msgid "Brpc metric" +msgstr "Brpc相关的metrics" + +#: ../../source/topics/system/observability.rst:67 +msgid "" +"Serving will dump brpc internal metrics in Prometheus format, refer to " +"`issue `_." +msgstr "" +"Serving会用Prometheus的格式输出brpc内部的metrics, 参考 `issue " +"`_." + +#: ../../source/topics/system/observability.rst:70 +msgid "MetricsService" +msgstr "MetricsService" + +#: ../../source/topics/system/observability.rst:73 +#: ../../source/topics/system/observability.rst:87 +#: ../../source/topics/system/observability.rst:120 +#: ../../source/topics/system/observability.rst:145 +msgid "name" +msgstr "name" + +#: ../../source/topics/system/observability.rst:73 +#: ../../source/topics/system/observability.rst:87 +#: ../../source/topics/system/observability.rst:120 +#: ../../source/topics/system/observability.rst:145 +msgid "type" +msgstr "类型" + +#: ../../source/topics/system/observability.rst:73 +#: ../../source/topics/system/observability.rst:87 +#: ../../source/topics/system/observability.rst:120 +#: ../../source/topics/system/observability.rst:145 +msgid "desc" +msgstr "说明" + +#: ../../source/topics/system/observability.rst:75 +msgid "exposer_transferred_bytes_total" +msgstr "" + +#: ../../source/topics/system/observability.rst:75 +#: ../../source/topics/system/observability.rst:77 +#: ../../source/topics/system/observability.rst:89 +#: ../../source/topics/system/observability.rst:99 +#: ../../source/topics/system/observability.rst:122 +#: ../../source/topics/system/observability.rst:147 +msgid "Counter" +msgstr "" + +#: ../../source/topics/system/observability.rst:75 +msgid "Transferred bytes to metrics services" +msgstr "metrics services传输的字节数" + +#: ../../source/topics/system/observability.rst:77 +msgid "exposer_scrapes_total" +msgstr "" + +#: ../../source/topics/system/observability.rst:77 +msgid "Number of times metrics were scraped" +msgstr "metrics services收集的次数" + +#: ../../source/topics/system/observability.rst:79 +msgid "exposer_request_duration_milliseconds" +msgstr "" + +#: ../../source/topics/system/observability.rst:79 +#: ../../source/topics/system/observability.rst:107 +#: ../../source/topics/system/observability.rst:132 +#: ../../source/topics/system/observability.rst:157 +msgid "Summary" +msgstr "" + +#: ../../source/topics/system/observability.rst:79 +msgid "" +"Summary of latencies of serving scrape requests, in milliseconds with " +"0.5-quantile, 0.9-quantile, 0.99-quantile" +msgstr "metrics收集请求处理时间的summary, 毫秒为单位,包括0.5,0.9,0.99分点" + +#: ../../source/topics/system/observability.rst:84 +msgid "PredictionService" +msgstr "" + +#: ../../source/topics/system/observability.rst:87 +msgid "label" +msgstr "" + +#: ../../source/topics/system/observability.rst:89 +msgid "prediction_request_count" +msgstr "" + +#: ../../source/topics/system/observability.rst:89 +msgid "How many prediction service api requests are handled by this server." +msgstr "预测请求处理个数" + +#: ../../source/topics/system/observability.rst:89 +#: ../../source/topics/system/observability.rst:99 +#: ../../source/topics/system/observability.rst:107 +msgid "handler: PredictionService" +msgstr "" + +#: ../../source/topics/system/observability.rst:91 +#: ../../source/topics/system/observability.rst:101 +#: ../../source/topics/system/observability.rst:109 +#: ../../source/topics/system/observability.rst:124 +#: ../../source/topics/system/observability.rst:134 +#: ../../source/topics/system/observability.rst:149 +#: ../../source/topics/system/observability.rst:159 +msgid "service_id" +msgstr "" + +#: ../../source/topics/system/observability.rst:93 +#: ../../source/topics/system/observability.rst:103 +#: ../../source/topics/system/observability.rst:111 +#: ../../source/topics/system/observability.rst:126 +#: ../../source/topics/system/observability.rst:136 +#: ../../source/topics/system/observability.rst:151 +#: ../../source/topics/system/observability.rst:161 +msgid "party_id" +msgstr "" + +#: ../../source/topics/system/observability.rst:95 +#: ../../source/topics/system/observability.rst:105 +#: ../../source/topics/system/observability.rst:113 +#: ../../source/topics/system/observability.rst:128 +#: ../../source/topics/system/observability.rst:138 +#: ../../source/topics/system/observability.rst:153 +#: ../../source/topics/system/observability.rst:163 +msgid "action" +msgstr "" + +#: ../../source/topics/system/observability.rst:97 +#: ../../source/topics/system/observability.rst:130 +#: ../../source/topics/system/observability.rst:155 +msgid "code: error code of response" +msgstr "" + +#: ../../source/topics/system/observability.rst:99 +msgid "prediction_sample_count" +msgstr "" + +#: ../../source/topics/system/observability.rst:99 +msgid "How many prediction samples are processed by this services." +msgstr "预测样本处理个数" + +#: ../../source/topics/system/observability.rst:107 +msgid "prediction_request_duration_milliseconds" +msgstr "" + +#: ../../source/topics/system/observability.rst:107 +msgid "" +"Summary of prediction service api request duration in milliseconds with " +"0.5-quantile, 0.9-quantile, 0.99-quantile" +msgstr "metrics收集请求处理时间的summary, 毫秒为单位,包括0.5,0.9,0.99分点" + +#: ../../source/topics/system/observability.rst:118 +msgid "ExecutionService" +msgstr "" + +#: ../../source/topics/system/observability.rst:120 +#: ../../source/topics/system/observability.rst:145 +msgid "labels" +msgstr "" + +#: ../../source/topics/system/observability.rst:122 +msgid "execution_request_count" +msgstr "" + +#: ../../source/topics/system/observability.rst:122 +#: ../../source/topics/system/observability.rst:147 +msgid "How many execution requests are handled by this server." +msgstr "处理请求数" + +#: ../../source/topics/system/observability.rst:122 +#: ../../source/topics/system/observability.rst:132 +msgid "handler: ExecutionService" +msgstr "" + +#: ../../source/topics/system/observability.rst:132 +msgid "execution_request_duration_milliseconds" +msgstr "" + +#: ../../source/topics/system/observability.rst:132 +msgid "" +"Summary of execution service api request duration in milliseconds with " +"0.5-quantile, 0.9-quantile, 0.99-quantile" +msgstr "execution service请求处理时间的summary, 毫秒为单位,包括0.5,0.9,0.99分点" + +#: ../../source/topics/system/observability.rst:142 +msgid "ModelService" +msgstr "" + +#: ../../source/topics/system/observability.rst:147 +msgid "model_service_request_count" +msgstr "" + +#: ../../source/topics/system/observability.rst:147 +#: ../../source/topics/system/observability.rst:157 +msgid "handler: ModelService" +msgstr "" + +#: ../../source/topics/system/observability.rst:157 +msgid "model_service_request_duration_milliseconds" +msgstr "" + +#: ../../source/topics/system/observability.rst:157 +msgid "" +"Summary of model service api request duration in milliseconds with " +"0.5-quantile, 0.9-quantile, 0.99-quantile" +msgstr "model service请求处理时间的summary, 毫秒为单位,包括0.5,0.9,0.99分点" diff --git a/docs/source/imgs/services.png b/docs/source/imgs/services.png new file mode 100644 index 0000000000000000000000000000000000000000..328be759437af815a54bf7c83b0e8e9c48cb43c4 GIT binary patch literal 152445 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

YnL!mKKP4d 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 +- **Observability**: + :doc:`Logs and Metrics ` Deployment ---------- diff --git a/docs/source/reference/model.md b/docs/source/reference/model.md index 1977c35..bb11b94 100644 --- a/docs/source/reference/model.md +++ b/docs/source/reference/model.md @@ -307,7 +307,7 @@ The definition of a operator. | desc | [ string](#string ) | Description of the op | | version | [ string](#string ) | Version of the op | | tag | [ OpTag](#optag ) | none | -| inputs | [repeated IoDef](#iodef ) | none | +| inputs | [repeated IoDef](#iodef ) | If tag variable_inputs is true, the op should have only one `IoDef` for inputs, referring to the parameter list. | | output | [ IoDef](#iodef ) | none | | attrs | [repeated AttrDef](#attrdef ) | none | @@ -324,6 +324,7 @@ Representation operator property | returnable | [ bool](#bool ) | The operator's output can be the final result | | mergeable | [ bool](#bool ) | The operator accept the output of operators with different participants and will somehow merge them. | | session_run | [ bool](#bool ) | The operator needs to be executed in session. | +| variable_inputs | [ bool](#bool ) | Whether this op has variable input argument. default `false`. | diff --git a/docs/source/topics/deployment/deployment.rst b/docs/source/topics/deployment/deployment.rst index 71e82fe..2e272a9 100644 --- a/docs/source/topics/deployment/deployment.rst +++ b/docs/source/topics/deployment/deployment.rst @@ -97,6 +97,7 @@ See :ref:`Serving Config ` for more config information For ``Bob``, you should refer to `bob-serving-config `_ . +.. _log_conf_file: 1.3 Create logging config file ------------------------------ diff --git a/docs/source/topics/system/observability.rst b/docs/source/topics/system/observability.rst index edbf05d..81baaf4 100644 --- a/docs/source/topics/system/observability.rst +++ b/docs/source/topics/system/observability.rst @@ -1,2 +1,164 @@ SecretFlow-Serving System Observability ======================================= + +Secretflow-Serving currently supports two observation types: logs and metrics. + +Logs +====== + +You can configure the log path, log level, etc. by specifying the :ref:`LoggingConfig` when serving is started. +You can also view the :ref:`example `. + +Metrics +======== + +Format +---------- + +Secretflow-Serving uses the `Prometheus `_ standard to generate metrics. +The metric service is turned off by default, +you may start metric service by specifying ``metrics_exposer_port`` of :ref:`ServerConfig`. +Then You can obtain the metrics by requesting :ref:`MetricsService ` on this port. +That is to say, Serving supports pull mode. +You could use `The Prometheus monitoring system `_ to collect metrics, +or simply use ``curl`` like this: + +.. code-block:: shell + + curl xx.xx.xx.xx:port/metrics + +Metric entries +------------------ + +Serving mainly records the number of interface requests and the request duration time for various services, +with some additional labels, +such as the error code, party_id, etc. + +The sevices of Secretflow-Serving are shown below: + +.. image:: /imgs/services.png + + +:ref:`PredictRequest` first goes to the :ref:`PredictionService`, +and the :ref:`PredictionService` will then request the local ``ExecutionCore`` or +the remote :ref:`ExecutionService` according to the different :ref:`DispatchType` in +the Graph. If the :ref:`FeatureSourceType` of request is ``FS_SERVICE``, +then the ``ExecutionCore`` will request the :ref:`BatchFeatureService`. + +:ref:`GetModelInfoRequest` goes to :ref:`ModelService` to get model info. :ref:`Model info` is public for all parties. + +Metrics of Secretflow-Serving have the following parts: + +.. note:: + Prometheus supports a multi-dimensional data model with time series data identified by metric name and key/value pairs, called labels. + Secretflow-Serving metrics have some common labels: + + 1. handler: the subject providing services + 2. action: operation name + 3. party id: ``self_id`` of :ref:`ClusterConfig` + 4. service_id: ``id`` of :ref:`ServingConfig` + + If you want to know what is ``Counter`` or ``Summary``, you could check out `this page `_. + + +Brpc metric +^^^^^^^^^^^^^^^^^ + +Serving will dump brpc internal metrics in Prometheus format, refer to `issue `_. + +MetricsService +^^^^^^^^^^^^^^ + ++---------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+ +| name | type | desc | ++=======================================+=========+===================================================================================================================+ +| exposer_transferred_bytes_total | Counter | Transferred bytes to metrics services | ++---------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+ +| exposer_scrapes_total | Counter | Number of times metrics were scraped | ++---------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+ +| exposer_request_duration_milliseconds | Summary | Summary of latencies of serving scrape requests, in milliseconds with 0.5-quantile, 0.9-quantile, 0.99-quantile | ++---------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+ + + +PredictionService +^^^^^^^^^^^^^^^^^ + ++------------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+------------------------------+ +| name | type | desc | label | ++==========================================+=========+===================================================================================================================+==============================+ +| prediction_request_count | Counter | How many prediction service api requests are handled by this server. | handler: PredictionService | ++ + + +------------------------------+ +| | | | service_id | ++ + + +------------------------------+ +| | | | party_id | ++ + + +------------------------------+ +| | | | action | ++ + + +------------------------------+ +| | | | code: error code of response | ++------------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+------------------------------+ +| prediction_sample_count | Counter | How many prediction samples are processed by this services. | handler: PredictionService | ++ + + +------------------------------+ +| | | | service_id | ++ + + +------------------------------+ +| | | | party_id | ++ + + +------------------------------+ +| | | | action | ++------------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+------------------------------+ +| prediction_request_duration_milliseconds | Summary | Summary of prediction service api request duration in milliseconds with 0.5-quantile, 0.9-quantile, 0.99-quantile | handler: PredictionService | ++ + + +------------------------------+ +| | | | service_id | ++ + + +------------------------------+ +| | | | party_id | ++ + + +------------------------------+ +| | | | action | ++------------------------------------------+---------+-------------------------------------------------------------------------------------------------------------------+------------------------------+ + + +ExecutionService +^^^^^^^^^^^^^^^^^ ++-----------------------------------------+---------+--------------------------------------------------------------------------------------------------------------------+------------------------------+ +| name | type | desc | labels | ++=========================================+=========+====================================================================================================================+==============================+ +| execution_request_count | Counter | How many execution requests are handled by this server. | handler: ExecutionService | ++ + + +------------------------------+ +| | | | service_id | ++ + + +------------------------------+ +| | | | party_id | ++ + + +------------------------------+ +| | | | action | ++ + + +------------------------------+ +| | | | code: error code of response | ++-----------------------------------------+---------+--------------------------------------------------------------------------------------------------------------------+------------------------------+ +| execution_request_duration_milliseconds | Summary | Summary of execution service api request duration in milliseconds with 0.5-quantile, 0.9-quantile, 0.99-quantile | handler: ExecutionService | ++ + + +------------------------------+ +| | | | service_id | ++ + + +------------------------------+ +| | | | party_id | ++ + + +------------------------------+ +| | | | action | ++-----------------------------------------+---------+--------------------------------------------------------------------------------------------------------------------+------------------------------+ + +ModelService +^^^^^^^^^^^^^^^^^ + ++---------------------------------------------+---------+----------------------------------------------------------------------------------------------------------------+------------------------------+ +| name | type | desc | labels | ++=============================================+=========+================================================================================================================+==============================+ +| model_service_request_count | Counter | How many execution requests are handled by this server. | handler: ModelService | ++ + + +------------------------------+ +| | | | service_id | ++ + + +------------------------------+ +| | | | party_id | ++ + + +------------------------------+ +| | | | action | ++ + + +------------------------------+ +| | | | code: error code of response | ++---------------------------------------------+---------+----------------------------------------------------------------------------------------------------------------+------------------------------+ +| model_service_request_duration_milliseconds | Summary | Summary of model service api request duration in milliseconds with 0.5-quantile, 0.9-quantile, 0.99-quantile | handler: ModelService | ++ + + +------------------------------+ +| | | | service_id | ++ + + +------------------------------+ +| | | | party_id | ++ + + +------------------------------+ +| | | | action | ++---------------------------------------------+---------+----------------------------------------------------------------------------------------------------------------+------------------------------+ \ No newline at end of file diff --git a/secretflow_serving/config/feature_config.proto b/secretflow_serving/config/feature_config.proto index e78cfe2..ade7684 100644 --- a/secretflow_serving/config/feature_config.proto +++ b/secretflow_serving/config/feature_config.proto @@ -42,7 +42,7 @@ enum MockDataType { // Mock feature source will generates values(random or fixed, according to type) // for the desired features. message MockOptions { - // default MDT_RANDOM + // default MDT_FIXED MockDataType type = 1; } diff --git a/secretflow_serving/config/logging_config.proto b/secretflow_serving/config/logging_config.proto index 61b04e8..75867aa 100644 --- a/secretflow_serving/config/logging_config.proto +++ b/secretflow_serving/config/logging_config.proto @@ -17,6 +17,7 @@ syntax = "proto3"; package secretflow.serving; +// Serving log level enum LogLevel { // Placeholder for proto3 default value, do not use it. INVALID_LOG_LEVEL = 0; @@ -31,6 +32,7 @@ enum LogLevel { ERROR_LOG_LEVEL = 4; } +// Serving log config options message LoggingConfig { // system log // default value: "serving.log" diff --git a/secretflow_serving/config/model_config.proto b/secretflow_serving/config/model_config.proto index 3b099f4..5e9ffd5 100644 --- a/secretflow_serving/config/model_config.proto +++ b/secretflow_serving/config/model_config.proto @@ -24,7 +24,7 @@ enum SourceType { // Local filesystem ST_FILE = 1; - // S3 OSS + // OSS/AWS S3 ST_OSS = 2; } @@ -32,7 +32,8 @@ message FileSourceMeta { // empty by design } -// Options for a S3 Oss model source +// Options for a Oss model source. Serving accesses data services using the AWS +// S3 protocol. message OSSSourceMeta { // Bucket access key string access_key = 1; diff --git a/secretflow_serving/feature_adapter/mock_adapter.cc b/secretflow_serving/feature_adapter/mock_adapter.cc index f77af7a..146a5f1 100644 --- a/secretflow_serving/feature_adapter/mock_adapter.cc +++ b/secretflow_serving/feature_adapter/mock_adapter.cc @@ -14,6 +14,8 @@ #include "secretflow_serving/feature_adapter/mock_adapter.h" +#include + #include "secretflow_serving/feature_adapter/feature_adapter_factory.h" #include "secretflow_serving/util/arrow_helper.h" @@ -43,7 +45,7 @@ MockAdapter::MockAdapter( "invalid mock options"); mock_type_ = spec_.mock_opts().type() != MockDataType::INVALID_MOCK_DATA_TYPE ? spec_.mock_opts().type() - : MockDataType::MDT_RANDOM; + : MockDataType::MDT_FIXED; } void MockAdapter::OnFetchFeature(const Request& request, Response* response) { @@ -54,90 +56,61 @@ void MockAdapter::OnFetchFeature(const Request& request, Response* response) { size_t cols = feature_schema_->num_fields(); std::vector> arrays; + std::mt19937 rand_gen; + + const auto int_generator = [&] { + return mock_type_ == MockDataType::MDT_FIXED ? 1 : rand_gen() % 100; + }; + const auto str_generator = [&] { + return mock_type_ == MockDataType::MDT_FIXED + ? "1" + : std::to_string(rand_gen() % 100); + }; + for (size_t c = 0; c < cols; ++c) { std::shared_ptr array; const auto& f = feature_schema_->field(c); if (f->type()->id() == arrow::Type::type::BOOL) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED ? 1 : std::rand() % 2; + const auto generator = [&] { + return mock_type_ == MockDataType::MDT_FIXED ? 1 : rand_gen() % 2; }; array = CreateArray(rows, generator); } else if (f->type()->id() == arrow::Type::type::INT8) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED - ? 1 - : (std::rand() % std::numeric_limits::max()); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::UINT8) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED - ? 1 - : (std::numeric_limits::max()); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::INT16) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED - ? 1 - : (std::numeric_limits::max()); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::UINT16) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED - ? 1 - : (std::numeric_limits::max()); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::INT32) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED ? 1 : std::rand(); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::UINT32) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED ? 1 : std::rand(); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::INT64) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED ? 1 : std::rand(); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::UINT64) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED ? 1 : std::rand(); - }; - array = CreateArray(rows, generator); + array = CreateArray(rows, int_generator); } else if (f->type()->id() == arrow::Type::type::FLOAT) { - const auto generator = [this] { + const auto generator = [&] { return mock_type_ == MockDataType::MDT_FIXED ? 1 - : ((float)std::rand() / float(RAND_MAX)); + : static_cast(rand_gen() % 100) / 50; }; array = CreateArray(rows, generator); } else if (f->type()->id() == arrow::Type::type::DOUBLE) { - const auto generator = [this] { + const auto generator = [&] { return mock_type_ == MockDataType::MDT_FIXED ? 1 - : ((double)std::rand() / (RAND_MAX)); + : static_cast(rand_gen() % 100) / 50; }; array = CreateArray(rows, generator); } else if (f->type()->id() == arrow::Type::type::STRING) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED - ? "1" - : std::to_string(std::rand()); - }; - array = CreateArray(rows, generator); + array = + CreateArray(rows, str_generator); } else if (f->type()->id() == arrow::Type::type::BINARY) { - const auto generator = [this] { - return mock_type_ == MockDataType::MDT_FIXED - ? "1" - : std::to_string(std::rand()); - }; - array = CreateArray(rows, generator); + array = + CreateArray(rows, str_generator); } else { SERVING_THROW(errors::ErrorCode::UNEXPECTED_ERROR, "unkown field type {}", f->type()->ToString()); diff --git a/secretflow_serving/framework/executor.cc b/secretflow_serving/framework/executor.cc index 27c8c65..d7aceb7 100644 --- a/secretflow_serving/framework/executor.cc +++ b/secretflow_serving/framework/executor.cc @@ -60,9 +60,8 @@ Executor::Executor(const std::shared_ptr& execution) const auto& target_schema = first_input_schema_list.front(); ++iter; for (; iter != input_schema_map_.end(); ++iter) { - SERVING_ENFORCE(iter->second.size() == 1, errors::ErrorCode::LOGIC_ERROR, - "entry nodes should have one input table({})", - iter->second.size()); + SERVING_ENFORCE_EQ(iter->second.size(), 1U, + "entry nodes should have only one input table"); const auto& schema = iter->second.front(); SERVING_ENFORCE_EQ( diff --git a/secretflow_serving/ops/BUILD.bazel b/secretflow_serving/ops/BUILD.bazel index 8b73eb3..7c81211 100644 --- a/secretflow_serving/ops/BUILD.bazel +++ b/secretflow_serving/ops/BUILD.bazel @@ -19,6 +19,9 @@ package(default_visibility = ["//visibility:public"]) serving_cc_library( name = "ops", deps = [ + "tree_ensemble_predict", + "tree_merge", + "tree_select", ":arrow_processing", ":dot_product", ":merge_y", @@ -32,6 +35,7 @@ serving_cc_library( deps = [ "//secretflow_serving/core:exception", "//secretflow_serving/protos:graph_cc_proto", + "//secretflow_serving/protos:op_cc_proto", ], ) @@ -217,3 +221,77 @@ serving_cc_library( name = "graph_version", hdrs = ["graph_version.h"], ) + +serving_cc_library( + name = "tree_utils", + hdrs = ["tree_utils.h"], +) + +serving_cc_library( + name = "tree_select", + srcs = ["tree_select.cc"], + hdrs = ["tree_select.h"], + deps = [ + ":node_def_util", + ":op_factory", + ":op_kernel_factory", + ":tree_utils", + ], + alwayslink = True, +) + +serving_cc_test( + name = "tree_select_test", + srcs = ["tree_select_test.cc"], + deps = [ + ":tree_select", + "//secretflow_serving/util:test_utils", + "//secretflow_serving/util:utils", + ], +) + +serving_cc_library( + name = "tree_merge", + srcs = ["tree_merge.cc"], + hdrs = ["tree_merge.h"], + deps = [ + ":node_def_util", + ":op_factory", + ":op_kernel_factory", + ":tree_utils", + ], + alwayslink = True, +) + +serving_cc_test( + name = "tree_merge_test", + srcs = ["tree_merge_test.cc"], + deps = [ + ":tree_merge", + "//secretflow_serving/util:test_utils", + "//secretflow_serving/util:utils", + ], +) + +serving_cc_library( + name = "tree_ensemble_predict", + srcs = ["tree_ensemble_predict.cc"], + hdrs = ["tree_ensemble_predict.h"], + deps = [ + ":node_def_util", + ":op_factory", + ":op_kernel_factory", + "//secretflow_serving/core:link_func", + ], + alwayslink = True, +) + +serving_cc_test( + name = "tree_ensemble_predict_test", + srcs = ["tree_ensemble_predict_test.cc"], + deps = [ + ":tree_ensemble_predict", + "//secretflow_serving/util:test_utils", + "//secretflow_serving/util:utils", + ], +) diff --git a/secretflow_serving/ops/arrow_processing.cc b/secretflow_serving/ops/arrow_processing.cc index 76399d3..de58605 100644 --- a/secretflow_serving/ops/arrow_processing.cc +++ b/secretflow_serving/ops/arrow_processing.cc @@ -139,15 +139,14 @@ ArrowProcessing::ArrowProcessing(OpKernelOptions opts) BuildOutputSchema(); // optional attr - std::string trace_content; - GetNodeBytesAttr(opts_.node_def, "trace_content", &trace_content); + std::string trace_content = + GetNodeBytesAttr(opts_.node_def, *opts_.op_def, "trace_content"); if (trace_content.empty()) { dummy_flag_ = true; return; } - - bool content_json_flag = false; - GetNodeAttr(opts_.node_def, "content_json_flag", &content_json_flag); + bool content_json_flag = + GetNodeAttr(opts_.node_def, *opts_.op_def, "content_json_flag"); if (content_json_flag) { JsonToPb(trace_content, &compute_trace_); @@ -333,12 +332,26 @@ ArrowProcessing::ArrowProcessing(OpKernelOptions opts) SERVING_GET_ARROW_RESULT( arrow::compute::GetFunctionRegistry()->GetFunction(func.name()), arrow_func); + // Noticed, we only allowed scalar type arrow compute function SERVING_ENFORCE( arrow_func->kind() == arrow::compute::Function::Kind::SCALAR, errors::ErrorCode::LOGIC_ERROR, "unsupported arrow compute func:{}", func.name()); + // check number of func arguments correct + if (!arrow_func->arity().is_varargs) { + SERVING_ENFORCE_EQ(func.inputs_size(), arrow_func->arity().num_args, + "The number of input does not match the " + "number required by the function({})", + func.name()); + } else { + SERVING_ENFORCE_GE(func.inputs_size(), arrow_func->arity().num_args, + "The number of input does not meet the " + "minimum number required by the function({})", + func.name()); + } + // check func options valid if (arrow_func->doc().options_required) { SERVING_ENFORCE(!func.option_bytes().empty(), diff --git a/secretflow_serving/ops/dot_product.cc b/secretflow_serving/ops/dot_product.cc index e84d4a3..165eb68 100644 --- a/secretflow_serving/ops/dot_product.cc +++ b/secretflow_serving/ops/dot_product.cc @@ -69,6 +69,9 @@ DotProduct::DotProduct(OpKernelOptions opts) : OpKernel(std::move(opts)) { // feature name feature_name_list_ = GetNodeAttr>(opts_.node_def, "feature_names"); + SERVING_ENFORCE(!feature_name_list_.empty(), + errors::ErrorCode::INVALID_ARGUMENT, + "get empty attr:feature_names"); std::set f_name_set; for (auto& feature_name : feature_name_list_) { SERVING_ENFORCE(f_name_set.emplace(feature_name).second, @@ -101,7 +104,7 @@ DotProduct::DotProduct(OpKernelOptions opts) : OpKernel(std::move(opts)) { GetNodeAttr(opts_.node_def, "output_col_name"); // optional attr - GetNodeAttr(opts_.node_def, "intercept", &intercept_); + intercept_ = GetNodeAttr(opts_.node_def, *opts_.op_def, "intercept"); BuildInputSchema(); BuildOutputSchema(); diff --git a/secretflow_serving/ops/merge_y.cc b/secretflow_serving/ops/merge_y.cc index b86d7b4..8332b98 100644 --- a/secretflow_serving/ops/merge_y.cc +++ b/secretflow_serving/ops/merge_y.cc @@ -32,7 +32,8 @@ MergeY::MergeY(OpKernelOptions opts) : OpKernel(std::move(opts)) { link_function_ = ParseLinkFuncType(link_function_name); // optional attr - GetNodeAttr(opts_.node_def, "yhat_scale", &yhat_scale_); + yhat_scale_ = + GetNodeAttr(opts_.node_def, *opts_.op_def, "yhat_scale"); input_col_name_ = GetNodeAttr(opts_.node_def, "input_col_name"); output_col_name_ = @@ -43,7 +44,7 @@ MergeY::MergeY(OpKernelOptions opts) : OpKernel(std::move(opts)) { } void MergeY::DoCompute(ComputeContext* ctx) { - // santiy check + // sanity check SERVING_ENFORCE(ctx->inputs.size() == 1, errors::ErrorCode::LOGIC_ERROR); SERVING_ENFORCE(ctx->inputs.front().size() >= 1, errors::ErrorCode::LOGIC_ERROR); diff --git a/secretflow_serving/ops/node.cc b/secretflow_serving/ops/node.cc index cb168a3..7a182df 100644 --- a/secretflow_serving/ops/node.cc +++ b/secretflow_serving/ops/node.cc @@ -23,11 +23,15 @@ Node::Node(NodeDef node_def) : node_def_(std::move(node_def)), op_def_(op::OpFactory::GetInstance()->Get(node_def_.op())) { if (node_def_.parents_size() > 0) { - SERVING_ENFORCE(node_def_.parents_size() == op_def_->inputs_size(), - errors::ErrorCode::LOGIC_ERROR, - "node({}) input size({}) not fit op({}) input size({})", - node_def_.name(), node_def_.parents_size(), op_def_->name(), - op_def_->inputs_size()); + if (node_def_.parents_size() != op_def_->inputs_size()) { + // check op input is variable + if (!op_def_->tag().variable_inputs()) { + SERVING_THROW(errors::ErrorCode::INVALID_ARGUMENT, + "node({}) input size({}) not fit op({}) input size({})", + node_def_.name(), node_def_.parents_size(), + op_def_->name(), op_def_->inputs_size()); + } + } } input_nodes_ = {node_def_.parents().begin(), node_def_.parents().end()}; } diff --git a/secretflow_serving/ops/node_def_util.cc b/secretflow_serving/ops/node_def_util.cc index df1189f..ec33e57 100644 --- a/secretflow_serving/ops/node_def_util.cc +++ b/secretflow_serving/ops/node_def_util.cc @@ -28,45 +28,84 @@ bool GetAttrValue(const NodeDef& node_def, const std::string& attr_name, return false; } +bool GetAttrValue(const OpDef& op_def, const std::string& attr_name, + AttrValue* attr_value) { + for (const auto& attr : op_def.attrs()) { + if (attr.name() == attr_name && attr.is_optional()) { + *attr_value = attr.default_value(); + return true; + } + } + return false; +} + } // namespace -#define DEFINE_GET_LIST_ATTR(TYPE, FIELD_LIST, CAST) \ - bool GetNodeAttr(const NodeDef& node_def, const std::string& attr_name, \ - std::vector* value) { \ - AttrValue attr_value; \ - if (!GetAttrValue(node_def, attr_name, &attr_value)) { \ - return false; \ - } \ - SERVING_ENFORCE( \ - attr_value.has_##FIELD_LIST(), errors::ErrorCode::LOGIC_ERROR, \ - "attr_value({}) does not have expected type({}) value, node: {}", \ - attr_name, #FIELD_LIST, node_def.name()); \ - SERVING_ENFORCE(!attr_value.FIELD_LIST().data().empty(), \ - errors::ErrorCode::INVALID_ARGUMENT, \ - "attr_value({}) type({}) has empty value, node: {}", \ - attr_name, #FIELD_LIST, node_def.name()); \ - value->reserve(attr_value.FIELD_LIST().data().size()); \ - for (const auto& v : attr_value.FIELD_LIST().data()) { \ - value->emplace_back(CAST); \ - } \ - return true; \ +#define DEFINE_GET_LIST_ATTR(TYPE, FIELD_LIST, CAST) \ + bool GetNodeAttr(const NodeDef& node_def, const std::string& attr_name, \ + std::vector* value) { \ + AttrValue attr_value; \ + if (!GetAttrValue(node_def, attr_name, &attr_value)) { \ + return false; \ + } \ + SERVING_ENFORCE( \ + attr_value.has_##FIELD_LIST(), errors::ErrorCode::LOGIC_ERROR, \ + "attr_value({}) does not have expected type({}) value, node: {}", \ + attr_name, #FIELD_LIST, node_def.name()); \ + value->reserve(attr_value.FIELD_LIST().data().size()); \ + for (const auto& v : attr_value.FIELD_LIST().data()) { \ + value->emplace_back(CAST); \ + } \ + return true; \ + } \ + bool GetDefaultAttr(const OpDef& op_def, const std::string& attr_name, \ + std::vector* value) { \ + AttrValue attr_value; \ + if (!GetAttrValue(op_def, attr_name, &attr_value)) { \ + return false; \ + } \ + SERVING_ENFORCE(attr_value.has_##FIELD_LIST(), \ + errors::ErrorCode::UNEXPECTED_ERROR, \ + "default attr_value({}) does not have expected type({}) " \ + "value, op: {}", \ + attr_name, #FIELD_LIST, op_def.name()); \ + value->reserve(attr_value.FIELD_LIST().data().size()); \ + for (const auto& v : attr_value.FIELD_LIST().data()) { \ + value->emplace_back(CAST); \ + } \ + return true; \ } -#define DEFINE_GET_ATTR(TYPE, FIELD, CAST) \ - bool GetNodeAttr(const NodeDef& node_def, const std::string& attr_name, \ - TYPE* value) { \ - AttrValue attr_value; \ - if (!GetAttrValue(node_def, attr_name, &attr_value)) { \ - return false; \ - } \ - SERVING_ENFORCE( \ - attr_value.has_##FIELD(), errors::ErrorCode::LOGIC_ERROR, \ - "attr_value({}) does not have expected type({}) value, node: {}", \ - attr_name, #FIELD, node_def.name()); \ - const auto& v = attr_value.FIELD(); \ - *value = CAST; \ - return true; \ - } \ +#define DEFINE_GET_ATTR(TYPE, FIELD, CAST) \ + bool GetNodeAttr(const NodeDef& node_def, const std::string& attr_name, \ + TYPE* value) { \ + AttrValue attr_value; \ + if (!GetAttrValue(node_def, attr_name, &attr_value)) { \ + return false; \ + } \ + SERVING_ENFORCE( \ + attr_value.has_##FIELD(), errors::ErrorCode::LOGIC_ERROR, \ + "attr_value({}) does not have expected type({}) value, node: {}", \ + attr_name, #FIELD, node_def.name()); \ + const auto& v = attr_value.FIELD(); \ + *value = CAST; \ + return true; \ + } \ + bool GetDefaultAttr(const OpDef& op_def, const std::string& attr_name, \ + TYPE* value) { \ + AttrValue attr_value; \ + if (!GetAttrValue(op_def, attr_name, &attr_value)) { \ + return false; \ + } \ + SERVING_ENFORCE(attr_value.has_##FIELD(), \ + errors::ErrorCode::UNEXPECTED_ERROR, \ + "default attr_value({}) does not have expected type({}) " \ + "value, op: {}", \ + attr_name, #FIELD, op_def.name()); \ + const auto& v = attr_value.FIELD(); \ + *value = CAST; \ + return true; \ + } \ DEFINE_GET_LIST_ATTR(TYPE, FIELD##s, CAST) DEFINE_GET_ATTR(std::string, s, v) @@ -101,10 +140,37 @@ bool GetNodeBytesAttr(const NodeDef& node_def, const std::string& attr_name, attr_value.has_by(), errors::ErrorCode::LOGIC_ERROR, "attr_value({}) does not have expected type(bytes) value, node: {}", attr_name, node_def.name()); - SERVING_ENFORCE(!attr_value.bys().data().empty(), - errors::ErrorCode::INVALID_ARGUMENT, - "attr_value({}) type(BytesList) has empty value, node: {}", - attr_name, node_def.name()); + value->reserve(attr_value.bys().data().size()); + for (const auto& v : attr_value.bys().data()) { + value->emplace_back(v); + } + return true; +} + +bool GetBytesDefaultAttr(const OpDef& op_def, const std::string& attr_name, + std::string* value) { + AttrValue attr_value; + if (!GetAttrValue(op_def, attr_name, &attr_value)) { + return false; + } + SERVING_ENFORCE(attr_value.has_by(), errors::ErrorCode::LOGIC_ERROR, + "default attr_value({}) does not have expected type(bytes) " + "value, op: {}", + attr_name, op_def.name()); + *value = attr_value.by(); + return true; +} + +bool GetBytesDefaultAttr(const OpDef& op_def, const std::string& attr_name, + std::vector* value) { + AttrValue attr_value; + if (!GetAttrValue(op_def, attr_name, &attr_value)) { + return false; + } + SERVING_ENFORCE(attr_value.has_by(), errors::ErrorCode::LOGIC_ERROR, + "default attr_value({}) does not have expected type(bytes) " + "value, op: {}", + attr_name, op_def.name()); value->reserve(attr_value.bys().data().size()); for (const auto& v : attr_value.bys().data()) { value->emplace_back(v); diff --git a/secretflow_serving/ops/node_def_util.h b/secretflow_serving/ops/node_def_util.h index e6f6430..06a0d67 100644 --- a/secretflow_serving/ops/node_def_util.h +++ b/secretflow_serving/ops/node_def_util.h @@ -21,6 +21,7 @@ #include "secretflow_serving/core/exception.h" #include "secretflow_serving/protos/graph.pb.h" +#include "secretflow_serving/protos/op.pb.h" namespace secretflow::serving::op { @@ -28,7 +29,11 @@ namespace secretflow::serving::op { bool GetNodeAttr(const NodeDef& node_def, const std::string& attr_name, \ TYPE* value); \ bool GetNodeAttr(const NodeDef& node_def, const std::string& attr_name, \ - std::vector* value); + std::vector* value); \ + bool GetDefaultAttr(const OpDef& op_def, const std::string& attr_name, \ + TYPE* value); \ + bool GetDefaultAttr(const OpDef& op_def, const std::string& attr_name, \ + std::vector* value); DECLARE_GET_ATTR(std::string) DECLARE_GET_ATTR(int32_t) @@ -49,10 +54,28 @@ T GetNodeAttr(const NodeDef& node_def, const std::string& attr_name) { return value; } +template +T GetNodeAttr(const NodeDef& node_def, const OpDef& op_def, + const std::string& attr_name) { + T value; + if (!GetNodeAttr(node_def, attr_name, &value)) { + if (!GetDefaultAttr(op_def, attr_name, &value)) { + SERVING_THROW(errors::ErrorCode::UNEXPECTED_ERROR, + "can not get attr:{} from node:{}, op:{}", attr_name, + node_def.name(), node_def.op()); + } + } + return value; +} + bool GetNodeBytesAttr(const NodeDef& node_def, const std::string& attr_name, std::string* value); bool GetNodeBytesAttr(const NodeDef& node_def, const std::string& attr_name, std::vector* value); +bool GetBytesDefaultAttr(const OpDef& op_def, const std::string& attr_name, + std::string* value); +bool GetBytesDefaultAttr(const OpDef& op_def, const std::string& attr_name, + std::vector* value); inline std::string GetNodeBytesAttr(const NodeDef& node_def, const std::string& attr_name) { @@ -65,4 +88,42 @@ inline std::string GetNodeBytesAttr(const NodeDef& node_def, return value; } +inline std::string GetNodeBytesAttr(const NodeDef& node_def, + const OpDef& op_def, + const std::string& attr_name) { + std::string value; + if (!GetNodeBytesAttr(node_def, attr_name, &value)) { + if (!GetBytesDefaultAttr(op_def, attr_name, &value)) { + SERVING_THROW(errors::ErrorCode::UNEXPECTED_ERROR, + "can not get attr:{} from node:{}, op:{}", attr_name, + node_def.name(), node_def.op()); + } + } + return value; +} + +template +void CheckAttrValueDuplicate(const std::vector& items, + const std::string& attr_name) { + std::set item_set; + for (const auto& item : items) { + SERVING_ENFORCE(item_set.emplace(item).second, + errors::ErrorCode::LOGIC_ERROR, + "found duplicate item:{} in {}", item, attr_name); + } +} + +template +void CheckAttrValueDuplicate(const std::vector& items, + const std::string& attr_name, T ignore_item) { + std::set item_set; + for (const auto& item : items) { + if (item != ignore_item) { + SERVING_ENFORCE(item_set.emplace(item).second, + errors::ErrorCode::LOGIC_ERROR, + "found duplicate item:{} in {}", item, attr_name); + } + } +} + } // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/node_def_util_test.cc b/secretflow_serving/ops/node_def_util_test.cc index 822f1e2..be2cb22 100644 --- a/secretflow_serving/ops/node_def_util_test.cc +++ b/secretflow_serving/ops/node_def_util_test.cc @@ -191,25 +191,4 @@ TEST_F(NodeDefUtilTest, OneOfError) { EXPECT_THROW(JsonToPb(json_content, &node_def), Exception); } -TEST_F(NodeDefUtilTest, EmptyList) { - std::string json_content = R"JSON( -{ - "name": "test_node", - "op": "test_op", - "attr_values": { - "attr_ss": { - "ss": { - "data": [] - } - } - } -} -)JSON"; - - NodeDef node_def; - JsonToPb(json_content, &node_def); - - EXPECT_THROW(GetNodeAttr>(node_def, "attr_ss"), - Exception); -} } // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/op_def_builder.cc b/secretflow_serving/ops/op_def_builder.cc index 3b5a79d..c780e6f 100644 --- a/secretflow_serving/ops/op_def_builder.cc +++ b/secretflow_serving/ops/op_def_builder.cc @@ -245,6 +245,11 @@ OpDefBuilder& OpDefBuilder::Mergeable() { return *this; } +OpDefBuilder& OpDefBuilder::VariableInputs() { + variable_inputs_ = true; + return *this; +} + OpDefBuilder& OpDefBuilder::Input(std::string name, std::string desc) { return Io(std::move(name), std::move(desc), false); } @@ -280,6 +285,13 @@ std::shared_ptr OpDefBuilder::Build() const { SERVING_ENFORCE(!output_defs_.empty(), errors::ErrorCode::LOGIC_ERROR, "missing output def for op: {}", name_); + if (variable_inputs_) { + SERVING_ENFORCE_EQ( + input_defs_.size(), 1U, + "there should be only one input def for `variable inputs` op: {}", + name_); + } + auto op_def = std::make_shared(); op_def->set_name(name_); op_def->set_version(version_); @@ -287,6 +299,7 @@ std::shared_ptr OpDefBuilder::Build() const { op_def->mutable_tag()->set_returnable(returnable_); op_def->mutable_tag()->set_mergeable(mergeable_); + op_def->mutable_tag()->set_variable_inputs(variable_inputs_); // TODO: check valid for (const auto& pair : attr_defs_) { diff --git a/secretflow_serving/ops/op_def_builder.h b/secretflow_serving/ops/op_def_builder.h index 55acab0..f4e11f6 100644 --- a/secretflow_serving/ops/op_def_builder.h +++ b/secretflow_serving/ops/op_def_builder.h @@ -62,6 +62,7 @@ class OpDefBuilder final { // tag OpDefBuilder& Returnable(); OpDefBuilder& Mergeable(); + OpDefBuilder& VariableInputs(); // io OpDefBuilder& Input(std::string name, std::string desc); @@ -79,6 +80,7 @@ class OpDefBuilder final { bool returnable_ = false; bool mergeable_ = false; + bool variable_inputs_ = false; std::unordered_map attr_defs_; std::unordered_map input_defs_; diff --git a/secretflow_serving/ops/op_factory.h b/secretflow_serving/ops/op_factory.h index 8fee0c2..f3e0b8e 100644 --- a/secretflow_serving/ops/op_factory.h +++ b/secretflow_serving/ops/op_factory.h @@ -125,6 +125,10 @@ class OpDefBuilderWrapper { builder_.Mergeable(); return *this; } + OpDefBuilderWrapper& VariableInputs() { + builder_.VariableInputs(); + return *this; + } OpDefBuilderWrapper& Input(std::string name, std::string desc) { builder_.Input(std::move(name), std::move(desc)); return *this; diff --git a/secretflow_serving/ops/op_kernel.h b/secretflow_serving/ops/op_kernel.h index ae99162..b51ee74 100644 --- a/secretflow_serving/ops/op_kernel.h +++ b/secretflow_serving/ops/op_kernel.h @@ -48,12 +48,20 @@ struct ComputeContext { class OpKernel { public: - explicit OpKernel(OpKernelOptions opts) : opts_(std::move(opts)) {} + explicit OpKernel(OpKernelOptions opts) : opts_(std::move(opts)) { + num_inputs_ = opts_.op_def->inputs_size(); + if (opts_.op_def->tag().variable_inputs()) { + // The actual number of inputs for op with variable parameters + // depends on node's parents. + num_inputs_ = opts_.node_def.parents_size(); + } + } virtual ~OpKernel() = default; - size_t GetInputsNum() const { return input_schema_list_.size(); } + size_t GetInputsNum() const { return num_inputs_; } const std::shared_ptr& GetInputSchema(size_t index) const { + SERVING_ENFORCE_LT(index, input_schema_list_.size()); return input_schema_list_[index]; } @@ -116,7 +124,9 @@ class OpKernel { protected: OpKernelOptions opts_; + size_t num_inputs_; std::vector> input_schema_list_; + std::shared_ptr output_schema_; }; diff --git a/secretflow_serving/ops/tree_ensemble_predict.cc b/secretflow_serving/ops/tree_ensemble_predict.cc new file mode 100644 index 0000000..294842d --- /dev/null +++ b/secretflow_serving/ops/tree_ensemble_predict.cc @@ -0,0 +1,114 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "secretflow_serving/ops/tree_ensemble_predict.h" + +#include + +#include "arrow/compute/api.h" + +#include "secretflow_serving/core/exception.h" +#include "secretflow_serving/core/link_func.h" +#include "secretflow_serving/ops/node_def_util.h" +#include "secretflow_serving/ops/op_factory.h" +#include "secretflow_serving/ops/op_kernel_factory.h" +#include "secretflow_serving/util/arrow_helper.h" + +namespace secretflow::serving::op { + +TreeEnsemblePredict::TreeEnsemblePredict(OpKernelOptions opts) + : OpKernel(std::move(opts)) { + input_col_name_ = GetNodeAttr(opts_.node_def, "input_col_name"); + output_col_name_ = + GetNodeAttr(opts_.node_def, "output_col_name"); + num_trees_ = GetNodeAttr(opts_.node_def, "num_trees"); + SERVING_ENFORCE_EQ(static_cast(num_trees_), num_inputs_, + "the number of inputs does not meet the number of trees."); + + auto func_type_str = + GetNodeAttr(opts_.node_def, *opts_.op_def, "algo_func"); + func_type_ = ParseLinkFuncType(func_type_str); + + BuildInputSchema(); + BuildOutputSchema(); +} + +void TreeEnsemblePredict::DoCompute(ComputeContext* ctx) { + // sanity check + SERVING_ENFORCE(ctx->inputs.size() == num_inputs_, + errors::ErrorCode::LOGIC_ERROR); + SERVING_ENFORCE(ctx->inputs.front().size() == 1, + errors::ErrorCode::LOGIC_ERROR); + + // merge trees weight + arrow::Datum incremented_datum(ctx->inputs.front().front()->column(0)); + for (size_t i = 1; i < ctx->inputs.size(); ++i) { + auto cur_array = ctx->inputs[i].front()->column(0); + SERVING_GET_ARROW_RESULT(arrow::compute::Add(incremented_datum, cur_array), + incremented_datum); + } + auto merged_array = std::static_pointer_cast( + std::move(incremented_datum).make_array()); + + // apply link func + arrow::DoubleBuilder builder; + SERVING_CHECK_ARROW_STATUS(builder.Resize(merged_array->length())); + for (int64_t i = 0; i < merged_array->length(); ++i) { + auto score = ApplyLinkFunc(merged_array->Value(i), func_type_); + SERVING_CHECK_ARROW_STATUS(builder.Append(score)); + } + std::shared_ptr res_array; + SERVING_CHECK_ARROW_STATUS(builder.Finish(&res_array)); + ctx->output = + MakeRecordBatch(output_schema_, res_array->length(), {res_array}); +} + +void TreeEnsemblePredict::BuildInputSchema() { + // build input schema + auto schema = + arrow::schema({arrow::field(input_col_name_, arrow::float64())}); + for (size_t i = 0; i < num_inputs_; ++i) { + input_schema_list_.emplace_back(schema); + } +} + +void TreeEnsemblePredict::BuildOutputSchema() { + // build output schema + output_schema_ = + arrow::schema({arrow::field(output_col_name_, arrow::float64())}); +} + +REGISTER_OP_KERNEL(TREE_ENSEMBLE_PREDICT, TreeEnsemblePredict) +REGISTER_OP(TREE_ENSEMBLE_PREDICT, "0.0.1", "") + .VariableInputs() + .Returnable() + .StringAttr("input_col_name", "The column name of tree weight", false, + false) + .StringAttr("output_col_name", + "The column name of tree ensemble predict score", false, false) + .Int32Attr("num_trees", "The number of ensemble's tree", false, false) + .StringAttr( + "algo_func", + "Optional value: " + "LF_SIGMOID_RAW, LF_SIGMOID_MM1, LF_SIGMOID_MM3, " + "LF_SIGMOID_GA, " + "LF_SIGMOID_T1, LF_SIGMOID_T3, " + "LF_SIGMOID_T5, LF_SIGMOID_T7, LF_SIGMOID_T9, LF_SIGMOID_LS7, " + "LF_SIGMOID_SEG3, " + "LF_SIGMOID_SEG5, LF_SIGMOID_DF, LF_SIGMOID_SR, LF_SIGMOID_SEGLS", + false, true, "LF_IDENTITY") + .Input("*args", "variable inputs, accept tree's weights") + .Output("score", "The prediction result of tree ensemble."); + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_ensemble_predict.h b/secretflow_serving/ops/tree_ensemble_predict.h new file mode 100644 index 0000000..7d250f9 --- /dev/null +++ b/secretflow_serving/ops/tree_ensemble_predict.h @@ -0,0 +1,42 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "secretflow_serving/ops/op_kernel.h" + +#include "secretflow_serving/protos/link_function.pb.h" + +namespace secretflow::serving::op { + +class TreeEnsemblePredict : public OpKernel { + public: + explicit TreeEnsemblePredict(OpKernelOptions opts); + + void DoCompute(ComputeContext* ctx) override; + + protected: + void BuildInputSchema() override; + + void BuildOutputSchema() override; + + private: + std::string input_col_name_; + std::string output_col_name_; + + int32_t num_trees_; + LinkFunctionType func_type_; +}; + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_ensemble_predict_test.cc b/secretflow_serving/ops/tree_ensemble_predict_test.cc new file mode 100644 index 0000000..aef5b60 --- /dev/null +++ b/secretflow_serving/ops/tree_ensemble_predict_test.cc @@ -0,0 +1,437 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "secretflow_serving/ops/tree_ensemble_predict.h" + +#include "gtest/gtest.h" + +#include "secretflow_serving/core/link_func.h" +#include "secretflow_serving/ops/op_factory.h" +#include "secretflow_serving/ops/op_kernel_factory.h" +#include "secretflow_serving/util/arrow_helper.h" +#include "secretflow_serving/util/utils.h" + +namespace secretflow::serving::op { + +struct Param { + std::string algo_func; + + std::vector> tree_weights; +}; + +class TreeEnsemblePredictParamTest : public ::testing::TestWithParam { + protected: + void SetUp() override {} + void TearDown() override {} +}; + +TEST_P(TreeEnsemblePredictParamTest, Works) { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_ENSEMBLE_PREDICT", + "attr_values": { + "input_col_name": { + "s": "weights" + }, + "output_col_name": { + "s": "scores" + } + } +} +)JSON"; + + auto param = GetParam(); + + NodeDef node_def; + JsonToPb(json_content, &node_def); + { + AttrValue func_value; + func_value.set_s(param.algo_func); + node_def.mutable_attr_values()->insert( + {"algo_func", std::move(func_value)}); + + AttrValue num_trees_value; + num_trees_value.set_i32(param.tree_weights.size()); + node_def.mutable_attr_values()->insert( + {"num_trees", std::move(num_trees_value)}); + } + + ComputeContext compute_ctx; + for (size_t i = 0; i < param.tree_weights.size(); ++i) { + const auto& weights = param.tree_weights[i]; + + // build input values + std::shared_ptr w_array; + arrow::DoubleBuilder builder; + SERVING_CHECK_ARROW_STATUS(builder.AppendValues(weights)); + SERVING_CHECK_ARROW_STATUS(builder.Finish(&w_array)); + + auto w_record_batch = MakeRecordBatch( + arrow::schema({arrow::field("weights", arrow::float64())}), + w_array->length(), {w_array}); + compute_ctx.inputs.emplace_back( + std::vector>{w_record_batch}); + + // add mock parents for node + node_def.add_parents(std::to_string(i)); + } + + // expect result + std::shared_ptr expect_array; + arrow::DoubleBuilder expect_res_builder; + for (size_t row = 0; row < param.tree_weights[0].size(); ++row) { + double score = param.tree_weights[0][row]; + for (size_t col = 1; col < param.tree_weights.size(); ++col) { + score += param.tree_weights[col][row]; + } + SERVING_CHECK_ARROW_STATUS(expect_res_builder.Append( + ApplyLinkFunc(score, ParseLinkFuncType(param.algo_func)))); + } + SERVING_CHECK_ARROW_STATUS(expect_res_builder.Finish(&expect_array)); + auto expect_res = + MakeRecordBatch(arrow::schema({arrow::field("scores", arrow::float64())}), + expect_array->length(), {expect_array}); + + // build node + auto mock_node = std::make_shared(std::move(node_def)); + ASSERT_EQ(mock_node->GetOpDef()->inputs_size(), 1); + ASSERT_TRUE(mock_node->GetOpDef()->tag().returnable()); + ASSERT_TRUE(mock_node->GetOpDef()->tag().variable_inputs()); + + OpKernelOptions opts{mock_node->node_def(), mock_node->GetOpDef()}; + auto kernel = OpKernelFactory::GetInstance()->Create(std::move(opts)); + + // check input schema + ASSERT_EQ(kernel->GetInputsNum(), param.tree_weights.size()); + const auto& input_schema_list = kernel->GetAllInputSchema(); + ASSERT_EQ(input_schema_list.size(), kernel->GetInputsNum()); + for (size_t i = 0; i < input_schema_list.size(); ++i) { + const auto& input_schema = input_schema_list[i]; + ASSERT_EQ(input_schema, kernel->GetInputSchema(i)); + ASSERT_EQ(input_schema->num_fields(), 1); + auto field = input_schema->field(0); + ASSERT_EQ(field->name(), "weights"); + ASSERT_EQ(field->type()->id(), arrow::Type::type::DOUBLE); + } + + // check output schema + auto output_schema = kernel->GetOutputSchema(); + ASSERT_EQ(output_schema->num_fields(), 1); + for (int j = 0; j < output_schema->num_fields(); ++j) { + auto field = output_schema->field(j); + ASSERT_EQ(field->name(), "scores"); + ASSERT_EQ(field->type()->id(), arrow::Type::type::DOUBLE); + } + + // compute + kernel->Compute(&compute_ctx); + + // check output + ASSERT_TRUE(compute_ctx.output); + + std::cout << "expect_score: " << expect_res->ToString() << std::endl; + std::cout << "result: " << compute_ctx.output->ToString() << std::endl; + + double epsilon = 1E-13; + ASSERT_TRUE(compute_ctx.output->ApproxEquals( + *expect_res, arrow::EqualOptions::Defaults().atol(epsilon))); +} + +INSTANTIATE_TEST_SUITE_P( + TreeEnsemblePredictParamTestSuite, TreeEnsemblePredictParamTest, + ::testing::Values(Param{"LF_IDENTITY", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_RAW", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_MM1", + {{0.339306861, 0.519965351}, + {-0.418656051, -0.0926064253}}}, + Param{"LF_SIGMOID_MM3", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_GA", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_T1", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}, + {-0.381145447, -0.0979942083}}}, + Param{"LF_SIGMOID_T3", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_T5", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_T7", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_T9", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_LS7", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_SEG3", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_SEG5", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_DF", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_SR", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}}, + Param{"LF_SIGMOID_SEGLS", + {{-0.0406250022, 0.338384569}, + {-0.116178043, 0.16241236}, + {-0.196025193, 0.0978358239}}})); + +class TreeEnsemblePredictTest : public ::testing::Test { + protected: + void SetUp() override {} + void TearDown() override {} +}; + +TEST_F(TreeEnsemblePredictTest, Constructor) { + // default algo func + { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_ENSEMBLE_PREDICT", + "attr_values": { + "input_col_name": { + "s": "weights" + }, + "output_col_name": { + "s": "scores" + }, + "num_trees": { + "i32": 3 + } + }, + "parents": [ + "node_1", "node_2", "node_3" + ] +} +)JSON"; + + NodeDef node_def; + JsonToPb(json_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get("TREE_ENSEMBLE_PREDICT"); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_NO_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts))); + } + + // num_trees vs parents mismatch + { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_ENSEMBLE_PREDICT", + "attr_values": { + "input_col_name": { + "s": "weights" + }, + "output_col_name": { + "s": "scores" + }, + "num_trees": { + "i32": 2 + } + }, + "parents": [ + "node_1", "node_2", "node_3" + ] +} +)JSON"; + + NodeDef node_def; + JsonToPb(json_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get("TREE_ENSEMBLE_PREDICT"); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts)), + Exception); + try { + OpKernelFactory::GetInstance()->Create(std::move(opts)); + } catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } + + // wrong algo func + { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_ENSEMBLE_PREDICT", + "attr_values": { + "input_col_name": { + "s": "weights" + }, + "output_col_name": { + "s": "scores" + }, + "num_trees": { + "i32": 3 + }, + "algo_func": { + "s": "SFSDFDF" + } + }, + "parents": [ + "node_1", "node_2", "node_3" + ] +} +)JSON"; + + NodeDef node_def; + JsonToPb(json_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get("TREE_ENSEMBLE_PREDICT"); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts)), + Exception); + try { + OpKernelFactory::GetInstance()->Create(std::move(opts)); + } catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } + + // missing num_trees + { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_ENSEMBLE_PREDICT", + "attr_values": { + "input_col_name": { + "s": "weights" + }, + "output_col_name": { + "s": "scores" + } + }, + "parents": [ + "node_1", "node_2", "node_3" + ] +} +)JSON"; + + NodeDef node_def; + JsonToPb(json_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get("TREE_ENSEMBLE_PREDICT"); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts)), + Exception); + try { + OpKernelFactory::GetInstance()->Create(std::move(opts)); + } catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } + + // missing input_col_name + { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_ENSEMBLE_PREDICT", + "attr_values": { + "output_col_name": { + "s": "scores" + }, + "num_trees": { + "i32": 3 + } + }, + "parents": [ + "node_1", "node_2", "node_3" + ] +} +)JSON"; + + NodeDef node_def; + JsonToPb(json_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get("TREE_ENSEMBLE_PREDICT"); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts)), + Exception); + try { + OpKernelFactory::GetInstance()->Create(std::move(opts)); + } catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } + + // missing output_col_name + { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_ENSEMBLE_PREDICT", + "attr_values": { + "input_col_name": { + "s": "weights" + }, + "num_trees": { + "i32": 3 + } + }, + "parents": [ + "node_1", "node_2", "node_3" + ] +} +)JSON"; + + NodeDef node_def; + JsonToPb(json_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get("TREE_ENSEMBLE_PREDICT"); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts)), + Exception); + try { + OpKernelFactory::GetInstance()->Create(std::move(opts)); + } catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_merge.cc b/secretflow_serving/ops/tree_merge.cc new file mode 100644 index 0000000..5b9ce39 --- /dev/null +++ b/secretflow_serving/ops/tree_merge.cc @@ -0,0 +1,120 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "secretflow_serving/ops/tree_merge.h" + +#include + +#include "secretflow_serving/core/exception.h" +#include "secretflow_serving/ops/node_def_util.h" +#include "secretflow_serving/ops/op_factory.h" +#include "secretflow_serving/ops/op_kernel_factory.h" +#include "secretflow_serving/util/arrow_helper.h" + +namespace secretflow::serving::op { + +TreeMerge::TreeMerge(OpKernelOptions opts) : OpKernel(std::move(opts)) { + input_col_name_ = GetNodeAttr(opts_.node_def, "input_col_name"); + output_col_name_ = + GetNodeAttr(opts_.node_def, "output_col_name"); + + // build leaf tree nodes + auto leaf_node_ids = GetNodeAttr>( + opts_.node_def, *opts_.op_def, "leaf_node_ids"); + CheckAttrValueDuplicate(leaf_node_ids, "leaf_node_ids"); + auto leaf_weights = GetNodeAttr>( + opts_.node_def, *opts_.op_def, "leaf_weights"); + if (!leaf_weights.empty()) { + SERVING_ENFORCE_EQ( + leaf_node_ids.size(), leaf_weights.size(), + "The length of attr value `leaf_node_ids` `leaf_weights` " + "should be same."); + // build bfs weight list + std::map leaf_weight_map; + for (size_t i = 0; i < leaf_node_ids.size(); ++i) { + leaf_weight_map.emplace(leaf_node_ids[i], leaf_weights[i]); + } + for (const auto& [id, weight] : leaf_weight_map) { + bfs_weights_.emplace_back(weight); + } + } + + BuildInputSchema(); + BuildOutputSchema(); +} + +void TreeMerge::DoCompute(ComputeContext* ctx) { + // sanity check + SERVING_ENFORCE(ctx->inputs.size() == 1, errors::ErrorCode::LOGIC_ERROR); + SERVING_ENFORCE(ctx->inputs.front().size() > 1, + errors::ErrorCode::LOGIC_ERROR); + // TODO: support for static detection of whether the execution dp_type + // matches. + SERVING_ENFORCE(!bfs_weights_.empty(), errors::ErrorCode::LOGIC_ERROR, + "party doesn't have leaf weights, can not get merge result."); + + const auto& selects_array = ctx->inputs.front().front()->column(0); + + arrow::DoubleBuilder res_builder; + SERVING_CHECK_ARROW_STATUS(res_builder.Resize(selects_array->length())); + for (int64_t row = 0; row < selects_array->length(); ++row) { + TreePredictSelect merged_select( + std::static_pointer_cast(selects_array) + ->Value(row)); + for (size_t p = 1; p < ctx->inputs.front().size(); ++p) { + TreePredictSelect partial_select( + std::static_pointer_cast( + ctx->inputs.front()[p]->column(0)) + ->Value(row)); + merged_select.Merge(partial_select); + } + SERVING_CHECK_ARROW_STATUS( + res_builder.Append(bfs_weights_[merged_select.GetLeafIndex()])); + } + std::shared_ptr res_array; + SERVING_CHECK_ARROW_STATUS(res_builder.Finish(&res_array)); + ctx->output = + MakeRecordBatch(output_schema_, res_array->length(), {res_array}); +} + +void TreeMerge::BuildInputSchema() { + // build input schema + auto schema = arrow::schema({arrow::field(input_col_name_, arrow::binary())}); + input_schema_list_.emplace_back(schema); +} + +void TreeMerge::BuildOutputSchema() { + // build output schema + output_schema_ = + arrow::schema({arrow::field(output_col_name_, arrow::float64())}); +} + +REGISTER_OP_KERNEL(TREE_MERGE, TreeMerge) +REGISTER_OP(TREE_MERGE, "0.0.1", "") + .Mergeable() + .StringAttr("input_col_name", "The column name of selects", false, false) + .StringAttr("output_col_name", "The column name of tree predict score", + false, false) + .Int32Attr("leaf_node_ids", + "The id list of the leaf nodes, If party does not possess " + "weights, the attr can be omitted.", + true, true, std::vector()) + .DoubleAttr("leaf_weights", + "The weight list for leaf node, If party does not possess " + "weights, the attr can be omitted.", + true, true, std::vector()) + .Input("selects", "Input tree selects") + .Output("score", "The prediction result of tree."); + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_merge.h b/secretflow_serving/ops/tree_merge.h new file mode 100644 index 0000000..33edac5 --- /dev/null +++ b/secretflow_serving/ops/tree_merge.h @@ -0,0 +1,40 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "secretflow_serving/ops/op_kernel.h" +#include "secretflow_serving/ops/tree_utils.h" + +namespace secretflow::serving::op { + +class TreeMerge : public OpKernel { + public: + explicit TreeMerge(OpKernelOptions opts); + + void DoCompute(ComputeContext* ctx) override; + + protected: + void BuildInputSchema() override; + + void BuildOutputSchema() override; + + private: + std::string input_col_name_; + std::string output_col_name_; + + std::vector bfs_weights_ = {}; +}; + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_merge_test.cc b/secretflow_serving/ops/tree_merge_test.cc new file mode 100644 index 0000000..03989ee --- /dev/null +++ b/secretflow_serving/ops/tree_merge_test.cc @@ -0,0 +1,285 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "secretflow_serving/ops/tree_merge.h" + +#include "arrow/ipc/api.h" +#include "gtest/gtest.h" + +#include "secretflow_serving/ops/op_factory.h" +#include "secretflow_serving/ops/op_kernel_factory.h" +#include "secretflow_serving/util/arrow_helper.h" +#include "secretflow_serving/util/test_utils.h" +#include "secretflow_serving/util/utils.h" + +namespace secretflow::serving::op { + +class TreeMergeTest : public ::testing::Test { + protected: + void SetUp() override {} + void TearDown() override {} +}; + +TEST_F(TreeMergeTest, Works) { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_MERGE", + "attr_values": { + "input_col_name": { + "s": "selects" + }, + "output_col_name": { + "s": "weights" + }, + "leaf_node_ids": { + "i32s": { + "data": [ + 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "leaf_weights": { + "ds": { + "data": [ + -0.116178043, 0.16241236, -0.418656051, -0.0926064253, 0.15993154, 0.358381808, -0.104386188, 0.194736511 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"; + NodeDef node_def; + JsonToPb(json_content, &node_def); + + std::vector> input_fields = { + arrow::field("selects", arrow::binary())}; + + auto expect_input_schema = arrow::schema(input_fields); + auto expect_output_schema = + arrow::schema({arrow::field("weights", arrow::float64())}); + + auto mock_node = std::make_shared(std::move(node_def)); + ASSERT_EQ(mock_node->GetOpDef()->inputs_size(), 1); + ASSERT_TRUE(mock_node->GetOpDef()->tag().mergeable()); + + OpKernelOptions opts{mock_node->node_def(), mock_node->GetOpDef()}; + auto kernel = OpKernelFactory::GetInstance()->Create(std::move(opts)); + + // check input schema + ASSERT_EQ(kernel->GetInputsNum(), mock_node->GetOpDef()->inputs_size()); + const auto& input_schema_list = kernel->GetAllInputSchema(); + ASSERT_EQ(input_schema_list.size(), kernel->GetInputsNum()); + for (const auto& input_schema : input_schema_list) { + ASSERT_TRUE(input_schema->Equals(expect_input_schema)); + } + // check output schema + auto output_schema = kernel->GetOutputSchema(); + ASSERT_TRUE(output_schema->Equals(expect_output_schema)); + + // build input + + ComputeContext compute_ctx; + std::vector alice_select_0 = {0, /*01100000*/ (1 << 5) | (1 << 6)}; + std::vector bob_select_0 = { + 0, /*11000011*/ 1 | (1 << 1) | (1 << 6) | (1 << 7)}; + + std::vector alice_select_1 = {0, /*00000101*/ 1 | (1 << 2)}; + std::vector bob_select_1 = { + 0, /*11001100*/ (1 << 2) | (1 << 3) | (1 << 6) | (1 << 7)}; + + { + std::shared_ptr alice_array; + arrow::BinaryBuilder alice_builder; + SERVING_CHECK_ARROW_STATUS( + alice_builder.Append(alice_select_0.data(), alice_select_0.size())); + SERVING_CHECK_ARROW_STATUS( + alice_builder.Append(alice_select_1.data(), alice_select_1.size())); + SERVING_CHECK_ARROW_STATUS(alice_builder.Finish(&alice_array)); + + std::shared_ptr bob_array; + arrow::BinaryBuilder bob_builder; + SERVING_CHECK_ARROW_STATUS( + bob_builder.Append(bob_select_0.data(), bob_select_0.size())); + SERVING_CHECK_ARROW_STATUS( + bob_builder.Append(bob_select_1.data(), bob_select_1.size())); + SERVING_CHECK_ARROW_STATUS(bob_builder.Finish(&bob_array)); + + auto alice_input = + MakeRecordBatch(arrow::schema(input_fields), 2, {alice_array}); + auto bob_input = + MakeRecordBatch(arrow::schema(input_fields), 2, {bob_array}); + + compute_ctx.inputs.emplace_back( + std::vector>{alice_input, + bob_input}); + } + + // expect result + std::shared_ptr weight_array; + // 01100000 & 11000011 = 01000000 + // 00000101 & 11001100 = 00000100 + SERVING_GET_ARROW_RESULT( + arrow::ipc::internal::json::ArrayFromJSON(arrow::float64(), + "[-0.104386188, -0.418656051]"), + weight_array); + auto expect_result = MakeRecordBatch(expect_output_schema, 2, {weight_array}); + + kernel->Compute(&compute_ctx); + + // check output + ASSERT_TRUE(compute_ctx.output); + ASSERT_TRUE(compute_ctx.output->schema()->Equals(expect_output_schema)); + + std::cout << "expect_select: " << expect_result->ToString() << std::endl; + std::cout << "result: " << compute_ctx.output->ToString() << std::endl; + + ASSERT_TRUE(compute_ctx.output->Equals(*expect_result)); +} + +TEST_F(TreeMergeTest, Constructor) { + // default intercept + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_MERGE", + "attr_values": { + "input_col_name": { + "s": "selects" + }, + "output_col_name": { + "s": "weights" + } + }, + "op_version": "0.0.1" +} +)JSON"; + + NodeDef node_def; + JsonToPb(json_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get("TREE_MERGE"); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_NO_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts))); +} + +struct Param { + std::string node_content; +}; + +class TreeMergeExceptionTest : public ::testing::TestWithParam { + protected: + void SetUp() override {} + void TearDown() override {} +}; + +TEST_P(TreeMergeExceptionTest, Constructor) { + auto param = GetParam(); + + NodeDef node_def; + JsonToPb(param.node_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get(node_def.op()); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts)), + Exception); +} + +INSTANTIATE_TEST_SUITE_P( + TreeMergeExceptionTestSuite, TreeMergeExceptionTest, + ::testing::Values( + /*leaf_node_ids and leaf_weights num mismatch*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_MERGE", + "attr_values": { + "input_col_name": { + "s": "selects" + }, + "output_col_name": { + "s": "weights" + }, + "leaf_node_ids": { + "i32s": { + "data": [ + 7, 8, 9, 10, 11, 12 + ] + } + }, + "leaf_weights": { + "ds": { + "data": [ + -0.116178043, 0.16241236, -0.418656051, -0.0926064253, 0.15993154, 0.358381808, -0.104386188, 0.194736511 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"}, + /*missing input_col_name*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_MERGE", + "attr_values": { + "output_col_name": { + "s": "weights" + }, + "leaf_node_ids": { + "i32s": { + "data": [ + 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "leaf_weights": { + "ds": { + "data": [ + -0.116178043, 0.16241236, -0.418656051, -0.0926064253, 0.15993154, 0.358381808, -0.104386188, 0.194736511 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"}, + /*missing output_col_name*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_MERGE", + "attr_values": { + "input_col_name": { + "s": "selects" + }, + "leaf_node_ids": { + "i32s": { + "data": [ + 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "leaf_weights": { + "ds": { + "data": [ + -0.116178043, 0.16241236, -0.418656051, -0.0926064253, 0.15993154, 0.358381808, -0.104386188, 0.194736511 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"})); + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_select.cc b/secretflow_serving/ops/tree_select.cc new file mode 100644 index 0000000..dd14d65 --- /dev/null +++ b/secretflow_serving/ops/tree_select.cc @@ -0,0 +1,221 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "secretflow_serving/ops/tree_select.h" + +#include +#include + +#include "arrow/compute/api.h" + +#include "secretflow_serving/core/exception.h" +#include "secretflow_serving/ops/node_def_util.h" +#include "secretflow_serving/ops/op_factory.h" +#include "secretflow_serving/ops/op_kernel_factory.h" +#include "secretflow_serving/util/arrow_helper.h" + +#include "secretflow_serving/protos/data_type.pb.h" + +namespace secretflow::serving::op { + +TreeSelect::TreeSelect(OpKernelOptions opts) : OpKernel(std::move(opts)) { + // feature name + feature_name_list_ = GetNodeAttr>( + opts_.node_def, "input_feature_names"); + CheckAttrValueDuplicate(feature_name_list_, "input_feature_names"); + // feature types + feature_type_list_ = GetNodeAttr>( + opts_.node_def, "input_feature_types"); + SERVING_ENFORCE_EQ(feature_name_list_.size(), feature_type_list_.size(), + "attr:input_feature_names size={} does not match " + "attr:input_feature_types size={}, node:{}, op:{}", + feature_name_list_.size(), feature_type_list_.size(), + opts_.node_def.name(), opts_.node_def.op()); + // output_col_name + output_col_name_ = + GetNodeAttr(opts_.node_def, "output_col_name"); + // root node id + root_node_id_ = GetNodeAttr(opts_.node_def, "root_node_id"); + + // build tree nodes + auto node_ids = GetNodeAttr>(opts_.node_def, "node_ids"); + CheckAttrValueDuplicate(node_ids, "node_ids"); + auto lchild_ids = + GetNodeAttr>(opts_.node_def, "lchild_ids"); + CheckAttrValueDuplicate(lchild_ids, "lchild_ids", -1); + auto rchild_ids = + GetNodeAttr>(opts_.node_def, "rchild_ids"); + CheckAttrValueDuplicate(rchild_ids, "rchild_ids", -1); + auto leaf_flags = + GetNodeAttr>(opts_.node_def, "leaf_flags"); + auto split_feature_idx_list = + GetNodeAttr>(opts_.node_def, "split_feature_idxs"); + std::for_each(split_feature_idx_list.begin(), split_feature_idx_list.end(), + [&](const auto& idx) { + if (idx >= 0) { + SERVING_ENFORCE_LT(static_cast(idx), + feature_name_list_.size()); + used_feature_idx_list_.emplace(idx); + } + }); + auto split_values = + GetNodeAttr>(opts_.node_def, "split_values"); + SERVING_ENFORCE(node_ids.size() == lchild_ids.size() && + node_ids.size() == rchild_ids.size() && + node_ids.size() == leaf_flags.size() && + node_ids.size() == split_feature_idx_list.size() && + node_ids.size() == split_values.size(), + errors::ErrorCode::LOGIC_ERROR, + "The length of attr value `node_ids` `lchild_ids` " + "`rchild_ids` `leaf_flags` " + "`split_feature_idxs` `split_values` " + "should be same."); + for (size_t i = 0; i < node_ids.size(); ++i) { + TreeNode node{.id = node_ids[i], + .lchild_id = lchild_ids[i], + .rchild_id = rchild_ids[i], + .is_leaf = leaf_flags[i], + .split_feature_idx = split_feature_idx_list[i], + .split_value = split_values[i]}; + nodes_.emplace(node_ids[i], std::move(node)); + } + + int32_t index = 0; + for (auto& p : nodes_) { + if (p.second.is_leaf) { + ++num_leaf_; + p.second.leaf_bfs_index = index++; + } + } + + BuildInputSchema(); + BuildOutputSchema(); +} + +void TreeSelect::DoCompute(ComputeContext* ctx) { + SERVING_ENFORCE(ctx->inputs.size() == 1, errors::ErrorCode::LOGIC_ERROR); + SERVING_ENFORCE(ctx->inputs.front().size() == 1, + errors::ErrorCode::LOGIC_ERROR); + + std::map> input_features; + for (const auto& idx : used_feature_idx_list_) { + const auto& col = ctx->inputs.front().front()->column(idx); + if (col->type_id() != arrow::Type::DOUBLE) { + arrow::Datum double_array_datum; + SERVING_GET_ARROW_RESULT( + arrow::compute::Cast( + col, arrow::compute::CastOptions::Safe(arrow::float64())), + double_array_datum); + input_features.emplace(idx, std::move(double_array_datum).make_array()); + } else { + input_features.emplace(idx, col); + } + } + + std::shared_ptr res_array; + arrow::BinaryBuilder builder; + for (int64_t row = 0; row < ctx->inputs.front().front()->num_rows(); ++row) { + TreePredictSelect pred_select; + pred_select.SetLeafs(num_leaf_); + std::queue nodes; + nodes.push(root_node_id_); + + while (!nodes.empty()) { + const auto it = nodes_.find(nodes.front()); + nodes.pop(); + SERVING_ENFORCE(it != nodes_.end(), errors::ErrorCode::LOGIC_ERROR); + const auto& node = it->second; + if (node.is_leaf) { + SERVING_ENFORCE(node.leaf_bfs_index != -1, + errors::ErrorCode::LOGIC_ERROR); + pred_select.SetLeafSelected(node.leaf_bfs_index); + } else { + if (node.split_feature_idx < 0) { + // split feature not belong to this party, both side could be + // possible + nodes.push(node.lchild_id); + nodes.push(node.rchild_id); + } else { + auto d_a = std::static_pointer_cast( + input_features[node.split_feature_idx]); + SERVING_ENFORCE(d_a, errors::ErrorCode::LOGIC_ERROR); + if (d_a->Value(row) < node.split_value) { + nodes.push(node.lchild_id); + } else { + nodes.push(node.rchild_id); + } + } + } + } + + SERVING_CHECK_ARROW_STATUS( + builder.Append(pred_select.select.data(), pred_select.select.size())); + } + SERVING_CHECK_ARROW_STATUS(builder.Finish(&res_array)); + ctx->output = MakeRecordBatch( + output_schema_, ctx->inputs.front().front()->num_rows(), {res_array}); +} + +void TreeSelect::BuildInputSchema() { + // build input schema + std::vector> fields; + for (size_t i = 0; i < feature_name_list_.size(); ++i) { + auto data_type = DataTypeToArrowDataType(feature_type_list_[i]); + SERVING_ENFORCE( + arrow::is_numeric(data_type->id()), errors::INVALID_ARGUMENT, + "feature type must be numeric, get:{}", feature_type_list_[i]); + fields.emplace_back(arrow::field(feature_name_list_[i], data_type)); + } + input_schema_list_.emplace_back(arrow::schema(std::move(fields))); +} + +void TreeSelect::BuildOutputSchema() { + // build output schema + output_schema_ = + arrow::schema({arrow::field(output_col_name_, arrow::binary())}); +} + +REGISTER_OP_KERNEL(TREE_SELECT, TreeSelect) +REGISTER_OP(TREE_SELECT, "0.0.1", + "Obtaining the local prediction path information of the decision " + "tree using input features.") + .StringAttr("input_feature_names", "List of feature names", true, false) + .StringAttr("input_feature_types", + "List of input feature data types. Optional value: DT_UINT8, " + "DT_INT8, DT_UINT16, DT_INT16, DT_UINT32, DT_INT32, DT_UINT64, " + "DT_INT64, DT_FLOAT, DT_DOUBLE", + true, false) + .StringAttr("output_col_name", "Column name of tree select", false, false) + .Int32Attr("root_node_id", "The id of the root tree node", false, false) + .Int32Attr("node_ids", "The id list of the tree node", true, false) + .Int32Attr("lchild_ids", + "The left child node id list, `-1` means not valid", true, false) + .Int32Attr("rchild_ids", + "The right child node id list, `-1` means not valid", true, + false) + .BoolAttr("leaf_flags", "The leaf flag list of the nodes", true, false) + .Int32Attr("split_feature_idxs", + "The list of split feature index, `-1` means feature not belong " + "to party or not valid", + true, false) + .DoubleAttr( + "split_values", + "node split value, goes left when less than it. valid when `is_leaf " + "== false`", + true, false) + .Input("features", "Input feature table") + .Output("select", + "The local prediction path information of the decision tree."); + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_select.h b/secretflow_serving/ops/tree_select.h new file mode 100644 index 0000000..536c94a --- /dev/null +++ b/secretflow_serving/ops/tree_select.h @@ -0,0 +1,46 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "secretflow_serving/ops/op_kernel.h" +#include "secretflow_serving/ops/tree_utils.h" + +namespace secretflow::serving::op { + +class TreeSelect : public OpKernel { + public: + explicit TreeSelect(OpKernelOptions opts); + + void DoCompute(ComputeContext* ctx) override; + + protected: + void BuildInputSchema() override; + + void BuildOutputSchema() override; + + private: + std::vector feature_name_list_; + std::vector feature_type_list_; + + std::string output_col_name_; + + int32_t root_node_id_; + std::map nodes_; + std::set used_feature_idx_list_; + + size_t num_leaf_ = 0; +}; + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_select_test.cc b/secretflow_serving/ops/tree_select_test.cc new file mode 100644 index 0000000..d216c74 --- /dev/null +++ b/secretflow_serving/ops/tree_select_test.cc @@ -0,0 +1,643 @@ +// Copyright 2023 Ant Group Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "secretflow_serving/ops/tree_select.h" + +#include "arrow/ipc/api.h" +#include "gtest/gtest.h" + +#include "secretflow_serving/ops/op_factory.h" +#include "secretflow_serving/ops/op_kernel_factory.h" +#include "secretflow_serving/util/arrow_helper.h" +#include "secretflow_serving/util/test_utils.h" +#include "secretflow_serving/util/utils.h" + +namespace secretflow::serving::op { + +class TreeSelectTest : public ::testing::Test { + protected: + void SetUp() override {} + void TearDown() override {} +}; + +TEST_F(TreeSelectTest, Works) { + std::string json_content = R"JSON( +{ + "name": "test_node", + "op": "TREE_SELECT", + "attr_values": { + "input_feature_names": { + "ss": { + "data": [ + "x1", "x2", "x3", "x4", "x5", + "x6", "x7", "x8", "x9", "x10" + ] + } + }, + "input_feature_types": { + "ss": { + "data": [ + "DT_DOUBLE", "DT_FLOAT", "DT_DOUBLE", "DT_FLOAT", "DT_INT16", + "DT_UINT16", "DT_INT32", "DT_UINT32", "DT_INT64", "DT_UINT64" + ] + } + }, + "output_col_name": { + "s": "select" + }, + "root_node_id": { + "i32": 0 + }, + "node_ids": { + "i32s": { + "data": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "lchild_ids": { + "i32s": { + "data": [ + 1, 3, 5, 7, 9, 11, 13, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "rchild_ids": { + "i32s": { + "data": [ + 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "leaf_flags": { + "bs": { + "data": [ + "false", "false", "false", "false", "false", "false", "false", "true", "true", "true", "true", "true", "true", "true", "true" + ] + } + }, + "split_feature_idxs": { + "i32s": { + "data": [ + 3, -1, -1, 2, 2, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "split_values": { + "ds": { + "data": [ + -0.154862225, 0.0, 0.0, -0.208345324, 0.301087976, -0.300848633, 0.0800122, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"; + NodeDef node_def; + JsonToPb(json_content, &node_def); + + std::vector> input_fields = { + arrow::field("x1", arrow::float64()), + arrow::field("x2", arrow::float32()), + arrow::field("x3", arrow::float64()), + arrow::field("x4", arrow::float32()), + arrow::field("x5", arrow::int16()), + arrow::field("x6", arrow::uint16()), + arrow::field("x7", arrow::int32()), + arrow::field("x8", arrow::uint32()), + arrow::field("x9", arrow::int64()), + arrow::field("x10", arrow::uint64())}; + + auto expect_input_schema = arrow::schema(input_fields); + auto expect_output_schema = + arrow::schema({arrow::field("select", arrow::binary())}); + + auto mock_node = std::make_shared(std::move(node_def)); + ASSERT_EQ(mock_node->GetOpDef()->inputs_size(), 1); + + OpKernelOptions opts{mock_node->node_def(), mock_node->GetOpDef()}; + auto kernel = OpKernelFactory::GetInstance()->Create(std::move(opts)); + + // check input schema + ASSERT_EQ(kernel->GetInputsNum(), mock_node->GetOpDef()->inputs_size()); + const auto& input_schema_list = kernel->GetAllInputSchema(); + ASSERT_EQ(input_schema_list.size(), kernel->GetInputsNum()); + for (size_t i = 0; i < input_schema_list.size(); ++i) { + const auto& input_schema = input_schema_list[i]; + ASSERT_TRUE(input_schema->Equals(expect_input_schema)); + } + // check output schema + auto output_schema = kernel->GetOutputSchema(); + ASSERT_TRUE(output_schema->Equals(expect_output_schema)); + + // build input + ComputeContext compute_ctx; + { + std::shared_ptr x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11; + using arrow::ipc::internal::json::ArrayFromJSON; + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::float64(), "[-0.01, -0.2]"), + x1); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::float32(), "[0.01, 0.1]"), + x2); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::float64(), "[0.01, -1]"), x3); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::float32(), "[-0.01, -0.2]"), + x4); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::int16(), "[-1, -1]"), x5); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::uint16(), "[1, 1]"), x6); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::int32(), "[-1, -1]"), x7); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::uint32(), "[1, 1]"), x8); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::int64(), "[-1, -1]"), x9); + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::uint64(), "[1, 1]"), x10); + // redundant column + SERVING_GET_ARROW_RESULT(ArrayFromJSON(arrow::uint64(), "[23, 15]"), x11); + input_fields.emplace_back(arrow::field("x11", arrow::uint64())); + + auto features = + MakeRecordBatch(arrow::schema(input_fields), 2, + {x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11}); + auto shuffle_fs = test::ShuffleRecordBatch(features); + std::cout << shuffle_fs->ToString() << std::endl; + + compute_ctx.inputs.emplace_back( + std::vector>{shuffle_fs}); + } + + // expect result + std::vector except_select_0 = {0, /*01100000*/ (1 << 5) | (1 << 6)}; + std::vector except_select_1 = {0, /*00000101*/ 1 | (1 << 2)}; + + kernel->Compute(&compute_ctx); + + // check output + ASSERT_TRUE(compute_ctx.output); + ASSERT_TRUE(compute_ctx.output->schema()->Equals(output_schema)); + std::shared_ptr expect_select_array; + arrow::BinaryBuilder builder; + SERVING_CHECK_ARROW_STATUS( + builder.Append(except_select_0.data(), except_select_0.size())); + SERVING_CHECK_ARROW_STATUS( + builder.Append(except_select_1.data(), except_select_0.size())); + SERVING_CHECK_ARROW_STATUS(builder.Finish(&expect_select_array)); + + std::cout << "expect_select: " << expect_select_array->ToString() + << std::endl; + std::cout << "result: " << compute_ctx.output->column(0)->ToString() + << std::endl; + + ASSERT_TRUE(compute_ctx.output->column(0)->Equals(expect_select_array)); +} + +struct Param { + std::string node_content; +}; + +class TreeSelectExceptionTest : public ::testing::TestWithParam { + protected: + void SetUp() override {} + void TearDown() override {} +}; + +TEST_P(TreeSelectExceptionTest, Constructor) { + auto param = GetParam(); + + NodeDef node_def; + JsonToPb(param.node_content, &node_def); + + auto op_def = OpFactory::GetInstance()->Get(node_def.op()); + OpKernelOptions opts{std::move(node_def), op_def}; + EXPECT_THROW(OpKernelFactory::GetInstance()->Create(std::move(opts)), + Exception); +} + +INSTANTIATE_TEST_SUITE_P( + TreeSelectExceptionTestSuite, TreeSelectExceptionTest, + ::testing::Values(/*name and type num mismatch*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_SELECT", + "attr_values": { + "input_feature_names": { + "ss": { + "data": [ + "x1", "x2", "x3", "x4", "x5", + "x6", "x7", "x8", "x9" + ] + } + }, + "input_feature_types": { + "ss": { + "data": [ + "DT_DOUBLE", "DT_FLOAT", "DT_DOUBLE", "DT_FLOAT", "DT_INT16", + "DT_UINT16", "DT_INT32", "DT_UINT32", "DT_INT64", "DT_UINT64" + ] + } + }, + "output_col_name": { + "s": "select" + }, + "root_node_id": { + "i32": 0 + }, + "node_ids": { + "i32s": { + "data": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "lchild_ids": { + "i32s": { + "data": [ + 1, 3, 5, 7, 9, 11, 13, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "rchild_ids": { + "i32s": { + "data": [ + 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "leaf_flags": { + "bs": { + "data": [ + "false", "false", "false", "false", "false", "false", "false", "true", "true", "true", "true", "true", "true", "true", "true" + ] + } + }, + "split_feature_idxs": { + "i32s": { + "data": [ + 3, -1, -1, 2, 2, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "split_values": { + "ds": { + "data": [ + -0.154862225, 0.0, 0.0, -0.208345324, 0.301087976, -0.300848633, 0.0800122, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"}, + /*missing input_feature_names*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_SELECT", + "attr_values": { + "input_feature_types": { + "ss": { + "data": [ + "DT_DOUBLE", "DT_FLOAT", "DT_DOUBLE", "DT_FLOAT", "DT_INT16", + "DT_UINT16", "DT_INT32", "DT_UINT32", "DT_INT64", "DT_UINT64" + ] + } + }, + "output_col_name": { + "s": "select" + }, + "root_node_id": { + "i32": 0 + }, + "node_ids": { + "i32s": { + "data": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "lchild_ids": { + "i32s": { + "data": [ + 1, 3, 5, 7, 9, 11, 13, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "rchild_ids": { + "i32s": { + "data": [ + 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "leaf_flags": { + "bs": { + "data": [ + "false", "false", "false", "false", "false", "false", "false", "true", "true", "true", "true", "true", "true", "true", "true" + ] + } + }, + "split_feature_idxs": { + "i32s": { + "data": [ + 3, -1, -1, 2, 2, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "split_values": { + "ds": { + "data": [ + -0.154862225, 0.0, 0.0, -0.208345324, 0.301087976, -0.300848633, 0.0800122, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"}, + /*missing input_feature_types*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_SELECT", + "attr_values": { + "input_feature_names": { + "ss": { + "data": [ + "x1", "x2", "x3", "x4", "x5", + "x6", "x7", "x8", "x9", "x10" + ] + } + }, + "output_col_name": { + "s": "select" + }, + "root_node_id": { + "i32": 0 + }, + "node_ids": { + "i32s": { + "data": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "lchild_ids": { + "i32s": { + "data": [ + 1, 3, 5, 7, 9, 11, 13, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "rchild_ids": { + "i32s": { + "data": [ + 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "leaf_flags": { + "bs": { + "data": [ + "false", "false", "false", "false", "false", "false", "false", "true", "true", "true", "true", "true", "true", "true", "true" + ] + } + }, + "split_feature_idxs": { + "i32s": { + "data": [ + 3, -1, -1, 2, 2, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "split_values": { + "ds": { + "data": [ + -0.154862225, 0.0, 0.0, -0.208345324, 0.301087976, -0.300848633, 0.0800122, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"}, + /*missing output_col_name*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_SELECT", + "attr_values": { + "input_feature_names": { + "ss": { + "data": [ + "x1", "x2", "x3", "x4", "x5", + "x6", "x7", "x8", "x9", "x10" + ] + } + }, + "input_feature_types": { + "ss": { + "data": [ + "DT_DOUBLE", "DT_FLOAT", "DT_DOUBLE", "DT_FLOAT", "DT_INT16", + "DT_UINT16", "DT_INT32", "DT_UINT32", "DT_INT64", "DT_UINT64" + ] + } + }, + "root_node_id": { + "i32": 0 + }, + "node_ids": { + "i32s": { + "data": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "lchild_ids": { + "i32s": { + "data": [ + 1, 3, 5, 7, 9, 11, 13, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "rchild_ids": { + "i32s": { + "data": [ + 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "leaf_flags": { + "bs": { + "data": [ + "false", "false", "false", "false", "false", "false", "false", "true", "true", "true", "true", "true", "true", "true", "true" + ] + } + }, + "split_feature_idxs": { + "i32s": { + "data": [ + 3, -1, -1, 2, 2, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "split_values": { + "ds": { + "data": [ + -0.154862225, 0.0, 0.0, -0.208345324, 0.301087976, -0.300848633, 0.0800122, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"}, + /*missing root_node_id*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_SELECT", + "attr_values": { + "input_feature_names": { + "ss": { + "data": [ + "x1", "x2", "x3", "x4", "x5", + "x6", "x7", "x8", "x9", "x10" + ] + } + }, + "input_feature_types": { + "ss": { + "data": [ + "DT_DOUBLE", "DT_FLOAT", "DT_DOUBLE", "DT_FLOAT", "DT_INT16", + "DT_UINT16", "DT_INT32", "DT_UINT32", "DT_INT64", "DT_UINT64" + ] + } + }, + "output_col_name": { + "s": "select" + }, + "node_ids": { + "i32s": { + "data": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "lchild_ids": { + "i32s": { + "data": [ + 1, 3, 5, 7, 9, 11, 13, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "rchild_ids": { + "i32s": { + "data": [ + 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "leaf_flags": { + "bs": { + "data": [ + "false", "false", "false", "false", "false", "false", "false", "true", "true", "true", "true", "true", "true", "true", "true" + ] + } + }, + "split_feature_idxs": { + "i32s": { + "data": [ + 3, -1, -1, 2, 2, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "split_values": { + "ds": { + "data": [ + -0.154862225, 0.0, 0.0, -0.208345324, 0.301087976, -0.300848633, 0.0800122, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"}, + /*mismatch lchild_ids*/ Param{R"JSON( +{ + "name": "test_node", + "op": "TREE_SELECT", + "attr_values": { + "input_feature_names": { + "ss": { + "data": [ + "x1", "x2", "x3", "x4", "x5", + "x6", "x7", "x8", "x9", "x10" + ] + } + }, + "input_feature_types": { + "ss": { + "data": [ + "DT_DOUBLE", "DT_FLOAT", "DT_DOUBLE", "DT_FLOAT", "DT_INT16", + "DT_UINT16", "DT_INT32", "DT_UINT32", "DT_INT64", "DT_UINT64" + ] + } + }, + "output_col_name": { + "s": "select" + }, + "root_node_id": { + "i32": 0 + }, + "node_ids": { + "i32s": { + "data": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ] + } + }, + "lchild_ids": { + "i32s": { + "data": [ + 1, 3, 5, 7, 9, 11, 13, -1, -1, -1, -1, -1, -1 + ] + } + }, + "rchild_ids": { + "i32s": { + "data": [ + 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "leaf_flags": { + "bs": { + "data": [ + "false", "false", "false", "false", "false", "false", "false", "true", "true", "true", "true", "true", "true", "true", "true" + ] + } + }, + "split_feature_idxs": { + "i32s": { + "data": [ + 3, -1, -1, 2, 2, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1 + ] + } + }, + "split_values": { + "ds": { + "data": [ + -0.154862225, 0.0, 0.0, -0.208345324, 0.301087976, -0.300848633, 0.0800122, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + } + } + }, + "op_version": "0.0.1" +} +)JSON"})); + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/ops/tree_utils.h b/secretflow_serving/ops/tree_utils.h new file mode 100644 index 0000000..98e53cd --- /dev/null +++ b/secretflow_serving/ops/tree_utils.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include + +#include "secretflow_serving/core/exception.h" + +namespace secretflow::serving::op { + +struct TreeNode { + int32_t id; + // [common] left child. `-1` means not valid + int32_t lchild_id; + // [common] right child. `-1` means not valid + int32_t rchild_id; + // [common] + bool is_leaf; + // [internal] split feature index, in party's dataset space. `-1` means + // feature not belong to self or not valid. + int32_t split_feature_idx; + // [internal] split value, goes left when less than it. valid when `is_leaf == + // false` + double split_value; + // [leaf] weight for leaf node. + // * master(with label) has meaningful leaf_weight. + // * slave(without label) is trained with encrypted data, leaf_weight is + // meaningless. + double leaf_weight = 0.0; + // only for leaf node; + int32_t leaf_bfs_index = -1; +}; + +struct TreePredictSelect { + // | counts of padding bits in last bit map | 0-7 bit map | 8-15 bit map | ... + std::vector select; + + TreePredictSelect() = default; + + explicit TreePredictSelect(const std::string& str) { + select.resize(str.size()); + std::memcpy(select.data(), str.data(), str.size()); + } + + explicit TreePredictSelect(std::string_view str) { + select.resize(str.size()); + std::memcpy(select.data(), str.data(), str.size()); + } + + void SetLeafs(size_t leafs) { + select.resize(std::ceil(leafs / 8.0) + 1, 0); + uint8_t pad_bits = leafs % 8 ? 8 - leafs % 8 : 0; + select[0] = pad_bits; + } + + size_t Leafs() const { + if (select.empty()) { + return 0; + } + return (select.size() - 1) * 8 - select[0]; + } + + void Merge(const TreePredictSelect& s) { + SERVING_ENFORCE(Leafs(), errors::ErrorCode::LOGIC_ERROR); + SERVING_ENFORCE(Leafs() == s.Leafs(), errors::ErrorCode::LOGIC_ERROR); + for (size_t i = 1; i < s.select.size(); i++) { + select[i] &= s.select[i]; + } + } + + void SetLeafSelected(uint32_t leaf_idx) { + SERVING_ENFORCE(leaf_idx < Leafs(), errors::ErrorCode::LOGIC_ERROR); + size_t vec_idx = leaf_idx / 8 + 1; + uint8_t bit_mask = 1 << (leaf_idx % 8); + select[vec_idx] |= bit_mask; + } + + int32_t GetLeafIndex() const { + SERVING_ENFORCE(Leafs(), errors::ErrorCode::LOGIC_ERROR); + + int32_t idx = -1; + size_t i = 1; + + while (i < select.size()) { + if (select[i]) { + const auto s = select[i]; + // assert only one bit is set. + SERVING_ENFORCE((s & (s - 1)) == 0, errors::ErrorCode::LOGIC_ERROR, + "i {}, s {}", i, s); + idx = (i - 1) * 8 + std::round(std::log2(s)); + i++; + break; + } + i++; + } + + while (i < select.size()) { + // assert only one bit is set. + SERVING_ENFORCE(select[i++] == 0, errors::ErrorCode::LOGIC_ERROR); + } + + SERVING_ENFORCE(idx != -1, errors::ErrorCode::LOGIC_ERROR); + + return idx; + } +}; + +} // namespace secretflow::serving::op diff --git a/secretflow_serving/protos/op.proto b/secretflow_serving/protos/op.proto index f0bcc1b..0a137ae 100644 --- a/secretflow_serving/protos/op.proto +++ b/secretflow_serving/protos/op.proto @@ -39,6 +39,9 @@ message OpTag { // The operator needs to be executed in session. bool session_run = 3; + + // Whether this op has variable input argument. default `false`. + bool variable_inputs = 5; } // The definition of a operator. @@ -54,6 +57,8 @@ message OpDef { OpTag tag = 4; + // If tag variable_inputs is true, the op should have only one `IoDef` for + // inputs, referring to the parameter list. repeated IoDef inputs = 6; IoDef output = 7; diff --git a/secretflow_serving/server/execution_core.cc b/secretflow_serving/server/execution_core.cc index ddd1cf7..dea6f1e 100644 --- a/secretflow_serving/server/execution_core.cc +++ b/secretflow_serving/server/execution_core.cc @@ -249,27 +249,27 @@ ExecutionCore::Stats::Stats( const std::shared_ptr<::prometheus::Registry>& registry) : execute_request_counter_family( ::prometheus::BuildCounter() - .Name("execution_core_request_count_family") + .Name("execution_core_request_count") .Help("How many execution requests are handled by " "this ExecutionCore.") .Labels(labels) .Register(*registry)), execute_request_duration_summary_family( ::prometheus::BuildSummary() - .Name("execution_core_request_duration_family") - .Help("prediction service api request duration in milliseconds") + .Name("execution_core_request_duration_milliseconds") + .Help("ExecutionCore api request duration in milliseconds") .Labels(labels) .Register(*registry)), fetch_feature_counter_family( ::prometheus::BuildCounter() - .Name("fetch_feature_counter_family") + .Name("fetch_feature_counter") .Help("How many times to fetch remote features service by " "this ExecutionCore.") .Labels(labels) .Register(*registry)), fetch_feature_duration_summary_family( ::prometheus::BuildSummary() - .Name("fetch_feature_duration_family") + .Name("fetch_feature_duration_milliseconds") .Help("durations of fetching remote features in milliseconds") .Labels(labels) .Register(*registry)) {} diff --git a/secretflow_serving/server/execution_service_impl.cc b/secretflow_serving/server/execution_service_impl.cc index f8e8422..b559090 100644 --- a/secretflow_serving/server/execution_service_impl.cc +++ b/secretflow_serving/server/execution_service_impl.cc @@ -66,15 +66,15 @@ ExecutionServiceImpl::Stats::Stats( const std::shared_ptr<::prometheus::Registry>& registry) : api_request_counter_family( ::prometheus::BuildCounter() - .Name("execution_request_count_family") + .Name("execution_request_count") .Help("How many execution requests are handled by " "this server.") .Labels(labels) .Register(*registry)), api_request_duration_summary_family( ::prometheus::BuildSummary() - .Name("execution_request_duration_family") - .Help("prediction service api request duration in milliseconds") + .Name("execution_request_duration_milliseconds") + .Help("execution service api request duration in milliseconds") .Labels(labels) .Register(*registry)) {} diff --git a/secretflow_serving/server/kuscia/config_parser.cc b/secretflow_serving/server/kuscia/config_parser.cc index b42404b..3ec3bb0 100644 --- a/secretflow_serving/server/kuscia/config_parser.cc +++ b/secretflow_serving/server/kuscia/config_parser.cc @@ -29,6 +29,21 @@ namespace secretflow::serving::kuscia { +namespace { +const char* kSpiCertEnv = "SERVING_SPI_CERT"; +const char* kSpiPrivateKeyEnv = "SERVING_SPI_PRIVATE_KEY"; +const char* kSpiCaEnv = "SERVING_SPI_CA"; + +void DumpFile(const std::string& file_path, const std::string& content) { + std::ofstream outfile(file_path); + SERVING_ENFORCE(outfile.is_open(), errors::ErrorCode::IO_ERROR, + "cat not open file:{} to dump content.", file_path); + outfile << content; + outfile.close(); +} + +} // namespace + namespace kusica_proto = ::kuscia::proto::api::v1alpha1::appconfig; KusciaConfigParser::KusciaConfigParser(const std::string& config_file) { @@ -134,6 +149,32 @@ KusciaConfigParser::KusciaConfigParser(const std::string& config_file) { OSSSourceMeta oss_meta; JsonToPb(oss_meta_str, model_config_.mutable_oss_source_meta()); } + + // fill spi tls config + if (feature_config_.has_value() && feature_config_->has_http_opts()) { + auto* http_opts = feature_config_->mutable_http_opts(); + if (char* env_p = std::getenv(kSpiCertEnv)) { + if (strlen(env_p) != 0) { + std::string file_path = "./serving_spi_cert"; + DumpFile(file_path, env_p); + http_opts->mutable_tls_config()->set_certificate_path(file_path); + } + } + if (char* env_p = std::getenv(kSpiPrivateKeyEnv)) { + if (strlen(env_p) != 0) { + std::string file_path = "./serving_spi_pk"; + DumpFile(file_path, env_p); + http_opts->mutable_tls_config()->set_private_key_path(file_path); + } + } + if (char* env_p = std::getenv(kSpiCaEnv)) { + if (strlen(env_p) != 0) { + std::string file_path = "./serving_spi_ca"; + DumpFile(file_path, env_p); + http_opts->mutable_tls_config()->set_ca_file_path(file_path); + } + } + } } } // namespace secretflow::serving::kuscia diff --git a/secretflow_serving/server/kuscia/config_parser_test.cc b/secretflow_serving/server/kuscia/config_parser_test.cc index b4fd925..e640ca5 100644 --- a/secretflow_serving/server/kuscia/config_parser_test.cc +++ b/secretflow_serving/server/kuscia/config_parser_test.cc @@ -17,6 +17,8 @@ #include "butil/files/temp_file.h" #include "gtest/gtest.h" +#include "secretflow_serving/util/utils.h" + namespace secretflow::serving::kuscia { class KusciaConfigParserTest : public ::testing::Test { @@ -30,13 +32,18 @@ TEST_F(KusciaConfigParserTest, Works) { tmpfile.save(1 + R"JSON( { "serving_id": "kd-1", - "input_config": "{\"partyConfigs\":{\"alice\":{\"serverConfig\":{\"featureMapping\":{\"v24\":\"x24\",\"v22\":\"x22\",\"v21\":\"x21\",\"v25\":\"x25\",\"v23\":\"x23\"}},\"modelConfig\":{\"modelId\":\"glm-test-1\",\"basePath\":\"/tmp/alice\",\"sourceSha256\":\"3b6a3b76a8d5bbf0e45b83f2d44772a0a6aa9a15bf382cee22cbdc8f59d55522\",\"sourcePath\":\"examples/alice/glm-test.tar.gz\",\"sourceType\":\"ST_FILE\"},\"featureSourceConfig\":{\"mockOpts\":{}},\"channel_desc\":{\"protocol\":\"http\"}},\"bob\":{\"serverConfig\":{\"featureMapping\":{\"v6\":\"x6\",\"v7\":\"x7\",\"v8\":\"x8\",\"v9\":\"x9\",\"v10\":\"x10\"}},\"modelConfig\":{\"modelId\":\"glm-test-1\",\"basePath\":\"/tmp/bob\",\"sourceSha256\":\"330192f3a51f9498dd882478bfe08a06501e2ed4aa2543a0fb586180925eb309\",\"sourcePath\":\"examples/bob/glm-test.tar.gz\",\"sourceType\":\"ST_FILE\"},\"featureSourceConfig\":{\"mockOpts\":{}},\"channel_desc\":{\"protocol\":\"http\"}}}}", + "input_config": "{\"partyConfigs\":{\"alice\":{\"serverConfig\":{\"featureMapping\":{\"v24\":\"x24\",\"v22\":\"x22\",\"v21\":\"x21\",\"v25\":\"x25\",\"v23\":\"x23\"}},\"modelConfig\":{\"modelId\":\"glm-test-1\",\"basePath\":\"/tmp/alice\",\"sourceSha256\":\"3b6a3b76a8d5bbf0e45b83f2d44772a0a6aa9a15bf382cee22cbdc8f59d55522\",\"sourcePath\":\"examples/alice/glm-test.tar.gz\",\"sourceType\":\"ST_FILE\"},\"featureSourceConfig\":{\"httpOpts\":{\"endpoint\":\"alice_ep\"}},\"channel_desc\":{\"protocol\":\"http\"}},\"bob\":{\"serverConfig\":{\"featureMapping\":{\"v6\":\"x6\",\"v7\":\"x7\",\"v8\":\"x8\",\"v9\":\"x9\",\"v10\":\"x10\"}},\"modelConfig\":{\"modelId\":\"glm-test-1\",\"basePath\":\"/tmp/bob\",\"sourceSha256\":\"330192f3a51f9498dd882478bfe08a06501e2ed4aa2543a0fb586180925eb309\",\"sourcePath\":\"examples/bob/glm-test.tar.gz\",\"sourceType\":\"ST_FILE\"},\"featureSourceConfig\":{\"httpOpts\":{\"endpoint\":\"bob_ep\"}},\"channel_desc\":{\"protocol\":\"http\"}}}}", "cluster_def": "{\"parties\":[{\"name\":\"alice\", \"role\":\"\", \"services\":[{\"portName\":\"service\", \"endpoints\":[\"kd-1-service.alice.svc\"]}, {\"portName\":\"internal\", \"endpoints\":[\"kd-1-internal.alice.svc:53510\"]}, {\"portName\":\"brpc-builtin\", \"endpoints\":[\"kd-1-brpc-builtin.alice.svc:53511\"]}]}, {\"name\":\"bob\", \"role\":\"\", \"services\":[{\"portName\":\"brpc-builtin\", \"endpoints\":[\"kd-1-brpc-builtin.bob.svc:53511\"]}, {\"portName\":\"service\", \"endpoints\":[\"kd-1-service.bob.svc\"]}, {\"portName\":\"internal\", \"endpoints\":[\"kd-1-internal.bob.svc:53510\"]}]}], \"selfPartyIdx\":0, \"selfEndpointIdx\":0}", "allocated_ports": "{\"ports\":[{\"name\":\"service\", \"port\":53509, \"scope\":\"Cluster\", \"protocol\":\"HTTP\"}, {\"name\":\"internal\", \"port\":53510, \"scope\":\"Domain\", \"protocol\":\"HTTP\"}, {\"name\":\"brpc-builtin\", \"port\":53511, \"scope\":\"Domain\", \"protocol\":\"HTTP\"}]}", "oss_meta": "" } )JSON"); + // set env + EXPECT_EQ(setenv("SERVING_SPI_CERT", "hello", 1), 0); + EXPECT_EQ(setenv("SERVING_SPI_PRIVATE_KEY", "world", 1), 0); + EXPECT_EQ(setenv("SERVING_SPI_CA", "hello_world", 1), 0); + KusciaConfigParser config_parser(tmpfile.fname()); EXPECT_EQ("kd-1", config_parser.service_id()); @@ -64,7 +71,17 @@ TEST_F(KusciaConfigParserTest, Works) { EXPECT_TRUE(bob_p.listen_address().empty()); auto feature_config = config_parser.feature_config(); - EXPECT_TRUE(feature_config->has_mock_opts()); + EXPECT_TRUE(feature_config->has_http_opts()); + EXPECT_EQ(feature_config->http_opts().endpoint(), "alice_ep"); + EXPECT_EQ(ReadFileContent( + feature_config->http_opts().tls_config().certificate_path()), + "hello"); + EXPECT_EQ(ReadFileContent( + feature_config->http_opts().tls_config().private_key_path()), + "world"); + EXPECT_EQ( + ReadFileContent(feature_config->http_opts().tls_config().ca_file_path()), + "hello_world"); auto server_config = config_parser.server_config(); EXPECT_EQ(5, server_config.feature_mapping_size()); diff --git a/secretflow_serving/server/metrics/metrics_service.cc b/secretflow_serving/server/metrics/metrics_service.cc index 0c6c30d..0dcb5aa 100644 --- a/secretflow_serving/server/metrics/metrics_service.cc +++ b/secretflow_serving/server/metrics/metrics_service.cc @@ -122,7 +122,7 @@ MetricsService::Stats::Stats( num_scrapes(num_scrapes_family.Add({})), request_latencies_family( ::prometheus::BuildSummary() - .Name("exposer_request_duration_seconds") + .Name("exposer_request_duration_milliseconds") .Help("Latencies of serving scrape requests, in milliseconds") .Register(*registry)), request_latencies_s(request_latencies_family.Add( diff --git a/secretflow_serving/server/model_service_impl.cc b/secretflow_serving/server/model_service_impl.cc index 27abdfe..67daff9 100644 --- a/secretflow_serving/server/model_service_impl.cc +++ b/secretflow_serving/server/model_service_impl.cc @@ -85,7 +85,7 @@ ModelServiceImpl::Stats::Stats( .Register(*registry)), api_request_duration_summary_family( ::prometheus::BuildSummary() - .Name("model_service_request_duration_seconds") + .Name("model_service_request_duration_milliseconds") .Help("model service api request duration in " "milliseconds.") .Labels(labels) diff --git a/secretflow_serving/server/prediction_service_impl.cc b/secretflow_serving/server/prediction_service_impl.cc index e72d439..47f04b5 100644 --- a/secretflow_serving/server/prediction_service_impl.cc +++ b/secretflow_serving/server/prediction_service_impl.cc @@ -92,15 +92,15 @@ PredictionServiceImpl::Stats::Stats( .Register(*registry)), api_request_duration_summary_family( ::prometheus::BuildSummary() - .Name("prediction_request_duration_seconds") + .Name("prediction_request_duration_milliseconds") .Help("prediction service api request duration in milliseconds.") .Labels(labels) .Register(*registry)), predict_counter_family( ::prometheus::BuildCounter() - .Name("prediction_count") + .Name("prediction_sample_count") .Help("How many prediction samples are processed by " - "this server.") + "this services.") .Labels(labels) .Register(*registry)), predict_counter(predict_counter_family.Add(::prometheus::Labels{})) {} diff --git a/secretflow_serving/util/utils.cc b/secretflow_serving/util/utils.cc index 3391afc..c001e5e 100644 --- a/secretflow_serving/util/utils.cc +++ b/secretflow_serving/util/utils.cc @@ -23,7 +23,6 @@ namespace secretflow::serving { -namespace { std::string ReadFileContent(const std::string& file) { if (!std::filesystem::exists(file)) { SERVING_THROW(errors::ErrorCode::IO_ERROR, "can not find file: {}", file); @@ -34,7 +33,6 @@ std::string ReadFileContent(const std::string& file) { return std::string((std::istreambuf_iterator(file_is)), std::istreambuf_iterator()); } -} // namespace void LoadPbFromJsonFile(const std::string& file, ::google::protobuf::Message* message) { diff --git a/secretflow_serving/util/utils.h b/secretflow_serving/util/utils.h index a5ccaec..f6dd8fb 100644 --- a/secretflow_serving/util/utils.h +++ b/secretflow_serving/util/utils.h @@ -30,6 +30,8 @@ inline bool CheckStatusOk(const apis::Status& st) { } } +std::string ReadFileContent(const std::string& file); + void LoadPbFromJsonFile(const std::string& file, ::google::protobuf::Message* message);