HEBench
Quickstart C++ Wrapper Backend Tutorial - Benchmark Implementation

The previous steps were all about getting the new backend project, engine, and benchmark description setup to run. Now we need to actually implement our benchmark logic by implementing each of the 7 virtual functions defined in hebench::cpp::BaseBenchmark which map to the hebench::APIBridge backend interface. For this example, we will go over each function and provide an overview of what that function needs to do and the tutorial implemention using SEAL.

We already have our original workflow that executes the element-wise addition operation, and we want to benchmark, as shown during the introduction. To write our test into HEBench, we must understand the detailed workload description. All supported workloads can be found at HEBench Supported Workloads . In particular, Vector Element-wise Addition Workload contains the detailed information for our workload, including number, format, and layout of the parameters; a detailed description of the benchmarks algorithm and how each of the previously described parameters are used; and the expected format and data layout of the benchmark input and results.

Communication between Pipeline Stages

Benchmarks are required to implement a number of functions as defined in the API Bridge. These functions are called by the frontend as part of the testing procedure. Each function will receive some parameters as input, perform some expected operation, and then pass the results back to the frontend which will use the returned results as input to later functions in the flow. This logical flow must be respected by the different functions we have to implement.


API Bridge Function pipeline flow chart.

For more information on the pipeline flow, check API Bridge Function Pipeline Chart .

To enable a high amount of flexibility and enable the widest variety of implementations with the exception of the encode and decode's hebench::APIBridge::DataPackCollection parameters, all communication is done via hebench::APIBridge::Handle objects. These handle objects are completely opaque to the Test Harness and it is up to the backend to decide what is stored in each handle at each step of the pipeline.

C++ wrapper offers a series of helper methods to ease the creation and data wrapping in these handles. While it is not necessary to use these methods, it is recommended for code correctness, robustness, and clarity. See hebench::cpp::BaseEngine::createHandle() and hebench::cpp::BaseEngine::retrieveFromHandle() for details.

All the methods that will be called from Test Harness should receive validated inputs and C++ wrapper performs some basic validation as well (such as null handle checks); however, it is a good idea to validate those inputs in the case where we are using incompatible versions between Test Harness and our backend's required API Bridge, or any other errors occur. For clarity, though, most validation will be omitted in this tutorial (or handled with an assert call).

Always throw hebench::cpp::HEBenchError from C++ wrapper to report errors. C++ wrapper will understand this error type and inform Test Harness accordingly. Throwing other exceptions is valid, but they result in Test Harness receiving HEBENCH_ECODE_CRITICAL_ERROR from the backend. There are some examples throughout the tutorial code.

Mapping Workflow to API Bridge Pipeline

Our task is to map our workflow to the stages of the API Bridge pipeline (each function in the flow graph).

In our example, we already have a workflow that is easy to map, since we have organized stages into function calls. We copy our workflow declaration into the benchmark class (with some extra helper methods) as shown here:

92  class Workload
95  {
96  public:
97  Workload(std::size_t vector_size);
98 
99  std::vector<seal::Plaintext> encodeVector(const std::vector<std::vector<std::int64_t>> &vec);
100  std::vector<seal::Plaintext> encodeVector(const std::vector<gsl::span<const std::int64_t>> &vec);
101  std::vector<seal::Ciphertext> encryptVector(const std::vector<seal::Plaintext> &encoded_vec);
102  std::vector<seal::Ciphertext> eltwiseadd(const std::vector<seal::Plaintext> &A,
103  const std::vector<seal::Ciphertext> &B);
104  std::vector<seal::Plaintext> decryptResult(const std::vector<seal::Ciphertext> &encrypted_result);
105  std::vector<std::vector<int64_t>> decodeResult(const std::vector<seal::Plaintext> &encoded_result);
106 
107  private:
108  class SealBFVContext
109  {
110  public:
111  SealBFVContext(int poly_modulus_degree);
112 
113  seal::BatchEncoder &encoder() { return *m_p_batch_encoder; }
114  seal::Evaluator &evaluator() { return *m_p_evaluator; }
115  seal::Decryptor &decryptor() { return *m_p_decryptor; }
116  const seal::Encryptor &encryptor() const { return *m_p_encryptor; }
117  const seal::PublicKey &public_key() const { return m_public_key; }
118  const seal::SecretKey &secret_key() const { return m_secret_key; }
119  seal::SEALContext &context() { return *m_p_seal_context; }
120 
121  private:
122  std::shared_ptr<seal::SEALContext> m_p_seal_context;
123  std::unique_ptr<seal::KeyGenerator> m_p_keygen;
124  seal::PublicKey m_public_key;
125  seal::SecretKey m_secret_key;
126  std::unique_ptr<seal::Encryptor> m_p_encryptor;
127  std::unique_ptr<seal::Evaluator> m_p_evaluator;
128  std::unique_ptr<seal::Decryptor> m_p_decryptor;
129  std::unique_ptr<seal::BatchEncoder> m_p_batch_encoder;
130  };
131 
132  std::size_t m_vector_size;
133  std::shared_ptr<SealBFVContext> m_p_context;
134 
135  SealBFVContext &context() { return *m_p_context; }
136  };
Workload
Defines all possible workloads.
Definition: types.h:83

