Skip to content

Lab 30: Talking to the FPU

Time: ~50 minutes | Prerequisites: Lab 29 | Hardware: Pico 2

The registers that make FFTs fast

Echo waving welcome Your chip has a whole second set of registers holding floats, and instructions that multiply two of them in a single cycle. MicroPython takes about a thousand. Let's go get that back.

What You'll Build

Float assembly: loading, arithmetic, and a loop that scales an array 122x faster than Python.

Learning Objectives

  • Use the s0-s31 float registers
  • Apply vldr, vstr, vadd, vsub, vmul and vneg
  • Pass float arrays by address with uctypes.addressof
  • Explain why r registers hold addresses and s registers hold data
  • Hoist loop-invariant loads out of the inner loop

Concepts Introduced

ID Concept
505 Floating Point Register
506 Register Bank s0 to s31
507 Load Store Architecture
508 VLDR Instruction
509 VSTR Instruction
510 VADD Instruction
511 VSUB Instruction
512 VMUL Instruction
513 Memory Address
514 Address Of Buffer
515 Typed Array
516 Pointer Arithmetic
517 Byte Offset
518 No Allocation In Timed Region

Procedure

Open 30-fpu-assembly.py and work through it section by section:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# Lab 30: Talking to the FPU
#
# Lab 29 used the core registers r0-r7, which hold integers. The Cortex-M33
# has a SECOND set of registers -- s0 to s31 -- that hold single-precision
# floats, and a matching set of instructions that operate on them.
#
# These are the instructions that make a fast FFT possible. The hardware
# multiplies two floats in one cycle. MicroPython takes about a thousand.

import machine
import micropython
import math
from array import array
from uctypes import addressof

machine.mem32[0xE000EDFC] = machine.mem32[0xE000EDFC] | (1 << 24)
machine.mem32[0xE0001000] = machine.mem32[0xE0001000] | 1


def rd():
    return machine.mem32[0xE0001004]


# =========================================================================
# PART 1 -- load, add, store
# =========================================================================
print("=== PART 1: your first float instructions ===")
print()
print("Float registers are s0-s31. They are SEPARATE from r0-r7 -- you")
print("cannot add an s register to an r register.")
print()
print("  vldr(s0, [r0, 0])   load a float from memory into s0")
print("  vstr(s0, [r0, 0])   store s0 back to memory")
print("  vadd(s2, s0, s1)    s2 = s0 + s1")
print()
print("Note the addresses still come from CORE registers. The r registers")
print("say WHERE; the s registers hold WHAT.")
print()


@micropython.asm_thumb
def float_add(r0):
    # r0 = address of array('f', [a, b, result])
    vldr(s0, [r0, 0])           # s0 = buf[0]
    vldr(s1, [r0, 4])           # s1 = buf[1]   (4 bytes per float)
    vadd(s2, s0, s1)
    vstr(s2, [r0, 8])           # buf[2] = s0 + s1


buf = array("f", [3.5, 1.25, 0.0])
float_add(addressof(buf))
print("3.5 + 1.25 =", buf[2])


# =========================================================================
# PART 2 -- the four arithmetic instructions
# =========================================================================
print()
print("=== PART 2: arithmetic ===")


@micropython.asm_thumb
def float_ops(r0):
    # buf = [a, b, add, sub, mul, neg]
    vldr(s0, [r0, 0])
    vldr(s1, [r0, 4])
    vadd(s2, s0, s1)
    vstr(s2, [r0, 8])
    vsub(s2, s0, s1)
    vstr(s2, [r0, 12])
    vmul(s2, s0, s1)
    vstr(s2, [r0, 16])
    vneg(s2, s0)
    vstr(s2, [r0, 20])


buf = array("f", [6.0, 2.5, 0, 0, 0, 0])
float_ops(addressof(buf))
print("a = %.2f, b = %.2f" % (buf[0], buf[1]))
print("  a + b  = %8.3f" % buf[2])
print("  a - b  = %8.3f" % buf[3])
print("  a * b  = %8.3f" % buf[4])
print("  -a     = %8.3f" % buf[5])


# =========================================================================
# PART 3 -- a loop over an array of floats
# =========================================================================
print()
print("=== PART 3: scaling an array ===")
print()


