diff --git a/.gitignore b/.gitignore index ec44c9a..b3fd9bd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ _liburing.c _liburing.o _liburing.*.so _uringcore.*.so +_uringcore_liburing.*.so diff --git a/MANIFEST.in b/MANIFEST.in index f0f5dda..9dde61b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,7 @@ include _ffi_build.py include src/uringcore.c +include src/uringcore_liburing.c +include THIRD_PARTY_LICENSES/liburing-MIT.txt recursive-include docs *.md recursive-include uringloop *.py *.pyi include uringloop/py.typed diff --git a/THIRD_PARTY_LICENSES/liburing-MIT.txt b/THIRD_PARTY_LICENSES/liburing-MIT.txt new file mode 100644 index 0000000..c89bbf2 --- /dev/null +++ b/THIRD_PARTY_LICENSES/liburing-MIT.txt @@ -0,0 +1,21 @@ +liburing +Copyright 2020 Jens Axboe + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/phase1-static-liburing-spike.md b/docs/phase1-static-liburing-spike.md new file mode 100644 index 0000000..af0e71b --- /dev/null +++ b/docs/phase1-static-liburing-spike.md @@ -0,0 +1,33 @@ +# Phase 1 static-liburing ring spike + +This is the second native ring implementation spike required by Phase 1 of +the roadmap. It mirrors the lifecycle boundary of the raw-syscall +`_uringcore.Ring` with a separate `_uringcore_liburing.Ring` implemented +through the pinned liburing submodule. + +The extension links `libs/src/liburing.a` into the module. It therefore has +no runtime dependency on a system `liburing.so`. liburing is used under its +MIT license, whose notice is included in the package. + +Like the raw-syscall spike, this module owns ring initialization and teardown +but does not submit or reap operations and is not wired into the Python +proactor. The source checkout must configure and build the pinned submodule +before building this experimental extension; packaging the vendored sources +for standalone wheel builds remains part of the route decision. + +The decision record can now compare the two lifecycle implementations using +the same API and tests. Neither spike is the production backend until that +record selects a route. + +On the initial CPython 3.12 x86-64 development build, including debug +information, the module sizes are: + +| Route | Extension size | +| --- | ---: | +| Raw syscalls | 35,152 bytes | +| Static liburing | 109,840 bytes | + +The static module adds 74,688 bytes in this build. These are spike +measurements rather than release-wheel results; the decision record must +repeat them with the release build and record its compiler and strip +settings. diff --git a/pyproject.toml b/pyproject.toml index 02e8b57..9904a33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "An io_uring-based proactor event loop for asyncio" readme = "README.md" license = "MIT" -license-files = ["LICENSE"] +license-files = ["LICENSE", "THIRD_PARTY_LICENSES/*"] requires-python = ">=3.12" dependencies = [ "cffi>=1.17.1", diff --git a/setup.py b/setup.py index 0d83377..9e141a5 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,12 @@ Extension( "uringloop._uringcore", sources=["src/uringcore.c"], - ) + ), + Extension( + "uringloop._uringcore_liburing", + sources=["src/uringcore_liburing.c"], + include_dirs=["libs/src/include"], + extra_objects=["libs/src/liburing.a"], + ), ], ) diff --git a/src/uringcore_liburing.c b/src/uringcore_liburing.c new file mode 100644 index 0000000..79c1e3e --- /dev/null +++ b/src/uringcore_liburing.c @@ -0,0 +1,256 @@ +#define PY_SSIZE_T_CLEAN +#include +#include + +#include +#include +#include +#include +#include + +typedef struct { + PyObject_HEAD + struct io_uring ring; + int initialized; + unsigned int sq_entries; + unsigned int cq_entries; + unsigned int features; +} UringCoreLiburingRing; + +static void +uringcore_liburing_ring_close_resources(UringCoreLiburingRing *self) +{ + if (self->initialized) { + io_uring_queue_exit(&self->ring); + memset(&self->ring, 0, sizeof(self->ring)); + self->initialized = 0; + } +} + +static PyObject * +uringcore_liburing_ring_new( + PyTypeObject *type, + PyObject *Py_UNUSED(args), + PyObject *Py_UNUSED(kwargs)) +{ + return type->tp_alloc(type, 0); +} + +static int +uringcore_liburing_ring_init( + UringCoreLiburingRing *self, + PyObject *args, + PyObject *kwargs) +{ + static char *keyword_names[] = {"entries", NULL}; + PyObject *entries_object = NULL; + PyObject *entries_index = NULL; + unsigned long parsed_entries; + unsigned int entries = 256; + struct io_uring_params params; + int result; + + if (!PyArg_ParseTupleAndKeywords( + args, kwargs, "|O:Ring", keyword_names, &entries_object)) { + return -1; + } + if (entries_object != NULL) { + entries_index = PyNumber_Index(entries_object); + if (entries_index == NULL) { + return -1; + } + parsed_entries = PyLong_AsUnsignedLong(entries_index); + Py_DECREF(entries_index); + if (parsed_entries == (unsigned long)-1 && PyErr_Occurred()) { + PyErr_Clear(); + PyErr_Format( + PyExc_ValueError, + "entries must be between 1 and %u", + UINT_MAX); + return -1; + } + if (parsed_entries == 0 || parsed_entries > UINT_MAX) { + PyErr_Format( + PyExc_ValueError, + "entries must be between 1 and %u", + UINT_MAX); + return -1; + } + entries = (unsigned int)parsed_entries; + } + + uringcore_liburing_ring_close_resources(self); + memset(¶ms, 0, sizeof(params)); + + result = io_uring_queue_init_params(entries, &self->ring, ¶ms); + if (result < 0) { + errno = -result; + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + + self->initialized = 1; + self->sq_entries = params.sq_entries; + self->cq_entries = params.cq_entries; + self->features = params.features; + return 0; +} + +static void +uringcore_liburing_ring_dealloc(UringCoreLiburingRing *self) +{ + uringcore_liburing_ring_close_resources(self); + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static PyObject * +uringcore_liburing_ring_close( + UringCoreLiburingRing *self, + PyObject *Py_UNUSED(ignored)) +{ + uringcore_liburing_ring_close_resources(self); + Py_RETURN_NONE; +} + +static PyObject * +uringcore_liburing_ring_enter( + UringCoreLiburingRing *self, + PyObject *Py_UNUSED(ignored)) +{ + if (!self->initialized) { + PyErr_SetString(PyExc_RuntimeError, "ring is closed"); + return NULL; + } + return Py_NewRef(self); +} + +static PyObject * +uringcore_liburing_ring_exit( + UringCoreLiburingRing *self, + PyObject *Py_UNUSED(args)) +{ + uringcore_liburing_ring_close_resources(self); + Py_RETURN_FALSE; +} + +static PyObject * +uringcore_liburing_ring_get_closed( + UringCoreLiburingRing *self, + void *Py_UNUSED(context)) +{ + return PyBool_FromLong(!self->initialized); +} + +static PyMethodDef uringcore_liburing_ring_methods[] = { + { + "close", + (PyCFunction)uringcore_liburing_ring_close, + METH_NOARGS, + PyDoc_STR("Release the liburing ring resources."), + }, + { + "__enter__", + (PyCFunction)uringcore_liburing_ring_enter, + METH_NOARGS, + NULL, + }, + { + "__exit__", + (PyCFunction)uringcore_liburing_ring_exit, + METH_VARARGS, + NULL, + }, + {NULL, NULL, 0, NULL}, +}; + +static PyMemberDef uringcore_liburing_ring_members[] = { + { + "sq_entries", + T_UINT, + offsetof(UringCoreLiburingRing, sq_entries), + READONLY, + PyDoc_STR("Number of submission queue entries allocated by the kernel."), + }, + { + "cq_entries", + T_UINT, + offsetof(UringCoreLiburingRing, cq_entries), + READONLY, + PyDoc_STR("Number of completion queue entries allocated by the kernel."), + }, + { + "features", + T_UINT, + offsetof(UringCoreLiburingRing, features), + READONLY, + PyDoc_STR("Feature flags returned by io_uring_setup."), + }, + {NULL}, +}; + +static PyGetSetDef uringcore_liburing_ring_getset[] = { + { + "closed", + (getter)uringcore_liburing_ring_get_closed, + NULL, + PyDoc_STR("Whether the liburing ring resources have been released."), + NULL, + }, + {NULL}, +}; + +PyDoc_STRVAR( + uringcore_liburing_ring_doc, + "Ring(entries=256)\n" + "--\n" + "\n" + "Own an io_uring lifecycle through statically linked liburing."); + +static PyTypeObject UringCoreLiburingRingType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "uringloop._uringcore_liburing.Ring", + .tp_basicsize = sizeof(UringCoreLiburingRing), + .tp_dealloc = (destructor)uringcore_liburing_ring_dealloc, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = uringcore_liburing_ring_doc, + .tp_methods = uringcore_liburing_ring_methods, + .tp_members = uringcore_liburing_ring_members, + .tp_getset = uringcore_liburing_ring_getset, + .tp_init = (initproc)uringcore_liburing_ring_init, + .tp_new = uringcore_liburing_ring_new, +}; + +static PyModuleDef uringcore_liburing_module = { + PyModuleDef_HEAD_INIT, + .m_name = "_uringcore_liburing", + .m_doc = "Statically linked liburing ring primitives.", + .m_size = -1, +}; + +PyMODINIT_FUNC +PyInit__uringcore_liburing(void) +{ + PyObject *module; + + if (PyType_Ready(&UringCoreLiburingRingType) < 0) { + return NULL; + } + + module = PyModule_Create(&uringcore_liburing_module); + if (module == NULL) { + return NULL; + } + + if (PyModule_AddObjectRef( + module, + "Ring", + (PyObject *)&UringCoreLiburingRingType) < 0) { + Py_DECREF(module); + return NULL; + } + if (PyModule_AddIntConstant(module, "ABI_VERSION", 1) < 0) { + Py_DECREF(module); + return NULL; + } + return module; +} diff --git a/tests/unit/test_uringcore_liburing.py b/tests/unit/test_uringcore_liburing.py new file mode 100644 index 0000000..22b7f9a --- /dev/null +++ b/tests/unit/test_uringcore_liburing.py @@ -0,0 +1,36 @@ +import pytest + +from uringloop import _uringcore_liburing + + +def test_static_liburing_core_has_versioned_abi(): + assert _uringcore_liburing.ABI_VERSION == 1 + + +@pytest.mark.parametrize("entries", [-(2**32), -1, 0, 2**32, 2**64]) +def test_static_liburing_ring_rejects_out_of_range_queue_size(entries): + with pytest.raises(ValueError, match="entries must be between"): + _uringcore_liburing.Ring(entries) + + +def test_static_liburing_ring_owns_and_releases_kernel_resources(): + ring = _uringcore_liburing.Ring(8) + + assert ring.sq_entries >= 8 + assert ring.cq_entries >= ring.sq_entries + assert ring.closed is False + + ring.close() + assert ring.closed is True + + ring.close() + assert ring.closed is True + + +def test_static_liburing_ring_context_manager_closes_resources(): + with _uringcore_liburing.Ring(entries=8) as ring: + assert ring.closed is False + + assert ring.closed is True + with pytest.raises(RuntimeError, match="ring is closed"): + ring.__enter__() diff --git a/uringloop/_uringcore_liburing.pyi b/uringloop/_uringcore_liburing.pyi new file mode 100644 index 0000000..959461d --- /dev/null +++ b/uringloop/_uringcore_liburing.pyi @@ -0,0 +1,22 @@ +from typing import Final, Literal, Self + +ABI_VERSION: Final[int] + +class Ring: + def __init__(self, entries: int = 256) -> None: ... + @property + def sq_entries(self) -> int: ... + @property + def cq_entries(self) -> int: ... + @property + def features(self) -> int: ... + @property + def closed(self) -> bool: ... + def close(self) -> None: ... + def __enter__(self) -> Self: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> Literal[False]: ...