Revisiting Bubble Universe - RISC-V, Software FP, BCPL

With a little bit of interest in the Bubble Universe demo recently I thought I’d go back and look at mine. It’s slow … however given the limitations I don’t think it’s that bad.

Here is the output of the first frame:

The number at the top, 1396 is the milliseconds to generate, so just under 1.4 seconds per frame. It increases to about 1450mS after about 10 iterations which is explainable when angles exceed the +/- Pi value.

Initially I was just over 2 second per frame, so even without hardware FP, I did feel there was some work I could do. The first thing was optimising the reduction in angles that are outside the +/- Pi range as my sin and cos functions are only valid in that range. After I improved these, still looking for more tweaks I looked at the number of terms in my sin/cos code (Taylor/Maclaurin series) and reduced it by one step - which kept the same precision but was again, only fractionally faster. (Saved 2 FP Multiplies per evaluation) The final step was replacing my FP multiply by the one written by Luke Wren, creator of the Hazard3 RISC-V cores in the RP2350 and that gave me another small increase (at the expense of the code being Hazard3 only).

But these were small steps. I did expect better, so it’s still quite possible I’m missing something here.

The algorithm I’m using is essentially the same published by Paul Dunn on Facebook some time back - it has “n = 200”, so 200 steps of outer loop and 200 inner loop. That’s 40,000 pixels per frame and each pixel requires 2 x sine() and 2 x cosine () calls, so 160,000 trig calls. (The code for sin & cos is virtually identical FWIW) It still seems slow.

(I did notice some demos have n=150 - which is obviously much faster at the expense of pixel “density” and colour - running it with n=255 is very nice and colourful, but also even slower)

I don’t think the overhead of the actual screen plot is the issue - running it without the call to vduPlot does improve speed, but not be that much.

It could be that it’s the nature of BCPL - The compiler I’m using compiles to a bytecode and this bytecode is interpreted by a hand-coded interpreter written (by me) in RISC-V assembler. I really don’t think I’m missing any tricks here though. I’m fairly sure the bulk of the time is inside the math functions (Also all hand coded in RISC-V assembler)

As an experiment, I bludgeoned a C version of the same code into the PicoCalc (internally my little bios/framework that supports the BCPL OS while not using the SDK is mostly ABI compliant) and it is running 300mS per frame faster. So it seems the bulk of the time really is being spent inside those maths functions.

Now… I have the best part of 8MB of PSRAM just sitting there, so of-course I could “cheat” and fill it with a big lookup table, but really I can’t be bothered right now. I’m actually quite happy I did manage to save a tiny bit of time, but slightly disappointed it wasn’t as much as I’d hoped.

The laws of premature optimisation and all that!

Here is my BCPL code for interest:


GET "libhdr.h"
GET "sys.h"
GET "math.h"
GET "vdu.h"

MANIFEST
{
  n   = 200
  ti  = 1.0 #/ FLOAT n
  tau = M_PI #* 2.0
  r   = tau  #/ 235.0
  sz  = 75.0 //100.0
  sw  = 320 / 2    // Width
  sh  = 320 / 2    // Height
}


