perf(api): trim Python overhead on the plain decode fast path (#122) - #128
Merged
Merged
Conversation
For HS256 the Python wrapper cost about as much as the native decode call itself (0.85us overhead on top of ~1.30us native). The fast path in PyJWT.decode / decode_complete called _is_plain_decode (a 9-argument function) and _require_detached_payload_for_rfc7797 (a keyword-argument call), and _validate_claims_default always called time.time() and re-checked exp even though Rust had already validated it with leeway=0. - Inline the _is_plain_decode condition and the RFC 7797 detached_payload pre-check directly into decode() and decode_complete(), removing the two function calls from the hot path. _require_detached_payload_for_rfc7797 stays as a function for the general (non-fast) path, which still needs its full signature. - In _validate_claims_default, only re-check exp in Python when it is not a plain int. Rust already enforces exp > now with an integer clock on this path, which is the exact same predicate as the truncating check below for an integer exp. A float exp still needs the Python recheck: Rust rounds a fractional value to the nearest second while PyJWT (and this check, for parity) truncates it, so the two can disagree right at the boundary. - time.time() is now only called when iat or a non-int exp is present. Measured (HS256, prebuilt token): wrapper overhead over the native call dropped from ~0.85us to ~0.57us with iat+int exp present (~33% less), and to ~0.37us with no time claims at all (~57% less). No behavioural change. Added a regression test pinning exp to a fractional second (K + 0.6 for the current whole second K) to prove the float recheck still runs: Rust rounds that up and would accept it, but the Python truncating recheck must still reject it for PyJWT parity. Closes #122
Regression test for the exp re-check change in the previous commit: a float exp pinned to K + 0.6 (K = current whole second) must still raise ExpiredSignatureError via the Python recheck, even though Rust's own rounding-based boundary check alone would accept it as not yet expired.
2 tasks
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.
Problem
For HS256 the Python wrapper cost about as much as the native decode call itself:
_oxyjwt.decode(native)oxyjwt.decode(fast path)The fast path in
PyJWT.decode/decode_completecalled_is_plain_decode(a 9-argument function) and_require_detached_payload_for_rfc7797(a keyword-argument call), and_validate_claims_defaultalways calledtime.time()and re-checkedexpeven though Rust had already validated it (leeway is always 0 on this path).Fix
_is_plain_decodecondition and the RFC 7797detached_payloadpre-check directly intodecode()/decode_complete(), removing both function calls from the hot path._require_detached_payload_for_rfc7797stays as a function for the general (non-fast) path, which still needs its full signature._validate_claims_default, only re-checkexpin Python when it is not a plainint. Rust already enforcesexp > nowwith an integer clock on this path, which is the exact same predicate as the truncating check below for an integerexp— redoing it only cost atime.time()call for no behavioral difference. Afloatexpstill gets the Python recheck: Rust rounds a fractional value to the nearest second, while PyJWT (and this check, for parity) truncates it, so the two can disagree right at the boundary.time.time()is now only called wheniator a non-intexpis present.No behavioral change.
Results
HS256, prebuilt token, release build:
iat+int exppresentTests
Added
test_float_exp_boundary_still_checked_in_python(tests/test_decode_fast_path.py): pinsexptoK + 0.6for the current whole secondK. Rust's own rounding-based boundary check alone would round that up toK + 1and accept it as not-yet-expired; the Python recheck must still truncate it back toKand reject it, proving the float recheck still runs after this change. Confirmed non-flaky over repeated runs (the boundary logic doesn't depend on landing near a wall-clock second edge).Checklist
oxyjwt.decodeoverhead over native reduced by ≥ 30% (measured 33–57%, see above)tests/test_decode_fast_path.py,tests/test_parity_pyjwt.pygreenexpboundary paritypytest(259 passed, 1 skipped) andmypycleanCloses #122.