diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml index 6c557a87a..79f19146d 100644 --- a/.github/workflows/testsuite.yml +++ b/.github/workflows/testsuite.yml @@ -112,6 +112,19 @@ jobs: PYTHON_BINARY: ${{ steps.python.outputs.python-path }} run: | "$PYTHON_BINARY" -m pip install .[test] + - name: Install cppyy (non-Windows, runtime only) + # Windows is skipped: CPyCppyy has no pre-built wheel for Python 3.14+ + # on Windows, and building from source fails with an ABI mismatch — + # the pre-built cppyy_backend-1.15.3 .lib is missing the symbol + # Cppyy::GetNumBasesLongestBranch that CPyCppyy 1.13.0 requires. + # This is an upstream cppyy issue; re-enable once fixed upstream. + # standalone:true is skipped: cppyy is a runtime JIT backend only. + # Soft-fail (|| echo) so a transient PyPI issue doesn't block CI. + if: ${{ runner.os != 'Windows' && !matrix.standalone }} + env: + PYTHON_BINARY: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_BINARY" -m pip install "cppyy>=3.1" || echo "cppyy install failed, skipping" - name: Determine Cython cache dir id: cython-cache @@ -143,6 +156,9 @@ jobs: FLOAT_DTYPE_32: ${{ matrix.float_dtype_32 }} PYTHON_BINARY: ${{ steps.python.outputs.python-path }} DO_NOT_RESET_PREFERENCES: true # Make sure that GSL setting is used + # cppyy's libCling.so on macOS links against a MacPorts zstd path; + # prepend both Homebrew locations (arm64 + Intel) so it resolves at runtime. + DYLD_LIBRARY_PATH: "/opt/homebrew/opt/zstd/lib:/usr/local/opt/zstd/lib" - name: Send coverage to Coveralls (parallel) if: ${{ startsWith(matrix.os.image, 'ubuntu-') && matrix.python-version == needs.get_python_versions.outputs.max-python }} uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 diff --git a/brian2/codegen/__init__.py b/brian2/codegen/__init__.py index 4f75b39f8..9971b932e 100644 --- a/brian2/codegen/__init__.py +++ b/brian2/codegen/__init__.py @@ -10,4 +10,4 @@ from . import _prefs from . import cpp_prefs as _cpp_prefs -__all__ = ["NumpyCodeObject", "CythonCodeObject"] +__all__ = ["NumpyCodeObject", "CythonCodeObject", "CppyyCodeObject"] diff --git a/brian2/codegen/_prefs.py b/brian2/codegen/_prefs.py index f2bb3fa19..d0103e711 100644 --- a/brian2/codegen/_prefs.py +++ b/brian2/codegen/_prefs.py @@ -22,9 +22,12 @@ Can be a string, in which case it should be one of: * ``'auto'`` the default, automatically chose the best code generation - target available. + target available. Priority order: cython > cppyy > numpy. * ``'cython'``, uses the Cython package to generate C++ code. Needs a working installation of Cython and a C++ compiler. + * ``'cppyy'``, uses cppyy for JIT compilation via LLVM/Cling. Needs + cppyy installed but no external C++ compiler. Provides fast in-memory + compilation without filesystem I/O. * ``'numpy'`` works on all platforms and doesn't need a C compiler but is often less efficient. diff --git a/brian2/codegen/generators/cppyy_generator.py b/brian2/codegen/generators/cppyy_generator.py new file mode 100644 index 000000000..5d89c1110 --- /dev/null +++ b/brian2/codegen/generators/cppyy_generator.py @@ -0,0 +1,332 @@ +""" +C++ code generator for the cppyy runtime target. + +Inherits CPPCodeGenerator's full translation pipeline (expressions, the +read→declare→execute→write phases, scalar hoisting, boolean optimization). +Overrides array naming and keyword generation so data arrives from Python +as function parameters rather than global C++ variables. +""" + +from __future__ import annotations + +import hashlib +import re +from typing import Any + +from brian2.codegen.generators.cpp_generator import ( + CPPCodeGenerator, + c_data_type, + stripped_deindented_lines, +) +from brian2.core.functions import DEFAULT_FUNCTIONS, Function +from brian2.core.variables import ( + ArrayVariable, + AuxiliaryVariable, + Constant, + DynamicArrayVariable, + Subexpression, +) + +# (c_type, param_name, namespace_key) +FunctionParam = tuple[str, str, str] + + +def _extract_primary_cpp_symbol(piece: str) -> str | None: + """ + Extract the primary C++ symbol name (function or variable) defined in a code piece. + + Only the FIRST non-comment, non-empty line is inspected so that identifiers + inside function bodies are never mistaken for the declaration symbol. + + Returns None when no identifiable symbol is found. + + Examples:: + + "static double* _namespaceta_values;" -> "_namespaceta_values" + "static inline double ta(..." -> "ta" + "static inline double _timedarray(... -> "_timedarray" + """ + for line in piece.split("\n"): + stripped = line.strip() + if not stripped or stripped.startswith("//") or stripped.startswith("/*"): + continue + # Collect all identifiers that appear immediately before '(', '[', or ';' + # on this first declaration line, then take the last one — it is the + # function/variable name, not the return-type keywords. + candidates = re.findall(r"\b(\w+)\s*(?=[(\[;])", stripped) + if candidates: + return candidates[-1] + return None + return None + + +def _cppyy_c_data_type(dtype: type | Any) -> str: + """ + Like c_data_type but remaps types for cppyy's buffer protocol. + + cppyy is strict about buffer types: + - numpy int8 maps to signed char (int8_t), not char + - numpy int64 maps to ``long`` on LP64 platforms (macOS, Linux 64-bit), + but ``int64_t`` is typedef'd to ``long long`` — cppyy's buffer protocol + only matches the canonical type, so we must use ``long`` directly. + """ + import struct + + ctype: str = c_data_type(dtype) + if ctype == "char": + return "int8_t" + # On LP64 platforms (sizeof(long)==8), cppyy maps numpy int64 buffers to + # ``long*`` but rejects ``int64_t*`` (== ``long long*``). + if ctype == "int64_t" and struct.calcsize("l") == 8: + return "long" + if ctype == "uint64_t" and struct.calcsize("l") == 8: + return "unsigned long" + return ctype + + +class CppyyCodeGenerator(CPPCodeGenerator): + """ + C++ code generator targeting cppyy's JIT runtime. + + All C++ translation logic (expressions, 4-phase pattern, etc.) is inherited. + We only change how arrays are named and how keywords/params are assembled. + """ + + class_name: str = "cppyy" + + @staticmethod + def get_array_name(var: ArrayVariable, access_data: bool = True) -> str: + """ + Globally unique name for an array variable. + + access_data=True → "_ptr_array_{owner}_{name}" (data pointer) + access_data=False → "_dynamic_array_{owner}_{name}" (container object) + """ + owner_name: str = getattr(var.owner, "name", "temporary") + + if isinstance(var, DynamicArrayVariable): + if access_data: + return f"_ptr_array_{owner_name}_{var.name}" + else: + return f"_dynamic_array_{owner_name}_{var.name}" + elif isinstance(var, ArrayVariable): + return f"_ptr_array_{owner_name}_{var.name}" + else: + raise TypeError( + f"get_array_name called with non-array variable: {type(var)}" + ) + + def determine_keywords(self) -> dict[str, Any]: + """ + Build template keywords: function params, support code, hash defines. + + This runs at the end of translate_statement_sequence(). The returned + dict gets merged with scalar_code/vector_code and passed to templates. + + We iterate sorted(self.variables.items()) — the code object's + _build_param_mapping does the same, so parameter order is guaranteed + to match between the signature and the call site. + """ + + support_code_parts: list[str] = [] + hash_define_parts: list[str] = [] + user_functions: list[Any] = [] + user_func_namespaces: dict[ + str, Any + ] = {} # for setting C++ globals post-compile + added: set[str] = set() + + function_params: list[FunctionParam] = [] + handled_pointers: set[str] = set() + + for varname, var in sorted(self.variables.items()): + if isinstance(var, (AuxiliaryVariable, Subexpression)): + continue + + # --- User functions (TimedArray, BinomialFunction, etc.) --- + if isinstance(var, Function): + if self.codeobj_class in var.implementations: + result: tuple | None = self._add_user_function(varname, var, added) + if result is not None: + hd, _pointers, sc, uf = result + hash_define_parts.extend(hd) + # Wrap each user-function support code piece in its own + # #ifndef guard so that Cling doesn't redeclare static + # symbols (e.g. _namespace_timedarray_values) when the + # same function is used across multiple code objects. + for piece in sc: + stripped = "\n".join( + line + for line in piece.split("\n") + if line.strip() and not line.strip().startswith("//") + ) + if stripped: + # Key the guard on the C++ symbol name so that + # Cling never tries to redefine the same symbol + # even when the body differs (e.g. a GC'd + # TimedArray's name is reused with different K/N + # in a later test). The data pointer is always + # refreshed by _set_user_func_globals after + # compilation, so skipping the redeclaration is + # safe as long as only one test runs at a time. + symbol = _extract_primary_cpp_symbol(piece) + if symbol: + guard = f"_BRIAN_CPPYY_SYM_{symbol}" + else: + h = hashlib.md5(stripped.encode()).hexdigest()[:16] + guard = f"_BRIAN_CPPYY_UF_{h}" + support_code_parts.append( + f"#ifndef {guard}\n#define {guard}\n{piece}\n" + f"#endif // {guard}" + ) + else: + support_code_parts.append(piece) + user_functions.extend(uf) + + # Grab namespace values (actual numpy arrays) for C++ globals + impl = var.implementations[self.codeobj_class] + func_ns: dict[str, Any] | None = impl.get_namespace(self.owner) + if func_ns: + user_func_namespaces.update(func_ns) + continue + + # --- Constants: scalar typed parameters --- + if isinstance(var, Constant): + c_type: str = _cppyy_c_data_type(type(var.value)) + function_params.append((c_type, varname, varname)) + continue + + # --- Array variables: pointer + size parameters --- + if isinstance(var, ArrayVariable): + pointer_name = self.get_array_name(var) + if pointer_name in handled_pointers: + continue + handled_pointers.add(pointer_name) + + if getattr(var, "ndim", 1) > 1: + # 2D dynamic arrays: pass the capsule instead of a data pointer, + # because monitors need to resize them. The C++ code extracts + # the DynamicArray2D* from the capsule and calls methods on it. + if isinstance(var, DynamicArrayVariable): + dyn_name = self.get_array_name(var, access_data=False) + capsule_key = f"{dyn_name}_capsule" + function_params.append(("PyObject*", capsule_key, capsule_key)) + continue + + c_type = _cppyy_c_data_type(var.dtype) + namespace_key = self.get_array_name(var) + function_params.append((f"{c_type}*", pointer_name, namespace_key)) + + if not var.scalar: + function_params.append(("int", f"_num{varname}", f"_num{varname}")) + + # For 1D dynamic arrays, ALSO pass the capsule so monitors can resize + if isinstance(var, DynamicArrayVariable): + dyn_name = self.get_array_name(var, access_data=False) + capsule_key = f"{dyn_name}_capsule" + function_params.append(("PyObject*", capsule_key, capsule_key)) + + # --- Object variables with capsule-like names (e.g. _queue_capsule) --- + # These are PyCapsule objects passed as PyObject* parameters. + for varname, var in sorted(self.variables.items()): + if varname.endswith("_capsule") and not isinstance( + var, + (ArrayVariable, Constant, Function, AuxiliaryVariable, Subexpression), + ): + if varname not in {p[1] for p in function_params}: + function_params.append(("PyObject*", varname, varname)) + + # group_get_indices: both _cond and _indices are AuxiliaryVariables only + # when the IndexWrapper.__getitem__ path in group.py creates the code + # object. Other templates (e.g. synapses_create_generator) also have + # _cond but not _indices. Require both to uniquely identify this template. + if isinstance(self.variables.get("_cond"), AuxiliaryVariable) and isinstance( + self.variables.get("_indices"), AuxiliaryVariable + ): + function_params.append(("int*", "_return_values_buf", "_return_values_buf")) + function_params.append(("int*", "_return_values_n", "_return_values_n")) + + # group_variable_get: _variable AuxiliaryVariable + _group_idx array present. + # C++ writes subexpression values per index into _output_buf. + if isinstance(self.variables.get("_variable"), AuxiliaryVariable) and ( + "_group_idx" in self.variables + and not isinstance(self.variables.get("_cond"), AuxiliaryVariable) + ): + var = self.variables["_variable"] + c_type = _cppyy_c_data_type(var.dtype) + function_params.append((f"{c_type}*", "_output_buf", "_output_buf")) + + # group_variable_get_conditional: _variable and _cond are AuxiliaryVariables, + # but _indices is NOT (unlike group_get_indices). + # C++ writes matching values into _output_buf and the count into _output_n[0]. + if ( + isinstance(self.variables.get("_variable"), AuxiliaryVariable) + and isinstance(self.variables.get("_cond"), AuxiliaryVariable) + and not isinstance(self.variables.get("_indices"), AuxiliaryVariable) + ): + var = self.variables["_variable"] + c_type = _cppyy_c_data_type(var.dtype) + function_params.append((f"{c_type}*", "_output_buf", "_output_buf")) + function_params.append(("int*", "_output_n", "_output_n")) + + # Optional denormals flushing (gcc/clang x86) + denormals_code: str = "" + if self.flush_denormals: + denormals_code = """ + #define CSR_FLUSH_TO_ZERO (1 << 15) + unsigned csr = __builtin_ia32_stmxcsr(); + csr |= CSR_FLUSH_TO_ZERO; + __builtin_ia32_ldmxcsr(csr); + """ + + return { + "support_code_lines": "\n".join( + stripped_deindented_lines("\n".join(support_code_parts)) + ), + "hashdefine_lines": "\n".join( + stripped_deindented_lines("\n".join(hash_define_parts)) + ), + "denormals_code_lines": "\n".join( + stripped_deindented_lines(denormals_code) + ), + "function_params": function_params, + "user_func_namespaces": user_func_namespaces, + "user_functions": user_functions, + } + + +# --- Function implementations --- +# +# We get sin/cos/exp/log/etc. for free via MRO (registered on CPPCodeGenerator). +# Same for arcsin→asin, int→int_, exprel, TimedArray, BinomialFunction. +# +# clip/sign/timestep/poisson/rand/randn are defined globally in _ensure_support_code() +# (cppyy_rt.py) so each code object emits no per-codeobject support code for them. +# This prevents redefinition errors when different code objects share the same +# function but differ in other support code (different hash → both would compile the +# function body without this guard). + +DEFAULT_FUNCTIONS["clip"].implementations.add_implementation( + CppyyCodeGenerator, code="", name="_clip" +) +DEFAULT_FUNCTIONS["sign"].implementations.add_implementation( + CppyyCodeGenerator, code="", name="_sign" +) +DEFAULT_FUNCTIONS["timestep"].implementations.add_implementation( + CppyyCodeGenerator, code="", name="_timestep" +) +DEFAULT_FUNCTIONS["poisson"].implementations.add_implementation( + CppyyCodeGenerator, code="", name="_poisson" +) +DEFAULT_FUNCTIONS["rand"].implementations.add_dynamic_implementation( + CppyyCodeGenerator, + code=lambda owner: {}, + namespace=lambda owner: {}, + name="_rand", +) +DEFAULT_FUNCTIONS["randn"].implementations.add_dynamic_implementation( + CppyyCodeGenerator, + code=lambda owner: {}, + namespace=lambda owner: {}, + name="_randn", +) diff --git a/brian2/codegen/runtime/__init__.py b/brian2/codegen/runtime/__init__.py index 361097246..96aeb6b95 100644 --- a/brian2/codegen/runtime/__init__.py +++ b/brian2/codegen/runtime/__init__.py @@ -2,7 +2,7 @@ Runtime targets for code generation. """ -# Register the base category before importing the indivial codegen targets with +# Register the base category before importing the individual codegen targets with # their subcategories from brian2.core.preferences import prefs @@ -15,12 +15,22 @@ logger = get_logger(__name__) +# Always available from .numpy_rt import * +# Optional: Cython (requires Cython + C++ compiler) try: from .cython_rt import * except ImportError: - pass # todo: raise a warning? + logger.debug("Cython runtime not available", exc_info=True) + +# Optional: cppyy (requires cppyy, no external compiler needed) +try: + from .cppyy_rt import * +except ImportError: + logger.debug("cppyy runtime not available", exc_info=True) + +# Optional: GSL integration try: from .GSLcython_rt import * except ImportError: diff --git a/brian2/codegen/runtime/cppyy_rt/__init__.py b/brian2/codegen/runtime/cppyy_rt/__init__.py new file mode 100644 index 000000000..b48bd2470 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/__init__.py @@ -0,0 +1,24 @@ +""" +cppyy Runtime Backend for Brian2. +""" + +from __future__ import annotations + +from brian2.utils.logger import get_logger + +logger = get_logger(__name__) + +try: + from brian2.codegen.runtime.cppyy_rt.cppyy_rt import CppyyCodeObject + from brian2.codegen.targets import codegen_targets + + # Register the target (same pattern as numpy_rt and cython_rt) + codegen_targets.add(CppyyCodeObject) + + __all__ = ["CppyyCodeObject"] + logger.debug("cppyy runtime backend registered") + +except ImportError as e: + logger.debug(f"cppyy runtime backend not available: {e}") + __all__ = [] + CppyyCodeObject = None diff --git a/brian2/codegen/runtime/cppyy_rt/cppyy_rt.py b/brian2/codegen/runtime/cppyy_rt/cppyy_rt.py new file mode 100644 index 000000000..b6c25c3a6 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/cppyy_rt.py @@ -0,0 +1,941 @@ +""" +cppyy runtime code object for Brian2. + +Each code block (before_run, run, after_run) becomes a C++ function JIT-compiled +by cppyy/Cling. Functions receive all data as typed parameters — numpy arrays get +passed as raw pointers with zero-copy via cppyy's buffer protocol. + +Three naming worlds need to stay in sync: + 1. RuntimeDevice: "_array_neurongroup_v" + 2. C++ params: "_ptr_array_neurongroup_v" + 3. C++ body: "_ptr_array_neurongroup_v[_idx]" + +(2) and (3) match automatically. We bridge (1)→(2) in variables_to_namespace(). +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import os +import re +from collections.abc import Callable +from typing import Any + +import numpy as np +from numpy.typing import NDArray + +from brian2.core.base import BrianObjectException +from brian2.core.functions import Function +from brian2.core.preferences import BrianPreference, prefs +from brian2.core.variables import ( + ArrayVariable, + AuxiliaryVariable, + Constant, + DynamicArrayVariable, + Subexpression, + Variable, +) +from brian2.utils.logger import get_logger + +from ...codeobject import check_compiler_kwds +from ...generators.cppyy_generator import CppyyCodeGenerator, _cppyy_c_data_type +from ...targets import codegen_targets +from ...templates import Templater +from ..numpy_rt import NumpyCodeObject + +__all__: list[str] = ["CppyyCodeObject"] + +logger = get_logger(__name__) + +# --- Type aliases --- +# (cpp_param_name, namespace_key, c_type_string) +ParamTuple = tuple[str, str, str] +# (namespace_key, callable that returns current value) +NonconstantEntry = tuple[str, Callable[[], Any]] + +# --- Preferences --- +prefs.register_preferences( + "codegen.runtime.cppyy", + "cppyy runtime codegen preferences", + extra_compile_args=BrianPreference( + default=[], + docs="Extra flags passed to cppyy/Cling, e.g. ['-O2', '-ffast-math'].", + ), + enable_introspection=BrianPreference( + default=False, + docs=""" + Enable runtime introspection of compiled C++ code. + + When True, all compiled code objects register with a global introspector + that allows viewing generated C++ source, parameter mappings, namespace + contents, and even replacing functions at runtime. + + Adds minor overhead (stores source strings, maintains registry), so + leave disabled for production runs. + + Usage: + prefs.codegen.runtime.cppyy.enable_introspection = True + from brian2.codegen.runtime.cppyy_rt.introspector import get_introspector + intro = get_introspector() + """, + ), +) + +# --- Lazy cppyy import --- +_cppyy: Any = None + + +def _guard_support_code(code: str) -> str: + """ + Wrap per-codeobject support code in #ifndef guards to prevent + Cling redefinition errors. + + When Brian2 calls run() multiple times, it recreates code objects that + generate identical support code (inline functions like _timestep, _rand). + Cling can't redefine symbols, but it dores preserve preprocessor state + across cppyy.cppdef() calls. So we wrap the support code in #ifndef + guards keyed by a content hash — if Cling already compiled this exact + block, the preprocessor skips it. + + The generated code has a predictable structure: + // Per-codeobject support code + [hash defines] + [inline function definitions] # this part gets guarded + // Template-specific support code + extern "C" void _brian_cppyy_...(...) { ... } + + We split at 'extern "C"', guard everything before it, and leave the + function definition (which has a unique name) unguarded. + """ + marker: str = 'extern "C"' + pos: int = code.find(marker) + if pos == -1: + # No function definition found — nothing to guard + return code + + support: str = code[:pos] + func_def: str = code[pos:] + + # Check if there's actual compilable code (not just comments/whitespace) + real_lines: list[str] = [ + line.strip() + for line in support.split("\n") + if line.strip() and not line.strip().startswith("//") + ] + if not real_lines: + # Only comments before extern "C" — no risk of redefinition + return code + + content_hash: str = hashlib.md5("\n".join(real_lines).encode()).hexdigest()[:16] + guard: str = f"_BRIAN_CPPYY_SC_{content_hash}" + + return f"#ifndef {guard}\n#define {guard}\n{support}#endif // {guard}\n\n{func_def}" + + +# Maps C++ user-function name -> content_hash of the first compiled body. +# Used by _rename_conflicting_user_functions to detect when the same C++ +# function name would be redefined with a different body in Cling. +_user_func_registry: dict[str, str] = {} + +# Regex matching #ifndef _BRIAN_CPPYY_SYM_XXX ... #endif blocks emitted +# by the generator for each user-function support code piece. +_SYM_BLOCK_RE = re.compile( + r"(#ifndef (_BRIAN_CPPYY_SYM_(\w+))\n" + r"#define _BRIAN_CPPYY_SYM_\3\n" + r"(.*?)" + r"#endif // _BRIAN_CPPYY_SYM_\3)", + re.DOTALL, +) + + +def _rename_conflicting_user_functions(code: str) -> tuple[str, dict[str, str]]: + """ + Ensure that user-function definitions (TimedArray, BinomialFunction, …) + in the per-codeobject support code never conflict with already-compiled + Cling symbols. + + Strategy: + - Scan for ``#ifndef _BRIAN_CPPYY_SYM_*`` guard blocks. + - For blocks that contain a C++ function definition (detected by ``{``), + compute a hash of the function body. + - If the C++ function name was previously compiled with a *different* body, + rename it to ``funcname_`` everywhere in this code string (both the + definition and every call site inside the ``extern "C"`` body). + - Also rename any associated ``_namespace__values`` global so that + cppyy does not reject re-assigning a different-sized buffer to the same + C++ pointer (cppyy tracks buffer sizes per global). + - Update all guard macros accordingly. + + Returns the modified code and a dict mapping original C++ global names to + renamed ones (used by ``_set_user_func_globals`` to write to the right symbol). + + This handles the case where Brian2 recycles a name like ``_timedarray`` after + GC (the previous TimedArray with 2 values is collected, then a new one with + 10 values reuses the name). Without renaming both would try to define the + same C++ symbol, causing a Cling ``redefinition`` error. + """ + global _user_func_registry + + renames: dict[str, str] = {} # original_func_name -> new_func_name + + for m in _SYM_BLOCK_RE.finditer(code): + cpp_symbol: str = m.group(3) # e.g. "_timedarray" + block_content: str = m.group(4).strip() + + # Only care about function definitions (have { in the content) + if "{" not in block_content: + continue + + content_hash: str = hashlib.md5(block_content.encode()).hexdigest()[:8] + + if cpp_symbol not in _user_func_registry: + _user_func_registry[cpp_symbol] = content_hash + elif _user_func_registry[cpp_symbol] != content_hash: + # Same C++ name, different body → rename to avoid redefinition + new_name = f"{cpp_symbol}_{content_hash}" + if new_name not in _user_func_registry: + _user_func_registry[new_name] = content_hash + renames[cpp_symbol] = new_name + + if not renames: + return code, {} + + # Maps original C++ global name → renamed global name. + # Returned so that _set_user_func_globals can target the correct symbol. + ns_global_renames: dict[str, str] = {} + + for old_name, new_name in renames.items(): + # 1. Rename call sites and function definitions. + # Negative lookbehind (?_values global. + # cppyy tracks the buffer size of each C++ global; reassigning to a + # different-sized array raises "buffer too large for value". Renaming + # the global gives each distinct function body its own C++ pointer so + # cppyy never sees a size mismatch. + # + # Convention (from _add_user_function / generate_cpp_code): + # C++ global = "_namespace" + ns_key where ns_key = "_values" + # e.g. _timedarray → _namespace_timedarray_values + old_ns_global = f"_namespace{old_name}_values" # _namespace_timedarray_values + new_ns_global = ( + f"_namespace{new_name}_values" # _namespace_timedarray_H2_values + ) + if old_ns_global in code: + code = code.replace(old_ns_global, new_ns_global) + ns_global_renames[old_ns_global] = new_ns_global + + return code, ns_global_renames + + +def _get_cppyy() -> Any: + """Import cppyy on first use so we don't blow up at import time.""" + global _cppyy + if _cppyy is None: + try: + import cppyy + + _cppyy = cppyy + except ImportError: + raise ImportError( + "cppyy is required for the cppyy runtime target. " + "Install it with: pip install cppyy" + ) from None + return _cppyy + + +# --- One-time support code init --- +_support_code_initialized: bool = False + +# --- Per-compilation unique counter (prevents Cling redefinition of extern "C" symbols) --- +_compile_counter: int = 0 + + +def _ensure_support_code() -> None: + """ + Define universal C++ helpers exactly once in cppyy's interpreter. + + Covers: The DynamicArray header from brianlib, standard headers, Brian2's _brian_mod/_brian_pow/etc., int_(), + and the shared MT19937 RNG engine. Guarded so repeated calls are no-ops. + """ + global _support_code_initialized + if _support_code_initialized: + return + + cppyy = _get_cppyy() + + # Add brianlib include path and load dynamic_array.h ── + # This makes DynamicArray1D and DynamicArray2D available to + # all subsequently compiled cppyy code. These are the SAME classes + # that the Cython DynamicArray wrappers use, so pointers are + # compatible across the two FFI boundaries. + import brian2 + + brianlib_path = os.path.join( + os.path.dirname(brian2.__file__), "devices", "cpp_standalone", "brianlib" + ) + cppyy.add_include_path(brianlib_path) + + # Also add the synapses directory for spikequeue.h + synapses_path = os.path.join(os.path.dirname(brian2.__file__), "synapses") + cppyy.add_include_path(synapses_path) + + # Include the header — Cling compiles it and knows the class layout. + # After this, cppyy C++ code can use DynamicArray1D*, etc. + cppyy.include("dynamic_array.h") + cppyy.include("spikequeue.h") + from brian2.codegen.generators.cpp_generator import _universal_support_code + + guarded_code: str = f""" + #ifndef _BRIAN2_CPPYY_SUPPORT_CODE + #define _BRIAN2_CPPYY_SUPPORT_CODE + + #include + #include + #include + #include + #include + #include + #include + #include + + #ifndef M_PI + #define M_PI 3.14159265358979323846 + #endif + + #ifndef INFINITY + #define INFINITY (std::numeric_limits::infinity()) + #endif + + // Brian2 universal support code: type promotion, _brian_mod, _brian_floordiv, etc. + {_universal_support_code} + + // int_() — stdint_compat.h may already define this (included by spikequeue.h) + #ifndef _BRIAN_STDINT_COMPAT_H + template + inline int32_t int_(T value) {{ return static_cast(value); }} + #endif + + // Shared RNG for rand/randn/poisson — external linkage so all Cling TUs share one instance + std::mt19937 _brian_cppyy_rng; + std::uniform_real_distribution _dist_rand(0.0, 1.0); + + // Marsaglia polar method state — serializable unlike std::normal_distribution + bool _brian_randn_has_spare = false; + double _brian_randn_spare = 0.0; + + // Seeding function callable from Python via cppyy.gbl._brian_cppyy_seed() + extern "C" void _brian_cppyy_seed(unsigned int seed) {{ + _brian_cppyy_rng.seed(seed); + _brian_randn_has_spare = false; + }} + extern "C" void _brian_cppyy_seed_random() {{ + std::random_device rd; + _brian_cppyy_rng.seed(rd()); + _brian_randn_has_spare = false; + }} + + // RNG state serialization for get/set_random_state() + extern "C" const char* _brian_cppyy_get_rng_state() {{ + std::ostringstream oss; + oss << _brian_cppyy_rng << " " << (int)_brian_randn_has_spare + << " " << std::setprecision(17) << _brian_randn_spare; + static std::string _rng_state_str; + _rng_state_str = oss.str(); + return _rng_state_str.c_str(); + }} + extern "C" void _brian_cppyy_set_rng_state(const char* state_cstr) {{ + std::istringstream iss(state_cstr); + int has_spare_int; + iss >> _brian_cppyy_rng >> has_spare_int >> _brian_randn_spare; + _brian_randn_has_spare = (bool)has_spare_int; + }} + + // ── Helper to extract a C++ pointer from a PyCapsule ── + // This is how we bridge Cython's DynamicArray objects to cppyy: + // Cython wraps the C++ pointer in a PyCapsule, Python passes the + // capsule to our function, and we unwrap it back to a C++ pointer. + #include + + template + DynamicArray1D* _extract_dynamic_array_1d(PyObject* capsule) {{ + void* ptr = PyCapsule_GetPointer(capsule, "DynamicArray1D"); + return static_cast*>(ptr); + }} + + template + DynamicArray2D* _extract_dynamic_array_2d(PyObject* capsule) {{ + void* ptr = PyCapsule_GetPointer(capsule, "DynamicArray2D"); + return static_cast*>(ptr); + }} + + // ── Helper to extract a CSpikeQueue from a PyCapsule ── + inline CSpikeQueue* _extract_spike_queue(PyObject* capsule) {{ + void* ptr = PyCapsule_GetPointer(capsule, "CSpikeQueue"); + return static_cast(ptr); + }} + + // ── Global inline helpers (shared across all code objects) ── + template + inline T _clip(T value, double a_min, double a_max) {{ + if (value < (T)a_min) return (T)a_min; + if (value > (T)a_max) return (T)a_max; + return value; + }} + + template + inline int _sign(T x) {{ + return (T(0) < x) - (x < T(0)); + }} + + inline int64_t _timestep(double t, double dt) {{ + return (int64_t)((t + 1e-3*dt)/dt); + }} + + inline int32_t _poisson(double lam, int _vectorisation_idx) {{ + std::poisson_distribution _poisson_dist(lam); + return _poisson_dist(_brian_cppyy_rng); + }} + + inline double _rand(const int _vectorisation_idx) {{ + return _dist_rand(_brian_cppyy_rng); + }} + + inline double _randn(const int _vectorisation_idx) {{ + if (_brian_randn_has_spare) {{ + _brian_randn_has_spare = false; + return _brian_randn_spare; + }} + double u, v, s; + do {{ + u = _dist_rand(_brian_cppyy_rng) * 2.0 - 1.0; + v = _dist_rand(_brian_cppyy_rng) * 2.0 - 1.0; + s = u * u + v * v; + }} while (s >= 1.0 || s == 0.0); + double factor = std::sqrt(-2.0 * std::log(s) / s); + _brian_randn_spare = v * factor; + _brian_randn_has_spare = true; + return u * factor; + }} + + #endif // _BRIAN2_CPPYY_SUPPORT_CODE + """ + cppyy.cppdef(guarded_code) + _support_code_initialized = True + + +def _make_func_name(codeobj_name: str, block: str) -> str: + """ + Build a deterministic C++ function name from code object + block name. + Must match the Jinja2 template logic in common_group.cpp. + """ + safe: str = codeobj_name.replace(".", "_").replace("*", "").replace("-", "_") + return f"_brian_cppyy_{block}_{safe}" + + +def _cppyy_constant_or_scalar(varname: str, variable: Variable) -> str: + """ + Like constant_or_scalar but uses _ptr_array_X naming to match our C++ params. + + The standard version produces "_array_X[0]" (device naming), but our + function signatures use "_ptr_array_X" (generator naming). + """ + if variable.array: + return f"{CppyyCodeGenerator.get_array_name(variable)}[0]" + else: + return f"{varname}" + + +class CppyyCodeObject(NumpyCodeObject): + """ + Code object that JIT-compiles C++ via cppyy/Cling. + + Inherits NumpyCodeObject's lifecycle but overrides namespace population + to set up _ptr_array_* and _num* entries that our C++ functions expect. + """ + + templater: Templater = Templater( + "brian2.codegen.runtime.cppyy_rt", + ".cpp", + env_globals={ + "c_data_type": _cppyy_c_data_type, + "constant_or_scalar": _cppyy_constant_or_scalar, + }, + ) + generator_class: type = CppyyCodeGenerator + class_name: str = "cppyy" + + def __init__( + self, + owner: Any, + code: Any, + variables: dict[str, Variable], + variable_indices: dict[str, str], + template_name: str, + template_source: str, + compiler_kwds: dict[str, Any], + name: str = "cppyy_code_object*", + ) -> None: + check_compiler_kwds(compiler_kwds, [], "cppyy") + super().__init__( + owner, + code, + variables, + variable_indices, + template_name, + template_source, + compiler_kwds={}, + name=name, + ) + # Populated in compile() — maps block → parameter metadata + self._param_mappings: dict[str, list[ParamTuple]] = {} + # Prevent GC of arrays whose pointers are held by C++ globals + self._namespace_refs: dict[str, NDArray[Any]] = {} + # Maps block → unique C++ function name (counter-suffixed to avoid Cling redefinition) + self._compiled_func_names: dict[str, str] = {} + + @classmethod + def is_available(cls) -> bool: + """Check if cppyy is installed without importing it.""" + return importlib.util.find_spec("cppyy") is not None + + # --- Namespace population --- + # + # We override entirely (not calling super) because NumpyCodeObject + # doesn't set _num* entries and uses device naming instead of generator naming. + + def variables_to_namespace(self) -> None: + """ + Fill self.namespace with everything the C++ functions need. + + Arrays go under generator naming (_ptr_array_*), sizes under _num*, + constants under their plain name, and Variable objects under _var_*. + """ + self.nonconstant_values: list[NonconstantEntry] = [] + + # Ensure _owner is available (needed for monitors in fallback path) + if "_owner" not in self.namespace: + self.namespace["_owner"] = self.owner + + for name, var in self.variables.items(): + if isinstance(var, Function): + self._insert_func_namespace(var) + continue + + if isinstance(var, (AuxiliaryVariable, Subexpression)): + continue + + # Try to get the value — some dummy Variables don't have one + try: + if not hasattr(var, "get_value"): + raise TypeError() + value: Any = var.get_value() + except (TypeError, AttributeError): + self.namespace[name] = var + continue + + if isinstance(var, ArrayVariable): + gen_name: str = self.generator_class.get_array_name(var) + self.namespace[gen_name] = value + self.namespace[f"_num{name}"] = var.get_len() + + # Scalar constants also get a plain-name entry with the unwrapped value + if var.scalar and var.constant: + self.namespace[name] = value.item() + else: + self.namespace[name] = value + + # ── Dynamic arrays: store BOTH the data view AND the capsule ── + # The data view (_ptr_array_*) gives C++ direct pointer access + # to the current data buffer, used in computation functions. + # The capsule (_capsule_*) gives C++ access to the DynamicArray + # C++ object itself, used in monitor functions that need resize. + if isinstance(var, DynamicArrayVariable): + dyn_array_name = self.generator_class.get_array_name( + var, access_data=False + ) + self.namespace[dyn_array_name] = self.device.get_value( + var, access_data=False + ) + + capsule_name = f"{dyn_array_name}_capsule" + try: + capsule = self.device.get_capsule(var) + self.namespace[capsule_name] = capsule + except (TypeError, AttributeError): + # Not all variables support capsules (e.g. plain arrays) + pass + + self.namespace[f"_var_{name}"] = var + + if isinstance(var, DynamicArrayVariable) and var.needs_reference_update: + gen_name = self.generator_class.get_array_name(var) + self.nonconstant_values.append((gen_name, var.get_value)) + self.nonconstant_values.append((f"_num{name}", var.get_len)) + + # group_get_indices: inject output buffers for the result. + # C++ fills _return_values_buf and writes the match count to + # _return_values_n[0]. run_block() reads them back as a return value. + if self.template_name == "group_get_indices": + N = int(self.namespace.get("N", 0)) + self.namespace["_return_values_buf"] = np.zeros(N, dtype=np.int32) + self.namespace["_return_values_n"] = np.zeros(1, dtype=np.int32) + + # group_variable_get: C++ writes subexpression values per index into _output_buf. + # Size = number of indices (_num_group_idx); dtype from _variable. + if self.template_name == "group_variable_get": + var = self.variables.get("_variable") + n = int(self.namespace.get("_num_group_idx", 0)) + dtype = var.dtype if var is not None else np.float64 + self.namespace["_output_buf"] = np.zeros(max(n, 1), dtype=dtype) + + # group_variable_get_conditional: C++ writes matching values into _output_buf + # and the count into _output_n[0]. Max size = N. + if self.template_name == "group_variable_get_conditional": + var = self.variables.get("_variable") + N = int(self.namespace.get("N", 0)) + dtype = var.dtype if var is not None else np.float64 + self.namespace["_output_buf"] = np.zeros(max(N, 1), dtype=dtype) + self.namespace["_output_n"] = np.zeros(1, dtype=np.int32) + + def update_namespace(self) -> None: + """Refresh data pointers/sizes for dynamic arrays that may have been resized.""" + for name, func in self.nonconstant_values: + self.namespace[name] = func() + + def _insert_func_namespace(self, func: Function) -> None: + """ + Pull in a function implementation's namespace (e.g. TimedArray data). + Most built-in functions have nothing to inject; this is a no-op for them. + """ + try: + impl = func.implementations[self.__class__] + except KeyError: + return + + func_namespace: dict[str, Any] | None = impl.get_namespace(self.owner) + if func_namespace is not None: + self.namespace.update(func_namespace) + + if impl.dependencies is not None: + for dep in impl.dependencies.values(): + self._insert_func_namespace(dep) + + # --- Parameter mapping --- + # + # Reconstructs the same param list the generator built in determine_keywords(). + # Both iterate sorted(self.variables.items()) with the same filtering, so order matches. + + def _build_param_mapping(self) -> list[ParamTuple]: + """ + Build the (cpp_param_name, namespace_key, c_type) list matching the + C++ function signature order. + + This MUST mirror the iteration logic in CppyyCodeGenerator.determine_keywords() + exactly — same sorted order, same filtering, same parameter additions — + otherwise the call-site args won't line up with the compiled signature. + """ + params: list[ParamTuple] = [] + handled_pointers: set[str] = set() + + for varname, var in sorted(self.variables.items()): + if isinstance(var, (AuxiliaryVariable, Subexpression)): + continue + if isinstance(var, Function): + continue + + if isinstance(var, Constant): + c_type: str = _cppyy_c_data_type(type(var.value)) + params.append((varname, varname, c_type)) + continue + + if isinstance(var, ArrayVariable): + pointer_name: str = self.generator_class.get_array_name(var) + if pointer_name in handled_pointers: + continue + handled_pointers.add(pointer_name) + + if getattr(var, "ndim", 1) > 1: + # 2D dynamic arrays: pass capsule only (no data pointer). + # Mirrors determine_keywords() which does the same. + if isinstance(var, DynamicArrayVariable): + dyn_name = self.generator_class.get_array_name( + var, access_data=False + ) + capsule_key = f"{dyn_name}_capsule" + params.append((capsule_key, capsule_key, "PyObject*")) + continue + + c_type = _cppyy_c_data_type(var.dtype) + namespace_key: str = self.generator_class.get_array_name(var) + + params.append((pointer_name, namespace_key, f"{c_type}*")) + + if not var.scalar: + params.append((f"_num{varname}", f"_num{varname}", "int")) + + # 1D dynamic arrays: ALSO pass the capsule so C++ can resize. + # This mirrors determine_keywords() which appends the capsule + # param right after the pointer + size params. + if isinstance(var, DynamicArrayVariable): + dyn_name = self.generator_class.get_array_name( + var, access_data=False + ) + capsule_key = f"{dyn_name}_capsule" + params.append((capsule_key, capsule_key, "PyObject*")) + + # --- Object variables with capsule-like names (e.g. _queue_capsule) --- + handled_keys = {p[1] for p in params} + for varname, var in sorted(self.variables.items()): + if varname.endswith("_capsule") and not isinstance( + var, + ( + ArrayVariable, + Constant, + Function, + AuxiliaryVariable, + Subexpression, + ), + ): + if varname not in handled_keys: + params.append((varname, varname, "PyObject*")) + + # group_get_indices: append output-buffer params that mirror the extra + # entries added by CppyyCodeGenerator.determine_keywords(). + if self.template_name == "group_get_indices": + params.append(("_return_values_buf", "_return_values_buf", "int*")) + params.append(("_return_values_n", "_return_values_n", "int*")) + + # group_variable_get: output buffer for subexpression values. + if self.template_name == "group_variable_get": + var = self.variables.get("_variable") + dtype = var.dtype if var is not None else np.float64 + c_type = _cppyy_c_data_type(dtype) + params.append(("_output_buf", "_output_buf", f"{c_type}*")) + + # group_variable_get_conditional: output buffer + count for conditional get. + if self.template_name == "group_variable_get_conditional": + var = self.variables.get("_variable") + dtype = var.dtype if var is not None else np.float64 + c_type = _cppyy_c_data_type(dtype) + params.append(("_output_buf", "_output_buf", f"{c_type}*")) + params.append(("_output_n", "_output_n", "int*")) + + return params + + # --- Compilation --- + + def compile_block(self, block: str) -> Any | None: + """ + JIT-compile a code block and wire up any user-function globals. + Returns the compiled function, or None for empty blocks. + """ + code: str = getattr(self.code, block, "").strip() + if not code or "EMPTY_CODE_BLOCK" in code: + return None + + cppyy = _get_cppyy() + _ensure_support_code() + + # Rename user functions whose name would be reused with a different body + # (e.g. a GC'd TimedArray's C++ name is recycled by a new TimedArray + # with different K/N parameters). Must happen before _guard_support_code + # so the outer hash is computed on the already-renamed code. + code, _ns_renames = _rename_conflicting_user_functions(code) + # Store so _set_user_func_globals knows which C++ globals were renamed. + self._current_ns_global_renames: dict[str, str] = _ns_renames + + # Guard support code against redefinition (happens when run() is + # called multiple times — Brian2 recreates code objects with the + # same inline function definitions) + code = _guard_support_code(code) + + # Make function name unique per-compilation to prevent Cling redefinition + # errors when the same Brian object name appears across multiple test runs + # or simulation setups in the same Python process. + global _compile_counter + original_func_name = _make_func_name(self.name, block) + unique_func_name = f"{original_func_name}_{_compile_counter:06d}" + _compile_counter += 1 + code = code.replace(original_func_name, unique_func_name) + + logger.diagnostic(f"cppyy: compiling '{block}' for {self.name}") + try: + cppyy.cppdef(code) + except Exception as exc: + raise BrianObjectException( + f"cppyy compilation failed for '{block}' of '{self.name}'.\n" + f"Generated C++ code:\n{code}\n", + self.owner, + ) from exc + + try: + compiled_func: Any = getattr(cppyy.gbl, unique_func_name) + except AttributeError: + raise RuntimeError( + f"cppyy compiled OK but function '{unique_func_name}' not found. " + f"Template/name mismatch? codeobj={self.name}, block={block}" + ) from None + + self._compiled_func_names[block] = unique_func_name + + # Wire up static C++ globals for user functions (e.g. TimedArray data pointers) + self._set_user_func_globals(cppyy) + + self._param_mappings[block] = self._build_param_mapping() + + # register with introspector if enabled + self._register_with_introspector(block, code) + + return compiled_func + + def _set_user_func_globals(self, cppyy: Any) -> None: + """ + Point C++ static globals (e.g. `static double* _namespace_timedarray_values`) + at the actual numpy data. Also pins the arrays to prevent GC. + """ + for _name, var in self.variables.items(): + if not isinstance(var, Function): + continue + try: + impl = var.implementations[self.__class__] + except KeyError: + continue + + func_namespace: dict[str, Any] | None = impl.get_namespace(self.owner) + if not func_namespace: + continue + + for ns_key, ns_value in func_namespace.items(): + if hasattr(ns_value, "dtype") and ns_value.ndim >= 1: + cpp_global_name: str = f"_namespace{ns_key}" + # If this global was renamed during compilation (because its + # function body differed from a previously compiled version), + # use the renamed symbol so we don't hit a size-mismatch error. + ns_renames = getattr(self, "_current_ns_global_renames", {}) + cpp_global_name = ns_renames.get(cpp_global_name, cpp_global_name) + try: + setattr(cppyy.gbl, cpp_global_name, ns_value) + self._namespace_refs[ns_key] = ns_value + logger.diagnostic( + f"cppyy: set global {cpp_global_name} → " + f"array shape {ns_value.shape}" + ) + except AttributeError: + logger.warn( + f"Could not set C++ global '{cpp_global_name}' for " + f"'{ns_key}'. May segfault if the function is called." + ) + + def _register_with_introspector(self, block: str, source: str) -> None: + """Register this code object with the global introspector, if enabled.""" + from .introspector import CppyyIntrospector + + introspector: CppyyIntrospector | None = CppyyIntrospector.get_instance() + if introspector is not None: + introspector.register(self, block, source) + + # --- Execution --- + + def run_block(self, block: str) -> None: + """ + Call a compiled C++ function with args extracted from self.namespace. + + cppyy does the numpy→pointer conversion automatically: a float64 array + passed where C++ expects double* gets its buffer pointer extracted with + zero copies. + """ + compiled_func: Any | None = self.compiled_code.get(block) + if compiled_func is None: + return + + try: + param_mapping: list[ParamTuple] = self._param_mappings[block] + args: list[Any] = [] + + # Sanity check: param count must match function arity + expected_nargs = len(param_mapping) + logger.diagnostic( + f"cppyy: calling {self.name}.{block} with {expected_nargs} params" + ) + + for cpp_name, ns_key, c_type in param_mapping: + val: Any = self.namespace.get(ns_key) + + if val is None: + # Naming bridge bug — log and limp along with a zero + logger.warn( + f"Namespace key '{ns_key}' missing for param " + f"'{cpp_name}' ({c_type}) in {self.name}.{block}. " + f"Keys: {sorted(self.namespace.keys())[:20]}..." + ) + if "*" in c_type: + args.append(np.zeros(1, dtype=np.float64)) + else: + args.append(0) + else: + if isinstance(val, np.ndarray): + val = np.ascontiguousarray(val) + # bool arrays need int8 view so cppyy's buffer protocol matches + if val.dtype == np.bool_: + val = val.view(np.int8) + # cppyy can't extract a buffer pointer from empty arrays — + # pass a 1-element dummy instead. The C++ code won't read + # past _num* elements anyway, and for dynamic arrays the + # real access goes through the capsule/DynamicArray object. + if val.size == 0 and c_type.endswith("*"): + val = np.zeros(1, dtype=val.dtype) + args.append(val) + try: + compiled_func(*args) + except Exception as cpp_exc: + # Convert C++ out_of_range to Python IndexError so that + # exc_isinstance(exc, IndexError) works in tests. + cppyy_mod = _get_cppyy() + if cppyy_mod is not None and isinstance( + cpp_exc, cppyy_mod.gbl.std.out_of_range + ): + raise IndexError(str(cpp_exc)) from cpp_exc + raise + + # group_get_indices: C++ wrote matching indices into the output + # buffer and the count into _return_values_n[0]. Return the slice + # so the caller (group.__getitem__) gets back a numpy int32 array. + if self.template_name == "group_get_indices": + n = int(self.namespace["_return_values_n"][0]) + return self.namespace["_return_values_buf"][:n].copy() + + # group_variable_get: C++ filled _output_buf with _num_group_idx values. + if self.template_name == "group_variable_get": + n = int(self.namespace.get("_num_group_idx", 0)) + return self.namespace["_output_buf"][:n].copy() + + # group_variable_get_conditional: C++ filled _output_buf with values + # where _cond was True; count is in _output_n[0]. + if self.template_name == "group_variable_get_conditional": + n = int(self.namespace["_output_n"][0]) + return self.namespace["_output_buf"][:n].copy() + + except Exception as exc: + raise BrianObjectException( + f"Exception during '{block}' of '{self.name}'.\n", + self.owner, + ) from exc + + +codegen_targets.add(CppyyCodeObject) + +# NOTE: rand/randn/clip/sign/timestep/poisson implementations are registered +# on CppyyCodeGenerator (in cppyy_generator.py), not here. This is intentional — +# the generator needs them during code generation, and FunctionImplementationContainer +# finds them via MRO fallback. Registering on both causes shadowing bugs. diff --git a/brian2/codegen/runtime/cppyy_rt/introspector.py b/brian2/codegen/runtime/cppyy_rt/introspector.py new file mode 100644 index 000000000..9b83c52ae --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/introspector.py @@ -0,0 +1,816 @@ +""" +Runtime introspection for the cppyy backend. + +Enable with: prefs.codegen.runtime.cppyy.enable_introspection = True + +Usage: + from brian2.codegen.runtime.cppyy_rt.introspector import get_introspector + intro = get_introspector() + + intro.list_objects() # see all compiled code objects + intro.source("*stateupdater*") # view generated C++ + intro.params("*stateupdater*") # view parameter mapping + intro.namespace("*stateupdater*") # view runtime values + intro.inspect("*stateupdater*") # all of the above + + body = intro.get_body("*stateupdater*", "run") + new_body = body.replace("exp(_lio_2)", "1.0 + _lio_2") + intro.replace_body("*stateupdater*", "run", new_body) + intro.restore("*stateupdater*", "run") # undo +""" + +from __future__ import annotations + +import html +import re as _re +from fnmatch import fnmatch +from typing import Any + +import numpy as np + +from brian2.core.preferences import prefs +from brian2.utils.logger import get_logger + +logger = get_logger(__name__) + +ParamTuple = tuple[str, str, str] + +# --- Optional rich support --- +_RICH_AVAILABLE: bool = False +try: + from rich.console import Console + from rich.panel import Panel + from rich.syntax import Syntax + from rich.table import Table + from rich.text import Text + from rich.tree import Tree + + _RICH_AVAILABLE = True +except ImportError: + pass + + +def get_introspector() -> CppyyIntrospector | None: + """Get the global introspector, or None if disabled.""" + return CppyyIntrospector.get_instance() + + +class CppyyIntrospector: + """ + Live debugging interface into cppyy's JIT-compiled C++ code. + + Singleton — all code objects register with the same instance. + """ + + _instance: CppyyIntrospector | None = None + + def __init__(self) -> None: + self._objects: dict[str, Any] = {} + self._sources: dict[tuple[str, str], str] = {} + self._original_sources: dict[tuple[str, str], str] = {} + self._original_funcs: dict[tuple[str, str], Any] = {} + self._version_counter: dict[tuple[str, str], int] = {} + self._eval_counter: int = 0 + # Track registration order so we can prefer latest + self._registration_order: list[str] = [] + + @classmethod + def get_instance(cls) -> CppyyIntrospector | None: + if not prefs.codegen.runtime.cppyy.enable_introspection: + return None + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def reset(cls) -> None: + cls._instance = None + + def register(self, codeobj: Any, block: str, source: str) -> None: + name: str = codeobj.name + self._objects[name] = codeobj + self._sources[(name, block)] = source + self._original_sources[(name, block)] = source + self._original_funcs[(name, block)] = codeobj.compiled_code.get(block) + if name not in self._registration_order: + self._registration_order.append(name) + logger.diagnostic(f"introspector: registered {name}.{block}") + + def _resolve_name(self, pattern: str) -> str: + """ + Resolve a name or glob pattern to a single code object name. + + When multiple objects match (e.g. *stateupdater* matching both + _codeobject and _codeobject_1), we prefer the LATEST registered + match. This is usually what the user wants — after a second run(), + the new code object is the active one. But if the user modified + the original and is calling restore(), the original is still there. + + If disambiguation is needed, uses these heuristics: + 1. If one match lacks a trailing _\\d+ suffix and others have one, + prefer the base name (the "original" code object). + 2. Otherwise prefer the most recently registered. + """ + if pattern in self._objects: + return pattern + + matches: list[str] = [name for name in self._objects if fnmatch(name, pattern)] + + if len(matches) == 0: + available: str = ", ".join(sorted(self._objects.keys())) + raise KeyError( + f"No code object matching '{pattern}'. Available: {available}" + ) + + if len(matches) == 1: + return matches[0] + + # Multiple matches — try to pick the most useful one. + # Prefer the base name (without _1, _2 suffix) if it exists. + base_matches: list[str] = [m for m in matches if not _re.search(r"_\d+$", m)] + if len(base_matches) == 1: + return base_matches[0] + + # Fall back to most recently registered + for name in reversed(self._registration_order): + if name in matches: + return name + + return matches[0] + + def _resolve_names(self, pattern: str) -> list[str]: + if pattern == "*": + return sorted(self._objects.keys()) + return sorted(name for name in self._objects if fnmatch(name, pattern)) + + def list_objects(self, pattern: str = "*") -> ObjectListDisplay: + """List all registered code objects, their blocks, and template types.""" + rows: list[dict[str, str]] = [] + for name in self._resolve_names(pattern): + codeobj = self._objects[name] + blocks: list[str] = [ + block + for block in ("before_run", "run", "after_run") + if (name, block) in self._sources + ] + is_active: bool = name in self._registration_order[-len(self._objects) :] + rows.append( + { + "name": name, + "template": getattr(codeobj, "template_name", "?"), + "blocks": ", ".join(blocks), + "num_vars": str(len(codeobj.variables)), + "active": "●" if is_active else "○", + } + ) + return ObjectListDisplay(rows) + + def source(self, pattern: str, block: str = "run") -> SourceDisplay: + """View the C++ source for a code object's block.""" + name: str = self._resolve_name(pattern) + key: tuple[str, str] = (name, block) + if key not in self._sources: + available_blocks: list[str] = [b for n, b in self._sources if n == name] + raise KeyError( + f"No source for {name}.{block}. Available blocks: {available_blocks}" + ) + return SourceDisplay(self._sources[key], title=f"{name}.{block}") + + def params(self, pattern: str, block: str = "run") -> ParamsDisplay: + """View parameter mapping with current runtime values.""" + name: str = self._resolve_name(pattern) + codeobj = self._objects[name] + mapping: list[ParamTuple] = codeobj._param_mappings.get(block, []) + + rows: list[dict[str, Any]] = [] + for i, (cpp_name, ns_key, c_type) in enumerate(mapping): + val: Any = codeobj.namespace.get(ns_key, "") + rows.append( + { + "index": i, + "c_type": c_type, + "cpp_name": cpp_name, + "ns_key": ns_key, + "value": _describe_value(val), + } + ) + return ParamsDisplay(rows, title=f"{name}.{block}") + + def namespace(self, pattern: str) -> NamespaceDisplay: + """View the full namespace dict, categorized by type.""" + name: str = self._resolve_name(pattern) + codeobj = self._objects[name] + ns: dict[str, Any] = codeobj.namespace + + categorized: dict[str, list[tuple[str, str]]] = { + "arrays": [], + "sizes": [], + "constants": [], + "variable_objects": [], + "dynamic_arrays": [], + "other": [], + } + + for key in sorted(ns.keys()): + val = ns[key] + desc = _describe_value(val) + + if key.startswith("_ptr_array_"): + categorized["arrays"].append((key, desc)) + elif key.startswith("_num"): + categorized["sizes"].append((key, desc)) + elif key.startswith("_var_"): + categorized["variable_objects"].append((key, desc)) + elif key.startswith("_dynamic_array_"): + categorized["dynamic_arrays"].append((key, desc)) + elif isinstance(val, (int, float, np.integer, np.floating)): + categorized["constants"].append((key, desc)) + else: + categorized["other"].append((key, desc)) + + return NamespaceDisplay(categorized, title=name) + + def inspect(self, pattern: str, block: str = "run") -> InspectDisplay: + """Full inspection: source + params + namespace in one view.""" + name: str = self._resolve_name(pattern) + return InspectDisplay( + source=self.source(name, block), + params=self.params(name, block), + namespace=self.namespace(name), + title=f"{name}.{block}", + ) + + def cpp_globals(self) -> list[str]: + """List all Brian-related symbols in cppyy's global namespace.""" + from .cppyy_rt import _get_cppyy + + cppyy = _get_cppyy() + return sorted(x for x in dir(cppyy.gbl) if "_brian_" in x) + + def get_body(self, pattern: str, block: str = "run") -> str: + """Extract just the function body, ready for editing.""" + name: str = self._resolve_name(pattern) + source: str = self._sources[(name, block)] + func_name: str = self._get_func_name(name, block) + _, _, body = _extract_function_parts(source, func_name) + return body + + def replace_body(self, pattern: str, block: str, new_body: str) -> str: + """ + Replace a function's body, keeping its signature. + + Compiles under a versioned name (_v1, _v2...) since Cling can't + redefine extern "C" symbols. Swaps the code object's function ref. + Returns the versioned function name. + """ + name: str = self._resolve_name(pattern) + codeobj = self._objects[name] + from .cppyy_rt import _get_cppyy + + cppyy = _get_cppyy() + + version: int = self._version_counter.get((name, block), 0) + 1 + self._version_counter[(name, block)] = version + + mapping: list[ParamTuple] = codeobj._param_mappings[block] + params_str: str = ", ".join( + f"{c_type} {cpp_name}" for cpp_name, _, c_type in mapping + ) + + original_func_name: str = self._get_func_name(name, block) + versioned_name: str = f"{original_func_name}_v{version}" + + new_source: str = ( + f'extern "C" void {versioned_name}({params_str}) {{\n{new_body}\n}}\n' + ) + + logger.info( + f"introspector: compiling {versioned_name} (replacing {original_func_name})" + ) + cppyy.cppdef(new_source) + + new_func: Any = getattr(cppyy.gbl, versioned_name) + codeobj.compiled_code[block] = new_func + + display_source: str = new_source.replace(versioned_name, original_func_name) + self._sources[(name, block)] = display_source + + return versioned_name + + def replace_source(self, pattern: str, block: str, new_source: str) -> str: + """Replace with completely new C++ source. Function name auto-versioned.""" + name: str = self._resolve_name(pattern) + codeobj = self._objects[name] + from .cppyy_rt import _get_cppyy + + cppyy = _get_cppyy() + + version: int = self._version_counter.get((name, block), 0) + 1 + self._version_counter[(name, block)] = version + + original_func_name: str = self._get_func_name(name, block) + versioned_name: str = f"{original_func_name}_v{version}" + + patched_source: str = new_source.replace(original_func_name, versioned_name) + cppyy.cppdef(patched_source) + new_func: Any = getattr(cppyy.gbl, versioned_name) + codeobj.compiled_code[block] = new_func + + self._sources[(name, block)] = new_source + return versioned_name + + def restore(self, pattern: str, block: str = "run") -> None: + """Restore the original compiled function.""" + name: str = self._resolve_name(pattern) + key: tuple[str, str] = (name, block) + + if key not in self._original_funcs: + raise KeyError(f"No original function stored for {name}.{block}") + + codeobj = self._objects[name] + codeobj.compiled_code[block] = self._original_funcs[key] + self._sources[key] = self._original_sources[key] + self._version_counter.pop(key, None) + + logger.info(f"introspector: restored original {name}.{block}") + + def inject_cpp(self, code: str) -> None: + """Compile arbitrary C++ into Cling (define helpers, structs, etc.).""" + from .cppyy_rt import _get_cppyy + + cppyy = _get_cppyy() + cppyy.cppdef(code) + logger.info("introspector: injected custom C++ code") + + def eval_cpp(self, expression: str, result_type: str = "double") -> Any: + """Evaluate a C++ expression and return the result.""" + from .cppyy_rt import _get_cppyy + + cppyy = _get_cppyy() + func_name: str = f"_brian_eval_{self._eval_counter}" + self._eval_counter += 1 + cppyy.cppdef( + f"{result_type} {func_name}() {{ return ({result_type})({expression}); }}" + ) + return getattr(cppyy.gbl, func_name)() + + def snapshot(self, pattern: str) -> dict[str, Any]: + """Capture current state as a plain dict (for comparisons).""" + name: str = self._resolve_name(pattern) + codeobj = self._objects[name] + + array_snapshot: dict[str, Any] = {} + for key, val in codeobj.namespace.items(): + if isinstance(val, np.ndarray): + array_snapshot[key] = { + "shape": val.shape, + "dtype": str(val.dtype), + "min": float(val.min()) if val.size > 0 else None, + "max": float(val.max()) if val.size > 0 else None, + "mean": float(val.mean()) if val.size > 0 else None, + } + + return { + "name": name, + "sources": { + block: src for (n, block), src in self._sources.items() if n == name + }, + "versions": { + block: ver + for (n, block), ver in self._version_counter.items() + if n == name + }, + "arrays": array_snapshot, + } + + def print_objects(self, pattern: str = "*") -> None: + """Pretty-print all code objects to the terminal.""" + display = self.list_objects(pattern) + if _RICH_AVAILABLE: + _rich_print_objects(display) + else: + print(repr(display)) + + def print_source(self, pattern: str, block: str = "run") -> None: + """Pretty-print C++ source with syntax highlighting.""" + display = self.source(pattern, block) + if _RICH_AVAILABLE: + _rich_print_source(display) + else: + print(repr(display)) + + def print_params(self, pattern: str, block: str = "run") -> None: + """Pretty-print parameter mapping.""" + display = self.params(pattern, block) + if _RICH_AVAILABLE: + _rich_print_params(display) + else: + print(repr(display)) + + def print_namespace(self, pattern: str) -> None: + """Pretty-print namespace contents.""" + display = self.namespace(pattern) + if _RICH_AVAILABLE: + _rich_print_namespace(display) + else: + print(repr(display)) + + def print_inspect(self, pattern: str, block: str = "run") -> None: + """Pretty-print full inspection (source + params + namespace).""" + display = self.inspect(pattern, block) + if _RICH_AVAILABLE: + _rich_print_inspect(display) + else: + print(repr(display)) + + def _get_func_name(self, name: str, block: str) -> str: + codeobj = self._objects.get(name) + if codeobj is not None: + stored = getattr(codeobj, "_compiled_func_names", {}) + if block in stored: + return stored[block] + safe: str = name.replace(".", "_").replace("*", "").replace("-", "_") + return f"_brian_cppyy_{block}_{safe}" + + def _repr_html_(self) -> str: + return self.list_objects()._repr_html_() + + +# Rich CLI renderers (only used when `rich` is installed) + + +def _rich_print_objects(display: ObjectListDisplay) -> None: + console = Console() + table = Table( + title="[bold]Compiled Code Objects[/bold]", + show_header=True, + header_style="bold cyan", + border_style="dim", + ) + table.add_column("", width=1) + table.add_column("Code Object", style="green") + table.add_column("Template", style="yellow") + table.add_column("Blocks") + table.add_column("# Vars", justify="right") + + for row in display.rows: + table.add_row( + row.get("active", "●"), + row["name"], + row["template"], + row["blocks"], + row["num_vars"], + ) + console.print(table) + + +def _rich_print_source(display: SourceDisplay) -> None: + console = Console() + syntax = Syntax( + display.source, "cpp", theme="monokai", line_numbers=True, word_wrap=False + ) + console.print( + Panel(syntax, title=f"[bold]{display.title}[/bold]", border_style="cyan") + ) + + +def _rich_print_params(display: ParamsDisplay) -> None: + console = Console() + table = Table( + title=f"[bold]Parameter Mapping: {display.title}[/bold]", + show_header=True, + header_style="bold cyan", + border_style="dim", + ) + table.add_column("#", justify="right", style="dim", width=4) + table.add_column("C++ Type", style="magenta") + table.add_column("Parameter Name", style="green") + table.add_column("Namespace Key", style="yellow") + table.add_column("Current Value") + + for row in display.rows: + val_style = "red bold" if "MISSING" in row["value"] else "" + table.add_row( + str(row["index"]), + row["c_type"], + row["cpp_name"], + row["ns_key"], + Text(row["value"], style=val_style), + ) + console.print(table) + + +def _rich_print_namespace(display: NamespaceDisplay) -> None: + console = Console() + tree = Tree(f"[bold]Namespace: {display.title}[/bold]") + + labels: dict[str, str] = { + "arrays": "[cyan]Arrays (data pointers)[/cyan]", + "sizes": "[yellow]Sizes (_num*)[/yellow]", + "constants": "[green]Constants (scalars)[/green]", + "variable_objects": "[dim]Variable Objects (_var_*)[/dim]", + "dynamic_arrays": "[magenta]Dynamic Arrays[/magenta]", + "other": "[dim]Other[/dim]", + } + + for cat, entries in display.categorized.items(): + if not entries: + continue + branch = tree.add(f"{labels.get(cat, cat)} ({len(entries)})") + for key, desc in entries: + branch.add(f"[bold]{key}[/bold] → {desc}") + + console.print(tree) + + +def _rich_print_inspect(display: InspectDisplay) -> None: + console = Console() + console.print() + console.rule(f"[bold cyan]Inspect: {display.title}[/bold cyan]") + console.print() + + _rich_print_source(display.source) + console.print() + _rich_print_params(display.params) + console.print() + _rich_print_namespace(display.namespace) + + +# Value description helper + + +def _describe_value(val: Any) -> str: + if isinstance(val, np.ndarray): + if val.size <= 4: + return f"ndarray({val.shape}, {val.dtype}) = {val.tolist()}" + return ( + f"ndarray({val.shape}, {val.dtype}) " + f"range=[{val.min():.4g}, {val.max():.4g}]" + ) + elif isinstance(val, (int, np.integer)): + return f"int = {val}" + elif isinstance(val, (float, np.floating)): + return f"float = {val:.6g}" + elif hasattr(val, "__class__"): + return val.__class__.__name__ + else: + return repr(val) + + +def _extract_function_parts(source: str, func_name: str) -> tuple[str, str, str]: + """Split C++ source into (preamble, signature, body) by brace matching.""" + marker: str = f"void {func_name}" + func_start: int = source.find(marker) + if func_start == -1: + raise ValueError( + f"Could not find function '{func_name}' in source. " + f"Source starts with: {source[:200]}..." + ) + + preamble: str = source[:func_start].rstrip() + brace_pos: int = source.find("{", func_start) + if brace_pos == -1: + raise ValueError(f"No opening brace found after '{func_name}'") + + signature: str = source[func_start:brace_pos].strip() + + depth: int = 0 + for i in range(brace_pos, len(source)): + if source[i] == "{": + depth += 1 + elif source[i] == "}": + depth -= 1 + if depth == 0: + body: str = source[brace_pos + 1 : i] + return preamble, signature, body + + raise ValueError(f"Unmatched braces in function '{func_name}'") + + +# ======================================================================== +# Jupyter HTML display classes +# ======================================================================== + +_DISPLAY_CSS: str = """ + +""" + + +def _highlight_cpp(source: str) -> str: + s: str = html.escape(source) + s = _re.sub(r"(//.*?)$", r'\1', s, flags=_re.MULTILINE) + s = _re.sub( + r"\b(extern|void|const|for|if|else|return|static|inline|template|" + r"typename|struct|namespace|typedef|using|auto|break|continue|" + r"while|do|switch|case|default)\b", + r'\1', + s, + ) + s = _re.sub( + r"\b(int|int8_t|int32_t|int64_t|size_t|long|double|float|char|" + r"bool|unsigned|void)\b", + r'\1', + s, + ) + s = _re.sub( + r"\b(\d+\.?\d*(?:[eE][+-]?\d+)?[fFuUlL]*)\b", + r'\1', + s, + ) + return s + + +class ObjectListDisplay: + def __init__(self, rows: list[dict[str, str]]) -> None: + self.rows = rows + + def _repr_html_(self) -> str: + header = ( + "Code ObjectTemplate" + "Compiled Blocks# Variables" + ) + body = "" + for row in self.rows: + body += ( + f"{html.escape(row['name'])}" + f"{html.escape(row['template'])}" + f"{html.escape(row['blocks'])}" + f"{html.escape(row['num_vars'])}" + ) + return ( + f'{_DISPLAY_CSS}
' + f"

