constant_time: fix the -Os inlining loss, and add an Xtensa assembly path - #873
Draft
sweetlilmre wants to merge 3 commits into
Draft
constant_time: fix the -Os inlining loss, and add an Xtensa assembly path#873sweetlilmre wants to merge 3 commits into
sweetlilmre wants to merge 3 commits into
Conversation
At -Os the compiler leaves mbedtls_ct_bool(), mbedtls_ct_if() and mbedtls_ct_uint_lt() out of line, even though their assembly bodies are a handful of instructions. Every use then pays a call.
Measured on mbedtls_mpi_core_sub(), which calls mbedtls_ct_uint_lt() twice per limb:
arm-none-eabi-gcc -mthumb -mcpu=cortex-m4 -Os : 2 calls, not inlined
arm-none-eabi-gcc -mthumb -mcpu=cortex-m4 -O2 : 0 calls, inlined
arm-none-eabi-gcc -marm -mcpu=arm7tdmi -Os : 2 calls, not inlined
x86-64 gcc and clang -Os/-O2 : 0 calls, always inlined
So Arm -Os builds pay a call per constant-time comparison today. x86-64 inlines regardless, and does so for the generic C body too, so the difference is the target rather than the code, which is presumably why this has gone unnoticed.
Cost on the Arm thumb -Os measurement above, whole translation unit:
stock 2 calls core_sub 98 B .text 2904 B
with MBEDTLS_CT_INLINE 0 calls core_sub 112 B .text 2912 B (+8 B)
MBEDTLS_CT_INLINE is deliberately not applied to the generic C fallback, which is much larger: the same change there costs +396 bytes of .text on the same measurement, which is a size/speed trade-off rather than a clear win, and belongs to whoever maintains the size budget.
I have no Arm timing measurement to offer, only the codegen and size figures above. On Xtensa, where I can measure, this change is worth 278.02 ms -> 243.19 ms on a P-256 ECDSA verify, but only in combination with patch 2/3 of this series, since Xtensa has no assembly path before it.
The macro is used rather than a bare __attribute__((always_inline)) because mbedTLS supports MSVC, MSVC compiles the generic C branch, and __attribute__ is GNU-only.
Signed-off-by: sweetlilmre <sweetlilmre@gmail.com>
mbedtls_ct_bool(), mbedtls_ct_if() and mbedtls_ct_uint_lt() have hand-written assembly for Arm, AArch64, x86-64 and x86. Every other architecture uses the generic C built from mbedtls_ct_compiler_opaque() barriers - six of them for one mbedtls_ct_mpi_uint_if(mbedtls_ct_uint_lt(a, b), 1, 0). This adds the Xtensa equivalent of the existing Arm sequences. Xtensa has no and-not instruction, so ~x is built with movi -1 / xor. This matters because mbedtls_mpi_core_sub() and mbedtls_mpi_core_mla() became constant time in mbedtls 4b4869a and TF-PSA-Crypto 77bd479, which puts these primitives on the bignum hot path. mbedtls_mpi_core_mul() calls mla once per limb of B with excess_len == 1, so an n x n multiply runs the changed line exactly n times, and each mbedtls_mpi_core_montmul() performs 6n such operations. Effect on an ESP32-D0WD-V3 at 240 MHz, GCC 14.2 -Os, offline, RSA/MPI accelerator off, measured against mbedTLS 3.6.6 from ESP-IDF v5.5.5 on the same device: 3.6.6 4.1.0 this patch with patch 1/3 ecdsa_verify P-256 245.12 ms 327.75 ms 278.02 ms 243.19 ms ecp_mul P-256 113.92 ms 151.56 ms 129.59 ms 113.99 ms mpi_inv_mod P-256 8.66 ms 12.23 ms 9.38 ms 7.59 ms Attribution is exact: reverting just the constant-time lines in mbedtls_mpi_core_sub() and mbedtls_mpi_core_mla() to the 3.6 plain-C form recovers the whole gap on its own. Size, on the same build. This patch on its own makes mbedtls_ct_uint_lt() smaller, because the assembly is more compact than the generic C, but mbedtls_mpi_core_sub() grows because the surrounding generic-C selection is still inlined into it, and the two calls per limb are still there: core_sub ct_uint_lt pair calls stock 4.1.0 88 B 41 B 129 B 2 this patch alone 112 B 30 B 142 B 2 with patches 1/3 and 3/3 95 B 0 B 95 B 0 So this patch alone costs +13 bytes for the pair and does not remove the calls; only patch 1/3 does that, and then the out-of-line copy disappears entirely and the pair ends up 34 bytes smaller than stock. The whole firmware image grows by 16 bytes in both cases, because these primitives have other callers that also inline them. Also measured on an ESP32-S3 (Xtensa LX7), separate device, same harness. The regression is worse on the newer core and the series recovers most of it: 3.6.6 4.1.0 with 1/3 and 3/3 ecdsa_verify P-256 189.38 ms 266.97 ms 193.97 ms (+2.4% vs 3.6.6) ecp_mul P-256 87.40 ms 122.22 ms 89.57 ms (+2.5%) mpi_inv_mod P-256 7.40 ms 10.68 ms 6.82 ms (-7.9%) Chip coverage: the gate is __XTENSA__, so this serves ESP32 (LX6, measured), ESP32-S3 (LX7, measured) and ESP32-S2 (LX7, built and codegen-checked, not run). It uses only base Xtensa Core ISA instructions, which is why LX6 and LX7 take it unchanged. The RISC-V Espressif parts - C2, C3, C5, C6, H2, P4 - are NOT covered by this patch and keep the full regression; a RISC-V path would be a separate piece of work that I have not done. Applies on top of patch 1/3, which it extends by one line: Xtensa joins the list of architectures whose primitives are force-inlined. A pure-C fix was tried first and rejected. Rewriting the generic mbedtls_ct_uint_lt() as the branchless six-operation sequence without barriers is faster, but clang 22 -O2 recognises the idiom, re-derives "x < y" and emits a conditional branch. Barriers on the two inputs alone do not help either, because a barrier hides a value and not the structure of an expression. Both were caught with MBEDTLS_TEST_CONSTANT_FLOW_MEMSAN and a negative control: a plain-C carry fails, unmodified upstream passes, and both C rewrites fail. That is what led to assembly, which is opaque to the optimiser by construction. The Xtensa assembly itself cannot be MemSan-tested, as there is no MemSan for Xtensa. It rests on the same argument as the existing Arm and x86 paths, plus disassembly: with this patch mbedtls_mpi_core_sub() contains one conditional branch, the loop back-edge on the public limb count, identical to stock, and no calls at all. Separately, note that test_suite_bignum_core's mpi_core_sub and mpi_core_mla cases cannot validate the generic C path as they stand. They compare the returned carry while the inputs are still TEST_CF_SECRET, and the carry is secret-derived, so under MemSan they report for any implementation including unmodified upstream - verified, byte-identical reports for stock and for a plain-C carry. On x86 builds with MBEDTLS_HAVE_ASM the inline assembly launders the poison and they pass. The generic C constant-time path therefore appears to have no constant-flow coverage at present. Signed-off-by: sweetlilmre <sweetlilmre@gmail.com>
mbedtls_mpi_core_sub() and mbedtls_mpi_core_mla() call mbedtls_ct_mpi_uint_if(cond, 1, 0) to turn a condition into a 0/1 borrow or carry bit. With if0 == 0 the general form reduces to (cond & if1): the (~cond & if0) half is always zero. mbedtls_ct_mpi_uint_if_else_0() is exactly that, and needs no mbedtls_ct_compiler_opaque() barrier to build ~cond. This is a small improvement and it touches constant-time code, so it is deliberately separate from the rest of the series and can be dropped without affecting it. It is architecture-independent, unlike patches 1/3 and 2/3. Measured contribution, on an ESP32-D0WD-V3 in the one configuration where I isolated it: a P-256 ECDSA verify went from 266.29 ms to 263.06 ms, about 1.2%. I did not isolate it again in the final configuration, so I have no figure for it there. Verified constant-flow clean on its own with MBEDTLS_TEST_CONSTANT_FLOW_MEMSAN, in the same run where a plain-C carry fails - so the harness demonstrably detects the property being claimed. Signed-off-by: sweetlilmre <sweetlilmre@gmail.com>
sweetlilmre
force-pushed
the
constant-time-embedded-perf
branch
from
August 25, 2026 11:36
720e137 to
c64192a
Compare
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.
Intro
While building a firmware for an esp32 device and migrating from Arduino / IDF to pure IDF I noticed a failure during TLS negotiation. This was due to the TLS handshake taking longer than the watchdog timeout and causing a reboot during mid fetch. In investigating this behaviour, I found a regression in performance due to constant time hardening.
AI was used in both the development and analysis of this patch, specifically Claude Opus 5 on Medium level thinking. The patch was adversarially reviewed by myself through several iterations, constant-time testing was done exposing a coverage gap in the current code and purpose test harnesses built to validate the approach.
Summary
Two independent problems make the constant-time primitives expensive on embedded targets, both on the bignum hot path since
mbedtls_mpi_core_sub()andmbedtls_mpi_core_mla()were made constant time (mbedtls4b4869a15, TF-PSA-Crypto77bd4798). On an ESP32 this costs 15-41% per bignum primitive against mbedTLS 3.6.6, at identical call counts.-Osthese functions are not inlined, so every use pays a call. This affects targets that already have an assembly path, including Arm.mbedtls_ct_compiler_opaque()barriers.The third commit is an unrelated small cleanup at two call sites, separated deliberately so it can be dropped without affecting the rest.
Why
-Osmatters herembedtls_mpi_core_sub()callsmbedtls_ct_uint_lt()twice per limb. Whether that call survives:-Os-O2arm-none-eabi-gcc -mthumb -mcpu=cortex-m4arm-none-eabi-gcc -marm -mcpu=arm7tdmixtensa-esp-elf-gccx86-64 inlines regardless, and does so for the generic C body too, so the difference is the target rather than the code. That is presumably why this has gone unnoticed. Arm Cortex-M
-Osbuilds pay it today.MBEDTLS_CT_INLINEis applied only where an assembly path is in use. Cost on Arm thumb-Os, whole translation unit: +8 bytes of.text. Applying it to the generic C fallback instead costs +396 bytes, which is a size/speed trade-off rather than a clear win, so that has been left alone.Measurements
ESP32-D0WD-V3 (Xtensa LX6) and ESP32-S3 (Xtensa LX7), both at 240 MHz, GCC 14.2,
-Os, RSA/MPI accelerator off so this is pure library C. Offline benchmark: no WiFi, no TLS, fixed operands, one task pinned to a core, batches sized to ~200 ms, min/mean/max over 5 batches. Reference is mbedTLS 3.6.6 from ESP-IDF v5.5.5 on the same device.ESP32 (LX6):
ecdsa_verifyP-256ecp_mulP-256mpi_inv_modP-256mpi_mul256-bitESP32-S3 (LX7):
ecdsa_verifyP-256ecp_mulP-256mpi_inv_modP-256The regression is worse on the newer core, and the series recovers 94% of it there against 100% on LX6.
Attribution is exact: reverting only the constant-time lines in
mbedtls_mpi_core_sub()andmbedtls_mpi_core_mla()to the 3.6 plain-C form recovers the whole gap on its own, which is what pins the cause to them rather than to anything else that changed between 3.6 and 4.x.The regression is O(n) at roughly 44 cycles per limb, flat from 256 to 4096 bits.
mbedtls_mpi_core_mul()callsmlaonce per limb of B withexcess_len == 1, so an n×n multiply runs the changed line exactly n times, and eachmbedtls_mpi_core_montmul()performs 6n such operations. Because the multiply itself is O(n²) the relative cost shrinks as operands grow (+15.4% at 256 bits, +2.0% at 4096), so it is worst exactly where ECC lives.End to end, in real firmware
The tables above are primitives. The effect on a real TLS 1.2 ECDHE-ECDSA handshake to
api.github.com, from the ESP32 firmware, timed from the host, six reps each:About 830 ms, or 19.8%. It was run as A-B-A because a single before/after against a live third-party host proves nothing: the two unpatched runs bracket the patched one and agree with each other to within 55 ms of mean. The firmware is also 144 bytes smaller.
That 19.8% is conditional, and in one configuration the series is a regression. It is measured with the ESP32 RSA/MPI accelerator disabled (
MBEDTLS_HARDWARE_MPIoff), which is this firmware's shipped setting for reasons unrelated to this patch. With the accelerator enabled, the same series makes the same handshake roughly 875 ms slower, measured as A-B-A on one device in one session, four reps each, timed inside the firmware:The two unpatched runs bracket the patched one and agree with each other to within 13 ms of total, so this is not drift.
The cause is not established. The plausible mechanism is code growth from the forced inlining costing flash-cache misses on a part that executes from cached external flash — which would be consistent with an earlier finding on this same chip that building mbedTLS at
-O2also made TLS slower rather than faster — but that is a hypothesis and has not been measured. Note also that with the accelerator on, ESP-IDF substitutes its ownmbedtls_mpi_mul_mpi, so the bignum hot path is largely off the code this series touches, and there is less for it to win back.So the honest summary is: a clear win where the constant-time helpers are actually on the hot path, and a measurable loss on ESP32 builds that use the RSA/MPI accelerator. If that trade is unacceptable, patch 1/3 (the forced inlining) is the piece to reconsider — 2/3 and 3/3 do not grow code the same way.
Constant-time testing, including a rejected approach
A pure-C fix was tried first: rewriting the generic
mbedtls_ct_uint_lt()as the same branchless six-operation sequence the Arm path uses, with no barriers. It is faster, and it is not constant time — clang 22 at-O2recognises the idiom, re-derivesx < yand emits a conditional branch. Keeping barriers on only the two inputs does not help either, because a barrier hides a value and not the structure of an expression.Both were caught with
MBEDTLS_TEST_CONSTANT_FLOW_MEMSANand a negative control:_if_else_0change in commit 3That is what led to assembly, which is opaque to the optimiser by construction. It also says the existing design is right and the assembly paths are load-bearing.
A gap in existing constant-flow coverage
Investigating the above surfaced a second issue:
test_suite_bignum_core'smpi_core_subandmpi_core_mlacases cannot validate the generic C path as they stand. They compare the returned carry (test_suite_bignum_core.function:825) while the inputs are stillTEST_CF_SECRET, and the carry is secret-derived by construction, so under MemSan they report for any implementation including unmodified upstream. This was verified: stock and a plain-C carry produce byte-identical reports.On x86 builds with
MBEDTLS_HAVE_ASMthe inline assembly launders the MemSan poison and the cases pass. The consequence is that the generic C constant-time path appears to have no constant-flow coverage at present, since it is only ever exercised on architectures that do not use it. That seems worth addressing independently of this PR.What has not been verified
mbedtls_mpi_core_sub()contains one conditional branch, the loop back-edge on the public limb count, identical to stock, and no calls at all. The project's own constant-flow suite should be run before accepting..textsize were measured on Arm; the timings above are Xtensa.-Os/-O2; no other compilers or optimisation levels were tested.The commits
constant_time: force inlining where an assembly path is in use— independent, and the one that helps existing Arm users.constant_time: add an Xtensa assembly path— additive#elifbranches; cannot affect an architecture that already has a path. Applies on top of 1, which it extends by one line.bignum_core: use _if_else_0 where the zero branch is unused— independent and architecture-neutral. Worth about 1.2% in the one configuration where it was isolated, so if it is contentious it should be dropped rather than hold up the other two.These can be split into separate PRs, commit 3 dropped, or a RISC-V path added, if any of that would help. The harness and the raw captures behind every number above can be shared if useful.