Native core P2: CUDA whole-batch engine¶
This document preserves the P2 delivery history. In 0.6.1 the Python payload API was version 3, after P4 added capability metadata, and the C ABI was version 1. 0.7.0 raised them to C ABI 2 and payload API 4.
P2 moves the complete unpenalized Renewable Huber Newton update behind one PyO3 call. The existing Python estimator still owns input validation, pandas feature names, scikit-learn compatibility, public attributes, and portable checkpoints.
Implemented boundary¶
RenewableHuberRegressor
|
| one call per partial_fit
v
Rust/PyO3 NativeCudaEngine
|
| narrow C ABI, opaque handle
v
CUDA C++ engine
- persistent coefficient and information state
- persistent cuBLAS/cuSOLVER handles and workspaces
- fused residual/Huber/curvature and row-weight kernels
- GEMV/GEMM through cuBLAS
- pivoted LU solve and singular-system SVD fallback
The compatible transport remains contiguous host NumPy data. Raw features are
copied to the selected GPU once and the intercept column is appended in
reusable device workspace, avoiding a second full host-side design-matrix
copy. A separate DLPack hot path accepts C-contiguous CUDA float32/float64
tensors already resident on the engine device. PyO3 consumes each capsule with
the engine stream, validates dtype/device/shape/strides, and keeps the producer
allocation alive until the CUDA ABI completes its device-to-device copy. There
is no implicit CUDA-to-host fallback. Coefficients, the information matrix,
and reusable workspaces stay allocated on that device across partial_fit
calls.
The solver loop is native. Objective/convergence scalars still cross to the C++ host between iterations, but they use one pinned four-scalar buffer and paired synchronization. Update and portable state export share the final stream completion. A library-owned CUDA memory pool amortizes workspace allocation without changing the process-wide default allocator.
Cold engines initialize only the regular SPD/LU resources. gesvdjInfo,
singular values, U/V matrices, and the SVD work buffer are allocated lazily
after LU actually reports a singular system. This preserves the exact
minimum-norm fallback while removing unused SVD setup from ordinary
Cholesky-only fits. The rank-deficient golden case and a CUDA-DLPack
rank-deficient test both exercise this lazy path.
For DLPack input with an intercept, the append kernel reads the producer's X allocation directly instead of first staging an identical device copy. The target vector is safely aliased for the duration of the native call; capsule ownership and the final stream completion guarantee its lifetime. Host input continues to use owned engine workspace.
The original P2 extension reported C ABI version 1 and Python payload API
version 2; 0.6.1 reported C ABI 1 and payload API 3. The current extension
reports C ABI version 2 and payload API 4.
The base package checks both before creating an engine, so an
older or unrelated native module fails explicitly instead of reaching a
native method or result-dictionary mismatch later. Compatible builds
additionally advertise device_input="dlpack".
Numerical contract¶
The native engine implements the existing strict float32 and float64
unpenalized solver:
- frequency semantics for
sample_weight; - the paper bandwidth formula and Huber score/curvature;
- historical information and renewable batch-order semantics;
- monotone backtracking line search;
- relative coefficient convergence;
- finite least-squares-style handling of a rank-deficient Hessian;
- portable NumPy state returned at the estimator/checkpoint boundary.
The P2 engine did not implement the L1/LAMM loop, and an explicit
backend="native_cuda", penalty="l1" request raised a ValidationError. C ABI
2 / Python API 4 adds that loop; see docs/native-penalty-completion-plan.md.
An extension that does not advertise l1 in supported_penalties is still
refused before any native call, never silently changed to another engine.
Build on Windows¶
Requirements:
- Python 3.10-3.13 and Maturin;
- stable Rust with the MSVC target;
- Visual Studio 2022 C++ Build Tools;
- CMake and Ninja;
- CUDA Toolkit 12.x with cuBLAS and cuSOLVER;
- a compatible NVIDIA driver.
From a PowerShell prompt:
The build script enters the Visual Studio x64 environment, asks Maturin to
compile the cuda feature, and installs _renewable_huber_native_cuda; the
base package shim exposes it as renewable_huber._native_cuda. This is a
developer/source build; the base PyPI wheel continues to require neither Rust
nor CUDA.
Use¶
from renewable_huber import RenewableHuberRegressor
model = RenewableHuberRegressor(
backend="native_cuda",
device="cuda",
dtype="float32",
penalty="none",
)
model.partial_fit(X_batch, y_batch, sample_weight=batch_weights)
prediction = model.predict(X_test) # NumPy array in the host-input P2 adapter
backend="auto" never opts users into native CUDA. CPU auto-dispatch may
independently choose native CPU when its host-local performance gate passes.
One engine supports sequential calls from different Python threads; concurrent
mutation of one estimator is not supported. While partial_fit or predict
is running, callers must not mutate the same writable NumPy buffers from
another thread because the native call releases the GIL during the host-to-GPU
transfer and solve.
Verification¶
Run the CUDA differential tests after building:
The native test replays every unpenalized case in
tests/golden/native_core_v1.json, including weighted streaming, float32
outliers, and the rank-deficient ridge=0 case. It compares state,
diagnostics, and probe predictions against the frozen NumPy oracle.
The corpus remains unchanged; the CUDA differential test applies
rtol=1e-8, atol=2e-9 for float64, because vendor LU can cross the relative
convergence boundary one or two iterations later than NumPy/LAPACK while
reaching the same objective and information matrix. Iteration counts are not
part of the cross-library equality contract.
Run a comparable smoke benchmark:
python scripts/benchmarks/benchmark_shape_sweep.py `
--profile smoke --backend all --penalty none --dtype both `
--lifecycle cold --operation partial-fit `
--warmup 3 --repeats 9 --output artifacts/native-p2-cold-v2.json
# Capture reusable native/CuPy/NumPy streaming throughput separately.
python scripts/benchmarks/benchmark_shape_sweep.py `
--profile smoke --backend all --penalty none --dtype both `
--lifecycle steady --operation partial-fit `
--warmup 3 --repeats 9 --output artifacts/native-p2-steady-v2.json
Capture native whole-update Nsight Systems and Nsight Compute reports:
.\scripts\profiling\run_nsight_systems.ps1 `
-Python python -Engine native_cuda -Penalty none
.\scripts\profiling\run_nsight_compute.ps1 `
-Python python -Engine native_cuda -Penalty none
P2 measured baseline¶
The committed shape sweep
and Nsight Systems summary
were captured on an RTX 5070 Ti with CUDA Runtime 12.9. For three steady-state
float32 repeats, native host input measured:
| Shape | NumPy CPU | CuPy host input | Native host input |
|---|---|---|---|
| 100,000 x 90 | 77.5 ms | 30.4 ms | 36.0 ms |
| 16,384 x 256 | 2,288.6 ms | 81.4 ms | 47.0 ms |
| 1,000,000 x 32 | 208.2 ms | 71.9 ms | 82.4 ms |
This is a correctness-first native baseline, not a universal speedup claim.
It wins on the wide float32 case and the reference float64 case, but trails
CuPy for the float32 reference and long streaming cases. Small GPU workloads
remain slower than NumPy because launch and transfer overhead dominate.
The native figures are steady-state measurements: one engine is primed once and restored outside each timed repeat. The NumPy and CuPy entries construct a new estimator in every timed repeat. These policies are recorded per result in the JSON and the table must not be read as an initialization-cost comparison. It is a schema-v1 historical observation, not an eligible dispatch or regression-gate baseline. Schema v2 now applies the same cold or steady reset/initialization contract to every engine; see the native performance policy.
The profiled three-repeat reference window was 120.8 ms. It contained 396 device-to-host copies and 401 stream-wait synchronizations; those counts make device-side convergence and reduction results the next optimization target. CUDA API time, kernel time, and memcpy time overlap and must not be summed as wall-clock time.
The optimized engine now uses a device-pointer cuBLAS reduction handle so the
two objective scalars and the two convergence norms are each transferred and
synchronized as one pair. Exactly symmetric, positive-ridge information uses
cuSOLVER Cholesky; non-symmetric state and failed factorization retain the
full-matrix LU and minimum-norm SVD paths. Both solve paths validate cuSOLVER
device status. SVD metadata and workspaces are initialized only if LU confirms
a singular system, while a dedicated rank-deficient DLPack test preserves the
minimum-norm fallback. The PyO3 adapter also marks freshly allocated state
snapshots, allowing the Python backend to avoid a second p + p² host copy.
A GPU-loaded diagnostic run reduced the reference float32 native steady time from the historical 36.0 ms to 19.9 ms, but that run is intentionally not a publishable comparison: another graphics workload occupied the GPU.
The approved 2026-08-01 schema-v2 cold run used three warmups, nine measured
samples, and 0.5 seconds of fixed block work per sample for all standard
shapes, both dtypes, and both public operations. Native CUDA passed all 16
matched CuPy host-input comparisons and all 16 matched CuPy device-input
comparisons. Host speedup ranged from 1.04x to 1.96x (median 1.35x); DLPack
device speedup ranged from 1.06x to 2.04x (median 1.53x). Maximum Native
relative MAD was 3.62% for host input and 3.28% for device input, below the
unchanged 10% ceiling. The fixed-runner record is
p3-windows-rtx5070ti-native-cuda-v2.json.
The original single-call capture rejected two cold latency pairs because Windows/WDDM relative MAD exceeded 10%. The runner now uses fixed block sampling and isolates cyclic GC without trimming or retrying observations. Sampling policy is part of the comparison key, so that historical capture is not compared with the approved stabilized baseline.
The acceptance gate is correctness first. Performance results must record the GPU, driver, toolkit, shape, dtype, solver iterations, transfer policy, thread configuration, and BLAS provider; they are not portable headline numbers across machines. Native CUDA must pass the matched CuPy competitor parity gate under the same host/device transport before a calibration can recommend it.
P7 fixed-host breakdown (C ABI 2 / API 4)¶
On 2026-10-09 the current engine (f2dc7cb, unchanged at the profiled
4373a82) was profiled on the fixed RTX 5070 Ti host: driver 616.64, CUDA
12.9, CPython 3.11.0, NumPy 2.4.6, CuPy 14.2.0, Nsight Systems 2025.3.2, PCIe
5.0 x16. The desktop was in use; each capture's nvidia-smi compute clients
and top CPU processes were recorded before and after it. Each configuration
ran scripts/profiling/run_nsight_systems.ps1 -Engine native_cuda with the
shape sweep's standard shapes, two warmups and three resident-engine repeats
of the whole stream. --phase-ranges marks the estimator's prepare, update and
commit phases, and summarize_nsys_sqlite.py (schema 2) assigns every instant
of every partial_fit to exactly one class, so the classes add up to its wall
time. The summaries are committed as
benchmarks/baselines/p7-windows-rtx5070ti-nsys-<configuration>.json.
| Configuration | ms per stream | Iterations per batch | GPU compute | H2D | Launch and sync¹ | Python and binding (input validation) | Launches per iteration | Syncs per iteration | Line-search candidates per iteration | H2D API / DMA per batch |
|---|---|---|---|---|---|---|---|---|---|---|
| latency f32 none host | 2.83 | 5.00 | 16% | 2% | 79% | 4% (2%) | 56 | 2.8 | 2.20 | 0.038 / 0.007 ms |
| reference f32 none host | 11.36 | 3.75 | 24% | 14% | 44% | 18% (15%) | 52 | 2.6 | 2.00 | 0.390 / 0.337 ms |
| reference f32 none device | 9.28 | 3.75 | 30% | 0% | 56% | 14% (11%) | 52 | 2.6 | 2.00 | — |
| reference f64 none host | 37.44 | 3.75 | 70% | 8% | 14% | 8% (7%) | 50 | 2.5 | 1.87 | 0.769 / 0.703 ms |
| reference f32 l1 host | 18.98 | 8.00 | 15% | 8% | 66% | 11% (9%) | 45 | 2.3 | 2.06 | 0.380 / 0.329 ms |
| streaming f32 none host | 38.36 | 2.31 | 14% | 18% | 46% | 23% (19%) | 53 | 2.8 | 1.89 | 0.404 / 0.348 ms |
| wide f32 none host | 11.05 | 3.00 | 31% | 7% | 52% | 10% (8%) | 68 | 3.8 | 3.00 | 0.177 / 0.132 ms |
wide f32 none host, cuda_graphs=True |
8.53 | 3.00 | 31% | 10% | 45% | 14% (10%) | 34 | 3.8 | 3.00 | 0.185 / 0.138 ms |
¹ Includes device-to-host and device-to-device copies and memsets, which are 0.3%–1.2% in every configuration.
Nsight inflates API time: profiled latency costs 0.565 ms per iteration, the unprofiled p5 sweep about 0.47 ms. The shares are for prioritizing, not a prediction of the unprofiled speedup.
What the breakdown shows:
- Launch and sync latency dominates every
float32configuration (44%–79%). A Newton iteration issues 45–68 kernel launches at about 5 µs each under Nsight, 0.28–0.32 ms of API time, while the GPU sits idle between them. - The line search evaluates 1.9–3.0 candidates per iteration, and each costs
about 21 API calls and a stream synchronization. Two patterns produce
them. The first iteration of a stream starts from zero coefficients, and its
Newton step overshoots far: reference needs 8 step sizes, wide 11 and then
9, latency 4 and 4. In warm
float32batches the second iteration often backtracks one to five more times, because thefloat32cuBLASasumobjective cannot resolve the remaining decrease. Together the extra candidates are roughly 12%–28% of the wall time. - Pageable host-to-device copies are not the bottleneck they look like.
The API time per batch is only 13%–16% above the DMA time (23 GB/s against
27 GB/s on reference), so the driver's own staging is already efficient. H2D
is 14%–18% of host-input
float32time. float64is compute-bound. Kernels are 70% of its wall time; onefloat64Gram GEMM takes 980 µs.- The fixed cost of a
partial_fitcall is small. The Python core and the PyO3 entry before the first CUDA call take 0.011–0.033 ms, decoding the result 0.015–0.032 ms and committing it 0.009–0.018 ms. The final synchronization waits 2–3 µs, and the state export's device-to-host copy is within the under-1.2% "other copies" share. Input validation (np.isfiniteover the batch) is most of the Python column at 0.21–0.65 ms per batch; it is outside this work and only recorded. cuda_graphs=Truehalves the launches on wide and cuts its stream time by 23% under Nsight.
P7 optimizations: what was adopted, and what was not¶
The breakdown named four candidates. One was adopted.
Adopted: fused line-search rounds (1815ddc, a6f9fe6, 238df5b,
67d60bb). A round evaluates up to four Newton steps, or four LAMM
curvatures, in two kernels:
- the first forms the candidates and reduces their coefficient-space terms (history quadratic, step and coefficient norms, the L1 inner products);
- the second computes every candidate's residuals and Huber loss in one pass over X, and its last block folds all partial sums.
The host pays one parameter copy, two launches, one transfer and one synchronization per round instead of about 21 API calls and a synchronization per candidate, and accepts the first candidate in step order exactly as the sequential search did. The accepted candidate's residuals feed the next gradient directly.
Three refinements followed measurements:
- a full-width round made streaming
float6412.7% slower in the first cold A/B, becausefloat64is compute-bound there and Newton almost always accepts the full step. A Newton search now opens with the full step alone, unless it starts from an empty state or the previous iteration backtracked; LAMM opens with two curvatures. The width never changes a value; - Nsight then put the round's residual kernel at 290 µs per call on
streaming
float64, against 64 µs for the cuBLAS GEMV it replaced: with a warp per row, the shuffle tree spends a warp-widefloat64add per level and candidate. Up to 64 coefficients a thread now owns its row; the kernel takes 40 µs there; - the loss sum takes absolute values like the
asumit replaced, which only matters for a negative weight passed through the raw C ABI.
cuda_graphs=True now captures the round, so the graph path stays
bit-identical to the stream path; Nsight needs --cuda-graph-trace=node to
import such a capture, and run_nsight_systems.ps1 passes it.
Numerics. Every sum across rows or coefficients accumulates in double in
a fixed order over a grid that depends only on the batch shape, so repeated
runs are bit-identical and a candidate's value never depends on which round it
shared. Against main the results change at rounding level, because the
objective no longer comes from float32 cuBLAS reductions. On the 16 standard
workloads float64 iteration counts are unchanged and coefficients agree to
about 1e-7 relative or better; float32 unpenalized counts are unchanged except wide,
one iteration fewer. float32 L1 counts move, as they already did between
engines: float32 at tol=1e-6 sits at the rounding floor. Over 20 dataset
seeds per shape the mean change in total iterations is −0.3 (latency), +0.2
(reference), +1.5 (wide) and +4.9 (streaming, 69.2 → 74.0; NumPy needs 67.6).
Accumulating the narrow rows' dot products in double was tried and made
streaming worse (+10.2), so it was reverted.
Final A/B against main (f2dc7cb → 5e7a45d, frozen plan,
--rounds 9 --minimum-sample-seconds 1.0, standard profile, both penalties,
both dtypes, host and device input):
| Lifecycle | Speedup range | Median | Cases > 10% faster | Cases slower |
|---|---|---|---|---|
| cold | 1.00x–2.34x | 1.58x | 29 of 32 | 0 |
| steady | 1.01x–4.30x | 1.69x | 30 of 32 | 0 |
Per shape, the cold medians are latency 1.61x, reference 1.25x, wide 1.90x
and streaming 1.40x; the steady medians are latency 2.23x, reference 1.26x, wide
1.87x and streaming 1.46x. Native CUDA stays faster than CuPy under
the same transport in every case, and no case is slower than main.
Neither strict gate (gate.json) passes. In cold the only failing check is
the iteration difference of the four float32 L1 cases below, above the
one-iteration limit. Steady fails the same four, plus relative MAD above 10%
in three cases: two on the baseline side (latency f64 L1 host 19.1%,
streaming f32 unpenalized host 14.6%) and one on the candidate side (streaming
f32 L1 device 12.5%). A steady recapture with a larger repetition cap was
started and stopped on the maintainer's instruction, so those three stand as
measured.
On the maintainer's instruction the iteration limit was then relaxed for this
A/B only: gate-max-iteration-delta-11.json beside each strict gate recomputes
it from the same merged records with max_iteration_delta=11, the smallest
value covering the observed differences, and every other threshold at the
runner default. Nothing was remeasured and the runner's default stays 1. The
relaxed cold gate passes; the relaxed steady gate fails only the three MAD
cases. The time per iteration in the four cases is:
| Case | Iterations main → candidate | Cold ms per iteration | Steady ms per iteration |
|---|---|---|---|
| streaming f32 L1, device input | 52 → 63 | 0.781 → 0.275 | 0.609 → 0.252 |
| streaming f32 L1, host input | 52 → 63 | 0.961 → 0.434 | 0.797 → 0.385 |
| wide f32 L1, device input | 49 → 51 | 0.549 → 0.260 | 0.432 → 0.178 |
| wide f32 L1, host input | 49 → 51 | 0.553 → 0.240 | 0.458 → 0.193 |
After the change, the same Nsight configurations
(p7-windows-rtx5070ti-nsys-after-<configuration>.json) show:
| Configuration | ms per batch before → after | Launches per iteration | Launch and sync share |
|---|---|---|---|
| latency f32 none host | 2.83 → 1.22 | 56 → 21 | 78% → 63% |
| reference f32 none host | 2.84 → 2.51 | 52 → 23 | 43% → 31% |
| reference f32 none device | 2.32 → 1.97 | 52 → 23 | 55% → 43% |
| reference f64 none host | 9.36 → 9.77 | 50 → 22 | 14% → 15% |
| reference f32 l1 host | 4.75 → 2.64 | 45 → 9 | 65% → 38% |
| streaming f32 none host | 2.40 → 1.92 | 53 → 25 | 45% → 36% |
| wide f32 none host | 2.76 → 2.07 | 68 → 24 | 51% → 35% |
| wide f32 none host, graphs | 2.13 → 2.02 | 34 → 23 | 44% → 31% |
Not adopted:
- Pinned staging for host input (D). Pageable copies already run within
13%–16% of their DMA time, so a pinned buffer could save about 0.05 ms per
reference batch, and filling it costs a host
memcpyof the same size. Not implemented. - Fusing the remaining small kernels (C). After the rounds, an iteration
issues about 21 calls, most of them inside cuSOLVER
potrf/potrsand cuBLAS. The fusions that keep results bit-identical (Hessian assembly with its factor copy, the gradient-delta subtraction) remove three or four, an estimated 8% of a latency iteration at most, below the 10% acceptance threshold. Not implemented. - A whole-iteration CUDA Graph (A). After the rounds, wide with
cuda_graphs=Truemeasures 2.02 ms per batch against 2.07 ms without: the graph's advantage was the candidate DAG the rounds now fuse. Capturing the rest would mean capturing cuSOLVER for an opt-in path only. Not implemented.
Rollback and lifetime rules¶
- Destruction of
NativeCudaEnginereleases device allocations and CUDA library handles exactly once. - Python exceptions are raised from stable status codes and engine-owned error text; C++ exceptions and Rust panics do not cross the ABI.
- A failed first update does not mark the Python estimator as fitted; a native
initialization failure is reported as
BackendUnavailableError. - Loading a checkpoint creates a fresh engine and restores the portable state before prediction or the next update.
- Failure to import or initialize an explicitly requested native backend is a
BackendUnavailableError, not an implicit CuPy/NumPy fallback.