Skip to content

picogame: RP2040/RP2350 flags optimization + enable picogame on selected boards - #11358

Open
lynt-smitka wants to merge 2 commits into
adafruit:mainfrom
MakerClassCZ:pg-pico
Open

lynt-smitka wants to merge 2 commits into
adafruit:mainfrom
MakerClassCZ:pg-pico

Conversation

@lynt-smitka

Copy link
Copy Markdown

Enables picogame on raspberry_pi_pico and raspberry_pi_pico_w.

Neither fits at the port default -O3: pico has 48 KB free of its 1020 KB partition and pico_w 2 KB of its 1536 KB, while the engine needs 50 KB. Both build at -O2, which leaves around 140 KB free and measures within 1% of -O3 across the render kernels on this M0+.

pico_w sits at 99.9% today, so it builds all 17 translations on every pull request; with the flags it skips them like the other boards.

Comment thread ports/raspberrypi/boards/raspberry_pi_pico/mpconfigboard.mk Outdated
@dhalbert dhalbert changed the title picogame: enable on raspberry_pi_pico and raspberry_pi_pico_w picogame: enable on RP2040 Sep 13, 2026

@dhalbert dhalbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is good; I would just like some comment tweaks.

Comment thread ports/raspberrypi/mpconfigport.mk Outdated
Comment thread ports/raspberrypi/mpconfigport.mk Outdated
@lynt-smitka

Copy link
Copy Markdown
Author

I'll rework it a bit, including adding better comments. I ran the tests for each flag separately and I have to interpret the results (the vectorization need few more tests). I hope it will be done in few hours.

@dhalbert

Copy link
Copy Markdown
Collaborator

I meant to have this enabled for all of RP2040 -- sorry I was talking about optimization flags only.

@lynt-smitka

Copy link
Copy Markdown
Author

The new recommendation after heavy benchmarking individual flags:

OPTIMIZATION_FLAGS ?= -O2 -funswitch-loops -fvect-cost-model=dynamic

AI summarized benchmark results:

1. Each flag on its own

added to -O2 flash blit_flip mode7 fill_rect fill_circle vspans 4 KB copy AES 4 KB interpreter
nothing (-O2) baseline 113 5915 1163 322 3703 3857 17248 154754
-funswitch-loops +16344 87 5396 1162 322 3704 3851 17236 154754
-fpredictive-commoning +40 111 5914 1176 322 3709 3857 17266 154754
-fgcse-after-reload -168 111 5941 1165 322 3702 3857 17248 154754
-ftree-partial-pre +900 111 5913 1163 322 3703 3857 17218 154846
-fsplit-paths +2376 118 5912 1177 321 3708 3881 17254 154754
-fvect-cost-model=dynamic +4328 111 5913 731 245 3975 2441 14599 154754

Two flags do the work, and they do not overlap. -funswitch-loops owns the sprite blit and mode7,
and it is the expensive one at 16 KB. -fvect-cost-model=dynamic owns the fills, the memory copies
and AES, and it costs 4 KB. The other four are between -168 and +2,376 bytes and win no column.

vspans is left unmarked here because every build except the cost model sits between 3,702 and
3,709, a 0.2 % spread. It is the one call the cost model makes slower, which section 4 covers.

The interpreter does not move under any flag. fib(20) and a 20,000-iteration integer loop stay
within 0.1 % everywhere, so anything spending its time in the bytecode dispatch loop sees no
difference at all.

2. Combinations

set flash blit_flip mode7 fill_rect fill_circle vspans 4 KB copy AES 4 KB
-O2 baseline 113 5915 1163 322 3703 3857 17248
cost model alone +4328 111 5913 731 245 3975 2441 14599
predcom + cost model +4368 111 5913 730 245 3983 2441 14459
five passes +18860 87 5396 1162 332 3705 3851 17181
unswitch + cost model +20664 87 5413 732 245 3945 2441 14508
unswitch + predcom + cost model +20704 87 5417 733 246 3945 2447 14514
five passes + cost model +23252 87 5395 729 249 3944 2453 14538
-O3 +151020 87 5406 739 243 3807 2435 13543

Two flags reach -O3 on every kernel for 20,664 bytes instead of 151,020, which is 130,356 bytes
saved on every RP2040 board. The only column -O3 still holds alone is AES, by 7 %. Adding the
other three passes on top costs a further 2,588 bytes and wins nothing.

Dropping -funswitch-loops as well would save another 16,336 bytes, but the sprite blit is then
24 % slower than the -O3 those boards ship today. That is a real regression, not a wash, so the
set keeps it.

3. Outside picogame

