modax-solvers 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modax/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Solver package initialization."""
2
+
3
+ import jax
4
+
5
+ jax.config.update("jax_enable_x64", True)
modax/_codegen.py ADDED
@@ -0,0 +1,37 @@
1
+ """Compile generated straight-line device source into a ``cuda.jit`` function.
2
+
3
+ Two parts of the Rodas5P kernel are emitted as source rather than written: the
4
+ sparse factorisation and triangular solves, with every slot a literal
5
+ ([`modax._sparse_direct`][]), and the Jacobian writer, with every colour's
6
+ seed row a literal (``modax.rodas5P``). Both are load-bearing -- see the
7
+ measurements in AGENTS.md -- and both need the same three things done to the
8
+ text they produce, which is what this module does once.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ import linecache
15
+
16
+ from numba_cuda_mlir import cuda
17
+
18
+ _SOURCES = itertools.count(1)
19
+
20
+
21
+ def compile_device_source(name: str, lines: list[str], namespace: dict | None = None):
22
+ """Exec ``lines`` defining ``name`` and return it as a device function.
23
+
24
+ The source is registered with ``linecache`` under a filename of its own, so
25
+ a numba typing error inside it points at the offending line rather than at
26
+ nothing, and the text stays readable from a debugger as
27
+ ``fn._generated_source``. ``namespace`` supplies whatever the generated body
28
+ refers to by name.
29
+ """
30
+ source = "\n".join(lines) + "\n"
31
+ filename = f"<modax generated {name} {next(_SOURCES)}>"
32
+ linecache.cache[filename] = (len(source), None, source.splitlines(True), filename)
33
+ scope: dict = {}
34
+ exec(compile(source, filename, "exec"), dict(namespace or {}), scope) # noqa: S102
35
+ generated = scope[name]
36
+ generated._generated_source = source
37
+ return cuda.jit(device=True)(generated)
modax/_jax_common.py ADDED
@@ -0,0 +1,111 @@
1
+ """Shared scaffolding for exposing the numba-cuda ensemble solvers to JAX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable
6
+
7
+ import jax
8
+ import jax.numpy as jnp
9
+ from jax.custom_batching import custom_vmap
10
+
11
+
12
+ def normalize_y0_params(y0, params):
13
+ """Broadcast ``y0`` / ``params`` to a consistent ``(N, …)`` ensemble layout.
14
+
15
+ Accepts either 1-D (``(n_vars,)`` / ``(n_params,)``) or 2-D
16
+ (``(N, n_vars)`` / ``(N, n_params)``) inputs and returns 2-D arrays with a
17
+ common leading axis, so every numba-cuda solver shares one calling
18
+ convention.
19
+ """
20
+ y0_arr = jnp.asarray(y0, dtype=jnp.float64)
21
+ params_arr = jnp.asarray(params, dtype=jnp.float64)
22
+
23
+ if y0_arr.ndim not in (1, 2) or params_arr.ndim not in (1, 2):
24
+ raise ValueError(
25
+ "y0 must have shape (n_vars,) or (N, n_vars) and params shape "
26
+ f"(n_params,) or (N, n_params); got y0.shape={y0_arr.shape} and "
27
+ f"params.shape={params_arr.shape}"
28
+ )
29
+ if y0_arr.ndim == 2:
30
+ n = y0_arr.shape[0]
31
+ if params_arr.ndim == 2 and params_arr.shape[0] != n:
32
+ raise ValueError(
33
+ "params must have shape (n_params,) or (N, n_params) when y0 has "
34
+ f"shape (N, n_vars); got y0.shape={y0_arr.shape} and "
35
+ f"params.shape={params_arr.shape}"
36
+ )
37
+ elif params_arr.ndim == 2:
38
+ n = params_arr.shape[0]
39
+ else:
40
+ n = 1
41
+
42
+ if y0_arr.ndim == 1:
43
+ y0_arr = jnp.broadcast_to(y0_arr, (n, y0_arr.shape[0]))
44
+ if params_arr.ndim == 1:
45
+ params_arr = jnp.broadcast_to(params_arr, (n, params_arr.shape[0]))
46
+ return y0_arr, params_arr, n, y0_arr.shape[1]
47
+
48
+
49
+ def _broadcast_for_vmap(arg, is_batched: bool, axis_size: int, name: str):
50
+ arr = jnp.asarray(arg)
51
+ if is_batched:
52
+ if arr.ndim != 2:
53
+ raise NotImplementedError(
54
+ f"vmap over an already-ensembled {name} is not supported; "
55
+ "call the solver with batched y0/params directly instead."
56
+ )
57
+ if arr.shape[0] != axis_size:
58
+ raise ValueError(
59
+ f"batched {name} has leading axis {arr.shape[0]}, expected {axis_size}"
60
+ )
61
+ return arr
62
+ if arr.ndim != 1:
63
+ raise NotImplementedError(
64
+ f"vmap with unbatched ensemble-shaped {name} is not supported; "
65
+ "call the solver with batched y0/params directly instead."
66
+ )
67
+ return jnp.broadcast_to(arr, (axis_size,) + arr.shape)
68
+
69
+
70
+ def make_custom_vmap_solver(solve_impl: Callable, *, return_stats: bool):
71
+ """Wrap a solver implementation so outer ``jax.vmap`` becomes one ensemble call.
72
+
73
+ ``solve_impl`` must accept ``(y0, t_span, params)`` and return the normal
74
+ public solver result for those arrays. The custom batching rule supports
75
+ vmapping scalar solves over ``y0`` and/or ``params`` and lowers that vmap to
76
+ a single native ensemble solve with a leading trajectory axis. Every stats
77
+ field the kernels emit is a per-trajectory counter, so the stats pytree
78
+ only needs a trailing solve axis added.
79
+ """
80
+
81
+ @custom_vmap
82
+ def _solve(y0, t_span, params):
83
+ return solve_impl(y0, t_span, params)
84
+
85
+ @_solve.def_vmap
86
+ def _solve_vmap(axis_size, in_batched, y0, t_span, params):
87
+ y0_batched, t_span_batched, params_batched = in_batched
88
+ if t_span_batched:
89
+ t_span_arr = jnp.asarray(t_span)
90
+ if t_span_arr.ndim != 2:
91
+ raise NotImplementedError(
92
+ "vmap over nested t_span values is not supported; use a shared "
93
+ "t_span and vmap over y0 and/or params, or call the solver directly."
94
+ )
95
+ # JAX can mark closed-over constant save times as batched inside
96
+ # a larger vmapped function. Treat that as a shared time grid.
97
+ t_span = t_span_arr[0]
98
+
99
+ y0_arr = _broadcast_for_vmap(y0, y0_batched, axis_size, "y0")
100
+ params_arr = _broadcast_for_vmap(params, params_batched, axis_size, "params")
101
+ result = solve_impl(y0_arr, t_span, params_arr)
102
+
103
+ if not return_stats:
104
+ return result[:, None, :, :], True
105
+
106
+ sol, stats = result
107
+ stats_out = jax.tree_util.tree_map(lambda x: x[:, None], stats)
108
+ stats_batched = jax.tree_util.tree_map(lambda _: True, stats_out)
109
+ return (sol[:, None, :, :], stats_out), (True, stats_batched)
110
+
111
+ return _solve
@@ -0,0 +1,371 @@
1
+ """JAX custom-call bridge for launching numba-cuda kernels.
2
+
3
+ The public surface in this module is intentionally small: compile a CUDA
4
+ kernel, register a typed XLA FFI launcher for it, and call it from JAX. The C++ FFI shim is built lazily into ``/tmp`` so the project can
5
+ keep using plain ``uv run python`` without a package build step.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import hashlib
12
+ import subprocess
13
+ import sysconfig
14
+ import tempfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Sequence
18
+
19
+ import jax
20
+ import numpy as np
21
+
22
+ _CAPSULE_NAME = b"xla._CUSTOM_CALL_TARGET"
23
+ _TARGET_NAME = "modax_numba_cuda_abi_launch"
24
+ _CUSTOM_CALL_API_VERSION = 4
25
+ _REGISTERED = False
26
+ _LOADED_LIB: ctypes.CDLL | None = None
27
+
28
+ ABI_ARRAY = 0
29
+ ABI_SCALAR_F64 = 1
30
+ ABI_SCALAR_I32 = 2
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CudaLaunch:
35
+ """Compiled CUDA kernel launch metadata for an XLA FFI call."""
36
+
37
+ function: int
38
+ grid: tuple[int, int, int]
39
+ block: tuple[int, int, int]
40
+ shared_mem: int = 0
41
+
42
+
43
+ def _as_3d(value: int | Sequence[int]) -> tuple[int, int, int]:
44
+ if isinstance(value, int):
45
+ return (int(value), 1, 1)
46
+ parts = tuple(int(v) for v in value)
47
+ if len(parts) == 1:
48
+ return (parts[0], 1, 1)
49
+ if len(parts) == 2:
50
+ return (parts[0], parts[1], 1)
51
+ if len(parts) == 3:
52
+ return parts
53
+ raise ValueError(f"launch dimensions must have rank 1, 2, or 3; got {value!r}")
54
+
55
+
56
+ def _pycapsule_new(ptr: int, name: bytes = _CAPSULE_NAME) -> object:
57
+ ctypes.pythonapi.PyCapsule_New.argtypes = [
58
+ ctypes.c_void_p,
59
+ ctypes.c_char_p,
60
+ ctypes.c_void_p,
61
+ ]
62
+ ctypes.pythonapi.PyCapsule_New.restype = ctypes.py_object
63
+ return ctypes.pythonapi.PyCapsule_New(ctypes.c_void_p(ptr), name, None)
64
+
65
+
66
+ def _source() -> str:
67
+ return r"""
68
+ #include <cstdint>
69
+ #include <dlfcn.h>
70
+ #include <mutex>
71
+ #include <string>
72
+ #include <vector>
73
+
74
+ #include "xla/ffi/api/ffi.h"
75
+
76
+ namespace ffi = xla::ffi;
77
+
78
+ using CuLaunchKernel = int (*)(void*, unsigned int, unsigned int, unsigned int,
79
+ unsigned int, unsigned int, unsigned int,
80
+ unsigned int, void*, void**, void**);
81
+
82
+ static CuLaunchKernel LoadCuLaunchKernel() {
83
+ static std::once_flag once;
84
+ static CuLaunchKernel fn = nullptr;
85
+ std::call_once(once, []() {
86
+ void* lib = dlopen("libcuda.so.1", RTLD_NOW | RTLD_LOCAL);
87
+ if (lib == nullptr) return;
88
+ fn = reinterpret_cast<CuLaunchKernel>(dlsym(lib, "cuLaunchKernel"));
89
+ });
90
+ return fn;
91
+ }
92
+
93
+ // numba-cuda-mlir lowers an array parameter to an MLIR MemRef descriptor,
94
+ // passed flattened as {allocated, aligned, offset, sizes..., strides...} --
95
+ // 3 + 2*rank kernel parameters. The offset and strides count ELEMENTS, unlike
96
+ // the byte strides of the numba-cuda array ABI this replaced (which also led
97
+ // with a meminfo/parent pair, for 5 + 2*rank parameters).
98
+ struct ArrayArg {
99
+ void* allocated = nullptr;
100
+ void* aligned = nullptr;
101
+ int64_t offset = 0;
102
+ std::vector<int64_t> sizes;
103
+ std::vector<int64_t> strides;
104
+ };
105
+
106
+ struct KernelArgStorage {
107
+ ArrayArg array;
108
+ double f64 = 0.0;
109
+ int32_t i32 = 0;
110
+ };
111
+
112
+ static void AddArrayParams(ffi::AnyBuffer buf, KernelArgStorage& storage,
113
+ std::vector<void*>& params) {
114
+ storage.array.allocated = buf.untyped_data();
115
+ storage.array.aligned = buf.untyped_data();
116
+ storage.array.offset = 0;
117
+ auto dims = buf.dimensions();
118
+ storage.array.sizes.assign(dims.begin(), dims.end());
119
+ storage.array.strides.resize(storage.array.sizes.size());
120
+ // Row-major element counts, so the innermost stride is 1.
121
+ int64_t stride = 1;
122
+ for (int64_t i = static_cast<int64_t>(storage.array.sizes.size()) - 1; i >= 0; --i) {
123
+ storage.array.strides[static_cast<size_t>(i)] = stride;
124
+ stride *= storage.array.sizes[static_cast<size_t>(i)];
125
+ }
126
+
127
+ params.push_back(&storage.array.allocated);
128
+ params.push_back(&storage.array.aligned);
129
+ params.push_back(&storage.array.offset);
130
+ for (int64_t& size : storage.array.sizes) params.push_back(&size);
131
+ for (int64_t& stride_value : storage.array.strides) params.push_back(&stride_value);
132
+ }
133
+
134
+ static ffi::Error AddBufferParam(ffi::AnyBuffer buf, int64_t kind,
135
+ KernelArgStorage& storage,
136
+ std::vector<void*>& params) {
137
+ if (kind != 0) {
138
+ return ffi::Error(ffi::ErrorCode::kInvalidArgument,
139
+ "unknown Numba CUDA ABI argument kind");
140
+ }
141
+ AddArrayParams(buf, storage, params);
142
+ return ffi::Error::Success();
143
+ }
144
+
145
+ static ffi::Error LaunchNumbaCudaAbi(
146
+ void* stream, int64_t function, int64_t grid_x, int64_t grid_y,
147
+ int64_t grid_z, int64_t block_x, int64_t block_y, int64_t block_z,
148
+ int64_t shared_mem, ffi::Span<const int64_t> arg_kinds,
149
+ ffi::Span<const double> scalar_f64_values,
150
+ ffi::Span<const int32_t> scalar_i32_values,
151
+ ffi::RemainingArgs args, ffi::RemainingRets rets) {
152
+ CuLaunchKernel cuLaunchKernel = LoadCuLaunchKernel();
153
+ if (cuLaunchKernel == nullptr) {
154
+ return ffi::Error(ffi::ErrorCode::kInternal,
155
+ "could not load cuLaunchKernel from libcuda.so.1");
156
+ }
157
+ std::vector<KernelArgStorage> storage(arg_kinds.size());
158
+ std::vector<void*> params;
159
+ params.reserve(arg_kinds.size() * 12);
160
+ size_t arg_idx = 0;
161
+ size_t ret_idx = 0;
162
+ size_t f64_idx = 0;
163
+ size_t i32_idx = 0;
164
+
165
+ for (size_t i = 0; i < arg_kinds.size(); ++i) {
166
+ const int64_t kind = arg_kinds[i];
167
+ if (kind == 1) {
168
+ if (f64_idx >= scalar_f64_values.size()) {
169
+ return ffi::Error(ffi::ErrorCode::kInvalidArgument,
170
+ "not enough f64 scalar values");
171
+ }
172
+ storage[i].f64 = scalar_f64_values[f64_idx++];
173
+ params.push_back(&storage[i].f64);
174
+ } else if (kind == 2) {
175
+ if (i32_idx >= scalar_i32_values.size()) {
176
+ return ffi::Error(ffi::ErrorCode::kInvalidArgument,
177
+ "not enough i32 scalar values");
178
+ }
179
+ storage[i].i32 = scalar_i32_values[i32_idx++];
180
+ params.push_back(&storage[i].i32);
181
+ } else if (arg_idx < args.size()) {
182
+ auto arg = args.get<ffi::AnyBuffer>(arg_idx++);
183
+ if (!arg.has_value()) return arg.error();
184
+ ffi::Error err = AddBufferParam(arg.value(), kind, storage[i], params);
185
+ if (!err.success()) return err;
186
+ } else {
187
+ auto ret = rets.get<ffi::AnyBuffer>(ret_idx++);
188
+ if (!ret.has_value()) return ret.error();
189
+ ffi::Error err = AddBufferParam(*ret.value(), kind, storage[i], params);
190
+ if (!err.success()) return err;
191
+ }
192
+ }
193
+ if (arg_idx != args.size() || ret_idx != rets.size()) {
194
+ return ffi::Error(ffi::ErrorCode::kInvalidArgument,
195
+ "kernel ABI kinds did not consume all buffers");
196
+ }
197
+
198
+ int err = cuLaunchKernel(reinterpret_cast<void*>(function),
199
+ static_cast<unsigned int>(grid_x),
200
+ static_cast<unsigned int>(grid_y),
201
+ static_cast<unsigned int>(grid_z),
202
+ static_cast<unsigned int>(block_x),
203
+ static_cast<unsigned int>(block_y),
204
+ static_cast<unsigned int>(block_z),
205
+ static_cast<unsigned int>(shared_mem),
206
+ stream, params.data(), nullptr);
207
+ if (err != 0) {
208
+ return ffi::Error(ffi::ErrorCode::kInternal,
209
+ "cuLaunchKernel failed with CUDA driver error " +
210
+ std::to_string(err));
211
+ }
212
+ return ffi::Error::Success();
213
+ }
214
+
215
+ XLA_FFI_DEFINE_HANDLER_SYMBOL(
216
+ modax_numba_cuda_abi_launch, LaunchNumbaCudaAbi,
217
+ ffi::Ffi::Bind()
218
+ .Ctx<ffi::PlatformStream<void*>>()
219
+ .Attr<int64_t>("function")
220
+ .Attr<int64_t>("grid_x")
221
+ .Attr<int64_t>("grid_y")
222
+ .Attr<int64_t>("grid_z")
223
+ .Attr<int64_t>("block_x")
224
+ .Attr<int64_t>("block_y")
225
+ .Attr<int64_t>("block_z")
226
+ .Attr<int64_t>("shared_mem")
227
+ .Attr<ffi::Span<const int64_t>>("arg_kinds")
228
+ .Attr<ffi::Span<const double>>("scalar_f64_values")
229
+ .Attr<ffi::Span<const int32_t>>("scalar_i32_values")
230
+ .RemainingArgs()
231
+ .RemainingRets());
232
+ """
233
+
234
+
235
+ def _build_bridge() -> Path:
236
+ include_dir = Path(jax.ffi.include_dir())
237
+ build_dir = Path(tempfile.gettempdir()) / "modax_jax_numba_cuda_bridge"
238
+ build_dir.mkdir(parents=True, exist_ok=True)
239
+ source = _source()
240
+ digest = hashlib.sha256(source.encode()).hexdigest()[:16]
241
+ src_path = build_dir / f"bridge-{digest}.cc"
242
+ so_path = build_dir / f"bridge-{digest}{sysconfig.get_config_var('EXT_SUFFIX')}"
243
+ if so_path.exists():
244
+ return so_path
245
+ src_path.write_text(source)
246
+ cmd = [
247
+ "g++",
248
+ "-std=c++17",
249
+ "-shared",
250
+ "-fPIC",
251
+ "-O2",
252
+ f"-I{include_dir}",
253
+ str(src_path),
254
+ "-ldl",
255
+ "-o",
256
+ str(so_path),
257
+ ]
258
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
259
+ return so_path
260
+
261
+
262
+ def register_target() -> None:
263
+ """Register the generic CUDA launcher with JAX once per process."""
264
+
265
+ global _LOADED_LIB, _REGISTERED
266
+ if _REGISTERED:
267
+ return
268
+ so_path = _build_bridge()
269
+ _LOADED_LIB = ctypes.CDLL(str(so_path))
270
+ symbol = getattr(_LOADED_LIB, _TARGET_NAME)
271
+ address = ctypes.cast(symbol, ctypes.c_void_p).value
272
+ # A symbol resolved out of a loaded library always has one; the
273
+ # `None` is what `c_void_p` carries for a null pointer.
274
+ assert address is not None
275
+ capsule = _pycapsule_new(address)
276
+ jax.ffi.register_ffi_target(_TARGET_NAME, capsule, platform="CUDA", api_version=1)
277
+ _REGISTERED = True
278
+
279
+
280
+ # Loaded modules are held for the process lifetime: the ``CUfunction`` handed to
281
+ # the FFI target stays valid only while its module is loaded, and these kernels
282
+ # live as long as the JAX primitives that launch them.
283
+ _LOADED_MODULES: list[Any] = []
284
+
285
+
286
+ def compile_kernel(kernel: Any, argtypes: Sequence[Any]) -> int:
287
+ """Compile a ``cuda.jit`` kernel and return its ``CUfunction`` pointer.
288
+
289
+ numba-cuda-mlir exposes no ``get_cufunc()`` -- its ``MLIRLibrary`` only
290
+ offers the textual IR. The linked cubin and the mangled entry name are on
291
+ the compile result's metadata instead, so load the module through the driver
292
+ and look the function up by name.
293
+ """
294
+
295
+ cres = kernel.compile(tuple(argtypes)).cres
296
+ cubin = cres.metadata["cubin"]
297
+ func_name = cres.metadata["func_name"]
298
+
299
+ libcuda = ctypes.CDLL("libcuda.so.1")
300
+ module = ctypes.c_void_p()
301
+ err = libcuda.cuModuleLoadData(ctypes.byref(module), ctypes.c_char_p(cubin))
302
+ if err != 0:
303
+ raise RuntimeError(f"cuModuleLoadData failed with CUDA driver error {err}")
304
+ _LOADED_MODULES.append(module)
305
+
306
+ function = ctypes.c_void_p()
307
+ err = libcuda.cuModuleGetFunction(
308
+ ctypes.byref(function), module, func_name.encode()
309
+ )
310
+ if err != 0:
311
+ raise RuntimeError(f"cuModuleGetFunction failed with CUDA driver error {err}")
312
+ if function.value is None:
313
+ raise RuntimeError("cuModuleGetFunction returned a null function pointer")
314
+ return int(function.value)
315
+
316
+
317
+ def make_launch(
318
+ kernel: Any,
319
+ argtypes: Sequence[Any],
320
+ *,
321
+ grid: int | Sequence[int],
322
+ block: int | Sequence[int],
323
+ shared_mem: int = 0,
324
+ ) -> CudaLaunch:
325
+ return CudaLaunch(
326
+ function=compile_kernel(kernel, argtypes),
327
+ grid=_as_3d(grid),
328
+ block=_as_3d(block),
329
+ shared_mem=int(shared_mem),
330
+ )
331
+
332
+
333
+ def ffi_abi_call(
334
+ launch: CudaLaunch,
335
+ inputs: Sequence[Any],
336
+ output_specs: Sequence[jax.ShapeDtypeStruct],
337
+ *,
338
+ input_kinds: Sequence[int],
339
+ scalar_f64_values: Sequence[float] = (),
340
+ scalar_i32_values: Sequence[int] = (),
341
+ ) -> tuple[Any, ...]:
342
+ """Launch a Numba CUDA kernel using Numba's normal array/scalar ABI.
343
+
344
+ Outputs are always arrays, so only the input kinds need spelling out.
345
+ """
346
+
347
+ register_target()
348
+ attrs = {
349
+ "function": np.int64(launch.function),
350
+ "grid_x": np.int64(launch.grid[0]),
351
+ "grid_y": np.int64(launch.grid[1]),
352
+ "grid_z": np.int64(launch.grid[2]),
353
+ "block_x": np.int64(launch.block[0]),
354
+ "block_y": np.int64(launch.block[1]),
355
+ "block_z": np.int64(launch.block[2]),
356
+ "shared_mem": np.int64(launch.shared_mem),
357
+ "arg_kinds": np.asarray(
358
+ tuple(input_kinds) + (ABI_ARRAY,) * len(output_specs), dtype=np.int64
359
+ ),
360
+ "scalar_f64_values": np.asarray(tuple(scalar_f64_values), dtype=np.float64),
361
+ "scalar_i32_values": np.asarray(tuple(scalar_i32_values), dtype=np.int32),
362
+ }
363
+ result = jax.ffi.ffi_call(
364
+ _TARGET_NAME,
365
+ tuple(output_specs),
366
+ has_side_effect=False,
367
+ custom_call_api_version=_CUSTOM_CALL_API_VERSION,
368
+ )(*inputs, **attrs)
369
+ if not isinstance(result, tuple):
370
+ return (result,)
371
+ return result