@micropython.asm_thumb
def scale_array(r0, r1, r2):
    # r0 = address of float array
    # r1 = number of elements
    # r2 = address of a float holding the scale factor
    vldr(s1, [r2, 0])           # the multiplier, loaded once
    mov(r3, 0)                  # index

    label(SCALE_LOOP)
    lsl(r4, r3, 2)              # index * 4 bytes
    add(r4, r0, r4)
    vldr(s0, [r4, 0])
    vmul(s0, s0, s1)
    vstr(s0, [r4, 0])
    add(r3, 1)
    cmp(r3, r1)
    blt(SCALE_LOOP)


data = array("f", [1.0, 2.0, 3.0, 4.0, 5.0])
factor = array("f", [2.5])
print("before:", [round(v, 2) for v in data])
scale_array(addressof(data), len(data), addressof(factor))
print("after :", [round(v, 2) for v in data])
print()
print("The multiplier is loaded ONCE, outside the loop. Hoisting work out")
print("of the inner loop is the same idea as Lab 18's twiddle table.")


# =========================================================================
# PART 4 -- how much faster?
# =========================================================================
print()
print("=== PART 4: the payoff ===")

COUNT = 2000
pdata = [float(i) for i in range(COUNT)]
adata = array("f", pdata)
mult = array("f", [1.0001])


def scale_python(a, n, f):
    for i in range(n):
        a[i] = a[i] * f


def timeit(fn, *args):
    fn(*args)
    best = None
    for _ in range(5):
        s = rd()
        fn(*args)
        c = (rd() - s) & 0xFFFFFFFF
        if best is None or c < best:
            best = c
    return best


py_c = timeit(scale_python, pdata, COUNT, 1.0001)
asm_c = timeit(scale_array, addressof(adata), COUNT, addressof(mult))

freq = machine.freq()
print()
print("scaling %d floats:" % COUNT)
print("  Python   : %8d cycles  (%6.1f per element)" % (py_c, py_c / COUNT))
print("  assembly : %8d cycles  (%6.1f per element)" % (asm_c, asm_c / COUNT))
print("  speedup  : %.0fx" % (py_c / asm_c))
print()
print("About %.0f cycles per element in assembly -- a load, a multiply, a"
      % (asm_c / COUNT))
print("store, and the loop overhead. Compare Lab 25: MicroPython needed")
print("~1097 cycles for the multiply ALONE.")


# =========================================================================
# PART 5 -- the rules that keep you out of trouble
# =========================================================================
print()
print("=== PART 5: rules for float assembly ===")
print()
print("1. r registers hold ADDRESSES and counters. s registers hold DATA.")
print("   You cannot mix them without vmov.")
print()
print("2. Every float is 4 bytes, so element i lives at offset i*4.")
print("   'lsl(rd, ri, 2)' is how you multiply an index by 4.")
print()
print("3. array('f') gives you real float32 storage. addressof() gives you")
print("   its address. A Python list will NOT work -- it is a list of")
print("   pointers to objects, not a block of floats.")
print()
print("4. Load loop-invariant values ONCE, before the loop.")
print()
print("5. Allocate nothing inside a timed region. Assembly cannot allocate")
print("   anyway, which is part of why it is predictable.")
print()
print("That is everything you need for the butterfly. Next lab.")

Each part builds on the last, and the comments in the file explain the reasoning as you go. Run it, read it, then change something and run it again.

Predict before you measure

Echo offering a tip Wherever this lab reports a speedup, write your guess down before you run it. Every quantitative prediction made while building this course turned out to be optimistic — being wrong on paper is how you find out what the machine really does.

Troubleshooting

Symptom Likely cause Fix
unsupported Thumb instruction The assembler lacks that mnemonic Check Lab 28's probe; see Lab 33 for the workaround
Assembly returns nonsense Wrong argument order Arguments arrive in r0, r1, r2, r3
Results differ between runs No warm-up, or heap state Discard a warm-up; build objects before measuring (Labs 26, 32)
Variant looks slower than baseline Measurement artifact Re-run with everything allocated up front
MemoryError Too many variants alive gc.collect() between sections

Check Your Understanding

  1. What does this lab measure, and what does it deliberately exclude?
  2. Which result surprised you most against your prediction, and why?
  3. What would you change to make the effect larger?
  4. Where would this technique NOT be worth the complexity?

Onward

Echo celebrating 122x on float arrays. That's the arithmetic engine of the FFT, and you can now drive it directly.


Next: Lab 31 | Previous: Lab 29