-funswitch-loops rewrote 94 functions, so the drawing kernels are not the only place it shows.
These are core CircuitPython calls, same board, and the two -O2 columns are separate flashes so
the repeatability is visible in the table itself.

call -O2 -O2 again unswitch the set -O3
bitmaptools.alphablend, 96x72 13287 13293 12982 12982 12640
ulab sum, 1200 int16 4080 4080 3983 3987 3977
ulab arithmetic on 1200 floats 7904 7916 7763 7769 7792
ulab sum + mean + std 15457 15455 15219 15225 15220
bitmaptools.rotozoom 39154 39093 38830 38775 38800
bitmaptools.fill_region 4646 4646 4653 4638 4650

The gains are small but they are not noise and they are not picogame: 2.3 % on alpha blending,
2.4 % on an integer ulab reduction, 1.8 % on float arithmetic, 1.5 % on the statistics. The set
matches -O3 on all of them except alpha blending, where -O3 keeps a further 2.6 %.
fill_region is a memset wrapper and moves for neither flag.

4. What the two flags do

-funswitch-loops changed 94 functions for +16,236 bytes, the largest being:

common_hal_bitmaptools_alphablend       1762 -> 3394  (+1632)
picogame_blit_bitmap                    1928 -> 3506  (+1578)
numerical_sum_mean_std_ndarray          2188 -> 3064   (+876)
common_hal_audioi2sin_i2sin_fill_buffer 1148 -> 2008   (+860)

It did two different things to the two calls that got faster, and only one of them is unswitching in
the textbook sense.

In mode7 it is textbook. The format == PAL8 test is hoisted out of the whole row nest and the
nest is kept in two copies, one for RGB565 and one for PAL8. The RGB565 copy then carries no format
test and no row-interpolation call at all, because neither can be reached there. That removes three
instructions from the per-pixel loop, 31 cycles per pixel instead of 34. The predicted ratio is
0.912 and the measured one is 5396/5915 = 0.9123.

In the blit it is not. The specialization on the flip and transpose flags already exists at
-O2, in both builds the flags are tested once per row in the loop preheader, and the number of
inner-loop copies is the same. What changed is the unroll factor of the flipped opaque loop, two
pixels per iteration at -O2 and four with the flag, with a remainder prologue in front. That takes
one taken branch per two pixels down to one per four. The predicted saving over the 1,024 pixels of
the benchmark is 27 microseconds and the measured one is 28. That the unswitching is what let the
unroller reach four is the natural reading, but the binaries only show that the unroll factor and
the register allocation changed while the specialization structure did not.

This also explains a column that never moves. The unflipped blit stays at 59 to 62 microseconds in
every build because that case takes a memcpy fast path, which neither flag touches.

-fvect-cost-model=dynamic is narrower. It rewrites exactly three functions: memset (88 to 248
bytes), memcpy (94 to 166) and picogame's own row filler fill565 (96 to 148), each into an
unrolled version with an alignment prologue and epilogue. Every drawing function that calls them is
byte-identical to -O2. That is why it wins the fills, the copies and AES and nothing else.

5. What the cost model costs

It is a trade, not a free win. The one call that gets slower is vspans, the span renderer that
fills the raycaster's wall columns. -O2 sets -fvect-cost-model=very-cheap, and for that loop
very-cheap is the better answer.

build vspans
-O2 (very-cheap) 3703
-O2 + five passes + dynamic 3944 (+7 %)
-O3 (dynamic) 3807 (+3 %)
-O3 with very-cheap forced back on 2548 (-31 %)
five passes + the -O3 inline parameters, no vectorizer change 2545 (-31 %)

So vspans is fastest under very-cheap, and what speeds it up is -O3's inline parameters, not any
of the passes. Those cost 92,744 bytes on their own, which is why the set does not take them.

The reason is the unrolled fill565 from section 4. It pays on long runs, and fill_rect fills 240
pixels per row, so it gains 37 %. vspans issues runs four pixels wide, so every call pays the
alignment prologue and never reaches the fast body.

The same trade shows on big integers: -O3 takes the 60-factorial loop from 971 to 1516
microseconds, a 56 % regression that neither the passes nor the cost model cause on their own.

6. The eight -O3 passes left out

Added on top of the five passes. The last column is what the pass did to the machine code of the
drawing functions.

added on top of the five flash fill_rect fill_circle hot functions
-fipa-cp-clone +11280 1168 325 byte-identical
-fpeel-loops +24052 1168 325 byte-identical
-floop-unroll-and-jam +0 1177 332 byte-identical
-fsplit-loops +3296 1162 322 byte-identical
-ftree-loop-distribution -40 1176 332 byte-identical
-funroll-completely-grow-size does not link
-fversion-loops-for-strides +5904 1162 322 rewrites blit and mode7
-floop-interchange +0 1163 332 byte-identical