We also define some wrappers as internal representation for the parameters to our workflow methods. These are needed to keep track and retrieve the inputs and outputs of the stages as they are wrapped into opaque handles.

73  struct InternalParamInfo
74  {
75  public:
76  static constexpr std::int64_t tagPlaintext = 0x10;
77  static constexpr std::int64_t tagCiphertext = 0x20;
78 
79  std::uint64_t param_position;
80  std::int64_t tag;
81  };
82  // used to bundle a collection of samples for an operation parameter
83  template <class T>
84  struct InternalParam : public InternalParamInfo
85  {
86  public:
87  std::vector<T> samples;
88  };

The next steps follow the logical flow order of the function pipeline.

Steps

0. Benchmark Initialization

During construction of our actual benchmark class, TutorialEltwiseAddBenchmark, we validate the workload flexible parameters that were passed for this benchmark. These must be checked because users can use benchmark configuration files to pass different parameters.

Afterwards, other benchmark initialization steps are performed. In this case, we are initializing our original workflow and the SEAL context for our operations.

222 TutorialEltwiseAddBenchmark::TutorialEltwiseAddBenchmark(hebench::cpp::BaseEngine &engine,
223  const hebench::APIBridge::BenchmarkDescriptor &bench_desc,
224  const hebench::APIBridge::WorkloadParams &bench_params) :
225  hebench::cpp::BaseBenchmark(engine, bench_desc, bench_params)
226 {
227  // validate workload parameters
228 
229  // number of workload parameters (1 for eltwise add: n)
230  if (bench_params.count < TutorialEltwiseAddBenchmarkDescription::NumWorkloadParams)
231  throw hebench::cpp::HEBenchError(HEBERROR_MSG_CLASS("Invalid workload parameters. This workload requires "
232  + std::to_string(TutorialEltwiseAddBenchmarkDescription::NumWorkloadParams)
233  + "parameters."),
235 
236  // check values of the workload parameters and make sure they are supported by benchmark:
237  hebench::cpp::WorkloadParams::EltwiseAdd w_params(bench_params);
238  if (w_params.n() <= 0
239  || w_params.n() - 1 > TutorialEltwiseAddBenchmarkDescription::PolyModulusDegree / 2)
240  throw hebench::cpp::HEBenchError(HEBERROR_MSG_CLASS("Invalid workload parameters. This workload only supports vectors of size up to "
241  + std::to_string(TutorialEltwiseAddBenchmarkDescription::PolyModulusDegree / 2)),
243 
244  // Do any extra workload-parameter-based initialization here, if needed.
245 
246  // initialize original Workload (this initializes SEAL BFV context)
247  m_p_workload = std::make_shared<Workload>(w_params.n());
248 }
Base class that encapsulates common behavior of backend engines.
Definition: engine.hpp:70
#define HEBERROR_MSG_CLASS(message)
std::uint64_t count
Number of workload parameters.
Definition: types.h:371
Defines a benchmark test.
Definition: types.h:527
Specifies the parameters for a workload.
Definition: types.h:363
std::string to_string(const std::string_view &s)
#define HEBENCH_ECODE_INVALID_ARGS
Indicates invalid arguments to function call.
Definition: types.h:42

1. encode

hebench::cpp::BaseBenchmark::encode wraps the hebench::APIBridge::encode() function. In the default behavior: encode receives a call for all operation parameters that will be in plain text, and another call for all encrypted, in no specific order. This method should encode all parameters received via p_parameters, bundle them together using an internal format that will make easier to recover from other methods (such as encrypt and/or load) and return them in an opaque handle.

Encode is responsible for rearranging and encoding this data into a format and new memory location that is compatible with the backend.


API Bridge Encode flow chart.

For our benchmark, the element-wise add operation has only 2 operands. We have specified in the benchmark description that first is plain text and second is ciphertext. According to documentation, Test Harness will encode all parameters that ought to be encrypted in a single call to encode, and all the plain text in another call.

First, we validate the data packs. Our backend supports variable sample sizes, but since we are specifying hard values for number of samples per parameter in the benchmark description, we make sure we are receiving the correct number of samples here. We can set the specified count to 0 in the description and accept variable number of samples instead.

266  const hebench::APIBridge::DataPack &param_pack = p_parameters->p_data_packs[0];
267 
268  if (param_pack.buffer_count != this->getDescriptor().cat_params.offline.data_count[param_pack.param_position])
269  {
270  std::stringstream ss;
271  ss << "Unexpected number of input samples for operation parameter " << param_pack.param_position
272  << ". Expected " << this->getDescriptor().cat_params.offline.data_count[param_pack.param_position]
273  << ", but " << std::to_string(param_pack.buffer_count) << " received.";
275  }
std::uint64_t param_position
The 0-based position of this parameter in the corresponding function call.
Definition: types.h:614
std::uint64_t buffer_count
Number of data buffers in p_buffers.
Definition: types.h:613
Defines a data package for an operation.
Definition: types.h:611

Once we know the data pack is valid, we must arrange the raw data coming from Test Harness to be compatible with the input to our original encoding method. In offline category, each operation parameter contains a collection of samples for said parameter. Since the incoming data which is already in the expected format for our original encoding, we just have to point the data structures to the appropriate memory locations. Refer to the workload description reference for information on the data layouts.

Each sample coming from Test Harness is contained in a memory buffer wrapped in a hebench::APIBridge::NativeDataBuffer structure. A sample for element-wise add is a vector of scalars of the requested type during benchmark description (64-bit signed integer in this example). The number of elements in this vector should be the same as received from the workload parameters during construction of this object.

279  hebench::cpp::WorkloadParams::EltwiseAdd w_params(this->getWorkloadParameters());
280  std::vector<gsl::span<const std::int64_t>> clear_param(param_pack.buffer_count);
281  for (std::size_t sample_i = 0; sample_i < clear_param.size(); ++sample_i)
282  {
283  const hebench::APIBridge::NativeDataBuffer &native_sample = param_pack.p_buffers[sample_i];
284  clear_param[sample_i] =
285  gsl::span<const std::int64_t>(reinterpret_cast<const std::int64_t *>(native_sample.p),
286  native_sample.size / sizeof(std::int64_t));
287  assert(clear_param[sample_i].size() == w_params.n());
288  }
std::uint64_t size
Size of underlying data.
Definition: types.h:564
void * p
Pointer to underlying data.
Definition: types.h:558
NativeDataBuffer * p_buffers
Array of data buffers for parameter.
Definition: types.h:612
Structure to contain flexible data.
Definition: types.h:552

Since we arranged the input from Test Harness into the format expected by our original encoding method, now we get to call it to do our actual encoding.

292  std::vector<seal::Plaintext> encoded = m_p_workload->encodeVector(clear_param);

From the default pipeline, the result of the encoding will be passed to encrypt() or load() methods, which correspond to our workflow encryption and (for lack of load step) operation. So, to return the encoding, we wrap it in our internal representation. This representation can be as simple or as sophisticated as we want. The idea is to facilitate access to the wrapped data by methods that will be receiving it.

Then, we hide our representation inside an opaque handle to cross the boundary of the API Bridge. We use hebench::cpp::BaseEngine::createHandle() helper method to generate the handle for our return value.

The tag serves to keep track and identify that we are receiving the correct handles in the pipeline. The values for the tag are arbitrary, for backend to use for this purpose, and thus, we define an internal convention for tagging our handles.

We use move semantics when creating the handle to avoid copying possibly large amounts of data here. But, again, this is all backend specific, and any particular implementation is free to return the data in whichever way fits best.

295  InternalParam<seal::Plaintext> retval;
296  retval.samples = std::move(encoded);
297  retval.param_position = param_pack.param_position;
298  retval.tag = InternalParamInfo::tagPlaintext;
299 
300  return this->getEngine().createHandle<decltype(retval)>(
301  sizeof(seal::Plaintext) * retval.samples.size(), // size (arbitrary for our usage if we need to)
302  retval.tag, // extra tags
303  std::move(retval)); // constructor parameters

This is the complete listing of our encode() method:

260 {
261  assert(p_parameters && p_parameters->pack_count > 0 && p_parameters->p_data_packs);
262 
263  assert(p_parameters->pack_count == 1);
264 
266  const hebench::APIBridge::DataPack &param_pack = p_parameters->p_data_packs[0];
267 
268  if (param_pack.buffer_count != this->getDescriptor().cat_params.offline.data_count[param_pack.param_position])
269  {
270  std::stringstream ss;
271  ss << "Unexpected number of input samples for operation parameter " << param_pack.param_position
272  << ". Expected " << this->getDescriptor().cat_params.offline.data_count[param_pack.param_position]
273  << ", but " << std::to_string(param_pack.buffer_count) << " received.";
275  }
277 
279  hebench::cpp::WorkloadParams::EltwiseAdd w_params(this->getWorkloadParameters());
280  std::vector<gsl::span<const std::int64_t>> clear_param(param_pack.buffer_count);
281  for (std::size_t sample_i = 0; sample_i < clear_param.size(); ++sample_i)
282  {
283  const hebench::APIBridge::NativeDataBuffer &native_sample = param_pack.p_buffers[sample_i];
284  clear_param[sample_i] =
285  gsl::span<const std::int64_t>(reinterpret_cast<const std::int64_t *>(native_sample.p),
286  native_sample.size / sizeof(std::int64_t));
287  assert(clear_param[sample_i].size() == w_params.n());
288  }
290 
292  std::vector<seal::Plaintext> encoded = m_p_workload->encodeVector(clear_param);
295  InternalParam<seal::Plaintext> retval;
296  retval.samples = std::move(encoded);
297  retval.param_position = param_pack.param_position;
298  retval.tag = InternalParamInfo::tagPlaintext;
299 
300  return this->getEngine().createHandle<decltype(retval)>(
301  sizeof(seal::Plaintext) * retval.samples.size(), // size (arbitrary for our usage if we need to)
302  retval.tag, // extra tags
303  std::move(retval)); // constructor parameters
305 }
DataPack * p_data_packs
Collection of data packs.
Definition: types.h:625
ErrorCode encode(Handle h_benchmark, const DataPackCollection *p_parameters, Handle *h_plaintext)
Given a pack of parameters in raw, native data format, encodes them into plain text suitable for back...
std::uint64_t pack_count
Number of data packs in the collection.
Definition: types.h:626
Defines a collection of data packs.
Definition: types.h:624

2. encrypt

hebench::cpp::BaseBenchmark::encrypt is responsible for receiving the plain text output from encode() and encrypting it into ciphertext.


API Bridge Encrypt flow chart.

Here we retrieve our internal representation from the opaque handle representing the encoded data:

345  const InternalParam<seal::Plaintext> &encoded_parameter =
346  this->getEngine().retrieveFromHandle<InternalParam<seal::Plaintext>>(h_encoded_parameters,
347  InternalParamInfo::tagPlaintext);

We want input to encrypt() to be of type InternalParam<seal::Plaintext>. It is expected all data returned by all methods feeding into encrypt() return data in this format. This data must be wrapped into an opaque handle with tag InternalParamInfo::tagPlaintext. Note that this is our internal convention, established to facilitate communication among our implementation of the backend methods. Test Harness is not aware of our convention. It will only pass our handles in the order defined by the workload pipeline flow.

Since our internal representation is designed to maintain the input format expected by our original methods, now we just need to call the encryption from the original workflow.

351  std::vector<seal::Ciphertext> encrypted = m_p_workload->encryptVector(encoded_parameter.samples);

Finally, we wrap our encrypted parameter in our internal representation, hiding it inside an opaque handle to cross the boundary of the API Bridge. This handle will be passed to method load() in the default pipeline.

355  InternalParam<seal::Ciphertext> retval;
356  retval.samples = std::move(encrypted);
357  retval.param_position = encoded_parameter.param_position;
358  retval.tag = InternalParamInfo::tagCiphertext;
359 
360  return this->getEngine().createHandle<decltype(retval)>(
361  sizeof(seal::Ciphertext) * retval.samples.size(), // size (arbitrary for our usage if we need to)
362  retval.tag, // extra tags
363  std::move(retval)); // constructor parameters

This is the complete listing for our method:

343 {
345  const InternalParam<seal::Plaintext> &encoded_parameter =
346  this->getEngine().retrieveFromHandle<InternalParam<seal::Plaintext>>(h_encoded_parameters,
347  InternalParamInfo::tagPlaintext);
349 
351  std::vector<seal::Ciphertext> encrypted = m_p_workload->encryptVector(encoded_parameter.samples);
353 
355  InternalParam<seal::Ciphertext> retval;
356  retval.samples = std::move(encrypted);
357  retval.param_position = encoded_parameter.param_position;
358  retval.tag = InternalParamInfo::tagCiphertext;
359 
360  return this->getEngine().createHandle<decltype(retval)>(
361  sizeof(seal::Ciphertext) * retval.samples.size(), // size (arbitrary for our usage if we need to)
362  retval.tag, // extra tags
363  std::move(retval)); // constructor parameters
365 }
ErrorCode encrypt(Handle h_benchmark, Handle h_plaintext, Handle *h_ciphertext)
Encrypts a plain text into a cipher text.

3. load

Method hebench::cpp::BaseBenchmark::load has two jobs. The first and foremost is to transfer the data to the location where it will be used during the operation, whether it is a remote server, accelerator hardware, or simply local host. The second job, which is usually bundled with the first, is to rearrange the data, if needed, so that the operation itself is not burdened with unnecessary data manipulation. While most of the data manipulation and layout should have happened during encode(), any last minute arrangements should be done here.


API Bridge Encode flow chart.

This method will receive all handles resulting from previous calls made to encode() and encrypt() methods. Based on the workload pipeline flow specified in the documentation we know what we will be receiving in those handles, and it is up to our internal convention to extract our information from the opaque handles and organize it for the operation.

Since, for this example, the data will remain in the local host, we do not need to use any extra functionality to transfer it. We will only arrange the order of the parameters to directly match our original workflow operation into a pair (2-tuple).

402  std::pair<std::vector<seal::Plaintext>, std::vector<seal::Ciphertext>> params;

It is important to note that if the input handles are destroyed, the generated output handles should not be affected, according to the documentation specification for API Bridge. Also, it is good practice for a backend to avoid modifying the underlying data contained in input handles. This means, that if we only need to pass the data wrapped in an input handle along as return value, we must either duplicate the handle, or create a copy of the data that will not be modified or destroyed if the original data from the input handle is modified or destroyed. In this case we will duplicate the data. We show the handle duplication in the store() method.

406  // We query for the parameter position, and, once found, we create a copy of the data.
407  for (std::size_t handle_i = 0; handle_i < count; ++handle_i)
408  {
409  // both our InternalParam<Plaintext> and InternalParam<Ciphertext> inherit from InternalParamInfo
410  const InternalParamInfo &param_info =
411  this->getEngine().retrieveFromHandle<InternalParamInfo>(p_local_data[handle_i]);
412  assert(param_info.param_position < TutorialEltwiseAddBenchmarkDescription::ParametersCount);
413 
414  switch (param_info.param_position)
415  {
416  case 0:
417  {
418  if (!params.first.empty())
419  throw hebench::cpp::HEBenchError(HEBERROR_MSG_CLASS("Duplicated operation parameter detected in input handle."),
421  // specialize to the correct InternalParam<?>
422  const InternalParam<seal::Plaintext> &internal_param =
423  this->getEngine().retrieveFromHandle<InternalParam<seal::Plaintext>>(p_local_data[handle_i],
424  InternalParamInfo::tagPlaintext);
425  // create a deep copy of input
426  params.first = internal_param.samples;
427  break;
428  }
429  case 1:
430  {
431  if (!params.second.empty())
432  throw hebench::cpp::HEBenchError(HEBERROR_MSG_CLASS("Duplicated operation parameter detected in input handle."),
434  // specialize to the correct InternalParam<?>
435  const InternalParam<seal::Ciphertext> &internal_param =
436  this->getEngine().retrieveFromHandle<InternalParam<seal::Ciphertext>>(p_local_data[handle_i],
437  InternalParamInfo::tagCiphertext);
438  // create a deep copy of input
439  params.second = internal_param.samples;
440  break;
441  }
442  } // end switch
443  } // end for

We complete our method, as usual, by wrapping our representation inside an opaque handle to cross the boundary of the API Bridge. This handle will passed to method operate().

The full listing for our load() method is below.

398 {
399  assert(count == TutorialEltwiseAddBenchmarkDescription::ParametersCount);
400 
402  std::pair<std::vector<seal::Plaintext>, std::vector<seal::Ciphertext>> params;
404 
406  // We query for the parameter position, and, once found, we create a copy of the data.
407  for (std::size_t handle_i = 0; handle_i < count; ++handle_i)
408  {
409  // both our InternalParam<Plaintext> and InternalParam<Ciphertext> inherit from InternalParamInfo
410  const InternalParamInfo &param_info =
411  this->getEngine().retrieveFromHandle<InternalParamInfo>(p_local_data[handle_i]);
412  assert(param_info.param_position < TutorialEltwiseAddBenchmarkDescription::ParametersCount);
413 
414  switch (param_info.param_position)
415  {
416  case 0:
417  {
418  if (!params.first.empty())
419  throw hebench::cpp::HEBenchError(HEBERROR_MSG_CLASS("Duplicated operation parameter detected in input handle."),
421  // specialize to the correct InternalParam<?>
422  const InternalParam<seal::Plaintext> &internal_param =
423  this->getEngine().retrieveFromHandle<InternalParam<seal::Plaintext>>(p_local_data[handle_i],
424  InternalParamInfo::tagPlaintext);
425  // create a deep copy of input
426  params.first = internal_param.samples;
427  break;
428  }
429  case 1:
430  {
431  if (!params.second.empty())
432  throw hebench::cpp::HEBenchError(HEBERROR_MSG_CLASS("Duplicated operation parameter detected in input handle."),
434  // specialize to the correct InternalParam<?>
435  const InternalParam<seal::Ciphertext> &internal_param =
436  this->getEngine().retrieveFromHandle<InternalParam<seal::Ciphertext>>(p_local_data[handle_i],
437  InternalParamInfo::tagCiphertext);
438  // create a deep copy of input
439  params.second = internal_param.samples;
440  break;
441  }
442  } // end switch
443  } // end for
445 
447  return this->getEngine().createHandle<decltype(params)>(
448  sizeof(params), // size (arbitrary for our usage if we need to)
449  InternalParamInfo::tagPlaintext | InternalParamInfo::tagCiphertext, // extra tags
450  std::move(params)); // move to avoid extra copy
452 }
ErrorCode load(Handle h_benchmark, const Handle *h_local_packed_params, std::uint64_t local_count, Handle *h_remote)
Loads the specified data from the local host into the remote backend to use as parameter during a cal...

4. operate

hebench::cpp::BaseBenchmark::operate is expected to perform the benchmark operation on the provided combination of encrypted and plain text input data.


API Bridge Encode flow chart.

In practice, operate() should perform as fast as possible. Also, it should never return until all the results for the requested operation are available on the remote host or device and ready for retrieval from the local host.

To start, we obtain our internal input representation from the opaque input handle. This is the handle returned by method load().

478  const std::pair<std::vector<seal::Plaintext>, std::vector<seal::Ciphertext>> &params =
479  this->getEngine().retrieveFromHandle<std::pair<std::vector<seal::Plaintext>, std::vector<seal::Ciphertext>>>(
480  h_remote_packed, InternalParamInfo::tagCiphertext | InternalParamInfo::tagPlaintext);
481 
482  // Looks familiar?
483  const std::vector<seal::Plaintext> &A = params.first;
484  const std::vector<seal::Ciphertext> &B = params.second;

Input data for the operation has been packed into a single handle by method load(). Usually, all of the data samples supplied by Test Harness is encrypted and/or encoded. Indexers are used by Test Harness to point to a portion of input samples to use for the operation, requesting the backend to operate on a subset of the input instead of the complete dataset.

Note that, unless otherwise specified, in offline mode the complete dataset is used, and thus, needing to use the indexers is rare.

In this tutorial, the backend does not support operating on subsets of the dataset. In the following code, we simply validate the indexers and move on. However, support is not difficult to add in this scenario using spans to point to portions of the input dataset. It is left as an exercise to the reader.

488  std::array<std::size_t, TutorialEltwiseAddBenchmarkDescription::ParametersCount> param_size;
489  param_size[0] = A.size();
490  param_size[1] = B.size();
491  std::uint64_t results_count = 1;
492  for (std::size_t param_i = 0; param_i < TutorialEltwiseAddBenchmarkDescription::ParametersCount; ++param_i)
493  {
494  if (p_param_indexers[param_i].value_index >= param_size[param_i])
495  {
496  std::stringstream ss;
497  ss << "Invalid parameter indexer for operation parameter " << param_i << ". Expected index in range [0, "
498  << param_size[param_i] << "), but " << p_param_indexers[param_i].value_index << " received.";
501  }
502  else if (p_param_indexers[param_i].value_index + p_param_indexers[param_i].batch_size > param_size[param_i])
503  {
504  std::stringstream ss;
505  ss << "Invalid parameter indexer for operation parameter " << param_i << ". Expected batch size in range [1, "
506  << param_size[param_i] - p_param_indexers[param_i].value_index << "], but " << p_param_indexers[param_i].batch_size << " received.";
509  }
510  results_count *= p_param_indexers[param_i].batch_size; // count the number of results expected
511  }

Since we obtained the inputs for our operation in the correct format, next we pass them to our original workflow.

515  std::vector<seal::Ciphertext> result = m_p_workload->eltwiseadd(A, B);
516  assert(result.size() == results_count);

As a side note: if operate is executing on an external device that requires some sort of data streaming, this can be mimicked in offline mode as follows:

  1. Load first chunk of data during loading phase.
  2. (in parallel) Operate on current chunk of data. (in parallel) If more data is available, stream next chunk of data from host into remote.
  3. If more data is available, go to ii.
  4. Wait for all ongoing operations to complete.

Finally, we wrap the result in our internal representation, and hide it inside an opaque handle to cross the boundary of the API Bridge. This handle will be passed to method store() in the default pipeline.

Full listing of the operate() method follows.

475  const hebench::APIBridge::ParameterIndexer *p_param_indexers)
476 {
478  const std::pair<std::vector<seal::Plaintext>, std::vector<seal::Ciphertext>> &params =
479  this->getEngine().retrieveFromHandle<std::pair<std::vector<seal::Plaintext>, std::vector<seal::Ciphertext>>>(
480  h_remote_packed, InternalParamInfo::tagCiphertext | InternalParamInfo::tagPlaintext);
481 
482  // Looks familiar?
483  const std::vector<seal::Plaintext> &A = params.first;
484  const std::vector<seal::Ciphertext> &B = params.second;
486 
488  std::array<std::size_t, TutorialEltwiseAddBenchmarkDescription::ParametersCount> param_size;
489  param_size[0] = A.size();
490  param_size[1] = B.size();
491  std::uint64_t results_count = 1;
492  for (std::size_t param_i = 0; param_i < TutorialEltwiseAddBenchmarkDescription::ParametersCount; ++param_i)
493  {
494  if (p_param_indexers[param_i].value_index >= param_size[param_i])
495  {
496  std::stringstream ss;
497  ss << "Invalid parameter indexer for operation parameter " << param_i << ". Expected index in range [0, "
498  << param_size[param_i] << "), but " << p_param_indexers[param_i].value_index << " received.";
501  }
502  else if (p_param_indexers[param_i].value_index + p_param_indexers[param_i].batch_size > param_size[param_i])
503  {
504  std::stringstream ss;
505  ss << "Invalid parameter indexer for operation parameter " << param_i << ". Expected batch size in range [1, "
506  << param_size[param_i] - p_param_indexers[param_i].value_index << "], but " << p_param_indexers[param_i].batch_size << " received.";
509  }
510  results_count *= p_param_indexers[param_i].batch_size; // count the number of results expected
511  }
513 
515  std::vector<seal::Ciphertext> result = m_p_workload->eltwiseadd(A, B);
516  assert(result.size() == results_count);
518 
520  // Finally, we wrap the result in our internal representation.
521  InternalParam<seal::Ciphertext> retval;
522  retval.samples = std::move(result);
523  retval.param_position = 0; // position of this result component inside the result tuple.
524  retval.tag = InternalParamInfo::tagCiphertext;
525 
526  // Hide our representation inside an opaque handle to cross the boundary of the API Bridge.
527  // This handle will be passed to method `store()` in the default pipeline.
528  return this->getEngine().createHandle<decltype(retval)>(
529  sizeof(seal::Ciphertext) * retval.samples.size(), // size (arbitrary for our usage if we need to)
530  retval.tag, // extra tags
531  std::move(retval)); // move to avoid extra copies
533 }
ErrorCode operate(Handle h_benchmark, Handle h_remote_packed_params, const ParameterIndexer *p_param_indexers, uint64_t indexers_count, Handle *h_remote_output)
Performs the workload operation of the benchmark.
std::uint64_t value_index
Index of parameter value inside the data pack.
Definition: types.h:651
std::uint64_t batch_size
Number of values to use, starting from index.
Definition: types.h:652

5. store

hebench::cpp::BaseBenchmark::store is responsible for copying results back from our remote device into the local host.


API Bridge Encode flow chart.

We are on the downward slope now. We must store, decrypt, and decode the results of the operation.

The input handle for method store() is the handle returned by operate. In a backend where the operation occurs on a remote device (server, hardware accelerator, etc.) the result of the operation remains on the remote after completion. The job of this method is to transfer that result from remote into the local host for the rest of the pipeline.

As per specification of API Bridge, any extra handles should be padded with zeroes. So, we take care of that first to avoid extra work later.

462  std::memset(p_local_data, 0, sizeof(hebench::APIBridge::Handle) * count);

Since the host and remote are the same for this example, we do not need to perform any retrieval operations. We will just duplicate the handle to ensure that if the input handle is destroyed, the resulting handle remains.

466  p_local_data[0] = this->getEngine().duplicateHandle(h_remote_data,
467  InternalParamInfo::tagCiphertext); // validate that we are operating on the correct handle

Note that handle duplication does not perform a deep copy of the underlying data. Both, the original and duplicated handle will refer to the same internal data and modifying one will effectively reflect the changes in the other. While the specification calls for persistence of results after destruction of the input handles, it does not mention consistency of the data. Such consistency is backend dependent. To ensure data consistency, though, it is good practice for a backend to not modify the underlying data of an input handle.

This duplicated handle will be passed as input to the decrypt() method in the default pipeline.

The full listing for this method is:

457  hebench::APIBridge::Handle *p_local_data, std::uint64_t count)
458 {
459  if (count > 0)
460  {
462  std::memset(p_local_data, 0, sizeof(hebench::APIBridge::Handle) * count);
464 
466  p_local_data[0] = this->getEngine().duplicateHandle(h_remote_data,
467  InternalParamInfo::tagCiphertext); // validate that we are operating on the correct handle
469  }
470 }
ErrorCode store(Handle h_benchmark, Handle h_remote, Handle *h_local_packed_params, std::uint64_t local_count)
Retrieves the specified data from the backend.

6. decrypt

hebench::cpp::BaseBenchmark::decrypt receives result ciphertexts output from store() and decrypts them into plaintexts.


API Bridge Encode flow chart.

As before, we retrieve our internal representation from the input handle. This is coming from store() in the default pipeline.

372  const InternalParam<seal::Ciphertext> &encrypted_data =
373  this->getEngine().retrieveFromHandle<InternalParam<seal::Ciphertext>>(h_encrypted_data, InternalParamInfo::tagCiphertext);

Next, we use our original workload decryption.

379  std::vector<seal::Plaintext> encoded_data_samples = m_p_workload->decryptResult(encrypted_data.samples);

And we finish by wrapping the decrypted data in our internal representation and returning it inside an opaque handle through the API Bridge. This handle will be passed to method decode() in the default pipeline.

Full listing of this method follows.

370 {
372  const InternalParam<seal::Ciphertext> &encrypted_data =
373  this->getEngine().retrieveFromHandle<InternalParam<seal::Ciphertext>>(h_encrypted_data, InternalParamInfo::tagCiphertext);
375 
376  assert(encrypted_data.param_position == 0);
377 
379  std::vector<seal::Plaintext> encoded_data_samples = m_p_workload->decryptResult(encrypted_data.samples);
381 
383  InternalParam<seal::Plaintext> retval;
384  retval.samples = std::move(encoded_data_samples);
385  retval.param_position = encrypted_data.param_position;
386  retval.tag = InternalParamInfo::tagPlaintext;
387 
388  return this->getEngine().createHandle<decltype(retval)>(
389  sizeof(seal::Plaintext) * retval.samples.size(), // size (arbitrary for our usage if we need to)
390  retval.tag, // extra tags
391  std::move(retval)); // move to avoid copy
393 }
ErrorCode decrypt(Handle h_benchmark, Handle h_ciphertext, Handle *h_plaintext)
Decrypts a cipher text into corresponding plain text.

7 decode

hebench::cpp::BaseBenchmark::decode is responsible for receiving encoded result data and writing its decoded form back to the output buffer.


API Bridge Encode flow chart.

Here we decode the data from the operation result and arrange it into the format expected by Test Harness for validation. We touch upon some specification details regarding possible excess or insufficient data.

As usual, we retrieve our internal representation from the input handle. This handle comes from method decrypt() according to the default pipeline.

315  const InternalParam<seal::Plaintext> &encoded_data =
316  this->getEngine().retrieveFromHandle<InternalParam<seal::Plaintext>>(h_encoded_data, InternalParamInfo::tagPlaintext);

Having our internal representation, we call the original workload version to decode our result.

320  std::vector<std::vector<std::int64_t>> clear_result = m_p_workload->decodeResult(encoded_data.samples);

Finally, we rearrange the result clear text in the format expected by Test Harness, respecting the specifications.

The hebench::APIBridge::DataPackCollection* parameter points to pre-allocated memory into which the decoded results must be written. The exact size, format, data type, etc. is detailed in the workload description which for this example is Vector Element-wise Addition Workload .

We are returning the result, so, we find the data pack corresponding to this result component from the pre-allocated buffers. If we had more than one component, we would loop on the components and decode each. This method will throw an exception if the requested component is missing from the data packs passed by Test Harness into decode() (note that this should not happen in a default workload pipeline).

324  hebench::APIBridge::DataPack &native_datapack = this->findDataPack(*p_native, encoded_data.param_position);
325 
326  std::uint64_t min_sample_count = std::min(native_datapack.buffer_count, clear_result.size());
327  for (std::uint64_t sample_i = 0; sample_i < min_sample_count; ++sample_i)
328  {
329  // alias the samples
330  hebench::APIBridge::NativeDataBuffer &native_sample = native_datapack.p_buffers[sample_i];
331  // copy as much as possible
332  const std::vector<std::int64_t> &decoded = clear_result[sample_i];
333  std::uint64_t min_size = std::min(decoded.size(), native_sample.size / sizeof(std::int64_t));
334  std::copy_n(decoded.begin(), min_size,
335  reinterpret_cast<std::int64_t *>(native_sample.p));
336  }

According to specification, we must decode as much data as possible, where any excess encoded data that won't fit into the pre-allocated native buffers shall be ignored. If the buffers fit more data than we have, we only set as much as we have and do not touch the excess space.

Find the complete listing for this method next.

311 {
312  assert(p_native && p_native->p_data_packs && p_native->pack_count > 0);
313 
315  const InternalParam<seal::Plaintext> &encoded_data =
316  this->getEngine().retrieveFromHandle<InternalParam<seal::Plaintext>>(h_encoded_data, InternalParamInfo::tagPlaintext);
318 
320  std::vector<std::vector<std::int64_t>> clear_result = m_p_workload->decodeResult(encoded_data.samples);
322 
324  hebench::APIBridge::DataPack &native_datapack = this->findDataPack(*p_native, encoded_data.param_position);
325 
326  std::uint64_t min_sample_count = std::min(native_datapack.buffer_count, clear_result.size());
327  for (std::uint64_t sample_i = 0; sample_i < min_sample_count; ++sample_i)
328  {
329  // alias the samples
330  hebench::APIBridge::NativeDataBuffer &native_sample = native_datapack.p_buffers[sample_i];
331  // copy as much as possible
332  const std::vector<std::int64_t> &decoded = clear_result[sample_i];
333  std::uint64_t min_size = std::min(decoded.size(), native_sample.size / sizeof(std::int64_t));
334  std::copy_n(decoded.begin(), min_size,
335  reinterpret_cast<std::int64_t *>(native_sample.p));
336  }
338 }
ErrorCode decode(Handle h_benchmark, Handle h_plaintext, DataPackCollection *p_native)
Decodes plaintext data into the appropriate raw, native format.

At this point, the default pipeline is completed. Test Harness takes over, performs validation of the result, and, if result is correct, generates the benchmark reports.

Make sure to perform appropriate cleanup in the destructor of your classes. Test Harness will request destruction of resources when they are no longer needed.


Tutorial steps

Tutorial Home
Preparation
Engine Initialization and Benchmark Description
Benchmark Implementation
File References