This page as Markdown · All pages (llms.txt)

Quickstart

This page runs a pretrained PyTorch MNIST classifier on encrypted images on a Belfort GPU. You play both roles: the model provider, who compiles and deploys the model, and the end client, who encrypts images and decrypts the answers.

1. The model provider sends the model to the Belfort Gateway. 2. The gateway compiles it into client code and an encrypted model. 3. The encrypted model is deployed to the Belfort Runtime. 4. The model provider gives the end client a client token. 5. The end client generates keys. 6. The end client sends the public key to the runtime. 7. The end client runs encrypted inference against the runtime.

What you need#

  • macOS 14 or newer on Apple Silicon, or Linux (x86-64 or ARM) on a recent distribution
  • Python 3.12 or newer
  • A C++20 compiler

1. Install#

Create a virtual environment with Python 3.12 or newer (e.g., see uv's Python guide):

Terminal
uv venv --python 3.12
source .venv/bin/activate

Install the Belfort packages, along with the Hugging Face and image libraries the example uses to download the model and the test images:

Terminal
uv pip install --torch-backend=cpu belfort-ml belfort-ml-client huggingface_hub safetensors pyarrow pillow

Without uv, create the environment with python3.12 -m venv .venv, install with pip from PyTorch's CPU index, and run the scripts below with python in place of uv run:

Terminal
pip install --extra-index-url https://download.pytorch.org/whl/cpu belfort-ml belfort-ml-client huggingface_hub safetensors pyarrow pillow

2. Get an API key#

Sign in to the Belfort console, open API keys, and create one. The key (blf_live_...) is shown once. Set it in the shell you will run provider.py from:

Provider terminal
export BELFORT_API_KEY=blf_live_...

3. The model and the data#

Compilation limits

Take into consideration the following limits when writing your own model:

  • One encrypted input per model
  • No grouped convolutions, except depthwise (groups == in_channels == out_channels)
  • No MaxPool: use AvgPool
  • No softmax: apply it to the logits after decryption
  • Shallow networks: every layer adds compile time and bootstraps

We're continuously working on addressing these limitations!

More information on what compiles.

mnist.py downloads a pretrained MNIST classifier and the MNIST test images from Hugging Face; no account is needed. Save it in the directory that holds .venv:

mnist.py
"""The model and the test images, both downloaded from Hugging Face."""

import io

import numpy as np
import pyarrow.parquet as pq
import torch
from huggingface_hub import hf_hub_download
from PIL import Image
from safetensors.torch import load_file
from torch import nn


class MLP(nn.Module):
    """dacorvo/mnist-mlp: a 28x28 image, flattened, to ten digit scores."""

    def __init__(self) -> None:
        super().__init__()
        self.input_layer = nn.Linear(784, 256)
        self.mid_layer = nn.Linear(256, 256)
        self.output_layer = nn.Linear(256, 10)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = torch.relu(self.input_layer(x))
        x = torch.relu(self.mid_layer(x))
        return self.output_layer(x)


def load_model() -> MLP:
    weights = hf_hub_download(
        "dacorvo/mnist-mlp",
        "model.safetensors",
        revision="7ed1e96d182395fd7380ea8495b9eb2364ce9dc1",
    )
    net = MLP()
    net.load_state_dict(load_file(weights))
    return net.eval()


def test_images() -> tuple[torch.Tensor, list[int]]:
    """The 10,000 MNIST test images, normalized and flattened as the model expects."""
    path = hf_hub_download(
        "ylecun/mnist",
        "mnist/test-00000-of-00001.parquet",
        repo_type="dataset",
        revision="77f3279092a1c1579b2250db8eafed0ad422088c",
    )
    table = pq.read_table(path)
    pngs = table["image"].to_pylist()
    pixels = np.stack([np.array(Image.open(io.BytesIO(png["bytes"]))) for png in pngs])
    pixels = pixels.astype(np.float32) / 255
    images = torch.from_numpy((pixels - 0.1307) / 0.3081).flatten(1)
    return images, table["label"].to_pylist()

4. Compile#

Save provider.py next to it:

provider.py
"""The model provider: compile the model, then serve it to one end client.

    uv run provider.py compile   # compile the model on Belfort
    uv run provider.py serve     # mint a client token and start a GPU deploy
    uv run provider.py serve --dev   # testing only: the gateway chooses the seed

Both are safe to run again: a model already compiled from the same MLIR is
reused, not compiled again.
"""

import hashlib
import sys
from datetime import timedelta
from pathlib import Path

import belfort_ml
from mnist import load_model, test_images

NAME = "quickstart_mnist"
# client.py on this machine reads the token from here; it exists while serve
# runs.
TOKEN_FILE = Path(".client-token")


def compiled_model(provider: belfort_ml.Provider) -> belfort_ml.Model:
    images, _ = test_images()
    # The last 512 test images; client.py sends the first ones.
    calibration = list(images[-512:].split(1))
    artifacts = belfort_ml.compile(
        load_model(), calibration[0], NAME, "./build", calibration_inputs=calibration
    )
    return provider.models.get(
        name=NAME, create_from=artifacts, timeout=1800, use_composite_relu=False
    )


def hand_over(token: str, seed: str | None = None) -> None:
    """Write the token, and a developer-mode seed where client.py looks for it."""
    TOKEN_FILE.touch(mode=0o600)
    TOKEN_FILE.write_text(token)
    export = f"  export BELFORT_CLIENT_TOKEN={token}\n"
    if seed is not None:
        digest = hashlib.sha256(token.encode()).hexdigest()[:12]
        seed_file = Path(f".secret-seed-{digest}")
        seed_file.touch(mode=0o600)
        seed_file.write_text(seed)
        export += f"  export BELFORT_CLIENT_SECRET_SEED={seed}\n"
    print(
        f"\nStart client.py now. On this machine it reads the token from"
        f" {TOKEN_FILE}.\nOn another machine, run this before client.py:\n{export}",
        flush=True,
    )


def serve(model: belfort_ml.Model, dev: bool = False) -> None:
    TOKEN_FILE.unlink(missing_ok=True)  # a killed earlier run may have left one
    try:
        token = None if dev else model.create_client(label="quickstart")
        if token is not None:
            hand_over(token.token)
            # The GPU bills from here, so start it only once the client's keys
            # are in.
            model.wait_for_keys(token)
            print("Keys uploaded. Starting the deploy; Ctrl-C stops it.", flush=True)
        with model.deploy(
            key_set=token,
            max_lifetime=timedelta(minutes=30),
            timeout=1800,
            dev_mode=dev,
        ) as deploy:
            if dev:
                # The gateway supplies the developer seed and a client token.
                assert deploy.client_token is not None
                hand_over(deploy.client_token, deploy.secret_seed)
            input("Deploy ready. Press Enter to stop it...")
    finally:
        TOKEN_FILE.unlink(missing_ok=True)


if __name__ == "__main__":
    command = sys.argv[1] if len(sys.argv) > 1 else ""
    dev = sys.argv[2:] == ["--dev"]
    if command not in ("compile", "serve") or (sys.argv[2:] and not dev):
        sys.exit(__doc__)
    with belfort_ml.Provider() as provider:  # reads BELFORT_API_KEY
        model = compiled_model(provider)
        if command == "compile":
            print(f"{NAME} is compiled ({model.id})")
        else:
            serve(model, dev)

Compile the model:

Provider terminal
uv run provider.py compile

belfort_ml.compile traces the model on your machine and runs the example images through it to calibrate the value ranges. The calibration images are test images, so they do not include the ones the end client sends. It writes build/quickstart_mnist_torch.mlir.

provider.models.get(name=NAME, create_from=artifacts) returns the named mode, or uploads that file to the Belfort gateway and waits while Belfort generates the GPU code and the end client's code. Belfort receives the MLIR file, not your Python code. Running the command again reuses the compiled model as long as the MLIR is unchanged.

5. Serve#

Provider terminal
uv run provider.py serve

This compiles the model first if needed, mints a client token, writes it to .client-token in this directory, and prints it as export BELFORT_CLIENT_TOKEN=.... In production, you send the token to the end client yourself. The script then waits with the GPU deploy, until the end client has uploaded its evaluation keys. Only then does it start a GPU deploy that serves that token's keys, and it waits for Enter. The file is removed when the script exits. The deploy runs at most 30 minutes, and you are charged for the time it ran (rounded up to 10 minutes). Stop the model by pressing enter when you finished your inferences.

6. Run the end client#

Save client.py in the same directory:

client.py
"""The end client: encrypt MNIST test images, have the deploy classify them,
and decrypt the scores.

    uv run client.py [COUNT]

The secret key derives from a seed this script keeps in a local file per
token, so running it again on the same token reuses the key pair.
"""

import hashlib
import os
import sys
from pathlib import Path

from belfort_ml_client import Client
from mnist import test_images

token_file = Path(".client-token")
if token_file.exists():
    token = token_file.read_text().strip()
elif not (token := os.environ.get("BELFORT_CLIENT_TOKEN")):
    sys.exit("Run `uv run provider.py serve` here first, or set BELFORT_CLIENT_TOKEN.")

seed_file = Path(f".secret-seed-{hashlib.sha256(token.encode()).hexdigest()[:12]}")
seed = bytes.fromhex(seed_file.read_text()) if seed_file.exists() else None
images, labels = test_images()
count = int(sys.argv[1]) if len(sys.argv) > 1 else 10

with Client(token, secret_seed=seed) as client:
    client.prepare()
    if seed is None:
        seed_file.touch(mode=0o600)
        seed_file.write_text(client.secret_seed.hex())
    correct = 0
    for image, label in zip(images[:count], labels[:count]):
        answer = client.infer([image.tolist()])
        scores = answer["values"]
        predicted = scores.index(max(scores))
        correct += predicted == label
        print(f"digit {label}: predicted {predicted} in {answer['latency_ms']} ms")
    print(f"{correct} of {count} correct")

Run the client in a second terminal, in the same directory:

Client terminal
uv run client.py

client.py reads the token from .client-token, even if BELFORT_CLIENT_TOKEN is set. Without the file, it takes BELFORT_CLIENT_TOKEN.

Running the client on another machine

The end client normally runs on a different machine than the provider, and needs no API key there. On that machine:

  1. Do step 1 to install the packages.
  2. Save mnist.py from step 3 and client.py below in the directory that holds .venv.
  3. Copy the export BELFORT_CLIENT_TOKEN=... line the provider printed, run it in that machine's terminal, then run uv run client.py.

Keep the provider running until the client finishes: it starts the deploy once the client's keys are uploaded, then waits at Press Enter.

The client builds the encryption code generated for your model, generates a key pair, and uploads the evaluation keys. This takes about 1.5 minutes on the first run, and happens before the deploy starts; the client then waits for the deploy. Then it encrypts each test image, sends it, decrypts the ten scores, and prints the predicted digit with latency_ms, the time the gateway's call to the GPU took:

Output
digit 7: predicted 7 in 287 ms
digit 2: predicted 2 in 189 ms
...

uv run client.py 20 classifies 20 images instead of 10 and ends with a line such as 18 of 20 correct. Running it again on the same token reuses the key pair and takes about 20 seconds. The secret key exists only on the end client's machine, so neither you nor Belfort sees the images or the scores.

Press Enter in the first terminal to stop the deploy.

Testing with developer mode#

Provider terminal
uv run provider.py serve --dev

--dev skips the key upload: the gateway chooses the secret seed and the GPU generates the evaluation keys itself. Once the deploy is ready, the script writes the token to .client-token and the seed to the .secret-seed-... file client.py reads, and prints both as export lines for another machine. Then run uv run client.py as above; it only builds the client code and starts inferring.

Testing only

In developer mode Belfort knows the secret seed, so it can decrypt every input and output of the deploy. Never send real data to a developer-mode deploy. See Developer mode.

Next#