Skip to content

Conversation

Legend101Zz
Copy link
Contributor

@Legend101Zz Legend101Zz commented Jun 27, 2025

C++ Dynamic Arrays for JIT Replacement

As part of our ongoing effort to replace Brian’s current Cython-based just-in-time (JIT) compilation mechanism, this PR introduces the first draft implementation of new C++-based dynamic arrays for use in the runtime system.

This is an initial step toward our broader goal of replacing Python-based runtime data structures (like DynamicArray, SpikeQueue, etc.) with fully native C++ equivalents. The long-term plan is to unify runtime and standalone modes using shared C++ templates—eliminating Cython entirely from the core simulation loop.


@Legend101Zz Legend101Zz marked this pull request as ready for review July 9, 2025 06:49
@Legend101Zz
Copy link
Contributor Author

Legend101Zz commented Jul 9, 2025


@mstimberg Finally got it working! 🎉
After battling countless bugs and syntax errors (and with my IDE refusing to play nice with Cython 🥲 , so had to catpture the errors while building itself only ), I finally managed to build the cythondynamicarray module and generate the .cpp and .so files successfully. 🥳

Screenshot 2025-07-09 at 19 08 00


@mstimberg
Copy link
Member

As I mentioned in a comment, I will only have a closer look at the code later, but I just made a small commit to fix a test suite issue (it was using Brian directly from source to get the location of the Cython cache dir, but importing Brian from source now fails). If the tests still fail, it will be about actual problems in this PR, I hope 😉

Copy link
Member

@mstimberg mstimberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments below – things seem to be a bit broken at the moment, but it should be easier to debug them now that the tests are correctly working again 😊

* outside the logical size. To actually release unused memory,
* call `shrink_to_fit()`.
*/
void resize(size_t new_rows, size_t new_cols)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's first make sure that things work, but I wonder if it wouldn't be a good time to significantly simplify the code: if I am not mistaken, the only code that uses DynamicArray2D is the StateMonitor and this one will always resize along the first dimension. With the right memory layout, this would mean that we always add new data in the end, and therefore we'd never have to save the stride, for example. Something to keep in mind before finalizing the PR!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Legend101Zz As I mentioned above, I think it would significantly simplify the code here to only support resize_along_first – the more general resize function is never used in practice.

m_data.shrink_to_fit();
}
size_t size() const noexcept { return m_size; }
size_t capacity() const noexcept { return m_data.size(); }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sadly we cannot use these methods I guess due to some of the compilers running on old cpp versions :(

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept getting this error in ci

Screenshot 2025-07-12 at 20 57 11

@Legend101Zz
Copy link
Contributor Author

All tests pass now :)

@Legend101Zz Legend101Zz requested a review from mstimberg July 12, 2025 19:47
@Legend101Zz
Copy link
Contributor Author

@mstimberg it finally passes 😭😭🥳

@Legend101Zz
Copy link
Contributor Author

Legend101Zz commented Sep 5, 2025

@mstimberg as discussed, I’m starting to clean up the logs and other unnecessary code. I’ll begin by removing just the logs first ( 42a7c3f ), and then gradually move on to the other pieces. This way, we can easily spot if (hopefully not please 😭) the test suite breaks at any point.

@Legend101Zz
Copy link
Contributor Author

Legend101Zz commented Sep 5, 2025

@mstimberg also we got a conflict in workflow file , I guess due to recent changes to master branch can you please check which one we should keep ... I was not sure so did not resolve the conflict myself :)

Screenshot 2025-09-05 at 23 08 42

@Legend101Zz
Copy link
Contributor Author

Legend101Zz commented Sep 9, 2025

Hmm pretty strange somehow the above two commits break a testcase for all standalone: false testsuites ( which is good considering the the tricky open mp failing test is under standalone: true and that passes) but @mstimberg any views why this happened ?

Something related to cython not building the dynamic arrays nicely ?

@Legend101Zz
Copy link
Contributor Author

Hmm pretty strange somehow the above two commits break a testcase for all standalone: false testsuites ( which is good considering the the tricky open mp failing test is under standalone: true and that passes) but @mstimberg any views why this happened ?

Something related to cython not building the dynamic arrays nicely ?

ahh sorry my bad 😅

Screenshot 2025-09-09 at 20 46 56

did not see the message down below the PR that merged the changes from main had introduced a lot of changes so maybe something from there broke it ...

@mstimberg
Copy link
Member

Hmm, yes, it seems to fail on the assert spikes_all.i.shape == (3,) line that was added to detect issue #1662. In that bug, that line lead to an AttributeError, but in your case the shape of the SpikeMonitor.i array seems to be actually wrong (0 instead of 3). It is odd, though, because the next lines check the content of this array, and if the array was empty, this should have led to a test failure as well?

@Legend101Zz
Copy link
Contributor Author

Hmm, yes, it seems to fail on the assert spikes_all.i.shape == (3,) line that was added to detect issue #1662. In that bug, that line lead to an AttributeError, but in your case the shape of the SpikeMonitor.i array seems to be actually wrong (0 instead of 3). It is odd, though, because the next lines check the content of this array, and if the array was empty, this should have led to a test failure as well?

