EKO Oxidation
Mentors: Felix Hekhorn and Juan Manuel Cruz Martinez
Project Link (NNPDF Collaboration) - EKO
The short version
EKO (Evolution Kernel Operators) is a Python library that solves the DGLAP equations used to extract Parton Distribution Functions from collider data. It precomputes solution operators independently of the actual PDF, which turns a repeated integro-differential problem into a one-off integration followed by fast linear algebra. That integration is expensive, and getting more expensive as EKO adds higher perturbative orders and QED effects, so the project has been “oxidizing”: moving the hot loop from pure Python into Rust, one piece at a time, while scipy and numba keep the rest running.
Coming in, the Rust port already existed in prototype form. My job was to turn it into a documented architecture, an interface that doesn’t leak memory and is quicker than the legacy code, a public C ABI and a PyO3 binding for consumers who aren’t eko itself, and a release pipeline that gets all of that onto PyPI, GitHub Releases, and crates.io. Along the way, I also explored simplifying the core call chain, benchmarked the attempt thoroughly, and documented the negative result to establish clear constraints and validate the existing architecture.
Mapping the architecture
The first task (#516) was to document what the current Python/Rust split actually does before changing any of it. PR #521 walked the user entry point (runner.solve) down through Operator and OperatorMatrixElement, the parallel loop over target x-grid points, the inner loop over source basis functions and flavour labels, and finally into scipy.integrate.quad itself, comparing the pure-Python/Numba quad_ker path against the Rust rust_quad_ker entry point.
A companion PR, #529, added performance.md alongside it: raw benchmark numbers and the commands used to produce them, kept separate from the narrative document so the two could be updated independently.
Publishing what already existed
While the two crates (ekore and eko) were already publishing successfully, there was a problem with eko reaching PyPI (#517). PR #522 fixed this by updating maturin.yml to call bump-versions.py and replacing the deprecated Maturin publish action with a modern build-and-upload step. I confirmed the pipeline end-to-end using a throwaway PyPI project (akshat-eko-rs-test).
Negative attempt to improve performance
This is the part of the summer worth being specific about, because it’s a negative result with real numbers behind it, not just an abandoned idea.
The architecture that ships today looks like this for the Rust-enabled path:
scipy.integrate.quad(LowLevelCallable(rust_quad_ker, &cfg), 0.5, 1-ε)
↓ scipy's C backend calls the function pointer directly, no Python overhead
rust_quad_ker(u, *args) [crates/eko/src/lib.rs]
↓
ekore (Rust) → anomalous dimensions
↓ callback
cb_quad_ker_qcd / cb_quad_ker_qed [Numba]
↓
kernels/singlet.py, non_singlet.py → evolution operator matrix
↓
f64 returned to scipy
scipy → Rust → Numba → Rust → scipy. Rust owns the entry point and the anomalous-dimension computation, then hands off to Numba for the evolution operator matrix, which hasn’t been ported yet.
While working on the kernel split, I tried inverting this: keep the LowLevelCallable entry point in Numba instead of Rust, using nb.cfunc, and call into Rust only for the anomalous dimensions via ctypes:
scipy.integrate.quad(LowLevelCallable(quad_ker_llc, &cfg), 0.5, 1-ε)
↓
quad_ker_llc(u, *args) [nb.cfunc]
↓ ctypes call into Rust
ekors.qcd_gamma_singlet / qcd_gamma_ns
↓
ekore (Rust) → anomalous dimensions
↓ back to Numba
kernels/singlet.py, non_singlet.py → evolution operator matrix
↓
f64 returned to scipy
After struggling with complex ctypes type coercion across the boundary, I used a #[pyfunction] stand-in to benchmark the approach (poe lha -m nnlo and sv).
The results were stark compared to the master branch (rs → nb → rs):
| Metric | Master | Inverted (nb → rs → nb) |
Ratio |
|---|---|---|---|
| Wall clock | 11:16 | 20:24 | ~1.8× worse |
| Peak RSS | 616 MB | 1953 MB | ~3.2× worse |
Cost per quad call |
~1.5 ms | ~5 ms | ~3.3× worse |
Both regressions stemmed from Numba’s compilation model:
- Memory:
ctypespointers prevent the use ofcache=True. Instead of writing artifacts to disk and releasing them, Numba was forced to keep the entire compiled call graph resident in memory. - Time: The inverted path pays a heavy Numba-to-Rust transition tax on every
quadnode evaluation. In contrast, the master path does the expensive setup in Rust exactly once beforehand.
Because the Numba → Rust pattern proved unviable for this workload, the PR was not merged. However, the reasoning and metrics were permanently documented in architecture.md for future reference.
ekore_capi: a stable C-ABI
To make ekore accessible to standard high-energy physics tooling (C, C++, and eventually Fortran) (Issue #519), PR #537 introduced the ekore_capi crate. Before exposing this C interface, several prerequisite PRs were merged to harden the Rust codebase. This groundwork included ensuring benchmarks ran in --release mode, removing hidden heap allocations in favor of fixed caller-provided buffers, strictly scoping visibility (pub(crate)), centralizing workspace dependencies, updating MSRV, and bolstering unit tests and literature citations.
The ekore_capi crate relies on a few key FFI conventions:
- Safe Layouts: Complex numbers cross the boundary as a
#[repr(C)] ComplexF64struct to avoid relying on C99double complexlayouts. - Opaque Pointers: The Mellin-space harmonic cache is managed via
cache_newandcache_delete. - Caller Allocation: Computations use
<name>_result_lenhelpers so callers can safely pre-allocate correctly sized buffers. - Automation:
cargo-candcbindgenhandle header andpkg-configgeneration automatically.
Finally, PR #553 added install-capi.sh, a POSIX shell installer for pre-built binaries.
ekore_py: direct PyO3 bindings
While ekore_capi supports C, C++, and Fortran, Python users seeking only the standalone physics functions lacked direct access. PR #554 solved this by introducing ekore_py, which exposes ekore’s anomalous dimensions and operator matrix elements as native Python functions via PyO3 (packaged on PyPI as ekore-rs, imported as ekore_rs). This serves as a general-purpose physics library and is deliberately distinct from the internal eko-rs bridge package.
Most of the crate’s logic resides in macros.rs, which uses a single macro to generate the #[pyfunction] boilerplate for the various anomalous dimension and OME wrappers.
The libome exploration
The project’s task list required preparation for a third-party C++ contribution. To test this before any real external attempt, PR #562 evaluated whether ekore could successfully call out to an external C++ library. I wrote a mock C++ library (extras/gsoc/libome) to stand in for the real libome, compiled it via a Rust build.rs script using the cc crate, and consumed it from a new as3.rs file.
This demonstrated calling an external C++ library from ekore does not incur meaningful performance costs. It also demonstrated two legitimate ways to wire the connection. The first method is to vendor the real library as a submodule, build it with the cmake crate, and explicitly link its dependencies like GSL. The second method is to link directly against a pre-built .so file. In this latter case, Rust does not even need the C++ headers because rustc reads the function signatures directly from its own unsafe extern "C" declarations and #[repr(C)] structs.
However, integrating the real library is currently on pause. The real libome operates in x-space, while the ekore OME machinery operates in Mellin N-space. Bridging this gap requires a dedicated transform layer and help from a third party, which currently has an indefinite timescale. The mock integration code itself is fine as is, but rather than merging it while we wait, PR #562 was closed. We intentionally preserved the branch as a fully functional starting point for future developers once the third-party work is ready. Finally, PR #567 officially documented this mismatch in architecture.md so the constraint is recorded rather than rediscovered.
Fixing the release pipeline
The final stretch of the summer focused entirely on ensuring the release pipeline functioned correctly across all registries.
PR #556 fixed macOS specific C-ABI linking errors, removed static library generation, and enabled dynamic versioning so eko wheel names tracked the correct release. PR #561 standardized workflow names to a <category>-<target>.yml convention, restricted release triggers to semver tags, and added workflow_dispatch inputs for manual retries.
Testing these changes against real registries in PR #569 required several fixes before succeeding. eko failed due to a dynamic versioning bug, ekore-rs published without a description, and eko-rs failed due to a misdirected trusted publisher entry. Fixing these got all three packages live. During this process, Felix also pointed out a risk: while manual tag inputs help retry broken releases, they introduce the danger of accidentally attaching new code to an old tag.
To secure the pipeline against that risk, PR #573 added explicit tag validation, which does multiple checks to ensure the workflow_dispatch workflow is not overriding anything. It also closed out the remaining gaps by adding proper PyPI descriptions, bypassing dynamic versioning during manual workflows, and updating GitHub action versions across the board.
What got merged
| Contribution | PR | Status |
|---|---|---|
Architecture documentation (architecture.md) |
#521 | Merged |
| Fixed crate/package publishing (crates.io, PyPI) | #522 | Merged |
Inverted Numba → Rust → Numba call chain (benchmarked, rejected) |
#526 | Not merged, documented in architecture.md |
| Performance benchmarking document | #529 | Merged |
Split integration kernel from the Operator class |
#531 | Merged |
eko-rs workflow fix |
#540 | Merged |
--release build flag fix |
#541 | Merged |
| Removed mallocs, single-buffer integration | #542 | Merged |
Tightened ekore visibility, split constants.rs |
#543 | Merged |
| Centralised workspace dependencies | #544 | Merged |
| MSRV bump to 1.85.0 / Edition 2024 | #545 | Merged |
Tests for spacelike.rs |
#546 | Merged |
| Restored literature references | #550 | Merged |
ekore_capi crate addition |
#537 | Merged |
install-capi.sh installer |
#553 | Merged |
ekore_py crate addition, published as ekore-rs |
#554 | Merged |
| macOS release fixes, dynamic versioning | #556 | Merged |
Updated architecture.md with new crates |
#559 | Merged |
| Full GitHub workflow refactor | #561 | Merged |
libome external C++ interop prototype |
#562 | Not merged, branch kept as reference |
Documented libome mismatch in architecture.md |
#567 | Merged |
Live PyPI release fixes (eko, eko-rs, ekore-rs) |
#569 | Merged |
| Package descriptions, tag validation | #573 | Merged |
Left for later: Fortran test coverage for ekore_capi was explicitly postponed to #566. The C ABI supports it architecturally, but no automated Fortran suite exists yet.
Final Architecture
What I’d tell next year’s contributor
Benchmark architectural intuition. Inverting the Numba/Rust call chain seemed simpler but performed worse across all metrics: about 1.8× slower wall-clock time and over 3× worse peak memory and per-call cost. Catching this early with a poe lha benchmark prevented a permanent regression.
Numba’s caching model struggles with raw pointers. Both major bugs I encountered (the select_singlet_element typing error and the ctypes memory blowup) stemmed from unintuitive cache invalidation. When integrating FFI into a Numba codebase, test caching behavior in isolation first.
Scrutinize installer scripts like application code. The initial install-capi.sh draft contained classic POSIX traps: a read failure under a pipe, shell injection from unquoted paths, and unescaped & characters in sed. These vulnerabilities often hide perfectly within the happy path.
Document closed PRs. The libome PR was closed unmerged, but preserving the branch and formally documenting the domain mismatch in architecture.md ensures future developers will not have to rediscover the same dead end.
Thanks
A huge thank you to Felix Hekhorn and Juan Cruz-Martinez for bringing me onto this project and for their exceptional mentorship over the past few months. Their detailed feedback improved the code we shipped and profoundly shaped how I approach software design, performance profiling, and open-source development. I also want to express my sincere appreciation to the NNPDF team, the HEP Software Foundation, and GSoC team for making this summer project possible. I have learned an immense amount during this time, and I am deeply grateful for the experience and the opportunity to work alongside such talented developers. I am excited to stay involved and continue contributing to the repository well beyond the end of the program.