benchmark: time ML-KEM encaps/decaps as one round trip - #504
Merged
Conversation
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.
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #504
Scan targets checked: wolfhsm-core-bugs, wolfhsm-crypto-bugs, wolfhsm-src
No new issues found in the changed files. ✅
bigbrett
approved these changes
Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, leavingkey->devCtxempty. Everywh_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_MlKemMakeExportKeydeliberately - 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_MlKemKeyCacheImportalways requests aWC_ML_KEM_MAX_PRIVATE_KEY_SIZE(3168) slot at every security level, butWOLFHSM_CFG_SERVER_KEYCACHE_BIG_BUFSIZEdefaults 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 withWH_ERROR_BUFFER_SIZEand nothing at build time to say why.WOLFSSL_HAVE_MLKEMto the big-buffer condition.devCtxwas still erased, so it exercised the implicit-import path instead.Testing
wh_testfull suite,DMA=1and plainDMA=1and plain-std=c90 -Werror -Wall -WextraBIG_BUFSIZERound trips were measured with a probe on
client->comm->seqaround 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_MlKemSetKeyIdcall 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_MlKemMakeCacheKeyDmais the DMA form ofwh_Client_MlKemMakeCacheKeyAndExportPublic, not ofwh_Client_MlKemMakeCacheKey: it requires a public key out, which the timed operations do not need.