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.

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):
uv venv --python 3.12
source .venv/bin/activateInstall the Belfort packages, along with the Hugging Face and image libraries the example uses to download the model and the test images:
uv pip install --torch-backend=cpu belfort-ml belfort-ml-client huggingface_hub safetensors pyarrow pillowWithout 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:
pip install --extra-index-url https://download.pytorch.org/whl/cpu belfort-ml belfort-ml-client huggingface_hub safetensors pyarrow pillow2. 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:
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!
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:
"""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:
"""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:
uv run provider.py compilebelfort_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#
uv run provider.py serveThis 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:
"""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:
uv run client.pyclient.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:
- Do step 1 to install the packages.
- Save
mnist.pyfrom step 3 andclient.pybelow in the directory that holds.venv.- Copy the
export BELFORT_CLIENT_TOKEN=...line the provider printed, run it in that machine's terminal, then runuv 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:
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#
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#
- Compilation pipeline: which models compile, and what each operation costs
- The Belfort ML client: the end client from Python, and the input format for models with several arguments
- Deploys and credits: how deploys are billed
- Examples: more example models