And interestingly if I remove this line assert spikes_all.i.shape == (3,)

Then the test passes ...

Screenshot 2025-09-09 at 22 35 43

@mstimberg
Copy link
Member

I think the problem is that this function return 0 where it shouldn't, but I won't have the time to look into this in more detail today:

cdef size_t get_size(self):
"""C-level access to size"""
if self.dtype == np.float64:
return (<DynamicArray1DCpp[double]*>self.thisptr).size()
elif self.dtype == np.float32:
return (<DynamicArray1DCpp[float]*>self.thisptr).size()
elif self.dtype == np.int32:
return (<DynamicArray1DCpp[int32_t]*>self.thisptr).size()
elif self.dtype == np.int64:
return (<DynamicArray1DCpp[int64_t]*>self.thisptr).size()
elif self.dtype == np.bool_:
return (<DynamicArray1DCpp[char]*>self.thisptr).size()
return 0

@Legend101Zz
Copy link
Contributor Author

found the clue ...

   print('test',spikes_all,spikes_all.i,spikes_all.num_spikes)
> test <SpikeMonitor, recording from 'spikemonitor'> <spikemonitor.i: array([0, 4, 5], dtype=int32)> 3

so means that dynamic array's shape property and data property are out of sync ...

@Legend101Zz
Copy link
Contributor Author

So I did some debugging

I think the problem is that this function return 0 where it shouldn't, but I won't have the time to look into this in more detail today:

cdef size_t get_size(self):
"""C-level access to size"""
if self.dtype == np.float64:
return (<DynamicArray1DCpp[double]*>self.thisptr).size()
elif self.dtype == np.float32:
return (<DynamicArray1DCpp[float]*>self.thisptr).size()
elif self.dtype == np.int32:
return (<DynamicArray1DCpp[int32_t]*>self.thisptr).size()
elif self.dtype == np.int64:
return (<DynamicArray1DCpp[int64_t]*>self.thisptr).size()
elif self.dtype == np.bool_:
return (<DynamicArray1DCpp[char]*>self.thisptr).size()
return 0

this seems to work fine ... infact the whole cythondynamic arrays work nicely, as you can see here
I added this

    @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()

        print(f'data property: size={size}, data_ptr={<long>data_ptr}')
        shape[0] = size

        if size == 0:
            print('Returning empty array')
            return np.array([], dtype=self.dtype)

        # Create the numpy array
        result = cnp.PyArray_SimpleNewFromData(1, shape, self.numpy_type, data_ptr)
        print(f'Created numpy array: shape={result.shape}, dtype={result.dtype}')
        print(f'Array contents: {result}')

        return result
Screenshot 2025-09-09 at 23 37 31

But problem is somehow the end print('test',spikes_all.i.shape) call does evoke the data property , which inturn also returns correct value the end out put is somehow different test (0,)

Screenshot 2025-09-09 at 23 41 05

@Legend101Zz
Copy link
Contributor Author

will look indepth tomorrow

@mstimberg
Copy link
Member

Phew, this is actually a non-trivial issue… The reason why things broke is that the abstract DynamicArrayVariable object (which links to the underlying data but also serves as a description of the data – e.g. code generation does not have to know the values, but needs to know size and type) has a size attribute that before got updated by going from Cython through Python. In your SpikeMonitor, the resizing is now done directly on the underlying C++ object, so it does not go through Python and does not update DynamicArrayVariable.size. For StateMonitor, there is still a call that goes back to Python to resize the DynamicArrayVariable object, so it still works. Now, in runtime mode we can fix this by having the size of a DynamicArrayVariable be dynamic, and derived from the underlying data. I got this to work on my machine, but unfortunately this will also break standalone mode… I have to think a bit about how we can best solve this. I think we might have to route DynamicArrayVariable.size through the device, so that it can work differently on runtime mode and standalone mode, but this would be a major change (and break brian2cuda, etc.), so I don't want to rush this.
Maybe as an intermediate fix, you could add a after_run function to SpikeMonitor (this function already exists in the base class, and will automatically called at the end of the simulation; make sure to add a super()... call as well). In this function, you can then update the .size attribute of the dynamic arrays so that they reflect the size of the underlying data. This hopefully should make the tests pass and is maybe also the best general solution in the longer term – it should only break code that expects that the DynamicArrayVariable.size attribute for the SpikeMonitor items is up-to-date during a run, but I have a hard time thinking of a situation where something else would rely on the number of elements stored in a monitor during a run – this could only be some manual NetworkOperation, but I wouldn't worry about this for now.

@mstimberg
Copy link
Member

I just pushed the change I suggested in my comment above (adding after_run) – it seems to work on my machine at least! Note that you'll probably have to add the same thing if you change the StateMonitor template so that it directly resizes the underlying C++ DynamicArray (slightly adapted since it has 2D arrays as well).

@Legend101Zz
Copy link
Contributor Author

