Skip to content

benchmark: time ML-KEM encaps/decaps as one round trip - #504

Merged
bigbrett merged 2 commits into
wolfSSL:mainfrom
Frauschi:mlkem_bench
Aug 5, 2026
Merged

benchmark: time ML-KEM encaps/decaps as one round trip#504
bigbrett merged 2 commits into
wolfSSL:mainfrom
Frauschi:mlkem_bench

Conversation

@Frauschi

@Frauschi Frauschi commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

The ML-KEM benchmark rows created their key with wh_Client_MlKemMakeExportKey, which returns the key to the client without caching it server-side, leaving key->devCtx empty. Every wh_Client_MlKem{Encapsulate,Decapsulate} in the timed loop therefore took the implicit-import path: ship the whole private key to the server (1632 bytes for ML-KEM-512, up to 3168 for ML-KEM-1024), run the operation with the evict flag set, leave the cache empty for the next iteration.

That is two round trips per measurement instead of one, and the extra one carries a payload larger than anything the operation itself sends. ML-KEM was the only module doing this - AES, CMAC, ECC, RSA and ML-DSA all cache their key once before the loop and reference it by ID - so the ML-KEM rows were reported against a different amount of transport work than the rows they sit next to.

This generates the key straight into the server cache with wh_Client_MlKemMakeCacheKey, binds the ID to the client key struct, and evicts once after the loop. Key setup was already outside the timed section, so nothing moves in or out of the measurement.

The KEY-GEN rows keep wh_Client_MlKemMakeExportKey deliberately - those measure generating a key and getting it back, which is what the ECC and ML-DSA key generation rows measure too.

Second commit

Making the bench rows use a cached key exposed a sizing gap. wh_Server_MlKemKeyCacheImport always requests a WC_ML_KEM_MAX_PRIVATE_KEY_SIZE (3168) slot at every security level, but WOLFHSM_CFG_SERVER_KEYCACHE_BIG_BUFSIZE defaults to 8192 only when ML-DSA, XMSS or LMS is enabled, and to 1200 otherwise. An ML-KEM-only build got a buffer less than half the size every cache keygen and import needs, failing at run time with WH_ERROR_BUFFER_SIZE and nothing at build time to say why.

  • Added WOLFSSL_HAVE_MLKEM to the big-buffer condition.
  • Added the compile-time assert ML-DSA already carries next to its own cache import, so an undersized override fails to build with a message naming the setting.
  • Added a positive cached-key-by-ID DMA encaps/decaps test. That combination had no coverage: the only place a cached-by-ID key met the DMA calls was the negative usage-policy case, and the positive DMA test used a key whose devCtx was still erased, so it exercised the implicit-import path instead.

Testing

Check Result
wh_test full suite, DMA=1 and plain pass
POSIX benchmark, DMA=1 and plain pass, all 18 ML-KEM rows
Builds, both configs clean at -std=c90 -Werror -Wall -Wextra
Undersized BIG_BUFSIZE new assert fires, names the setting

Round trips were measured with a probe on client->comm->seq around the timed loop. The ten iterations of a default run (WOLFHSM_CFG_BENCH_PK_ITERS) advanced it by twenty before and by ten after, at all three security levels, on both the inline and the DMA path, encapsulation and decapsulation alike.

The new test was checked against a deliberate break: removing the wh_Client_MlKemSetKeyId call makes it fail rather than pass quietly.

Notes

The gain is invisible on a POSIX in-process transport, where a round trip is a memcpy, and is the whole story on a real IPC boundary. On a real target, where each round trip costs tens of microseconds, this improves ML-KEM benchmark results substantially.

The DMA rows share the non-DMA setup call deliberately. wh_Client_MlKemMakeCacheKeyDma is the DMA form of wh_Client_MlKemMakeCacheKeyAndExportPublic, not of wh_Client_MlKemMakeCacheKey: it requires a public key out, which the timed operations do not need.

The ML-KEM modules created their benchmark key with
wh_Client_MlKemMakeExportKey, which returns the key to the client without
caching it on the server, so key->devCtx holds no key ID. Every
wh_Client_MlKem{Encapsulate,Decapsulate} in the timed loop therefore took the
implicit-import path: it shipped the whole private key to the server (1632 bytes
for ML-KEM-512, up to 3168 for ML-KEM-1024), ran the operation with the evict
flag set, and left the cache empty again for the next iteration.

