-
Notifications
You must be signed in to change notification settings - Fork 5
Writing a Program
This document contains a tutorial walking you through writing programs in MAGE.
We'll start by writing a program that uses the HalfGates (garbled circuit) protocol to solve Yao's Millionaires' problem. We'll use the existing DSL for the HalfGates protocol to write the program.
A single build of MAGE has a set of built-in programs. For example, if you run ./planner
, you'll see a list of programs, each followed by a short description, printed out. The easiest way to write a new program for MAGE is to build your program into MAGE, adding it to the list of programs.
The source code for programs goes in the programs/
directory. Create the file millionaire.cpp
in this directory, and type (or copy and paste) the following source code:
#include "dsl/integer.hpp"
#include "dsl/util.hpp"
#include "programs/registry.hpp"
#include "programs/util.hpp"
using namespace mage::dsl;
namespace mage::programs::millionaire {
void create_millionaire_circuit(const ProgramOptions& args) {
Integer<32> alice_wealth;
alice_wealth.mark_input(Party::Garbler);
Integer<32> bob_wealth;
bob_wealth.mark_input(Party::Evaluator);
Bit result = (alice_wealth >= bob_wealth);
result.mark_output();
}
RegisterProgram millionaire("millionaire", "Solve Yao's Millionaire's problem", create_millionaire_circuit);
}
Notice that this is the example in Figure 5 of the OSDI 2021 paper.
The program begins the prerequisite #include
statements and a using namespace
statement so that we can simply write Integer
instead of mage::dsl::Integer
. As a convention, each program is written in its own namespace in mage::programs
to avoid symbol conflicts; in this case, our chosen namespace is mage::programs::millionaire
.
The create_millionaire_circuit
function contains the DSL code to solve Yao's Millionaires' problem. Let's discuss how it works:
-
The
Integer
type represents a two's complement integer. The template argument is the length of the integer, in bits. Thus, theInteger<32>
type represents a 32-bit integer. The integer itself could be signed or unsigned; the type does not specify the interpretation of those bits. -
An
Integer
, regardless of its length in bits, contains only a single MAGE-virtual pointer (a single MAGE-virtual address) during the planning phase. (It is for reasons such as this that we made the length a template parameter.) Thus, while having manyInteger
objects in a program may take up a lot of memory during runtime, the memory overhead is significantly less while planning. -
When an
Integer
object is created, it is initially in the so-called invalid state. In the invalid state, it has no runtime memory footprint. When it is initialized with a value, it enters the valid state and it begins to occupy runtime memory. -
An
Integer
may be initialized by assigning it a value. This can be done via an assignment operation, or by calling a function likemark_input()
. Themark_input()
function reads one party's input (perhaps reading the result of oblivious transfer) and assigns theInteger
to the value of the input. -
The
Bit
type is simply an alias forInteger<1>
. -
The
>=
operator compares two integers of the same width as unsigned integers, resulting in a single bit. -
The
mark_output()
function writes the value of theInteger
it is called on to the program's output.
Finally, we declare a variable of type RegisterProgram
. Declaring this variable adds the program to the list of programs that the planner
executable knows about. The planner
executable, as a result of this variable, knows that the millionaire
problem, when specified on the command line, corresponds to the create_millionaire_circuit
function. For example, if you run ./planner
(with no command-line arguments), you will see millionaire - Solve Yao's Millionaires' problem
as one line of the output. Note that the problems are listed in alphabetical order.
The example_input
program doesn't know about the millionaire
problem, so you'll have to generate input files ourself. We used Integer<32>
variables in the program as each party's input, so each party's input should consist of four bytes encoding each party's wealth in little-endian two's complement form.
For example, if Alice's wealth is $1,080,000 and Bob's wealth is $1,008,000, then we should create files with the following contents (see the following examples based on the output):
$ hexdump -C millionaire_0_0_garbler.input
00000000 c0 7a 10 00 |.z..|
00000004
$ hexdump -C millionaire_0_0_evaluator.input
00000000 80 61 0f 00 |.a..|
00000004
You can use a specialized text editor, such as ghex
or hexedit
, to compose these files.
Once you have prepared the input files, you can run the planner by running ./planner millionaire halfgates ../config.yaml 0 0 0
and then execute the program by running ./mage halfgates ../config.yaml 0 0 millionaire_0
and ./mage halfgates ../config.yaml 1 0 millionaire_0
. You can use the same config.yaml
file as you used in the "Running a Program" tutorial.
In the above commands, we set the "problem size" to 0
. In reality, any problem size would work, since the DSL code never uses the problem size (args.problem_size
). If you use a problem size other than 0
, you'll have to rename the input files to millionaire_<size>_0_garbler.input
and millionaire_<size>_0_evaluator.input
.
The above example, based on Yao's Millionaires' problem is demonstrative but simple. Now, let's move on to a more complex example: computing the dot-product of two vectors. In this case, the problem size is the dimension of the vectors.
We'll write multiple different versions of this program, in order to demonstrate different features of the DSL.
Here is the source code for the program (first cut):
#include <cstdint>
#include <vector>
#include "dsl/integer.hpp"
#include "programs/registry.hpp"
#include "programs/util.hpp"
using namespace mage::dsl;
namespace mage::programs::dot_product {
void create_dot_product_circuit(const ProgramOptions& args) {
std::uint64_t n = args.problem_size;
std::vector<Integer<8>> garbler_vector(n);
std::vector<Integer<8>> evaluator_vector(n);
for (std::uint64_t i = 0; i != n; i++) {
garbler_vector[i].mark_input(Party::Garbler);
evaluator_vector[i].mark_input(Party::Evaluator);
}
Integer<16> result(0);
for (std::uint64_t i = 0; i != n; i++) {
result = result + (garbler_vector[i] * evaluator_vector[i]);
}
result.mark_output();
}
RegisterProgram dot_product("dot_product", "Compute the dot product of two vectors", create_dot_product_circuit);
}
This program is written in the simplest possible way. However, note that:
- Because the DSL is internal to C++, you can use C++ STL data structures like
std::vector
. - For addition, the sum of two integers has the same width as those two integers; the integers wrap and the carry bit is discarded. But for multiplication, the product of two integers has double the width. So, although
garbler_vector[i]
andevaluator_vector[i]
each have typeInteger<8>
, the expressiongarbler_vector[i] * evaluator_vector[i]
has typeInteger<16>
. - In cases where the carry bit needs to be preserved even for addition, the
add_with_carry
method is provided. For example, ifa
andb
have typeInteger<8>
, one can doInteger<9> c = a.add_with_carry(b)
.
If you try to run the program, the problem_size
argument will determine the number of elements in each vector. So instead of choosing 0
, like we did for Yao's Millionaires' problem, choose a value and make sure the input files are the appropriate length.
A potential issue with the program above is that the result
variable may overflow. To fix this problem, we can use a larger integer width for result
, and convert (garbler_vector[i] * evaluator_vector[i])
to the same (larger) integer width before adding it to result
.
We can replace the create_dot_product_circuit
function with the following:
void create_dot_product_circuit(const ProgramOptions& args) {
std::uint64_t n = args.problem_size;
std::vector<Integer<8>> garbler_vector(n);
std::vector<Integer<8>> evaluator_vector(n);
for (std::uint64_t i = 0; i != n; i++) {
garbler_vector[i].mark_input(Party::Garbler);
evaluator_vector[i].mark_input(Party::Evaluator);
}
Integer<64> result(0);
for (std::uint64_t i = 0; i != n; i++) {
Integer<64> product;
product.mutate(garbler_vector[i] * evaluator_vector[i]);
result = result + product;
}
result.mark_output();
}
The mutate
function stores the same value of garbler_vector[i] * evaluator_vector[i]
into the integer it is invoked on, converting the value to an Integer<64>
. With this version of the function, we will get 64 bits of output, and the result won't overflow unless the input is very large.
The above solution is inefficient. It performs a 64-bit addition for each element, four times the work as a 16-bit addition. We can optimize this further by adding the elements in a tree, using 16-bit addition for the bottom layer, 17-bit addition for the next layer, and so on.
Fortunately, there is a library function to help with this, as we demonstrate below:
void create_dot_product_circuit(const ProgramOptions& args) {
std::uint64_t n = args.problem_size;
std::vector<Integer<16>> products(n);
for (std::uint64_t i = 0; i != n; i++) {
Integer<8> garbler_elem;
garbler_elem.mark_input(Party::Garbler);
Integer<8> evaluator_elem;
evaluator_elem.mark_input(Party::Evaluator);
products[i] = garbler_elem * evaluator_elem;
}
Integer<64> result = reduce<64>(products.data(), products.size());
result.mark_output();
}
Here, reduce
is a library function provided to efficiently aggregate Integer
s in MAGE's DSL. This program has also made an additional optimization; rather than materializing the garbler's and evaluator's inputs in separate arrays, it computes their products as it reads their input.
It's worth mentioning that one could use the tree-based strategy to aggregate the products efficiently without materializing all of the products in memory at once. While this would likely be an improvement, it's arguably not necessary, given that MAGE will swap to storage very efficiently if the physical memory is exhausted.