diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml index fb4a5a16e..10e8bf79b 100644 --- a/.github/workflows/testsuite.yml +++ b/.github/workflows/testsuite.yml @@ -25,8 +25,8 @@ jobs: persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: '3.12' - cache: 'pip' + python-version: "3.12" + cache: "pip" cache-dependency-path: .devcontainer/dev-requirements.txt - name: Install deps run: pip3 install -r .devcontainer/dev-requirements.txt @@ -40,19 +40,22 @@ jobs: strategy: fail-fast: false matrix: - os: [{image: ubuntu-24.04, triplet: x64-linux}, - {image: windows-2022, triplet: x64-windows}, - {image: macOS-13, triplet: x64-osx}, - {image: macOS-14, triplet: arm64-osx}] + os: + [ + { image: ubuntu-24.04, triplet: x64-linux }, + { image: windows-2022, triplet: x64-windows }, + { image: macOS-13, triplet: x64-osx }, + { image: macOS-14, triplet: arm64-osx }, + ] standalone: [false, true] float_dtype_32: [false, true] python-version: ["${{ needs.get_python_versions.outputs.max-python }}"] include: - - os: {image: ubuntu-24.04, triplet: x64-linux} + - os: { image: ubuntu-24.04, triplet: x64-linux } standalone: false python-version: "${{ needs.get_python_versions.outputs.min-python }} < 3.11.9 || ${{ needs.get_python_versions.outputs.min-python }} >= 3.12" float_dtype_32: false - - os: {image: ubuntu-24.04, triplet: x64-linux} + - os: { image: ubuntu-24.04, triplet: x64-linux } standalone: true python-version: "${{ needs.get_python_versions.outputs.min-python }} < 3.11.9 || ${{ needs.get_python_versions.outputs.min-python }} >= 3.12" float_dtype_32: false @@ -86,20 +89,23 @@ jobs: id: python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - cache: 'pip' + cache: "pip" python-version: ${{ matrix.python-version }} - name: Install Brian2 and dependencies env: PYTHON_BINARY: ${{ steps.python.outputs.python-path }} run: | "$PYTHON_BINARY" -m pip install .[test] - + - name: Determine Cython cache dir id: cython-cache run: | - CACHE_DIR=$(python -c 'from brian2.codegen.runtime.cython_rt.extension_manager import get_cython_cache_dir; print(get_cython_cache_dir())') + cd $GITHUB_WORKSPACE/.. # move out of the workspace to avoid direct import + CACHE_DIR=$("$PYTHON_BINARY" -c 'from brian2.codegen.runtime.cython_rt.extension_manager import get_cython_cache_dir; print(get_cython_cache_dir())') echo "Cython cache dir: $CACHE_DIR" echo "cachedir=$CACHE_DIR" >> "$GITHUB_OUTPUT" + env: + PYTHON_BINARY: ${{ steps.python.outputs.python-path }} - name: restore Cython cache uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 @@ -108,7 +114,7 @@ jobs: key: cython-extensions-${{ matrix.os.image }}-${{ matrix.python-version }}-32bit-${{ matrix.float_dtype_32 }} path: ${{ steps.cython-cache.outputs.cachedir }} - - name: Run Tests + - name: Run Tests run: | cd $GITHUB_WORKSPACE/.. && \ "$PYTHON_BINARY" $GITHUB_WORKSPACE/$SCRIPT_NAME && \ @@ -120,7 +126,7 @@ jobs: STANDALONE: ${{ matrix.standalone }} 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 + DO_NOT_RESET_PREFERENCES: true # Make sure that GSL setting is used - 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@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 @@ -146,32 +152,32 @@ jobs: run: shell: bash -l {0} steps: - - name: Checkout Repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 - persist-credentials: false - submodules: true + - name: Checkout Repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + submodules: true - - name: Setup Conda and Python - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 - with: - conda-remove-defaults: true - auto-update-conda: true - auto-activate-base: false - miniforge-version: latest - activate-environment: 'test_env' - python-version: "${{ needs.get_python_versions.outputs.max-python }}" + - name: Setup Conda and Python + uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 + with: + conda-remove-defaults: true + auto-update-conda: true + auto-activate-base: false + miniforge-version: latest + activate-environment: "test_env" + python-version: "${{ needs.get_python_versions.outputs.max-python }}" - - name: Install dependencies - run: pip install -r rtd-requirements.txt + - name: Install dependencies + run: pip install -r rtd-requirements.txt - - name: Install brian2 - run: pip install . + - name: Install brian2 + run: pip install . - - name: Build HTML documentation - run: | - cd docs_sphinx - sphinx-build -b html . ../docs - env: - READTHEDOCS: True + - name: Build HTML documentation + run: | + cd docs_sphinx + sphinx-build -b html . ../docs + env: + READTHEDOCS: True diff --git a/brian2/codegen/generators/cython_generator.py b/brian2/codegen/generators/cython_generator.py index 1daabf3be..635201582 100644 --- a/brian2/codegen/generators/cython_generator.py +++ b/brian2/codegen/generators/cython_generator.py @@ -5,6 +5,7 @@ from brian2.core.variables import ( AuxiliaryVariable, Constant, + DynamicArrayVariable, Subexpression, Variable, get_dtype_str, @@ -44,6 +45,36 @@ def get_numpy_dtype(obj): return numpy_dtype[get_dtype_str(obj)] +def get_dynamic_array_cpp_type(var): + """Get the full templated C++ type for a DynamicArrayVariable""" + cpp_dtype = get_cpp_dtype(var.dtype) # e.g., 'double', 'int32_t', 'float' + + # Special handling for bool - use char instead + if get_dtype_str(var.dtype) == "bool": + cpp_dtype = "char" + + if var.ndim == 1: + return f"DynamicArray1DCpp[{cpp_dtype}]" # Returns "DynamicArray1DCpp[double]" + elif var.ndim == 2: + return f"DynamicArray2DCpp[{cpp_dtype}]" # Returns "DynamicArray2DCpp[double]" + else: + raise ValueError( + f"Unsupported dynamic array dimension: {var.ndim}. Only 1D and 2D arrays are supported." + ) + + +def get_capsule_type(var): + """Get the capsule type name for PyCapsule_GetPointer""" + if var.ndim == 1: + return "DynamicArray1D" + elif var.ndim == 2: + return "DynamicArray2D" + else: + raise ValueError( + f"Unsupported dynamic array dimension: {var.ndim}. Only 1D and 2D arrays are supported." + ) + + class CythonNodeRenderer(NodeRenderer): def render_NameConstant(self, node): return {True: "1", False: "0"}.get(node.value, node.value) @@ -178,6 +209,7 @@ def translate_to_write_arrays(self, write): line = ( f"{self.get_array_name(var, self.variables)}[{index_var}] = {varname}" ) + lines.append(line) return lines @@ -348,40 +380,81 @@ def determine_keywords(self): load_namespace.append(line) elif isinstance(var, Variable): if var.dynamic: - pointer_name = self.get_array_name(var, False) - load_namespace.append( - f'{pointer_name} = _namespace["{pointer_name}"]' - ) + if isinstance(var, DynamicArrayVariable): + # Uses direct C++ pointers for fast access, avoiding Python overhead. + # We define unique names for the array object, its pointer, and the capsule. + dyn_array_name = self.get_array_name(var, access_data=False) + + if dyn_array_name in handled_pointers: + continue + + capsule_name = f"{dyn_array_name}_capsule" + + # Get the C++ type for accurate casting (e.g., "DynamicArray1DCpp[double]"). + cpp_type = get_dynamic_array_cpp_type(var) + + # Generate Cython code to retrieve the C++ pointer from the capsule. + # This is a two-step process in Cython: + # 1. Look up the capsule object in the provided namespace. + # 2. Unwrap the raw C++ pointer from the capsule for direct C++ object access. + load_namespace.append( + f'cdef object {capsule_name} = _namespace["{capsule_name}"]' + ) + load_namespace.append( + f"cdef {cpp_type}* {dyn_array_name}_ptr = " + f'<{cpp_type}*>PyCapsule_GetPointer({capsule_name}, "{get_capsule_type(var)}")' + ) + handled_pointers.add(dyn_array_name) + else: + pointer_name = self.get_array_name(var, False) + load_namespace.append( + f'{pointer_name} = _namespace["{pointer_name}"]' + ) # This is the "true" array name, not the restricted pointer. array_name = device.get_array_name(var) pointer_name = self.get_array_name(var) + dyn_array_name = self.get_array_name(var, access_data=False) if pointer_name in handled_pointers: continue - if getattr(var, "ndim", 1) > 1: - continue # multidimensional (dynamic) arrays have to be treated differently - if get_dtype_str(var.dtype) == "bool": + if isinstance(var, DynamicArrayVariable): + # For Dynamic Arrays, we get the data pointer directly from the C++ object + # The C++ DynamicArray class handles bool types correctly when we provide + # them as char (unlike non-dynamic arrays which require special Cython buffer handling). + cpp_dtype = get_cpp_dtype(var.dtype) + if get_dtype_str(var.dtype) == "bool": + # Use char for boolean dynamic arrays + cpp_dtype = "char" + newlines = [ ( - "cdef _numpy.ndarray[char, ndim=1, mode='c', cast=True]" - " _buf_{array_name} = _namespace['{array_name}']" - ), - ( - "cdef {cpp_dtype} * {array_name} = <{cpp_dtype} *>" - " _buf_{array_name}.data" - ), + f"cdef {cpp_dtype}* {array_name} = <{cpp_dtype}*>" + f" {dyn_array_name}_ptr.get_data_ptr()" + ) ] else: - newlines = [ - ( - "cdef _numpy.ndarray[{cpp_dtype}, ndim=1, mode='c']" - " _buf_{array_name} = _namespace['{array_name}']" - ), - ( - "cdef {cpp_dtype} * {array_name} = <{cpp_dtype} *>" - " _buf_{array_name}.data" - ), - ] + if get_dtype_str(var.dtype) == "bool": + newlines = [ + ( + "cdef _numpy.ndarray[char, ndim=1, mode='c', cast=True]" + " _buf_{array_name} = _namespace['{array_name}']" + ), + ( + "cdef {cpp_dtype} * {array_name} = <{cpp_dtype} *>" + " _buf_{array_name}.data" + ), + ] + else: + newlines = [ + ( + "cdef _numpy.ndarray[{cpp_dtype}, ndim=1, mode='c']" + " _buf_{array_name} = _namespace['{array_name}']" + ), + ( + "cdef {cpp_dtype} * {array_name} = <{cpp_dtype} *>" + " _buf_{array_name}.data" + ), + ] if not var.scalar: newlines += [ @@ -399,6 +472,7 @@ def determine_keywords(self): numpy_dtype=get_numpy_dtype(var.dtype), pointer_name=pointer_name, array_name=array_name, + dyn_array_name=dyn_array_name, varname=varname, ) load_namespace.append(line) diff --git a/brian2/codegen/runtime/cython_rt/cython_rt.py b/brian2/codegen/runtime/cython_rt/cython_rt.py index 145235ee5..a48642c9a 100644 --- a/brian2/codegen/runtime/cython_rt/cython_rt.py +++ b/brian2/codegen/runtime/cython_rt/cython_rt.py @@ -246,6 +246,11 @@ def variables_to_namespace(self): dyn_array_name = self.generator_class.get_array_name( var, access_data=False ) + # Adding logic to pass in the capsule object to namespace , we already have code set in generator to + # take the passed in object and work on it + capsule_name = f"{dyn_array_name}_capsule" + capsule = self.device.get_capsule(var) + self.namespace[capsule_name] = capsule self.namespace[dyn_array_name] = self.device.get_value( var, access_data=False ) @@ -264,7 +269,6 @@ def variables_to_namespace(self): self.namespace = { k: v for k, v in self.namespace.items() if k in all_identifiers } - # There is one type of objects that we have to inject into the # namespace with their current value at each time step: dynamic # arrays that change in size during runs, where the size change is not diff --git a/brian2/codegen/runtime/cython_rt/extension_manager.py b/brian2/codegen/runtime/cython_rt/extension_manager.py index e9d0b22b0..3cba022d3 100644 --- a/brian2/codegen/runtime/cython_rt/extension_manager.py +++ b/brian2/codegen/runtime/cython_rt/extension_manager.py @@ -264,6 +264,13 @@ def _load_module( synapses_dir = os.path.dirname(synapses.__file__) c_include_dirs.append(synapses_dir) + import brian2 + + brian2_base_dir = os.path.dirname(brian2.__file__) + brianlib_dir = os.path.join( + brian2_base_dir, "devices", "cpp_standalone", "brianlib" + ) + c_include_dirs.append(brianlib_dir) pyx_file = os.path.join(lib_dir, f"{module_name}.pyx") # ignore Python 3 unicode stuff for the moment # pyx_file = py3compat.cast_bytes_py2(pyx_file, encoding=sys.getfilesystemencoding()) diff --git a/brian2/codegen/runtime/cython_rt/templates/common_group.pyx b/brian2/codegen/runtime/cython_rt/templates/common_group.pyx index 0b24c5a3c..b9ea2572e 100644 --- a/brian2/codegen/runtime/cython_rt/templates/common_group.pyx +++ b/brian2/codegen/runtime/cython_rt/templates/common_group.pyx @@ -46,6 +46,29 @@ cdef extern from "stdint_compat.h": cdef int int_(float) cdef int int_(double) cdef int int_(long double) + +# PyCapsule support for direct C++ pointer access +from cpython.pycapsule cimport PyCapsule_GetPointer + +# Dynamic array C++ interface declarations +cdef extern from "dynamic_array.h": + cdef cppclass DynamicArray1DCpp "DynamicArray1D"[T]: + void resize(size_t) except + + void shrink_to_fit() + T& operator[](size_t) + T* get_data_ptr() + size_t size() + size_t capacity() + + cdef cppclass DynamicArray2DCpp "DynamicArray2D"[T]: + void resize(size_t, size_t) except + + void resize_along_first(size_t) except + + void shrink_to_fit() + T& operator()(size_t, size_t) + T* get_data_ptr() + size_t rows() + size_t cols() + size_t stride() {% endmacro %} {% macro before_run() %} diff --git a/brian2/codegen/runtime/cython_rt/templates/ratemonitor.pyx b/brian2/codegen/runtime/cython_rt/templates/ratemonitor.pyx index a96582d04..39b07fa20 100644 --- a/brian2/codegen/runtime/cython_rt/templates/ratemonitor.pyx +++ b/brian2/codegen/runtime/cython_rt/templates/ratemonitor.pyx @@ -5,7 +5,7 @@ {% block maincode %} cdef size_t _num_spikes = {{_spikespace}}[_num{{_spikespace}}-1] - + # For subgroups, we do not want to record all spikes # We assume that spikes are ordered cdef int _start_idx = -1 @@ -26,16 +26,25 @@ if _end_idx == -1: _end_idx =_num_spikes _num_spikes = _end_idx - _start_idx - - # Calculate the new length for the arrays - cdef size_t _new_len = {{_dynamic_t}}.shape[0] + 1 - # Resize the arrays - _owner.resize(_new_len) + # First we get the current size of array from the C++ Object itself + {% set t_array = get_array_name(variables['t'],access_data=False) %} + {% set rate_array = get_array_name(variables['rate'],access_data=False) %} + cdef size_t _current_len = {{t_array}}_ptr.size() + cdef size_t _new_len = _current_len + 1 + + # Now we resize the arrays directly , avoiding python indirection + {{t_array}}_ptr.resize(_new_len) + {{rate_array}}_ptr.resize(_new_len) + + # Update N after resizing {{N}} = _new_len - # Set the new values - {{_dynamic_t}}.data[_new_len-1] = {{_clock_t}} - {{_dynamic_rate}}.data[_new_len-1] = _num_spikes/{{_clock_dt}}/_num_source_neurons + cdef double* _t_data = {{t_array}}_ptr.get_data_ptr() + cdef double* _rate_data = {{rate_array}}_ptr.get_data_ptr() + + # At last we set the new values using the new pointers + _t_data[_new_len-1] = {{_clock_t}} + _rate_data[_new_len-1] = _num_spikes/{{_clock_dt}}/_num_source_neurons {% endblock %} diff --git a/brian2/codegen/runtime/cython_rt/templates/spikemonitor.pyx b/brian2/codegen/runtime/cython_rt/templates/spikemonitor.pyx index b5abb20e0..9086146eb 100644 --- a/brian2/codegen/runtime/cython_rt/templates/spikemonitor.pyx +++ b/brian2/codegen/runtime/cython_rt/templates/spikemonitor.pyx @@ -6,11 +6,10 @@ {# Get the name of the array that stores these events (e.g. the spikespace array) #} {% set _eventspace = get_array_name(eventspace_variable) %} - cdef size_t _num_events = {{_eventspace}}[_num{{_eventspace}}-1] cdef size_t _start_idx, _end_idx, _curlen, _newlen, _j {% for varname, var in record_variables | dictsort %} - cdef {{cpp_dtype(var.dtype)}}[:] _{{varname}}_view + cdef {{cpp_dtype(var.dtype)}}* _{{varname}}_ptr {% endfor %} if _num_events > 0: # For subgroups, we do not want to record all spikes @@ -34,19 +33,27 @@ {{ scalar_code|autoindent }} _curlen = {{N}} _newlen = _curlen + _num_events - # Resize the arrays - _owner.resize(_newlen) + # Resize the C++ arrays directly, avoiding Python indirection for efficiency + {% for varname, var in record_variables | dictsort %} + {% set dyn_array_name = get_array_name(var, access_data=False) %} + {{dyn_array_name}}_ptr.resize(_newlen) + {% endfor %} + # Update N after resize {{N}} = _newlen + + # No we get new fresh pointers after resize {% for varname, var in record_variables | dictsort %} - _{{varname}}_view = {{get_array_name(var, access_data=False)}}.data + {% set dyn_array_name = get_array_name(var, access_data=False) %} + _{{varname}}_ptr = {{dyn_array_name}}_ptr.get_data_ptr() {% endfor %} + # Copy the values across for _j in range(_start_idx, _end_idx): _idx = {{_eventspace}}[_j] _vectorisation_idx = _idx {{ vector_code|autoindent }} {% for varname in record_variables | sort %} - _{{varname}}_view [_curlen + _j - _start_idx] = _to_record_{{varname}} + _{{varname}}_ptr[_curlen + _j - _start_idx] = _to_record_{{varname}} {% endfor %} {{count}}[_idx - _source_start] += 1 {% endblock %} diff --git a/brian2/codegen/runtime/cython_rt/templates/statemonitor.pyx b/brian2/codegen/runtime/cython_rt/templates/statemonitor.pyx index 38d5c0b31..8a89529cc 100644 --- a/brian2/codegen/runtime/cython_rt/templates/statemonitor.pyx +++ b/brian2/codegen/runtime/cython_rt/templates/statemonitor.pyx @@ -6,8 +6,9 @@ cdef size_t _new_len = {{N}} + 1 # Resize the recorded times - _var_t.resize(_new_len) - {{_dynamic_t}}[_new_len-1] = {{_clock_t}} + {{_dynamic_t_ptr}}.resize(_new_len) + cdef double* _t_data = {{_dynamic_t_ptr}}.get_data_ptr() + _t_data[_new_len-1] = {{_clock_t}} # scalar code _vectorisation_idx = 1 @@ -17,22 +18,17 @@ {% for varname, var in _recorded_variables | dictsort %} {% set c_type = cpp_dtype(variables[varname].dtype) %} - {% set np_type = numpy_dtype(variables[varname].dtype) %} + {% set array_name = get_array_name(var, access_data=False) %} + # Resize the recorded variable "{{varname}}" and get the (potentially # changed) reference to the underlying data - _var_{{varname}}.resize((_new_len, _num{{_indices}})) - {% if c_type == 'bool'%} - cdef _numpy.ndarray[char, ndim=2, mode='c', cast=True] _record_buf_{{varname}} = {{get_array_name(var, access_data=False)}}.data - cdef bool* _record_data_{{varname}} = <{{c_type}}*> _record_buf_{{varname}}.data - {% else %} - cdef _numpy.ndarray[{{c_type}}, ndim=2, mode='c'] _record_buf_{{varname}} = {{get_array_name(var, access_data=False)}}.data - cdef {{c_type}}* _record_data_{{varname}} = <{{c_type}}*> _record_buf_{{varname}}.data - {% endif %} + {{array_name}}_ptr.resize(_new_len, _num{{_indices}}) + cdef {{c_type}}* _record_data_{{varname}} = <{{c_type}}*> {{get_array_name(var, access_data=False) + "_ptr"}}.get_data_ptr() for _i in range(_num{{_indices}}): # vector code _idx = {{_indices}}[_i] _vectorisation_idx = _idx - + {{ vector_code | autoindent }} _record_data_{{varname}}[(_new_len-1)*_num{{_indices}} + _i] = _to_record_{{varname}} diff --git a/brian2/codegen/runtime/cython_rt/templates/synapses_create_array.pyx b/brian2/codegen/runtime/cython_rt/templates/synapses_create_array.pyx index 901d79aa2..b0578c5ce 100644 --- a/brian2/codegen/runtime/cython_rt/templates/synapses_create_array.pyx +++ b/brian2/codegen/runtime/cython_rt/templates/synapses_create_array.pyx @@ -9,11 +9,11 @@ cdef size_t _old_num_synapses = {{N}} cdef size_t _new_num_synapses = _old_num_synapses + _num{{sources}} - {{_dynamic__synaptic_pre}}.resize(_new_num_synapses) - {{_dynamic__synaptic_post}}.resize(_new_num_synapses) + {{_dynamic__synaptic_pre_ptr}}.resize(_new_num_synapses) + {{_dynamic__synaptic_post_ptr}}.resize(_new_num_synapses) # Get the potentially newly created underlying data arrays - cdef int32_t[:] _synaptic_pre_data = {{_dynamic__synaptic_pre}}.data - cdef int32_t[:] _synaptic_post_data = {{_dynamic__synaptic_post}}.data + cdef int32_t* _synaptic_pre_data = {{_dynamic__synaptic_pre_ptr}}.get_data_ptr() + cdef int32_t* _synaptic_post_data = {{_dynamic__synaptic_post_ptr}}.get_data_ptr() for _idx in range(_num{{sources}}): # After this code has been executed, the arrays _real_sources and @@ -22,7 +22,7 @@ {{ vector_code | autoindent }} _synaptic_pre_data[_idx + _old_num_synapses] = _real_sources _synaptic_post_data[_idx + _old_num_synapses] = _real_targets - + # now we need to resize all registered variables and set the total number # of synapses (via Python) _owner._resize(_new_num_synapses) diff --git a/brian2/codegen/runtime/cython_rt/templates/synapses_create_generator.pyx b/brian2/codegen/runtime/cython_rt/templates/synapses_create_generator.pyx index eb9200616..17a8a5f2f 100644 --- a/brian2/codegen/runtime/cython_rt/templates/synapses_create_generator.pyx +++ b/brian2/codegen/runtime/cython_rt/templates/synapses_create_generator.pyx @@ -7,6 +7,8 @@ ######################## TEMPLATE SUPPORT CODE ############################## {% block template_support_code %} +from libc.string cimport memcpy + cdef int _buffer_size = 1024 cdef int[:] _prebuf = _numpy.zeros(_buffer_size, dtype=_numpy.int32) cdef int[:] _postbuf = _numpy.zeros(_buffer_size, dtype=_numpy.int32) @@ -14,14 +16,17 @@ cdef int _curbuf = 0 cdef int _raw_pre_idx cdef int _raw_post_idx -cdef void _flush_buffer(buf, dynarr, int buf_len): - cdef size_t _curlen = dynarr.shape[0] +# We now update this function to be a use direct dynamic array pointers +cdef void _flush_buffer(int[:] buf,DynamicArray1DCpp[int32_t]* dynarr, int buf_len): + cdef size_t _curlen = dynarr.size() cdef size_t _newlen = _curlen+buf_len # Resize the array dynarr.resize(_newlen) - # Get the potentially newly created underlying data arrays - data = dynarr.data - data[_curlen:_curlen+buf_len] = buf[:buf_len] + # Get raw data pointer from C++ array + cdef int32_t* data_ptr = dynarr.get_data_ptr() + + # Use memcpy for fast bulk copy + memcpy(&data_ptr[_curlen], &buf[0], buf_len * sizeof(int32_t)) {% endblock %} @@ -34,7 +39,7 @@ cdef void _flush_buffer(buf, dynarr, int buf_len): global _curbuf - cdef size_t oldsize = len({{_dynamic__synaptic_pre}}) + cdef size_t oldsize = {{_dynamic__synaptic_pre_ptr}}.size() cdef size_t newsize # The following variables are only used for probabilistic connections @@ -199,16 +204,16 @@ cdef void _flush_buffer(buf, dynarr, int buf_len): _curbuf += 1 # Flush buffer if _curbuf==_buffer_size: - _flush_buffer(_prebuf, {{_dynamic__synaptic_pre}}, _curbuf) - _flush_buffer(_postbuf, {{_dynamic__synaptic_post}}, _curbuf) + _flush_buffer(_prebuf, {{_dynamic__synaptic_pre_ptr}}, _curbuf) + _flush_buffer(_postbuf, {{_dynamic__synaptic_post_ptr}}, _curbuf) _curbuf = 0 # Final buffer flush - _flush_buffer(_prebuf, {{_dynamic__synaptic_pre}}, _curbuf) - _flush_buffer(_postbuf, {{_dynamic__synaptic_post}}, _curbuf) + _flush_buffer(_prebuf, {{_dynamic__synaptic_pre_ptr}}, _curbuf) + _flush_buffer(_postbuf, {{_dynamic__synaptic_post_ptr}}, _curbuf) _curbuf = 0 # reset the buffer for the next run - newsize = len({{_dynamic__synaptic_pre}}) + newsize = {{_dynamic__synaptic_pre_ptr}}.size() # now we need to resize all registered variables and set the total number # of synapse (via Python) _owner._resize(newsize) diff --git a/brian2/core/variables.py b/brian2/core/variables.py index 8d74b4a5c..7def09fb5 100644 --- a/brian2/core/variables.py +++ b/brian2/core/variables.py @@ -661,6 +661,21 @@ def resize(self, new_size): self.size = new_size + def get_capsule(self): + """ + Get a PyCapsule object for direct C++ pointer access. + + This provides safe access to the underlying C++ dynamic array object + for high-performance operations in Cython code. + + Returns + ------- + capsule : PyCapsule + A PyCapsule containing the C++ pointer to the dynamic array. + + """ + return self.device.get_capsule(self) + class Subexpression(Variable): """ diff --git a/brian2/devices/cpp_standalone/brianlib/dynamic_array.h b/brian2/devices/cpp_standalone/brianlib/dynamic_array.h index 2cafb1767..893635b24 100644 --- a/brian2/devices/cpp_standalone/brianlib/dynamic_array.h +++ b/brian2/devices/cpp_standalone/brianlib/dynamic_array.h @@ -1,87 +1,500 @@ #ifndef _BRIAN_DYNAMIC_ARRAY_H #define _BRIAN_DYNAMIC_ARRAY_H -#include +#include +#include +#include +#include +#include +#include -/* - * 2D Dynamic array class +// NOTE : using std::vector is bit-packed and doesn’t support .data(), unlike other std::vector. + +/** + * A simple 1D dynamic array that grows efficiently over time. + * + * This class is designed to mimic the behavior of C-style contiguous memory, + * making it suitable for interop with tools like Cython and NumPy. + * + * Internally, it keeps track of: + * - `m_size`: the number of elements the user is actively working with. + * - `m_data.capacity()`: the total number of elements currently allocated. + * + * When growing, it over-allocates using a growth factor to avoid frequent + * memory reallocations — giving us amortized O(1) behavior for appending elements. * - * Efficiency note: if you are regularly resizing, make sure it is the first dimension that - * is resized, not the second one. + * When shrinking, it simply zeroes out the unused portion instead of + * releasing memory immediately. To actually free that extra memory, + * call `shrink_to_fit()`. + */ +template +class DynamicArray1D +{ +private: + std::vector m_data; + size_t m_size; // Logical size (what user sees) + double m_growth_factor; + +public: + /** + * + * We call m_data.resize(initial_size) to ensure that operator[] is safe up to + * initial_size-1 immediately after construction. This also sets capacity() to + * at least initial_size. + */ + DynamicArray1D(size_t initial_size = 0, double factor = 2.0) + : m_size(initial_size), m_growth_factor(factor) + { + m_data.resize(initial_size,T(0)); + } + + ~DynamicArray1D(){}; + + /** + * @brief Resizes the array to a new logical size. + * + * If the new size is larger than the current capacity, we grow the buffer. + * To avoid frequent reallocations, we over-allocate using a growth factor— + * that means the actual buffer might grow more than you asked for. + * This helps keep future resizes fast (amortized O(1) behavior). + * + * If the new size is smaller than the current logical size, we don't shrink + * the buffer immediately. Instead, we zero out the unused part to avoid + * keeping stale data around. If you really want to release unused memory, + * call `shrink_to_fit()` separately. + */ + void resize(size_t new_size) + { + if (new_size > m_data.size()) + { + // Growing: allocate more than strictly needed to reduce future allocations + size_t grown = static_cast(m_data.size() * m_growth_factor) + 1; + size_t new_capacity = std::max(new_size, grown); + m_data.resize(new_capacity,T(0)); + } + else if (new_size < m_size) + { + // Shrinking: zero out "deleted" entries for safety + std::fill(m_data.begin() + new_size, + m_data.begin() + m_size, + T(0)); + } + m_size = new_size; + } + + /** + * Shrink array to exact new size as mentioned , freezing unused memory + * @param new_size Must be <= current logical size + * + * Unlike resize(), this immediately frees unused memory by creating + * a new buffer of exactly new_size . + * Note: Use it with precaution as it defeats the purpose of amortized growth optimisation + */ + void shrink(size_t new_size) + { + if(new_size > m_size){ + // Don't allow growing via shrink method + return; + } + if(new_size < m_size){ + std::vector new_buffer(new_size); // We create a new buffer of exact size + if (std::is_trivially_copyable::value && new_size > 0) + { + std::memcpy(new_buffer.data(), m_data.data(), new_size* sizeof(T)); + }else + { + for ( size_t i =0; i < new_size; ++i) + { + new_buffer[i] = m_data[i]; + } + } + m_data.swap(new_buffer); + m_size = new_size; + } + // If new_size == m_size, we still shrink the buffer to fit + else{ + shrink_to_fit(); + } + } + + /** + * Shrink capacity to match current size + * Use with precaution as it defeats the purpose of amortized growth + */ + void shrink_to_fit() + { + m_data.resize(m_size); + m_data.shrink_to_fit(); + } + size_t size() const { return m_size; } + size_t capacity() const { return m_data.size(); } + + /** + * @brief Direct access to the underlying data pointer. + * @return Pointer to the first element (may be null if capacity()==0). + * + * This be used by us for using the dynamic array with numpy + */ + T *get_data_ptr() { return m_data.data(); } + const T *get_data_ptr() const { return m_data.data(); } + + T &operator[](size_t idx) { return m_data[idx]; } + const T &operator[](size_t idx) const { return m_data[idx]; } +}; + +/** + * @brief A two-dimensional dynamic array backed by a flat, row-major buffer. * + * Stores data in a single contiguous std::vector to match C-style and NumPy + * memory layout, enabling zero-copy interop (e.g., via Cython). + * Supports amortized , O(1) growth in both dimensions and efficient shrinking. */ -template +template class DynamicArray2D { - int old_n, old_m; - std::vector< std::vector* > data; +private: + std::vector m_buffer; // Underlying flat buffer (capacity = allocated slots) + size_t m_rows; // Logical number of rows exposed to the user + size_t m_cols; // Logical number of columns exposed to the user + size_t m_buffer_rows; // Physical buffer row capacity + size_t m_buffer_cols; // Physical buffer column capacity (stride) + double m_growth_factor; // Grow multiplier to reduce realloc frequency + + /** + * Convert 2D coordinates to flat index + * Row-major: i.e. elements of same row are contiguous + */ + inline size_t index(size_t i, size_t j) const + { + assert(i < m_buffer_rows && j < m_buffer_cols); + return i * m_buffer_cols + j; + } + public: - int n, m; - DynamicArray2D(int _n=0, int _m=0) - { - old_n = 0; - old_m = 0; - resize(_n, _m); - }; - ~DynamicArray2D() - { - resize(0, 0); // handles deallocation - } - void resize() - { - if(old_n!=n) - { - if(nold_n) - { - for(int i=old_n; i; - } - } - if(old_m!=m) - { - for(int i=0; iresize(m); - } else if(n>old_n) - { - for(int i=old_n; iresize(m); - } - } else if(old_m!=m) - { - for(int i=0; iresize(m); - } - } - old_n = n; - old_m = m; - }; - void resize(int _n, int _m) - { - n = _n; - m = _m; - resize(); - } - // We cannot simply use T& as the return type here, since we don't - // get a bool& out of a std::vector - inline typename std::vector::reference operator()(int i, int j) - { - return (*data[i])[j]; - } - inline std::vector& operator()(int i) - { - return (*data[i]); - } + // We keep these for backwards compatibility + size_t &n; + size_t &m; + + DynamicArray2D(size_t rows = 0, size_t cols = 0, double factor = 2.0) + : m_rows(rows), m_cols(cols), + m_buffer_rows(rows), m_buffer_cols(cols), + m_growth_factor(factor), + n(m_rows), m(m_cols) + { + m_buffer.resize(m_buffer_rows * m_buffer_cols,T(0)); + } + /** + * @brief Legacy constructor + */ + DynamicArray2D(int _n, int _m) + : DynamicArray2D(static_cast(_n), + static_cast(_m), + 2.0) {} + + ~DynamicArray2D(){}; + + /** + * @brief Resize the array to new_rows x new_cols, preserving as much data as possible. + * @param new_rows The desired number of logical rows. + * @param new_cols The desired number of logical columns. + * + * If the requested size is larger than the current buffer, we grow the + * internal storage using an over-allocation strategy: + * new_dim = max(requested, old_capacity * growth_factor + 1) + * for each dimension. This reduces the number of reallocations over time + * and provides amortized O(1) growth. + * + * When resizing down (shrinking), we *don’t* free memory immediately. + * Instead, we simply zero out the parts of the buffer that are now + * outside the logical size. To actually release unused memory, + * call `shrink_to_fit()`. + */ + void resize(size_t new_rows, size_t new_cols) + { + bool needs_realloc = false; + size_t grow_rows = m_buffer_rows; + size_t grow_cols = m_buffer_cols; + + // First we check if buffer needs to grows + if (new_rows > m_buffer_rows) + { + size_t candidate = static_cast(m_buffer_rows * m_growth_factor) + 1; + grow_rows = std::max(new_rows, candidate); + needs_realloc = true; + } + if (new_cols > m_buffer_cols) + { + size_t candidate = static_cast(m_buffer_cols * m_growth_factor) + 1; + grow_cols = std::max(new_cols, candidate); + needs_realloc = true; + } + + if (needs_realloc) + { + // Allocate new buffer and copy existing data + std::vector new_buf(grow_rows * grow_cols,T(0)); + size_t copy_rows = std::min(m_rows, new_rows); + size_t copy_cols = std::min(m_cols, new_cols); + + for (size_t i = 0; i < copy_rows; ++i) + { + for (size_t j = 0; j < copy_cols; ++j) + { + new_buf[i * grow_cols + j] = m_buffer[index(i, j)]; + } + } + // Swap in the new buffer and update capacities + m_buffer.swap(new_buf); + m_buffer_rows = grow_rows; + m_buffer_cols = grow_cols; + } + else if (new_rows < m_rows || new_cols < m_cols) + { + // Efficiently clear only the unused region without reallocating + // Zero rows beyond new_rows + for (size_t i = new_rows; i < m_buffer_rows; ++i) + { + size_t base = i * m_buffer_cols; + std::fill(&m_buffer[base], &m_buffer[base + m_buffer_cols], T(0)); + } + // Zero columns beyond new_cols in remaining rows + for (size_t i = 0; i < new_rows; ++i) + { + size_t base = i * m_buffer_cols + new_cols; + std::fill(&m_buffer[base], &m_buffer[base + (m_buffer_cols - new_cols)], T(0)); + } + } + else if (new_rows > m_rows || new_cols > m_cols) + { + // Initialize new regions to zero + + // Zero new rows + for (size_t i = m_rows; i < new_rows; ++i) + { + size_t base = i * m_buffer_cols; + std::fill(&m_buffer[base], &m_buffer[base + m_buffer_cols], T(0)); + } + + // Zero new columns in existing rows + for (size_t i = 0; i < m_rows; ++i) + { + size_t base = i * m_buffer_cols + m_cols; + size_t new_cols_in_row = std::min(new_cols, m_buffer_cols) - m_cols; + if (new_cols_in_row > 0) + { + std::fill(&m_buffer[base], &m_buffer[base + new_cols_in_row], T(0)); + } + } + } + + // Finally, we update logical dimensions + m_rows = new_rows; + m_cols = new_cols; + } + + // Legacy overloads for compatibility + void resize(int new_n, int new_m) + { + resize(static_cast(new_n), static_cast(new_m)); + } + + void resize() + { + resize(m_rows, m_cols); + } + + /** + * @brief Efficiently resize only the first dimension (rows) while keeping columns unchanged. + * + * @note This method assumes columns remain constant. If you need to change both + * dimensions, use the general resize(rows, cols) method instead. + */ + void resize_along_first(size_t new_rows) + { + if(new_rows > m_buffer_rows) // growth case + { + // So first we calculate how much to grow the buffer and then we over-allocate to avoid frequent reallocations + size_t candidate = static_cast(m_buffer_rows * m_growth_factor) + 1; + size_t grow_rows = std::max(new_rows,candidate); + + // now we create a new buffer with new row capacity , while the column capacity remains same + std::vector new_buf(grow_rows * m_buffer_cols,T(0)); + + // Figure out how many rows of existing data we can preserve + size_t copy_rows = std::min(m_rows, new_rows); + + if ( std::is_trivially_copyable::value && copy_rows > 0) + { + // We copy one complete row in a single memcpy operation ... much faster than copying element by element + for (size_t i = 0; i < copy_rows; ++i) + { + std::memcpy(&new_buf[i*m_buffer_cols], // destination: row i in new buffer + &m_buffer[i*m_buffer_cols], // source: row i in old buffer + m_buffer_cols * sizeof(T) // size: entire row + ); + } + } + else + { + for (size_t i =0; i< copy_rows; i++) + { + for (size_t j =0; j < m_buffer_cols; ++j) + { + new_buf[i*m_buffer_cols +j] = m_buffer[index(i,j)]; + } + } + } + + m_buffer.swap(new_buf); + m_buffer_rows = grow_rows; + } + else if (new_rows < m_rows) // shrinkage case + { + // As we are reducing the number of rows , so we zero out deleted rows + for ( size_t i = new_rows; i < m_rows ; ++i) + { + size_t base = i * m_buffer_cols; + + // Zero out the entire row in one operation + std::fill(&m_buffer[base], &m_buffer[base + m_buffer_cols],T(0)); + } + + /* Note: We don't shrink the actual buffer capacity here + * This is intentional for performance - if you're shrinking temporarily, + * you don't want to pay the cost of reallocation when you grow again. + * Call shrink_to_fit() explicitly if you need to reclaim memory. + */ + } + else if (new_rows > m_rows) + { + // Initialize new rows to zero + for (size_t i = m_rows; i < new_rows; ++i) + { + size_t base = i * m_buffer_cols; + std::fill(&m_buffer[base], &m_buffer[base + m_buffer_cols], T(0)); + } + } + // We just update the logical row count to reflect the new size + m_rows =new_rows; + } + + + /** + * @brief Shrink array to exact new shape, freeing unused memory + * @param new_rows Must be <= current logical rows + * @param new_cols Must be <= current logical cols + */ + void shrink(size_t new_rows, size_t new_cols) + { + if (new_rows > m_rows || new_cols > m_cols) { + // Don't allow growing via shrink + return; + } + + if (new_rows < m_rows || new_cols < m_cols) { + // Create new compact buffer + std::vector new_buffer(new_rows * new_cols); + + // Copy existing data row by row + for (size_t i = 0; i < new_rows; ++i) { + if (std::is_trivially_copyable::value && new_cols > 0) { + std::memcpy(&new_buffer[i * new_cols], + &m_buffer[i * m_buffer_cols], + new_cols * sizeof(T)); + } else { + for (size_t j = 0; j < new_cols; ++j) { + new_buffer[i * new_cols + j] = m_buffer[index(i, j)]; + } + } + } + + // Replace buffer and update dimensions + m_buffer.swap(new_buffer); + m_rows = new_rows; + m_cols = new_cols; + m_buffer_rows = new_rows; + m_buffer_cols = new_cols; + } + // If dimensions unchanged, just compact buffer + else { + shrink_to_fit(); + } + } + + // Convenience overload for 1D shrink (just rows) + void shrink(size_t new_rows) { + shrink(new_rows, m_cols); + } + + /** + * Shrink buffer to exact size + * Warning: Invalidates pointers and defeats growth optimization + */ + void shrink_to_fit() + { + if (m_rows < m_buffer_rows || m_cols < m_buffer_cols) + { + std::vector new_buffer(m_rows * m_cols); + + // Copy data to compact buffer + for (size_t i = 0; i < m_rows; ++i) + { + if (std::is_trivially_copyable::value) + { + std::memcpy(&new_buffer[i * m_cols], &m_buffer[index(i, 0)], m_cols * sizeof(T)); + } + else + { + for (size_t j = 0; j < m_cols; ++j) + { + new_buffer[i * m_cols + j] = m_buffer[index(i, j)]; + } + } + } + + m_buffer.swap(new_buffer); + m_buffer_rows = m_rows; + m_buffer_cols = m_cols; + } + } + + // Dimension getters + size_t rows() const { return m_rows; } + size_t cols() const { return m_cols; } + size_t stride() const { return m_buffer_cols; } // for numpy stride calculationx + + /** + * Raw data access for numpy integration + * Returns pointer to start of buffer + * Note: stride() != cols() when buffer is over-allocated + */ + T *get_data_ptr() { return m_buffer.data(); } + const T *get_data_ptr() const { return m_buffer.data(); } + + // 2D element access ... + inline T &operator()(size_t i, size_t j) { return m_buffer[index(i, j)]; } + inline const T &operator()(size_t i, size_t j) const { return m_buffer[index(i, j)]; } + + // Overloads for int indices for backward compatibility. + inline T &operator()(int i, int j) { return operator()(static_cast(i), static_cast(j)); } + inline const T &operator()(int i, int j) const { return operator()(static_cast(i), static_cast(j)); } + + // mixed-type overloads to resolve ambiguity + inline T &operator()(size_t i, int j) { return operator()(i, static_cast(j));} + inline T &operator()(int i, size_t j) { return operator()(static_cast(i), j);} + + /** + * @brief Returns a copy of row i as std::vector. + */ + std::vector operator()(size_t i) const + { + std::vector row(m_cols); + for (size_t j = 0; j < m_cols; ++j) + { + row[j] = m_buffer[index(i, j)]; + } + return row; + } }; #endif diff --git a/brian2/devices/device.py b/brian2/devices/device.py index 5227928cc..ea6298757 100644 --- a/brian2/devices/device.py +++ b/brian2/devices/device.py @@ -324,6 +324,7 @@ def code_object( scalar_code, vector_code, kwds = generator.translate( abstract_code, dtype=prefs["core.default_float_dtype"] ) + # Add the array names as keywords as well for varname, var in variables.items(): if isinstance(var, ArrayVariable): @@ -334,6 +335,9 @@ def code_object( if hasattr(var, "resize"): dyn_array_name = generator.get_array_name(var, access_data=False) template_kwds[f"_dynamic_{varname}"] = dyn_array_name + template_kwds[f"_dynamic_{varname}_ptr"] = ( + f"{dyn_array_name}_ptr" # so we can access the right name of dynamic array pointer + ) template_kwds.update(kwds) logger.diagnostic( @@ -342,7 +346,6 @@ def code_object( logger.diagnostic( f"{name} snippet (vector):\n{indent(code_representation(vector_code))}" ) - code = template( scalar_code, vector_code, @@ -505,6 +508,7 @@ def get_array_name(self, var, access_data=True): return f"_array_{owner_name}_{var.name}" else: return f"_dynamic_array_{owner_name}_{var.name}" + elif isinstance(var, ArrayVariable): return f"_array_{owner_name}_{var.name}" else: @@ -528,6 +532,28 @@ def get_value(self, var, access_data=True): else: return self.arrays[var] + def get_capsule(self, var): + """ + Get a PyCapsule object for direct C++ pointer access to dynamic arrays. + + Parameters + ---------- + var : DynamicArrayVariable + The dynamic array variable to get the capsule for. + + Returns + ------- + capsule : PyCapsule + A PyCapsule containing the C++ pointer to the dynamic array. + """ + if not isinstance(var, DynamicArrayVariable): + raise TypeError( + f"get_capsule only supports DynamicArrayVariable, got {type(var)}" + ) + + array_obj = self.arrays[var] + return array_obj.get_capsule() + def set_value(self, var, value): self.arrays[var][:] = value diff --git a/brian2/memory/cythondynamicarray.pyx b/brian2/memory/cythondynamicarray.pyx new file mode 100644 index 000000000..198a54ee9 --- /dev/null +++ b/brian2/memory/cythondynamicarray.pyx @@ -0,0 +1,498 @@ +# cython: language_level=3 +# distutils: language = c++ +# distutils: include_dirs = brian2/devices/cpp_standalone/brianlib +# distutils: extra_compile_args = -std=c++11 + + +import numpy as np +cimport numpy as cnp +cimport cython +from libc.string cimport memset +from libc.stdint cimport int64_t, int32_t +from cython cimport view +from cpython.pycapsule cimport PyCapsule_New +from cpython.ref cimport PyTypeObject + +cnp.import_array() + +cdef extern from "numpy/ndarrayobject.h": + object PyArray_NewFromDescr(PyTypeObject* subtype, + cnp.PyArray_Descr* descr, + int nd, + cnp.npy_intp* dims, + cnp.npy_intp* strides, + void* data, + int flags, + object obj) + cnp.PyArray_Descr* PyArray_DescrFromType(int) + +cdef extern from "numpy/ndarraytypes.h": + void PyArray_CLEARFLAGS(cnp.PyArrayObject *arr, int flags) + enum: + NPY_ARRAY_C_CONTIGUOUS + NPY_ARRAY_F_CONTIGUOUS + NPY_ARRAY_OWNDATA + NPY_ARRAY_WRITEABLE + NPY_ARRAY_ALIGNED + NPY_ARRAY_WRITEBACKIFCOPY + NPY_ARRAY_UPDATEIFCOPY + +cdef extern from "dynamic_array.h": + cdef cppclass DynamicArray1DCpp "DynamicArray1D"[T]: + DynamicArray1DCpp(size_t,double) except + + void resize(size_t) except + + void shrink_to_fit() + void shrink(size_t) except + + T& operator[](size_t) + T* get_data_ptr() + size_t size() + size_t capacity() + + + cdef cppclass DynamicArray2DCpp "DynamicArray2D"[T]: + size_t n # rows + size_t m # cols + DynamicArray2DCpp(size_t, size_t, double) except + + DynamicArray2DCpp(int, int) except + # Legacy constructor + void resize(size_t, size_t) except + + void resize(int, int) except + # Legacy method + void resize() except + + void resize_along_first(size_t) except + + void shrink_to_fit() + void shrink(size_t, size_t) except + + void shrink(size_t) except + + T& operator()(size_t, size_t) + T& operator()(int, int) + T* get_data_ptr() + size_t rows() + size_t cols() + size_t stride() + + +# We have to define a mapping for numpy dtypes to our class +cdef dict NUMPY_TYPE_MAP = { + np.float64: cnp.NPY_DOUBLE, + np.float32: cnp.NPY_FLOAT, + np.int32: cnp.NPY_INT32, + np.int64: cnp.NPY_INT64, + np.bool_: cnp.NPY_BOOL +} + + +cdef class DynamicArray1DClass: + cdef void* thisptr + cdef int numpy_type + cdef object dtype + cdef double factor + + def __cinit__(self,size_t intial_size, dtype = np.float64, double factor=2.0): + self.dtype = np.dtype(dtype) + self.factor = factor + self.numpy_type = NUMPY_TYPE_MAP[self.dtype.type] + + if self.dtype == np.float64: + self.thisptr = new DynamicArray1DCpp[double](intial_size,factor) + elif self.dtype == np.float32: + self.thisptr = new DynamicArray1DCpp[float](intial_size,factor) + elif self.dtype == np.int32: + self.thisptr = new DynamicArray1DCpp[int32_t](intial_size,factor) + elif self.dtype == np.int64: + self.thisptr = new DynamicArray1DCpp[int64_t](intial_size,factor) + elif self.dtype == np.bool_: + # When asked for a bool array, we create a char array in C++ as for bool, C++ tries to optimize and save memory by packing all the boolean values tightly — 1 bit per value, instead of 1 byte. + self.thisptr = new DynamicArray1DCpp[char](intial_size,factor) + else: + raise TypeError("Unsupported dtype: {}".format(self.dtype)) + + def __dealloc__(self): + cdef DynamicArray1DCpp[double]* ptr_double + cdef DynamicArray1DCpp[float]* ptr_float + cdef DynamicArray1DCpp[int32_t]* ptr_int + cdef DynamicArray1DCpp[int64_t]* ptr_long + cdef DynamicArray1DCpp[char]* ptr_bool + if self.thisptr != NULL: + if self.dtype == np.float64: + ptr_double = self.thisptr + del ptr_double + elif self.dtype == np.float32: + ptr_float = self.thisptr + del ptr_float + elif self.dtype == np.int32: + ptr_int = self.thisptr + del ptr_int + elif self.dtype == np.int64: + ptr_long = self.thisptr + del ptr_long + elif self.dtype == np.bool_: + ptr_bool = self.thisptr + del ptr_bool + + + cdef void* get_data_ptr(self) : + """C-level access to data pointer""" + if self.dtype == np.float64: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.float32: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.int32: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.int64: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.bool_: + return (self.thisptr).get_data_ptr() + return NULL + + def get_capsule(self): + """ + Returns a PyCapsule object wrapping the underlying C++ Dynamic1D Array class pointer. + + PyCapsules are used to safely pass raw C/C++ pointers between Python modules + or C extensions without exposing the actual implementation details to Python. + """ + return PyCapsule_New(self.thisptr, "DynamicArray1D", NULL) + + cdef size_t get_size(self): + """C-level access to size""" + if self.dtype == np.float64: + return (self.thisptr).size() + elif self.dtype == np.float32: + return (self.thisptr).size() + elif self.dtype == np.int32: + return (self.thisptr).size() + elif self.dtype == np.int64: + return (self.thisptr).size() + elif self.dtype == np.bool_: + return (self.thisptr).size() + return 0 + + def resize(self, size_t new_size): + """Resize array to new size""" + if self.dtype == np.float64: + (self.thisptr).resize(new_size) + elif self.dtype == np.float32: + (self.thisptr).resize(new_size) + elif self.dtype == np.int32: + (self.thisptr).resize(new_size) + elif self.dtype == np.int64: + (self.thisptr).resize(new_size) + elif self.dtype == np.bool_: + (self.thisptr).resize(new_size) + + def shrink(self, size_t new_size): + """Shrink array to exact new size, freeing unused memory""" + if self.dtype == np.float64: + (self.thisptr).shrink(new_size) + elif self.dtype == np.float32: + (self.thisptr).shrink(new_size) + elif self.dtype == np.int32: + (self.thisptr).shrink(new_size) + elif self.dtype == np.int64: + (self.thisptr).shrink(new_size) + elif self.dtype == np.bool_: + (self.thisptr).shrink(new_size) + + @property + def data(self): + """Return numpy array view of underlying data""" + cdef cnp.npy_intp shape[1] + cdef size_t size = self.get_size() + cdef void* data_ptr = self.get_data_ptr() + + shape[0] = size + if size == 0: + return np.array([], dtype=self.dtype) + # Note : This creates a zero-copy NumPy view over the memory allocated by the C++ backend — + # changes to the NumPy array will reflect in the C++ array and vice versa. + return cnp.PyArray_SimpleNewFromData(1, shape, self.numpy_type, data_ptr) + + @property + def shape(self): + return (self.get_size(),) + + def __getitem__(self, item): + return self.data[item] + + def __setitem__(self, item, val): + cdef cnp.ndarray arr = self.data + arr[item] = val + + def __len__(self): + return self.get_size() + + +cdef class DynamicArray2DClass: + cdef void* thisptr + cdef int numpy_type + cdef object dtype + cdef double factor + + def __cinit__(self, tuple shape, dtype=np.float64, double factor=2.0): + cdef size_t rows = shape[0] if len(shape) > 0 else 0 + cdef size_t cols = shape[1] if len(shape) > 1 else 0 + + self.dtype = np.dtype(dtype) + self.factor = factor + self.numpy_type = NUMPY_TYPE_MAP[self.dtype.type] + + if self.dtype == np.float64: + self.thisptr = new DynamicArray2DCpp[double](rows, cols, factor) + elif self.dtype == np.float32: + self.thisptr = new DynamicArray2DCpp[float](rows, cols, factor) + elif self.dtype == np.int32: + self.thisptr = new DynamicArray2DCpp[int32_t](rows, cols, factor) + elif self.dtype == np.int64: + self.thisptr = new DynamicArray2DCpp[int64_t](rows, cols, factor) + elif self.dtype == np.bool_: + # When asked for a bool array, we create a char array in C++ as for bool, C++ tries to optimize and save memory by packing all the boolean values tightly — 1 bit per value, instead of 1 byte. + self.thisptr = new DynamicArray2DCpp[char](rows, cols, factor) + else: + raise TypeError(f"Unsupported dtype: {dtype}") + + def __dealloc__(self): + cdef DynamicArray2DCpp[double]* ptr_double + cdef DynamicArray2DCpp[float]* ptr_float + cdef DynamicArray2DCpp[int32_t]* ptr_int + cdef DynamicArray2DCpp[int64_t]* ptr_long + cdef DynamicArray2DCpp[char]* ptr_bool + if self.thisptr != NULL: + if self.dtype == np.float64: + ptr_double = self.thisptr + del ptr_double + elif self.dtype == np.float32: + ptr_float = self.thisptr + del ptr_float + elif self.dtype == np.int32: + ptr_int = self.thisptr + del ptr_int + elif self.dtype == np.int64: + ptr_long = self.thisptr + del ptr_long + elif self.dtype == np.bool_: + ptr_bool = self.thisptr + del ptr_bool + + def get_capsule(self): + """ + Returns a PyCapsule object wrapping the underlying C++ Dynamic1D Array class pointer. + + PyCapsules are used to safely pass raw C/C++ pointers between Python modules + or C extensions without exposing the actual implementation details to Python. + """ + return PyCapsule_New(self.thisptr, "DynamicArray2D", NULL) + + cdef void* get_data_ptr(self): + """C-level access to data pointer""" + if self.dtype == np.float64: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.float32: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.int32: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.int64: + return (self.thisptr).get_data_ptr() + elif self.dtype == np.bool_: + return (self.thisptr).get_data_ptr() + return NULL + + cdef size_t get_rows(self): + """C-level access to rows""" + if self.dtype == np.float64: + return (self.thisptr).rows() + elif self.dtype == np.float32: + return (self.thisptr).rows() + elif self.dtype == np.int32: + return (self.thisptr).rows() + elif self.dtype == np.int64: + return (self.thisptr).rows() + elif self.dtype == np.bool_: + return (self.thisptr).rows() + return 0 + + cdef size_t get_cols(self): + """C-level access to cols""" + if self.dtype == np.float64: + return (self.thisptr).cols() + elif self.dtype == np.float32: + return (self.thisptr).cols() + elif self.dtype == np.int32: + return (self.thisptr).cols() + elif self.dtype == np.int64: + return (self.thisptr).cols() + elif self.dtype == np.bool_: + return (self.thisptr).cols() + return 0 + + + cdef size_t get_stride(self): + """C-level access to stride""" + if self.dtype == np.float64: + return (self.thisptr).stride() + elif self.dtype == np.float32: + return (self.thisptr).stride() + elif self.dtype == np.int32: + return (self.thisptr).stride() + elif self.dtype == np.int64: + return (self.thisptr).stride() + elif self.dtype == np.bool_: + return (self.thisptr).stride() + return 0 + + def resize(self, new_shape): + if isinstance(new_shape, (tuple, list)) and new_shape[1] != self.get_cols(): + raise ValueError("Resizing is only supported along the first dimension") + self.resize_along_first(new_shape) + + def resize_along_first(self, new_shape): + """Resize along first dimension (rows), keeping columns unchanged""" + if isinstance(new_shape, int): + new_rows = new_shape + elif isinstance(new_shape, (tuple, list)): + new_rows = new_shape[0] + else: + raise ValueError("new_shape must be int, tuple, or list") + + cdef size_t rows = new_rows + if self.dtype == np.float64: + (self.thisptr).resize_along_first(rows) + elif self.dtype == np.float32: + (self.thisptr).resize_along_first(rows) + elif self.dtype == np.int32: + (self.thisptr).resize_along_first(rows) + elif self.dtype == np.int64: + (self.thisptr).resize_along_first(rows) + elif self.dtype == np.bool_: + (self.thisptr).resize_along_first(rows) + + def shrink(self, new_shape): + """Shrink array to exact new shape, freeing unused memory""" + cdef size_t new_rows + cdef size_t new_cols + if isinstance(new_shape, int): + # Shrink just rows, keep cols + new_rows = new_shape + if self.dtype == np.float64: + (self.thisptr).shrink(new_rows) + elif self.dtype == np.float32: + (self.thisptr).shrink(new_rows) + elif self.dtype == np.int32: + (self.thisptr).shrink(new_rows) + elif self.dtype == np.int64: + (self.thisptr).shrink(new_rows) + elif self.dtype == np.bool_: + (self.thisptr).shrink(new_rows) + else: + # Shrink both dimensions + new_rows = new_shape[0] + new_cols = new_shape[1] + if self.dtype == np.float64: + (self.thisptr).shrink(new_rows, new_cols) + elif self.dtype == np.float32: + (self.thisptr).shrink(new_rows, new_cols) + elif self.dtype == np.int32: + (self.thisptr).shrink(new_rows, new_cols) + elif self.dtype == np.int64: + (self.thisptr).shrink(new_rows, new_cols) + elif self.dtype == np.bool_: + (self.thisptr).shrink(new_rows, new_cols) + + @property + def data(self): + """ + The magic getter! This creates a zero-copy NumPy 'view' of our C++ data. + It's not a copy; it's a direct window into the C++ memory, which is why it's so fast. + Every time our code accesses `my_array.data`, this code runs to build that view on the fly. + """ + # First, what's the logical shape the user sees,we get it ... + cdef size_t rows = self.get_rows() + cdef size_t cols = self.get_cols() + # Now, the two most important pieces for our zero-copy trick: + # 1. The actual memory address where our data lives in C++. + cdef void* data_ptr = self.get_data_ptr() + # 2. The *physical* width of a row in memory. This might be wider than `cols` + # if we've over-allocated space to make future growth faster. + cdef size_t stride = self.get_stride() + # How many bytes does one element take up? (e.g., 8 for a float64) + cdef size_t itemsize = self.dtype.itemsize + + # --- Now we create the "map" that tells NumPy how to navigate our C++ memory correctly --- + + # These are C-style arrays to hold the shape and the "stride map". + cdef cnp.npy_intp shape[2] + cdef cnp.npy_intp strides[2] + + # So the shape is easy as it's just the logical dimensions. + shape[0] = rows + shape[1] = cols + + # Now, the stride map. This tells NumPy how many *bytes* to jump to move through the data. + # To move to the next item in the same row (j -> j+1), just jump by one item's size. + strides[1] = itemsize + # To move to the *next row* (i -> i+1), we have to jump over a whole physical row in memory. + strides[0] = stride * itemsize + + # We also need to describe our data type (e.g., float64) to NumPy in its native C language. + cdef cnp.PyArray_Descr* descr = PyArray_DescrFromType(self.numpy_type) + + # Now we set the permissions and properties for our numpy view + # Let's start with a crucial permission: making the array writeable! + # Without this, NumPy would make it read-only, and `arr[i] = x` would fail. + cdef int flags = cnp.NPY_ARRAY_WRITEABLE + + # A little optimization: if the memory is perfectly packed (no extra space in rows), + # we can tell NumPy it's "C-contiguous". This can speed up some operations. + if stride == cols: + flags |= cnp.NPY_ARRAY_C_CONTIGUOUS + + # Here we call the master C-API function, we give it: + # the memory pointer, the shape map, the stride map, the data type, and the permissions. + cdef cnp.ndarray result = PyArray_NewFromDescr( + np.ndarray, + descr, + 2, + shape, + strides, + data_ptr, + flags, # Use our flags variable + None + ) + + # By default, NumPy assumes it owns the data and will try to free it later. + # But *our* C++ vector owns it! Clearing this flag prevents a double-free, which would crash the program. + cnp.PyArray_CLEARFLAGS(result, cnp.NPY_ARRAY_OWNDATA) + return result + + @property + def shape(self): + return (self.get_rows(), self.get_cols()) + + def __getitem__(self, item): + return self.data[item] + + def __setitem__(self, item, val): + cdef cnp.ndarray arr = self.data + arr[item] = val + + def __len__(self): + return self.get_rows() + + +# Factory functions matching original API we had in python code +def DynamicArray(shape, dtype=float, factor=2, use_numpy_resize=False, refcheck=True): + """Create appropriate dynamic array based on shape""" + if isinstance(shape, int): + shape = (shape,) + + if len(shape) == 1: + return DynamicArray1DClass(shape[0], dtype, factor) + elif len(shape) == 2: + return DynamicArray2DClass(shape, dtype, factor) + else: + raise ValueError( + f"DynamicArray only supports 1D or 2D shapes. Got shape={shape} (dim={len(shape)})" + ) + +def DynamicArray1D(shape, dtype=float, factor=2, use_numpy_resize=False, refcheck=True): + """Create 1D dynamic array""" + if isinstance(shape, int): + shape = (shape,) + return DynamicArray1DClass(shape[0], dtype, factor) diff --git a/brian2/memory/dynamicarray.py b/brian2/memory/dynamicarray.py index 59bdf4175..732d3a238 100644 --- a/brian2/memory/dynamicarray.py +++ b/brian2/memory/dynamicarray.py @@ -1,214 +1,9 @@ -""" -TODO: rewrite this (verbatim from Brian 1.x), more efficiency -""" - -import numpy as np +try: + from .cythondynamicarray import DynamicArray, DynamicArray1D +except ImportError as e: + raise ImportError( + "DynamicArray is now compiled from Cython. Please ensure the extension is built.\n" + "If you're running from source, try: pip install -e ." + ) from e __all__ = ["DynamicArray", "DynamicArray1D"] - - -def getslices(shape, from_start=True): - if from_start: - return tuple(slice(0, x) for x in shape) - else: - return tuple(slice(x, None) for x in shape) - - -class DynamicArray: - """ - An N-dimensional dynamic array class - - The array can be resized in any dimension, and the class will handle - allocating a new block of data and copying when necessary. - - .. warning:: - The data will NOT be contiguous for >1D arrays. To ensure this, you will - either need to use 1D arrays, or to copy the data, or use the shrink - method with the current size (although note that in both cases you - negate the memory and efficiency benefits of the dynamic array). - - Initialisation arguments: - - ``shape``, ``dtype`` - The shape and dtype of the array to initialise, as in Numpy. For 1D - arrays, shape can be a single int, for ND arrays it should be a tuple. - ``factor`` - The resizing factor (see notes below). Larger values tend to lead to - more wasted memory, but more computationally efficient code. - ``use_numpy_resize``, ``refcheck`` - Normally, when you resize the array it creates a new array and copies - the data. Sometimes, it is possible to resize an array without a copy, - and if this option is set it will attempt to do this. However, this can - cause memory problems if you are not careful so the option is off by - default. You need to ensure that you do not create slices of the array - so that no references to the memory exist other than the main array - object. If you are sure you know what you're doing, you can switch this - reference check off. Note that resizing in this way is only done if you - resize in the first dimension. - - The array is initialised with zeros. The data is stored in the attribute - ``data`` which is a Numpy array. - - - Some numpy methods are implemented and can work directly on the array object, - including ``len(arr)``, ``arr[...]`` and ``arr[...]=...``. In other cases, - use the ``data`` attribute. - - Examples - -------- - - >>> x = DynamicArray((2, 3), dtype=int) - >>> x[:] = 1 - >>> x.resize((3, 3)) - >>> x[:] += 1 - >>> x.resize((3, 4)) - >>> x[:] += 1 - >>> x.resize((4, 4)) - >>> x[:] += 1 - >>> x.data[:] = x.data**2 - >>> x.data - array([[16, 16, 16, 4], - [16, 16, 16, 4], - [ 9, 9, 9, 4], - [ 1, 1, 1, 1]]) - - Notes - ----- - - The dynamic array returns a ``data`` attribute which is a view on the larger - ``_data`` attribute. When a resize operation is performed, and a specific - dimension is enlarged beyond the size in the ``_data`` attribute, the size - is increased to the larger of ``cursize*factor`` and ``newsize``. This - ensures that the amortized cost of increasing the size of the array is O(1). - """ - - def __init__( - self, shape, dtype=float, factor=2, use_numpy_resize=False, refcheck=True - ): - if isinstance(shape, int): - shape = (shape,) - self._data = np.zeros(shape, dtype=dtype) - self.data = self._data - self.dtype = dtype - self.shape = self._data.shape - self.factor = factor - self.use_numpy_resize = use_numpy_resize - self.refcheck = refcheck - - def resize(self, newshape): - """ - Resizes the data to the new shape, which can be a different size to the - current data, but should have the same rank, i.e. same number of - dimensions. - """ - datashapearr = np.array(self._data.shape) - newshapearr = np.array(newshape) - resizedimensions = newshapearr > datashapearr - if resizedimensions.any(): - # resize of the data is needed - minnewshapearr = datashapearr # .copy() - dimstoinc = minnewshapearr[resizedimensions] - incdims = np.array(dimstoinc * self.factor, dtype=int) - newdims = np.maximum(incdims, dimstoinc + 1) - minnewshapearr[resizedimensions] = newdims - newshapearr = np.maximum(newshapearr, minnewshapearr) - do_resize = False - if self.use_numpy_resize and self._data.flags["C_CONTIGUOUS"]: - if sum(resizedimensions) == resizedimensions[0]: - do_resize = True - if do_resize: - self.data = None - self._data.resize(tuple(newshapearr), refcheck=self.refcheck) - else: - newdata = np.zeros(tuple(newshapearr), dtype=self.dtype) - slices = getslices(self._data.shape) - newdata[slices] = self._data - self._data = newdata - elif (newshapearr < self.shape).any(): - # If we reduced the size, set the no longer used memory to 0 - self._data[getslices(newshape, from_start=False)] = 0 - # Reduce our view to the requested size if necessary - self.data = self._data[getslices(newshape, from_start=True)] - self.shape = self.data.shape - - def resize_along_first(self, newshape): - new_dimension = newshape[0] - if new_dimension > self._data.shape[0]: - new_size = np.maximum(self._data.shape[0] * self.factor, new_dimension + 1) - final_new_shape = np.array(self._data.shape) - final_new_shape[0] = new_size - if self.use_numpy_resize and self._data.flags["C_CONTIGUOUS"]: - self.data = None - self._data.resize(tuple(final_new_shape), refcheck=self.refcheck) - else: - newdata = np.zeros(tuple(final_new_shape), dtype=self.dtype) - slices = getslices(self._data.shape) - newdata[slices] = self._data - self._data = newdata - elif newshape < self.shape: - # If we reduced the size, set the no longer used memory to 0 - self._data[new_dimension:] = 0 - # Reduce our view to the requested size if necessary - self.data = self._data[:new_dimension] - self.shape = newshape - - def shrink(self, newshape): - """ - Reduces the data to the given shape, which should be smaller than the - current shape. `resize` can also be used with smaller values, but - it will not shrink the allocated memory, whereas `shrink` will - reallocate the memory. This method should only be used infrequently, as - if it is used frequently it will negate the computational efficiency - benefits of the DynamicArray. - """ - if isinstance(newshape, int): - newshape = (newshape,) - shapearr = np.array(self.shape) - newshapearr = np.array(newshape) - if (newshapearr <= shapearr).all(): - newdata = np.zeros(newshapearr, dtype=self.dtype) - newdata[:] = self._data[getslices(newshapearr)] - self._data = newdata - self.shape = tuple(newshapearr) - self.data = self._data - - def __getitem__(self, item): - return self.data.__getitem__(item) - - def __setitem__(self, item, val): - self.data.__setitem__(item, val) - - def __len__(self): - return len(self.data) - - def __str__(self): - return self.data.__str__() - - def __repr__(self): - return self.data.__repr__() - - -class DynamicArray1D(DynamicArray): - """ - Version of `DynamicArray` with specialised ``resize`` method designed - to be more efficient. - """ - - def resize(self, newshape): - (datashape,) = self._data.shape - if newshape > datashape: - (shape,) = self.shape # we work with int shapes only - newdatashape = max(newshape, int(shape * self.factor) + 1) - if self.use_numpy_resize and self._data.flags["C_CONTIGUOUS"]: - self.data = None - self._data.resize(newdatashape, refcheck=self.refcheck) - else: - newdata = np.zeros(newdatashape, dtype=self.dtype) - newdata[:shape] = self.data - self._data = newdata - elif newshape < self.shape[0]: - # If we reduced the size, set the no longer used memory to 0 - self._data[newshape:] = 0 - # Reduce our view to the requested size if necessary - self.data = self._data[:newshape] - self.shape = (newshape,) diff --git a/brian2/monitors/spikemonitor.py b/brian2/monitors/spikemonitor.py index 5c899d4d5..9780bde46 100644 --- a/brian2/monitors/spikemonitor.py +++ b/brian2/monitors/spikemonitor.py @@ -394,6 +394,16 @@ def __repr__(self): classname = self.__class__.__name__ return f"<{classname}, recording event '{self.event}' from '{self.group.name}'>" + def after_run(self): + super().after_run() + # In Cython runtime mode, we directly update the underlying dynamic array, + # so the size attribute of the Variable does not get updated automatically + for var in self.record_variables: + try: + self.variables[var].size = len(self.variables[var].get_value()) + except NotImplementedError: + pass # Does not apply to standalone mode + class SpikeMonitor(EventMonitor): """ diff --git a/brian2/monitors/statemonitor.py b/brian2/monitors/statemonitor.py index ac7556d1c..3e75993fe 100644 --- a/brian2/monitors/statemonitor.py +++ b/brian2/monitors/statemonitor.py @@ -425,3 +425,16 @@ def record_single_timestep(self): "network has been run once." ) self.codeobj() + + def after_run(self): + super().after_run() + # In Cython runtime mode, we directly update the underlying dynamic array, + # so the size attribute of the Variable does not get updated automatically + for var in ["t"] + list(self.record_variables): + try: + val = self.variables[var].get_value() + # Note : For 1D arrays : size = length (integer) + # For 2D arrays: size = shape( tuple) + self.variables[var].size = val.shape if val.ndim > 1 else len(val) + except (KeyError, NotImplementedError): + pass # Does not apply to standalone mode diff --git a/brian2/tests/test_cpp_standalone.py b/brian2/tests/test_cpp_standalone.py index 0bce5af12..b1be050b2 100644 --- a/brian2/tests/test_cpp_standalone.py +++ b/brian2/tests/test_cpp_standalone.py @@ -157,7 +157,6 @@ def test_openmp_consistency(): previous_device = get_device() n_cells = 100 n_recorded = 10 - numpy.random.seed(42) taum = 20 * ms taus = 5 * ms Vt = -50 * mV @@ -172,6 +171,13 @@ def test_openmp_consistency(): dApost *= 0.1 * gmax dApre *= 0.1 * gmax + eqs = Equations( + """ + dv/dt = (g-(v-El))/taum : volt + dg/dt = -g/taus : volt + """ + ) + numpy.random.seed(42) connectivity = numpy.random.randn(n_cells, n_cells) sources = numpy.random.randint(0, n_cells - 1, 10 * n_cells) # Only use one spike per time step (to rule out that a single source neuron @@ -182,13 +188,6 @@ def test_openmp_consistency(): ) v_init = Vr + numpy.random.rand(n_cells) * (Vt - Vr) - eqs = Equations( - """ - dv/dt = (g-(v-El))/taum : volt - dg/dt = -g/taus : volt - """ - ) - results = {} for n_threads, devicename in [ @@ -200,6 +199,7 @@ def test_openmp_consistency(): (4, "cpp_standalone"), ]: set_device(devicename, build_on_run=False, with_output=False) + # clear all instances Synapses.__instances__().clear() if devicename == "cpp_standalone": reinit_and_delete() @@ -208,13 +208,13 @@ def test_openmp_consistency(): n_cells, model=eqs, threshold="v>Vt", reset="v=Vr", refractory=5 * ms ) Q = SpikeGeneratorGroup(n_cells, sources, times) - P.v = v_init + P.v = v_init.copy() # Use copy to avoid reference issues P.g = 0 * mV S = Synapses( P, P, model=""" - dApre/dt=-Apre/taupre : 1 (event-driven) + dApre/dt=-Apre/taupre : 1 (event-driven) dApost/dt=-Apost/taupost : 1 (event-driven) w : 1 """, @@ -247,11 +247,12 @@ def test_openmp_consistency(): device.build(directory=None, with_output=False) results[n_threads, devicename] = {} - results[n_threads, devicename]["w"] = state_mon.w - results[n_threads, devicename]["v"] = v_mon.v + results[n_threads, devicename]["w"] = state_mon.w.copy() + results[n_threads, devicename]["v"] = v_mon.v.copy() results[n_threads, devicename]["s"] = spike_mon.num_spikes - results[n_threads, devicename]["r"] = rate_mon.rate[:] + results[n_threads, devicename]["r"] = rate_mon.rate[:].copy() + # Now run the assertions for key1, key2 in [ ((0, "runtime"), (0, "cpp_standalone")), ((1, "cpp_standalone"), (0, "cpp_standalone")), diff --git a/brian2/tests/test_memory.py b/brian2/tests/test_memory.py index 1c15abc1a..e3ac5e383 100644 --- a/brian2/tests/test_memory.py +++ b/brian2/tests/test_memory.py @@ -60,12 +60,12 @@ def test_dynamic_array_1d_shrink(): assert len(da) == 5 assert all(da[:] == np.arange(5)) # After using shrink, the underlying array should have changed - assert len(da._data) == 5 + assert len(da.data) == 5 @pytest.mark.codegen_independent def test_dynamic_array_2d_access(): - da = DynamicArray1D((10, 20)) + da = DynamicArray((10, 20)) da[:, :] = np.arange(200).reshape((10, 20)) assert da[5, 10] == 5 * 20 + 10 assert da.shape == (10, 20) @@ -77,37 +77,52 @@ def test_dynamic_array_2d_access(): @pytest.mark.codegen_independent -def test_dynamic_array_2d_resize_up_down(): +def test_dynamic_array_2d_resize_rows_only(): for numpy_resize in [True, False]: da = DynamicArray((10, 20), use_numpy_resize=numpy_resize, refcheck=False) da[:, :] = np.arange(200).reshape((10, 20)) + # Resize rows up da.resize((15, 20)) assert da.shape == (15, 20) assert_equal(da[10:, :], np.zeros((5, 20))) assert_equal(da[:10, :], np.arange(200).reshape((10, 20))) - da.resize((15, 25)) - assert da.shape == (15, 25) - assert_equal(da[:10, 20:], np.zeros((10, 5))) - assert_equal(da[:10, :20], np.arange(200).reshape((10, 20))) + # Resize rows down + da.resize((10, 20)) da.resize((10, 20)) assert da.shape == (10, 20) assert_equal(da[:, :], np.arange(200).reshape((10, 20))) +@pytest.mark.codegen_independent +def test_dynamic_array_2d_resize_columns_fails(): + da = DynamicArray((10, 20)) + da[:, :] = np.arange(200).reshape((10, 20)) + + # Attempting to resize columns should raise ValueError + with pytest.raises( + ValueError, match="Resizing is only supported along the first dimension" + ): + da.resize((10, 25)) + + # Attempting to resize both dimensions should also raise ValueError + with pytest.raises( + ValueError, match="Resizing is only supported along the first dimension" + ): + da.resize((15, 25)) + + @pytest.mark.codegen_independent def test_dynamic_array_2d_resize_down_up(): for numpy_resize in [True, False]: da = DynamicArray((10, 20), use_numpy_resize=numpy_resize, refcheck=False) da[:, :] = np.arange(200).reshape((10, 20)) + + # Resize rows down da.resize((5, 20)) assert da.shape == (5, 20) assert_equal(da, np.arange(100).reshape((5, 20))) - da.resize((5, 15)) - assert da.shape == (5, 15) - for row_idx, row in enumerate(da): - assert_equal(row, 20 * row_idx + np.arange(15)) - + # Resize rows back up da.resize((10, 20)) assert da.shape == (10, 20) for row_idx, row in enumerate(da[:5, :15]): @@ -123,7 +138,7 @@ def test_dynamic_array_2d_shrink(): da.shrink((5, 15)) assert da.shape == (5, 15) # After using shrink, the underlying array should have changed - assert da._data.shape == (5, 15) + assert da.data.shape == (5, 15) assert_equal( da[:, :], np.arange(15).reshape((1, 15)) + 20 * np.arange(5).reshape((5, 1)) ) @@ -135,6 +150,7 @@ def test_dynamic_array_2d_shrink(): test_dynamic_array_1d_resize_down_up() test_dynamic_array_1d_shrink() test_dynamic_array_2d_access() - test_dynamic_array_2d_resize_up_down() + test_dynamic_array_2d_resize_rows_only() + test_dynamic_array_2d_resize_columns_fails() test_dynamic_array_2d_resize_down_up() test_dynamic_array_2d_shrink() diff --git a/brian2/tests/test_subgroup.py b/brian2/tests/test_subgroup.py index c3e99664b..fff3e4382 100644 --- a/brian2/tests/test_subgroup.py +++ b/brian2/tests/test_subgroup.py @@ -121,15 +121,21 @@ def test_state_variables_group_as_index_problematic(): G = NeuronGroup(10, "v : 1") SG = G[4:9] G.v = 1 - tests = [("i", 1), ("N", 1), ("N + i", 2), ("v", 0)] - for value, n_warnings in tests: + tests = [ + ("i", 1), + ("N", 1), + ("N + i", 2), + ("v", 0), + ] + for value, n_ambiguous in tests: with catch_logs() as l: G.v.__setitem__(SG, value) - assert ( - len(l) == n_warnings - ), f"expected {int(n_warnings)}, got {len(l)} warnings" - assert all( - [entry[1].endswith("ambiguous_string_expression") for entry in l] + ambiguous_found = sum( + [1 for entry in l if entry[1].endswith("ambiguous_string_expression")] + ) + assert ambiguous_found == n_ambiguous, ( + f"Expected {n_ambiguous} ambiguous warnings for value '{value}', " + f"but got {ambiguous_found}" ) diff --git a/brian2/tests/test_synapses.py b/brian2/tests/test_synapses.py index 4e4813693..b2ca3ab93 100644 --- a/brian2/tests/test_synapses.py +++ b/brian2/tests/test_synapses.py @@ -2224,7 +2224,7 @@ def numerically_check_permutation_code(code): ns = vals.copy() ns["shuffled_indices"] = arange(9) ns["presyn"] = arange(9) % 3 - ns["postsyn"] = arange(9) / 3 + ns["postsyn"] = arange(9) // 3 for _ in range(10): origvals = {} for k, v in vals.items(): diff --git a/setup.py b/setup.py index 7d630a27a..816e1b92e 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ from typing import List # A Helper function to require cython extension -def require_cython_extension(module_path, module_name): +def require_cython_extension(module_path, module_name,extra_include_dirs=None): """ Create a cythonized Extension object from a .pyx source. """ @@ -22,8 +22,11 @@ def require_cython_extension(module_path, module_name): # Module name for setuptools full_module_name = ".".join(module_path + [module_name]) - ext = Extension(full_module_name, [pyx_file], include_dirs=[ - numpy.get_include()],) + include_dirs = [numpy.get_include()] + if extra_include_dirs: + include_dirs.extend(extra_include_dirs) + + ext = Extension(full_module_name, [pyx_file],include_dirs=include_dirs) return ext @@ -35,7 +38,16 @@ def require_cython_extension(module_path, module_name): module_path=["brian2", "synapses"], module_name="cythonspikequeue", ) + extensions.append(spike_queue_ext) +dynamic_array_ext = require_cython_extension( + module_path=["brian2", "memory"], + module_name="cythondynamicarray", + extra_include_dirs=["brian2/devices/cpp_standalone/brianlib"] +) + +extensions.append(dynamic_array_ext) + setup(ext_modules=extensions)