# Tune the workload you have.

> Understand the cost drivers, select candidates, and measure the complete circuit.

- Canonical page: https://www.poulpy.dev/docs/performance/
- Poulpy version: 0.9.0
- Source revision: [`7dd13e6aa9808982f5355fb6acb70ca434de4e35`](https://github.com/poulpy-fhe/poulpy/tree/7dd13e6aa9808982f5355fb6acb70ca434de4e35)
- Documentation reviewed: 2026-10-08
- Audience: For engineers with a working computation and parameter set

## The short version

Start with a correct portable implementation. Select accelerated candidates for your target hardware, then compare them at the same input range, output accuracy, and security assumptions. The [benchmark explorer](https://www.poulpy.dev/benchmarks/) reports 16-level CKKS bootstrapping measurements on an AMD Ryzen 9 9950X (x86 backends) and an Apple M5 Pro (NEON), with fixed presets and precision checks. Each run names its hardware. These results were measured with Poulpy 0.9.0 and describe that workload and hardware.

The guidance below describes mechanisms and starting points. Record actual measurements using [Measure a workload](https://www.poulpy.dev/docs/measurement/).

Poulpy's backend APIs are hardware-independent, and a GPU backend is in active development. The build commands and Rayon diagnostics on this page exercise CPU implementations. For device execution, also account for data transfers, synchronization, batching, and device memory in the measurement boundary.

## Key material and arithmetic

Key switching and external products read prepared evaluation-key material. Large keys can make memory traffic a substantial part of the cost. Measure prepared-key bytes as well as serialized size: they are different representations.

Coefficient-domain operations process polynomial limbs. At a fixed ciphertext width, larger `base2k` means fewer limbs, but the backend also changes the cost and storage of each transformed limb. Transform count alone does not determine runtime.

## 1. Arithmetic family

`FFT64` uses a floating-point transform, while `NTT4x30` and `NTT3x42` use integer residue transforms. The integer families permit wider limbs in their supported configurations. An accelerated NTT backend may pack its residues differently from the portable implementation.

For small gate-level workloads, include FFT64 in the comparison. For leveled numerical work, include NTT4x30, and NTT3x42 if IFMA is available. Compare key preparation and the full circuit, not only an isolated multiplication. See [Backends](https://www.poulpy.dev/docs/backends/) for types and supported hardware.

## 2. Backend

Compare the portable baseline with an accelerated implementation of the same arithmetic family first. Then compare arithmetic families. This helps separate hardware implementation effects from representation and parameter changes.

A wider instruction set is a candidate optimization, not a guarantee for every CPU or memory-bound workload. For CPU measurements, record the model, actual target flags, and thread count with each result.

## 3. Compilation options

Use release mode and the build requirements for your selected backend. The following settings apply to the CPU implementations:

| Backend | Crate feature | Target features |
| --- | --- | --- |
| AVX2/FMA | `enable-avx` | `+avx2,+fma` |
| AVX-512 | `enable-avx512f` | `+avx512f` |
| IFMA | `enable-ifma` | `+avx512f,+avx512ifma,+avx512vl` |
| NEON | `enable-neon` | AArch64 target |

`-C target-cpu=native` selects the build machine's instructions; binaries built that way may not run on another machine. The [backend guide](https://www.poulpy.dev/docs/backends/) covers scheme integration and Rayon features.

From the [pinned checkout](https://www.poulpy.dev/docs/versions/), inspect CPU capabilities:

```sh
cargo test --locked -p poulpy-cpu-portable capabilities -- --ignored --nocapture
```

## 4. `base2k`

Choose a limb width within the backend's supported bounds for the operation and shape. The query `Module::<BE>::max_base2k(n, products, failure_bits, squaring)` estimates the largest radix from the degree, the number of accumulated products, the squaring flag, and the failure target `2^(-failure_bits)`; see [Backends](https://www.poulpy.dev/docs/backends/#choosing-a-subfamily) for examples and model assumptions. The query only models DFT-domain products, so also reserve coefficient-word headroom for additions and subtractions before normalization.

At fixed Torus width `k`, the limb count is `ceil(k / base2k)`. Smaller limbs increase this count; larger limbs change product/noise bounds and may not suit every configuration. Re-evaluate accuracy and parameter assumptions when changing it.

## 5. `dsize`

`dsize` groups several limbs into one gadget digit. Changing it trades digit count, evaluation-key size, auxiliary precision, and evaluation work. Use the key-layout and noise constraints to select valid candidates, then measure them. There is no workload-independent best value established by this guide.

## 6. Threads

On CPUs, Rayon variants parallelize supported operations internally. Serial CPU backends can instead be used for independent operations scheduled by the application. Compare these strategies for the latency or throughput goal that matters to you.

More workers can stop helping when memory bandwidth, scheduling, or scratch reservations dominate. The useful pool width depends on the backend and parameter shape; it is not necessarily the CPU's core count.

### Setting the pool

```rust
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build()?;
pool.install(|| {
    // Poulpy calls use this pool; eight is an example, not a recommendation.
});
```

### Idle polling

A circuit opens many short parallel regions, and a worker that Rayon has put to sleep takes time to wake. The executor therefore keeps the pool's workers polling for work for a short window after the last region, 1 ms by default. The cost is processor time: every worker stays busy for up to the window after each burst of work. Change the window or turn polling off with `poulpy_cpu_rayon::set_idle_polling`:

```rust
poulpy_cpu_rayon::set_idle_polling(None); // workers sleep as soon as Rayon lets them
poulpy_cpu_rayon::set_idle_polling(Some(std::time::Duration::from_micros(200)));
```

The setting is global. A window much longer than a millisecond lets the pollers compete with the thread that drives the circuit.

## Measuring your own thread count

The pinned source supplies an ignored diagnostic for vector-matrix products, convolution, inverse transforms, and coefficient arithmetic. On a CPU supporting these flags:

```sh
RUSTFLAGS="-C target-cpu=native" cargo test --locked --release -p poulpy-cpu-avx512 \
  --features enable-avx512f,enable-ifma,enable-rayon tuning -- --ignored --nocapture
```

Run on an idle machine with the ring degree, limb count, and rank relevant to the application. Record repetition spread and any per-kernel worker caps. This diagnostic selects candidates; it does not replace measuring the full circuit, preparation, and memory footprint.

Keep commands, raw results, and the [configuration record](https://www.poulpy.dev/docs/measurement/#record-enough-to-reproduce) together so another developer can reproduce the recommendation.
