Running Cicada Programs

Because a Cicada “program” is actually multiple copies of a single program running simultaneously (typically on multiple machines), bootstrapping the entire process can be tricky. It would quickly become annoying if you had to execute five separate command lines every time you wanted to run a program with five players, especially while coding and debugging!

To streamline the process, Cicada provides tools to assist with the following use-cases:

Learning and Development

Whether you’re new to Cicada or developing new algorithms and programs, we assume for this use-case that it’s acceptable to have all of your players running on the same machine; although this greatly reduces privacy, it dramatically reduces the complexity and overhead of running a Cicada program.

During this phase, we recommend that you create a top-level function that wraps your program, then use SocketCommunicator.run to execute that function using multiple players running on the local machine:

[1]:
# my-program.py
from cicada.communicator import SocketCommunicator

def main(communicator):
    # Your program here
    pass

SocketCommunicator.run(world_size=5, fn=main);

With this approach, your program can easily be run in a Jupyter notebook (as we do throughout this documentation), or executed from the command-line with a single command:

$ python my-program.py
... Your program output here

In addition to running your function in parallel, SocketCommunicator.run will gather its return values, returning them as a list in rank order. For example:

[2]:
def main(communicator):
    return f"Hello from player {communicator.rank}!"

results = SocketCommunicator.run(world_size=5, fn=main)
for rank, result in enumerate(results):
    print(f"Player {rank} result: {result!r}")
Player 0 result: 'Hello from player 0!'
Player 1 result: 'Hello from player 1!'
Player 2 result: 'Hello from player 2!'
Player 3 result: 'Hello from player 3!'
Player 4 result: 'Hello from player 4!'

Special return values are used to indicate players that fail, whether by raising an exception or dying unexpectedly:

[3]:
import os
import signal

def main(communicator):
    # Examples of normal return values.
    if communicator.rank in [0, 1, 2]:
        return f"Hello from player {communicator.rank}!"
    # Example of a failure that raises an exception.
    if communicator.rank == 3:
        raise RuntimeError("Ahhhh! YOU GOT ME!")
    # Example of a process that dies unexpectedly.
    if communicator.rank == 4:
        os.kill(os.getpid(), signal.SIGKILL)

results = SocketCommunicator.run(world_size=5, fn=main)
for rank, result in enumerate(results):
    print(f"Player {rank} result: {result!r}")
Comm world player 3 failed: RuntimeError('Ahhhh! YOU GOT ME!')
Comm world player 4 failed: Terminated(exitcode=-9)
Player 0 result: 'Hello from player 0!'
Player 1 result: 'Hello from player 1!'
Player 2 result: 'Hello from player 2!'
Player 3 result: Failed(exception=RuntimeError('Ahhhh! YOU GOT ME!'))
Player 4 result: Terminated(exitcode=-9)

As you can see, the Terminated class indicates players whose process exited unexpectedly, and stores the process exit code, while Failed indicates players that raised an exception, and includes a copy of the exception that you can use to better understand what went wrong. Iit also includes a traceback object, which can be used to identify which line in the code raised the exception:

[4]:
from cicada.communicator.socket import Failed

def main(communicator):
    # Examples of normal return values.
    if communicator.rank in [0, 1, 2]:
        return f"Hello from player {communicator.rank}!"
    # Example of a failure that raises an exception.
    if communicator.rank == 3:
        raise RuntimeError("Ahhhh! YOU GOT ME!")
    # Example of a process that dies unexpectedly.
    if communicator.rank == 4:
        os.kill(os.getpid(), signal.SIGKILL)

results = SocketCommunicator.run(world_size=5, fn=main)
for rank, result in enumerate(results):
    print(f"Player {rank} result: {result!r}")
    if isinstance(result, Failed):
        print(f"Player {rank}: {result.traceback}")
