Signed Bytes All chapters

Chapter 06  the floor

Everyone quotes the five milliseconds. Here is what is underneath them.

Primus says its FHE stack bootstraps in about five milliseconds and runs a confidential transfer in about ninety, on GPUs. Those numbers live in code that is not public. What is public is the layer beneath it: primus-fhe, the Rust library that does the number theoretic transforms, the residue arithmetic and the noise sampling. It ships benchmarks. This chapter runs them, on three machines it names, and shows you how to run them yourself.

6.1

Claim

  • ~5 ms bootstrap
  • ~90 ms confidential transfer
  • on GPU, in code that is not public

source Primus Labs, post of 27 August 2026

6.2

Mechanism

  • bootstrapping is mostly polynomial products
  • the NTT makes them cheap
  • so one transform is the honest unit

crate primus_ntt, "Number-theoretic transform (NTT) for homomorphic encryption"

6.3

Boundary

  • the repo is the arithmetic floor
  • no scheme, no bootstrap, no GPU
  • so the claim cannot be verified here

repo primus-labs/primus-fhe · 15 crates · commit 32b316b6 · 2026-07-08 · MIT or Apache 2.0

6.4

Check

  • cargo bench, unmodified
  • collect.py writes one JSON
  • paste yours, compare machines

bench crates/primus_ntt/benches/bench_u64.rs · n = 4096 · q of 30 and 50 bits, both checked: (q − 1) mod 2n = 0
run GitHub Actions run 40074135091, three jobs, unmodified

What the library is

primus-labs/primus-fhe · MIT or Apache 2.0

Fifteen crates, each described in its own manifest. This is the arithmetic every FHE scheme sits on, not a scheme. There is no bootstrapping here, no GPU code, nothing called AutoHoG. That is not a gap in the repository. It is a statement of what the repository is for, and the reason this chapter can only measure the floor.

Why the NTT

Lattice FHE works on polynomials, and multiplying two polynomials of degree 4096 the naive way costs about seventeen million coefficient products. The number theoretic transform turns that into a pointwise product: transform both, multiply coefficient by coefficient, transform back. It is the Fourier transform over a finite field, and it is why FHE is possible at practical speed at all.

Bootstrapping, the operation that refreshes a ciphertext before its noise grows too large, is mostly a long sequence of polynomial products. So the cost of one transform at the working length is the most honest single number you can measure about an FHE stack without seeing the scheme on top of it. Everything else is built out of it.

The benches use n = 4096 and three moduli: 28, 30 and 50 bits. The two u64 moduli are both valid, which this page checked: for an NTT of length n you need the modulus minus one to be divisible by 2n, and both are. The bench file also measures the same transform written two ways, a table specialised for u64 and a generic one, which makes for a comparison the authors evidently wanted to see themselves.

checked 1073692673 − 1 = 1073692672 = 8192 × 131066 · 1125899906826241 − 1 = 8192 × 137438953470

The measurements

criterion, collected by collect.py

Two comparisons the bench file sets up

The budget

measured floor against the published ceiling

Five milliseconds is a claim about a system you cannot run. One NTT is a measurement of a library you can. Dividing one by the other does not verify anything. It tells you how much room the claim leaves, in units of the operation that dominates it.

Run it yourself

ten minutes, any machine with Rust

The official numbers came from a public GitHub Actions workflow: three jobs, one after another, unmodified source, every log kept. You can read the run itself, fork it and trigger your own, or run the same commands on any x86_64 machine, then paste the JSON here. Nothing leaves your browser.

git clone https://github.com/primus-labs/primus-fhe.git
cd primus-fhe
cargo bench -p primus_ntt --bench bench_u64
python3 collect.py . primus-fhe-bench.json
your run

It does not build on Apple Silicon

found while trying

The first attempt to run these benches was on an Apple M4. At commit 32b316b6 the workspace does not build there: 47 errors, all in primus_ntt. Following them one step at a time, and stopping at each first error rather than patching ahead, turned out to be the most informative part of the chapter.

One line is not enough. In prime64/mod.rs the avx2 module is gated on the target architecture and avx512 right below it is not, so its AVX-512 kernels get compiled on ARM. Gating it exposes the real dependency: the scalar fast path, whose fields are documented as always available, builds its Barrett preconditioners with build_barrett_vector, and that function lives inside the AVX-512 module.

Moving that one function fixes the library. With build_barrett_vector in an always compiled module and avx512 gated, primus_ntt compiles on ARM with a single warning. What still fails is one test, test_builder_lane_order, which calls two layout builders from the gated prime32 modules. Its own comment says it needs no SIMD hardware.

