From ec1c65b18c98331f43993903d5543fba6e0af747 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Fri, 10 Jul 2026 14:02:52 +0200 Subject: [PATCH 1/2] perf(python): avoid conversion-dunder hop in numeric fast paths Two hot-path numeric operators routed a same-type operand through a conversion dunder instead of a direct downcast, per the PyO3 performance guide ("use cast, not extract"): - float `__richcmp__` extracted the operand as the primitive (`f64`), which calls `__float__` on a wrapped `Float64` on every comparison. A `cast::()` fast path skips the hop: ~34.3 -> ~23.7 ns (-31%) on same-type float comparisons (sort/min/max/conditionals). - int `__truediv__` used the `OtherType` enum extract, which calls `__int__` on a wrapped operand and, for large/negative operands, fell back to `f64` division (latent precision loss). A `cast::<$name>()` fast path does direct typed integer division: ~48.7 -> ~36.4 ns (-25%), now matching `//`/`%`, truncating toward zero to match .NET. Also switch the float `__add__/__sub__/__mul__` same-type fast paths from `extract::()` to `cast::()` (equivalent, avoids a discarded PyErr on a mixed-type miss). All 168 fable-library-core tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fable-library-py/src/floats.rs | 27 ++++++++++++++++++++------- src/fable-library-py/src/ints.rs | 12 ++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/fable-library-py/src/floats.rs b/src/fable-library-py/src/floats.rs index fcfa97e26f..d76c5767d7 100644 --- a/src/fable-library-py/src/floats.rs +++ b/src/fable-library-py/src/floats.rs @@ -49,9 +49,9 @@ macro_rules! float_variant { // --- Arithmetic Methods --- // Fast path: check if other is our type first to avoid __float__ call pub fn __add__(&self, other: &Bound<'_, PyAny>) -> PyResult { - // Fast path: other is same type - if let Ok(other_val) = other.extract::() { - return Ok(Self(self.0 + other_val.0)); + // Fast path: other is same type (cast avoids a discarded PyErr on miss) + if let Ok(other_val) = other.cast::() { + return Ok(Self(self.0 + other_val.get().0)); } // Slow path: extract as primitive let other_val = other.extract::<$type>()?; @@ -63,8 +63,8 @@ macro_rules! float_variant { } pub fn __sub__(&self, other: &Bound<'_, PyAny>) -> PyResult { - if let Ok(other_val) = other.extract::() { - return Ok(Self(self.0 - other_val.0)); + if let Ok(other_val) = other.cast::() { + return Ok(Self(self.0 - other_val.get().0)); } let other_val = other.extract::<$type>()?; Ok(Self(self.0 - other_val)) @@ -79,8 +79,8 @@ macro_rules! float_variant { } pub fn __mul__(&self, other: &Bound<'_, PyAny>) -> PyResult { - if let Ok(other_val) = other.extract::() { - return Ok(Self(self.0 * other_val.0)); + if let Ok(other_val) = other.cast::() { + return Ok(Self(self.0 * other_val.get().0)); } let other_val = other.extract::<$type>()?; Ok(Self(self.0 * other_val)) @@ -196,6 +196,19 @@ macro_rules! float_variant { op: CompareOp, py: Python<'py>, ) -> PyResult> { + // Fast path: same type (cast avoids a discarded PyErr and a __float__ hop) + if let Ok(other_val) = other.cast::() { + let other_float = other_val.get().0; + let result = match op { + CompareOp::Eq => self.0 == other_float, + CompareOp::Ne => self.0 != other_float, + CompareOp::Lt => self.0 < other_float, + CompareOp::Le => self.0 <= other_float, + CompareOp::Gt => self.0 > other_float, + CompareOp::Ge => self.0 >= other_float, + }; + return Ok(PyBool::new(py, result).into_any()); + } // Try to convert other to our type first if let Ok(other_float) = other.extract::<$type>() { let result = match op { diff --git a/src/fable-library-py/src/ints.rs b/src/fable-library-py/src/ints.rs index 3ff41bb4aa..d408ebb593 100644 --- a/src/fable-library-py/src/ints.rs +++ b/src/fable-library-py/src/ints.rs @@ -476,6 +476,18 @@ macro_rules! integer_variant { /// Performs division with support for both integer and floating-point divisors. /// Returns zero division error for zero divisors. pub fn __truediv__(&self, other: &Bound<'_, PyAny>) -> PyResult<$name> { + // Fast path: same type — direct typed integer division. Avoids the + // __int__ hop of the OtherType extract, and the float-fallback + // precision loss for large/negative operands. + if let Ok(other_val) = other.cast::<$name>() { + let divisor = other_val.get().0; + if divisor == 0 { + return Err(PyErr::new::( + "Cannot divide by zero", + )); + } + return Ok($name(self.0 / divisor)); + } let value = other.extract::(); match value { Ok(OtherType::Int(value)) => { From f83ad0c230575ac141918c33d509147d9b64bb6b Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Fri, 10 Jul 2026 15:10:23 +0200 Subject: [PATCH 2/2] test(python): cover same-type numeric division fast path Add regressions for the wrapper / wrapper __truediv__ fast path added in the numeric wrappers: truncation toward zero (matching .NET), multiple widths, the ZeroDivisionError path, and a uint64 value exceeding i64::MAX whose exact result the old f64 fallback lost precision on. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fable-library-py/tests/test_ints.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/fable-library-py/tests/test_ints.py b/src/fable-library-py/tests/test_ints.py index fecf320c39..141154f329 100644 --- a/src/fable-library-py/tests/test_ints.py +++ b/src/fable-library-py/tests/test_ints.py @@ -1,6 +1,8 @@ from array import array from decimal import Decimal +import pytest + from fable_library.core import byte, int16, int32, int64, sbyte, uint16, uint32, uint64 from pydantic import BaseModel @@ -179,6 +181,24 @@ def test_divide(): assert byte(10) / 3.0 == 3.3333333333333335 +def test_divide_same_type(): + # Same-type division uses the typed integer fast path (truncates toward + # zero, matching .NET) rather than a float fallback. + assert int32(100) / int32(7) == 14 + assert int32(-100) / int32(7) == -14 + assert int32(100) / int32(-7) == -14 + assert int64(100) / int64(7) == 14 + assert byte(10) / byte(3) == 3 + # uint64 operand exceeding i64::MAX: the old OtherType path fell back to + # f64 division and lost precision; the typed path is exact. + assert uint64(18446744073709551615) / uint64(7) == 2635249153387078802 + + +def test_divide_same_type_by_zero(): + with pytest.raises(ZeroDivisionError): + int32(1) / int32(0) + + def test_hash(): assert hash(byte(42)) != 0 assert hash(sbyte(42)) != 0