Legend101Zz commented Sep 10, 2025

I just pushed the change I suggested in my comment above (adding after_run) – it seems to work on my machine at least! Note that you'll probably have to add the same thing if you change the StateMonitor template so that it directly resizes the underlying C++ DynamicArray (slightly adapted since it has 2D arrays as well).

Great understood ( also tests pass now so I guess everything works fine ) ... I still need to understand and make sense of what you said 😅 , I guess a few debug points should make it easier ( will have to change the IDE back to vsc ) , but is this intermediate fix good for us for now ?

So should I continue with my part on cleaning up the PR ?

@mstimberg
Copy link
Member

but is this intermediate fix good for us for now ?

Yes, this is good enough for now (at least this PR) – but I think we will need the same kind of fix for StateMonitor when you remove the remaining code that goes back through Python (where it calls resize on the variable object) from its template.

@Legend101Zz
Copy link
Contributor Author

@mstimberg so some more context here on that unusual problem we are facing with the test_openmp_consistency test ... so as discussed I was sequentially removing parts of test that were added and for the last commit the testsuite failed : 732cbc5

Screenshot 2025-09-12 at 00 11 32

so this is as far as we can go in terms of removing things that were changed in the test ...

I'll revert back the above commit and also try to make sense of this more , like why the test fails if we remove this piece of code ( and that too as we know from earlier trials only when we run the full testsuite on all OS , if we run a partial testsuite just using the failing ubuntu OS that passes )

Ahh and also please whenever you get the chance , you can review the PR now as I guess we have hit the limit as to what changes we can remove from the test 😅

…as it already takes a copy"

This reverts commit 732cbc5.
Copy link
Member

@mstimberg mstimberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I finally found some time to look into the PR in more details… Looks very good, I don't have any major objections, my main comments are about, well, comments, and some about potential to simplify the code. There's one big thing that is still missing: the statemonitor.pyx templates still goes through Python to resize the dynamic variable. This would make sense to change as part of this PR, I think. There is a similar issue for the synapse creation templates still calling owner.resize, but this is slightly more work to change, I'd be happy with doing this in a separate PR as well.


def get_capsule_type(var):
"""Get the capsule type name for PyCapsule_GetPointer"""
if not hasattr(var, "ndim"):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check is not necessary here – this is a function that is only called internally, and should therefore always be called with a var of the correct type. If not, we'll get an AttributeError which gives us basically the same information.

Comment on lines +386 to +389
if isinstance(var, DynamicArrayVariable):
# We're dealing with a dynamic array (like synaptic connections that grow during simulation)
# For these arrays, we want BLAZING FAST access, so we'll create direct C++ pointers
# This bypasses all Python overhead and gives us pure C++ speed!
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big deal, obviously, but this comment feels a bit out of place and more like "notes to self" or a blog post.

if get_dtype_str(var.dtype) == "bool":
if isinstance(var, DynamicArrayVariable):
# For Dynamic Arrays, we get the data pointer directly from the C++ object
# This works for all types, including bools, because the C++ class handles the type correctly.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand this comment – two lines below bool is special-cased, no?

),
]
if getattr(var, "ndim", 1) > 1:
continue # multidimensional (dynamic) arrays have to be treated differently
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are in the else block here, so the array should not be dynamic, or am I mistaken?

]
if getattr(var, "ndim", 1) > 1:
continue # multidimensional (dynamic) arrays have to be treated differently
if get_dtype_str(var.dtype) == "bool":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given my above comment that we are dealing with a non-dynamic array here, do we really need to special-case bool?

inline T &operator()(int i, size_t j) { return operator()(static_cast<size_t>(i), j);}
/**
* @brief Returns a copy of row i as std::vector<T>.
* @note This is a copy; for slicing without copy, consider returning a view.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who is this comment addressing?

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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this always the case, since it is how we construct things? I think stride just returns the number of columns


extensions.append(spike_queue_ext)

dyanamic_array_ext = require_cython_extension(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo ("dyanamic")

dyanamic_array_ext = require_cython_extension(
module_path=["brian2", "memory"],
module_name="cythondynamicarray",
extra_include_dirs=["brian2/devices/cpp_standalone/brianlib"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be mistaken, but do we not specify the extra include dir three times? Once here, once in the header of the Cython file, and once in extension_manager.py?

Comment on lines +130 to +138
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}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least locally, this test seems to pass as it was before (i.e. without filtering by type of warning) – is there a reason to change it?

@Legend101Zz
Copy link
Contributor Author

I finally found some time to look into the PR in more details… Looks very good, I don't have any major objections, my main comments are about, well, comments, and some about potential to simplify the code. There's one big thing that is still missing: the statemonitor.pyx templates still goes through Python to resize the dynamic variable. This would make sense to change as part of this PR, I think. There is a similar issue for the synapse creation templates still calling owner.resize, but this is slightly more work to change, I'd be happy with doing this in a separate PR as well.

Great Thank you for the reviews , I'll start to look into them and resolve them quickly, so we can get this finally merged :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants