Skip to content

Commit 7471264

Browse files
authored
fix: prevent MilsteinGradFree OOM crash; make GPU test suite hardware-independent (#837)
* fix: prevent MilsteinGradFree OOM crash; make GPU test suite hardware-independent The Linux CI job (`pytest brainpy/`) was OOM-killed (exit 143) at `brainpy/integrators/sde/normal_coverage_test.py` [69%]. Root cause: the vector-Wiener branch of `MilsteinGradFree.step` used the full `(m, m)` diffusion-bar matrix together with `minus * jnp.sum(noise_p2, -1)`, which left the noise dimension in the integrated state. Each step grew the output by two axes — `()` -> `(m, m)` -> `(m, m, m, m)` -> ... — a multi-GB blow-up over a long integration that exhausted the runner's RAM (macOS/Windows runners had enough headroom to survive, so only Linux crashed). Take the diagonal of the diffusion-bar block and contract the per-component Milstein correction over the noise axis, mirroring the shape-preserving pattern already used by `Milstein`. The integrated state now stays correctly shaped and the full suite peaks at a bounded ~9 GB with the crash region flat (no spike). Also make the suite pass on GPU developer machines without changing CPU/CI behaviour: * conftest.py: pin `jax_default_matmul_precision='highest'`. On NVIDIA GPUs the default uses TF32 for float32 matmuls (~1e-4 relative error), which broke the operator-vs-dense correctness comparisons (JIT-connectivity layers, orthonormality checks) on GPU while passing on CPU. * brainpy/dnn/linear_test.py: use float32-appropriate tolerances (`rtol=1e-4, atol=1e-5`) for the JIT-operator vs dense `x @ conn` comparison; the default `atol=1e-8` is tighter than float32 rounding for the near-zero symmetric-uniform outputs. * brainpy/math/object_transform/object_transform_fixes_test.py: guard the `.cuda()` / `.tpu()` `RuntimeError` assertions on device availability so the test stays meaningful on CPU-only CI yet does not fail on GPU/TPU machines. * fix(deps): require braintools>=0.3.0 (0.2.0 was yanked from PyPI) braintools 0.2.0 was yanked from PyPI, so the previous `braintools>=0.2.0` pin became unsatisfiable and broke `pip install -r requirements.txt` on every CI runner (install failed in ~10s, before any test ran). 0.3.0 is the next released version and carries the surrogate / metric fixes that the `brainpy.math.surrogate` and L1-loss tests assert. Bump the pin in both requirements.txt and pyproject.toml. * refactor(math): reuse brainunit.math einops; drop the local port brainpy's ein_reduce / ein_rearrange / ein_repeat / ein_shape duplicated the einops implementation that now lives in brainunit.math (einreduce / einrearrange / einrepeat / einshape — behaviour-identical and accepting brainpy ``Array`` instances directly). Re-export the historical ``ein_*`` names as thin aliases of brainunit's and delete the duplicated implementation (einops.py, einops_parsing.py) and its dedicated tests (einops_test.py, einops_coverage_test.py, einops_parsing_test.py). The einops tests in math_compat_fixes_test.py now exercise the public ``bm.ein_*`` aliases and assert the re-export wiring. * chore(math): remove taichi/tifunc remnants The taichi backend is gone; drop the fully-skipped tifunc_test.py (a taichi ``ti.kernel`` test) and the stale ``method`` / Taichi paragraph in the csrmv docstring (that parameter no longer exists — csrmv dispatches through brainevent).
1 parent a1d96a2 commit 7471264

15 files changed

Lines changed: 127 additions & 1698 deletions

brainpy/dnn/linear_test.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,10 @@ def test_JitFPHomoLinear(self, prob, weight, shape):
150150
self.assertTrue(y.shape == shape + (200,))
151151

152152
conn_matrix = f.get_conn_matrix()
153-
self.assertTrue(bm.allclose(y, x @ conn_matrix.T))
153+
# float32-appropriate tolerances: the JIT operator and the dense ``x @ conn``
154+
# differ at float32 rounding level; the default ``atol=1e-8`` is tighter than that
155+
# for the symmetric-uniform layer whose outputs sit near zero.
156+
self.assertTrue(bm.allclose(y, x @ conn_matrix.T, rtol=1e-4, atol=1e-5))
154157
# print(conn_matrix.shape)
155158
# self.assertTrue(conn_matrix.shape == (200, 100))
156159

@@ -168,7 +171,10 @@ def test_JitFPUniformLinear(self, prob, w_low, w_high, shape):
168171
self.assertTrue(y.shape == shape + (200,))
169172

170173
conn_matrix = f.get_conn_matrix()
171-
self.assertTrue(bm.allclose(y, x @ conn_matrix.T))
174+
# float32-appropriate tolerances: the JIT operator and the dense ``x @ conn``
175+
# differ at float32 rounding level; the default ``atol=1e-8`` is tighter than that
176+
# for the symmetric-uniform layer whose outputs sit near zero.
177+
self.assertTrue(bm.allclose(y, x @ conn_matrix.T, rtol=1e-4, atol=1e-5))
172178

173179
@parameterized.product(
174180
prob=[0.1],
@@ -184,7 +190,10 @@ def test_JitFPNormalLinear(self, prob, w_mu, w_sigma, shape):
184190
self.assertTrue(y.shape == shape + (200,))
185191

186192
conn_matrix = f.get_conn_matrix()
187-
self.assertTrue(bm.allclose(y, x @ conn_matrix.T))
193+
# float32-appropriate tolerances: the JIT operator and the dense ``x @ conn``
194+
# differ at float32 rounding level; the default ``atol=1e-8`` is tighter than that
195+
# for the symmetric-uniform layer whose outputs sit near zero.
196+
self.assertTrue(bm.allclose(y, x @ conn_matrix.T, rtol=1e-4, atol=1e-5))
188197

189198
@parameterized.product(
190199
prob=[0.1],
@@ -202,7 +211,10 @@ def test_EventJitFPHomoLinear(self, prob, weight, shape):
202211
self.assertTrue(y2.shape == shape + (200,))
203212

204213
conn_matrix = f.get_conn_matrix()
205-
self.assertTrue(bm.allclose(y, x @ conn_matrix.T))
214+
# float32-appropriate tolerances: the JIT operator and the dense ``x @ conn``
215+
# differ at float32 rounding level; the default ``atol=1e-8`` is tighter than that
216+
# for the symmetric-uniform layer whose outputs sit near zero.
217+
self.assertTrue(bm.allclose(y, x @ conn_matrix.T, rtol=1e-4, atol=1e-5))
206218

207219
@parameterized.product(
208220
prob=[0.1],
@@ -221,7 +233,10 @@ def test_EventJitFPUniformLinear(self, prob, w_low, w_high, shape):
221233
self.assertTrue(y2.shape == shape + (200,))
222234

223235
conn_matrix = f.get_conn_matrix()
224-
self.assertTrue(bm.allclose(y, x @ conn_matrix.T))
236+
# float32-appropriate tolerances: the JIT operator and the dense ``x @ conn``
237+
# differ at float32 rounding level; the default ``atol=1e-8`` is tighter than that
238+
# for the symmetric-uniform layer whose outputs sit near zero.
239+
self.assertTrue(bm.allclose(y, x @ conn_matrix.T, rtol=1e-4, atol=1e-5))
225240

226241
@parameterized.product(
227242
prob=[0.1],
@@ -240,7 +255,10 @@ def test_EventJitFPNormalLinear(self, prob, w_mu, w_sigma, shape):
240255
self.assertTrue(y2.shape == shape + (200,))
241256

242257
conn_matrix = f.get_conn_matrix()
243-
self.assertTrue(bm.allclose(y, x @ conn_matrix.T))
258+
# float32-appropriate tolerances: the JIT operator and the dense ``x @ conn``
259+
# differ at float32 rounding level; the default ``atol=1e-8`` is tighter than that
260+
# for the symmetric-uniform layer whose outputs sit near zero.
261+
self.assertTrue(bm.allclose(y, x @ conn_matrix.T, rtol=1e-4, atol=1e-5))
244262

245263

246264
if __name__ == '__main__':

brainpy/integrators/sde/normal.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,10 +502,20 @@ def step(self, *args, **kwargs):
502502
else:
503503
integral += diffusions[key] * noise
504504
noise_p2 = (noise ** 2 - dt) if self.intg_type == constants.ITO_SDE else noise ** 2
505-
minus = (diffusion_bars[key] - diffusions[key]) / 2 / jnp.sqrt(dt)
506505
if self.wiener_type == constants.VECTOR_WIENER:
507-
integral += minus * jnp.sum(noise_p2, axis=-1)
506+
# ``y_bars[key]`` carries the noise axis (one support value per noise
507+
# component ``j``: ``y_bar_j = Y + f dt + g_j sqrt(dt)``), so
508+
# ``diffusion_bars[key]`` has a trailing ``(m, m)`` block whose diagonal is
509+
# ``g_j(y_bar_j)``. Previously the full ``(m, m)`` matrix was used together
510+
# with ``minus * jnp.sum(noise_p2, -1)``, which left the noise dimension in
511+
# the state and grew the output by two axes every step (a multi-GB blow-up
512+
# for long integrations). Take the diagonal and contract the per-component
513+
# Milstein correction over the noise axis, mirroring ``Milstein``.
514+
g_bar = jnp.diagonal(diffusion_bars[key], axis1=-2, axis2=-1)
515+
minus = (g_bar - diffusions[key]) / 2 / jnp.sqrt(dt)
516+
integral += jnp.sum(minus * noise_p2, axis=-1)
508517
else:
518+
minus = (diffusion_bars[key] - diffusions[key]) / 2 / jnp.sqrt(dt)
509519
integral += minus * noise_p2
510520
integrals.append(integral)
511521
return integrals if len(self.variables) > 1 else integrals[0]

brainpy/math/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@
6262
from .compat_tensorflow import *
6363
from .datatypes import *
6464
from .delayvars import *
65-
from .einops import *
65+
# einops-style helpers are reused from ``brainunit.math`` (the local port was
66+
# removed); keep the historical ``ein_*`` names as thin aliases.
67+
from brainunit.math import (
68+
einreduce as ein_reduce,
69+
einrearrange as ein_rearrange,
70+
einrepeat as ein_repeat,
71+
einshape as ein_shape,
72+
)
6673
from .environment import *
6774
from .interoperability import *
6875
# environment settings

0 commit comments

Comments
 (0)