That is the pattern, and it is the finding. Every precompute.rs inside an AVX folder contains no intrinsics at all. They are plain layout and arithmetic builders that happen to live in modules reserved for x86. At this commit, all four jobs in the project's CI run on ubuntu-latest, which is x86_64, so nothing in CI builds it anywhere else. Whether anyone built it on ARM by hand is not something a repository can tell you, and this page does not claim to know.

With both changes, ARM is green. On an Apple M4 the workspace builds, its tests pass, 309 passed and none failed, and it clears cargo clippy --workspace -- -D warnings, the same command the project's CI runs. Five files, 24 lines added, 19 removed, here, reported as primus-fhe #191. On x86 the project's own CI behaves exactly the same with and without the change, for a reason the next section explains. Every number on this page comes from x86_64, and every run says so.

at 32b316b6, on aarch64

unmodified                          47 errors in primus_ntt
gate mod avx512                      2 errors   table.rs:365, :370
move build_barrett_vector out       library builds, 1 warning
                                     4 errors   one test, prime32/tests.rs
gate that test, allow the warning   build, test, clippy -D warnings: pass
                                     309 passed, 0 failed

SIMD intrinsics in each precompute.rs under an AVX folder
  prime64/avx512/precompute.rs      0
  prime32/avx2/precompute.rs        0
  prime32/avx512/precompute.rs      0

jobs in .github/workflows/ci.yml    4, all on ubuntu-latest (x86_64)

n = 32 on AVX-512

found while checking the fix

To show the ARM change was harmless on x86, the project's own CI was run on it. One test crashed with a segmentation fault. So the same CI was run on the untouched upstream commit, five times each way, with the processor recorded in every job and the failing test repeated two hundred times. The patch made no difference. The processor made all of it.

CPUAVX-512jobscrashes per 200
AMD EPYC 7763no50
Intel Xeon Platinum 8573Cyes3200
AMD EPYC 9V45yes1200
AMD EPYC 9V74yes1200

Not intermittent, not noise: every tested CPU with AVX-512 crashed every time, the one without it never did, on the base commit and on the patched one alike. Three AVX-512 models were tested. That any other CPU with AVX-512F would crash the same way is an inference, not a measurement, though a strong one: the code chooses this path from that one flag. AddressSanitizer put the bad read at prime32/avx512/transform.rs:88, a load from an address just above zero, where no allocation ever lives.

The cause is two functions that disagree about where AVX-512 begins. The dispatcher in prime32/table.rs says SIMD paths start at n ≥ 32. The builder of the AVX-512 root tables in prime32/avx512/precompute.rs says n < 64 falls back to scalar, and returns an empty table. At exactly n = 32, the transform is sent to the AVX-512 kernel with roots that were never built, and the first load through them faults. The AVX2 builder uses n < 32, matching the dispatcher, which is why machines without AVX-512 are fine.

Its reach is narrow and worth stating precisely: a 32 point table over 32 bit words, on a CPU with AVX-512. It is a crash, not a silent wrong answer, and it does not touch a single number on this page, which come from the u64 table at n = 4096. It also explains why it lasted. The benches never use n = 32, and in the ten CI jobs run here, half landed on a processor without AVX-512 and passed.

Both findings were reported to the maintainers before this page was published, this one first: primus-fhe #190 for the crash and #191 for the ARM build, which links to it.

prime32/table.rs              if self.n >= 32 { … Avx512 => avx512_forward_transform … }
prime32/avx512/precompute.rs  if n < 64 { return AVec::with_capacity(64, 0); }
prime32/avx2/precompute.rs    if n < 32 { … }

n = 32   dispatched to AVX-512   roots empty   load at transform.rs:88 faults

run GitHub Actions 40093027843 · base 32b316b6 and patch 32702f97, five jobs each · ASan on base, AMD EPYC 9V45
reported primus-fhe #190 and #191, 24 October 2026

What this does and does not say

It does not say the five millisecond figure is wrong. That figure is for a full bootstrap on a GPU, with parameters this repository does not contain, in code that is not public. Nothing here can confirm it or contradict it, and the page is built so that it never pretends to.

What it does say is narrower and checkable. Here is the cost of the primitive that dominates bootstrapping, on a named CPU, with a confidence interval, at a commit you can check out. Here is how much of that primitive fits inside the published figure. And here is a way for anyone to produce the same measurement on their own machine and see whether it agrees.

That is the only kind of performance claim worth repeating: one that comes with the command that reproduces it.