From ca8fc4d5e679927132e9af2b7701e6990419d8d5 Mon Sep 17 00:00:00 2001 From: Yehor Smoliakov Date: Wed, 7 Oct 2026 23:27:15 +0300 Subject: [PATCH 1/2] perf: eliminate intermediate Rust strings in Python formatting --- python/benchmarks/string_formatting.py | 8 ++ python/tests/test_fasttime.py | 26 ++++++ src/lib.rs | 13 +++ src/python.rs | 110 +++++++++++++++++-------- tests/allocations.rs | 91 ++++++++++++++++++++ 5 files changed, 215 insertions(+), 33 deletions(-) create mode 100644 python/benchmarks/string_formatting.py create mode 100644 tests/allocations.rs diff --git a/python/benchmarks/string_formatting.py b/python/benchmarks/string_formatting.py new file mode 100644 index 0000000..701a312 --- /dev/null +++ b/python/benchmarks/string_formatting.py @@ -0,0 +1,8 @@ +"""Compare installed release wheels in separate virtual environments. + +Run with each environment's Python. Results are median nanoseconds per call, +including Python dispatch, over seven repetitions of 200,000 calls. +""" +import fasttime, timeit, statistics, json +values = {'date': fasttime.Date(2024,6,15), 'time': fasttime.Time(12,30,45,nanosecond=123456789), 'datetime': fasttime.DateTime.parse('2024-06-15T12:30:45.123456789Z'), 'offset': fasttime.OffsetDateTime.parse('2024-06-15T12:30:45.123456789+05:30'), 'duration': fasttime.Duration.nanoseconds(123456789), 'weekday':fasttime.Weekday.MONDAY} +print(json.dumps({f'{name}/{op.__name__}': round(statistics.median(timeit.repeat(lambda:op(value), number=200000, repeat=7))*1e9/200000,1) for name,value in values.items() for op in [str,repr]})) diff --git a/python/tests/test_fasttime.py b/python/tests/test_fasttime.py index 4764f34..3aefc1f 100644 --- a/python/tests/test_fasttime.py +++ b/python/tests/test_fasttime.py @@ -276,3 +276,29 @@ def test_round_trip_offset_datetime(): serialized = str(odt) assert original == serialized + + +@pytest.mark.parametrize("value, text, representation", [ + (fasttime.Weekday.MONDAY, "Monday", "Monday"), + (fasttime.Date(-2147483648, 1, 1), "-2147483648-01-01", + "Date(year=-2147483648, month=1, day=1)"), + (fasttime.Date(2147483647, 12, 31), "2147483647-12-31", + "Date(year=2147483647, month=12, day=31)"), + (fasttime.Time(23, 59, 59, nanosecond=999999999), "23:59:59.999999999", + "Time(hour=23, minute=59, second=59, nanosecond=999999999)"), + (fasttime.Duration.nanoseconds(-(2**127)), f"Duration({-(2**127)} ns)", + f"Duration.nanoseconds({-(2**127)})"), + (fasttime.Duration.nanoseconds(2**127-1), f"Duration({2**127-1} ns)", + f"Duration.nanoseconds({2**127-1})"), + (fasttime.DateTime.parse("2024-06-15T12:30:45.123456789Z"), + "2024-06-15T12:30:45.123456789Z", + "DateTime.parse('2024-06-15T12:30:45.123456789Z')"), + (fasttime.UtcOffset.from_seconds(-86399), "-23:59", + "UtcOffset.from_seconds(-86399)"), + (fasttime.OffsetDateTime.parse("2024-06-15T12:30:45.123456789+05:30"), + "2024-06-15T12:30:45.123456789+05:30", + "OffsetDateTime.parse('2024-06-15T12:30:45.123456789+05:30')"), +]) +def test_string_representations(value, text, representation): + assert str(value) == text + assert repr(value) == representation diff --git a/src/lib.rs b/src/lib.rs index f9e41d5..30cfe51 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,19 @@ //! - `OffsetDateTime`: `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|±HH:MM]` (RFC 3339 subset). //! - `DateTime::now_utc()` when the `std` feature is enabled. //! +//! ## Allocation behavior +//! +//! Rust parsing borrows the input string and returns inline `Copy` values; +//! calendar arithmetic and timestamp conversion do not allocate. `Display` +//! writes directly to the supplied formatter. Use `core::fmt::Write` with a +//! caller-owned buffer to format without allocating; `to_string()` and +//! `format!()` allocate their owned output. +//! +//! Python `str()` and `repr()` format through a fixed stack buffer, avoiding +//! an intermediate Rust `String`. The resulting Python Unicode object still +//! allocates and copies the formatted bytes. Python object construction and +//! exceptions are outside the allocation-free Rust API guarantee. +//! //! ## Python Bindings //! //! When built with the `python` feature, this crate provides Python bindings via `PyO3`. diff --git a/src/python.rs b/src/python.rs index 7eab42e..f479ca7 100644 --- a/src/python.rs +++ b/src/python.rs @@ -5,12 +5,44 @@ // backticks around Python type names would leak into `help()` output. #![allow(clippy::doc_markdown)] +use core::fmt::{self, Write}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::PyType; +use pyo3::types::{PyString, PyType}; use crate::{Date, DateTime, Duration, OffsetDateTime, Time, UtcOffset, Weekday as RustWeekday}; +// All built-in representations fit in 128 bytes, including i32 years and +// i128 durations. Keep formatting on the stack; only the returned Python +// Unicode object needs a heap allocation. +struct FormatBuffer { + bytes: [u8; 128], + len: usize, +} + +impl Write for FormatBuffer { + fn write_str(&mut self, value: &str) -> fmt::Result { + let end = self.len.checked_add(value.len()).ok_or(fmt::Error)?; + let output = self.bytes.get_mut(self.len..end).ok_or(fmt::Error)?; + output.copy_from_slice(value.as_bytes()); + self.len = end; + Ok(()) + } +} + +fn python_string<'py>(py: Python<'py>, args: fmt::Arguments<'_>) -> PyResult> { + let mut buffer = FormatBuffer { + bytes: [0; 128], + len: 0, + }; + buffer + .write_fmt(args) + .map_err(|_| PyValueError::new_err("Representation exceeds formatting buffer"))?; + let text = core::str::from_utf8(&buffer.bytes[..buffer.len]) + .map_err(|_| PyValueError::new_err("Invalid UTF-8 in representation"))?; + Ok(PyString::new(py, text)) +} + // ===== Weekday ===== #[pyclass(name = "Weekday", module = "fasttime", from_py_object)] @@ -40,12 +72,12 @@ impl PyWeekday { self.0.number_from_monday() } - fn __repr__(&self) -> String { - format!("{:?}", self.0) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{:?}", self.0)) } - fn __str__(&self) -> String { - format!("{:?}", self.0) + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{:?}", self.0)) } } @@ -163,14 +195,17 @@ impl PyDate { .map_err(|e| PyValueError::new_err(format!("Invalid date string: {e:?}"))) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!( - "Date(year={}, month={}, day={})", - self.0.year, self.0.month, self.0.day + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!( + "Date(year={}, month={}, day={})", + self.0.year, self.0.month, self.0.day + ), ) } @@ -277,14 +312,17 @@ impl PyTime { .map_err(|e| PyValueError::new_err(format!("Invalid time string: {e:?}"))) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!( - "Time(hour={}, minute={}, second={}, nanosecond={})", - self.0.hour, self.0.minute, self.0.second, self.0.nanosecond + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!( + "Time(hour={}, minute={}, second={}, nanosecond={})", + self.0.hour, self.0.minute, self.0.second, self.0.nanosecond + ), ) } @@ -369,12 +407,15 @@ impl PyDuration { PyDuration(-self.0) } - fn __str__(&self) -> String { - format!("Duration({} ns)", self.0.total_nanos()) + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("Duration({} ns)", self.0.total_nanos())) } - fn __repr__(&self) -> String { - format!("Duration.nanoseconds({})", self.0.total_nanos()) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!("Duration.nanoseconds({})", self.0.total_nanos()), + ) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { @@ -528,12 +569,12 @@ impl PyDateTime { }) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!("DateTime.parse('{}')", self.0) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("DateTime.parse('{}')", self.0)) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { @@ -620,12 +661,15 @@ impl PyUtcOffset { self.0.is_utc() } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!("UtcOffset.from_seconds({})", self.0.as_seconds()) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!("UtcOffset.from_seconds({})", self.0.as_seconds()), + ) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { @@ -785,12 +829,12 @@ impl PyOffsetDateTime { }) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!("OffsetDateTime.parse('{}')", self.0) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("OffsetDateTime.parse('{}')", self.0)) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { diff --git a/tests/allocations.rs b/tests/allocations.rs new file mode 100644 index 0000000..2c29d75 --- /dev/null +++ b/tests/allocations.rs @@ -0,0 +1,91 @@ +//! Guard the allocation-free Rust parsing, arithmetic and formatting paths. +use fasttime::{Date, DateTime, Duration, OffsetDateTime, Time}; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::fmt::{self, Write}; +use std::hint::black_box; + +thread_local! { + static COUNT: Cell> = const { Cell::new(None) }; +} + +struct CountingAllocator; + +fn record() { + let _ = COUNT.try_with(|count| { + if let Some(value) = count.get() { + count.set(Some(value + 1)); + } + }); +} + +// SAFETY: all operations delegate unchanged layouts and pointers to System. +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record(); + unsafe { System.alloc(layout) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + record(); + unsafe { System.alloc_zeroed(layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + record(); + unsafe { System.realloc(ptr, layout, size) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +struct Buffer([u8; 128], usize); +impl Write for Buffer { + fn write_str(&mut self, text: &str) -> fmt::Result { + let end = self.1 + text.len(); + self.0 + .get_mut(self.1..end) + .ok_or(fmt::Error)? + .copy_from_slice(text.as_bytes()); + self.1 = end; + Ok(()) + } +} + +#[test] +fn rust_hot_paths_do_not_allocate() { + // Positive control proves this thread's allocator instrumentation is active. + COUNT.with(|count| count.set(Some(0))); + let allocation = black_box(Box::new(42)); + assert!(COUNT.with(|count| count.replace(None).unwrap()) > 0); + drop(allocation); + + COUNT.with(|count| count.set(Some(0))); + for input in ["2024-06-15", "-2147483648-01-01", "2147483647-12-31"] { + let date = black_box(input).parse::().unwrap(); + let mut buffer = Buffer([0; 128], 0); + write!(buffer, "{date}").unwrap(); + black_box(buffer); + } + let time = black_box("12:30:45.123456789").parse::