Compiled Code Objects

" + f"{header}{body}
" + ) + + def __repr__(self) -> str: + lines = ["Compiled Code Objects:", ""] + for row in self.rows: + lines.append( + f" {row.get('active', '●')} {row['name']:<50s} " + f"template={row['template']:<20s} " + f"blocks=[{row['blocks']}] vars={row['num_vars']}" + ) + return "\n".join(lines) + + +class SourceDisplay: + def __init__(self, source: str, title: str = "") -> None: + self.source = source + self.title = title + + def _repr_html_(self) -> str: + return ( + f'{_DISPLAY_CSS}
' + f"

{html.escape(self.title)}

" + f"
{_highlight_cpp(self.source)}
" + ) + + def __repr__(self) -> str: + return f"--- {self.title} ---\n{self.source}" + + def __str__(self) -> str: + return self.source + + +class ParamsDisplay: + def __init__(self, rows: list[dict[str, Any]], title: str = "") -> None: + self.rows = rows + self.title = title + + def _repr_html_(self) -> str: + header = ( + "#C++ TypeParameter Name" + "Namespace KeyCurrent Value" + ) + body = "" + for row in self.rows: + missing_cls = ' class="missing"' if "MISSING" in row["value"] else "" + body += ( + f"{row['index']}" + f"{html.escape(row['c_type'])}" + f"{html.escape(row['cpp_name'])}" + f"{html.escape(row['ns_key'])}" + f"{html.escape(row['value'])}" + ) + return ( + f'{_DISPLAY_CSS}
' + f"