Comm world player 3 failed: RuntimeError('Ahhhh! YOU GOT ME!')
Comm world player 4 failed: Terminated(exitcode=-9)
Player 0 result: 'Hello from player 0!'
Player 1 result: 'Hello from player 1!'
Player 2 result: 'Hello from player 2!'
Player 3 result: Failed(exception=RuntimeError('Ahhhh! YOU GOT ME!'))
Player 3: Traceback (most recent call last):
  File "/Users/tshead/src/cicada-mpc/cicada/communicator/socket/__init__.py", line 786, in launch
    result = fn(communicator, *args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/var/folders/tl/h2xygzzn1154jzjn_n01x860001l4n/T/ipykernel_7011/308613329.py", line 9, in main
    raise RuntimeError("Ahhhh! YOU GOT ME!")
RuntimeError: Ahhhh! YOU GOT ME!

Player 4 result: Terminated(exitcode=-9)

Preparing for Deployment

Once you’ve worked-out the bugs in your new program, it’s time to start thinking about deployment. To truly protect the privacy of your program’s players, they will each need to execute your code on a separate machine that they control. This means that you’ll have to replace SocketCommunicator.run with an alternative. A good option is the SocketCommunicator.connect function, which works with the cicada command to create a communicator that you can pass to your main function yourself:

# my-program.py
from cicada.communicator import SocketCommunicator

def main(communicator):
    # Your program here
    pass

with SocketCommunicator.connect() as communicator:
    main(communicator)

Now, when you run your program with the cicada command, it will setup environment variables that SocketCommunicator.connect reads at runtime. To get started, try using cicada run to execute your program on the local machine:

$ cicada run --world-size 5 my-program.py
... Your program output here

Although this is still running all processes locally, it’s going through all of the steps of setting up the environment and running the separate processes, so it’s a good sanity check that nothing broke when you changed your startup code.

Tip

Advanced users may wish to bypass the cicada command by setting the CICADA_WORLD_SIZE, CICADA_RANK, CICADA_ADDRESS, and CICADA_ROOT_ADDRESS environment variables themselves. This can be handy if you’re running the same Cicada program repeatedly on hardware that doesn’t change.

Interactive Programs

In addition to simulating what it’s like to start a full-fledged program running on multiple machines, cicada can help if you’re running an interactive program, i.e. any program that will prompt users for command-line input. Because stdin can’t be shared between multiple processes, the only way for each process to get user input is to execute them in separate shells. If you’re working on a system with X11 installed, cicada can run each process in a separate xterm:

$ cicada run --world-size 5 --frontend xterm my-program.py

… when you do this, cicada creates as many new xterms as players, so that each can prompt for input and generate output without stepping on the others. In fact, you may find that you prefer working this way even if your program isn’t interactive, since the outputs from each player are neatly separated into their own windows.

One thing to keep in mind is that when your program ends, the xterms will immediately close, which may be a problem if you need to check your program’s output. To prevent this from happening, run your program with the –inspect flag, which will drop into an interactive Python interpreter when your program ends, allowing you to view the program output at your leisure, and even inspect the program’s internal state:

$ cicada run --world-size 5 --frontend xterm --inspect my-program.py

If you don’t have X11 support, or you don’t like the clutter of multiple xterms, cicada can use tmux to run each player in a separate tmux window pane:

$ cicada run --world-size 5 --frontend tmux --inspect my-program.py

Deployment

OK! You’ve run all the tests, and you’re finally ready to do this thing for real. Now you are going to have to run each player separately, on separate machines. To do this, you’ll use the cicada start command instead of cicada run. Before you begin, you’ll need to keep two things in mind:

  • Every player must have a public IP address that is visible to the other players.

  • Every player must know the public IP address and port number that the root (rank 0) player is using.

Assuming the above is true, you can use cicada start command to start each player individually. For example, assuming that 192.168.0.4 is a public IP address of player 0:

Important

Run each of the following commands in a shell on a different machine!

alice@host1 $ cicada start --world-size 5 --rank 0 --address tcp://192.168.0.4:25252 my-program.py
  bob@host2 $ cicada start --world-size 5 --rank 1 --root-address tcp://192.168.0.4:25252 my-program.py
carol@host3 $ cicada start --world-size 5 --rank 2 --root-address tcp://192.168.0.4:25252 my-program.py
  dan@host4 $ cicada start --world-size 5 --rank 3 --root-address tcp://192.168.0.4:25252 my-program.py
 erin@host5 $ cicada start --world-size 5 --rank 4 --root-address tcp://192.168.0.4:25252 my-program.py

Here, player 0 uses –address to explicitly specify a public address and port number, and the remaining players specify the same information with –root-address.

Tip

For practice, you can use cicada start to run processes on the local machine too.