LET start () = VALOF
{
  LET x, u, v = ?,?,?  
  LET t = 0.0
  LET tStart = ?

  vduOrigin (sw, sh)

  u := 0.0
  v := 0.0
  x := 0.0

  {
    vduCls ()
    tStart := sys (Sys_cputime)

    FOR i = 0 TO n DO
    {
      LET fi = FLOAT i
      FOR j = 0 TO n DO
      {
        LET rix = r #* fi #+ x
        LET iv  =      fi #+ v

        u := sys (Sys_flt, fl_sin, iv) #+ sys (Sys_flt, fl_sin, rix)
        v := sys (Sys_flt, fl_cos, iv) #+ sys (Sys_flt, fl_cos, rix)
        x := u #+ t

        vduGColRGB (i,j,99)
        vduPoint (FIX (u #* sz), FIX (v #* sz))
      }
    }
    t := t #+ ti //0.025

    vduXY (0,0)
    sawritef (" %4d*n", sys (Sys_cputime) - tStart)
  
  } REPEATUNTIL sapollrdch () > 0

  vduOrigin (0,0)

  RESULTIS 0
}

-Gordon

3 Likes

The real trig functions are expensive, and you’re calling them 80k times per frame. That’s where all your runtime is going, and there’s no way to optimise around them - you do need to use a lookup table to be performant here.

1 Like

It’s slightly worse - 200x200 is 40,000 pixels per frame and each pixel needs 4 trig. operations, so 160,000 operations.

But for what it is, I think it’s fine. There will always be the trade-off of code space and data space - sure, I have 8MB of PSRAM (and even more flash!) so big tables are a possibility, but not today.

As an experiment, I had a little wheeze this morning and ran it for 100 frames and stored each frame into a file - so 200KB per frame - then wrote a “play” program which read the file directly into video RAM and updated to the display… However, even though I have 25Mb/sec SPI clock rate I am only getting some 0.5MB/sec reading from one big file on the SD card. I suspect there are other inefficiencies in the fat32 code I’m using, but it was a good little experiment.

-Gordon

1 Like

I substantially optimized my implementation of Bubble Universe for zeptoforth on the PicoCalc by using a lookup table rather than just plain sine and cosine calls. Yes, it may have introduced some error due to its granularity, but it was much faster. (Note that my code is RP2350-specific due to its use of hardware single-precision floating point.)

You can see this in zeptoforth/test/rp2350/picocalc_fast_bubble.fs at master · tabemann/zeptoforth · GitHub.

The source code is as follows:

\ Copyright (c) 2025 Travis Bemann
\ 
\ Permission is hereby granted, free of charge, to any person obtaining a copy
\ of this software and associated documentation files (the "Software"), to deal
\ in the Software without restriction, including without limitation the rights
\ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
\ copies of the Software, and to permit persons to whom the Software is
\ furnished to do so, subject to the following conditions:
\ 
\ The above copyright notice and this permission notice shall be included in
\ all copies or substantial portions of the Software.
\ 
\ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
\ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
\ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
\ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
\ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
\ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
\ SOFTWARE.

begin-module bubble

  oo import
  float32 import
  picocalc-term import
  pixmap8 import
  st7365p-8-common import
  
  begin-module ftrig
    
    begin-module ftrig-internal
    
      360 constant divisions
      vpi 2e0 v/ divisions u>v v/ constant increment
      
      : fill-table ( -- )
        divisions 1+ 0 ?do i u>v increment v* vsin , loop
      ;
      
      create table fill-table
      
      : vsin'' ( theta -- )
        increment v/ vround-zero v>u cells table + @
      ;
      
      : vsin' ( theta -- )
        dup [ vpi 2e0 v/ ] literal v< if vsin'' exit then
        dup vpi v< if vpi swap v- vsin'' exit then
        vpi v-
        dup [ vpi 2e0 v/ ] literal v< if vsin'' vnegate exit then
        vpi swap v- vsin'' vnegate
      ;
      
    end-module> import
    
    : vsin ( f -- f' )
      dup [ vpi 2e0 v* ] literal v/ vround-zero
      [ vpi 2e0 v* ] literal v* v-
      dup v0< if [ vpi 2e0 v* ] literal v+ then
      vsin'
    ;
    
    : vcos ( f -- f' )
      [ vpi 2e0 v/ ] literal swap v- vsin
    ;
    
  end-module> import
    
  : draw-bubble ( -- )
    page
    10 ms
    [: { display }
      display clear-pixmap
      display update-display
    ;] with-term-display
    320 150 2 { w n step }
    2e0 vpi v* n n>v v/ { r }
    0e0 0e0 0e0 { x y t }
    begin key? not while
      w n step r x y t [: { w n step r x y t display }
        n n>v { n' }
        n' 2e0 v/ { n'2/ }
        w 2 / { w2/ }
        display clear-pixmap
        n 0 ?do
          n 0 ?do
            j n>v { j' }
            j' y v+ { jy+ }
            j' r v* x v+ { jr*x+ }
            jy+ vsin jr*x+ vsin v+ { u }
            jy+ vcos jr*x+ vcos v+ { v }
            u t v+ to x
            v to y
            j' n' v/ 255e0 v* vround-zero v>u
            i n>v n' v/ 255e0 v* vround-zero v>u
            168 rgb8
            u n'2/ v* vround-zero v>n w2/ +
            y n'2/ v* vround-zero v>n w2/ +
            display draw-pixel-const
          step +loop
        step +loop
        t 0.1e0 v+ to t
        display update-display
        x y t
      ;] with-term-display
      to t to y to x
    repeat
    key drop
  ;

end-module

So, if I understand that correctly (it’s been some decades since I looked at forth - it’s really not my thing, even though once upon a time I was paid to write some), you use the existing sine (vsin) function to create the table - only 360 entries deep, then re-define to look it up from the table with a simple linear interpolation from the entry and the next?

Part of me was thinking that with all the Flash going spare to “permanently” include large tables there for various functions. I might get round to testing it someday.

I did bump up the SPI clock to the SD card to 50Mhz but it didn’t really help. I feel it really ought to be faster than it was but don’t have time right now to dig deeper. I do know the FAT code I’m using only reads 512 byte sectors at a time, so maybe the card is a bit slow with the individual sector addresses being sent to it. Also, since it has a lot of black in it, some sort of fast RLE and stuff 1000 frames in PSRAM is … a test for another day…

-Gordon

Yes. It’s 361 entries deep, but it covers just 0 to pi/2, and from there generates values for the whole 2pi circle on the fly. Note that it does not interpolate between sine values but rather just rounds down to a full increment.

Part of the reason for this arrangement is to save SRAM, because typically when I work on programs with zeptoforth on my PicoCalc I compile them to SRAM rather than to flash to save on flash wear. (Yes, I have actually borked boards through flash wear in the past.) Also, as an added plus, SRAM is faster than flash once you take into account XIP cache misses, and even though all of the core of zeptoforth aside from the kernel executes out of the XIP cache, keeping the program and its table out of the XIP cache saves XIP cache for stuff that does need it.

1 Like

My port just generated a 64k sin/cos table at startup, which takes no time at all and is probably massive overkill. But there’s no reason to leave RAM unused, so you just size your table to the largest power-of-2 that fits in the remaining space.

Also, there’s no reason to work in degrees - 360 is a completely arbitrary division and is a poor fit with the architecture, forcing you to do a bunch of extra math on each access. Use a power of 2.

2 Likes

I want to leave SRAM left over for other applications, and zeptoforth for a number of reasons has no real protections against memory exhaustion (because allot-ing memory is merely advancing a pointer, but due to the nature of pad, which simply returns an address ahead of here, used by certain Forth words it is hard to judge how much RAM is too much), which may result in the system simply locking up hard. As a result, it is a bad idea to deliberately exhaust the remaining SRAM with the idea that nothing else will ever use it.

I’m working with floating point here, so there is no real advantage to working in powers of two.

Using Zeptoforth you can almost double the performance by using both cpus. I did so with my Mandelbrot writing to a buffer using two threads, de nem then moving the buffer to the real screen.

The only thing there is you have to be careful about synchronization, so you know precisely when both tasks are done rendering, and you have to work around the fact that zeptoforth display drivers are not thread-safe (mostly because if you had to acquire a lock every time a pixel was drawn there would be a substantial overhead).

Note though that threading issues can be circumvented if you write directly to the underlying buffer and then manually dirty it when you are done.