Mandelbrot set using assembler

One of the Graphics examples is a uLisp program to plot the Mandelbrot set.

This page describes how to speed up the program by writing the inner loop in RISC-V machine code using the RISC‑V assembler.

Mandelbrot set in uLisp

Here's the Mandelbrot set program running on a MAiX One Dock board:


Here's the original program in Lisp, slightly modified to make the inner loop a separate function, iterate:

(defun mandelbrot (x0 y0 scale)
  (set-rotation 2)
  (dotimes (y 240)
    (let ((b (+ (/ (- y 120) 120 scale) y0)))
      (dotimes (x 320)
        (let* ((a (+ (/ (- x 160) 120 scale) x0))
               (c (iterate a b)))
          (draw-pixel x y (if (plusp c) (hsv (* 359 (/ c 80)) 1 1) 0)))))))

Here's the iterate function:

(defun iterate (a0 b0)
  (let ((c 80) (a a0) (b b0) a2)
     (setq a2 (+ (- (* a a) (* b b)) a0))
     (setq b (+ (* 2 a b) b0))
     (setq a a2)
     (decf c)
     (when (or (> (+ (* a a) (* b b)) 4) (zerop c)) (return c)))))

These functions also call rgb and hsv to choose the colours for the contours:

(defun rgb (r g b)
  (logior (ash (logand r #xf8) 8) (ash (logand g #xfc) 3) (ash b -3)))
(defun hsv (h s v)
  (let* ((chroma (* v s))
         (x (* chroma (- 1 (abs (- (mod (/ h 60) 2) 1)))))
         (m (- v chroma))
         (i (truncate h 60))
         (params (list chroma x 0 0 x chroma))
         (r (+ m (nth i params)))
         (g (+ m (nth (mod (+ i 4) 6) params)))
         (b (+ m (nth (mod (+ i 2) 6) params))))
    (rgb (round (* r 255)) (round (* g 255)) (round (* b 255)))))

To plot the whole Mandelbrot set call:

(mandelbrot -0.5 0 1)

The section I displayed in the above photograph is obtained with:

(mandelbrot -0.53 -0.61 11)

For convenience, here's a function go that plots this and returns the time taken:

(defun go () (for-millis () (mandelbrot -0.53 -0.61 11)))

On a MAiX board running at 400 MHz the uLisp version takes 230 seconds.

Converting the iterate function to assembler

To speed up the plotting I rewrote the iterate function in RISC-V assembler, using the assembler written in uLisp.

First load the assembler code from here: RISC-V assembler in uLisp.

Fortunately the K210 processor used on the MAiX boards includes floating-point instructions, so we can use these to perform the arithmetic. I didn't include support for these in the original assembler so they need to be added from here: RISC-V assembler floating-point extensions.

Here's the assembler version of iterate. It's pretty much a direct conversion of the uLisp version above:

(defcode iterate (a b)
  ($flw 'fa0 8 '(a0)) ;a0
  ($flw 'fa1 8 '(a1)) ;b0
  ($fmv.s 'ft0 'fa0) ;a
  ($fmv.s 'ft1 'fa1) ;b
  ($li 'a4 2)
  ($fcvt.s.w 'ft5 'a4) ; ft5=2
  ($li 'a0 80)
  ($fmul.s 'ft2 'ft0 'ft0)
  ($fmul.s 'ft3 'ft1 'ft1)
  ($fsub.s 'ft4 'ft2 'ft3)
  ($fadd.s 'ft4 'ft4 'fa0) ;a2
  ($fmul.s 'ft6 'ft0 'ft1)
  ($fmul.s 'ft7 'ft6 'ft5)
  ($fadd.s 'ft1 'ft7 'fa1) ;b
  ($fmv.s 'ft0 'ft4) ;a
  ($addi 'a0 'a0 -1)
  ($beqz 'a0 ret)
  ($fmul.s 'ft6 'ft0 'ft0)
  ($fmul.s 'ft7 'ft1 'ft1)
  ($fadd.s 'ft7 'ft6 'ft7)
  ($fcvt.w.s 'a3 'ft7)
  ($addi 'a3 'a3 -4)
  ($blez 'a3 again)

If you assemble this code it will replace the Lisp version, and you can then run (go) again to see the speed improvement.

The version with a machine-code version of iterate takes 43 seconds, over five times faster.