Six of the seven that link leave the drawing code untouched. -fversion-loops-for-strides is the
exception: it rewrites 985 instructions in the blit and 214 in mode7 and is still no faster.
Together with the inline parameters these are the 130 KB between the set and -O3.

Comment thread ports/raspberrypi/mpconfigport.mk Outdated
@dhalbert

Copy link
Copy Markdown
Collaborator

Now I'm the one not sure of the state of this. :)

@lynt-smitka

Copy link
Copy Markdown
Author

I'm still finishing up a few tests. I've decided on the flags for the RP2040, but I'm still fine-tuning a similar set for the RP2350. I just need a little more time. This will also affect the choice of boards for running picogame.

The port default was -O3. Measured flag by flag on a raspberry_pi_pico
(RP2040) and a Fruit Jam (RP2350), two of what -O3 adds over -O2 move a
benchmark; the rest costs flash and wins nothing.

  -funswitch-loops           +16 KB. Hoists a loop-invariant test out of
                             the loop and keeps one body per value. Gives
                             the -O3 numbers on a flipped sprite blit
                             (23 %) and a textured floor (9 %), and 1-2 %
                             on bitmaptools.alphablend and ulab reductions.

  -fvect-cost-model=dynamic  +4 KB. -O2 uses very-cheap, which rejects
                             nearly every loop; dynamic lets the vectorizer
                             unroll memset, memcpy and a 16-bit row fill.
                             Gives the -O3 numbers on rectangle fills and
                             bytearray copies (37 %) and on aesio (16 %).
                             A run shorter than its alignment prologue
                             loses: a four-pixel span fill is 7 % slower.

The other four -O3 loop passes tried (-fpredictive-commoning,
-fgcse-after-reload, -ftree-partial-pre, -fsplit-paths) emit the same
code as -O2 for the loops that matter and cost -168 to +2376 bytes. The
rest of -O3 is 130 KB, 92 KB of it the inline parameters, and no column
moves under it. The bytecode interpreter does not move under any flag:
py/py.mk builds gc.o and vm.o at -O3 whatever this is set to.

RP2040, raspberry_pi_pico, microseconds per call:

                 -O2      pair     -O3
  blit_flip      113      87       87
  mode7          5915     5413     5406
  fill_rect      1163     732      739
  fill_circle    322      245      243
  4 KB copy      3857     2441     2435
  AES 4 KB       17248    14508    13543
  vspans         3703     3945     3807
  text           +0       +20664   +151020

RP2350, Fruit Jam with PSRAM off so the buffers sit in SRAM, three rounds:

                 pair     -O3
  fill_circle    136      135
  vspans         1666     1652
  triangles      1221     1212
  fill_rect      490      490
  blit565        44       44
  blit_flip      43       45
  mode7          2623     2639
  text           849004   971752

With PSRAM on, a 4 KB copy and fill, an integer loop, fib(20), bignum,
aesio, alphablend, rotozoom, fill_region, three ulab calls and an mbedtls
sha256 are all within 0.25 % of -O3.

So the pair is 130 KB smaller on RP2040 and 123 KB smaller on RP2350 at
the same speed, and one setting covers the port. pajenicko_picopad and
pimoroni_picosystem drop their board copies of the earlier five-pass set,
which would otherwise pin it with =. Boards that set -O2 or -Os keep
their setting.
Neither fits at -O3: pico had 48 KB free of its 1020 KB partition and
pico_w 2 KB of its 1536 KB, while the engine needs 50 KB. With the port
flags pico has 153 KB free and pico_w 148 KB (en_US build).
@lynt-smitka lynt-smitka changed the title picogame: enable on RP2040 picogame: RP2040/RP2350 flags optimization + enable picogame on selected boards Sep 20, 2026
@lynt-smitka

Copy link
Copy Markdown
Author

@dhalbert @tannewt After many measurements, I found the ideal combination of optimization flags that work great for both the RP2040 and RP2350.

OPTIMIZATION_FLAGS ?= -O2 -funswitch-loops -fvect-cost-model=dynamic

These flags result in massive savings of over 120 kB of flash on each board, with a performance loss of only about 1%. The only noticable penalty I measured was for AES on the RP2040, where the performance loss is 7%, but I still think that’s a great deal.

I’d still like to enable picogame on a wider range of boards. I can do tin this PR or in the next one. From our discussions here and in the PR #11357, it seemed to me that you were okay with enabling it on all RP2350 boards (this PR will give the boards much more flash than picogame consumes) and on a selection of RP2040 boards - is that correct? Can I proceed that way?

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.

3 participants