Parameter Mapping: {html.escape(self.title)}

" + f"{header}{body}
" + ) + + def __repr__(self) -> str: + lines = [f"Parameter Mapping: {self.title}", ""] + for row in self.rows: + lines.append( + f" [{row['index']:>2d}] {row['c_type']:<12s} " + f"{row['cpp_name']:<44s} <- ns[{row['ns_key']}] = {row['value']}" + ) + return "\n".join(lines) + + +class NamespaceDisplay: + _LABELS: dict[str, str] = { + "arrays": "Arrays (data pointers)", + "sizes": "Sizes (_num*)", + "constants": "Constants (scalars)", + "variable_objects": "Variable Objects (_var_*)", + "dynamic_arrays": "Dynamic Arrays", + "other": "Other", + } + + def __init__( + self, categorized: dict[str, list[tuple[str, str]]], title: str = "" + ) -> None: + self.categorized = categorized + self.title = title + + def _repr_html_(self) -> str: + sections = "" + for cat, entries in self.categorized.items(): + if not entries: + continue + label = self._LABELS.get(cat, cat) + rows = "" + for key, desc in entries: + rows += ( + f"{html.escape(key)}" + f"{html.escape(desc)}" + ) + sections += ( + f"
{html.escape(label)} " + f"({len(entries)})" + f"" + f"{rows}
KeyValue
" + ) + return ( + f'{_DISPLAY_CSS}
' + f"

