So I just wrote a simple ECS in c++ to better understand them that I am gonna trying port to Roc in order to get a better understanding of the performance and potential APIs. It is just a simple "firework" simulation thing that I made up. The ECS is a simplified and directly implemented version of the ECS in this talk.
This is what the output looks like:
output.gif
I can control the max number of entities and how fast the fireworks spawn:
faster.gif
I also can turn off rendering to just benchmark the underlying ECS performance. Turns out that drawing a bunch of colorful translucent circles is slow. Probably need to optimize the rendering more in general. But since I just care about the underlying ECS that doesn't matter.
Current possible number of entities while maintaining 60 FPS (on my M1 mac):
No idea if those are actually decent numbers for what is being done, but still should be good enough for comparing the Roc to the C++ version for performance since they will be written in similar ways.
For anyone interested, the repo is here: https://github.com/bhansconnect/roc-ecs
But currently it is only the c++ base and a readme. Next need to make a c++ platform and add a directly ported roc version.
Got this working in Roc. It is a pretty direct port of the c++ version and not the prettiest code, but it is fully functional. Also had to work around some Roc issues to make it fully functional:
roc.gif
Would post performance information, but currently I can't compile it in an optimized build. Need to work around this issue first
cooooooool
So I got the Roc version compiling in optimized build. Definitely gonna need to debug the performance. It essentially is the same speed as the debug build. Probably some references/copying of lists that is ruining the performance. But where we stand rn:
~5,000 circles for Roc vs ~700,000 for c++
So direct port of the c++ algorithm definitely block some roc optimizations and causing pain.
Yep, definitely a referencing issue. Essentially all of the time is spent in memmove
caused by List.set
. So need to double check my references here.
So found at least part of the issue. Roc does not know how to optimize updating a list inside of a struct directly. As in this code:
nextModel = { model & graphics: List.set model.graphics (Num.toNat id) { color: fadeColor color fade, radius} }
It will copy every time with current roc. Instead you need something like:
graphics = model.graphics
tmpModel = { model & graphics: [] }
nextModel = { tmpModel & graphics: List.set graphics (Num.toNat id) { color: fadeColor color fade, radius} }
Just changing a couple of those and performance jumped up to 16,000. There are still more to change.
Ok, I have a fixed at least all of the basic copying issues. I think it is still doing some extra copying but not completely sure. This now brings the Roc numbers up to 140,000
...so 5x
slower than the equivalent c++ implementation that can run at 700,000
. Definitely a respectable start.
Nice! Can you open an issue for the missing optimization?
that's disappointing that LLVM cannot figure it out
Disappointing, but not really surprising. We increment the refcount, ask it to do something that will lead to a memmove, and then decrement the refcount. So we are doing really global effects. From llvms view you might explicitly want to move the data to a new location and free the old one.
so then what is a good fix here in terms of code gen
take the value out and zero out the field?
That may actually be the simplest solution as silly as it sounds.
Is that panic safe? If you just zero out the bits then it could be an invalid object for a while. I don't know whether that matters for Roc. Interestingly, this is a problem that I also encountered in my language (I try to do full program ownership inference) and I "solved" this by replacing updates with ctor calls where I simply plug in each unchanged and changed value. In this case, I believe there isn't any moment where the object is invalid.
No app state is recoverable on a panic. Only platform side state, so it should be safe.
Also, if the record was boxed and shared between threads or something crazy like that. Zeroing the field would require copying the record to a local version before actually zeroing it. Which should also be safe.
Would have happened when updating the record anyway. Just happening earlier now.
I'm not talking about recovery, just safe destruction. How do you call the dtor of something that has invalid bits? Or do you simply forget everything in case of panics and let the platform figure out what to clean up?
It's the platform's problem
Many platforms will likely just crash with a message. Some will just delete an arena and move on. Others may just log it and leak memory.
What about other resources? Sockets, files?
Roc doesn't have control over sockets or files. Only the host does.
So Roc literally doesn't know how to close a socket.
It would have to call a host function to get it to close.
From rocs point of view, after a panic, the platform could pass the same socket back into it again, so if we closed it, that would be problematic.
I see. I wasn't aware of how much responsibility the platforms have.
Yeah, they deal with all forms of IO. Roc is really just a subfunction to a platform that does some pure data transformations. The only guaranteed* functions that a platform will provide are panic and memory allocation/resize/deallocation functions.
Not technically guaranteed: On an embedded system, I might have a empty roc_alloc that just crashes. So the roc programmer would not be allowed to use anything that allocates.
out of curiosity, did you also try this way?
graphics = List.set model.graphics (Num.toNat id) { color: fadeColor color fade, radius}
nextModel = { model & graphics }
Just tested. That copies.
Last updated: Jul 06 2025 at 12:14 UTC