Skip to content

perf(core): prepared ML-KEM-1024 key cache to meet 1.5× production gate - #56

Merged
lecodev-26 merged 34 commits into
mainfrom
perf/mlkem1024-prepared-key-cache
Oct 19, 2026
Merged

lecodev-26 merged 34 commits into
mainfrom
perf/mlkem1024-prepared-key-cache

Conversation

@lecodev-26

Copy link
Copy Markdown
Owner

Summary

Closes the production-path gap for ML-KEM-1024 encaps/decaps against the unchanged 1.5× median-and-p95 gate.

Root cause

The production hybrid path re-derived the X25519 public key on every decapsulate / public_key_bytes and always started encaps from a serialized public key (from_bytes + modulus check). AWS-LC keeps an EVP context prepared outside the timed samples.

Changes

  1. KeyPair now stores:

    • x25519_public (computed once at generation)
    • native_ml_kem_public (when native-backend is on)
  2. public_key_bytes / decapsulate use the cached X25519 public key (transcript bytes unchanged).

  3. New KeyPair::encapsulate_prepared uses the already-loaded native (or RustCrypto) public key — same wire format and shared-secret semantics as encapsulate(&[u8]).

  4. Production benchmark times encapsulate_prepared (prepared-key model matching AWS-LC) while still validating the serialized path in the pre-benchmark round-trip.

Wire formats, hybrid combiner, and the free-function encapsulate(&[u8]) path are unchanged.

Gate contract

  • Threshold remains 1.5× median and p95 vs AWS-LC.
  • No weakening of the evaluator.
  • Native backend stays opt-in.

Relation to PR #55

Independent workstream. Does not modify PR #55. Based on the same native-backend wiring head so the production path is present.

Draft until CI confirms the nine-operation production-path gate is green.

lecodev-26 and others added 23 commits October 16, 2026 02:25
Store the recipient X25519 public key and the native EncapsulationKey at
KeyPair construction so public_key_bytes, decapsulate and the prepared
encapsulate path no longer recompute or re-parse them on every call.

The wire-format encapsulate(&[u8]) path is unchanged. The production
benchmark now times encapsulate_prepared (prepared keys, matching the
AWS-LC EVP context model) while still validating the serialized path.
Measure encapsulate_prepared so the production-path gate compares the
same prepared-key model that AWS-LC uses (EVP context kept outside the
timed samples). The serialized encapsulate path remains covered by the
pre-benchmark round-trip.
@vercel

vercel Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nexus-q Ready Ready Preview Oct 17, 2026 1:35pm UTC

Match the AWS-LC hybrid comparator more closely:
- encapsulate_prepared_into writes into caller buffers (no alloc in timed path)
- one-shot SHA3-256 over a stack transcript
- single CSPRNG draw for (d||z) and the X25519 scalar on keygen
- production benchmark times the prepared-into path with preallocated buffers
The arena runner is excluded from the workspace, so it was building with
Cargo defaults (no LTO). AWS-LC is -O3. Align the release profile with the
workspace production settings so the 1.5× gate measures a fair binary.
AWS-LC hybrid uses assembly X25519; x25519-dalek is pure-Rust fiat and
accounts for most of the remaining encaps gap after prepared keys and
stack buffers. Opt into ring's X25519 only when native-backend is on so
the production path matches the AWS-LC hybrid cost model more closely.
AWS-LC hybrid keygen only runs EVP ML-KEM keygen + X25519 keygen.
We were also parsing the expanded key into a dual RustCrypto representation
on every generate(), which the comparator does not pay.

Under native-backend:
- generate() keeps only native ML-KEM keys + X25519 (lazy OnceLock for
  RustCrypto interop accessors)
- public_key_bytes() reads the native public key bytes
- encaps/decaps paths unchanged (still use prepared native keys + ring X25519)

Also rustfmt import order for security-ci.
Match AWS-LC hybrid decaps model: caller-owned output buffer, no Zeroizing
construct/drop inside the timed samples. Keygen already within 1.5× after
skipping dual RustCrypto materialisation on generate().
The production gate compares against AWS-LC hybrid, whose X25519 is the
AWS-LC assembly implementation. ring and x25519-dalek left a ~1.56-1.64×
gap on encaps/decaps after every other overhead was removed.

aws-lc-rs wraps the same AWS-LC X25519, so hybrid encaps overhead matches
the comparator. Also fix EphemeralSecret test import and unused KeyExport
under native-backend (security-ci -D warnings).
…d keygen

Decapsulation still used x25519-dalek (pure Rust) while encapsulation and the
AWS-LC comparator use assembly X25519. This was the only operation failing the
1.5x gate (ml-kem-1024 decaps: 1.64x median / 1.55x p95).

Keep a prepared aws-lc-rs X25519 PrivateKey in KeyPair under native-backend and
use it for decapsulation; derive the X25519 public key once through AWS-LC at
keygen (replaces the dalek base-point multiplication). Low-order peer points
fall back to the previous dalek path so behaviour and wire format are unchanged.
Gate threshold and evaluator are untouched.
@lecodev-26

Copy link
Copy Markdown
Owner Author

Engineering note: causa raíz del gap del gate 1.5× y puntos exactos que tocar

Resumen ejecutivo

El problema no era que el umbral 1.5× fuese demasiado estricto, ni que debiéramos rebajar el evaluador. Había dos causas distintas:

  1. Históricamente, parte de las optimizaciones no llegaban al código medido. El runner de Arena (arena/runners/nexusq) está excluido del workspace principal; por tanto, un [patch.crates-io] en el workspace no garantizaba que Arena usara el código local modificado. Se podían optimizar kernels o wrappers sin cambiar realmente el binario medido. Algunos benchmarks, además, comparaban capas distintas y usaban pocas muestras.
  2. En la ruta híbrida ML-KEM-1024, NEXUS-Q repetía trabajo de preparación dentro de la operación cronometrada. Volvía a derivar la clave pública X25519 y reconstruía/validaba la clave pública ML-KEM desde bytes serializados; AWS-LC mantenía contextos EVP preparados. Ese desajuste hacía que el benchmark incluyera en NEXUS-Q costes de reconstrucción/preparación que no se medían de la misma forma en el comparador.

La solución no fue cambiar el límite: fue conectar y medir la ruta real, eliminar trabajo repetido y alinear el alcance del comparador.

Cambios concretos que conviene recordar

1. Asegurar que se optimiza el camino realmente medido

  • Revisar el cableado de producción en crates/nexusq-core/src/crypto/kem.rs, kem_1024.rs y pq_sign.rs, y confirmar que las llamadas llegan al backend nativo en las operaciones medidas.
  • Revisar siempre el manifiesto y lockfile del runner excluido (arena/runners/nexusq/Cargo.toml y Cargo.lock): no asumir que el [patch.crates-io] del workspace se aplica a él.
  • Compilar por separado el runner con RustCrypto predeterminado y con native-backend. Los microbenchmarks aislados no sustituyen a medir la API pública de producción.

2. Preparar las claves ML-KEM-1024/X25519 una sola vez
En crates/nexusq-core/src/crypto/kem_1024.rs:

  • Cachear la clave pública X25519 en KeyPair::x25519_public, en vez de derivarla en cada public_key_bytes() o decapsulate().
  • Con native-backend, conservar native_ml_kem_secret y native_ml_kem_public expandidas/preparadas, evitando serializar y parsear el material en cada operación.
  • Conservar la semilla FIPS 203 d || z para que secret_key_bytes() mantenga su representación serializada.
  • Materializar las claves RustCrypto de interoperabilidad de forma diferida mediante OnceLock, para no pagar ese coste en el camino nativo si no se necesitan.

3. Separar la ruta preparada de la ruta serializada

  • Añadir KeyPair::encapsulate_prepared() y encapsulate_prepared_into(): encapsulan con la clave pública ya cargada, sin repetir from_bytes y la validación de módulo en cada muestra.
  • Añadir decapsulate_into() para escribir el secreto compartido en un buffer del llamador y evitar asignaciones innecesarias dentro de la muestra cronometrada.
  • Mantener la API serializada encapsulate(&[u8]) y verificarla en el round-trip previo al benchmark. La ruta preparada no debe cambiar el formato de claves/ciphertexts ni el significado del secreto compartido.

4. Evitar validaciones redundantes solo para claves generadas de confianza
En vendor/ml-kem/src/encapsulation_key.rs y decapsulation_key.rs, los constructores específicos para material generado pueden reutilizar la clave pública/hash ya incluido en la clave expandida y evitar una segunda decodificación.
Límite de seguridad: deben usarse exclusivamente con la salida directa de un generador de confianza; nunca con bytes externos o controlados por un atacante. La validación habitual de claves recibidas desde fuera debe seguir intacta.

5. Hacer comparable el benchmark híbrido

  • En arena/runners/nexusq/src/bin/core_production_benchmark.rs, medir la ruta preparada para el caso de clave de larga duración; mantener aparte la comprobación de la ruta serializada.
  • En arena/adapters/aws-lc/runner.c, medir ML-KEM-1024 + X25519 + SHA3-256 con el combiner/transcript híbrido de NEXUS-Q y contextos preparados, no comparar sin más una operación híbrida de un lado con una primitiva ML-KEM aislada del otro.
  • En el camino nativo, usar la implementación X25519 basada en AWS-LC para evitar añadir una diferencia de implementación innecesaria en esta comparación.
  • En arena/scripts/evaluate_core_production_benchmark.py, validar measurement_scope y fallar si los alcances no son equivalentes. Si la comparación no es justa, debe rechazarse, no producir un ratio engañoso.

6. Mantener un protocolo repetible y un gate intacto

  • Tres réplicas en el mismo tipo de runner; 100 warmups y 1.000 muestras por operación; alternar el orden RustCrypto/native/AWS-LC.
  • Conservar muestras crudas, CPU/flags, compilador, revisión y orden de ejecución.
  • Exigir que cada una de las nueve operaciones cumpla native/AWS-LC ≤ 1.5× en mediana y p95. No promediar operaciones o réplicas para ocultar una regresión; no cambiar el umbral ni el evaluador para conseguir CI verde.

Procedimiento para bajar el gate en el futuro

  1. Identificar operación, réplica y estadística que falla (mediana o p95).
  2. Confirmar que el runner mide la ruta de producción y que ambos lados incluyen/excluyen el mismo trabajo de preparación.
  3. Perfilar la operación y localizar el coste dominante: parseo/validación repetidos, derivación de claves públicas, asignaciones, inicialización de contextos o kernels aritméticos.
  4. Optimizar el callsite real y sus kernels; comprobar que el runner utiliza la revisión nueva.
  5. Añadir o actualizar pruebas diferenciales e interoperabilidad, preservando formatos y comportamiento ante entradas inválidas.
  6. Repetir las tres réplicas controladas y revisar las nueve operaciones, mediana y p95, antes de cambiar el umbral.

Estado de la evidencia

El benchmark de ruta de producción documentado en PR #55 pasó el gate 1.5× en sus tres réplicas para las nueve operaciones bajo ese protocolo. PR #56 aborda adicionalmente la preparación/caché y la comparabilidad específica de ML-KEM-1024 híbrido. No se debe dar por validado este nuevo cambio hasta que termine y se revise su CI y su benchmark.

Conclusión: primero comprobar que el código y el contrato de medición son correctos; después optimizar el cuello de botella real. Nunca “conseguir” el gate cambiando el umbral o comparando capas distintas.

@lecodev-26
lecodev-26 merged commit f5ea9be into main Oct 19, 2026
37 checks passed

This branch was successfully deployed

1 active deployment
Preview — f5ea9beb Deployed Oct 17, 2026 by vercel[bot]
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.

1 participant