Namespace: {html.escape(self.title)}

" + f"{sections}
" + ) + + def __repr__(self) -> str: + lines = [f"Namespace: {self.title}", ""] + for cat, entries in self.categorized.items(): + if not entries: + continue + label = self._LABELS.get(cat, cat) + lines.append(f" [{label}]") + for key, desc in entries: + lines.append(f" {key:<50s} {desc}") + lines.append("") + return "\n".join(lines) + + +class InspectDisplay: + def __init__( + self, + source: SourceDisplay, + params: ParamsDisplay, + namespace: NamespaceDisplay, + title: str = "", + ) -> None: + self.source = source + self.params = params + self.namespace = namespace + self.title = title + + def _repr_html_(self) -> str: + return ( + f'{_DISPLAY_CSS}
' + f"

Inspect: {html.escape(self.title)}

" + f"
C++ Source" + f"
{_highlight_cpp(self.source.source)}
" + f"
Parameter Mapping" + f"{self.params._repr_html_()}
" + f"
Namespace (click to expand)" + f"{self.namespace._repr_html_()}
" + f"
" + ) + + def __repr__(self) -> str: + return ( + f"{'=' * 60}\n" + f"INSPECT: {self.title}\n" + f"{'=' * 60}\n\n" + f"{repr(self.source)}\n\n" + f"{repr(self.params)}\n\n" + f"{repr(self.namespace)}" + ) diff --git a/brian2/codegen/runtime/cppyy_rt/templates/common_group.cpp b/brian2/codegen/runtime/cppyy_rt/templates/common_group.cpp new file mode 100644 index 000000000..2ac144e48 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/common_group.cpp @@ -0,0 +1,61 @@ + +{% set _safe_name = codeobj_name | replace(".", "_") | replace("*", "") | replace("-", "_") %} + +{# ── Helper: build the parameter list for a C++ function signature ── #} +{% macro param_list() %} +{% for c_type, param_name, ns_key in function_params %}{{ c_type }} {{ param_name }}{% if not loop.last %}, {% endif %}{% endfor %} +{% endmacro %} + + +{# BLOCK: before_run — runs once before simulation starts #} +{% macro before_run() %} +{% set _func_name = "_brian_cppyy_before_run_" + _safe_name %} + +// Per-codeobject support code (user functions, hashdefines) +{{ hashdefine_lines }} +{{ support_code_lines }} + +extern "C" void {{ _func_name }}({{ param_list() }}) { + {{ denormals_code_lines }} + {% block before_code %} + // EMPTY_CODE_BLOCK + {% endblock %} +} +{% endmacro %} + + +{# BLOCK: run — the main simulation step, runs every timestep #} +{% macro run() %} +{% set _func_name = "_brian_cppyy_run_" + _safe_name %} + +// Per-codeobject support code +{{ hashdefine_lines }} +{{ support_code_lines }} + +// Template-specific support code (e.g. synaptic queue access) +{% block template_support_code %} +{% endblock %} + +extern "C" void {{ _func_name }}({{ param_list() }}) { + {{ denormals_code_lines }} + {% block maincode %} + {% endblock %} +} +{% endmacro %} + + +{# BLOCK: after_run — runs once after simulation completes #} +{% macro after_run() %} +{% set _func_name = "_brian_cppyy_after_run_" + _safe_name %} + +// Per-codeobject support code +{{ hashdefine_lines }} +{{ support_code_lines }} + +extern "C" void {{ _func_name }}({{ param_list() }}) { + {{ denormals_code_lines }} + {% block after_code %} + // EMPTY_CODE_BLOCK + {% endblock %} +} +{% endmacro %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/group_get_indices.cpp b/brian2/codegen/runtime/cppyy_rt/templates/group_get_indices.cpp new file mode 100644 index 000000000..9665c61cb --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/group_get_indices.cpp @@ -0,0 +1,36 @@ +{# Get indices matching a condition template for cppyy backend. + # + # Because cppyy functions are extern "C" void (no return value), we can't + # return the array directly like Cython does. Instead, the generator adds + # two extra output parameters to the function signature: + # + # int* _return_values_buf -- pre-allocated buffer of size N (filled here) + # int* _return_values_n -- 1-element array; C++ writes the match count + # + # These are injected by: + # CppyyCodeGenerator.determine_keywords() -- adds them to function_params + # CppyyCodeObject.variables_to_namespace() -- allocates the numpy arrays + # CppyyCodeObject._build_param_mapping() -- mirrors the two extra entries + # CppyyCodeObject.run_block() -- slices and returns the result + #} +{# USES_VARIABLES { N, _indices } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + const size_t _vectorisation_idx = 1; + + {{ scalar_code | autoindent }} + + const int _N = {{ constant_or_scalar('N', variables['N']) }}; + int _num_matches = 0; + for (int _idx = 0; _idx < _N; _idx++) { + const size_t _vectorisation_idx = _idx; + + {{ vector_code | autoindent }} + + if (_cond) { + _return_values_buf[_num_matches++] = _idx; + } + } + _return_values_n[0] = _num_matches; +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/group_variable_get.cpp b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_get.cpp new file mode 100644 index 000000000..9f395318b --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_get.cpp @@ -0,0 +1,20 @@ +{# Note: used only for subexpressions -- for normal arrays the device accesses + data directly (see variableview_get_with_index_array) #} +{# USES_VARIABLES { _group_idx } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + const size_t _vectorisation_idx = 1; + const int _num_indices = _num_group_idx; + + {{ scalar_code | autoindent }} + + for (int _idx_group_idx = 0; _idx_group_idx < _num_indices; _idx_group_idx++) { + const int _idx = {{ _group_idx }}[_idx_group_idx]; + const size_t _vectorisation_idx = _idx; + + {{ vector_code | autoindent }} + + _output_buf[_idx_group_idx] = _variable; + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/group_variable_get_conditional.cpp b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_get_conditional.cpp new file mode 100644 index 000000000..c41414736 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_get_conditional.cpp @@ -0,0 +1,18 @@ +{# USES_VARIABLES { N } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + const size_t _vectorisation_idx = -1; + {{ scalar_code | autoindent }} + + const int _N = {{ constant_or_scalar('N', variables['N']) }}; + int _n_out = 0; + for (int _idx = 0; _idx < _N; _idx++) { + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + if (_cond) { + _output_buf[_n_out++] = _variable; + } + } + _output_n[0] = _n_out; +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/group_variable_set.cpp b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_set.cpp new file mode 100644 index 000000000..5bf5c77c0 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_set.cpp @@ -0,0 +1,13 @@ +{# USES_VARIABLES { _group_idx } #} +{# ALLOWS_SCALAR_WRITE #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + const size_t _vectorisation_idx = -1; + {{ scalar_code | autoindent }} + for (int _idx_group_idx = 0; _idx_group_idx < (int)_num_group_idx; _idx_group_idx++) { + const size_t _idx = {{ _group_idx }}[_idx_group_idx]; + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/group_variable_set_conditional.cpp b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_set_conditional.cpp new file mode 100644 index 000000000..6c6317731 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/group_variable_set_conditional.cpp @@ -0,0 +1,17 @@ +{# USES_VARIABLES { N } #} +{# ALLOWS_SCALAR_WRITE #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + const size_t _vectorisation_idx = -1; + {{ scalar_code['condition'] | autoindent }} + {{ scalar_code['statement'] | autoindent }} + const int _N = {{ constant_or_scalar('N', variables['N']) }}; + for (int _idx = 0; _idx < _N; _idx++) { + const size_t _vectorisation_idx = _idx; + {{ vector_code['condition'] | autoindent }} + if (_cond) { + {{ vector_code['statement'] | autoindent }} + } + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/ratemonitor.cpp b/brian2/codegen/runtime/cppyy_rt/templates/ratemonitor.cpp new file mode 100644 index 000000000..6eaac7108 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/ratemonitor.cpp @@ -0,0 +1,49 @@ +{# Rate monitor template for cppyy backend #} +{# USES_VARIABLES { N, t, rate, _clock_t, _clock_dt, _spikespace, + _num_source_neurons, _source_start, _source_stop } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + size_t _num_spikes = {{ _spikespace }}[_num_spikespace - 1]; + + // For subgroups, filter spikes to source range + int _start_idx = _num_spikes; + int _end_idx = _num_spikes; + for (size_t _j = 0; _j < _num_spikes; _j++) { + int _idx = {{ _spikespace }}[_j]; + if (_idx >= _source_start) { + _start_idx = _j; + break; + } + } + if (_start_idx == (int)_num_spikes) { + _start_idx = _num_spikes; + } + for (size_t _j = _start_idx; _j < _num_spikes; _j++) { + int _idx = {{ _spikespace }}[_j]; + if (_idx >= _source_stop) { + _end_idx = _j; + break; + } + } + _num_spikes = _end_idx - _start_idx; + + // Resize t and rate arrays via capsules + {% set _t_capsule = get_array_name(variables['t'], access_data=False) + "_capsule" %} + {% set _rate_capsule = get_array_name(variables['rate'], access_data=False) + "_capsule" %} + auto* _dyn_t = _extract_dynamic_array_1d({{ _t_capsule }}); + auto* _dyn_rate = _extract_dynamic_array_1d({{ _rate_capsule }}); + + size_t _current_len = _dyn_t->size(); + size_t _new_len = _current_len + 1; + + _dyn_t->resize(_new_len); + _dyn_rate->resize(_new_len); + + // Update N + {{ N }} = _new_len; + + // Write values + _dyn_t->get_data_ptr()[_new_len - 1] = {{ _clock_t }}; + _dyn_rate->get_data_ptr()[_new_len - 1] = (double)_num_spikes / {{ _clock_dt }} / _num_source_neurons; +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/reset.cpp b/brian2/codegen/runtime/cppyy_rt/templates/reset.cpp new file mode 100644 index 000000000..701ff8c93 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/reset.cpp @@ -0,0 +1,15 @@ +{# USES_VARIABLES { N } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + {% set _eventspace = get_array_name(eventspace_variable) %} + const int32_t* _events = {{ _eventspace }}; + const int32_t _num_events = {{ _eventspace }}[{{ constant_or_scalar('N', variables['N']) }}]; + const size_t _vectorisation_idx = -1; + {{ scalar_code | autoindent }} + for (int32_t _index_events = 0; _index_events < _num_events; _index_events++) { + const size_t _idx = _events[_index_events]; + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/spatialstateupdate.cpp b/brian2/codegen/runtime/cppyy_rt/templates/spatialstateupdate.cpp new file mode 100644 index 000000000..0b5b5efa7 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/spatialstateupdate.cpp @@ -0,0 +1,184 @@ +{# USES_VARIABLES { Cm, dt, v, N, Ic, Ri, + _ab_star0, _ab_star1, _ab_star2, _b_plus, _b_minus, + _v_star, _u_plus, _u_minus, + _v_previous, + _gtot_all, _I0_all, + _c, + _P_diag, _P_parent, _P_children, + _B, _morph_parent_i, _starts, _ends, + _morph_children, _morph_children_num, _morph_idxchild, + _invr0, _invrn, _invr, + r_length_1, r_length_2, area } #} + +{% extends 'common_group.cpp' %} + +{% block before_code %} + double _Ri = {{ Ri }}; + + // Inverse axial resistance + for (int _i = 1; _i < N; _i++) { + {{ _invr }}[_i] = 1.0 / (_Ri * (1.0 / {{ r_length_2 }}[_i - 1] + 1.0 / {{ r_length_1 }}[_i])); + } + // Cut sections + for (int _i = 0; _i < _num_starts; _i++) { + {{ _invr }}[{{ _starts }}[_i]] = 0; + } + + // Linear systems + // The particular solution + // a[i,j]=ab[u+i-j,j] -- u is the number of upper diagonals = 1 + for (int _i = 0; _i < N; _i++) { + {{ _ab_star1 }}[_i] = -({{ Cm }}[_i] / {{ dt }}) - {{ _invr }}[_i] / {{ area }}[_i]; + } + for (int _i = 1; _i < N; _i++) { + {{ _ab_star0 }}[_i] = {{ _invr }}[_i] / {{ area }}[_i - 1]; + {{ _ab_star2 }}[_i - 1] = {{ _invr }}[_i] / {{ area }}[_i]; + {{ _ab_star1 }}[_i - 1] -= {{ _invr }}[_i] / {{ area }}[_i - 1]; + } + + // Set the boundary conditions + for (int _counter = 0; _counter < _num_starts; _counter++) { + int _first = {{ _starts }}[_counter]; + int _last = {{ _ends }}[_counter] - 1; // compartment indices in [starts, ends[ + // Inverse axial resistances at the ends: r0 and rn + double __invr0 = {{ r_length_1 }}[_first] / _Ri; + double __invrn = {{ r_length_2 }}[_last] / _Ri; + {{ _invr0 }}[_counter] = __invr0; + {{ _invrn }}[_counter] = __invrn; + // Correction for boundary conditions + {{ _ab_star1 }}[_first] -= (__invr0 / {{ area }}[_first]); + {{ _ab_star1 }}[_last] -= (__invrn / {{ area }}[_last]); + // RHS for homogeneous solutions + {{ _b_plus }}[_last] = -(__invrn / {{ area }}[_last]); + {{ _b_minus }}[_first] = -(__invr0 / {{ area }}[_first]); + } +{% endblock %} + +{% block maincode %} + // MAIN CODE + const size_t _vectorisation_idx_scalar = 1; + {{ scalar_code | autoindent }} + + // STEP 1: compute g_total and I_0 + for (int _i = 0; _i < N; _i++) { + int _idx = _i; + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + {{ _gtot_all }}[_idx] = _gtot; + {{ _I0_all }}[_idx] = _I0; + {{ _v_previous }}[_idx] = {{ v }}[_idx]; + } + + // STEP 2: for each section: solve three tridiagonal systems + + // system 2a: solve for _v_star + for (int _i = 0; _i < _num_B - 1; _i++) { + // first and last index of the i-th section + int _j_start = {{ _starts }}[_i]; + int _j_end = {{ _ends }}[_i]; + + // upper triangularization of tridiagonal system for _v_star, _u_plus, and _u_minus + for (int _j = _j_start; _j < _j_end; _j++) { + {{ _v_star }}[_j] = -({{ Cm }}[_j] / {{ dt }} * {{ v }}[_j]) - {{ _I0_all }}[_j]; // RHS -> _v_star + {{ _u_plus }}[_j] = {{ _b_plus }}[_j]; // RHS -> _u_plus + {{ _u_minus }}[_j] = {{ _b_minus }}[_j]; // RHS -> _u_minus + double _bi = {{ _ab_star1 }}[_j] - {{ _gtot_all }}[_j]; // main diagonal + if (_j < N - 1) { + {{ _c }}[_j] = {{ _ab_star0 }}[_j + 1]; // superdiagonal + } + if (_j > 0) { + double _ai = {{ _ab_star2 }}[_j - 1]; // subdiagonal + double _m = 1.0 / (_bi - _ai * {{ _c }}[_j - 1]); + {{ _c }}[_j] = {{ _c }}[_j] * _m; + {{ _v_star }}[_j] = ({{ _v_star }}[_j] - _ai * {{ _v_star }}[_j - 1]) * _m; + {{ _u_plus }}[_j] = ({{ _u_plus }}[_j] - _ai * {{ _u_plus }}[_j - 1]) * _m; + {{ _u_minus }}[_j] = ({{ _u_minus }}[_j] - _ai * {{ _u_minus }}[_j - 1]) * _m; + } else { + {{ _c }}[0] = {{ _c }}[0] / _bi; + {{ _v_star }}[0] = {{ _v_star }}[0] / _bi; + {{ _u_plus }}[0] = {{ _u_plus }}[0] / _bi; + {{ _u_minus }}[0] = {{ _u_minus }}[0] / _bi; + } + } + // backwards substitution of the upper triangularized system + for (int _j = _j_end - 2; _j >= _j_start; _j--) { + {{ _v_star }}[_j] = {{ _v_star }}[_j] - {{ _c }}[_j] * {{ _v_star }}[_j + 1]; + {{ _u_plus }}[_j] = {{ _u_plus }}[_j] - {{ _c }}[_j] * {{ _u_plus }}[_j + 1]; + {{ _u_minus }}[_j] = {{ _u_minus }}[_j] - {{ _c }}[_j] * {{ _u_minus }}[_j + 1]; + } + } + + // STEP 3: solve the coupling system + + // indexing for _P_children + int _children_rowlength = _num_morph_children / _num_morph_children_num; + + // STEP 3a: construct the coupling system with matrix _P in sparse form + for (int _i = 0; _i < _num_B - 1; _i++) { + int _i_parent = {{ _morph_parent_i }}[_i]; + int _i_childind = {{ _morph_idxchild }}[_i]; + int _first = {{ _starts }}[_i]; + int _last = {{ _ends }}[_i] - 1; + double _this_invr0 = {{ _invr0 }}[_i]; + double _this_invrn = {{ _invrn }}[_i]; + + // Towards parent + if (_i == 0) { // first section, sealed end + {{ _P_diag }}[0] = {{ _u_minus }}[_first] - 1; + {{ _P_children }}[0 + 0] = {{ _u_plus }}[_first]; + // RHS + {{ _B }}[0] = -{{ _v_star }}[_first]; + } else { + {{ _P_diag }}[_i_parent] += (1 - {{ _u_minus }}[_first]) * _this_invr0; + {{ _P_children }}[_i_parent * _children_rowlength + _i_childind] = -{{ _u_plus }}[_first] * _this_invr0; + // RHS + {{ _B }}[_i_parent] += {{ _v_star }}[_first] * _this_invr0; + } + + // Towards children + {{ _P_diag }}[_i + 1] = (1 - {{ _u_plus }}[_last]) * _this_invrn; + {{ _P_parent }}[_i] = -{{ _u_minus }}[_last] * _this_invrn; + // RHS + {{ _B }}[_i + 1] = {{ _v_star }}[_last] * _this_invrn; + } + + // STEP 3b: solve the linear system (O(n) sparse Gaussian elimination) + + // part 1: lower triangularization + for (int _i = _num_B - 1; _i >= 0; _i--) { + int _num_children = {{ _morph_children_num }}[_i]; + // for every child eliminate the corresponding matrix element of row i + for (int _k = 0; _k < _num_children; _k++) { + int _j = {{ _morph_children }}[_i * _children_rowlength + _k]; // child index + // subtracting _subfac times the j-th from the i-th row + double _subfac = {{ _P_children }}[_i * _children_rowlength + _k] / {{ _P_diag }}[_j]; + {{ _P_diag }}[_i] = {{ _P_diag }}[_i] - _subfac * {{ _P_parent }}[_j - 1]; + {{ _B }}[_i] = {{ _B }}[_i] - _subfac * {{ _B }}[_j]; + } + } + + // part 2: forwards substitution + {{ _B }}[0] = {{ _B }}[0] / {{ _P_diag }}[0]; // first section has no parent + for (int _i = 1; _i < _num_B; _i++) { + int _j = {{ _morph_parent_i }}[_i - 1]; // parent index + {{ _B }}[_i] = {{ _B }}[_i] - {{ _P_parent }}[_i - 1] * {{ _B }}[_j]; + {{ _B }}[_i] = {{ _B }}[_i] / {{ _P_diag }}[_i]; + } + + // STEP 4: for each section compute the final solution by linear combination + for (int _i = 0; _i < _num_B - 1; _i++) { + int _i_parent = {{ _morph_parent_i }}[_i]; + int _j_start = {{ _starts }}[_i]; + int _j_end = {{ _ends }}[_i]; + for (int _j = _j_start; _j < _j_end; _j++) { + if (_j < _numv) { // don't go beyond the last element + {{ v }}[_j] = ({{ _v_star }}[_j] + {{ _B }}[_i_parent] * {{ _u_minus }}[_j] + + {{ _B }}[_i + 1] * {{ _u_plus }}[_j]); + } + } + } + + for (int _i = 0; _i < N; _i++) { + {{ Ic }}[_i] = {{ Cm }}[_i] * ({{ v }}[_i] - {{ _v_previous }}[_i]) / {{ dt }}; + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/spikegenerator.cpp b/brian2/codegen/runtime/cppyy_rt/templates/spikegenerator.cpp new file mode 100644 index 000000000..d344e8bac --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/spikegenerator.cpp @@ -0,0 +1,30 @@ +{# USES_VARIABLES { _spikespace, neuron_index, _timebins, _period_bins, _lastindex, t_in_timesteps, N } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + int32_t _the_period = {{ _period_bins }}; + int32_t _timebin = {{ t_in_timesteps }}; + int32_t _cpp_numspikes = 0; + + if (_the_period > 0) { + _timebin = _timebin % _the_period; + // If there is a periodicity in the SpikeGenerator, we need to reset the + // lastindex when the period has passed + if ({{ _lastindex }} > 0 && {{ _timebins }}[{{ _lastindex }} - 1] >= _timebin) { + {{ _lastindex }} = 0; + } + } + + for (int _idx = {{ _lastindex }}; _idx < _num_timebins; _idx++) { + if ({{ _timebins }}[_idx] > _timebin) { + break; + } + + {{ _spikespace }}[_cpp_numspikes] = {{ neuron_index }}[_idx]; + _cpp_numspikes++; + } + + {{ _spikespace }}[N] = _cpp_numspikes; + + {{ _lastindex }} += _cpp_numspikes; +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/spikemonitor.cpp b/brian2/codegen/runtime/cppyy_rt/templates/spikemonitor.cpp new file mode 100644 index 000000000..ffd8d9194 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/spikemonitor.cpp @@ -0,0 +1,76 @@ +{# USES_VARIABLES { N, count, _clock_t, _source_start, _source_stop, _source_N } #} +{# WRITES_TO_READ_ONLY_VARIABLES { N, count } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + {# Get the spikespace array name #} + {% set _eventspace = get_array_name(eventspace_variable) %} + + int32_t _num_events = {{ _eventspace }}[_num{{ eventspace_variable.name }} - 1]; + + if (_num_events > 0) { + // ── Filter for subgroup range ── + size_t _start_idx = _num_events; + size_t _end_idx = _num_events; + + for (size_t _j = 0; _j < (size_t)_num_events; _j++) { + const int _idx = {{ _eventspace }}[_j]; + if (_idx >= _source_start) { + _start_idx = _j; + break; + } + } + for (size_t _j = _num_events - 1; _j >= _start_idx; _j--) { + const int _idx = {{ _eventspace }}[_j]; + if (_idx < _source_stop) { + break; + } + _end_idx = _j; + } + _num_events = _end_idx - _start_idx; + + if (_num_events > 0) { + // Scalar code + const size_t _vectorisation_idx = 1; + {{ scalar_code | autoindent }} + + size_t _curlen = {{ N }}; + size_t _newlen = _curlen + _num_events; + + // ── Resize all recorded dynamic arrays via capsules ── + {% for varname, var in record_variables | dictsort %} + {% set _dyn_name = get_array_name(var, access_data=False) %} + {% set _capsule_name = _dyn_name + "_capsule" %} + {% set _rec_ctype = c_data_type(var.dtype) %} + { + auto* _dyn_{{ varname }} = _extract_dynamic_array_1d<{{ _rec_ctype }}>({{ _capsule_name }}); + _dyn_{{ varname }}->resize(_newlen); + } + {% endfor %} + + // Update N after resize + {{ N }} = _newlen; + + // ── Cache capsule extractions and data pointers before spike loop ── + {% for varname, var in record_variables | dictsort %} + {% set _dyn_name = get_array_name(var, access_data=False) %} + {% set _rec_ctype = c_data_type(var.dtype) %} + auto* _cached_dyn_{{ varname }} = _extract_dynamic_array_1d<{{ _rec_ctype }}>({{ _dyn_name }}_capsule); + auto* _cached_ptr_{{ varname }} = _cached_dyn_{{ varname }}->get_data_ptr(); + {% endfor %} + + // ── Record each spike ── + for (size_t _j = _start_idx; _j < _end_idx; _j++) { + const size_t _idx = {{ _eventspace }}[_j]; + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + + {% for varname, var in record_variables | dictsort %} + _cached_ptr_{{ varname }}[_curlen + _j - _start_idx] = _to_record_{{ varname }}; + {% endfor %} + + {{ count }}[_idx - _source_start]++; + } + } + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/statemonitor.cpp b/brian2/codegen/runtime/cppyy_rt/templates/statemonitor.cpp new file mode 100644 index 000000000..9c106af0b --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/statemonitor.cpp @@ -0,0 +1,60 @@ +{# USES_VARIABLES { t, _clock_t, _indices, N } #} +{# WRITES_TO_READ_ONLY_VARIABLES { t, N } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + // ── Extract DynamicArray objects from capsules ── + // These are the SAME C++ objects that Cython created. The capsule + // holds a void* to the DynamicArray1D that the RuntimeDevice + // allocated. We cast it back to the correct type and can call resize(), + // get_data_ptr(), etc. — all in C++, no Python overhead. + + {% set _t_capsule = "_dynamic_array_" + owner.name + "_t_capsule" %} + auto* _dyn_t = _extract_dynamic_array_1d({{ _t_capsule }}); + + // Get current size and compute new size + size_t _old_len = _dyn_t->size(); + size_t _new_len = _old_len + 1; + + // Resize the time array — this may reallocate the underlying buffer + _dyn_t->resize(_new_len); + + // Write the current clock time into the last element + _dyn_t->get_data_ptr()[_new_len - 1] = {{ _clock_t }}; + + // ── Resize each recorded variable's 2D array ── + {% for varname, var in _recorded_variables | dictsort %} + {% set _rec_capsule = get_array_name(var, access_data=False) + "_capsule" %} + {% set _rec_ctype = c_data_type(var.dtype) %} + { + auto* _dyn_{{ varname }} = _extract_dynamic_array_2d<{{ _rec_ctype }}>({{ _rec_capsule }}); + _dyn_{{ varname }}->resize_along_first(_new_len); + } + {% endfor %} + + // ── Cache 2D array extractions before the loop ── + {% for varname, var in _recorded_variables | dictsort %} + {% set _rec_capsule = get_array_name(var, access_data=False) + "_capsule" %} + {% set _rec_ctype = c_data_type(var.dtype) %} + auto* _cached_dyn_{{ varname }} = _extract_dynamic_array_2d<{{ _rec_ctype }}>({{ _rec_capsule }}); + {% endfor %} + + // ── Scalar code (runs once) ── + const size_t _vectorisation_idx = -1; + {{ scalar_code | autoindent }} + + + for (int _i = 0; _i < _num_indices; _i++) { + const size_t _idx = {{ _indices }}[_i]; + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + + {% for varname, var in _recorded_variables | dictsort %} + _cached_dyn_{{ varname }}->operator()(_new_len - 1, _i) = _to_record_{{ varname }}; + {% endfor %} + } + + // Update N (the number of recorded timesteps) + {{ N }} = _new_len; + +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/stateupdate.cpp b/brian2/codegen/runtime/cppyy_rt/templates/stateupdate.cpp new file mode 100644 index 000000000..654ac7bea --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/stateupdate.cpp @@ -0,0 +1,18 @@ +{# ITERATE_ALL { _idx } #} +{# USES_VARIABLES { N } #} +{# ALLOWS_SCALAR_WRITE #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + // scalar code (runs once, outside the loop) + const size_t _vectorisation_idx = -1; + {{ scalar_code | autoindent }} + + const int _N = {{ constant_or_scalar('N', variables['N']) }}; + + // vector code (runs per neuron) + for (int _idx = 0; _idx < _N; _idx++) { + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/summed_variable.cpp b/brian2/codegen/runtime/cppyy_rt/templates/summed_variable.cpp new file mode 100644 index 000000000..434561739 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/summed_variable.cpp @@ -0,0 +1,18 @@ +{# USES_VARIABLES { N } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + {% set _target_var_array = get_array_name(_target_var) %} + {% set _index_array = get_array_name(_index_var) %} + const int _target_size = {{ constant_or_scalar(_target_size_name, variables[_target_size_name]) }}; + for (int _target_idx = 0; _target_idx < _target_size; _target_idx++) { + {{ _target_var_array }}[_target_idx + {{ _target_start }}] = 0; + } + const size_t _vectorisation_idx = -1; + {{ scalar_code | autoindent }} + for (int _idx = 0; _idx < {{ N }}; _idx++) { + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + {{ _target_var_array }}[{{ _index_array }}[_idx]] += _synaptic_var; + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/synapses.cpp b/brian2/codegen/runtime/cppyy_rt/templates/synapses.cpp new file mode 100644 index 000000000..976684f97 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/synapses.cpp @@ -0,0 +1,37 @@ +{# USES_VARIABLES { _queue_capsule } #} +{% extends 'common_group.cpp' %} + +{% block template_support_code %} +#include +#include +{% endblock %} + +{% block maincode %} + // Extract the C++ spike queue from the capsule + CSpikeQueue* _queue = _extract_spike_queue(_queue_capsule); + + // Peek at current timestep's spikes (synapse indices) + std::vector* _spike_vector = _queue->peek(); + size_t _num_spikes = _spike_vector->size(); + + if (_num_spikes == 0) { + _queue->advance(); + return; + } + + int32_t* _spike_data = &(*_spike_vector)[0]; + + // Scalar code + const size_t _vectorisation_idx = 1; + {{ scalar_code | autoindent }} + + // Process each spike (synapse index) + for (size_t _spike_idx = 0; _spike_idx < _num_spikes; _spike_idx++) { + const int32_t _idx = _spike_data[_spike_idx]; + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + } + + // Advance the queue to the next timestep + _queue->advance(); +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/synapses_create_array.cpp b/brian2/codegen/runtime/cppyy_rt/templates/synapses_create_array.cpp new file mode 100644 index 000000000..003bcaf95 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/synapses_create_array.cpp @@ -0,0 +1,39 @@ +{# USES_VARIABLES { _synaptic_pre, _synaptic_post, sources, targets, N, + N_pre, N_post, _source_offset, _target_offset } +#} +{# WRITES_TO_READ_ONLY_VARIABLES { _synaptic_pre, _synaptic_post, N} #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + {% set _pre_capsule = get_array_name(variables['_synaptic_pre'], access_data=False) + "_capsule" %} + {% set _post_capsule = get_array_name(variables['_synaptic_post'], access_data=False) + "_capsule" %} + + size_t _old_num_synapses = {{ N }}; + size_t _new_num_synapses = _old_num_synapses + _numsources; + + // Resize pre/post synapse index arrays via capsules + auto* _dyn_pre = _extract_dynamic_array_1d({{ _pre_capsule }}); + auto* _dyn_post = _extract_dynamic_array_1d({{ _post_capsule }}); + + _dyn_pre->resize(_new_num_synapses); + _dyn_post->resize(_new_num_synapses); + + int32_t* _synaptic_pre_data = _dyn_pre->get_data_ptr(); + int32_t* _synaptic_post_data = _dyn_post->get_data_ptr(); + + for (size_t _idx = 0; _idx < _numsources; _idx++) { + {{ vector_code | autoindent }} + _synaptic_pre_data[_idx + _old_num_synapses] = _real_sources; + _synaptic_post_data[_idx + _old_num_synapses] = _real_targets; + } + + // Python-side resize of all registered variables and update N + // (handled by _owner._resize and _owner._update_synapse_numbers + // which are called from the code object's after_run or via Python) +{% endblock %} + +{% block after_code %} + // This is intentionally empty — the Python-side resize is handled + // by the code object wrapper calling _owner._resize() after run. + // EMPTY_CODE_BLOCK +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/synapses_create_generator.cpp b/brian2/codegen/runtime/cppyy_rt/templates/synapses_create_generator.cpp new file mode 100644 index 000000000..5f0cd0a0b --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/synapses_create_generator.cpp @@ -0,0 +1,169 @@ +{# USES_VARIABLES { _synaptic_pre, _synaptic_post, rand, N, + N_pre, N_post, _source_offset, _target_offset } #} +{# WRITES_TO_READ_ONLY_VARIABLES { _synaptic_pre, _synaptic_post, N} #} +{# ITERATE_ALL { _idx } #} +{% extends 'common_group.cpp' %} + +{% block template_support_code %} +#include +#include +#include + +const int _buffer_size = 1024; + +inline void _flush_buffer(int32_t* buf, DynamicArray1D* dynarr, int buf_len) { + size_t _curlen = dynarr->size(); + dynarr->resize(_curlen + buf_len); + memcpy(dynarr->get_data_ptr() + _curlen, buf, buf_len * sizeof(int32_t)); +} +{% endblock %} + +{% block maincode %} + {% set _pre_capsule = get_array_name(variables['_synaptic_pre'], access_data=False) + "_capsule" %} + {% set _post_capsule = get_array_name(variables['_synaptic_post'], access_data=False) + "_capsule" %} + + auto* _dyn_pre = _extract_dynamic_array_1d({{ _pre_capsule }}); + auto* _dyn_post = _extract_dynamic_array_1d({{ _post_capsule }}); + + int32_t _prebuf[1024]; + int32_t _postbuf[1024]; + int _curbuf = 0; + + // scalar code + const size_t _vectorisation_idx = 1; + {{scalar_code['setup_iterator']|autoindent}} + {{scalar_code['generator_expr']|autoindent}} + {{scalar_code['create_cond']|autoindent}} + {{scalar_code['update']|autoindent}} + + const int _N_outer = {{ constant_or_scalar(outer_index_size, variables[outer_index_size]) }}; + const int _N_result = {{ constant_or_scalar(result_index_size, variables[result_index_size]) }}; + for (int _{{outer_index}} = 0; _{{outer_index}} < _N_outer; _{{outer_index}}++) { + int _raw{{outer_index_array}} = _{{outer_index}} + {{outer_index_offset}}; + + {% if not result_index_condition %} + {{vector_code['create_cond']|autoindent}} + if (!_cond) continue; + {% endif %} + {{vector_code['setup_iterator']|autoindent}} + {% if iterator_func=='range' %} + for (int {{inner_variable}} = _iter_low; {{inner_variable}} < _iter_high; {{inner_variable}} += _iter_step) { + {% elif iterator_func=='sample' %} + {% if iterator_kwds['sample_size'] == 'fixed' %} + { + // Fixed-size sample: use selection sampling (Knuth AOCP Vol 2 3.4.2) + // _iter_size is const; copy to mutable _uiter_size so we can clamp it. + int _uiter_size = _iter_size; + int _n_selected = 0; + int _n_dealt_with = 0; + int _n_total; + if (_iter_step > 0) + _n_total = (_iter_high - _iter_low - 1) / _iter_step + 1; + else + _n_total = (_iter_low - _iter_high - 1) / (-_iter_step) + 1; + + if (_uiter_size > _n_total) { + {% if skip_if_invalid %} + _uiter_size = _n_total; + {% else %} + throw std::out_of_range("Requested sample size " + std::to_string(_uiter_size) + " is bigger than the population size " + std::to_string(_n_total) + "."); + {% endif %} + } + if (_uiter_size < 0) { + {% if skip_if_invalid %} + continue; + {% else %} + throw std::out_of_range("Requested sample size " + std::to_string(_uiter_size) + " is negative."); + {% endif %} + } + + int {{inner_variable}} = _iter_low - _iter_step; + while (_n_selected < _uiter_size) { + {{inner_variable}} += _iter_step; + _n_dealt_with++; + double _U = _rand(_vectorisation_idx); + if ((_n_total - _n_dealt_with) * _U >= _uiter_size - _n_selected) { + continue; + } + _n_selected++; + {% else %} + { + // Probabilistic sample + if (_iter_p == 0) continue; + int _iter_sign = (_iter_step < 0) ? -1 : 1; + bool _jump_algo = (_iter_p < 0.25); + double _log1p = _jump_algo ? log(1.0 - _iter_p) : 1.0; + double _pconst = 1.0 / _log1p; + int {{inner_variable}} = _iter_low - _iter_step; + while (_iter_sign * ({{inner_variable}} + _iter_step) < _iter_sign * _iter_high) { + {{inner_variable}} += _iter_step; + if (_jump_algo) { + int _jump = (int)(log(_rand(_vectorisation_idx)) * _pconst) * _iter_step; + {{inner_variable}} += _jump; + if (_iter_sign * {{inner_variable}} >= _iter_sign * _iter_high) + break; + } else { + if (_rand(_vectorisation_idx) >= _iter_p) continue; + } + {% endif %} + {% endif %} + + {{vector_code['generator_expr']|autoindent}} + int _raw{{result_index_array}} = _{{result_index}} + {{result_index_offset}}; + + {% if result_index_condition %} + {% if result_index_used %} + if (_{{result_index}} < 0 || _{{result_index}} >= _N_result) { + {% if skip_if_invalid %} + continue; + {% else %} + throw std::out_of_range("index {{result_index}}=" + std::to_string(_{{result_index}}) + " outside allowed range from 0 to " + std::to_string(_N_result - 1)); + {% endif %} + } + {% endif %} + // create_cond and update both declare _post_idx (or _pre_idx) as + // const variables. Scope create_cond to prevent redefinition errors. + bool _create_cond_result = true; + { + {{vector_code['create_cond']|autoindent}} + _create_cond_result = (bool)_cond; + } + {% endif %} + {% if if_expression!='True' and result_index_condition %} + if (!_create_cond_result) continue; + {% endif %} + {% if not result_index_used %} + if (_{{result_index}} < 0 || _{{result_index}} >= _N_result) { + {% if skip_if_invalid %} + continue; + {% else %} + throw std::out_of_range("index {{result_index}}=" + std::to_string(_{{result_index}}) + " outside allowed range from 0 to " + std::to_string(_N_result - 1)); + {% endif %} + } + {% endif %} + {{vector_code['update']|autoindent}} + + for (int _repetition = 0; _repetition < _n; _repetition++) { + _prebuf[_curbuf] = _pre_idx; + _postbuf[_curbuf] = _post_idx; + _curbuf++; + if (_curbuf == _buffer_size) { + _flush_buffer(_prebuf, _dyn_pre, _curbuf); + _flush_buffer(_postbuf, _dyn_post, _curbuf); + _curbuf = 0; + } + } + {% if iterator_func=='range' %} + } + {% else %} + } + } + {% endif %} + } + + // Final flush of remaining buffered synapses + if (_curbuf > 0) { + _flush_buffer(_prebuf, _dyn_pre, _curbuf); + _flush_buffer(_postbuf, _dyn_post, _curbuf); + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/synapses_push_spikes.cpp b/brian2/codegen/runtime/cppyy_rt/templates/synapses_push_spikes.cpp new file mode 100644 index 000000000..329c75ee3 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/synapses_push_spikes.cpp @@ -0,0 +1,26 @@ +{# USES_VARIABLES { _queue_capsule } #} +{% extends 'common_group.cpp' %} + +{% block template_support_code %} +#include +#include +{% endblock %} + +{% block before_code %} + // Queue initialization happens in Python (_owner.initialise_queue()) + // This is handled by the code object's before_run mechanism +{% endblock %} + +{% block maincode %} + {% set eventspace = get_array_name(eventspace_variable) %} + + // Get the spike count from the last entry in the spike buffer + int32_t _spike_count = {{ eventspace }}[_num{{ eventspace_variable.name }} - 1]; + + // Extract the C++ spike queue from the capsule + CSpikeQueue* _queue = _extract_spike_queue(_queue_capsule); + + if (_spike_count > 0) { + _queue->push({{ eventspace }}, _spike_count); + } +{% endblock %} diff --git a/brian2/codegen/runtime/cppyy_rt/templates/threshold.cpp b/brian2/codegen/runtime/cppyy_rt/templates/threshold.cpp new file mode 100644 index 000000000..8603fdb28 --- /dev/null +++ b/brian2/codegen/runtime/cppyy_rt/templates/threshold.cpp @@ -0,0 +1,27 @@ +{# USES_VARIABLES { N } #} +{% extends 'common_group.cpp' %} + +{% block maincode %} + {% set _eventspace = get_array_name(eventspace_variable) %} + const size_t _vectorisation_idx = -1; + {{ scalar_code | autoindent }} + const int _N = {{ constant_or_scalar('N', variables['N']) }}; + long _count = 0; + for (int _idx = 0; _idx < _N; _idx++) { + const size_t _vectorisation_idx = _idx; + {{ vector_code | autoindent }} + if (_cond) { + {{ _eventspace }}[_count++] = _idx; + {% if _uses_refractory %} + {{ not_refractory }}[_idx] = false; + {{ lastspike }}[_idx] = {{ t }}; + {% endif %} + } + } + {{ _eventspace }}[_N] = _count; +{% endblock %} + +{% block after_code %} + {% set _eventspace = get_array_name(eventspace_variable) %} + {{ _eventspace }}[{{ constant_or_scalar('N', variables['N']) }}] = 0; +{% endblock %} diff --git a/brian2/codegen/targets.py b/brian2/codegen/targets.py index 7f56d5f05..158b57208 100644 --- a/brian2/codegen/targets.py +++ b/brian2/codegen/targets.py @@ -5,4 +5,8 @@ __all__ = ["codegen_targets"] # This should be filled in by subpackages +# +#: Set of all registered code generation target classes. +#: Each target is a CodeObject subclass with a `class_name` attribute. +#: Targets register themselves by calling codegen_targets.add(TargetClass) codegen_targets = set() diff --git a/brian2/conftest.py b/brian2/conftest.py index fa1b0fbf7..5db57466e 100644 --- a/brian2/conftest.py +++ b/brian2/conftest.py @@ -47,6 +47,15 @@ def fake_randn(vectorisation_idx): return 0.5 """, ) +fake_randn.implementations.add_implementation( + "cppyy", + """ + double randn(int vectorisation_idx) + { + return 0.5; + } + """, +) @pytest.fixture diff --git a/brian2/devices/device.py b/brian2/devices/device.py index ea6298757..9209fd706 100644 --- a/brian2/devices/device.py +++ b/brian2/devices/device.py @@ -44,9 +44,11 @@ def auto_target(): """ - Automatically chose a code generation target (invoked when the - `codegen.target` preference is set to `'auto'`. Caches its result so it - only does the check once. Prefers cython > numpy. + Automatically choose a code generation target (invoked when the + `codegen.target` preference is set to `'auto'`). Caches its result so it + only does the check once. + + Priority order: cython > cppyy > numpy Returns ------- @@ -58,9 +60,20 @@ def auto_target(): target_dict = { target.class_name: target for target in codegen_targets if target.class_name } + using_fallback = False + + # Priority: cython > cppyy > numpy if "cython" in target_dict and target_dict["cython"].is_available(): _auto_target = target_dict["cython"] + elif "cppyy" in target_dict and target_dict["cppyy"].is_available(): + _auto_target = target_dict["cppyy"] + logger.info( + "Using cppyy for code generation. cppyy provides JIT " + "compilation without requiring an external C++ compiler.", + "codegen_cppyy", + once=True, + ) else: _auto_target = target_dict["numpy"] using_fallback = True @@ -77,12 +90,20 @@ def auto_target(): ) else: logger.debug( - "Chosing %r as the code generation target." % _auto_target.class_name + "Choosing %r as the code generation target." % _auto_target.class_name ) return _auto_target +def reset_auto_target(): + """ + Reset the cached auto target. Used for testing. + """ + global _auto_target + _auto_target = None + + class Device: """ Base Device object. @@ -268,16 +289,29 @@ def code_object_class(self, codeobj_class=None, fallback_pref="codegen.target"): if isinstance(codeobj_class, str): if codeobj_class == "auto": return auto_target() + + # Look up the target by name for target in codegen_targets: if target.class_name == codeobj_class: + # Check if the target is available + if ( + hasattr(target, "is_available") + and not target.is_available() + ): + raise ValueError( + f"Code generation target '{codeobj_class}' is not " + f"available. Please ensure the required dependencies " + f"are installed." + ) return target - # No target found - targets = ["auto"] + [ + + # No target found - provide helpful error message + available_targets = ["auto"] + [ target.class_name for target in codegen_targets if target.class_name ] raise ValueError( - f"Unknown code generation target: {codeobj_class}, should be one" - f" of {targets}" + f"Unknown code generation target: '{codeobj_class}'. " + f"Should be one of {available_targets}" ) else: return codeobj_class @@ -598,14 +632,38 @@ def seed(self, seed=None): self.rand_buffer_index[:] = 0 self.randn_buffer_index[:] = 0 + # Also seed the cppyy RNG if the backend is available. + # _ensure_support_code() compiles the C++ RNG eagerly so that seeding + # takes effect before any code object is compiled. + try: + from brian2.codegen.runtime.cppyy_rt.cppyy_rt import _ensure_support_code + + _ensure_support_code() + import cppyy + + if seed is not None: + cppyy.gbl._brian_cppyy_seed(int(seed) % (2**32)) + else: + cppyy.gbl._brian_cppyy_seed_random() + except (ImportError, AttributeError): + pass + def get_random_state(self): - return { + state = { "numpy_state": np.random.get_state(), "rand_buffer_index": np.array(self.rand_buffer_index), "rand_buffer": np.array(self.rand_buffer), "randn_buffer_index": np.array(self.randn_buffer_index), "randn_buffer": np.array(self.randn_buffer), } + try: + import cppyy + + if hasattr(cppyy.gbl, "_brian_cppyy_get_rng_state"): + state["cppyy_rng_state"] = str(cppyy.gbl._brian_cppyy_get_rng_state()) + except (ImportError, AttributeError): + pass + return state def set_random_state(self, state): np.random.set_state(state["numpy_state"]) @@ -613,6 +671,14 @@ def set_random_state(self, state): self.rand_buffer[:] = state["rand_buffer"] self.randn_buffer_index[:] = state["randn_buffer_index"] self.randn_buffer[:] = state["randn_buffer"] + if "cppyy_rng_state" in state: + try: + import cppyy + + if hasattr(cppyy.gbl, "_brian_cppyy_set_rng_state"): + cppyy.gbl._brian_cppyy_set_rng_state(state["cppyy_rng_state"]) + except (ImportError, AttributeError): + pass class Dummy: diff --git a/brian2/input/binomial.py b/brian2/input/binomial.py index 9129d9028..356fb4e6d 100644 --- a/brian2/input/binomial.py +++ b/brian2/input/binomial.py @@ -165,7 +165,11 @@ class BinomialFunction(Function, Nameable): #: The key has to be the name of the target, the value a function #: that takes three parameters (n, p, use_normal) and returns a tuple of #: (code, dependencies) - implementations = {"cpp": _generate_cpp_code, "cython": _generate_cython_code} + implementations = { + "cpp": _generate_cpp_code, + "cython": _generate_cython_code, + "cppyy": _generate_cpp_code, + } @check_units(n=1, p=1) def __init__(self, n, p, approximate=True, name="_binomial*"): diff --git a/brian2/input/timedarray.py b/brian2/input/timedarray.py index 22cf7ed5a..d2a262fb8 100644 --- a/brian2/input/timedarray.py +++ b/brian2/input/timedarray.py @@ -219,6 +219,7 @@ class TimedArray(Function, Nameable, CacheKey): implementations = { "cpp": (_generate_cpp_code_1d, _generate_cpp_code_2d), "cython": (_generate_cython_code_1d, _generate_cython_code_2d), + "cppyy": (_generate_cpp_code_1d, _generate_cpp_code_2d), } @check_units(dt=second) diff --git a/brian2/synapses/synapses.py b/brian2/synapses/synapses.py index fcc7cf963..b2d96fe0e 100644 --- a/brian2/synapses/synapses.py +++ b/brian2/synapses/synapses.py @@ -17,7 +17,7 @@ from brian2.core.namespace import get_local_namespace from brian2.core.spikesource import SpikeSource from brian2.core.variables import DynamicArrayVariable, Variables -from brian2.devices.device import device, get_device +from brian2.devices.device import RuntimeDevice, device, get_device from brian2.equations.equations import ( DIFFERENTIAL_EQUATION, PARAMETER, @@ -385,6 +385,13 @@ def update_abstract_code(self, run_namespace=None, level=0): @device_override("synaptic_pathway_before_run") def before_run(self, run_namespace): super().before_run(run_namespace) + # Ensure the queue is initialised before any code objects run. + # For Cython, this is also done in the template's before_code block, + # but cppyy's C++ before_code can't call Python, so we do it here. + # Under standalone mode the queue is initialised in the generated C++, + # so initialise_queue() must not be called from Python at that point. + if isinstance(get_device(), RuntimeDevice): + self.initialise_queue() def create_code_objects(self, run_namespace): if self._pushspikes_codeobj is None: @@ -1931,12 +1938,8 @@ def _add_synapses_from_arrays(self, sources, targets, n, p, namespace=None): template_kwds, needed_variables = self._get_multisynaptic_indices() template_kwds["_registered_variables"] = self._registered_variables - template_kwds["source_offset_val"] = self.variables[ - "_source_offset" - ].get_value() - template_kwds["target_offset_val"] = self.variables[ - "_target_offset" - ].get_value() + template_kwds["source_offset_val"] = int(getattr(self.source, "start", 0)) + template_kwds["target_offset_val"] = int(getattr(self.target, "start", 0)) for var in self._registered_variables: if var.name not in needed_variables: @@ -2024,20 +2027,18 @@ def _add_synapses_from_arrays(self, sources, targets, n, p, namespace=None): fallback_pref="codegen.synapse_connect_target" ), ) + if isinstance(get_device(), RuntimeDevice): + old_num_synapses = len(self) codeobj() + # Standalone device schedules code for later execution — synapse count + # is only known after run(). Skip Python-side bookkeeping in that case. + if isinstance(get_device(), RuntimeDevice): + new_num_synapses = len(self.variables["_synaptic_pre"].get_value()) + self._resize(new_num_synapses) + from brian2.codegen.runtime.cppyy_rt import CppyyCodeObject - # Update the DynamicArrayVariable.size attribute of resized variables - try: - for var in self._registered_variables: - var.size = len(var.get_value()) - self.variables["N_incoming"].size = len( - self.variables["N_incoming"].get_value() - ) - self.variables["N_outgoing"].size = len( - self.variables["N_outgoing"].get_value() - ) - except NotImplementedError: - pass # Does not apply to standalone mode + if isinstance(codeobj, CppyyCodeObject): + self._update_synapse_numbers(old_num_synapses) def _expression_index_dependence(self, expr, namespace, additional_indices=None): """ @@ -2097,12 +2098,8 @@ def _add_synapses_generator( template_kwds, needed_variables = self._get_multisynaptic_indices() template_kwds["_registered_variables"] = self._registered_variables - template_kwds["source_offset_val"] = self.variables[ - "_source_offset" - ].get_value() - template_kwds["target_offset_val"] = self.variables[ - "_target_offset" - ].get_value() + template_kwds["source_offset_val"] = int(getattr(self.source, "start", 0)) + template_kwds["target_offset_val"] = int(getattr(self.target, "start", 0)) for var in self._registered_variables: if var.name not in needed_variables: @@ -2259,20 +2256,18 @@ def _add_synapses_generator( fallback_pref="codegen.synapse_connect_target" ), ) + if isinstance(get_device(), RuntimeDevice): + old_num_synapses = len(self) codeobj() - - # Update the DynamicArrayVariable.size attribute of resized variables - try: - for var in self._registered_variables: - var.size = len(var.get_value()) - self.variables["N_incoming"].size = len( - self.variables["N_incoming"].get_value() - ) - self.variables["N_outgoing"].size = len( - self.variables["N_outgoing"].get_value() - ) - except NotImplementedError: - pass # Does not apply to standalone mode + # Standalone device schedules code for later execution — synapse count + # is only known after run(). Skip Python-side bookkeeping in that case. + if isinstance(get_device(), RuntimeDevice): + new_num_synapses = len(self.variables["_synaptic_pre"].get_value()) + self._resize(new_num_synapses) + from brian2.codegen.runtime.cppyy_rt import CppyyCodeObject + + if isinstance(codeobj, CppyyCodeObject): + self._update_synapse_numbers(old_num_synapses) def _check_parsed_synapses_generator(self, parsed, namespace): """ diff --git a/brian2/tests/__init__.py b/brian2/tests/__init__.py index b00b2cd8e..2e874fa62 100644 --- a/brian2/tests/__init__.py +++ b/brian2/tests/__init__.py @@ -239,6 +239,12 @@ def run( codegen_targets.append("cython") except ImportError: pass + try: + import cppyy # noqa: F401 + + codegen_targets.append("cppyy") + except ImportError: + pass elif isinstance(codegen_targets, str): # allow to give a single target codegen_targets = [codegen_targets] diff --git a/brian2/tests/test_GSL.py b/brian2/tests/test_GSL.py index 2115cd4a6..33be6daac 100644 --- a/brian2/tests/test_GSL.py +++ b/brian2/tests/test_GSL.py @@ -17,10 +17,10 @@ def skip_if_not_implemented(func): @functools.wraps(func) def wrapped(): - if prefs.codegen.target == "numpy" or ( - prefs.codegen.target == "auto" and auto_target().class_name == "numpy" - ): - pytest.skip("GSL support for numpy has not been implemented yet") + target = prefs.codegen.target + effective = auto_target().class_name if target == "auto" else target + if effective in ("numpy", "cppyy"): + pytest.skip(f"GSL support for {effective!r} has not been implemented yet") else: return func() diff --git a/dev/continuous-integration/run_test_suite.py b/dev/continuous-integration/run_test_suite.py index b4737b50f..3cb89fa44 100644 --- a/dev/continuous-integration/run_test_suite.py +++ b/dev/continuous-integration/run_test_suite.py @@ -36,6 +36,10 @@ "yes", "true", ] + import importlib.util + + cppyy_available = importlib.util.find_spec("cppyy") is not None + if split_run == "1": targets = ["numpy"] independent = True @@ -43,13 +47,15 @@ targets = ["cython"] independent = False else: - targets = None + targets = None # auto-detect (numpy + cython + cppyy if available) independent = True if operating_system == "windows" or standalone: in_parallel = [] else: in_parallel = ["codegen_independent", "numpy", "cpp_standalone"] + if cppyy_available: + in_parallel.append("cppyy") if operating_system in ["linux", "windows_nt"]: openmp = True diff --git a/pyproject.toml b/pyproject.toml index 33c962a8b..77335a6b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ license-files = ["LICENSE", "AUTHORS", "CONTRIBUTORS"] [project.optional-dependencies] test = ['pytest>=8', 'pytest-xdist>=1.22.3', 'pytest-cov>=2.0', 'pytest-timeout'] docs = ['sphinx>=7,<9', 'ipython>=5', 'sphinx-tabs'] +cppyy = ['cppyy>=3.1'] [project.urls] Homepage = 'https://briansimulator.org'