That is two round trips per measurement instead of one, and the extra one
carries a payload larger than anything the operation itself sends. ML-KEM was
the only module doing this - AES, CMAC, ECC, RSA and ML-DSA all cache their key
once before the loop and reference it by ID - so the ML-KEM rows were reported
against a different amount of transport work than the rows they sit next to.

Generate the key straight into the server key cache instead with
wh_Client_MlKemMakeCacheKey, bind the returned key ID to the client key struct,
and evict it once after the loop. Key setup was already outside the timed
section, so nothing moves in or out of the measurement; what changes is that
the measurement now contains one request instead of two.

The DMA rows share that same setup, deliberately.
wh_Client_MlKemMakeCacheKeyDma is the DMA form of
wh_Client_MlKemMakeCacheKeyAndExportPublic, not of wh_Client_MlKemMakeCacheKey:
it requires a public key out, and the timed operations do not want one.
Encapsulation and decapsulation, DMA and not, need nothing from the client key
struct beyond the key ID and the parameter set wc_MlKemKey_Init already
applied. The keygen request is fixed size; the server does write a best-effort
public key into the response body, which the plain cache keygen discards.

Verified against the POSIX benchmark with a probe on the client sequence
counter: the ten timed iterations of a default run
(WOLFHSM_CFG_BENCH_PK_ITERS) advanced it by twenty before this change and by
ten after, at all three security levels, on both the inline and the DMA path,
for encapsulation and decapsulation alike. The gain is invisible on a POSIX
in-process transport, where a round trip is a memcpy, and is the whole story on
a real IPC boundary - on an AURIX TC4xx, where each round trip costs tens of
microseconds, the ML-KEM rows retained 67-79% of the raw CSRM-native throughput
while Curve25519, an operation of similar cost that does cache its key,
retained 99%.

The KEY-GEN rows keep wh_Client_MlKemMakeExportKey deliberately. Those measure
generating a key and getting it back, which is what the ECC and ML-DSA key
generation rows measure too; switching them to a cache keygen would make ML-KEM
the odd one out in the other direction.
…A path

wh_Server_MlKemKeyCacheImport asks for a cache slot of
WC_ML_KEM_MAX_PRIVATE_KEY_SIZE (3168) at every security level, but nothing
guaranteed the big cache buffer could hold one.
WOLFHSM_CFG_SERVER_KEYCACHE_BIG_BUFSIZE defaults to 8192 only when ML-DSA,
XMSS or LMS is enabled, and to 1200 otherwise, so an ML-KEM-only build got a
buffer less than half the size of the slot every cache keygen and every key
import needs. Those calls failed at run time with WH_ERROR_BUFFER_SIZE from
wh_Server_KeystoreGetCacheSlotChecked, with nothing at build time to say why.

Add WOLFSSL_HAVE_MLKEM to the big-buffer condition, and add the compile-time
assert ML-DSA already carries next to its own cache import, so an explicit
undersized override fails to build with a message that names the setting
instead of failing later on a key operation.

The gap showed up because the benchmark ML-KEM rows now reference a cached key
by ID. The DMA half of that flow had no positive test: the only place a
cached-by-ID key met wh_Client_MlKem{Encapsulate,Decapsulate}Dma was the
negative usage-policy case. The positive DMA test used a key whose devCtx was
still erased, because wh_Client_MlKemImportKeyDma never binds the returned ID
back onto the key struct, so it exercised the implicit-import path instead.

Add the positive case at each security level: cache keygen, bind the ID,
encapsulate and decapsulate over DMA, compare the shared secrets, then evict.
The eviction doubles as the assertion that the key was still cached, which is
what separates the by-id path from an implicit import that evicts after every
call. Dropping the wh_Client_MlKemSetKeyId call makes the new block fail, so it
tests what it claims to.
@Frauschi Frauschi self-assigned this Aug 5, 2026

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #504

Scan targets checked: wolfhsm-core-bugs, wolfhsm-crypto-bugs, wolfhsm-src

No new issues found in the changed files. ✅

@Frauschi Frauschi assigned wolfSSL-Bot and unassigned Frauschi Aug 5, 2026
@Frauschi
Frauschi requested review from bigbrett and padelsbach August 5, 2026 10:04
@bigbrett
bigbrett merged commit 9b0023e into wolfSSL:main Aug 5, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants